Шаги по улучшению кода
Nuances of programmingПеревод статьи Isaac Lyman: Steps to better code
Начинающие программисты обычно проводят год или два, не обращая внимания на правила «хорошего кода». Конечно, они могут слышать такие выражения, как «элегантный» или «чистый» код, но не всегда способны дать им определение. Это вполне нормально. Для программиста без опыта существует только один важный параметр — рабочий код. Вскоре каждый программист поднимает планку качества кода. Хороший код должен не просто работать, а быть модульным, легко тестируемым, поддерживаемым и продуманным. Если вам повезет работать с командой, которая тщательно продумывает архитектуру кода, тогда вы сможете развить навыки для написания хорошо структурированного программного обеспечения. Если же не повезет, тогда команда будет постоянно жаловаться на качество вашего кода. В любом случае изучить несколько универсальных принципов написания кода будет полезно каждому.
Возьмем, например, глобальную переменную. Доступ к глобальным переменным можно получить из любой функции или места в приложении. Большинство влиятельных блогеров не рекомендуют использовать глобальные переменные, а начинающие программисты не понимают, почему это так. Причина заключает в том, что хотя такая практика и помогает писать код быстрее, но он становится сложнее для понимания. Конечно, глобальная переменная позволит легко вставлять, например, username
в любом месте приложения, чтобы сэкономить несколько строчек кода. Однако здесь вы жертвуете безопасностью ради удобства. Если во время создания приложения обнаружится баг, связанный со значением username
, тогда придется отлаживать не только один класс или метод, но и весь проект. Но вернемся к этому позже.
Разница между «хорошим» и «плохим» кодом заключается не только в его влиянии на вас. Код всегда остается общим ресурсом, им обычно делятся со сторонними разработчиками или с программистами из вашей команды, или человеком, у которого останется ваша работа, или «будущим собой» (который уже не будет понимать свой старый код), или отладчиком кода, который будет искать ошибки и баги. Все эти люди сделают свою работу намного быстрее, если код будет написан с умом. Таким образом, писать хороший код — это форма профессиональной любезности.
А теперь давайте рассмотрим принципы, которые помогут писать более качественный код.
Термины
Для начала дадим несколько определений:
- состояние (state) — это данные, хранящиеся в памяти приложения. Каждая назначенная переменная является частью состояния приложения;
- рефакторинг (refactor) — изменение кода программы без изменения ее поведения (по крайней мере, видимого пользователю). Цель рефакторинга — упростить код и сделать его проще для чтения и понимания.
1. Разделение проблем
Давайте сравним создание кода с процессом готовки. Простой рецепт предполагает, что каждый последующий шаг будет совершен после завершения предыдущего, как только все шаги будут выполнены — блюдо будет готово. Но если взять, например, сложный рецепт, когда на плите одновременно стоят две кипящие кастрюли, в микроволновке вращается тарелка, у вас есть три вида овощей, мельницы с разными специями (и вы не помните, что уже добавили), это вызовет настоящий стресс.
Кроме того, на кухне есть еще один повар, который то усложняет, то упрощает процесс. Нужно тратить время на координацию, передавая вещи туда и обратно, сражаться за место за плитой и все время регулировать температуру в духовке. Чтобы делать все это, нужна практика.
Если знать, что на кухне будет несколько поваров, тогда стоит разделить рецепт на несколько субрецептов. Тогда каждый из поваров будет работать только над своей частью рецепта с минимальным взаимодействием. Один — готовит кипящую воду для макарон, второй — нарезает и готовит овощи, третий — измельчает сыр. Четвертый — делает соус. Благодаря четко определенным задачам каждый из них делает свою работу.
Худший вариант написания кода равен самому простому рецепту, когда каждая строчка кода определена в одном и том же пространстве и нанизана сверху вниз. Чтобы понять или изменить такой код, вам нужно будет пересмотреть его много раз. Ведь переменная из второй строчки может влиять на операцию в строке 832, и единственный способ это понять — пересмотреть весь код.
Более хороший вариант написания кода похож на пример со вторым поваром. Некоторые операции передаются другим частям программы, что частично упрощает код, но не приводит к его упорядочению. Это, конечно, улучшение, но этого недостаточно.
Лучший из вариантов — это разделение рецепта на субрецепты. В коде их называют «модулями» или «классами». Каждый модуль связан с определенной операцией или частью данных. Таким образом, человек, занимающийся овощами, не будет беспокоиться об ингредиентах для соуса, а человек, готовящий макароны, не будет волноваться о терке для сыра. Их проблемы разделены.
Польза от такого разделения очевидна. Предположим, что программисту нужно изменить некоторые данные программы позже — сделать ее свободной от глютена для клиентов с целиакией или добавить сезонный овощ. Ему нужно будет внимательно посмотреть код и изменить одну небольшую часть. Если весь код, связанный с овощами, содержится в одном небольшом классе с минимальным интерфейсом, программисту не нужно будет беспокоиться о том, что добавление овоща испортит соус.
Суть в том, что для внесения каких-либо изменений программисту нужно будет думать только о небольших частях программы, а не обо всех сразу.
2. Глобальные переменные
Возьмем, например, переменную username
. При создании формы авторизации в приложении вы вдруг решили, что имя пользователя будет использоваться в нескольких местах приложения, например, на странице авторизации и в настройках. Поэтому сделали эту переменную глобальной. В Python она обозначается как global
, а в JavaScript это свойство объекта window
. На первый взгляд, это хорошее решение. Теперь в любом месте, где вам нужно использовать переменную username
, вы легко можете это сделать. Но почему не сделать глобальными все переменные?
Представим, что что-то пошло не так. Нашелся баг в коде, который связан с переменной username
. Даже с использованием инструментов поиска в вашей IDE найти эту переменную будет сложно. При поиске переменной username
вы получите сотни, а то и тысячи результатов. Некоторые результаты поиска будут глобальной переменной, которую вы установили в начале проекта, некоторые — другими переменными с именем username
, некоторые будут просто словом username, которое использовалось в комментарии, имени класса, имени метода и т. д. Можно будет установить дополнительные фильтры для поиска, но все же отладка займет много времени.
Решение проблемы заключается в том, чтобы поместить username
в контейнер (например, класса или объекта данных), который вводится или передается в качестве аргумента классам и методам, нуждающимся в нем. Контейнер также может хранить любые подобные данные, связанные с входом в систему (только не пароль пользователя). Также можно сделать этот контейнер неизменяемым, например, при установке имени пользователя оно уже не может быть изменено. Это упростит отладку, даже если имя пользователя используется десятки тысяч раз.
Такое использование кода облегчит вашу жизнь. Нужные данные всегда будут находиться только в одном месте. И если вам понадобится отследить, когда часть кода или данных была изменена, вы всегда сможете использовать getter
или setter
.
3. Не повторяйте себя
Давайте поговорим про отношения.
Быть в отношениях — это хорошо, ведь вы всегда находите поддержку в своем партнере. Часто бывает, что приходится рассказывать историю вашего знакомства. И редко такая история бывает такой простой, как: «Мы завязали разговор в продуктовом магазине и вышли замуж на следующий день». Таким образом, вам приходится рассказывать одну и ту же историю каждый раз, несколько раз в неделю, что довольно утомительно.
Чтобы усугубить ситуацию, представьте себе, что через несколько месяцев вы узнаете новую информацию о своем знакомстве со второй половинкой. Вы считали, что это была чистая случайность, но на самом деле это не так. Знакомство произошло после нескольких месяцев тщательного заговора, который был успешно организован, чтобы вы понравились друг другу. С одной стороны, это сработало, и вы оба счастливы. С другой стороны, вы постоянно рассказывали другую историю в течение нескольких месяцев. Когда люди узнают, что на самом деле произошло, они могут подумать, что вы солгали им (несознательная ложь, но все же).
Находясь в полном недоумении, вы создаете веб-страницу «Как мы встретились», а потом посещаете офис FedEx, чтобы распечатать тысячу визитных карточек с адресом этого сайта. После этого отправляете письмо со ссылкой всем, кто слышал старую версию. И теперь, когда кто-то спрашивает, как вы встретили своего партнера, вы просто даете ему визитную карточку. Если история когда-либо изменится, вы можете обновить веб-страницу, тогда все узнают новые подробности вашего знакомства.
Это не только хороший способ решить одну сложную ситуацию в отношениях, но и отличный способ для программирования: писать код для каждой операции (каждого алгоритма, каждого элемента представления, каждого взаимодействия с внешним интерфейсом) только один раз, и всякий раз, когда другая часть кода должна знать об этой операции, вызывать ее по имени. То есть каждый раз, когда вы копируете и вставляете код больше одного раза, подумайте, возможно, вы делаете что-то не так. Поэтому если история о том, как LonelyUser
получил сопоставление с MarriedUser
, повторяется больше одного раза, пришло время рефакторинга.
Суть в том, что если операция должна измениться, вам нужно будет изменить только один класс или метод. Это быстрее и надежнее, чем попытка сохранить несколько копий одного и того же кода, что в итоге потребует много времени для внесения правок, а если пропустить одну или две строчки, может вызвать проблемы, которые будет трудно диагностировать.
4. Сокрытие сложности
Допустим, я хочу продать вам машину. Но потребуется определенная подготовка, чтобы узнать, как ее использовать.
Чтобы завести автомобиль, нужно взять белый и красный провод и соединить их, приподнять переднее колесо и залить соответствующее количество топлива в инжектор, который находится под центральной консолью. Как только автомобиль запустится, доберитесь до коробки передач и передвиньте распределительный вал к первой передаче на дифференциальном валу. Чтобы ехать быстрее, увеличьте поток бензина в инжектор. Чтобы остановиться, вставьте палку в колеса.
Очень надеюсь, что вы ненавидите этот автомобиль так же, как ненавижу его я. Теперь спроецируйте свою злость на элементы кода с чрезмерно сложными интерфейсами.
Когда вы создаете класс или метод, первое, что вам нужно создать, — это интерфейс, то есть часть кода, которую должен знать другой кусок кода (вызывающий) для использования этого класса или метода. Для метода это также называется сигнатурой. Каждый раз во время просмотра функции или класса в API документации (например, на MDN или jquery.com) вы видите интерфейс — все, что вам нужно знать для его использования без содержащегося в нем кода.
Интерфейс должен быть простым, но выразительным. Все должно быть понятно без слов, вызывающая функция не должна знать порядок выполнения событий или данных, за которые она не отвечает.
Плохой интерфейс:
function addTwoNumbersTogether(number1, number2, memoizedResults, globalContext, sumElement, addFn) // returns an array
Хороший интерфейс:
function addTwoNumbersTogether(number1, number2) // returns a number
Если интерфейс можно уменьшить, то это нужно сделать. Если значение может быть выведено из других значений — это нужно сделать. Если у метода больше нескольких параметров, вы должны спросить себя, не делаете ли вы что-то не так (хотя можно делать исключения для конструкторов с зависимостями).
Но не нужно заходить слишком далеко. Если вы настраиваете глобальные переменные, чтобы избежать передачи параметров функции, вы делаете это неверно. Если для этого метода требуется множество разных данных, попробуйте разбить его на несколько мелких функций. Если это невозможно, то создайте класс, специально предназначенный для передачи этих данных.
Стоит помнить, что все методы и данные, принадлежащие классу, но доступные за пределами этого класса, являются частью его интерфейса. Это значит, что как можно больше методов и полей должны быть приватными.
В JavaScript переменные, объявленные с помощью var
, let
или const
, автоматически становятся приватными в функции, в которой они объявлены, до тех пор, пока вы не возвращаете или не назначаете их объекту. Во многих других языках используется ключевое слово private
. Оно должно стать вашим лучшим другом. Переменные стоит делать публичными только в случаях крайней необходимости.
5. Близость
Нужно объявлять переменные как можно ближе к месту их использования.
Инстинкт программиста к организации кода может работать даже против самого программиста. Ведь можно подумать, что организованный код выглядит так:
function () { var a = getA(), b = getB(), c = getC(), d = getD(); doSomething(b); doAnotherThing(a); doOtherStuff(c); finishUp(d); }
getA()
и другие подобные функции не определены в этом сегменте кода, но представьте, что они возвращают полезные значения.
Смотря на этот небольшой метод, можно подумать, что код хорошо организован и легко читается. Но это не так. d
, по какой-то причине, объявляется в строке 4, хотя она не используется до строки 9, а это значит, что нужно прочитать весь метод, чтобы убедиться, что переменная больше нигде не используется.
Хорошо организованный метод будет выглядеть так:
function () { var b = getB(); doSomething(b); var a = getA(); doAnotherThing(a); var c = getC(); doOtherStuff(c); var d = getD(); finishUp(d); }
Теперь понятно, что переменная будет использоваться сразу после ее объявления.
Конечно, в большинстве случаев ситуация не настолько простая. Что, если b
нужно передать два метода: doSomething()
и doOtherStuff()
? В этом случае вашей задачей будет взвесить параметры и убедиться, что метод все еще прост для чтения (в первую очередь, оставляя его небольшим). В любом случае нужно убедиться в том, что b
не объявляется раньше момента ее использования и используется в ближайшем сегменте кода.
Если делать все последовательно, то можно обнаружить независимость части метода от кода выше и ниже его. Это хорошая возможность поместить его в другой метод. Даже если этот метод будет использоваться только один раз, будет полезно вложить все части операции в понятный, хорошо названный блок.
6. Многоуровневое вложение кода (Deep nesting)
JavaScript известен своей сложной ситуацией, известной как «callback hell»:
Видите )};
,повторяющийся начиная с середины кода? Это и есть пресловутый ад обратного вызова (callback hell). Этого можно избежать, но это история для еще одной статьи.
Но давайте рассмотрим нечто, что называется if hell.
callApi().then(function (result) { try { if (result.status === 0) { model.apiCall.success = true; if (result.data.items.length > 0) { model.apiCall.numOfItems = result.data.items.length; if (isValid(result.data) { model.apiCall.result = result.data; } } } } catch (e) { // suppress errors } });
Подсчитайте пары фигурных скобок { }
. Шесть из которых вложены. Это слишком много. Этот блок кода трудно читать частично из-за того, что код вот-вот закроет правую сторону экрана, а программисты ненавидят горизонтальную прокрутку, ведь придется прочитать все условия if
, чтобы выяснить, как вы попали на строку 10.
Теперь посмотрим на это:
callApi().then(function (result) { if (result.status !== 0) { return; } model.apiCall.success = true; if (result.data.items.length <= 0) { return; } model.apiCall.numOfItems = result.data.items.length; if (!isValid(result.data)) { return; } model.apiCall.result = result.data; });
Так намного лучше. Теперь это нормальный путь кода, и только в некоторых ситуациях код отклоняется в блок if
. Процесс отладки упрощается в разы. И если мы хотим добавить дополнительный код для обработки условий ошибки, можно легко написать пару строк внутри этих блоков if
(а представьте, что блоки if
в исходном коде также имели бы блоки else
, ужас).
Кроме этого, были удалены блоки try-catch
, потому что не нужно подавлять ошибки. Ошибки — ваш друг, и без них приложение станет мусором.
7. Чистые функции
Чистая функция (или функциональный метод) — это метод, который не меняется и не зависит от внешнего состояния. Другими словами, одинаковые входные данные будут выдавать один и тот же результат, независимо от того, что изменилось за пределами такой чистой функции, а состояние приложения никак не зависит от происходящего внутри функции. Все чистые функции имеют хотя бы один аргумент и, по крайней мере, одно возвращаемое значение.
Это чистая функция:
function getSumOfSquares(number1, number2) { return (number1 * number1) + (number2 * number2); }
А это не чистая функция:
function getSumOfExponents(number1, number2) { scope.sum = Math.pow(number1, scope.exp) + Math.pow(number2, scope.exp); }
Если вам нужно провести отладку первой функции, то поместите ее в отдельную среду, например,jsfiddle
или консоль браузера и поиграйте с ней, пока не выясните, что случилось.
Если же надо сделать отладку второй функции, то придется перерыть всю программу, чтобы убедиться, что найдены все места, где доступны scope.sum
и scope.exp
. И если нужно переместить эту функцию в другой класс, придется проверить, имеет ли она доступ ко всем тем же областям.
Не все методы могут стать чистыми, но если в вашем приложении их совсем нет, его полезность будет ограниченной. Чистые функции нужно создавать как можно чаще. Это сделает ваше приложение легким в обслуживании и масштабировании.
8. Модульное тестирование
Любой класс или метод, который является более, чем голой оболочкой над другим кодом, то есть любым классом или методом, содержащим логику, должен сопровождаться модульным тестом. Этот модульный тест должен запускаться автоматически как часть вашей сборки.
Правильно написанные модульные тесты устраняют ложные предположения и облегчают понимание кода. Если кто-то не поймет, что делает определенный кусок кода, он всегда может посмотреть на модульный тест и увидеть варианты использования. Написание таких тестов может тормозить процесс разработки, но наличие таких тестов признак того, что вы на верном пути.
Заключение
Хороший код приносит удовольствие от работы с ним, его поддержка не вызывает у вас особых проблем. Плохой код — пытка для души. Старайтесь писать хороший код.
При написании кода стоит задавать себе один вопрос: легко ли его будет удалить при ненадобности? Если код глубоко вложен, скопирован и вставлен повсюду, зависит от разных уровней и строк кода, разбросанных по всей программе, люди не будут понимать, как с ним работать, как его читать и изменять. Код должен быть понятным и читабельным, части кода должны быть легко удаляемыми, если больше не несут полезности.