Парадигмы разработки Модульное и компонентное программирование: единица композиции крупнее функции
0%

Модульное и компонентное программирование: единица композиции крупнее функции

Модульное и компонентное программирование: единица композиции крупнее функции

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

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

Критерий Парнаса: резать по решениям, а не по шагам

Каноническая работа — Дэвид Парнас, 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)

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

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

Граф зависимостей — это архитектура

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

Правила, которые стоит закрепить машинно, а не в вики:

  1. Никаких циклов. Цикл между модулями означает, что это один модуль, разрезанный по ошибке. Go и JPMS запрещают циклы на уровне языка; в остальных языках проверяется линтером.
  2. Направление зависимостей задано слоями. Домен не знает про инфраструктуру; зависимость разворачивается интерфейсом (порт) — это и есть инверсия зависимостей на уровне модулей, база гексагональной архитектуры (см. слои, гексагон, чистая архитектура).
  3. Публичная поверхность минимальна. Всё, что не обязано быть публичным, публичным быть не должно: internal/ в Go, module-info.java в JPMS, pub(crate) в Rust, __all__ и приватные подпакеты в Python, exports в package.json.
  4. Правила проверяются в 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-совместимость (линкуется и работает без перекомпиляции). Для динамических библиотек и плагинов вторая важнее первой и нарушается легче: добавление поля в структуру меняет её размер.

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

Где проводить границу: практические критерии

Теория говорит «прячьте решение». На практике полезны четыре проверяемых критерия.

  1. Единица изменения. Если две части всегда меняются вместе — это один модуль. Если независимо — два.
  2. Единица понимания. Модуль должен объясняться абзацем без слов «и ещё». Если объяснение начинается с «ну, там на самом деле два разных…» — граница проходит внутри.
  3. Единица владения. Модуль, который правят пять команд, порождает конфликты и согласования; либо один владелец, либо разделение.
  4. Единица тестирования. Модуль, для тестирования которого нужно поднять полсистемы, границей не является.

Классические антипаттерны нарезки:

  • Слои вместо фич. Пакеты controllers/, services/, repositories/ означают, что любая фича размазана по трём модулям, а изменение всегда трогает три. Нарезка по фичам (orders/, billing/, catalog/) даёт обратное свойство.
  • Модуль utils или common. У него нет скрываемого решения, поэтому от него зависят все — и он становится точкой глобальной связности. Лечение: разнести содержимое по владельцам либо сделать несколько узких модулей с внятными именами.
  • Публичное «на всякий случай». Всё, что стало публичным, немедленно кем-то используется и превращается в контракт.

Модульный монолит и микросервисы: тот же вопрос, другая цена шва

Микросервис — это модуль, у которого граница совпадает с сетевым вызовом и отдельным процессом. Это даёт независимое развёртывание и масштабирование, но платите вы за это дорого: сериализация, частичные отказы, отсутствие атомарности между модулями, распределённая отладка.

Критично важный вывод, который стоит любых архитектурных совещаний: сеть не делает модуль модульным. Если два сервиса ходят в одну базу и меняются только вместе, вы получили распределённый монолит — все издержки распределённости без выгод модульности. Правильный порядок действий — сначала провести границу внутри процесса (модульный монолит), убедиться, что она держится, и только потом при необходимости разносить по процессам. Подробный разбор — в статьях Монолит и модульный монолит и Микросервисы; стратегический взгляд на границы предметных областей — в ограниченных контекстах DDD.

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

Типичные ошибки

  1. Разрез по шагам обработки, а не по решениям. Прямое нарушение критерия Парнаса; проявляется тем, что каждая задача трогает все модули.
  2. Циклы между модулями. Признак неверной границы; сначала запретите их в CI, потом чините.
  3. Протекающий интерфейс. Метод, возвращающий внутренний тип (курсор ORM, буфер, соединение), делает внутренности частью контракта.
  4. Модуль без владельца решения. Если модуль ничего не прячет, он не модуль, а папка.
  5. Версия без семантики. Мажор, который «просто следующий по порядку», обесценивает контракт: обновление становится лотереей.
  6. Плагин-API поверх внутренних структур. Первое же изменение внутри ломает всех потребителей; API расширения должен быть спроектирован отдельно от реализации.
  7. Модуль, который тестируется только целиком с системой. Формальная граница есть, фактической нет.
  8. Микросервис как способ навести порядок в коде. Порядок наводится границей, а не сетью между беспорядочными частями.

Как это соотносится с остальным треком

Парадигма Отношение
ООП Класс прячет данные, модуль прячет решение; масштаб разный, идея инкапсуляции общая
Обобщённое программирование Дженерики и тайпклассы — механика подстановки, на которой строятся параметризованные модули
Функциональная Функтор в ML — прямое продолжение идеи «функция как значение», поднятой на уровень модулей
Аспектная Аспекты пересекают границы модулей — именно поэтому они и опасны, и полезны
Данные Схема данных — это интерфейс модуля, живущий дольше кода; версионируется по тем же правилам
Выбор и смешение Модуль — та самая «единица выбора парадигмы»: внутри одного модуля парадигма должна быть одна

Мини-итог

  1. Модульная парадигма меняет свободу «дотянуться до чего угодно» на возможность менять внутренности, никого не спрашивая.
  2. Критерий Парнаса: резать по скрываемым решениям, а не по шагам обработки. Проверка — сколько модулей трогает типичная задача.
  3. Абстрагировать зависимость можно четырьмя способами: номинальный интерфейс, структурное соответствие, тайпкласс или трейт, функтор. Различаются они возможностью дооснастить чужой тип и местом проверки.
  4. Правило сироты и когерентность — не бюрократия, а защита от зависимости поведения программы от порядка компоновки.
  5. Граф модулей — это архитектура: запрет циклов, направление зависимостей, минимальная публичная поверхность, проверка правил в CI.
  6. Компонент = модуль плюс независимая поставка, а значит плюс версия, разрешение конфликтов и ABI-совместимость.
  7. Микросервис — модуль с сетевым швом. Сначала граница, потом сеть; обратный порядок даёт распределённый монолит.

Источники

Что дальше

Мы прошли парадигмы, которые устоялись: у каждой есть учебники, инструменты и десятилетия практики. Осталось посмотреть на границу, где вычисление перестаёт быть детерминированным значением: Парадигмы за горизонтом — вероятностное, дифференцируемое и квантовое программирование.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов