Что на самом деле важно для качества кода?

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

В этой статье я акцентирую внимание на следующих тезисах:

1. Приведенный выше мем применим ко многим (если не к большинству или всем) человеческим начинаниям.

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

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

Кривая IQ в других сферах деятельности

Вот тот же мем в контексте запуска стартапов, взятый из видео Майкла Сайбела (из Y-Combinator):

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

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

Что касается MVP (Minimum Viable Product, минимально жизнеспособный продукт), начинающие предприниматели всегда хотят запустить какое-нибудь 150-экранное приложение с таким же количеством функций, как у Salesforce. И наоборот, опытные предприниматели начинают с посадочной страницы и продвижения продукта на форумах типа HackerNews, а может быть, и с запуска на ProductHunt. Конечно, и те, и другие сразу переходят к разработке MVP, но эксперт справляется с задачей и создает рассылочный список за пару недель, а у новичка на создание MVP уходит больше года (а в половине случаев он не укладывается и в этот срок, поскольку выясняется, что программисты, нанятые за 10 долларов в час на Upwork, не умеют создавать сложное программное обеспечение).

Рассмотрим другой случай:

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

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

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

Простой код: глубокое погружение

В оставшейся части статьи вернемся к первому мему (о качестве кода) и рассмотрим разницу между подходами к написанию кода новичков и опытных инженеров (уже прошедших этап заботы о DDD, SOLID и т. д.). И те, и другие “пишут простой код”, но конечный результат получается совершенно разным. Я проиллюстрирую это на конкретном примере из реальной жизни.

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

Вот пример задачи из реальной практики: постройте граф, предполагающий, что человек хочет похудеть, и показывающий его вес, отложенный по оси Y, в сравнении со временем, отложенным по оси X. Вот имитированная реализация:

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

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

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

Решение начинающего разработчика

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

//Настройка по умолчанию графа темпа похудения для всех длительностей процесса похудения
export const REGULAR_WEIGHT_LOSS_PACING = {
1: [0, 0, 1.2, 0.2],
2: [0, 0.8, 1.2, 0.2],
3: [0, 0.8, 1.2, 0.8, 0.2],
4: [0, 0.6, 1.6, 1.2, 0.6, 0.2],
5: [0, 0.6, 1.6, 1.2, 1, 0.6, 0.2],
6: [0, 0.6, 1, 1.6, 1.4, 1, 0.4, 0.2],
7: [0.6, 1.4, 1.8, 1.4, 1, 0.6, 0.4, 0.2],
8: [0.6, 1.4, 1.8, 1.4, 1, 0.8, 0.6, 0.4, 0.2],
9: [0, 0.6, 0.8, 1, 1.2, 1.4, 1.4, 1.2, 1, 0.6, 0.2],
10: [0, 0.4, 0.8, 1, 1.4, 1.6, 1.7, 1.5, 1.2, 0.4, 0.2],
12: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
18: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
19: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
20: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
};

// Более резкая настройка графа темпов похудения для всех длительностей похудения.
// Это относится к пользователям из Великобритании или к тем, кто использует кг в качестве единицы измерения веса и имеющим целью снижение веса менее 19 кг.
export const STEEPER_WEIGHT_LOSS_PACING = {
1: [0, 0, 1.2, 0.2],
2: [0, 0.8, 1.2, 0.2],
3: [0, 0.8, 1.2, 0.8, 0.2],
4: [0, 0.6, 1.2, 1.2, 0.6, 0.2],
5: [0, 0.6, 1.6, 1.2, 1, 0.6, 0.2],
6: [0, 0.6, 1, 1.4, 1.4, 1, 0.4, 0.2],
7: [0.6, 1.4, 1.8, 1.4, 1, 0.6, 0.4, 0.2],
8: [0.6, 1.4, 1.6, 1.2, 1, 0.8, 0.6, 0.4, 0.2],
9: [0, 0.6, 0.8, 1, 1.2, 1.4, 1.4, 1.2, 1, 0.6, 0.2],
10: [0, 0.4, 0.8, 1, 1.4, 1.6, 1.7, 1.5, 1.2, 0.4, 0.2],
12: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
18: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
19: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
20: [1.2, 1.5, 1.6, 1.4, 1.4, 1.2, 1, 0.8, 0.6, 0.6, 0.6, 0.2, 0.1],
};

export const monthNamesAbbr = [
i18n.t("dates:Jan"),
i18n.t("dates:Feb"),
i18n.t("dates:Mar"),
i18n.t("dates:Apr"),
i18n.t("dates:May"),
i18n.t("dates:Jun"),
i18n.t("dates:Jul"),
i18n.t("dates:Aug"),
i18n.t("dates:Sep"),
i18n.t("dates:Oct"),
i18n.t("dates:Nov"),
i18n.t("dates:Dec"),
];

