Речь о процедурно‑параметрическом программировании, о котором почти никто не пишет. Я реализовал одну и ту же задачу в ООП и в этой парадигме и сравнил их численно. Одна из метрик разошлась в 6 раз.

Начало

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

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

Проблема

Возьмем три сущности: Warrior, Mage и Worker. У каждого есть метод attack, но результат атаки должен зависеть не только от того, кто атакует, но и от того, кого атакуют: атака по мирному жителю не то же самое, что атака по воину, у которого есть щит. То есть поведение определяется парой типов (кто, кого), для N типов у нас N² вариантов (Warrior — Mage, Mage — Warrior, Warrior — Worker, Worker — Warrior и так далее)

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

class Warrior : Unit:
	void attack(Unit target):
    	if(target instanceof Warrior): …
		if(target instanceof Mage): …
        if(target instanceof Worker): …
class Mage : Unit:
	void attack(Unit target):
		if(target instanceof Warrior): …
		if(target instanceof Mage): …
        if(target instanceof Worker): …
class Worker : Unit: //то же самое

При добавлении нового типа, помимо создания нового класса с четырьмя ветками, приходится дописывать ветку во все существующие методы attack. Методы превращаются в свалку if‑ов.

Вариант лучше — паттерн Visitor. Чище по коду, поведение собрано в одном месте. Но проблема та же: новый тип заставляет дописать метод в интерфейс visitor’а и реализовать его во всех существующих visitor’ах.

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

void attack(Unit, Unit); // базовая реализация
void attack(Warrior attacker, Worker target);
void attack(Mage attacker, Warrior target);

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

Unit a = new Warrior();
Unit t = new Worker();
attack(a, t);

Вызовется attack(Unit, Unit), а не attack(Warrior, Worker)

Получается, ни один вариант не подходит. Вызов метода a.attack(t) выбирает реализацию (диспетчеризует) только по атакующему, а t приходится перебирать вручную через instanceof/visitor. Свободная функция выбирает перегрузку статически, по объявленным типам. Для локализации изменений нужно и то, и другое сразу.

У этой проблемы есть имя — expression problem. Мультиметоды как решение есть в CLOS и Julia, а в C++ похожее поведение даёт std::variant с std::visit — так что дальше речь не про «новую идею», а про то, чем ППП отличается от уже существующих решений.

Матчасть

Динамический полиморфизм есть не только в ООП и реализуется по‑разному (интерфейсы в Go, типажи в Rust). Процедурно‑параметрический подход (ППП) — ещё один вариант его поддержки, который при этом не меняет остальных концепций процедурного и функционального программирования. Отталкивается ППП не от классов, а от процедурного подхода (как в C): свободные функции и структуры без поведения. Дальше весь синтаксис будет на PPC — экспериментальном расширении языка C специально под ППП.

Warrior, Mage и Worker — это не классы с методами, а структуры без поведения (специализации). Они прикрепляются к одному общему типу‑контейнеру — обобщение

struct Unit { … }<>; // обобщение

Специализация сама записывается в обобщение из своего файла; обобщение не перечисляет специализации у себя. Именно этим ППП отличается от std::variant, где новый тип нужно вписать в объявление варианта, то есть открыть существующий файл.

struct Warrior { … } // специализация
Unit += Warrior;

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

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

attack<Unit attacker, Unit target>(); // обобщающая функция
attack<Warrior attacker, Worker target>();	// обработчик специализации
attack<Mage attacker, Warrior target>();

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

Допустим, появился новый тип Archer. В ППП достаточно написать новые обработчики в новых файлах. Ни один из уже написанных файлов не меняется, в том числе объявление обобщения. Компилятор сам подхватит новые обработчики:

struct Archer { … }
Unit += Archer;
attack<Archer attacker, Warrior target>();
attack<Archer attacker, Worker target>();
attack<Archer attacker, Mage target>();
attack<Warrior attacker, Archer target>();
attack<Worker attacker, Archer target>();
attack<Mage attacker, Archer target>();
attack<Archer attacker, Archer target>();

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

