Человеко-читаемый JavaScript: история о двух экспертах +17


AliExpress RU&CIS



Каждый хочет быть экспертом. Но что это хотя бы означает? За годы работы мне встречалось два типа людей, именуемых «экспертами». Эксперт первого типа – это человек, который не только знает в языке каждый винтик, но и непременно все эти винтики использует, независимо от того, приносит ли это пользу. Эксперт второго типа также знает каждую синтаксическую тонкость, но разборчивее подходит к выбору инструмента для решения задачи, учитывая ряд факторов, как связанных, так и не связанных с кодом.

Давайте угадаю, эксперта какого типа вы хотели бы видеть в своей команде. Второго, верно? Это такой разработчик, который стремится выдавать удобочитаемый код, такие строки JavaScript, которые будут понятны другим специалистам, и которые легко будет поддерживать. Но характеристика «удобочитаемый» редко является определяющей – на самом деле, она обычно заключена в глазах смотрящего. Итак, к чему нас это приводит? К чему нужно стремиться, если наша цель – удобочитаемый код? Есть ли в данном случае явно верный или неверный выбор? Зависит от многого.

Очевидный выбор


Чтобы облегчить труд разработчика, TC39 в последние годы добавил множество новых возможностей в ECMAScript, в том числе, многие проверенные паттерны, заимствованные из других языков. Одним из таких нововведений, появившихся в ES2019, является метод Array.prototype.flat(). Он принимает аргумент глубины или Infinity и выравнивает массив. При отсутствии аргументов глубина массива по умолчанию равна 1.

Прежде, чем появилось это дополнение, требовался следующий синтаксис, чтобы выровнять массив до единственного уровня.

let arr = [1, 2, [3, 4]];
[].concat.apply([], arr);

// [1, 2, 3, 4]

Добавляя flat(), можно описать ту же возможность всего одной выразительной функцией.

arr.flat();

// [1, 2, 3, 4]

Вторая строка кода читается проще? Решительно да. На самом деле, с этим согласились бы оба эксперта.

Не каждый разработчик знает о существовании flat(). Но знать об этом заранее и не обязательно, так как flat() – понятный глагол, из которого ясно, что тут происходит. Он гораздо более интуитивен, чем concat.apply().

Вот тот редкий случай, в котором можно уверенно ответить, какой вариант синтаксиса лучше – новый или старый. Оба эксперта, ознакомившись с двумя вариантами синтаксиса, выберут второй. Выберут ту строку кода, которая короче, четче и удобнее в поддержке.

Но выбор и компромисс не всегда так однозначны.

Проверка на вшивость


Чем чудесен JavaScript, так это своей невероятной многогранностью. Вот почему в Вебе он повсюду. Хорошо это с вашей точки зрения или плохо – уже другой вопрос.

Но такая многогранность тянет за собой парадокс выбора. Один и тот же код можно написать самыми разными способами. Как определить, какой из них «правильный»? К такому решению даже не подступиться, если не знать всех доступных вариантов и не понимать, в чем они не дотягивают.

Давайте попробуем функциональное программирование с map() в качестве примера. Я разберу здесь несколько итераций – все они приведут к одному и тому же результату.

Вот самая лаконичная версия из всех наших примеров с map(). В ней меньше всего символов, и все они умещаются в одну строку. От этой версии будем отталкиваться.

const arr = [1, 2, 3];

let multipliedByTwo = arr.map(el => el * 2);

// multipliedByTwo равно [2, 4, 6]

В следующем примере добавляется всего два символа: скобки. Мы что-нибудь потеряли? А приобрели? Делает ли погоду то, что в функции, имеющей более одного параметра, всегда потребуется использовать скобки? Я считаю – да, делает. Нет ничего дурного, если добавить их здесь, но единообразие кода значительно повысится, когда вам неизбежно придется написать функцию со множеством параметров. На самом деле, на момент написания этих строк, в Prettier данное ограничение оказалось обязательным; там мне не удалось создать стрелочную функцию без скобок.

let multipliedByTwo = arr.map((el) => el * 2);


Идем дальше. Мы добавили фигурные скобки и оператор возврата. Теперь это уже начинает походить на традиционное определение функции. Прямо сейчас может показаться, что ключевое слово, столь же длинное, как и вся логика функции – это стрельба из пушки по воробьям. Но, если в функции будет свыше одной строки, то этот дополнительный синтаксис обязательно понадобится. Мы предполагаем, что у нас не будет никаких других функций длиннее одной строки? Кажется сомнительным.

