Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
void seek(long index, int mode) {/*...*/}
struct FILE console = {open, close, read, write, seek};
Если теперь предположить, что символ STDIN определен как указатель FILE* и ссылается на структуру console, тогда getchar() можно реализовать как-то так:
extern struct FILE* STDIN;
int getchar() {
return STDIN->read();
}
Иными словами, getchar() просто вызывает функцию, на которую ссылается указатель read в структуре FILE, на которую, в свою очередь, ссылается STDIN.
Этот простой трюк составляет основу полиморфизма в ОО. В C++, например, каждая виртуальная функция в классе представлена указателем в таблице виртуальных методов vtable и все вызовы виртуальных функций выполняются через эту таблицу. Конструкторы производных классов просто инициализируют таблицу vtable объекта указателями на свои версии функций.
Суть полиморфизма заключается в применении указателей на функции. Программисты использовали указатели на функции для достижения полиморфного поведения еще со времен появления архитектуры фон Неймана в конце 1940-х годов. Иными словами, парадигма ОО не принесла ничего нового.
Впрочем, это не совсем верно. Пусть полиморфизм появился раньше языков ОО, но они сделали его намного надежнее и удобнее.
Проблема явного использования указателей на функции для создания полиморфного поведения в том, что указатели на функции по своей природе опасны. Такое их применение оговаривается множеством соглашений. Вы должны помнить об этих соглашениях и инициализировать указатели. Вы должны помнить об этих соглашениях и вызывать функции посредством указателей. Если какой-то программист забудет о соглашениях, возникшую в результате ошибку будет чертовски трудно отыскать и устранить.
Языки ОО избавляют от необходимости помнить об этих соглашениях и, соответственно, устраняют опасности, связанные с этим. Поддержка полиморфизма на уровне языка делает его использование тривиально простым. Это обстоятельство открывает новые возможности, о которых программисты на C могли только мечтать. Отсюда можно заключить, что ОО накладывает ограничение на косвенную передачу управления.
Сильные стороны полиморфизма
Какими положительными чертами обладает полиморфизм? Чтобы в полной мере оценить их, рассмотрим пример программы copy. Что случится с программой, если создать новое устройство ввода/вывода? Допустим, мы решили использовать программу copy для копирования данных из устройства распознавания рукописного текста в устройство синтеза речи: что нужно изменить в программе copy, чтобы она смогла работать с новыми устройствами?
Самое интересное, что никаких изменений не требуется! В действительности нам не придется даже перекомпилировать программу copy. Почему? Потому что исходный код программы copy не зависит от исходного кода драйверов ввода/вывода. Пока драйверы реализуют пять стандартных функций, определяемых структурой FILE, программа copy сможет с успехом их использовать.
Проще говоря, устройства ввода/вывода превратились в плагины для программы copy.
Почему операционная система UNIX превратила устройства ввода/вывода в плагины? Потому что в конце 1950-х годов мы поняли, что наши программы не должны зависеть от конкретных устройств. Почему? Потому что мы успели написать массу программ, зависящих от устройств, прежде чем смогли понять, что в действительности мы хотели бы, чтобы эти программы, выполняя свою работу, могли бы использовать разные устройства.
Например, раньше часто писались программы, читавшие исходные данные из пакета перфокарт[17] и пробивавшие на перфораторе новую стопку перфокарт с результатами. Позднее наши клиенты стали передавать исходные данные не на перфокартах, а на магнитных лентах. Это было неудобно, потому что приходилось переписывать большие фрагменты первоначальных программ. Было бы намного удобнее, если бы та же программа могла работать и с перфокартами, и с магнитной лентой.
Для поддержки независимости от устройств ввода/вывода была придумана архитектура плагинов и реализована практически во всех операционных системах. Но даже после этого большинство программистов не давали распространения этой идее в своих программах, потому что использование указателей на функции было опасно.
Объектно-ориентированная парадигма позволила использовать архитектуру плагинов повсеместно.
Инверсия зависимости
Представьте, на что походило программное обеспечение до появления надежного и удобного механизма полиморфизма. В типичном дереве вызовов главная функция вызывала функции верхнего уровня, которые вызывали функции среднего уровня, в свою очередь, вызывавшие функции нижнего уровня. Однако в таком дереве вызовов зависимости исходного кода непреклонно следовали за потоком управления (рис. 5.1).
Рис. 5.1. Зависимости исходного кода следуют за потоком управления
Чтобы вызвать одну из функций верхнего уровня, функция main должна сослаться на модуль, содержащий эту функцию. В языке C для этой цели используется директива #include. В Java — инструкция import. В C# — инструкция using. В действительности любой вызывающий код был вынужден ссылаться на модуль, содержащий вызываемый код.
Эти требования предоставляли архитектору программного обеспечения несколько вариантов. Поток управления определяется поведением системы, а зависимости исходного кода определяются этим потоком управления.
Однако когда в игру включился полиморфизм, стало возможным нечто совершенно иное (рис. 5.2).
Рис. 5.2. Инверсия зависимости
На рис. 5.2 модуль верхнего уровня HL1 вызывает функцию F() из модуля среднего уровня ML1. Вызов посредством интерфейса является уловкой лишь для исходного кода. Во время выполнения интерфейсов не существует. Модуль HL1 просто вызывает F() внутри ML1[18].
Но обратите внимание, что направление зависимости в исходном коде (отношение наследования) между ML1 и интерфейсом I поменялось на противоположное по отношению к потоку управления. Этот эффект называют инверсией зависимости (dependency inversion), и он имеет далеко идущие последствия для архитекторов программного обеспечения.
Факт поддержки языками ОО надежного и удобного механизма полиморфизма означает, что любую зависимость исходного кода, где бы она ни находилась, можно инвертировать.
Теперь вернемся к дереву вызовов, изображенному на рис. 5.1, и к множеству зависимостей в его исходном коде. Любую из зависимостей в этом исходном коде можно обратить, добавив интерфейс.
При таком подходе архитекторы, работающие в системах, которые написаны на объектно-ориентированных языках, получают абсолютный контроль над направлением всех зависимостей в исходном коде. Они не ограничены только направлением потока управления. Неважно, какой модуль вызывает и какой модуль вызывается, архитектор может определить зависимость в исходном коде в любом направлении.
Какая возможность! И эту возможность открывает ОО. Собственно, это все, что дает ОО, — по крайней мере с точки зрения архитектора.
Что можно сделать, обладая этой возможностью? Можно, например, переупорядочить зависимости в исходном коде так, что база данных и пользовательский интерфейс (ПИ) в вашей системе будут зависеть от бизнес-правил (рис. 5.3), а не наоборот.
Рис. 5.3. База данных и пользовательский интерфейс зависят от бизнес-правил
Это означает, что ПИ и база данных могут быть плагинами к бизнес-правилам. То есть в исходном коде с реализацией бизнес-правил могут отсутствовать любые ссылки на ПИ или базу данных.
Как следствие, бизнес-правила, ПИ и базу данных можно скомпилировать в три разных компонента или единицы развертывания (например, jar-файлы, библиотеки DLL или файлы Gem), имеющих те же зависимости, как в исходном коде. Компонент с бизнес-правилами не будет зависеть от компонентов, реализующих ПИ и базу данных.
Как результат, появляется возможность развертывать бизнес-правила независимо от ПИ и базы данных. Изменения в ПИ или в базе данных не должны оказывать никакого влияния на бизнес-правила. То есть компоненты можно развертывать отдельно и независимо.
Проще говоря, когда реализация компонента изменится, достаточно повторно развернуть только этот компонент. Это независимость развертывания.
Если система состоит из модулей, которые можно развертывать независимо, их можно разрабатывать независимо, разными командами. Это независимость разработки.
Заключение
Что такое ОО? Существует много взглядов и ответов на этот вопрос. Однако для программного архитектора ответ очевиден: ОО дает, посредством поддержки полиморфизма, абсолютный контроль над всеми зависимостями в исходном коде. Это позволяет архитектору создать архитектуру со сменными модулями (плагинами), в которой модули верхнего уровня не зависят от модулей нижнего уровня. Низкоуровневые детали не выходят за рамки модулей плагинов, которые можно развертывать и разрабатывать независимо от модулей верхнего уровня.
Глава 6. Функциональное программирование
Многие идеи функционального программирования появились задолго до появления самого программирования. Эта парадигма в значительной мере основана на -исчислении, изобретенном Алонзо Чёрчем в 1930-х годах.
Квадраты целых чисел
Суть функционального программирования проще объяснить на примерах. С этой целью исследуем решение простой задачи: вывод квадратов первых 25 целых чисел (то есть от 0 до 24).
В языках, подобных Java, эту задачу можно решить так:
public class Squint {
public static void main(String args[]) {
for (int i=0; i<25; i++)
System.out.println(i*i);
}
}
В Clojure, функциональном языке и производном от языка Lisp, аналогичную программу можно записать так:
(println (take 25 (map (fn [x] (* x x)) (range))))
Этот код может показаться немного странным, если вы не знакомы с Lisp. Поэтому я, с вашего позволения, немного переоформлю его и добавлю несколько комментариев.
(println ;___________________ Вывести
(take 25 ;_________________ первые 25
(map (fn [x] (* x x)) ;__ квадратов
(range)))) ;___________ целых чисел
Совершенно понятно, что println, take, map и range — это функции. В языке Lisp вызов функции производится помещением ее имени в круглые скобки. Например, (range) — это вызов функции range.
Выражение (fn [x] (* x x)) — это анонимная функция, которая, в свою очередь, вызывает функцию умножения и передает ей две копии входного аргумента. Иными словами, она вычисляет квадрат заданного числа.
Взглянем на эту программу еще раз, начав с самого внутреннего вызова функции:
• функция range возвращает бесконечный список целых чисел, начиная с 0;
• этот список передается функции map, которая вызывает анонимную функцию для вычисления квадрата каждого элемента и производит бесконечный список квадратов;
• список квадратов передается функции take, которая возвращает новый список, содержащий только первые 25 элементов;
• функция println выводит этот самый список квадратов первых 25 целых чисел.
Если вас напугало упоминание бесконечных списков, не волнуйтесь. В действительности программа создаст только первые 25 элементов этих бесконечных списков. Дело в том, что новые элементы бесконечных списков не создаются, пока программа не обратится к ним.
Если все вышесказанное показалось вам запутанным и непонятным, тогда можете отложить эту книгу и прекрасно провести время, изучая функциональное программирование и язык Clojure. В этой книге я не буду рассматривать эти темы, так как не это является моей целью.
Моя цель — показать важнейшее отличие между программами на Clojure и Java. В программе на Java используется изменяемая переменная — переменная, состояние которой изменяется в ходе выполнения программы. Это переменная i — переменная цикла. В программе на Clojure, напротив, нет изменяемых переменных. В ней присутствуют инициализируемые переменные, такие как x, но они никогда не изменяются.
В результате мы пришли к удивительному утверждению: переменные в функциональных языках не изменяются.
Неизменяемость и архитектура
Почему этот аспект важен с архитектурной точки зрения? Почему архитектора должна волновать изменчивость переменных? Ответ на этот вопрос до нелепого прост: все состояния гонки (race condition), взаимоблокировки (deadlocks) и проблемы параллельного обновления обусловлены изменяемостью переменных. Если в программе нет изменяемых переменных, она никогда не окажется в состоянии гонки и никогда не столкнется с проблемами одновременного изменения. В отсутствие изменяемых блокировок программа не может попасть в состояние взаимоблокировки.
Иными словами, все проблемы, характерные для приложений с конкурирующими вычислениями, — с которыми нам приходится сталкиваться, когда требуется организовать многопоточное выполнение и задействовать вычислительную мощность нескольких процессоров, исчезают сами собой в отсутствие изменяемых переменных.
Вы, как архитектор, обязаны интересоваться проблемами конкуренции. Вы должны гарантировать надежность и устойчивость проектируемых вами систем в многопоточном и многопроцессорном окружении. И один из главных вопросов, которые вы должны задать себе: достижима ли неизменяемость на практике?
Ответ на этот вопрос таков: да, если у вас есть неограниченный объем памяти и процессор с неограниченной скоростью вычислений. В отсутствие этих бесконечных ресурсов ответ выглядит не так однозначно. Да, неизменяемость достижима, но при определенных компромиссах.
Рассмотрим некоторые из этих компромиссов.
Ограничение изменяемости
Один из самых общих компромиссов, на которые приходится идти ради неизменяемости, — деление приложения или служб внутри приложения на изменяемые и неизменяемые компоненты. Неизменяемые компоненты решают свои задачи исключительно функциональным способом, без использования изменяемых переменных. Они взаимодействуют с другими компонентами, не являющимися чисто функциональными и допускающими изменение состояний переменных (рис. 6.1).
Изменяемое состояние этих других компонентов открыто всем проблемам многопоточного выполнения, поэтому для защиты изменяемых переменных от конкурирующих обновлений и состояния гонки часто используется некоторая разновидность транзакционной памяти.
Транзакционная память интерпретирует переменные в оперативной памяти подобно тому, как база данных интерпретирует записи на диске. Она защищает переменные, применяя механизм транзакций с повторениями.
Простым примером могут служить атомы в Clojure:
(def counter (atom 0)) ; инициализировать счетчик нулем
(swap! counter inc) ; безопасно увеличить счетчик counter.
Рис. 6.1. Изменяемое состояние и транзакционная память
В этом фрагменте переменная counter определяется как атом (с помощью функции atom). Атом в языке Clojure — это особая переменная, способная изменяться при очень ограниченных условиях, соблюдение которых гарантирует функция swap!.
Функция swap!, показанная в предыдущем фрагменте, принимает два аргумента: атом, подлежащий изменению, и функцию, вычисляющую новое значение для сохранения в атоме. В данном примере атом counter получит значение, вычисленное функцией inc, которая просто увеличивает свой аргумент на единицу.
Функция swap! реализует традиционный алгоритм сравнить и присвоить. Она читает значение counter и передает его функции inc. Когда inc вернет управление, доступ к переменной counter блокируется и ее значение сравнивается со значением, переданным в inc. Если они равны, в counter записывается значение, которое вернула функция inc, и блокировка освобождается. Иначе блокировка освобождается и попытка повторяется.
Механизм атомов эффективен для простых приложений. Но, к сожалению, он не гарантирует абсолютную защищенность от конкурирующих обновлений и взаимоблокировок, когда в игру вступает несколько взаимозависимых изменяемых переменных. В таких ситуациях предпочтительнее использовать более надежные средства.
Суть в том, что правильно организованные приложения должны делиться на компоненты, имеющие и не имеющие изменяемых переменных. Такое деление обеспечивается использованием подходящих дисциплин для защиты изменяемых переменных.
Со стороны архитекторов было бы разумно как можно больше кода поместить в неизменяемые компоненты и как можно меньше — в компоненты, допускающие возможность изменения.
Регистрация событий
Проблема ограниченности объема памяти и конечной вычислительной мощности процессоров очень быстро теряет свою актуальность. В настоящее время обычными стали компьютеры с процессорами, выполняющими миллиарды инструкций в секунду, и имеющие объем оперативной памяти в несколько миллиардов байтов. Чем больше памяти, тем быстрее работает компьютер и тем меньше потребность в изменяемом состоянии.
Как простой пример, представьте банковское приложение, управляющее счетами клиентов. Оно изменяет счета, когда выполняются операции зачисления или списания средств.
Теперь вообразите, что вместо сумм на счетах мы сохраняем только информацию об операциях. Всякий раз, когда кто-то пожелает узнать баланс своего счета, мы просто выполняем все транзакции с состоянием счета от момента его открытия. Эта схема не требует изменяемых переменных.
Очевидно, такой подход кажется абсурдным. С течением времени количество транзакций растет и в какой-то момент объем вычислений, необходимый для определения баланса счета, станет недопустимо большим. Чтобы такая схема могла работать всегда, мы должны иметь неограниченный объем памяти и процессор с бесконечно высокой скоростью вычислений.
Но иногда не требуется, чтобы эта схема работала всегда. Иногда у нас достаточно памяти и вычислительной мощности, чтобы подобная схема работала в течение времени выполнения приложения.
Эта идея положена в основу технологии регистрации событий (event sourcing)[19]. Регистрация событий (event sourcing) — это стратегия, согласно которой сохраняются транзакции, а не состояние. Когда требуется получить состояние, мы просто применяем все транзакции с самого начала.
Конечно, реализуя подобную стратегию, можно использовать компромиссные решения для экономии ресурсов. Например, вычислять и сохранять состояние каждую полночь. А затем, когда потребуется получить информацию о состоянии, выполнить лишь транзакции, имевшие место после полуночи.
Теперь представьте хранилище данных, которое потребуется для поддержки этой схемы: оно должно быть о-о-очень большим. В настоящее время объемы хранилищ данных растут так быстро, что, например, триллион байтов мы уже не считаем большим объемом — то есть нам понадобится намного, намного больше.
Что особенно важно, никакая информация не удаляется из такого хранилища и не изменяется. Как следствие, от набора CRUD-операций[20] в приложениях остаются только CR. Также отсутствие операций изменения и/или удаления с хранилищем устраняет любые проблемы конкурирующих обновлений.
Обладая хранилищем достаточного объема и достаточной вычислительной мощностью, мы можем сделать свои приложения полностью неизменяемыми — и, как следствие, полностью функциональными.
Если это все еще кажется вам абсурдным, вспомните, как работают системы управления версиями исходного кода.
Заключение
Итак:
• Структурное программирование накладывает ограничение на прямую передачу управления.
• Объектно-ориентированное программирование накладывает ограничение на косвенную передачу управления.
• Функциональное программирование накладывает ограничение на присваивание.
Каждая из этих парадигм что-то отнимает у нас. Каждая ограничивает подходы к написанию исходного кода. Ни одна не добавляет новых возможностей.
Фактически последние полвека мы учились тому, как не надо делать.
Осознав это, мы должны признать неприятный факт: разработка программного обеспечения не является быстро развивающейся индустрией. Правила остаются теми же, какими они были в 1946 году, когда Алан Тьюринг написал первый код, который мог выполнить электронный компьютер. Инструменты изменились, аппаратура изменилась, но суть программного обеспечения осталась прежней.
Программное обеспечение — материал для компьютерных программ — состоит из последовательностей, выбора, итераций и косвенности. Ни больше ни меньше.
Часть III. Принципы дизайна
Хорошая программная система начинается с чистого кода. С одной стороны, если здание строить из плохих кирпичей, его архитектура не имеет большого значения. С другой стороны, плохие кирпичи можно перемешать с хорошими. Именно на этом основаны принципы SOLID.
Принципы SOLID определяют, как объединять функции и структуры данных в классы и как эти классы должны сочетаться друг с другом. Использование слова «класс» не означает, что эти принципы применимы только к объектно-ориентированному программному коду. В данном случае «класс» означает лишь инструмент объединения функций и данных в группы. Любая программная система имеет такие объединения, как бы они ни назывались, «класс» или как-то еще. Принципы SOLID применяются к этим объединениям.
Цель принципов — создать программные структуры среднего уровня, которые:
• терпимы к изменениям;
• просты и понятны;
• образуют основу для компонентов, которые могут использоваться во многих программных системах.
Термин «средний уровень» отражает тот факт, что эти принципы применяются программистами на уровне модулей. Они применяются на уровне, лежащем непосредственно над уровнем программного кода, и помогают определять программные структуры, используемые в модулях и компонентах.
Как из хороших кирпичей можно сложить никуда не годную стену, так из хорошо продуманных компонентов среднего уровня можно создать никуда не годную систему. Поэтому сразу после знакомства с принципами SOLID мы перейдем к их аналогам в мире компонентов, а затем к высокоуровневым принципам создания архитектур.
Принципы SOLID имеют долгую историю. Я начал собирать их в конце 1980-х годов, обсуждая принципы проектирования программного обеспечения с другими пользователями USENET (ранняя разновидность Facebook). На протяжении многих лет принципы смещались и изменялись. Некоторые исчезали. Другие объединялись. А какие-то добавлялись. В окончательном виде они были сформулированы в начале 2000-х годов, хотя и в другом порядке, чем я представлял.
В 2004 году или около того Майкл Физерс прислал мне электронное письмо, в котором сообщил, что если переупорядочить мои принципы, из их первых букв можно составить слово SOLID[21] — так появились принципы SOLID.
Последующие главы подробнее описывают каждый принцип, а пока познакомьтесь с краткой аннотацией:
• SRP: Single Responsibility Principle — принцип единственной ответственности.
Действительное следствие закона Конвея: лучшей является такая структура программной системы, которая формируется в основном под влиянием социальной структуры организации, использующей эту систему, поэтому каждый программный модуль имеет одну и только одну причину для изменения.
• OCP: Open-Closed Principle — принцип открытости/закрытости.
Этот принцип был сформулирован Бертраном Мейером в 1980-х годах. Суть его сводится к следующему: простая для изменения система должна предусматривать простую возможность изменения ее поведения добавлением нового, но не изменением существующего кода.
• LSP: Liskov Substitution Principle — принцип подстановки Барбары Лисков.
Определение подтипов Барбары Лисков известно с 1988 года. В двух словах, этот принцип утверждает, что для создания программных систем из взаимозаменяемых частей эти части должны соответствовать контракту, который позволяет заменять эти части друг другом.
• ISP: Interface Segregation Principle — принцип разделения интерфейсов.
Этот принцип призывает разработчиков программного обеспечения избегать зависимости от всего, что не используется.
• DIP: Dependency Inversion Principle — принцип инверсии зависимости.
Код, реализующий высокоуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали. Напротив, детали должны зависеть от политики.
Эти принципы детально описаны во множестве публикаций[22]. Последующие главы освещают влияние этих принципов на проектирование архитектур, не повторяя подробное обсуждение из этих публикаций. Если вы еще не знакомы с перечисленными принципами, обсуждения, следующего далее, будет недостаточно, чтобы понять их во всех подробностях, поэтому я рекомендую обратиться к документам, перечисленным в сноске.
Глава 7. Принцип единственной ответственности
Из всех принципов SOLID наиболее трудно понимаемым является принцип единственной ответственности (Single Responsibility Principle, SRP). Это, вероятно, обусловлено выбором названия, недостаточно точно соответствующего сути. Услышав это название, многие программисты решают: оно означает, что каждый модуль должен отвечать за что-то одно.
Самое интересное, что такой принцип действительно существует. Он гласит: функция должна делать что-то одно и только одно. Этот принцип мы используем, когда делим большие функции на меньшие, то есть на более низком уровне. Но он не является одним из принципов SOLID — это не принцип единственной ответственности.
Традиционно принцип единственной ответственности описывался так:
Модуль должен иметь одну и только одну причину для изменения.
Программное обеспечение изменяется для удовлетворения нужд пользователей и заинтересованных лиц. Пользователи и заинтересованные лица как раз и есть та самая «причина для изменения», о которой говорит принцип. Фактически принцип можно перефразировать так:
Модуль должен отвечать за одного и только за одного пользователя или заинтересованное лицо.
К сожалению, слова «пользователь» и «заинтересованное лицо» не совсем правильно использовать здесь, потому что одного и того же изменения системы могут желать несколько пользователей или заинтересованных лиц. Более правильным выглядит понятие группы, состоящей из одного или нескольких лиц, желающих данного изменения. Мы будем называть такие группы акторами (actor).
Соответственно, окончательная версия принципа единственной ответственности выглядит так:
Модуль должен отвечать за одного и только за одного актора.
Теперь определим, что означает слово «модуль». Самое простое определение — файл с исходным кодом. В большинстве случаев это определение можно принять. Однако некоторые языки среды разработки не используют исходные файлы для хранения кода. В таких случаях модуль — это просто связный набор функций и структур данных.
Слово «связный» подразумевает принцип единственной ответственности. Связность — это сила, которая связывает код, ответственный за единственного актора.
Пожалуй, лучший способ понять суть этого принципа — исследовать признаки его нарушения.
Признак 1: непреднамеренное дублирование
Мой любимый пример — класс Employee из приложения платежной ведомости. Он имеет три метода: calculatePay(), reportHours() и save() (рис. 7.1).
Рис. 7.1. Класс Employee
Этот класс нарушает принцип единственной ответственности, потому что три его метода отвечают за три разных актора.
• Реализация метода calculatePay() определяется бухгалтерией.
• Реализация метода reportHours() определяется и используется отделом по работе с персоналом.
• Реализация метода save() определяется администраторами баз данных.
Поместив исходный код этих трех методов в общий класс Employee, разработчики объединили перечисленных акторов. В результате такого объединения действия сотрудников бухгалтерии могут затронуть что-то, что требуется сотрудникам отдела по работе с персоналом.
Например, представьте, что функции calculatePay() и reportHours() используют общий алгоритм расчета не сверхурочных часов. Представьте также, что разработчики, старающиеся не дублировать код, поместили реализацию этого алгоритма в функцию с именем regularHours() (рис. 7.2).
Рис. 7.2. Общий алгоритм
Теперь вообразите, что сотрудники бухгалтерии решили немного изменить алгоритм расчета не сверхурочных часов. Сотрудники отдела по работе с персоналом были бы против такого изменения, потому что вычисленное время они используют для других целей.
Разработчик, которому было поручено внести изменение, заметил, что функция regularHours() вызывается методом calculatePay(), но, к сожалению, не заметил, что она также вызывается методом reportHours().
Разработчик внес требуемые изменения и тщательно протестировал результат. Сотрудники бухгалтерии проверили и подтвердили, что обновленная функция действует в соответствии с их пожеланиями, после чего измененная версия системы была развернута.
Разумеется, сотрудники отдела по работе с персоналом не знали о произошедшем и продолжали использовать отчеты, генерируемые функцией reportHours(), но теперь содержащие неправильные цифры. В какой-то момент проблема вскрылась, и сотрудники отдела по работе с персоналом разом побледнели от ужаса, потому что ошибочные данные обошлись их бюджету в несколько миллионов долларов.
Все мы видели нечто подобное. Эти проблемы возникают из-за того, что мы вводим в работу код, от которого зависят разные акторы. Принцип единственной ответственности требует разделять код, от которого зависят разные акторы.
Признак 2: слияния
Слияния — обычное дело для исходных файлов с большим количеством разных методов. Эта ситуация особенно вероятна, если эти методы отвечают за разных акторов.
Например, представим, что коллектив администраторов баз данных решил внести простое исправление в схему таблицы Employee. Представим также, что сотрудники отдела по работе с персоналом пожелали немного изменить формат отчета, возвращаемого функцией reportHours().
Два разных разработчика, возможно, из двух разных команд, извлекли класс Employee из репозитория и внесли изменения. К сожалению, их изменения оказались несовместимыми. В результате потребовалось выполнить слияние.
Я думаю, мне не нужно рассказывать вам, что всякое слияние сопряжено с некоторым риском. Современные инструменты довольно совершенны, но никакой инструмент не сможет правильно обработать все возможные варианты слияния. В итоге риск есть всегда.
В нашем примере процедура слияния поставила под удар администраторов баз данных и отдел по работе с персоналом. Вполне возможно, что риску подверглась также бухгалтерия.
Существует много других признаков, которые мы могли бы рассмотреть, но все они сводятся к изменению одного и того же исходного кода разными людьми по разным причинам.
И снова, исправить эту проблему можно, разделив код, предназначенный для обслуживания разных акторов.
Решения
Существует много решений этой проблемы. Но каждое связано с перемещением функций в разные классы.
Наиболее очевидным, пожалуй, является решение, связанное с отделением данных от функций. Три класса, как показано на рис. 7.3, используют общие данные EmployeeData — простую структуру без методов. Каждый класс включает только исходный код для конкретной функции. Эти три класса никак не зависят друг от друга. То есть любое непреднамеренное дублирование исключено.
Недостаток такого решения — разработчик теперь должен создавать экземпляры трех классов и следить за ними. Эта проблема часто решается применением шаблона проектирования «Фасад» (Facade), как показано на рис. 7.4.
Класс EmployeeFacade содержит очень немного кода и отвечает за создание экземпляров трех классов и делегирование вызовов методов.
Рис. 7.3. Три класса, не зависящих друг от друга
Рис. 7.4. Шаблон «Фасад»
Некоторые разработчики предпочитают держать наиболее важные бизнес-правила как можно ближе к данным. Это можно сделать, сохранив важные методы в оригинальном классе Employee, и затем использовать этот класс как фасад для низкоуровневых функций (рис. 7.5).
Рис. 7.5. Наиболее важные методы остаются в оригинальном классе Employee и используются как фасад для низкоуровневых функций
Вы можете возразить против такого решения на том основании, что каждый класс содержит только одну функцию. Однако в реальности такое едва ли возможно. Количество функций, необходимых для расчета зарплаты, создания отчета или сохранения данных, наверняка будет больше в любом случае. Каждый из таких классов может иметь также много приватных методов.
Каждый такой класс, содержащий подобное семейство методов, образует свою область видимости. Вне этой области никто не знает о существовании приватных членов семейства.
Заключение
Принцип единственной ответственности (Single Responsibility Principle; SRP) касается функций и классов, но он проявляется в разных формах на еще двух более высоких уровнях. На уровне компонентов он превращается в принцип согласованного изменения (Common Closure Principle; CCP), а на архитектурном уровне — в принцип оси изменения (Axis of Change), отвечающий за создание архитектурных границ. Все эти идеи мы обсудим в последующих главах.
Глава 8. Принцип открытости/закрытости
Принцип открытости/закрытости (Open-Closed Principle; OCP) был сформулирован Бертраном Мейером в 1988 году.[23] Он гласит:
Программные сущности должны быть открыты для расширения и закрыты для изменения.
Иными словами, должна иметься возможность расширять поведение программных сущностей без их изменения.
Это одна из основных причин, почему мы изучаем архитектуру программного обеспечения. Очевидно, если простое расширение требований ведет к значительным изменениям в программном обеспечении, значит, архитекторы этой программной системы потерпели сокрушительное фиаско.
Большинство студентов, изучающих проектирование программного обеспечения, признают принцип OCP как руководство по проектированию классов и модулей. Но на уровне архитектурных компонентов этот принцип приобретает еще большую значимость.
Увидеть это поможет простой мысленный эксперимент.
Мысленный эксперимент
Представьте, что у нас есть финансовая сводка. Содержимое страницы прокручивается, и отрицательные значения выводятся красным цветом.
Теперь допустим, что заинтересованные лица попросили нас представить ту же информацию в виде отчета, распечатанного на черно-белом принтере. Отчет должен быть разбит на страницы, включать соответствующие верхний и нижний колонтитулы на каждой странице и колонку меток. Отрицательные значения должны заключаться в круглые скобки.
Очевидно, что для этого придется написать новый код. Но как много старого кода придется изменить?
В программном обеспечении с хорошо проработанной архитектурой таких изменений должно быть очень немного. В идеале их вообще не должно быть.
Как? Правильно разделяя сущности, которые изменяются по разным причинам (принцип единственной ответственности), и затем правильно организуя зависимости между этими сущностями (принцип инверсии зависимостей).
Применяя принцип единственной ответственности, можно прийти к потоку данных, изображенному на рис. 8.1. Некоторая процедура анализирует финансовые данные и производит данные для отчета, которые затем форматируются двумя процедурами формирования отчетов.
Рис. 8.1. Результат применения принципа единственной ответственности