Несколько рекомендаций для написания понятных для людей программ

Андрей Романов

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

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

Обязательно инициализировать переменную в месте её определения

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

Избегать вложенных вызовов методов

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

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

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

Иногда желание написать несколько методов в одной строке связанно с попыткой объединить их в один логический блок. Есть смысл давать подобным блокам осмысленные имена (то есть выделять в методы).

Осмысленно выбирать имена переменных, методов и классов

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

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

Поговорим о переменных. В переменных хранятся данные, часто это объекты классов, как следствие первый порыв говорит, что переменную стоит назвать подобно классу (пример, Cars *_cars), но это название не несёт никакой информации о назначении переменной, о том, как и для чего она будет использоваться. В большинстве случаев назначение переменной понятно из контекста, но это бывает только при условии, что имя класса и имя метода, в котором используется переменная, подобраны хорошо и переменных в классе не много (особенно переменных того же типа). Есть смысл подумать и понять, а для чего именно используется эта переменная. Например, если она содержит в себе ссылку на ремонтируемую в данный момент машину, тогда, возможно, есть смысл так и назвать эту переменную?

Методы (они же функции), зачем они нужны? Методы производят некоторые операции над данными или пользователем, это очевидно. Но тогда, возможно, есть смысл называть методы, описывая в названии то, что они делают? С этим, вроде бы, все согласны, но есть маленькая проблема. Очень многие методы содержат так называемые побочные действия и побочные условия выполнения. Поясню на примере: пусть метод называется – (void)writeToDisc. Казалось бы, всё понятно, метод пишет на диск данные. Но, заглядывая внутрь, мы обнаруживаем, что перед записью проверяется какое-то условие, а после успешной записи наращивается счётчик. Почему бы об этом не сообщить в имени метода? На самом деле, подобный метод будет нарушать принцип единой ответственности, так как он отвечает не только за запись данных на диск, но и за проверку возможности записи и наращивание некоего счётчика. В этом конкретном примере, метод должен находиться в отдельном классе, который бы отвечал за запись на диск и всё, что с ней связано.

Теперь про названия классов. Классы по своему назначению стоят как бы между переменными и методами. Во-первых, класс может описывать какие-либо данные и соответствующие операции над ними, во-вторых, класс может описывать некоторый прецедент (в терминах UML). Исходя из этой особенности классов, название класса может характеризовать как его назначение, так и объект, который описывает класс.

Если нет возможности придумать имя для объекта (класса, переменной, метода), то есть смысл задуматься о том, необходим ли этот объект.

Сохранять семантическое значение переменной на весь период её жизни

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

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

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

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

Комментировать программу

Комментарии в классическом виде – зло! С этим нужно смириться. Во-первых, они загромождают код, во-вторых, они имеют свойство устаревать и не обновляться вовремя (по сути вводят в заблуждение человека, который пытается разобраться в программе). Но что тогда делать? Как комментировать программу?

Под комментариями я подразумеваю использование локальных переменных и методов, и иногда классов с говорящими именами. Например, в коде класса несколько раз встречается строка [cars addObject:a]; Что здесь происходит? Первая мысль: “Ну что тут не понятного, добавление объекта в массив машин, а, добавление машины в массив машин”. Но что понятно из этой мысли? Какой смысл в контексте бизнес логики в этом действии, и что за массив cars, и, вообще, кто сказал, что это массив. Но давайте посмотрим на другую строку кода (предполагаем, что на неё будут заменены, все строки [cars addObject:*];) [self addCarToRepairQueue:car]. Смысл действия становится понятен мгновенно, к тому же мы понимаем свою небольшую ошибку: cars это не массив, а очередь. Иногда, для подобных целей, можно использовать целые классы, которые являются обёртками над классами библиотек (без наследования от них).

Использование комментирующих имён позволяет сохранять актуальность комментариев и повышает простоту понимания кода.

Группировать методы от общего к частному

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

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

Но как организовать код для удобного чтения? Вспомним один замечательный момент: большинство людей читают сверху вниз (не исключаю, что есть особые эстеты, которые любят иногда просматривать текст с конца, в частности, это удобно при чтении некоторых статей в сети). Как следствие, сверху должно быть общее описание работы класса, в нашем случае это открытые методы класса. Сортировка открытых методов должна быть построена по следующему принципу: вначале методы, отвечающие за создание и инициализацию класса, далее все остальные, завершаться всё должно методом dealloc (он так же считается публичным, хотя и вызывать его не рекомендуется). Подобная сортировка совпадает с жизненным циклом объекта класса и, как следствие, является естественной для читающего.

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

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

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

Использовать источник данных

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

В классах есть 2 вида данных: первый вид данных – это данные, которые необходимы в процессе работы класса (служебные данные), второй вид данных – это те данные, ради которых создаётся этот класс (значимые данные в контексте бизнес логики всего приложения). Неизбежно наличие методов, изменяющих значимые данные – это может быть как непосредственное редактирование (например, в случае если класс реализует прецедент “Редактирование данных”), так и любое внутреннее изменение.

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

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

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

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

Описывать замкнутые операции вместо наследования и перегрузки

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