let multipliedByTwo = arr.map((el) => {

  return el * 2;

});

Далее мы вообще убрали стрелочную функцию. Используем тот же синтаксис, что и раньше, но теперь предпочли ключевое слово function. Это интересно, поскольку нет такого сценария, в котором этот синтаксис бы не работал; при любом количестве параметров или строк у нас не возникнет проблем, поэтому здесь мы сильны единообразием. Этот код пространнее, чем наше первое определение, но так ли это плохо? Как это повредит новому программисту или человеку, поднаторевшему не в JavaScript, а в каком-то другом языке? Разве кого-нибудь, хорошо знающего JavaScript, смутит такой синтаксис при сравнении?

let multipliedByTwo = arr.map(function(el) {

  return el * 2;

});

Наконец, подходим к последнему варианту: передавать только функцию. И timesTwo можно написать при помощи любого угодного нам синтаксиса. Опять же, нет такого сценария, при котором передача имени функции обернулась бы для нас проблемой. Но сделаем шаг назад и подумаем, а может ли такой код кого-нибудь запутать. Если вы только знакомитесь с данной базой кода, ясно ли вам, что timesTwo – это функция, а не объект? Определенно, map() здесь послужит вам подсказкой, но такую деталь вполне можно и упустить. Как насчет того места, в котором объявляется и инициализируется timesTwo? Легко ли ее найти? Понятно ли, что она делает, и как она влияет на результат? Все эти соображения важны.

const timesTwo = (el) => el * 2;

let multipliedByTwo = arr.map(timesTwo);

Как видите, очевидного ответа здесь нет. Но выбирать верные варианты при формировании вашей базы кода можно лишь при условии, что ты понимаешь все опции и сопряженные с ними ограничения. В частности, знаешь, что для единообразия кода тебе понадобятся и круглые скобки, и фигурные, и ключевые слова return.

Есть ряд вопросов, которыми нужно озаботиться, когда пишешь код. Вопросы производительности – как правило, самые распространенные. Но, когда сравниваешь функционально идентичные фрагменты кода, то выбор нужно делать с оглядкой на людей, которым этот код придется читать.

Может быть, новее – не всегда лучше


Итак, мы рассмотрели ярко выраженный пример, в котором оба эксперта предпочтут более новый синтаксис, даже если он не общеизвестен. Мы также разобрали пример, который ставит много вопросов, но дает не так много ответов. Теперь давайте погрузимся в код, который я написал ранее… и удалил. Этот код превратил меня в эксперта первого типа, когда я решил задачу при помощи малоизвестной синтаксической конструкции, тем самым пренебрегая моими коллегами и удобством поддержки нашей базы кода.

Деструктурирующее присваивание позволяет распаковать значения из объектов (или массивов). Обычно оно выглядит примерно так.

const {node} = exampleObject;


Здесь в одной строке инициализируется переменная, и ей присваивается значение. Но так может и не быть.

let node

;({node} = exampleObject)

В последней строке кода значению присваивается переменная при помощи деструктуризации, но объявление переменной происходит на одну строку выше. Такое предпринимается часто, но многие не осознают, что так можно.

Давайте присмотримся к этому коду. Здесь навязывается несуразная точка с запятой, и это в коде, где точка с запятой не применяется для завершения строк. Здесь команда заключается в круглые скобки, а также добавляются фигурные скобки; совершенно непонятно, что здесь происходит. Читать эту строку сложно, т я как эксперт совершенно не вправе был писать такой код.

let node

node = exampleObject.node

Этот код решает задачу. Он работает, понятно, что в нем делается, и мои коллеги поймут его, никуда не подсматривая. Что касается деструктурирующего синтаксиса, я не должен его применять только потому, что могу применить.

Код – это еще не все


Как мы убедились, решение эксперта-2 редко напрашивается, если исходить только из кода; тем не менее, можно легко различить, какой код должен писать каждый из экспертов. Дело в том, что читать код должны машины, а интерпретировать – люди. Поэтому нужно учитывать и такие факторы, которые связаны не только с кодом!

