Никому не нравится находить в коде комментарии, которые неуместны и бесполезны. Такую ошибку легко допустить, не обновив (или не удалив) комментарий после изменения кода. Плохой комментарий не поломает ваш код, но представьте, что будет при отладке. Вы читаете комментарий, в нем говорится одно, а код делает совершенно другое. В итоге вы просто теряете время, выясняя, как это работает; в худшем случае, это даже может ввести вас в заблуждение.

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

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

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

Обзор техник

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

Мы можем разделить все техники написания самодокументируемого кода на три широких категории:

  • структурные, в которой структура кода или каталогов используется для уточнения его назначения;
  • связанные с именованием, такие как именование функций и переменных;
  • связанные с синтаксисом, в которых для этого используются (или не используются) отдельные возможности языка.

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

Структурные

Сначала рассмотрим структурные техники, они реализуются путем специального размещения кода для повышения его ясности.

Перенос кода в функцию

Это то же самое, что и рефакторинг “выделение функции” — он означает, что мы берем существующий код и переносим его в новую функцию: мы “выделяем” код в новую функцию.

Например, попытайтесь угадать, что делает следующая строка кода:

var width = (value - 0.5) * 16;

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

var width = emToPixels(value);

function emToPixels(ems) {
    return (ems - 0.5) * 16;
}

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

Замена условного выражения функцией

Условия с несколькими операндами сложно понять без комментариев. Мы можем применить к ним схожий метод для того, чтобы они были понятнее:

if(!el.offsetWidth || !el.offsetHeight) {
}

Для чего используется условие из этого примера?

function isVisible(el) {
    return el.offsetWidth && el.offsetHeight;
}

if(!isVisible(el)) {
}

Опять-таки, мы перенесли код в функцию и код сразу стал намного очевиднее.

Замена выражения переменной

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

Рассмотрим еще раз предыдущий пример с условием:

if(!el.offsetWidth || !el.offsetHeight) {
}

Вместо выделения функции мы можем сделать его яснее за счет добавления переменной:

var isVisible = el.offsetWidth && el.offsetHeight;
if(!isVisible) {
}

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

Наиболее часто этот метод применяется с математическими выражениями:

return a * b + (c / d);

Мы можем сделать яснее этот пример за счет разделения вычислений:

var multiplier = a * b;
var divisor = c / d;
return multiplier + divisor;

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

Интерфейсы классов и модулей

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

Рассмотрим пример:

class Box {
    setState(state) {
        this.state = state;
    }

    getState() {
        return this.state;
    }
}

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

Можете ли вы сказать, как используется этот класс? Может быть и сможете, потратив время, но сразу это не очевидно.

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

А что если поменять этот код следующим образом:

class Box {
    open() {
        this.state = 'open';
    }

    close() {
        this.state = 'closed';
    }

    isOpen() {
        return this.state === 'open';
    }
}

Теперь применение этого кода стало понятнее, разве не так? Отметьте, что мы только изменили публичный интерфейс, внутренняя реализация осталась той же, что и в примере с использованием свойства this.state.

Теперь вы с первого взгляда можете сказать, как используется класс Box. Это показывает, что несмотря на правильное именование функций в первой версии кода, весь класс в целом заставлял задуматься, а в результате простых действий типа этого мы сразу получаем значительный эффект. Всегда думайте о картине в целом.

Группирование кода

Группирование различных частей кода может работать как форма документации.

Например, всегда нужно стремиться объявлять переменные как можно ближе к тому месту, где они используются и группировать переменные, используемые совместно.

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

Рассмотрим пример:

var foo = 1;

blah()
xyz();

bar(foo);
baz(1337);
quux(foo);

Сразу ли вы видите, как часто используется foo. Сравните со следующим вариантом:

var foo = 1;
bar(foo);
quux(foo);

blah()
xyz();

baz(1337);

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

Используйте чистые функции

Чистые функции намного проще для понимания, чем функции, зависящие от состояния.

Что такое чистая функция? При вызове такой функции с одинаковыми параметрами, она всегда производит одинаковый результат, это обычно и называется “чистая” функция. Это значит, что у функции нет никаких побочных эффектов или зависимости от состояния (времени, свойств объекта, Ajax).

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

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

Хорошим примером побочных эффектов является document.write(). Опытные разработчики JavaScript знают, что этот метод не надо использовать, но многие новички спотыкаются на нем. Иногда этот метод работает хорошо, но в другие моменты при определенных обстоятельствах он может полностью очистить страницу. Такой вот побочный эффект.

Для лучшего обзора понятия чистых функций рекомендую прочитать статью Functional Programming: Pure Functions.

Структура файлов и каталогов

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

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

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

Именование

