Не так давно я проводил аудит кодовой базы на работе и заметил, что, несмотря на высокое качество кода, я очень быстро уставал умственно и с трудом мог работать с его текстом продолжительное время.
В конце концов, я понял, почему этот код был таким сложным для восприятия, но причина оказалась не той, которую я ожидал (см. цикломатическая сложность). После небольшого анализа и исследования выяснилось, что дело скорее в читаемости кода — аспекте, о котором у меня было мало данных, но который мне было интересно изучить с точки зрения объективных терминов и общепринятых метрик.
Сегодня мы погрузимся в результаты этого исследования, то есть вместо того, чтобы визуализировать код, мы поговорим о визуальных паттернах кода — тех, которые буквально заставляют мой мозг болеть!
Предупреждение! Это туманная и плохо изученная область. В исследовании использовались различные источники: популярные метрики, научные статьи и практические мнения (включая мое собственное). Но в конце пути мы сведем всё ниже к 8 визуально различимым свойствам, которые помогут программистам любого языка улучшить читаемость кода.
Метрики читаемости кода и альтернативные метрики сложности
Скажу сразу: не существует общепринятой и широко используемой метрики читаемости кода.
Всё, что мне удалось найти, — это 1) научные статьи, которые, похоже, не используются в реальном мире, и 2) мнения… а мне хотелось чего-то более осязаемого.
Поскольку я не собирался придумывать свою метрику, моей целью было собрать набор визуальных паттернов, которые можно использовать в обсуждении того, что делает код более читаемым.
Например, взгляните на приведенные ниже примеры, где основное различие — это просто форма или структура логики. Какой из них легче всего читать? Или некоторые из них объективно сложнее для восприятия?

Различия между некоторыми из приведённых выше примеров — это вопрос предпочтений, но интересно искать паттерны, которые указывают на «плохие» варианты… особенно если их легко заметить визуально, без необходимости использовать специальные инструменты!
Моя первая мысль при поиске таких паттернов была обратиться к метрикам сложности, но, что любопытно, не так уж много известных метрик сложности программного обеспечения или академических идей о читаемости, которые соответствовали бы моим критериям. В частности, мне нужны были метрики, которые:
-
Работают с фрагментами исходного кода или отдельными функциями;
-
Не сосредотачиваются на «специфической сложности»: некоторые показатели, такие как цикломатическая сложность, в значительной степени неразрывно связаны с алгоритмом, который реализуется;
-
Не учитывают поверхностные стилистические факторы: длину имён переменных, использование пробелов, выбор стиля отступов или расстановки скобок.
Но хорошая новость в том, что есть две метрики, которые соответствуют этим критериям и действительно применяются на практике. Именно с них и начинается наше исследование.
Метрики сложности Халстеда
В конце 70-х годов ученый по имени Морис Халстед разработал набор метрик в попытке создать эмпирические показатели сложности исходного кода. Это означает, что они могут быть переведены на разные языки программирования и платформы, что очень удобно для нашей цели, поскольку эти метрики фокусируются на том, как код написан, а не на сложности описываемых алгоритмов.

Основой его метрик стали четыре показателя, основанные на подсчёте операторов и операндов:
-
Число уникальных операторов (
)
-
Число уникальных операндов (
)
-
Общее количество операторов (
)
-
Общее количество операндов (
)
Халстед стремился создать систему взаимосвязанных метрик, таких как Длина программы, Объём и Сложность, используя ряд уравнений для описания связей между ними. В конечном итоге его амбициозная цель заключалась в вычислении числового значения, которое могло бы предсказывать количество ошибок в коде!