function calculateWeightAtMonth(
startWeight,
idealWeight,
monthsIn,
planDuration
) {
if (monthsIn <= 0) {
return startWeight;
}
if (monthsIn >= planDuration) {
return idealWeight;
}

let monthsToReduce = monthsIn;
if ([7, 8, 10, 12].includes(planDuration)) {
monthsToReduce -= 1; // пропускаем первый месяц на графе для планов 7, 8, 10, 12, поэтому внесите соответствующие изменения
}

const pacing = REGULAR_WEIGHT_LOSS_PACING[planDuration];
const totalWeightLossGoal = startWeight - idealWeight;
const monthlyWeightLossGoal = totalWeightLossGoal / planDuration;

let weight = startWeight;
for (let i = 0; i <= monthsToReduce; i++) {
weight -= Math.round(pacing[i] * monthlyWeightLossGoal);
}
if (weight < idealWeight) {
weight = idealWeight;
}
return weight;
}

function drawWeightGraph(...args) {
const today = new Date();
const programFirstMonth = today.getMonth();

const totalWeightLossGoal = startWeight - idealWeight;
const showSteeperSlope =
countryCode === "GB" ||
(unit === Unit.KILOGRAM && totalWeightLossGoal < 19);
const weightLossPacing = showSteeperSlope
? STEEPER_WEIGHT_LOSS_PACING
: REGULAR_WEIGHT_LOSS_PACING;

const monthlyWeightLossGoal = totalWeightLossGoal / planDuration;

const data = []; // Результат 1: это точки оси y
const pacing = [...weightLossPacing[planDuration]];

let weight = startWeight;
pacing.forEach((p, k) => {
weight -= Math.round(p * monthlyWeightLossGoal);
if (k === pacing.length - 2 || weight < idealWeight) {
weight = idealWeight;
}
data.push(weight);
});
}

const labels = []; // Результат 2: это метки месяцев
const pointBackgroundColor = [];
const pointBorderColor = [];
const pointBorderWidth = [];
const pointRadius = [];
// Ставим дополнительные точки на обоих концах для придания формы графу
for (let i = 0; i < data.length; i += 1) {
let shouldSkip = i === 0 || i === data.length - 1;
if (shouldSkip) {
labels.push("");
pointBackgroundColor.push("");
pointBorderColor.push("");
pointBorderWidth.push(0);
pointRadius.push(0);
} else {
let monthIndex = programFirstMonth + i;
if (planDuration === 1) monthIndex -= 1;
if (
planDuration === 7 ||
planDuration === 8 ||
planDuration === 10 ||
planDuration === 12
) {
monthIndex += 1;
}

if (monthIndex >= 12) {
monthIndex -= 12;
}
if (planDuration < 18) {
labels.push(monthNamesAbbr[monthIndex]);
}

pointBorderColor.push("#ffffff");
pointBorderWidth.push(2);
pointRadius.push(8);
if (i === data.length - 2) {
pointBackgroundColor.push("#f75462");
} else {
pointBackgroundColor.push("#2196f3");
}
}
// опущен код библиотеки графа
}

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

Так, если общая потеря веса составляет 24 фунта, а продолжительность программы  —  6 месяцев, то “линейная” скорость потери веса будет равна 4 фунтам в месяц. Возьмем линейный показатель 4 фунта и умножим его на жестко заданный коэффициент, что позволит преобразовать функцию из линейной в нужную нам форму. Наконец, вычислим ряд данных итеративно, вычитая каждую ежемесячную потерю веса из начального веса и помещая данные в массив.

Мое решение

Прежде чем перейти к рассмотрению моего решения, хочу рассказать о том, почему я вообще взялся за это. Решение начинающего разработчика выполнило поставленную задачу и дало прирост выручки в десятки миллионов долларов. Причина, по которой я стал заниматься этим кодом, заключается в том, что мы хотели провести ряд дополнительных A/B-тестов на графе (анимация, несколько линий и т. д.). Мне показалось, что этот код трудно поддерживать, поэтому я постарался сделать его лучше, чем когда я на него наткнулся. Уверен, что на встраивание новых версий графа в старый код у меня ушло бы больше времени, чем на рефакторинг и приведение его в лучшее состояние.

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

const startingWeight = ...; // входит как параметр
const idealWeight = ...; // входит как параметр
const weightLossDurationMonths = ...; // входит как параметр