Есть популярная цитата о двух самых сложных вещах в программировании:

Есть только две действительно сложные вещи: инвалидация кэша и именование сущностей — Фил Карлтон

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

Переименование функции

Именование функций обычно не является слишком сложным, но есть некторые правила, которым вы можете следовать:

  • Избегайте использования размытых слов типа “обрабатывать” или “управлять”: handleLinks(), manageObjects().
  • Используйте активные глаголы: cutGrass(), sendFile() - очевидно, что такие функции делают.
  • Обозначайте возвращаемое значение: getMagicBullet(), readFile(). Это не обязательно делать всегда, но это помогает там, где имеет смысл.
  • В языках с сильной типизацией можно использовать описание типа, что также помогает обозначать возвращаемые значения.

Переименование переменной

Для переменных есть два хороших правила:

  • Указывайте единицы измерения: если у вас есть числовые параметры, вы можете включить их в название как ожидаемые единицы измерения. Например, widthPx вместо width показывает, что используется значение в пикселях.
  • Не используйте сокращения: названия типа a или b непригодны ни для чего, кроме счетчиков в циклах.

Следуйте принятой системе именования

Старайтесь следовать одной и той же системе именования в вашем коде. Например, если у вас есть объект определенного типа, используйте соответствующее наименование:

var element = getElement();

Не надо спонтанно использовать другие термины:

var node = getElement();

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

Используйте осмысленные сообщения об ошибках

Undefined это не объект!

Давайте не будем следовать примеру JavaScript и сделаем так, чтобы ошибки, возвращаемые нашим кодом содержали осмысленные сообщения.

Что делает сообщение об ошибке осмысленным?

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

Синтаксис

Связанные с синтаксисом методики написания самодокументируемого кода могут быть более специфичны для каждого конкретного языка. Например, Ruby и Perl позволяют вам использовать весь спектр странных синтаксических трюков, которых в общем надо избегать.

Рассмотрим некоторые техники, применимые в JavaScript.

Не используйте синтаксические трюки

Не используйте странные трюки. Вот один из хороших способов запутать людей:

imTricky && doMagic();

Это эквивалентно следующему, более адекватно выглядящему коду:

if(imTricky) {
    doMagic();
}

Всегда предпочитайте последний вариант. Синтаксические трюки не дают никаких преимуществ.

Используйте именованные константы, избегайте магических чисел

Если у вас есть специальные значения в вашем коде, такие как числа или строки, используйте вместо них константы. Даже если это кажется очевидным сейчас, через месяц или два никто не вспомнит, почему было использовано конкретно это число:

const MEANING_OF_LIFE = 42;

Если вы не используете ES6, вы можете для тех же целей применить var, работать все будет так же.

Избегайте булевых флагов

Булевы флаги могут сделать код сложным для понимания. Рассмотрим пример:

myThing.setData({ x: 1 }, true);

Что означает true? Это совершенно не понятно, пока вы не покопаетесь в исходниках setData().

Вместо этого вы можете добавить другую функцию или переименовать существующую:

myThing.mergeData({ x: 1 });

Теперь вы можете сразу сказать, что происходит.

Используйте преимущества возможностей языка

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

Хорошим примером этого в JavaScript являются методы итерации по массиву:

var ids = [];
for(var i = 0; i < things.length; i++) {
  ids.push(things[i].id);
}

Этот код собирает список ID в новый массив. Однако, чтобы узнать об этом, нам надо прочитать весь код цикла. Сравним это с методом map():

var ids = things.map(function(thing) {
  return thing.id;
});

В этом случае мы сразу узнаем, что создается новый массив с чем-то, так как именно это получается после работы map(). Это может быть выгодным, особенно, если используется более сложная логика. Вот список функций для итерации на сайте MDN.

Другой пример в JavaScript это использование ключевого слова const.

Часто вы объявляете переменные, значения которых не собираетесь изменять. Распространенным примером этого является подключение модулей с CommonJS:

var async = require('async');

Мы можем сделать эту неизменяемость более очевидной:

const async = require('async');

Это дает еще и дополнительный бонус — если кто-нибудь случайно попробует изменить это, мы получим ошибку.

Антипаттерны

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

Извлечение ради нескольких коротких функций

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

Представьте, что вы отлаживаете какой-либо код. Вы смотрите на функцию a(), видите, что она использует функцию b(), использующую в свою очередь функцию c(). И так далее.

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

Не форсируйте

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

Заключение

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

Однако самодокументируемость кода не заменит ни документации, ни комментариев. Например, код ограничен в выражении намерений, поэтому хорошие комментарии необходимы. Документация API также очень важна для библиотек, так как чтение кода не представляется возможным, кроме совсем небольших библиотек.