Эти метрики, безусловно, спорные (они все-таки были разработаны в 70-х годах), но они дают нам числовые показатели, которые можно использовать как отправную точку для сравнения. И, в целом, их логика кажется разумной.
Интуитивно понятно: чем больше операторов задействовано, тем сложнее анализировать их возможные взаимодействия. Аналогично, чем больше операндов, тем сложнее понимать возможные варианты потока данных.
Пример сложности Халстеда на JavaScript
Если вернуться к нашему предыдущему примеру, то можно создать две его вариации, находящиеся на противоположных концах спектра по количеству операндов и операторов. Таким образом можно посмотреть, как это влияет на два показателя Халстеда — Объём и Сложность.
function getOddnessA(n) {
if (n % 2)
return 'Odd';
return 'Even';
}
// Halstead metrics from: https://ftaproject.dev/playground
// operators: 4 unique, 7 total
// operands: 5 unique, 6 total
// "Volume" 33.30
// "Difficulty" 2.50
function getOddnessB(n) {
const evenOdd = ['Odd', 'Even'];
const index = Number(n % 2 === 0);
return evenOdd[index];
}
// operators: 7 unique, 10 total
// operands: 9 unique, 12 total
// "Volume" 71.35
// "Difficulty" 3.75
На первый взгляд видно, что в первом примере меньше операторов и операндов, и он немного легче для восприятия. Числовые значения Халстеда для Объёма и Сложности подтверждают это. Это довольно удобно!
Главный недостаток здесь в том, что нет чётко определённого правила, что именно считать оператором или операндом во всех языках программирования. Поэтому лучше просто использовать конкретный инструмент или реализацию для измерений и придерживаться их.
Я использовал этот сайт для расчётов, приведённых выше, а на картинке ниже показана моя попытка раскрасить операторы и операнды разными цветами, чтобы визуально продемонстрировать: чем меньше цветов — тем проще код:

