Симфония кода
28 марта 2022 г.
🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧
В процессе написания, не бейте!
🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧
За мои несколько лет на фронте, у меня сложилось определенное видение того, какой код является наиболее читабельным и поддерживаемым.
Речь пойдет не о каких-то архитектурных вещах - обычно они слишком специфичны и нельзя выделить что-то общее, подходящее для всех кейсов. Скорее, я постараюсь описать какие-то микромоменты, которые касаются использования statement’ов, expression’ов, различных операторов, блоков кода и так далее - подобные вещи есть в любом коде.
Мне часто приходится объяснять подобные вещи, и поэтому для меня эта статья также является своеобразным конспектом, ссылки на который можно использовать, чтобы не рассказывать каждый раз одно и то же.
Я буду стараться дополнять статью, если мне в голову будут приходить еще какие-то идеи.
Навигация
Основные принципы
Для начала, я постараюсь описать основные принципы, которые я нашел. Многие из них так или иначе вытекают друг из друга, но я решил их выделить отдельно, так они несут определенную важную идею, которая не сразу заметна в их “источнике”.
1. Код как естественный язык
Мне кажется, что самое главное к чему стоит стремиться - возможность читать код практически как текст на естественном языке. Добиться идеального результата невозможно, ведь попытки создать естественный язык программирования не просто так завершились провалом. Но определенной степени схожести с естественным языком можно достигнуть.
Следовать этому принципу можно в любом коде, и не важно, декларативный он или императивный. И наоборот, в любом коде можно запутать читателя, даже в декларативном, хотя, казалось бы, он по определению должен быть максимально понятен (привет, FP).
В императивном стиле программирования мы обычно используем различные statement’ы, которые, в отличие от большинства expression’ов, содержат в себе понятные нам слова на естественном языке - “if”, “else”, “for”, “while”, “try”, “delete” и так далее. В итоге, с помощью этих слов выстраиваются конструкции, более понятные нашему мозгу. Например, в случае с “if”, мы можем мысленно построить предложение вроде “если <условие>, сделай <действие>“.
Декларативный стиль построен на expression’ах, и главной единицей там является функция. Именно на их названия и имена параметров нужно обращать внимание в первую очередь. При этом нужно соблюдать определенный баланс, иначе код станет слишком неконсистентным.
К примеру, вот такой код:
updateCredentials({
for: currentUser,
using: updatedData
})
Он читается очень естественно, но некоторые “вкусные” слова вроде “for”, “with” и “in” зарезервированы, и, хоть они и могут быть использованы как поля, внутри функции их придется переименовывать. Также, такие названия дают нам огромное количество вариантов составления “предложения” - код становится менее консистентным, так как все люди видят одно и то же по-разному и, соответственно, называют параметры тоже по-разному. Подобная проблема уже была в effector’е в виде большого количества комбинаций различных операторов для решения одной задачи. В итоге, параметры оператора sample
были расширены, чтобы сделать его универсальным, а использование более узконаправленных операторов было признано нежелательным.
Не берусь что-то заявлять про FP, так как у меня мало с ним опыта. Тем не менее, мне кажется, что по большей части, FP’шный код не читается как естественный язык, но он может содержать короткие и понятные инструкции в большой концентрации, что позволяет проще фокусироваться на задаче, и меньше держать в голове контекст происходящего. Это, конечно, если подходить к FP без фанатизма и использовать только основные принципы и самые понятные функции и типы. Если вы посмотрите на список модулей в fp-ts, без экспертизы вы вряд ли многое оттуда поймете - слова являются узконаправленными и редко используются в текстах на естественном языке, к которым мы стремимся. При максимальном использовании этих модулей, порог входа в код значительно увеличивается.
Есть еще один интересный момент, давайте сразу посмотрим на код:
const uniqueIds = Array.from( // 3
new Set( // 2
items.map(item => item.id) // 1
)
)
Вложенные expression’ы выполняются в обратном порядке - изнутри наружу. Ведь мы не можем выполнить Array.from
или new Set
до того, как узнаем значение передаваемого в них аргумента. Комментарии указывают порядок выполнения кода. Не похоже на естественный язык, не так ли?
Декомпозируем этот код и сделаем его линейным:
const ids = items.map(item => item.id) // 1
const setOfIds = new Set(ids) // 2
const uniqueIds = Array.from(setOfIds) // 3
Теперь все выполняется в естественном порядке и код проще читать. К тому же, появились дополнительные имена, которые также улучшают чтение - Array.from(setOfIds)
читается как “Массив из сета айдишников”. К тому же код даже уменьшился в высоту, так как убрались лишние строки со скобками.
Пример очень простой - комбинация Array.from
с new Set
используется довольно часто, поэтому скорее всего у вас не возникло бы много проблем в понимании изначального варианта. Но этот принцип применим в абсолютно любом похожем коде, поэтому он сильно может помочь в создании более читабельного кода.
Что конкретно можно делать?
- Делать явные условия. Например, не
!users.length
, аusers.length === 0
. Кстати, в реакте это очень поможет избежать пустых строк и нулей в рендере. - Не использовать слишком неочевидные операторы (
&
,|
,^
,~
, и так далее) - Не преобразовывать типы данных с помощью
!!
,+
и прочих операторов, которые для этого не предназначены - Стараться меньше использовать отрицание
!
. При чтении такого кода приходится мысленно выводить положительное значение, так как с ним мозгу удобнее работать. - Стараться использовать в коде как можно меньше слов. Если вы проведете какое-то значение через 5 функций, и в каждую из них оно будет передаваться под разным именем - скорее всего понять происходящее будет тяжело, ведь потеряется именная связь. По факту вы будете работать с 3 значениями, а выглядеть это будет на все 15. Чтобы разобраться в таком, нужно будет потратить время на погружение в код.
2. Больше строк, меньше ширины
Длинные строки чаще всего содержат в себе много логики, которую можно декомпозировать. Дополнительные переменные и функции, которые в результате появляются, позволяют нам скрыть какие-то вычисления под более понятным именем, с которым не нужно держать в голове весь контекст происходящего.
Таким образом:
- Код начинает быть более читабельным, так как дополнительное имя дает возможность лучше составить “предложение” на естественном языке.
- Мы пишем более надежный код, ведь проще проверить маленький кусочек логики на соответствие имени переменной, чем выяснять работоспособность большой строки, собравшей в себе всю логику.
- Новый код становится проще писать, поскольку появляются реюзабельные части, которые можно как угодно комбинировать.
На самом деле, “больше строк, меньше ширины” - скорее не сам принцип, а последствие хорошей декомпозиции. Но мне кажется, что зная, как должен выглядеть примерный результат, нам проще это воспринимать, чем абстрактное “декомпозируй”.
Что конкретно можно делать?
- Максимально декомпозировать: разбивать сложные выражения на несколько переменных, находить общую логику и выносить ее наружу. Но нужно чувствовать меру, об этом есть хорошая статья от Дэна Абрамова.
Лайфхаки с кодом
Здесь начинаются менее абстрактные вещи, которые можно продемонстрировать на конкретных примерах с кодом. Возможно, я вспомнил не все, так что скорее всего статья будет дополняться.
Early Return / Guard Clause
Есть довольно известная проблема, называемая “if/else hell”. Очень часто в различных чатах я вижу код с ней, выглядит это примерно так:
function calculatePaymentPrice({ user, prices, product }) {
let paymentPrice: number
if (user.isAuthenticated) {
if (prices.personal) {
paymentPrice = prices.personal
} else {
if (user.hasSubscription && product.inSubscription) {
paymentPrice = prices.bySubscription
} else {
paymentPrice = prices.default
}
}
} else {
paymentPrice = prices.default
}
return paymentPrice
}
Такой код грубо нарушает принцип #1:
- Его невозможно читать как связный текст
- Нужно держать в голове контекст всех вышестоящих блоков, иначе есть риск забыть где мы вообще находимся и как туда попали
- Чтобы разобраться в
else
, надо найти соответствующий емуif
и в голове развернуть условие, особенно ситуация усугубляется если условия сложные
Помимо этого код вытягивается в ширину и грозится вылезти за границы экрана)
Решение довольное простое и лаконичное - Early Return. Переводится как “ранний возврат”, и выглядит соответствующе:
function calculatePaymentPrice({ user, prices, product }) {
if (!user.isAuthorized) return prices.default
if (prices.personal) return prices.personal
const useSubscriptionPrice = user.hasSubscription && product.inSubscription
if (useSubscriptionPrice) return prices.bySubscription
return prices.default
}
Я считаю, что этот подход нужно использовать всегда, когда есть возможность. Он разворачивает код в линейную последовательность действий, которая отлично читается. Единственная проблема в том, что нам часто приходится использовать отрицания в условиях, и иногда это может слегка затруднить чтение. Поэтому нужно стараться выносить сложные условия в переменные.
На академическом языке Early Return обычно называется Guard Clause, так как по сути работает как фильтр нежелательных данных.
Небольшое примечание
Также, этот подход часто помогает обрабатывать “эдж-кейсы” в логике. Например, пустой массив или null
вместо данных. А также всякие статусы вроде loading
или наличие какой-то ошибки:
const UserList = () => {
const users = useStore($users)
const status = useStore($usersStatus)
if (status.loading) return 'Загрузка..'
if (status.failed) return 'Ошибка загрузки'
if (users.length === 0) return 'Нет данных'
return users.map(...)
}
В данном случае компонент UserList
несет в себе конкретную задачу - отобразить список пользователей. Состояние загрузки, ошибка, или пустой массив - своеобразные “исключения”, которые не дают это сделать. Поэтому, логичнее сначала обработать их, а на конец оставить основной случай в виде массива, заполненного пользователями.
Иногда люди делают так:
const UserList = () => {
// ...
if (users.length > 0) users.map(...)
return 'Нет данных'
}
Я считаю это неправильным, так как обработка “основного” кейса перемещается в середину между “побочными”.
Про логические операторы вместо “if”
shouldUpdate && update()
user && sendEmail({ user })
onClick && onClick()
У такого подхода много минусов:
- Плохая читабельность, так как пропущена часть со словом
if
, и сходу нельзя понять что делает строка. Помимо этого, логические операторы не являются каким-то словом, поэтому требуется дополнительное время на “парсинг” мозгом - Нецелевое использование оператора -
&&
,||
и?
с:
изначально не предназначены для такого, они нужны для построения логических выражений. Создатель JS, Брендан Эйх, даже упоминал об этом в своей статье “The infernal semicolon” - он считает такое использование “абьюзингом” возможностей языка - Такой код является выражением, поэтому скорее всего вы получите жалобы от правила no-unused-expressions. Там есть специальный флаг под этот кейс, но я считаю это костылем для анти-паттернщиков. По хорошему, в блоке кода нужно стараться писать все в виде statement’ов
И все это ради экономии ~2 символов, выглядит мягко говоря спорно)