Томаш Фиялковский — блог о программировании

Бенуа Б. Мандельброт называл себя «фракталистом» и известен вкладом в геометрию фракталов: он ввёл термин «фрактал» и развил теорию «шероховатости и самоподобия» в природе. В программировании модульность критически важна для гибкости, масштабируемости и читаемости кода. Мне приходилось участвовать во многих обсуждениях об идеальном размере модуля. Эти дискуссии часто поляризованы: от «лучше иметь несколько больших модулей» до «чем меньше модули, тем лучше». Истина, как обычно, где-то посередине. В этой статье я покажу, как её найти.

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

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

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

Простой пример: вызов createUser() может быть длиннее и сложнее, чем непосредственное new User(). Преимущества глубоких модулей иногда подводят к выводу: надо стремиться делать модули максимально глубокими. Но рассмотрим операцию извлечения метода или извлечения класса. Extract method создаёт новый метод — новый модуль с собственной реализацией и интерфейсом. Общая реализация до и после разбиения остаётся примерно постоянной: одна большая реализация превращается в две маленькие (A1 ≈ A2 + B). Однако суммарный интерфейс становится больше: IA < IA + IB. Это означает, что извлечение метода почти всегда увеличивает суммарную сложность проекта. Исключение — когда извлечение устраняет дублирование и тем самым уменьшает объём реализации.

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

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

Другой момент — лимит несвязанных концепций. Если внутри метода семь переменных с неинформативными именами, для многих это предел эффективной работы. Хорошие имена, стандарты и соглашения снимают нагрузку с кратковременной памяти, потому что имена вызывают ассоциации в долгосрочной памяти и показывают взаимоотношения между элементами. Но при всех ухищрениях человеческие когнитивные ограничения реальны; лучше не превышать порог примерно в семь концепций в одном модуле. Однако и здесь кроется крайность: если упор на упрощение каждого отдельного модуля, можно породить миллионы микро-модулей — тривиальных и очень мелких. Их бесчисленность приводит к чрезмерной суммарной сложности.

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

Это правило — хорошая отправная точка, вокруг которого применяются другие практики проектирования: согласованность уровней абстракции внутри модуля, согласованность по направлению изменений и т. п. Фракталы — геометрические структуры с самоподобием на разных масштабах. Аналогия с деревом, нарисованным ребёнком, наглядна: маленький ребёнок может изобразить дерево как зелёный овал на коричневом прямоугольнике, тогда как реальная структура дерева сложнее: крупная ветвь состоит из меньших ветвей, каждая из которых похожа на большую, и так далее; даже жилки листа повторяют ветвление.

То же применимо к модулям в софте: первоначальный взгляд может быть слишком упрощён, как детский рисунок, а настоящая модульность раскрывается фрактально. Мы начинаем с организации, делим её на подсистемы или поддомены: «управление продуктом», «обработка заказов», «поддержка клиентов» и т. п. Внутри них возникают сервисы — «обработка платежей», «корзина», и далее компоненты для разных платёжных методов, учёта транзакций, аналитики. В зависимости от архитектуры и требований каждый компонент может быть отдельным микросервисом, JAR-ом или просто пакетом. Можно углубляться дальше до классов и методов, которые вызывают другие методы, и так далее.

Поэтому нельзя назначить абсолютный размер «идеального модуля»: модульность многослойна, и на каждом уровне нужно поддерживать баланс глубины и сложности, ограниченный когнитивными возможностями человека. Глубокие модули и фрактальная модульность важны, но это только отправная точка для хорошего дизайна. Код должен оставаться понятным даже для уставшего программиста. Блог «Programming Chi» (или «Qi») посвящён скромным идеям, багам и неудачам, с которыми автор сталкивается ежедневно. К счастью, наряду с неудачами есть и успехи, элегантные решения, о которых тоже стоит писать.