Выводы из метрик Халстеда
С практической точки зрения эти метрики подсказывают несколько полезных принципов:
-
Меньшие функции с меньшим количеством переменных обычно легче читать, а избыточные переменные стоит вводить только в том случае, если вы ненавидите тех, кто читает ваш код.
-
Лучше избегать специфичных для языка операторов и синтаксического сахара, так как дополнительные конструкции — это дополнительная когнитивная нагрузка для читателя. Попробуйте прочитать чужой код на Perl или Ruby, если вам это не кажется очевидным.
-
Цепочки вызовов
map
/reduce
/filter
и других функциональных конструкций (лямбды, итераторы, списковые включения) могут быть краткими, но длинные или многократно использованные цепочки ухудшают читаемость.-
Это особенно распространено в JavaScript и Rust, а также среди Python-разработчиков, которые слишком увлеклись itertools.
-
Как уже упоминалось, метрики Халстеда вызывают ожесточённые споры в академической и профессиональной среде, но нам не обязательно ждать научного консенсуса.
Чёрт возьми, мы просто возьмём идеи и начнем работать с ними!
Когнитивная сложность
Более свежая метрика, разработанная для более точного измерения сложности чтения кода, называется Когнитивная сложность. Она была создана компанией SonarSource, занимающейся статическим анализом кода.
Некоторые считают, что название метрики вводит в заблуждение, так как оно делает её звучащей более научно и объективно, хотя, по сути, это скорее эвристика, выведенная из опыта.
Как практик, который заботится только об эффективности, я готов принять хорошие метрики с неудачными названиями, чем плохие метрики вообще. А если эвристика работает — она заслуживает уважения!
Авторы разделяют Когнитивную сложность на три ключевые идеи:
-
Краткие конструкции, объединяющие несколько операторов, уменьшают сложность
-
Каждое отклонение от линейного потока увеличивает сложность
-
Вложенные управляющие конструкции увеличивают сложность
Звучит логично, но их статья объясняет всё гораздо глубже. Давайте разберёмся, что именно, по мнению разработчиков статического анализа, ухудшает читаемость кода.
Краткие конструкции
В своей статье они приводят два фрагмента кода, утверждая, что первый усложняет чтение, а второй — нет:
// 1
MyObj myObj = null;
if (a != null) {
myObj = a.myObj;
}
// 2
MyObj myObj = a?.myObj;
Второй вариант короче и требует меньше времени на чтение, но я бы поспорил, что в этом случае выше шанс, что программист забудет правильно обработать все возможные ситуации… Например, эти фрагменты кода на самом деле не эквивалентны!
В первом случае myObj
будет либо a.myObj
, либо null
, а во втором — a.myObj
или undefined
!
Это может показаться незначительной разницей, но когда мне нужно «поохотиться» за ошибками, я ищу именно такие неожиданные нюансы, чтобы понять, можно ли их использовать для выявления проблем.
Даже если мы используем языки с сильной системой типов, такие как TypeScript или Rust, они лишь уменьшают вероятность того, что мы забудем обработать такой случай, но не гарантируют, что он будет правильно обработан во всех сценариях.
А если язык не поддерживает строгую проверку типов (например, чистый JavaScript), вероятность того, что этот граничный случай останется без обработки, значительно выше.
Так что, хотя я не спорю, что краткие конструкции проще писать и легче читать, у них всё же есть обратная сторона — они повышают плотность кода, что может повлиять на его понятность.
Отклонения от линейного потока
Очевидно, что «линейный» код без условий легче сканировать, и это один из ключевых аспектов Когнитивной сложности.
Логично, что наличие условий, циклов или goto
увеличивает сложность, но авторы метрики пошли дальше и добавили к этому списку макросы с условиями, try/except
блоки, длинные логические выражения и рекурсию.
Я полностью согласен, что всё это влияет на читаемость, но здесь есть четыре тонких момента, которые стоит обсудить.
Первый момент. Они считают switch
-конструкции единым блоком, но штрафуют каждый дополнительный else-if
в цепочке, так как он может содержать несколько сравнений.
Не поймите меня неправильно, я считаю, что switch
выглядит лучше, чем цепочки if-else
, но он тоже не безгрешен: изучение полноты покрытия всех случаев вswitch
заметно усложняет чтение кода, а пропущенные break
стали причиной множества ненужных головных болей у программистов, включая меня!
// Switches are preferred by this metric
function getSign1(n) {
switch (math.sign(n)) {
case -1:
return 'negative';
case 1:
return 'positive';
default:
return 'other';
}
}
// It's true that else-if doesn't look as nice when each case returns
function getSign2() {
if (math.sign(n) == -1) {
return 'negative';
} else if (math.sign(n) == 1) {
return 'positive';
} else {
return 'other';
}
}
// but if the switch uses "break", the advantage decreases...
function getSign3(n) {
let sign = '';
switch (math.sign(n)) {
case -1:
sign = 'negative';
break;
case 1:
sign = 'positive';
break;
default:
sign = 'other';
}
return sign;
}
Второй момент. Они учитывают последовательности логических операторов внутри условия. То есть, цепочка из &&
или ||
считается за один элемент, но смешивание этих операторов увеличивает сложность.
Честно говоря, это звучит вполне логично. Иногда условия не такие уж длинные, но читать их сложно, и этот подход как раз отражает такую ситуацию.
// this conditional only adds 1 point
if (debug || verbose || consoleMode) { ... }
// this conditional adds 2, and not because of the indent
if (debug ||
(verbose && consoleMode)) { ... }
// this conditional adds 3:
if (debug ||
!(verbose && consoleMode)) { ... }
Третий вывод. Обработка исключений — это то, что часто недооценивают, но если её реализовать неудачно, код становится очень трудным для восприятия.
При оценке Когнитивной сложности блоки try
/catch
увеличивают сложность, но:
-
Несколько
catch
-блоков не считаются сложнее, чем один. -
Блоки
try
иfinally
вообще не учитываются.
Я не хочу зацикливаться на каждом нюансе, но тут есть важный упущенный момент:
исключения усложняют не только чтение кода, но и его восприятие на уровне архитектуры.
Когда исключения пересекают границы функций, это смешивает их сложность. В итоге анализ кода требует понимания не только текущей функции, но и всей цепочки вызовов, что делает отладку намного сложнее.
function divideBy7(n):
if (n <= 0) {
throw Error(`divideBy7 expects positive numbers, got ${n}`);
// aggressive exception throwing hurts readability because
// now the reader has to search and find where this might be caught
}
return parseInt(n / 7)
Четвертый момент. Эта метрика считает любой goto
(он же «прыжок к метке») увеличивающим сложность. Звучит логично, да?
Но есть одна ситуация, в которой goto
иногда улучшает читаемость:
-
Когда используется конструкция типа
goto out
илиgoto done
для чистого выхода из функции при ошибке. -
Некоторые эксперты считают, что это лучше, чем громоздкие конструкции с несколькими уровнями
if
-ов иbreak
-ов.
Однако если goto
пересекает границы цикла неожиданным образом (например, его нельзя заменить на обычный continue
или break
), это ломает привычное понимание потока управления.
В таких случаях разработчику приходится заново выстраивать в голове всю логику кода, что делает его трудным для чтения, даже если структура и намерение разработчика логичны.
Моё мнение — иногда goto
настолько же безвреден, как и обычное условие, но в других случаях он хуже, чем просто добавить ещё один if
.