Работая в команде JavaScript-разработчиков, вы будете иначе подходить к выбору кода, чем при работе в многоязычной команде, члены которой не настолько погружаются в языковые тонкости.

Давайте для примера сравним оператор расширения и concat().

Оператор расширения был добавлен в ECMAScript несколько лет назад и теперь очень распространился. Это своеобразный вспомогательный синтаксис, при помощи которого можно сделать множество вещей. В частности, сцепить некоторое число массивов.

const arr1 = [1, 2, 3];

const arr2 = [9, 11, 13];

const nums = [...arr1, ...arr2];

При всем потенциале оператора расширения, символ его не самоочевиден. Поэтому, если вы не знаете, что он делает, он не слишком вам поможет. Тогда как оба эксперта вполне могут рассчитывать, что команда JavaScript-профи знакома с таким синтаксисом, эксперт-2, пожалуй, задумается, а можно ли сказать то же о команде многоязычных программистов. Поэтому эксперт-2 может предпочесть метод concat(), поскольку это информативный глагол, который, вероятно, можно понять из контекста кода.

Этот фрагмент кода дает тот же числовой результат, что и вышеприведенный пример с оператором расширения.

const arr1 = [1, 2, 3];

const arr2 = [9, 11, 13];

const nums = arr1.concat(arr2);

Это всего один пример, демонстрирующий, как человеческий фактор влияет на выбор кода. База кода, к которой имеют доступ люди из разных команд, может регулироваться более строгими стандартами, которые не обязательно поспевают за всеми крутыми синтаксическими новинками. Тогда приходится отвлечься от основного исходного кода и учесть другие факторы, касающиеся вашего инструментария и способные усложнить или облегчить жизнь людям, работающим над этим кодом. Есть код, который можно структурировать так, что он будет плохо поддаваться тестированию. Есть код, который загонит вас в угол, и вы не сможете в дальнейшем масштабироваться или добавлять новые возможности. Есть код, в котором страдает производительность, поддерживаются не все браузеры или плохо с доступностью. Все эти факторы учитывает в своих рекомендациях эксперт-2.

Эксперт-2 также учитывает фактор именования. Но будем честны, даже эксперты в большинстве случаев с именованием не справляются.

Заключение


Настоящий эксперт – не тот, кто применяет каждый писк из спецификации, а тот, кто знает спецификацию достаточно хорошо, чтобы рационально развертывать синтаксис и принимать хорошо продуманные решения. Эксперт, дорастающий до такого уровня, может подготовить новых экспертов.

Что же это означает для тех из нас, кто себя считает экспертом или хотя бы стремится в эксперты? Это означает, что, когда пишешь код, нужно задавать себе множество вопросов. Адекватно оценивать тех разработчиков, которые являются твоей целевой аудиторией. Лучший код, который вы можете написать – это код, решающий некоторую сложную задачу, но по определению понятный тем, кто будет читать вашу базу кода.

Да, это очень непросто. И однозначного ответа часто нет. Но вы должны задумываться о вышеизложенном, когда пишете каждую вашу функцию.



Наши виртуалки можно использовать для экспертной разработки на Javascript.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!




