Работающий код не всегда идеален, но создавая тексты программ стоит стремиться к тому, чтобы чтобы их было легко читать, понимать и модифицировать. Стоит стремиться к ясности кода. Чтобы этого достичь, код должен быть хорошо организован, ещё до открытия редактора всё нужно тщательно спланировать, подумать над оправданным разделением задач по компонентам программы.
Программирование с учётом ясности того, что получается, это то, что отделяет великих разработчиков от разработчиков обычных. В этом материале мы хотим привести несколько базовых принципов, которые позволят вам сделать первые шаги на пути к ясному коду.
Сразу хотелось бы отметить, что хотя идеи, рассматриваемые здесь, применимы к широкому спектру языков программирования, большинство примеров написано на объектно-ориентированном JavaScript. Если вы не знакомы с таким подходом к разработке на этом языке, можете посмотреть материалы по паттерну «Модуль» и по прототипному объектно-ориентированному программированию на JS. Это поможет вам быстрее освоить то, о чём мы будем здесь говорить.
Принцип единственной обязанности
Представьте, что вы занимаетесь домашними делами и взяли дрель для того, чтобы завинтить в стену шуруп. Когда вы сделали дело и опустили инструмент, оказалось, что у вашей дрели есть одна интересная особенность. Она, помимо своей основной функции, распыляет поверх закрученных ей шурупов быстросохнущий состав, который скрывает шуруп, имитируя штукатурку. Это очень хорошо, если вы собирались покрасить участок стены, в который вкручен шуруп, однако, подобное нужно не всегда. Кроме того, не хотелось бы обзаводиться второй дрелью для того, чтобы просто просверлить в чём-нибудь отверстие. Дрель была бы гораздо более полезным и надёжным инструментом, если бы она выполняла лишь одну функцию. Это сделало бы её достаточно гибким инструментом, подходящим для использования во многих ситуациях.
Принцип единственной обязанности указывает на то, что фрагмент кода должен делать что-то одно, и делать это хорошо. Так же как с дрелью из предыдущего примера, ограничение функциональности, на самом деле, повышает полезность фрагмента кода. Программирование с учётом этого не только избавляет от множества неприятностей, но и облегчает жизнь разработчикам, которые в будущем присоединятся к проекту.
Рассматривайте функции и методы с точки зрения сферы их ответственности. Расширяя сферу ответственности фрагмента кода, вы делаете его менее гибким и надёжным, более требовательным к изменениям и сильнее подверженным ошибкам. Для достижения ясности кода каждая функция или метод должны решать какую-то одну задачу.
Если вы описываете то, что должна делать функция, и при этом используете союз «и», то, вероятно, эта функция слишком сложна. Задача, решаемая функцией, должна быть достаточно проста для того, чтобы её можно было описать лишь с помощью осмысленного имени функции и списка её аргументов, имена которых так же указывают на их роль в решаемой функцией задаче.
Недавно мне дали задание создать электронную версию теста Майерса-Бриггса. Раньше мне уже приходилось это делать. Когда я, несколько лет назад, впервые столкнулся с такой же задачей, я создал огромнейшую функцию, которая называлась processForm
. Она вычисляла результаты, строила диаграммы, и заведовала всем, что касается DOM и визуализации данных.
Проблема была в том, что если что-то надо было изменить, приходилось перебирать целую гору кода лишь для того, чтобы понять, где именно нужно внести изменение. Кроме того, если что-то шло не так где-нибудь в недрах функции, найти ошибку было весьма непросто.
Итак, когда в этот раз передо мной встала та же задача, я разбил всю логику на функции, каждая из которых решала единственную задачу. Эти функции я обернул в объект модуля. В результате функция, которая вызывалась при отправке формы выглядела так:
return {
processForm: function() {
getScores();
calculatePercentages();
createCharts();
showResults();
}
};
Здесь можно взглянуть на полный код приложения.
Такой код очень легко читать, понимать и модифицировать. Даже человек, далёкий от программирования, может понять, что тут происходит. И каждая из этих функций (наверняка, вы уже об этом догадались!) решает лишь одну задачу. Это — принцип единственной обязанности в действии.
Если мне надо добавить, скажем, проверку данных формы, то вместо того, чтобы редактировать огромную функцию (и, возможно, нарушить её работу), я могу просто добавить в проект новый метод. Этот подход, кроме того, позволяет группировать связанные переменные и функции, что сокращает число конфликтов имён, повышает надёжность кода и значительно упрощает повторное использование функций для других целей если в этом возникает необходимость.
Итак, помните: одна функция — одна задача. Большие функции стоит перерабатывать в классы. Если функция решает множество задач, которые сильно связаны друг с другом, и в которых используются одни и те же данные, имеет смысл переделать её и превратить в объект с методами, примерно так же, как я поступил с моей большой функцией для обработки формы.
Разделение команд и запросов
Пожалуй, самая забавная переписка, которую мне доводилось читать — это та, в которой Дэвид Торн общается с Шэннон Уолкли по поводу создания плаката для поиска её пропавшей кошки Мисси. Каждый раз, когда Шэннон просила Дэвида сделать плакат, Дэвид делал то, что она просила, но добавлял кое-что от себя, в итоге Шэннон получала нечто, отличающееся от того, что ожидала. Эту переписку очень весело читать, но если ваш код делает то же самое — это уже не смешно.
Разделение команд и запросов формирует основу для защиты кода от нежелательных побочных эффектов, что позволяет избегать неприятных сюрпризов при вызове функций. Функции делятся на две категории. В одну входят функции, выполняющие команды, во вторую — функции, выполняющие запросы. Первые выполняют какие-то действия, вторые отвечают на вопросы. Смешивать их не рекомендуется. Рассмотрим следующую функцию:
function getFirstName() {
var firstName = document.querySelector("#firstName").value;
firstName = firstName.toLowerCase();
setCookie("firstName", firstName);
if (firstName === null) {
return "";
}
return firstName;
}
var activeFirstName = getFirstName();
Это упрощённый пример, тут легко увидеть неожиданные побочные эффекты, возникающие в ходе работы функции. В реальности большинство побочных эффектов найти куда сложнее.
Имя функции, getFirstName
, говорит нам о том, что функция должна возвратить имя (first name), которое она откуда-то берёт (get). Однако, первое, что она делает — это преобразует имя, взятое из документа, к нижнему регистру. Имя функции говорит о том, что она должна что-то откуда-то взять (выполнить запрос), но функция ещё и меняет состояние данных (то есть, выполняет некую команду). Это — побочный эффект, о котором невозможно догадаться, исходя из имени функции.
Дальше — хуже. Функция ещё и записывает в куки имя, запрошенное у неё, нам при этом ничего об этом не сообщая. Это вполне может привести к тому, что она перезапишет что-то, что мы сами записали в куки, и на что мы рассчитываем. Функция, выполняющая некий запрос ни в коем случае не должна перезаписывать какие-то данные.
Полезное правило, которого стоит придерживаться, заключается в том, что если функция отвечает на некий вопрос, она должна возвращать значение, а не менять состояние данных. И наоборот, если функция что-то делает, она должна менять данные и не должна ничего возвращать. Для достижения максимальной ясности кода одна и та же функция никогда не должна и возвращать некие значения и модифицировать данные.
Вот как выглядит улучшенная версия того же кода:
function getFirstName() {
var firstName = document.querySelector("#firstName").value;
if (firstName === null) {
return "";
}
return firstName;
}
setCookie("firstName", getFirstName().toLowerCase());
Это простой пример, но, будем надеяться, на нём хорошо видно, как предложенное разделение кода на тот, который выполняет действия, и тот, который возвращает результаты, может внести ясность в намерения программиста и избежать ошибок. По мере того, как размеры функций и кодовой базы растут, подобное разделение становится гораздо более важным, так как поиск определения функции всякий раз, когда ей собираются пользоваться, лишь для того, чтобы выяснить что именно эта функция делает, нельзя назвать эффективным использованием чьего-либо времени.
Слабое связывание
Подумаем над тем, чем различаются пазлы и кубики LEGO. В случае с пазлом, каждый его фрагмент можно соединить с другими лишь одним способом, а из всех фрагментов головоломки можно собрать лишь одну картинку. Если говорить о LEGO, то кубики можно соединять друг с другом как угодно, собирая из одних и тех же блоков всё, что захочется. Если бы вам нужно было выбрать один из этих типов строительных блоков для создания чего-то, до того, как вы будете знать, что именно будете создавать, что бы вы выбрали?
Связывание — это показатель того, как сильно один блок программы зависит от других. Слишком сильная зависимость (или сильное связывание) лишает программу гибкости. Так устроены паззлы и подобного следует избегать. Мы стремимся к гибкости кода, хотим, чтобы он обладал свойствами кубиков LEGO. А это уже называют слабым связыванием, и такая организация кода обычно ведёт к гораздо большей его ясности.
Помните о том, что код должен быть достаточно гибким для того, чтобы можно было перекрыть с его помощью множество вариантов его использования. Если вы ловите себя на том, что копируете и вставляете фрагменты кода и выполняете небольшие изменения, или переписываете что-то из-за того, что где-то что-то поменялось — знайте — это последствия сильного связывания. Ещё один признак сильной связанности компонентов программы — жёстко заданные ID в функциях, слишком большое количество параметров функций, множество очень похожих функций и наличие больших функций, нарушающих принцип единственной обязанности. Например, для того, чтобы сделать функцию getFirstName
из предыдущего примера подходящей для повторного использования, жёстко заданное в её коде firstName
можно заменить на некий универсальный ID
, передаваемый ей в качестве параметра.
Сильное связывание часто проявляется в группах функций и переменных, которые, на самом деле, хорошо было бы оформить в виде класса. Однако, подобное может происходить и тогда, когда классы зависят от методов или свойств других классов. Если вы сталкиваетесь с проблемами, связанными с взаимозависимостями функций, возможно, пришло время задуматься о создании из этих функций отдельного класса.
Я встретился с подобным, когда занимался кодом для набора интерактивных циферблатов. У циферблатов было множество переменных, включая те, что определяют их величину, размер стрелки, параметры оси вращения, и так далее. Из-за этого разработчик либо был вынужден использовать несуразное количество параметров функции, либо создавать по несколько копий каждой функции с жёстко заданными в них переменными. Кроме того, разные циферблаты вели себя немного по-разному. Это привело к появлению трёх наборов практически одинаковых функций — по одному для каждого циферблата. Если в двух словах, то связывание лишь усилилось из-за жёстко заданных переменных и из-за особенностей поведения объектов, в результате, как в случае с пазлом, был лишь единственный способ всё это собрать и заставить работать. Код оказался неоправданно сложным.
Мы решили эту проблему, разместив функции и переменные в классе, подходящем для повторного использования, экземпляр которого создавался для каждого из трёх циферблатов. Мы настроили класс так, чтобы при создании его экземпляра можно было передавать функцию, которая влияла на его поведение. Как результат, можно было пользоваться одним и тем же классом при создании его экземпляров для разных циферблатов. В итоге у нас оказалось меньше функций, переменные хранились в одном месте, что упростило поддержку кода проекта.
Классы, которые взаимодействуют друг с другом, также могут стать виновниками сильного связывания. Предположим, имеется класс, который создаёт объекты другого класса, нечто вроде учебного курса в колледже, представленного классом CollegeCourse
, который может создавать студентов, представленных классом Student
. Итак, класс CollegeCourse
работает нормально. Но пришло время, когда нам понадобилось добавить параметр к конструктору класса Student
. Вот тут начинается самое интересное. Для того, чтобы это сделать, нам понадобится модифицировать и класс CollegeCourse
, внеся в него изменения, соответствующие изменениям в классе Student
.
var CollegeCourse = (function() {
function createStudent_WRONG(firstName, lastName, studentID) {
/*
Если конструктор класса Student меняется, нужно изменить и этот метод, и все его вызовы!
*/
}
function createStudent_RIGHT(optionsObject) {
/*
Передача объекта в качестве аргумента позволяет объекту класса Student работать с изменениями. Нам может понадобиться изменить этот метод, но не понадобится менять все существующие вызовы этого метода.
*/
}
}());
У вас не должно возникнуть необходимости модифицировать класс из-за изменений другого класса. Это — хрестоматийный пример сильного связывания. Параметры конструктора могут быть переданы в виде объекта, при этом можно предусмотреть наличие значений по умолчанию, что ослабляет связи и означает, что код продолжит работать при добавлении новых параметров.
В итоге можно сказать, что программы рекомендуется строить из блоков, напоминающих кубики LEGO, а не фрагменты пазлов. Если вы столкнётесь с проблемами, напоминающими вышеописанные, весьма вероятно то, что они вызваны сильным связыванием элементов кода.
Высокий уровень связности
Видели когда-нибудь, как ребёнок наводит порядок в комнате, просто сваливая всё в шкаф? Конечно, внешне, при закрытых дверцах шкафа, это выглядит прилично, но после такой «уборки» ничего невозможно найти, и вещи, ничего общего друг с другом не имеющие, часто оказываются в одном и том же месте. То же самое может произойти и с кодом, если при его написании не стремиться к высокому уровню связности.
Связность — это мера того, насколько подходят друг к другу разные компоненты программы. Высокий уровень связности — это хорошо, это делает фрагменты кода более ясными. Низкий уровень связности ведёт к путанице. Функции и методы в блоках кода должны быть связаны по смыслу выполняемых ими действий — то есть, иметь высокий уровень связности.
Высокий уровень связности означает сбор конструкций, объединённых общей идеей, в одном месте. Скажем, сбор функций для работы с базой данных, или функций, имеющих отношение к какому-либо аспекту решаемой приложением задачи, в одном блоке кода или модуле. Подобный подход не только помогает понять, как организованы эти сущности, и где их можно найти, но и позволяет предотвратить конфликты именования. Если у вас имеется три десятка функций, шансы возникновения конфликтов имён гораздо выше, чем если имеется 30 методов, логически разделённых по четырём классам.
Если две или три функции используют одни и те же переменные, значит они должны быть как-то сгруппированы. На самом деле — это хорошая возможность для объединения их в объект. Если у вас есть набор функций и переменных, которые используются для управления элементом страницы, вроде слайдера, из всего этого можно сделать объект и повысить уровень связности кода внутри приложения.
Помните пример про циферблаты, когда благодаря классу удалось избежать сильной связанности? Это — отличный пример того, как высокий уровень связности помогает бороться с сильной связанностью сущностей. В данном случае высокий уровень связности и сильная связанность находятся на разных концах «шкалы ясности», поэтому, усиливая связность, мы ослабляем связанность, улучшая код.
Повторяющийся код — это надёжный признак низкого уровня связности. Похожие строки кода стоит поместить в функции, а похожие функции стоит переработать и сформировать классы. Тут полезно следовать правилу, в соответствии с которым одна и та же строка кода не должна повторяться дважды. На практике подобное возможно далеко не всегда, но, стремясь к ясности кода, всегда стоит думать о том, как сократить число повторений одинаковых фрагментов кода.
Аналогично, одни и те же данные не должны храниться в более чем одной переменной. Если вы определяете переменные с одинаковыми данными в разных местах программы, то вам, определённо, нужен класс. Или, если вы обнаруживаете, что передаёте ссылку на один и тот же HTML-элемент в несколько функций, возможно, эту ссылку стоит сделать частью экземпляра некоего класса.
Объекты даже можно помещать внутрь других объектов для того, чтобы ещё сильнее повысить уровень связности. Например, можно поместить AJAX-функции в один модуль, который включает в себя объекты для отправки формы, загрузки некоей информации и проверки учётных данных пользователей, используемых для входа в систему. Работа с подобными конструкциями может выглядеть, например, так:
Ajax.Form.submitForm();
Ajax.Content.getContent(7);
Ajax.Login.validateUser(username, password);
И наоборот, не стоит собирать в одном классе сущности, не имеющие друг к другу никакого отношения. В агентстве, где я раньше работал, было внутреннее API, в котором имелся объект Common
. В нём была собрана целая куча разнообразных методов и переменных, ничего общего друг с другом не имеющих. Класс оказался просто огромным, работать с ним было неудобно просто потому, что при его создании никто не задумывался о связности.
Если свойства не используются несколькими методами класса, это может быть признаком низкого уровня связности. Аналогично, если методы нельзя использовать в нескольких различных ситуациях — или если метод вообще не используется — это тоже признак плохой связности.
Высокий уровень связности помогает сглаживать последствия сильного связывания, а сильное связывание кода — это признак того, что проекту нужен более высокий уровень связности. Как правило, высокий уровень связности даёт разработчику больше плюсов, чем низкая связанность, хотя и того и другого обычно можно достичь совместно.
Итоги
Если наш код далёк от идеала — возникают проблемы. Достижение ясности кода — это гораздо больше, нежели использование правильных отступов — это тщательное планирование, выполняемое с самого начала проекта. Хотя добиться вершин мастерства в написании ясного кода непросто, если придерживаться принципов единственной обязанности, разделения команд и запросов, слабой связанности и высокого уровня связности, можно значительно улучшить ясность собственного кода. Это стоит принимать во внимание при работе над любым серьёзным программным проектом.
Уважаемые читатели! Планируете ли вы применять принципы, изложенные в этом материале, при написании собственного кода? А может быть вы уже ими пользуетесь?
Автор: ru_vds