Всё это, конечно, делается и на голом C++: unordered_map с ключом из пары type_index и методами для регистрации. Локализация правок будет та же. Только компилятор не будет проверять покрытие всех пар, конкретные типы придётся привести к базовому и в каждом обработчике кастовать обратно вручную — компилятор эти касты также не проверяет. В ППП за этим следит компилятор.

Что уже есть

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

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

Процедурно‑параметрическая парадигма и паттерны ОО проектирования — как средствами ППП имитируются паттерны GoF. Часть ОО‑паттернов при таком полиморфизме становится не нужна.

Компилятор разработан Павлом Косовым на базе clang, проект открыт в репозитории на gitverse. Описание синтаксиса и семантики можно почитать тут.

В свою очередь я хотел проверить эту парадигму на «реальном» проекте на конкретных числах. Об этом дальше.

Эксперимент

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

  • Стадия 0 — одно племя и один тип сущностей

  • Стадия 1 — два типа сущностей со своим поведением

  • Стадия 2 — второе племя, здоровье, бой (тут нужна двойная диспетчеризация)

  • Стадия 3 — фабрики для состава племен (люди и орки) и стратегии поведения племён. Например, воин при агрессивной стратегии бьёт слабейшего, есть случайная стратегия; рабочий при агрессивной стратегии просто добывает ресурсы.

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

Чиселки

Специально для этого эксперимента я собрал 9 метрик и разбил их на 3 группы, некоторые из них мои собственные. Полное описание в репозитории.

Здесь разберу один показатель — OCP Violation Points — сколько существующих файлов нужно изменить, чтобы добавить новый тип сущности.

На стадии 2 в ООП появился Visitor — про него уже рассказал в начале. На стадии 3 добавились стратегии поведения, и тут интересное: BattleStrategy по замыслу паттерн Стратегия, но ей нужен второй тип — тип юнита. Передать его в стратегию нечем и получаются методы actWarrior/actWorker(так как у разных типов сущностей разное поведение в зависимости от стратегии их племени), то есть паттерн приобретает ту же форму, что Visitor.

Вместе они дают трехуровневую диспетчеризацию

warrior.act(context) // вызов извне
    context.ownTribe.getStrategy().actWarrior(*this, context) // диспатч на стратегию
        target.acceptAttack(*this)
            attacker.attackWarrior(target)

Теперь лучник. Придется открыть и добавить:

  • battle_strategy.h — новый метод actArcher

  • random_strategy.cpp — его реализацию

  • aggressive_strategy.cpp — его реализацию

  • unit_attacker.h — новый метод attackArcher

  • warrior.cpp — его реализацию

  • tribe_factory.cpp — добавить лучника в состав племени.

В ППП обоих интерфейсов нет. Стратегия и атака это два мультиметода:

void strategy_execute<BattleStrategy* s, Unit* unit>(SimulationContext* context);
void attack<Unit* attacker, Unit* target>();

Цепочка вызовов короче:

unit_act<warrior>(context)
    strategy_execute<strategy, warrior>(context)
        attack<warrior, target>()

Лучник добавляется в одном новом файле, и изменить нужно только функцию, где наполняется племя. Шесть правок в ООП и 1 правка в ППП. Посчитав ещё промежуточный результат на стадии 2, получил такой график:

Получено очков
Нарушения приципа открытости/закрытости по стадиям в ООП и ППП

Заключение

ППП локализует изменения лучше, и чем больше в проекте паттернов, тем больше разрыв. Однако другие метрики не показали преимущества. Например, количество строк кода в ППП больше на 20–28%, но к стадии 2 разрыв исчезает. Доля измененных файлов среди всех затронутых на каждой новой стадии у обоих подходов примерно одинаковая. Ноль правок в старом коде ППП не гарантирует — дисциплина остается на разработчике. Но в ООП дисциплина не поможет в принципе. Чисто виртуальный метод в базовом классе ломает всех наследников. 

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

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