Возможно, вы понимаете как писать хороший код, как придерживаться хорошего дизайна. Но структурировать эти знания не получается. Книга Джона Оустерхаута “A philosophy of software design” может помочь исправить это.
Она позволила структурировать знания, накопленные мною за 3 года работы разработчиком и исправить многие ошибки в понимании хорошего дизайна кода. Она помогла четче прояснить для себя, что же это такое.
Здесь я хочу сделать краткое изложение всей книги в виде заметок, которые я записывал, когда читал её.
О чем книга
Эта книга о том, как писать простой код. Автор считает, что именно простота является главным признаком хорошего дизайна. Следовательно, главной проблемой он считает борьбу со сложностью кода.
Он выделяет 2 пути борьбы со сложностью:
- Писать более понятный код. За счет хороших комментариев, правильного именования переменных, простого интерфейса и реализации.
- Инкапсулировать сложность. Другими словами — скрывать неважную информацию.
Далее я буду использовать термин “Модуль”. Модулем может быть класс, функция, внешнее API и т.д. В том же значении этот термин использует автор книги.
Что такое сложность
Чтобы бороться со сложностью, нужно хорошо прояснить для себя, а что же это такое.
Симптомы сложности:
- Небольшие правки в функциональности требуют изменений кода во многих местах.
- Большая когнитивная сложность. Разработчику приходится изучить большое количество информации и держать многое в памяти, чтобы понять, как работает код.
- Не очевидно, что необходимо менять в коде, чтобы изменить функционал.
Главные причины сложности:
- Большое количество зависимостей
- Неочевидные вещи в коде:
- Общие названия переменных
- несколько целей у переменных
- плохая документация
- неочевидные зависимости (или утечка зависимостей)
Ещё важно отметить, что сложность имеет свойство накапливаться. Мы не замечаем, как сложность кода повышается. Когда мы откладываем рефакторинг кода или не заботимся о его дизайне, незаметно для себя можем прийти в такое состояние, когда код ужасно сложный и очень тяжело добавить новые фичи.
По этой причине автор выделяет 2 подхода программирования, он называет их:
- тактическое
- стратегическое
В тактическом программировании мы думаем только о решении поставленной задачи, не задумываясь о дизайне кода. Мы решаем её первым пришедшим в голову способом, не думая о том, что возможно другое решение, которое проще для понимания.
Разработчиков, придерживающихся такого подхода автор называет тактическое торнадо — человек, который оставляет за собой легаси код, в стремлении сделать задачи как можно быстрее. Этот код очень трудно понять, даже ему самому.
Стратегическое программирование — мы ищем лучшее решение, рассматривая несколько вариантов. Оно состоит из двух условий:
- проактивности — мы учитываем изменения и потребности в будущем и думаем о документации и понятности кода.
- реактивности — исправляем очевидные проблемы в старом коде, а не только пишем новый.
Важно отметить, что в стратегическом программировании невозможно сразу написать полностью правильный код. В любом случае некоторые проблемы прояснятся в будущем. Поэтому важно исправлять эти проблемы в уже написанном коде сразу, как только они были замечены.
Многие стартапы могут не согласиться с таким утверждением. Для них критично выпустить фичу как можно быстрее, иначе они просто не смогут существовать. Если стартап находится в такой ситуации, конечно можно пренебречь качеством, но при первой возможности лучше сразу отрефакторить код.
Ниже представлен график отображающий, сколько времени приходится тратить на разработку по мере роста проекта в разных подходах.
Автор предлагает тратить 20% своего времени на исправление старого кода, иначе сложность будет копиться, его понимание будет занимать много времени.
Модули должны быть глубокими
Это означает, что реализация модулей должна быть гораздо больше интерфейса.
Интерфейс говорит о том, что делает модуль, а реализация — как она это делает.
В хорошем коде, когда мы создаем или изменяем модуль, нам важно знать интерфейсы используемых внутри модулей и не нужно знать их реализацию. Нам важно знать реализацию только изменяемого модуля.
Поэтому, чем проще интерфейс у используемых модулей, тем проще с ними работать. Чем больше методов, аргументов и нюансов использования имеет модуль, тем сложнее его интерфейс.
Интерфейс может быть:
- Формальный — это сигнатура, публичные методы, свойства класса и т.д.
- Неформальный — комментарии к модулю, нюансы работы.
Простота кода сильно зависит от абстракции — это упрощение понимания модуля, за счет сокрытия не важной информации. У абстракции могут быть 2 проблемы:
- мало сокрытия — растет сложность интерфейса
- много сокрытия — недостаточно информации о модуле, возникают ошибки при его использовании.
Если ваш модуль очень маленький, то скорее всего интерфейс этого модуля больше, чем его функционал.
Важно, чтобы модуль имел простой интерфейс и при этом большую функциональность, для того, чтобы интерфейс скрывал сложность кода. Но при этом не нужно сильно увлекаться сокрытием, иначе можем скрыть важную информацию, и в итоге не будем до конца понимать, как правильно работать с модулем.
Утечка информации
Эта ситуация противоположна сокрытию информации.
Она происходит, когда нюансы реализации модуля просачиваются в другие модули, вынуждая переписывать код при изменении этих нюансов. Это может происходить через:
- Интерфейс
- Через back-door. Знания не описанные в интерфейсе, например, когда о формате файла знают несколько классов, хотя они важны только для одного. Такая утечка гораздо хуже утечки через интерфейс.
При обнаружении утечки, следует ответить на вопрос “Как изменить модули, чтобы знание влияло только на 1 класс?”. Возможно модули стоит объединить в один или вынести информацию наружу и обернуть её в более высокоуровневый модуль.
Временна́я декомпозиция
В процессе разработки мы часто размещаем информацию, основываясь на временной последовательности её выполнения. Например, если мы модифицируем файл, то можем разбить код на 3 класса: чтение, модификация и перезапись файла. При таком разбиении, в чтении и перезаписи файла нам придется знать о формате файла, тогда возникнет утечка информации.
Модуль должен быть завязан на знании, а не последовательности выполнения. Потому что модули должны быть переиспользуемыми. Модуль может быть нужным для совершенно другой задачи, без привязки к конкретной последовательности выполнения.
Общецелевые модули
Это модули с заделом на будущее, с возможностью использовать где-то ещё.
Преимущество таких модулей в том, что они не сильно привязаны к текущей задаче и склоняют вас делать интерфейс проще и универсальней.
В таким модулях лучше сначала описать интерфейс, а затем реализовать только те методы, которые нужны в текущей задаче.
Вопросы, которые помогут создать общецелевой интерфейс:
- Какой самый простой интерфейс покроет все мои нужды?
- В скольких ситуациях этот метод будет использован? Если только в одной, то скорее всего вы делаете интерфейс неправильно.
- Насколько легко использовать интерфейс в данный момент?
Разные слои, разные абстракции
В хорошо спроектированной системе есть разные слои абстракции кода, более высокоуровневый код использует модули на более низком уровне.
Какие проблемы на разных слоях абстракции могут возникать:
- Прокинутые методы — когда результат выполнения метода просто прокидывается на более высокий уровень, без каких либо обработок.Такой случай, как правило, только усложняет код. Проблема здесь в том, что ответственности разных классов пересекаются.
Исключением являются методы — диспетчеры, которые отвечают за выбор нужного метода для вызова.
Также, декораторы используют проброшенные методы. Поэтому их стоит использовать осторожно и только в крайнем случае. - Прокинутые переменные — когда переменные прокидываются вглубь к более низкоуровневым классам без обработок. В такой ситуации возникают следующие проблемы:
создается зависимость и никак не используется в промежуточных слоях
требуется изменения множества методов и заставляет усложнить интерфейс каждого.
Лучшее решения для такой ситуации — это использовать DI контейнер. Это не идеальное решение, оно может привести к неочевидным зависимостям, поэтому его следует использовать осторожно. Для того чтобы избежать многих проблем, переменные в контейнере можно делать неизменяемыми (immutable).
Старайтесь не перекладывать ответственность на верхний уровень
Не перекладываете ответственность на пользователей данного модуля насколько это возможно. Решайте как можно больше проблем внутри. (Пользователь — это не только человек, но и высокоуровневый модуль, который использует модуль).
Например, мы можем дать возможность настроить параметры модуля снаружи. Но может оказаться такая ситуация, когда параметры окажутся несовместимы, и эту проблему придется решать пользователю. Сначала стоит подумать, возможно ли определить эти параметры автоматически внутри.
Модуль должен выполнять свои обязанности до конца. В конце разработки модуля следует сделать дополнительные усилия и подумать, есть ли возможность скрыть лишние знания от пользователя.
Разделить или объединить
Для улучшения дизайна кода, часто, необходимо либо разделить модуль на несколько, либо наоборот объединить с другим. Чтобы понять, стоит ли объединять, рассмотрим признаки для объединения:
- Модули обращаются к общей информации.
- Используются совместно. Один нельзя использовать без другого.
- Решают общую задачу.
- Тяжело понять одну часть кода без другой.
- Если после объединения интерфейс упростится.
Разбивать код можно на общецелевой и специализированный. Если в модуле есть часть кода, которая может быть нужна в других задачах, то её стоит выделить отдельно и использовать внутри второго. То же самое с повторами в коде, это признак того, что повторяемая часть — это общецелевой модуль.
Специализированный код — это та часть кода, которая очевидно нужна только в конкретной задаче.
Если, после разделения метода или выделения подметода, приходится переключаться между ними, чтобы понять как они работают — это признак того, что методы разбиты неправильно или их не нужно разбивать. Дочерний метод не должен знать о том, где его используют.
Выделенный метод становится, в каком то смысле, общецелевым, и в перспективе его могут переиспользовать.
Работа с исключениями
Здесь под исключением понимаются не только exception которые выкидываются в коде. Это любые ситуации, которые вызывают необычное поведение системы. Когда что-то происходит не так, как задумывалось.
Исключения добавляют сложность в интерфейсе потому что:
- одни исключения порождают новые
- их нужно обрабатывать
- исключения могут быть не обнаружены, потому что они появляются редко
- обработка исключений сама может породить новое исключение
Исключение — это тоже часть интерфейса. Чем больше исключений у интерфейса, тем он сложнее.
Нужно стремиться скрыть исключение как можно раньше, не перекладывая это на пользователя.
Способы скрыть исключение:
- Игнорировать исключение. Принять его за нормальное поведение.
- Обработать внутри модуля, не выбрасывая его внаружу.
- Обработать множество исключений в одном обработчике, прокидывая через несколько уровней вверх и обработав в одном месте.
- Просто прервать программу с ошибкой, когда обрабатывать её бесполезно.
Не нужно стараться прятать ошибку внутри модуля всегда. Иногда бывает важно знать о случившейся ошибке внешнему модулю.
Проектируй дважды
Не стоит реализовывать первую пришедшую идею. Стоит рассмотреть несколько вариантов. Это позволит сэкономить время на переписывании кода.
Вначале стоит подумать про интерфейс. Не стоит рассматривать все плюсы и минусы каждого, достаточно ключевых. Важно подумать об абсолютно разных вариантах, которые принципиально отличаются друг от друга. В процессе размышления может родиться новый, который будет сочетать плюсы обоих.
Стоит подумать о том:
- какой из них проще
- является ли вариант более переиспользуемым
- будет ли реализация более производительной
Если для использования рассматриваемого варианта потребуется доработка верхнеуровневого класса, то это признак того, чтобы отказаться от него.
Рассмотрение вариантов не должно отнимать много времени, в среднем 1-2 часа. Чем более важный и крупным модуль мы хотим разрабатывать, тем больше времени на обдумывание мы можем потратить.
Зачем писать комментарии
Для начала рассмотрим популярные аргументы того, почему не стоит писать код и опровергнем их:
- Хороший код — самодокументируемый. Этот подход ошибочный, потому что:
- В коде нельзя дать высокоуровневое описание того, что делает метод или причину того, или иного решения в реализации.
- Если пытаться упростить реализацию для легкого понимания, то придется разбивать модуль так, что это может усложнить его интерфейс.
- Если пользователь читает всю реализацию, то ему приходится читать не только важную информацию, но и не важную, из-за чего теряется смысл в абстракции.
- Некоторые нюансы передаваемых аргументов и свойств нельзя описать в коде.
- Нет времени писать комментарии. Отсутствие комментариев вынудит потратить дополнительное время на понимание кода в будущем, из-за чего оно будет потрачено в ещё большем объеме.
- Комментарии устаревают и вводят в заблуждение. На самом деле поддержка правильно написанных комментариев не занимает много времени. Это потребуется только если происходят большие изменения в коде.
Главная идея в написании комментариев — записать важные мысли разработчика, которые нельзя описать в коде. Это позволит избежать ошибок, на которые попадется разработчик после него. Будут понятны намерения автора. Также это понизит когнитивную сложность.
Как писать комментарии
Разработчик должен понять что делает модуль не читая код. Если он может написать правильный комментарий к коду не читая его, значит комментарий к модулю не нужен.
Я просто перечислю ряд важных советов которые дает автор.
Хороший прием — использовать другие слова в комментарии чем в коде.
Описывая переменные, думайте существительными, а не глаголами.
Если комментарии интерфейса вынуждают описывать реализацию, то значит класс или метод слишком сложный. Скорее всего у вас неправильная абстракция кода.
Цель комментариев в реализации — дать понимание, что делает код (не как он это делает). Они нужны только для больших и сложных реализаций. Для простой реализации комментарий не нужен. Если разработчик поймет, что делает код, ему будет гораздо легче понять сам код.
Также бывает так, что общая логика размазана по нескольким модулям, например отправка и получение http запроса. Комментировать это трудно, поскольку дублировать комментарии нежелательно.
Для таких случаев лучше завести общий файл с заметками, и писать комментарий туда. Но будет одна проблема — комментарии находятся далеко от кода. При изменении кода, будет сложнее поддерживать комментарии.
Комментарии лучше писать вначале
Какие выгоды это дает:
- В процессе написания кода вы можете ориентироваться на написанные комментарии.
- Комментарии становятся инструментом проектирования дизайна кода. Если вы замечаете, что для интерфейса слишком длинный комментарий, скорее всего ваш интерфейс слишком сложный и лучше придумать что-то попроще.
- Раннее комментирование позволяет сформировать хороший дизайн и абстракцию ещё до написания кода.
Именование переменных
Именование — это одна из форм документирования.
Правильное именование позволяет:
- легче находить ошибки
- уменьшает сложность
- уменьшает необходимость в комментариях
Имя должно быть:
- Не слишком общим, например count. Если трудно подобрать полноценное имя, то это признак того, что вы делаете что-то не так. Возможно переменная имеет слишком много назначений. Лучше разбить её на несколько.
- Консистентным, т.е. такое имя должно использоваться в других местах с таким же назначением, и не использоваться другое имя для такого же назначения.
Консистентность кода
Консистентность — означает, что похожие вещи сделаны похожим образом, а непохожие — по другому. В чем может проявляться консистентность:
- наименованиях
- стиле кода
- интерфейсе
- в паттернах (например MVC улучшает консистентность)
Консистентность дает:
- Быстрое понимание того как работает код, если вы уже знакомы с похожими местами.
- Уменьшает ошибки. Если похожие места сделаны по разному, то разработчику легче ошибиться, подумав, что здесь также.
Консистентность легко могут нарушить новички в команде, поскольку они не знакомы со всеми договоренностями. Этого можно избежать при помощи:
- Документации
- Линтеров и других инструментов, не позволяющих пушить код, не соответствующий принятым стандартам.
- При изменении кода смотреть, как сделано сейчас.
Старайтесь не менять существующие договоренности, ценность от консистентности может быть выше, чем улучшения, которые предлагается ввести. Перед вводом неконсистентности дайте ответ на 2 вопроса:
- Старый подход не позволяет добиться нужного результата?
- Новый подход настолько лучше, что хуже тратить время на поддержку старого и все в команде с этим согласны?
Если ответ на любой вопрос “да”, то тогда можно нарушить консистентность.
Тренды в разработке ПО
Наследование в ООП
Автор рекомендует с осторожностью использовать наследование реализации, поскольку это создает зависимость дочернего класса от родительского. Лучше стараться избегать её и по возможности использовать хелперы и композицию. Если все же использовали наследование, то постарайтесь сделать их менее зависимыми.
Agile
Автор отмечает, что создание дизайна кода также инкрементально как развитие проекта в Agile. Постоянно происходит улучшение и рефакторинг кода. Нет лучшего решения навсегда.
Проблема в том, что agile часто вынуждает прибегать к тактическому программированию, требуя фокусироваться на фичах, а не на абстракциях.
Unit тесты
В отличие от интеграционных тестов, за unit тесты отвечают разработчики. Интеграционные лучше отдавать отдельной QA команде.
Unit тесты хороши тем, что позволяют не бояться рефакторить код, если они имеют хорошее покрытие.
TDD
Проблема TDD в том, что она вынуждает концентрироваться на поиске способа реализации, а не нахождении лучшей абстракции.
Лучше всего он подходит на исправлении багов. Сначала мы пишем тест на баг, убеждаемся, что тест не проходит, а потом чиним его.
Паттерны
Основная их проблема в том, что их применяют тогда, когда лучше обойтись более простым решением.
Хороши они тем, что позволяют соблюсти консистентность.
Геттеры и сеттеры
Их лучше избегать, потому что они делают доступ, даже к приватным свойства, публичным. А публичные методы в любом случае усложняют интерфейс.
Заключение от меня
Я привел краткие заметки только по тем темам, которые показались важными для меня. В книге автор разбирает каждую тему очень подробно и приводит множество примеров.
Кроме того, в книге очень много красных флагов. Это такие ситуации, заметив которые, вы должны насторожиться и задуматься над дизайном. Это делает книгу очень полезной для практического применения во время работы.
Если вы читали эту книгу, напишите в комментарии, что для вас было самым полезным, если я упустил это в статье. Если вы поняли некоторые темы по другому или с чем-то не согласны, я буду рад это обсудить.