Вложенные условия и циклы
Раз условия затрудняют чтение кода, то вложенные условия ещё хуже, верно?
Авторы метрики полностью поддерживают эту идею и усиливают её:
-
За каждый новый уровень вложенности добавляется дополнительный штраф к сложности.
-
Это идёт вдобавок к штрафам за обычные условия, циклы и другие конструкции.
Они демонстрируют это на примере кода на Java.
void myMethod () {
try {
if (condition1) { // +1
for (int i = 0; i < 10; i++) { // +2 (nesting=1)
while (condition2) { … } // +3 (nesting=2)
}
}
} catch (ExcepType1 | ExcepType2 e) { // +1
if (condition2) { … } // +2 (nesting=1)
}
}
// Cognitive complexity 9
На самом деле, это довольно распространённая идея, с которой я согласен.
Другие разработчики называют её, например:
-
Уровень отступов (Level of Indentation), или
-
Ухабистая дорога (Bumpy Road).
Трудно спорить с тем, что вложенная логика действительно ухудшает читаемость, особенно если уровень вложенности превышает 2.
function logIntegerDivA(x: number, y: number) {
if (debug) {
if (x != 0) {
if (y != 0) {
console.log(Math.floor(x/y));
}
}
}
}
// vs
function logIntegerDivB(x: number, y: number) {
if (debug === false) {
return;
}
if ((x == 0) || (y == 0)) {
return;
}
console.log(Math.floor(x/y));
}
Мы можем обсуждать, сколько баллов заслуживают различные конструкции, но наша цель — улучшить код, а не придумывать собственную метрику, чтобы продавать инструмент статического анализа.
Подводя итог этой части, можно сказать, что Когнитивная сложность поднимает множество важных вопросов о читаемости кода и может быть полезной эвристикой, но у нее есть и недостатки.
Например, один любопытный пробел заключается в том, что она напрямую не учитывает длину функции… а ведь при прочих равных длинные функции обычно сложнее читать, чем короткие.
Кроме того, поскольку компания, разработавшая эту метрику, не предоставляет открытой эталонной реализации для распространенных языков, лучше просто взять из нее полезные идеи и двигаться дальше.
Форма функции, шаблоны и переменные
Обсуждая визуальные шаблоны кода, часто упоминают форму функции (это включает не только отступы) и то, как в ней используются переменные.
Существует множество академических работ, посвященных использованию и пониманию переменных, но такие термины, как форма и шаблон, не имеют четкого определения. Поэтому мне не удалось найти исследований, которые комплексно охватывали бы все три ключевых фактора, влияющих на читаемость кода:
-
Хорошие имена переменных критически важны, а затенение переменных (то есть использование одинаковых имен для переменных в разных областях видимости, variable shadowing) — это катастрофа.
-
Желательно, чтобы переменные «жили» как можно меньше.
-
Привычные способы использования переменных проще понять, чем нестандартные.
Отличительные и описательные имена
Первый пункт кажется очевидным: чем понятнее имя переменной, тем легче понять, что делает код, а дублирующие или непонятные имена усложняют работу.
Но у этого принципа есть две важных грани, которые связаны с формой и использованием переменных:
-
Затенение переменных (variable shadowing) опасно. Если программисту приходится разбираться в правилах области видимости, чтобы понять, какая именно переменная используется, это повод переписать код.
-
Желательно использовать визуально различимые имена. (Например, вы когда-нибудь путали
i
иj
илиitem
иitems
?)-
Однажды мне попался код, в котором автор использовал три похожих имени переменной в одной функции:
node
,_node
иthisNode
. Неудивительно, что этот модуль оказался полон багов, причем некоторые из них серьезно влияли на безопасность системы.
-
Кратковременные переменные
Второй принцип связан с анализом «жизни» переменной, который оценивает, сколько кода написано между первым присвоением переменной и ее последним возможным использованием.
Эта тема больше известна специалистам по компиляторам, но она интуитивно понятна: чем дольше «живет» переменная, тем сложнее читателю держать в голове возможные значения и места ее использования.
// this version declares variables at the top,
function fibonacciA(n: number): number {
let fibN = n;
let fibNMinus1 = 1;
let fibNMinus2 = 0;
// live: n, fibN, fibNMinus1, fibNMinus2
if (n === 0) {
return 0;
} else if (n === 1) {
return 1;
}
// live: n, fibN, fibNMinus1, fibNMinus2
for (let i = 2; i <= n; i++) {
fibN = fibNMinus1 + fibNMinus2;
fibNMinus2 = fibNMinus1;
fibNMinus1 = fibN;
}
// live: fibN
return fibN;
}
// this reduces the live range of the three local variables
function fibonacciA(n: number): number {
// live: n
if (n === 0) {
return 0;
} else if (n === 1) {
return 1;
}
// these three only become live right before their use
let fibN = n;
let fibNMinus1 = 1;
let fibNMinus2 = 0;
for (let i = 2; i <= n; i++) {
fibN = fibNMinus1 + fibNMinus2;
fibNMinus2 = fibNMinus1;
fibNMinus1 = fibN;
}
return fibN;
}
Здесь не так важно точное определение, потому что речь идёт не только о сложности отслеживания данных в голове, но и о локальности — насколько далеко друг от друга расположены обращения к переменной.
На изображении ниже показан диапазон использования переменной, который приблизительно соответствует анализу её времени жизни. Даже в небольшом и искусственном примере разница заметна.

