Модульное и компонентное программирование: единица композиции крупнее функции
Любая парадигма трека до этого отвечала на вопрос «как выразить вычисление»: шагами, объектами, функциями, правилами, событиями, состояниями, массивами. Модульная парадигма отвечает на другой вопрос — как разрезать программу на части, каждую из которых можно понять, заменить и выкатить, не читая остальные.
Это звучит как организационная деталь, а не как парадигма. Но ограничение здесь ровно такое же по природе, как «не мутировать» или «не общаться через память»: имена не глобальны. Модуль показывает наружу интерфейс и прячет решение. Вы теряете право дотянуться до чужого поля напрямую — и получаете право поменять это поле, никого не спросив.
Критерий Парнаса: резать по решениям, а не по шагам
Каноническая работа — Дэвид Парнас, On the Criteria To Be Used in Decomposing Systems into Modules (1972). Парнас взял одну задачу (индекс KWIC) и решил её двумя способами.
- Декомпозиция по шагам обработки: модуль ввода, модуль сортировки, модуль вывода — то есть по блок-схеме алгоритма.
- Декомпозиция по скрываемым решениям: каждый модуль прячет одно проектное решение — способ хранения строк, способ сравнения, способ представления индекса.
Обе программы работают одинаково. Разница проявляется при изменении: «хранить строки не в массиве, а в файле» в первой версии трогает все модули, во второй — один. Отсюда формулировка, которая старше почти всего, чем вы пользуетесь, и до сих пор верна:
Модуль — это не «кусок кода, который что-то делает». Модуль — это решение, которое может измениться, спрятанное так, чтобы его изменение никого не задело.
Практический тест на качество границы очень простой и его можно применить сегодня к своему репозиторию: возьмите пять последних задач из трекера и посмотрите, сколько модулей пришлось тронуть в каждой. Если типичная задача трогает один-два — границы проведены по решениям. Если пять-семь — они проведены по шагам, и вы платите за это на каждом релизе. Связанные метрики связности и сцепления разобраны в статье Связность и зацепление.
Что модульная парадигма отнимает и что даёт
| Отнимает | Даёт взамен |
|---|---|
| Доступ к внутренностям соседа | Право менять внутренности, не согласовывая ни с кем |
| Глобальное пространство имён | Локальные имена, отсутствие коллизий, понятную зону поиска |
| Возможность «просто вызвать» что угодно откуда угодно | Явный граф зависимостей, который можно проверить машиной |
| Свободу собирать всё сразу | Раздельную компиляцию, инкрементальную сборку, независимое тестирование |
| Единый общий релиз | Независимую поставку и версионирование частей |
Немного истории: одна идея, четыре воплощения
Три способа абстрагировать зависимость
Практическая сердцевина парадигмы — вопрос, который возникает в любом языке: как модуль объявляет, что ему нужно, не называя конкретную реализацию. Есть ровно три механики, и разница между ними куда важнее синтаксиса.
1. Интерфейс, объявленный на типе (Java, C#, номинальная типизация)
Тип обязан заранее сказать implements Comparable. Плюс — намерение явно, компилятор проверяет. Минус — чужой тип нельзя дооснастить: если библиотека не объявила нужный интерфейс, вам остаётся обёртка-адаптер.
2. Структурное соответствие (Go, TypeScript, typing.Protocol в Python)
Тип подходит под интерфейс, если у него есть нужные методы. Объявлять ничего не надо, и интерфейс можно определить на стороне потребителя — это идиома Go «объявляй интерфейс там, где используешь».
// Интерфейс живёт в пакете-потребителе и содержит ровно то, что нужно потребителю.
type Storage interface {
Get(ctx context.Context, key string) ([]byte, error)
}
// Пакет с реализацией не знает про интерфейс вообще: соответствие структурное.
func Serve(s Storage) error { /* ... */ return nil }
Цена — потеря намерения: совпадение сигнатур не означает совпадения семантики, и компилятор не поймает случайное соответствие.
3. Тайпклассы и трейты (Haskell, Rust, Scala)
Реализация объявляется отдельно и от типа, и от интерфейса: impl Display for MyType. Это снимает главное ограничение номинальных интерфейсов — дооснащать можно и чужие типы. Компилятор при вызове подставляет «словарь методов»; в Rust это делается мономорфизацией, в Haskell — передачей словаря. Механика и её стоимость подробно разобраны в статье про обобщённое программирование.
Свобода немедленно порождает вопрос: что если два пакета объявят разные реализации одного трейта для одного типа? Ответ называется когерентностью, а её механизм — «правилом сироты»: реализовать трейт можно, только если ваш крейт владеет либо трейтом, либо типом.
use std::fmt;
// Нельзя: и Display, и Vec<T> — чужие. Это и есть orphan rule.
// impl fmt::Display for Vec<String> { ... }
// Можно: обёртка — наш тип, значит impl легален.
struct Csv(Vec<String>);
impl fmt::Display for Csv {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.join(","))
}
}
Правило выглядит бюрократией ровно до первого случая, когда две библиотеки в графе зависимостей объявили несовместимые реализации для одного типа: без когерентности поведение программы начинает зависеть от порядка компоновки. Подробности механики трейтов — в типах и трейтах Rust.
4. Функторы: модуль как аргумент
Самая недооценённая механика. В Standard ML и OCaml модуль — полноценное значение уровня сборки, а функтор — функция из модулей в модули: он принимает модуль, удовлетворяющий сигнатуре, и возвращает новый модуль.
(* Сигнатура: что требуется от параметра. *)
module type ORDERED = sig
type t
val compare : t -> t -> int
end
(* Функтор: модуль, параметризованный модулем. *)
module MakeSet (Ord : ORDERED) = struct
type elt = Ord.t
type t = elt list (* решение о представлении спрятано *)
let empty = []
let rec add x = function
| [] -> [x]
| h :: tl as l ->
let c = Ord.compare x h in
if c = 0 then l
else if c < 0 then x :: l
else h :: add x tl
end
(* Применение — на этапе сборки, стоимости в рантайме нет. *)
module IntSet = MakeSet (struct
type t = int
let compare = compare
end)
Это буквально внедрение зависимости, только единица подстановки — модуль целиком, а проверку совместимости делает компилятор. В языках без функторов ту же роль играют дженерики с ограничениями и конфигурация контейнера внедрения зависимостей — см. Внедрение зависимостей.
| Механика | Дооснастить чужой тип | Проверка намерения | Стоимость в рантайме |
|---|---|---|---|
| Номинальный интерфейс | нет, только адаптер | явная | динамическая диспетчеризация |
| Структурный интерфейс | да, автоматически | отсутствует | динамическая диспетчеризация |
| Тайпкласс или трейт | да, при когерентности | явная | мономорфизация или словарь |
| Функтор | да, целым модулем | явная | нулевая, подстановка при сборке |
Граф зависимостей — это архитектура
Как только модули появились, у программы появился граф. И почти все известные болезни больших кодовых баз описываются свойствами этого графа.
HTTP, gRPC, CLI"] --> APP["application
сценарии"] APP --> DOM["domain
правила, без зависимостей"] APP --> PORTS["ports
интерфейсы репозиториев"] INFRA["infrastructure
Postgres, Kafka, S3"] --> PORTS INFRA --> DOM MAIN["composition root
сборка графа"] --> API MAIN --> INFRA DOM -. "запрещено: домен не знает про инфраструктуру" .-> INFRA APP -. "запрещено: сценарий не знает про драйверы" .-> INFRA
Правила, которые стоит закрепить машинно, а не в вики:
- Никаких циклов. Цикл между модулями означает, что это один модуль, разрезанный по ошибке. Go и JPMS запрещают циклы на уровне языка; в остальных языках проверяется линтером.
- Направление зависимостей задано слоями. Домен не знает про инфраструктуру; зависимость разворачивается интерфейсом (порт) — это и есть инверсия зависимостей на уровне модулей, база гексагональной архитектуры (см. слои, гексагон, чистая архитектура).
- Публичная поверхность минимальна. Всё, что не обязано быть публичным, публичным быть не должно:
internal/в Go,module-info.javaв JPMS,pub(crate)в Rust,__all__и приватные подпакеты в Python,exportsв package.json. - Правила проверяются в CI. ArchUnit (JVM), import-linter (Python), dependency-cruiser и eslint-plugin-boundaries (JS/TS),
go list -deps,cargo-deny. Одна проверка в пайплайне заменяет десять напоминаний на ревью.
Отдельная выгода, о которой вспоминают только после того, как сборка стала занимать двадцать минут: граф модулей определяет стоимость сборки и тестов. Правка в модуле, от которого зависят все, пересобирает всё; правка в листе — только лист. Модульная структура — это не только читаемость, это ещё и время цикла обратной связи.
Компонент: модуль, который поставляется отдельно
Компонентное программирование добавляет к модулю одно свойство — независимую поставку. Классическое определение дал Клеменс Шиперски в книге Component Software: компонент — это единица независимого развёртывания, пригодная к композиции третьей стороной и не имеющая наблюдаемого постоянного состояния.
Три следствия, с которыми сталкивается любой, кто публикует библиотеку:
Версия становится частью контракта. Семантическое версионирование (semver.org) — это обещание: мажор ломает, минор добавляет, патч чинит. Обещание, которое дорого держать, но ещё дороже нарушать.
Появляется diamond dependency. Два ваших зависимых пакета требуют разные мажорные версии третьего. Экосистемы решают это по-разному, и способ решения определяет ощущения от языка:
| Экосистема | Как решает конфликт версий |
|---|---|
| npm | вложенные node_modules — несколько версий сосуществуют |
| Cargo | несколько semver-совместимых версий в графе, типы из разных версий несовместимы |
| Go modules | minimal version selection — одна версия на мажор, мажор кодируется в пути импорта |
| Maven | «ближайшее определение выигрывает» — источник самых загадочных ошибок в JVM |
Совместимость бывает двух видов. API-совместимость (компилируется) и ABI-совместимость (линкуется и работает без перекомпиляции). Для динамических библиотек и плагинов вторая важнее первой и нарушается легче: добавление поля в структуру меняет её размер.
Расширяемость через плагины — это компонентная парадигма в самой чистой форме: хост определяет контракт, третьи стороны поставляют реализации, ядро о них не знает. Разбор устройства таких систем — в статье Плагины и расширяемость.
Где проводить границу: практические критерии
Теория говорит «прячьте решение». На практике полезны четыре проверяемых критерия.
- Единица изменения. Если две части всегда меняются вместе — это один модуль. Если независимо — два.
- Единица понимания. Модуль должен объясняться абзацем без слов «и ещё». Если объяснение начинается с «ну, там на самом деле два разных…» — граница проходит внутри.
- Единица владения. Модуль, который правят пять команд, порождает конфликты и согласования; либо один владелец, либо разделение.
- Единица тестирования. Модуль, для тестирования которого нужно поднять полсистемы, границей не является.
Классические антипаттерны нарезки:
- Слои вместо фич. Пакеты
controllers/,services/,repositories/означают, что любая фича размазана по трём модулям, а изменение всегда трогает три. Нарезка по фичам (orders/,billing/,catalog/) даёт обратное свойство. - Модуль
utilsилиcommon. У него нет скрываемого решения, поэтому от него зависят все — и он становится точкой глобальной связности. Лечение: разнести содержимое по владельцам либо сделать несколько узких модулей с внятными именами. - Публичное «на всякий случай». Всё, что стало публичным, немедленно кем-то используется и превращается в контракт.
Модульный монолит и микросервисы: тот же вопрос, другая цена шва
Микросервис — это модуль, у которого граница совпадает с сетевым вызовом и отдельным процессом. Это даёт независимое развёртывание и масштабирование, но платите вы за это дорого: сериализация, частичные отказы, отсутствие атомарности между модулями, распределённая отладка.
Критично важный вывод, который стоит любых архитектурных совещаний: сеть не делает модуль модульным. Если два сервиса ходят в одну базу и меняются только вместе, вы получили распределённый монолит — все издержки распределённости без выгод модульности. Правильный порядок действий — сначала провести границу внутри процесса (модульный монолит), убедиться, что она держится, и только потом при необходимости разносить по процессам. Подробный разбор — в статьях Монолит и модульный монолит и Микросервисы; стратегический взгляд на границы предметных областей — в ограниченных контекстах DDD.
| Свойство | Модуль в процессе | Компонент-плагин | Микросервис |
|---|---|---|---|
| Стоимость вызова | наносекунды | наносекунды | миллисекунды плюс отказы |
| Независимый релиз | нет | частично | да |
| Атомарность с соседом | да, одна транзакция | да | нет, нужны саги |
| Изоляция отказа | нет | частичная | да |
| Стоимость ошибки в границе | рефакторинг | версия API | миграция данных и контрактов |
Типичные ошибки
- Разрез по шагам обработки, а не по решениям. Прямое нарушение критерия Парнаса; проявляется тем, что каждая задача трогает все модули.
- Циклы между модулями. Признак неверной границы; сначала запретите их в CI, потом чините.
- Протекающий интерфейс. Метод, возвращающий внутренний тип (курсор ORM, буфер, соединение), делает внутренности частью контракта.
- Модуль без владельца решения. Если модуль ничего не прячет, он не модуль, а папка.
- Версия без семантики. Мажор, который «просто следующий по порядку», обесценивает контракт: обновление становится лотереей.
- Плагин-API поверх внутренних структур. Первое же изменение внутри ломает всех потребителей; API расширения должен быть спроектирован отдельно от реализации.
- Модуль, который тестируется только целиком с системой. Формальная граница есть, фактической нет.
- Микросервис как способ навести порядок в коде. Порядок наводится границей, а не сетью между беспорядочными частями.
Как это соотносится с остальным треком
| Парадигма | Отношение |
|---|---|
| ООП | Класс прячет данные, модуль прячет решение; масштаб разный, идея инкапсуляции общая |
| Обобщённое программирование | Дженерики и тайпклассы — механика подстановки, на которой строятся параметризованные модули |
| Функциональная | Функтор в ML — прямое продолжение идеи «функция как значение», поднятой на уровень модулей |
| Аспектная | Аспекты пересекают границы модулей — именно поэтому они и опасны, и полезны |
| Данные | Схема данных — это интерфейс модуля, живущий дольше кода; версионируется по тем же правилам |
| Выбор и смешение | Модуль — та самая «единица выбора парадигмы»: внутри одного модуля парадигма должна быть одна |
Мини-итог
- Модульная парадигма меняет свободу «дотянуться до чего угодно» на возможность менять внутренности, никого не спрашивая.
- Критерий Парнаса: резать по скрываемым решениям, а не по шагам обработки. Проверка — сколько модулей трогает типичная задача.
- Абстрагировать зависимость можно четырьмя способами: номинальный интерфейс, структурное соответствие, тайпкласс или трейт, функтор. Различаются они возможностью дооснастить чужой тип и местом проверки.
- Правило сироты и когерентность — не бюрократия, а защита от зависимости поведения программы от порядка компоновки.
- Граф модулей — это архитектура: запрет циклов, направление зависимостей, минимальная публичная поверхность, проверка правил в CI.
- Компонент = модуль плюс независимая поставка, а значит плюс версия, разрешение конфликтов и ABI-совместимость.
- Микросервис — модуль с сетевым швом. Сначала граница, потом сеть; обратный порядок даёт распределённый монолит.
Источники
- David Parnas. On the Criteria To Be Used in Decomposing Systems into Modules, CACM, 1972.
- Philip Wadler, Stephen Blott. How to make ad-hoc polymorphism less ad hoc, POPL, 1989 — статья, породившая тайпклассы.
- David MacQueen. Modules for Standard ML, 1984 — модули и функторы.
- Clemens Szyperski. Component Software: Beyond Object-Oriented Programming — определение компонента и его следствия.
- Rust: правило сироты и когерентность.
- Go Modules Reference — minimal version selection и мажор в пути импорта.
- JEP 261: Module System — модульная система JVM и её ограничения.
- Semantic Versioning и Go: Package names — практика именования и версионирования.
Что дальше
Мы прошли парадигмы, которые устоялись: у каждой есть учебники, инструменты и десятилетия практики. Осталось посмотреть на границу, где вычисление перестаёт быть детерминированным значением: Парадигмы за горизонтом — вероятностное, дифференцируемое и квантовое программирование.