конспект книги «A Philosophy Of Software Design» by John Ousterhout

Введение

Природа сложности

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

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

Симптомы сложной системы:

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

Из перечисленных выше симптомов неизвестные неизвестности являются наиболее вероятной причиной внесения ошибок в систему.

Главные причины сложности программных систем:

  • зависимости - невозможность изолированного осмысления и модификации отдельного участка кода;
  • неопределённости - неочевидность важной информации (например, о наличии зависимостей).

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

Также, сложность системы более очевидна читающему её код, чем тому кто его пишет.

Работающий код - это еще не все

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

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

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

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

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

Глубокие модули

Модульный дизайн подразумевает разбиение системы на, относительно, независимые модули (такие как классы, подсистемы, сервисы).

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

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

Интерфейс модуля содержит информацию двух видов: формальную и неформальную.

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

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

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

Абстракция может быть ошибочной по двум причинам:

  • она может включать незначимые детали;
  • она может не включать значимые детали.

В любом случае результатом будет неопределённость.

Глубокий модуль - это модуль предоставляющий мощные возможности и простой интерфейс.

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

Сокрытие (и утечка) информации

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

Сокрытие данных снижает сложность двумя способами:

  1. упрощая интерфейсы модулей;
  2. снижая степень зависимости между модулями за счет инкапсуляции.

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

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

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

Сокрытие информации может приводить к укрупнению классов, что нормально.

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

Модули общего пользования

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

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

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

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

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

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

Разные уровни, разные абстракции

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

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

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

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

Нисходящее увеличение сложности

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

Для модуля более важно иметь простой интерфейс, чем простую реализацию.

Сокрытие сложности в модуле имеет смысл, если:

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

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

Вместе или отдельно?

Один из главных вопросов при дизайне программного обеспечения звучит так: располагать ли рассматриваемые функциональные возможности в одном модуле или в разных?

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

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

Функциональные возможности взаимосвязаны если они:

  • имеют общую информацию;
  • используются совместно;
  • пересекаются концептуально;
  • одно трудно понять без другого.

Использовать единый модуль следует если:

  • есть общая информация;
  • это упростит интерфейс;
  • это устранит дублирование.

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

Для методов действует следующее правило: метод должен делать что-то одно, и делать это полностью.

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

Наилучший способ переработки метода - это выделение подзадач в отдельные методы.

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

Положите конец ошибкам

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

В случае возникновение исключительной ситуации возможны два сценария:

  • продолжить работу программы;
  • завершить выполнение и сообщить об ошибке.

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

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

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

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

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

Проектируй дважды

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

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

При рассмотрении нескольких альтернатив полезно ответить на следующие вопросы:

  • какой из вариантов имеет наиболее простой интерфейс?
  • интерфейс какого из вариантов наиболее общий?
  • какой из интерфейсов обеспечит более эффективную реализацию?

Результатом может стать один из рассматриваемых вариантов или их пересечение.

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

Привычка проектировать дважды не только улучшает дизайн системы, но также развивает этот навык.

Зачем писать комментарии?

Основная идея написания комментариев - это фиксация важной информации, которая не может быть выражена в коде.

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

Оправдания:

  • Хороший код не нуждается в комментариях
  • Нет времени
  • Документация устаревает и начинает вводить в заблуждение
  • Я видел только бесполезные комментарии и документацию

Хороший код не нуждается в комментариях

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

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

Нет времени

Всегда будет что-то кажущееся более приоритетным, чем написание документации и комментариев.

Документирование кода относится к стратегическому проектированию и окупается в долгосрочной перспективе.

Написание документации в ходе проектирования улучшает дизайн системы в целом.

Документация устаревает и начинает вводить в заблуждение

Необходимо избегать дублирования информации и держать документацию как можно ближе к соответствующему коду.

В актуализации комментариев и документации очень помогает процесс code review.

Я видел только бесполезные комментарии и документацию

Написанию хорошей документации необходимо учиться.

Комментирование неочевидного

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

Советы:

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