Есть несколько подходов для реализации подобного требования, но рассмотрим только 2. Первый подход заключается в написании некоторого ViewController, который будет включать в себя всю логику выбора фото, его преобразование и отправку на сервер. Теперь, если захочется добавить где-либо кнопку выбора фото (без отправки на сервер, например, для создания нового объекта), сделать это без достаточно больших усилий не удастся, скорее всего прйдётся копировать код или пытаться выделить базовый класс.

Другой вариант, анализируем задачу. Выделяется 3 подзадачи: выбор фото, обработка фото, отправка фото. Есть соблазн написать 3 класса и далее их взаимодействие описать в вызывающем контроллере, но лучше выделить ещё один класс, который будет отвечать за их взаимодействие. Этот подход потребует некоторых дополнительных действий со стороны разработчика, но зато даст ряд преимуществ: во первых – позволит сконцентрироваться на деталях, во вторых – даст возможность строить различные комбинации из разработанных классов; в третьих – разрешит переносить функционал выбора и загрузки фотографий в любой раздел приложения без использования наследования.

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

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

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

В работе иногда встречаются похожие объекты (достаточно часто это списки), например, страницы настроек в приложении с общим ядром под различные платформы. Для одной из платформ (далее платформа A) страница настроек уже написана. На платформе A страница настроек состоит из таблицы с пятью ячейками, три из которых непосредственно изменяют состояние приложения, а две другие открывают новые представления для изменения настроек. Встаёт задача написания страницы настроек для второй платформы (платформа B), поведение сходных элементов должно соответствовать поведению на платформе A. Платформа B очень близка платформе A, настолько близка, что код может быть перенесён без изменений и будет работоспособным. В свете некоторых отличий платформы B (разрешение и производительность) дизайн страницы настроек изменяется – одна из ячеек, непосредственно изменявших состояние приложения, отсутствует и добавляется часть функций, которых не было на платформе A. Изменяется дизайн и последовательность ячеек на платформе. Так как на странице A часть функционала уже реализована, возникает желание наследоваться от имеющейся страницы и перегрузить “лишние” методы, а так же дописать свои.

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

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

Да, инициализация некоторого объекта во многих частях приложения (с учётом всех обвязок вокруг) будет копированием кода, но тут стоит задуматься, копирование какого кода является злом? Злом является копирование кода, содержащего бизнес-логику приложения, подобный код можно назвать значимым, любой другой код обвязкой.

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

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

Использовать правило единой ответсвенности и суперпозицию классов

Это значит следующее: всякий класс или метод должен отвечать только за одну функцию.

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

Например, рассмотрим выбор и отправку фото из галереи. Явно выделяется две подзадачи: выбор фото из галереи и отправка фото на сервер. Пусть класс ImageSelector отвечает за выбор фотографии. Класс ImageSender отвечает за отправку фотографии на сервер. Для координции работы классов будет использоваться класс ImageUploader. Клиенты будут использовать класс ImageUploader и ничего не будут знать об остальных классах, поддерживающих процесс выбора и отправки фотографии. Рассмотрим причины, по которым может потребоваться изменить коды представленных классов. Код ImageSender может измениться только в случае изменения протоколов или адреса назначения (хотя в последнем я не уверен, возможно, изменение адреса не повлечёт изменение кода класса). Код выбора может измениться только в случае изменения источников фотографий (например добавление какого-либо on-line источника, но в этом случае ImageSelector так же лучше представить как суперпозицию двух классов). Если потребуется дополнительная обработка фото перед отправкой, то изменения коснутся только ImageUploader, в который будет добавлен новый объект, отвечающий за редактирование.

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

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

Улучшать, если плохо

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

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

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

И небольшая притча. Однажды мне требовалось сделать textView с placeHolder (текст, который показывается пока textView пустое), покопавшись в интернете, мне удалось найти уже готовый полностью рабочий компонент. Но код, которым он был написан оставлял желать лучшего, компонент невозможно было поддерживать и изнутри он работал совсем не правильно (с точки зрения системных вызовов). Было принято решение исправить его. Да, будет потрачено время. Но к моменту изменения требований, у меня на руках был вменяемый класс, изменения в котором не приводили к фатальному результату и были понятны. Плюс ко всему, он перестал казаться чем-то лишним в проекте, он гармонично вписался во множество остальных классов.

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

Вкладывая в будущее, помнить о сегодня

Есть два вида пользы, которую мы получаем от наших действий: непосредственная (вознаграждение наступает немедленно) и отложенная (вознаграждение приходит не сразу). Если всё время стремиться к получению непосредственной пользы, то рано или поздно можно споткнуться, если всё время стремиться к отложенной, то просто умереть от голода. Писать плохой код, без использования суперпозиции и принципа единой ответственности, подобно получению непосредственной пользы работа идёт быстро и весело, но через какое-то время работать становится просто невозможно, так как никак нельзя разобраться с тем, что получилось. Только баланс между непосредственной пользой и отложенной, может привести к успеху.

Избегать фанатичного следования какой-либо религии разработки

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

Эпилог

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

Не бойтесь быть героем и менять мир к лучшему.

Литература:

  1. Мартин Фаулер, “Рефакторинг. Улучшение существующего кода”.
  2. Роберт Мартин, “Чистый код. Создание, анализ и рефакторинг”.