Risen.dev

Симфония кода

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 символов, выглядит мягко говоря спорно)