Худший случай — это переменная, «жизнь» которой охватывает несколько функций и изменяется в разных места. Управлять такими ситуациями безошибочно очень сложно, поэтому стоит тщательно подумать, можно ли организовать код по-другому.
Часто объект оказывается более подходящим решением, чем передача долгоживущих переменных между функциями, особенно если несколько переменных имеют примерно одинаковую продолжительность жизни. Если объект не подходит, постарайтесь минимизировать количество функций и строк кода, которые нужно просмотреть, чтобы понять возможное значение переменной.
Парадигмы функционального программирования обычно побуждают разработчиков сокращать время жизни переменных, иногда вплоть до полного отказа от них. Хотя в этом подходе встречается много элегантных решений, иногда можно зайти слишком далеко…
Если в коде есть длинные цепочки вызовов функций или вложенные колбэки, разбиение их на небольшие группы с использованием понятных имен переменных или вспомогательных функций значительно снижает когнитивную нагрузку на читателя.
// which is easier and faster to read?
function funcA(graph) {
return graph.nodes(`node[name = ${name}]`)
.connected()
.nodes()
.not('.hidden')
.data('name');
}
// or:
function funcB(graph) {
const targetNode = graph.nodes(`node[name = ${name}]`)
const neighborNodes = targetNode.connected().nodes();
const visibleNames = neighborNodes.not('.hidden').data('name')
return visibleNames;
}
Второй вариант немного менее эффективен?
Да.
Имеет ли это значение? Нет. Только если инструмент профилирования производительности явно указывает, что проблема именно в этих строках кода.
Как уже многие писали, компьютеры быстрые, а преждевременная оптимизация — зло.
Повторное использование знакомых шаблонов кода
Третья и последняя идея, касающаяся переменных, — это повторное использование знакомых конструкций кода и шаблонов переменных. По сути, это означает, что стоит использовать привычные решения и следовать принципу наименьшего удивления при написании кода.
Где возможно, повторяйте знакомые шаблоны (без бездумного копирования!), поскольку это снижает когнитивную нагрузку: читатель быстрее распознаёт привычные конструкции. Если же код отклоняется от стандартных решений, стоит давать переменным осмысленные имена или добавлять поясняющие комментарии.
Простой пример — последовательность написания условных конструкций. Например, выберите один из 6 вариантов реализации getOddness
(из начала поста) и придерживайтесь его во всей кодовой базе. Доведение этой идеи до логического завершения подразумевает использование шаблонных или обобщённых функций, чтобы читатель сразу понимал, что определённый шаблон повторяется в разных местах.
8 правил для улучшения читаемости кода
Объединив обсуждённые идеи и устранив дубликаты, получаем 8 ключевых факторов:
-
Количество строк, операторов и операндов – старайтесь писать короткие функции с меньшим числом переменных и операторов, их проще читать.
-
Новизна – избегайте новых, необычных конструкций в коде; предпочитайте знакомые шаблоны.
-
Группировка – разбивайте длинные цепочки вызовов функций, итераторов или сложных выражений на логические группы через вспомогательные функции или промежуточные переменные.
-
Простота условий – делайте условные выражения как можно короче и предпочитайте последовательности одного логического оператора вместо их смешивания.
-
GOTO
– не используйтеgoto
, кроме редких случаев, когда альтернативы хуже. -
Вложенность – сводите вложенные конструкции к минимуму (избегайте сильного увеличения уровня отступов). Если глубокая вложенность неизбежна, изолируйте её в отдельной функции.
-
Различимость переменных – используйте понятные и визуально отличимые имена переменных, избегайте затенения переменных.
-
Время жизни переменных – предпочитайте короткое время жизни переменных, особенно если они выходят за границы функций.
Эти принципы применимы к любому языку программирования и стилю оформления кода. Они дают объективные критерии, которые можно использовать при обсуждении читаемости кода.
Заключение
Мы рассмотрели множество идей из разных источников и собрали набор визуально наблюдаемых паттернов, которые помогают понять, почему некоторые фрагменты кода сложнее читать, чем другие.