Обычно документированию подлежит:

  • интерфейс;
  • элементы структур данных;
  • реализация;
  • межмодульное взаимодействие.

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

Интерфейсная документация метода обычно включает:

  • описание поведения с точки зрения клиента;
  • описание аргументов и возвращаемого значение (если есть);
  • побочные действия (side effects) - влияние метода на дальнейшее поведение системы не являющееся его результатом;
  • возможные исключительные ситуации;
  • предусловия.

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

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

По степени детализации можно выделить низкоуровневые и высокоуровневые комментарии.

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

Высокоуровневые комментарии могут предоставлять обоснование решений или простое и более абстрактное описание кода.

Выбор имен

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

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

Хорошие имена обладают двумя качествами: точность и последовательность (consistency).

Правила последовательного использования имен:

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

Документация как часть процесса проектирования

Лучшее время для документирования кода - перед его написанием.

Такой подход имеет следующие преимущества:

  • возможность проверки выбранной абстракции и дизайна кода (системы) на ранней стадии;
  • стабилизация абстракции в ходе документирования;
  • сокращение числа итераций при работе над кодом реализации.

Изменение существующего кода

Дизайн зрелой системы в большей мере зависит от изменений, вносимых в процессе её развития, чем от принятых вначале решений.

Часто, при работе над кодом, разработчик думает следующим образом: “Какое минимальное изменение необходимо сделать для решения поставленной задачи?”.

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

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

Правила поддержания актуальности комментариев:

  1. Комментарии следует располагать рядом с кодом к которому они относятся.
  2. Важную информацию следует располагать там же где и код.
  3. Следует избегать дублирования.
  4. Не следует повторять в комментариях документацию из других источников (или модулей) - достаточно ссылки.
  5. Перепроверяйте, что коммитите.
  6. Если комментарии имеют более высокий уровень абстракции, чем код, то их легче поддерживать.

Согласованность (consistency)

Термин согласованность будем понимать так: похожие вещи делаются схожим способом, а непохожие - по-разному.

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

Примеры применение принципа согласованности:

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

Обеспечение согласованности:

  • документирование принятых соглашений;
  • использование соответствующих инструментов (линтеров, анализаторов кода и т.п.).

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

Меняйте принятые соглашения только в исключительных случаях (или не меняйте вовсе).

Очевидный код

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

Очевидный код требует меньше комментариев.

Лучший способ обеспечения очевидности кода - code review.

Техники написания очевидного кода:

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

Возможные причины неочевидности кода:

  • событийно-ориентированное программирование;
  • обобщённые контейнерные типы;
  • различные типы для объявления переменных и создания объектов;
  • нарушение ожиданий читающего код.

ПО должно проектироваться для простоты чтения, а не написания.

Тенденции в разработке ПО

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

ООП

Одной из ключевых составляющих ООП является наследование.

Концептуально, в механизме наследования можно выделить:

  • наследование кода;
  • наследование типа.

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

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

Если избежать наследования кода нельзя, то желательно изолировать состояние родительского класса от дочерних.

Гибкая разработка (Agile development)

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

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

Модульное тестирование

Модульные тесты играют важную роль в проектировании системы, т.к. содействуют рефакторингу.

TDD (Test-driven development)

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

Наибольшую пользу TDD приносит при исправлении ошибок - пишется тест который не проходит из-за ошибки, а затем делается исправление.

Шаблоны проектирования

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

Если собственное решение будет проще, то не стоит приспосабливать проблему к шаблону проектирования.

Проектирование для производительности

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

  • сетевые взаимодействия;
  • работа с вторичной памятью;
  • динамическое выделение памяти;
  • кэш-промахи.

Изучать производительность системы лучше всего с помощью специальных тестов - micro-benchmarks.

Дизайн, обеспечивающий высокую эффективность, оправдывает себя если:

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

При проектировании для производительности нужно:

  1. Идентифицировать несколько мест где затрачивается бОльшая часть времени работы системы, и которые можно оптимизировать.
  2. Установить базовую линию, чтобы убедиться, что производительность действительно возросла.

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

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

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