Комментарии (11):

  1. mayorovp
    /#22987366 / +9

    Далее мы вообще убрали стрелочную функцию. Используем тот же синтаксис, что и раньше, но теперь предпочли ключевое слово function. […] Этот код пространнее, чем наше первое определение, но так ли это плохо?

    Это очень плохо. Одна строчка кода превратилась в пять. А если в этой строчке будет не одна операция map, а, скажем, map и filter — то эта одна строчка превратится в десять. Десятикратное увеличение строк кода на пустом месте — определенно не то, что способствует читаемости кода

  2. sultan99
    /#22987470 / +6

    Когда вижу в коде `let`, у меня повышается внимание на эту переменную и жду когда автор кода начнет ее изменять. Приходится заглядывать каждые `if else`, чтобы понять когда и как он ее меняет, но когда видишь код целиком написан на `let/var` и думаешь ну хренов эксперт из дикого запада.

    Давно уже пора привыкнуть этим стрелочным функциям, опциональным/тернарным операторам:

    const bob = users.find(user => user.name ===`Bob`)
    const message = bob?.age > 21 ? `Can buy beer` : `Not, yet`
    


    И если джун, не понимает, что тут написано, то отправляем его учить матчасть и не адаптируем код под него. Код хорош тогда когда он краток и понятен, а вот чтобы было понятно нужны знать азы, по любому.

    • Rsa97
      /#22988526

      Когда вижу в коде `let`, у меня повышается внимание на эту переменную и жду когда автор кода начнет ее изменять.
      Аналогично и с шаблонами. Встретив обратный апостроф ожидаешь, что будет подстановка значений в строку.

    • CoolWolf
      /#22988938

      Всё решается настроенной связкой eslint + prettier и набором правил для них под кодстайл проекта

  3. rjhdby
    /#22989718

    Всю статью можно было заменить одной цитатой Мартина Фаулера.


    Любой дурак может написать код, понятный компьютеру. Хороший программист пишет код, понятный человеку.

  4. andres_kovalev
    /#22993430

    Классические объявление функции и функциональное владение идентичны определению стрелочной функции только в случае контексто-независимых функций. Например, в данном случае тип функции имеет значение:


    const instance = {
      id: '...',
      filter(items) {
        return items.filter(item => item.id === this.id);
      }
    };

  5. Keyten
    /#22996524 / +1

    При всем потенциале оператора расширения, символ его не самоочевиден

    Половина статьи из серии «а что если код будет читать незнакомый с JS человек, и ему будет непонятно». Довольно сомнительная мотивация к увеличению кода.

    Разница между этими двумя конструкциями
    let multipliedByTwo = arr.map(el => el * 2);
    let multipliedByTwo = arr.map((el) => el * 2);

    в том, что последняя активно стремится к parenthesis hell (хотел изобрести этот термин ради этого комментария, но он оказался уже есть).

    Даже уже этот вариант гораздо менее читаем, потому что ты воспринимаешь стрелочную функцию не как одно целое, а аргументы и тело отдельно. Я уже не говорю о том, что через пару лет на этом самом месте ты встретишь какое-нибудь ))))) и будешь пересчитывать, через сколько скобок тебе нужно поставить Enter, и какая к чему относится.

    Убирать же стрелочную функцию и вовсе максимально сомнительное решение, ибо они как раз и созданы для того, чтобы понимать с первого взгляда «тут умножение на два», а не плодить в интерпретаторе в уме сущности «тут новая функция, пойдём смотреть, что у неё там в теле».

    Как видите, очевидного ответа здесь нет.

    Он есть. Если твоя стрелочная функция умещается в одну строчку, вроде timesTwo, имеет смысл её инлайнить (даже если она используется 10 раз подряд), потому что тогда тебе не придётся прыгать по коду с вопросами «а что делает эта функция» — да, даже если она объявлена буквально строкой выше. Иногда имеет смысл выделять, если код становится с этим гораздо более лаконичным.

    Если функция используется несколько раз, и при этом содержит логику, не очевидную с первого взгляда, имеет смысл её отделять отдельно. Если функция большая, то имеет смысл её отделять, если в основной функции и так много логики, и нарушается правило «делать функции поменьше и поатомарнее».

    Что касается деструктурирующего синтаксиса, я не должен его применять только потому, что могу применить

    Так всё-таки почему? Потому что не знающие JS коллеги не поймут?
    Этот синтаксис лаконичнее и проще. Может, мне ещё вместо { abc } использовать { abc: abc }?

    const nums = [...arr1, ...arr2];
    const nums = arr1.concat(arr2);

    Первое — очевидно с одного взгляда. Второе — нужно прочитать строчку до concat, вспомнить, что arr1 это массив, посмотреть, с чем конкатенируется, объединить в один смысл в голове. Ещё один совет из серии «как сделать ваш код менее читаемым».

    • mayorovp
      /#22996706

      Так всё-таки почему? Потому что не знающие JS коллеги не поймут?

      Потому что node = exampleObject.node читается проще чем ;({node} = exampleObject).

      • Keyten
        /#22998666

        const { node } = exampleObject;

        читается проще обоих вариантов, поэтому очевидно, что нужно использовать его.

        • mayorovp
          /#22998692

          Лучший-то лучший, да вот иногда так случается, что переменная в текущем скоупе уже объявлена, надо лишь присвоить ей значение.

          • Keyten
            /#23000318

            В таком случае очевидно, что лучший вариант

            node = exampleObject.node


            Непонятно, к чему всё это. «Не используйте деструктуризацию, потому что её можно использовать не всегда»?