Чтобы завершить историю, с которой начался этот пост: кодовая база, которая ломала мне мозг, содержала несколько анти-паттернов, особенно относящихся к пунктам 1, 2, 3, 6 и 8 из приведённого выше списка.
В коде было много длинных функций, использовалось слишком большое разнообразие языковых конструкций, а также встречались длинные цепочки вызовов функций, которые стоило бы вынести во вспомогательные функции. Как следствие, код содержал много уровней вложенности и долгоживущие переменные, что значительно усложняло его понимание.
Несмотря на высокое качество кода и уровень его авторов, нам удалось найти несколько критических ошибок, включая одну, которая буквально бросалась в глаза… но её не заметили, на мой взгляд, из-за того, что она находилась в середине длинной и сложной функции, которую было трудно анализировать.
Так как в этом посте мы обсуждали только отдельные фрагменты кода и функции, возможно, в будущем я расскажу об ошибках на уровне нескольких взаимодействующих функций. Если вам интересна тема сложности кода, найдите меня (автора оригинального поста — прим. переводчика) в соцсетях и дайте знать, или просто поделитесь этим постом!
На этом закончу словами наставника, который однажды сказал мне в начале карьеры:
«Человек, который с наибольшей вероятностью будет читать твой код через месяц — это ты сам.»
Полезные материалы:
Источник: https://habr.com/ru/articles/893820/