// Всегда масштабируем размер до ~120 точек данных по горизонтали
function computeXAxisLabels(weightLossDurationMonths, startDate = new Date()) {
// прибавляем к weightLossDuration, чтобы построить граф через месяц
const xLabelsNoZero = new Array(weightLossDurationMonths + 1).fill('')
.map((_, i, allPoints) => {
const monthOffset = i + 1;
const dateAtMonthi = new Date(startDate.getTime());
const yearOffset = Math.floor(startDate.getMonth() + monthOffset/12);
dateAtMonthi.setMonth((dateAtMonthi.getMonth() + monthOffset) % 12);
dateAtMonthi.setYear(dateAtMonthi.getFullYear() + yearOffset);
const monthNameAbbr = dateAtMonthi.toLocaleString('en', {
weekday: undefined,
year: undefined,
month: 'short',
day: undefined,
hour: undefined,
minute:undefined,
second: undefined
});
return monthNameAbbr;
});
// Поскольку первая точка данных не имеет метки, добавляем ее сюда
const xLabels = [
"",
...xLabelsNoZero,
];
// Нам нужно, чтобы количество точек данных было фиксированным и составляло около 120, поэтому добавим кучу
// пустых меток между текущими точками данных
const xLabelsWithPadding = [];
const numberOfEmptyPointsToAdd = Math.round((120-(weightLossDurationMonths+2))/(weightLossDurationMonths+1));
xLabels.forEach((label, idx) => {
xLabelsWithPadding.push(label);
if (idx === xLabels.length - 1) return; // dont pad after the last one
for (let i = 0; i < numberOfEmptyPointsToAdd; i++) {
xLabelsWithPadding.push("");
}
});
return xLabelsWithPadding;
}
// Обратная логарифмическая функция соответствует желаемому поведению. Для изменения глубины кривой можно использовать метод проб и ошибок
// по отношению к wolfram alpha и построить конкретные значения для всего, кроме currentDay - это ваша зависимая переменная.
// Например, построить график снижения веса на 30 фунтов за 120 дней:
// строим график 30*log30(120/x) от x = 0 до x = 125.
// Умножение значения x сдвигает перехват x, поэтому он сдвинут на 120 дней.
// Умножение самого логарифма смещает перехват y,
// увеличение основания логарифма снижает вогнутость,
// деление logX на logY - это то же самое, что и взятие за основание логарифма Y от X
function computeYValue(numPoints, currentPoint, weightLossGoal){
return weightLossGoal * Math.log(numPoints/currentPoint)/Math.log(30) - 1;
}
function computeYAxisPoints(startingWeight, idealWeight, xAxisLabelsWithPaddings, startDate = new Date()) {
const weightLossGoal = startingWeight - idealWeight;
const yValues = xAxisLabelsWithPaddings
.map((_, i) => {
const computedLogWeightAboveTarget = computeYValue(xAxisLabelsWithPaddings.length, i, weightLossGoal);
// не превышаем начальный вес, не снижаем идеальный вес
if (computedLogWeightAboveTarget > weightLossGoal) return weightLossGoal;
return Math.max(computedLogWeightAboveTarget, 0);
});
return yValues;
}
// ОТВЕТ 1: ось x:
const xAxisLabelsWithPaddings = computeXAxisLabels(weightLossDurationMonths);
// ОТВЕТ 2: значения Y
const yValues = computeYAxisPoints(startingWeight, idealWeight, xAxisLabelsWithPaddings)
.map(y => y + idealWeight);

Наблюдения

Вы можете попробовать изменить решение здесь.

Я не утверждаю, что мое решение идеальное. Уверен, что кто-то предложит лучший вариант. Не думаю, что в программной инженерии есть такое понятие, как “идеальное решение”. В моем подходе самым сложным было создание графа нужной формы. Поэтому я упростил задачу, удерживая граф на уровне около 120 горизонтальных точек данных и изменяя расположение меток, вместо того чтобы изменять сам граф. В качестве альтернативы можно сгенерировать более “правдивую” функцию, в которой каждая возможная дата потери веса будет представлена на графе. Для этого можно сделать примерно следующее:

function computeYValue(numPoints, currentPoint, weightLossGoal){
const logBase = Math.max(Math.E, numPoints/10);
return weightLossGoal * Math.log(numPoints/currentPoint)/Math.log(logBase);
}

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

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

В управлении сложными задачами не существует единственно верных решений. Не имеет значения, использовал ли предыдущий разработчик ООП (концепцию объектно-ориентированного программирования), обернул ли он запутанный код в паттерны проектирования, обернул ли он операторы if как стратегии, и выполняли ли все функции одну задачу.

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

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

Начинающий разработчик обработал крайние случаи с помощью операторов if. Несомненно, это привело к небольшим ошибкам, которые выявились на этапе контроле качества. Проблема начинающих разработчиков заключается в том, что вместо того чтобы переосмыслить свой подход, они всегда принимают ошибочные решения, добавляя код для обработки крайних случаев. Они всегда делают самое простое, но выбирают горизонт планирования в несколько часов. Это то, что Джон Оустерхаут называет “тактическим программированием”  —  выполнение самых простых, на первый взгляд, вещей.

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

Мой код значительно меньше, несмотря на длинный комментарий. Мое решение исправляет размер оси, так что нам не нужен “более резкий темп похудения” для людей, использующих килограмм в качестве единицы измерения, или людей, у которых нет большого количества лишнего веса. Мы просто меняем метки оси y.

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

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

Как только вы освоите введенные мной дополнительные понятия, чтение моего кода потребует меньше размышлений. Оценивание операторов if не будет вашей головной болью. Написание кода, заставляющее читателя выполнять роль человека-интерпретатора кода, в конечном итоге отдаляет доставку продукта и увеличивает количество ошибок.

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

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

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Victor Moreno: What Actually Matters for Code Quality (Clean Code is Wrong)

Предыдущая статьяРасширяем возможности собственного мозга на базе ИИ, Python и ChatGPT
Следующая статьяКак профессионально писать логи Python