Речь о процедурно‑параметрическом программировании, о котором почти никто не пишет. Я реализовал одну и ту же задачу в ООП и в этой парадигме и сравнил их численно. Одна из метрик разошлась в 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— новый методactArcherrandom_strategy.cpp— его реализациюaggressive_strategy.cpp— его реализациюunit_attacker.h— новый методattackArcherwarrior.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. Однако язык продолжает развиваться, так что часть этого, вероятно, поправят.
Множественная диспетчеризация работает в паре с отделением поведения от данных. Поэтому новый тип ничего не ломает. Но и поэтому нельзя открыть один файл и увидеть, что сущность умеет. Что из этого выйдет на большой системе?
Комментарии (23)

Hardened_Steel
24.07.2026 10:03Всё это, конечно, делается и на голом C++:
unordered_mapс ключом из парыtype_indexи методами для регистрации. Локализация правок будет та же. Только компилятор не будет проверять покрытие всех пар, конкретные типы придётся привести к базовому и в каждом обработчике кастовать обратно вручную — компилятор эти касты также не проверяет. В ППП за этим следит компилятор.На C++ можно тоже через таблицу реализовать, а с шаблонами не надо в обработчиках ничего кастовать. Проверить таблицу можно во время запуска приложения (перед main), например. А как в вашем ППП-компиляторе реализована проверка?

kititnikx Автор
24.07.2026 10:03Про шаблоны соглашусь. Тогда разница в том, кто ведёт таблицу, разработчик или язык.
По проверке. Реализация проверок осуществляется во время компиляции. Пока таблица собирается целиком и проверяется на пропущенные пары при запуске, тоже перед main. Так получается из-за раздельной компиляции. В планах перенести сборку таблицы в свой компоновщик.
Hardened_Steel
24.07.2026 10:03Реализация проверок осуществляется во время компиляции. Пока таблица собирается целиком и проверяется на пропущенные пары при запуске, тоже перед main.
Взаимоисключающие утверждения.
В планах перенести сборку таблицы в свой компоновщик.
Больше похоже на правду. Мне вот сложно представить полноценную проверку на этапе компиляции с раздельной компиляцией.
В статье мало технических деталей как это устроено внутри и для чего нужен аж целый компилятор для этого, если это может быть реализовано в C++ библиотеке.
kititnikx Автор
24.07.2026 10:03Взаимоисключающие утверждения
Неточно выразился. На этапе компиляции проверяются связь специализаций с обобщением и уникальность специализаций.
Спасибо за обсуждение. Технических деталей правда мало, так как статья изначально планировалась обзорной. Если будет интерес к теме со стороны читателей, можно будет написать статью с разбором технических деталей и осветить все вопросы, которые всплывут в комментариях к этой статье. В том числе подробнее сравненить компилятор с C++ библиотекой. Если есть интерес прямо сейчас - можно почитать здесь.

NeoCode2
24.07.2026 10:03То есть это "процедурно‑параметрическое программирование" - по сути мультиметоды с динамической диспетчеризацией, то есть свободные функции + какой-то аналог таблицы виртуальных функций?
И интересно как это устроено на низком уровне, то есть те самые таблицы. Ведь если в C++ у нас указатель на vbable хранится в начале объекта, то здесь несколько объектов...

Siemargl
24.07.2026 10:03Именно. Матчасть с примерами даже в вике
Лучше конечно в английской вике.
Изобретено в 1995г, под большинство языков программирования есть библиотеки.
Впрочем для геймдева, для которого приводятся примеры, применимо с оговорками невысокой производительности.

kititnikx Автор
24.07.2026 10:03Не виртуальные таблицы, а локальные индексные. В объекте лежит не указатель на таблицу, а признак специализации(целое число, индекс). Таблицы принадлежат не типам, а обобщающим функциям. Для мультиметода таблица многомерная.
Как это реализовано в PPC описано в статье в разделе 3 (Отображение процедурно-параметрических конструкций в низкоуровневое представление).

CrushBy
24.07.2026 10:03Если вопрос в конце не риторический, то вот пример достаточно большой системы: MyCompany. Она написана на lsFusion, где методов, собственно, тоже нет. Свойства и действия глобальные, а реализация может выбираться по классам всех параметров.
Ваш пример с атакой выглядел бы примерно так:
CLASS ABSTRACT Unit; CLASS Warrior : Unit; CLASS Mage : Unit; CLASS Worker : Unit; attack ABSTRACT MULTI (Unit, Unit); attack(Warrior attacker, Worker target) + { // Warrior attacks Worker } attack(Mage attacker, Warrior target) + { // Mage attacks Warrior }MULTI обозначает, что все реализации не должны пересекаться по сигнатуре. Но можно сделать LIST, тогда можно добавлять много реализаций.
То есть реализация выбирается не только по атакующему, как у обычного метода, а по фактическим классам обоих параметров. Никакие instanceof и Visitor для этого не нужны. При этом реализации можно добавлять из отдельных модулей, не меняя базовый.Минус ровно тот, который вы описали: нельзя открыть класс и увидеть всё, что он умеет. Но это решается поиском реализаций и использований в IDE.
А вот тезис "новый тип ничего не ломает" спорный. Если для него подходит базовая реализация, то да. Если нужна отдельная специализация, а разработчик её забыл, всё может прекрасно скомпилироваться и работать неправильно. В lsFusion для абстрактного действия можно потребовать полное покрытие классов. Без такого контроля проблема никуда не исчезает, просто проявляется в другом месте.

kreofil
24.07.2026 10:03Языков, в которых есть мультиметоды, сейчас хватает. Начиная с CLOS. В основном доступ к параметрам реализуется с использованием хеширования, что медленнее чем в ЗЗС. Сравнение и обзор есть в статье: Сравнение производительности для различных способов реализации мультиметодов. Системы анализа и обработки данных. – 2025. – № 4 (100). – С. 69–84.(https://journals.nstu.ru/vestnik/catalogue/contents/view_article?id=41742).
На уровне PPC полиморфные функции могут контролироваться примерно также как при использовании абстрактных классов в C++. Обобщенная функция может быть абстрактной. Тогда все альтернативы должны быть обязательно реализованы. Если обобщенная функция имеет тело, то она, как и виртуальные методы, используется по умолчанию там, где нет ее замены.

BURAKKu
24.07.2026 10:03Хотелось бы увидеть реакцию бренч предиктора в процессоре на это

jakobz
24.07.2026 10:03Если на виртуальные методы как-то работает - то и тут будет. Насколько я понимаю (так себе) - предиктору вообще пофиг как образуется адрес перехода: что он у тебя захардкодан, что он у тебя из vtable берется, что он по формуле считается. Предсказание пишется в линейку кеша инструкций - типа вот в этой линейке на пятой инструкции полетим на такой-то адрес. Повезло и реально полетели - ок, не повезло - выкидываем спекулятивные результаты до перехода и начинаем с места где был переход.
Если у тебя цикл по зайчикам и белочкам в перемешку - будет вероятность предсказать переход 50%, как ты там не делай.

Format-X22
24.07.2026 10:03Мне кажется в реальном мире никто не делает N^2, да и функции с перегрузкой и прочее. Есть объект персонажа, есть миксины - свойства, которые имеет персонаж. Там какой-нибудь один из пяти типов атаки, молификаторы и прочее. Что-то на этапе компиляции можно сделать, что-то будет добавляться в процессе. А подход называется композиция. В итоге там может быть вообще один метод обработки атаки для вообще всех, на вход принимает конфиг с числами и парой енумов, на выходе результат взаимодействия. И енумы это не список вообще всех видов персонажей, а маленькие енумы из тех же пяти типов атак, трёх защит и пачка особых свойств, сводящихся к суммированию или вычитанию числовых значений, и всё. Композиция решает такой вопрос, и не только в играх.

xSVPx
24.07.2026 10:03Совершенно непонятным осталось то, почему это в Archer
attack<Warrior attacker, Archer target>();
attack<Worker attacker, Archer target>();
attack<Mage attacker, Archer target>();
В классах warrior/worker/mage что по итогу содержится?
Как понять какой метод где искать ?

kititnikx Автор
24.07.2026 10:03В ППП нет классов. warrior/worker/mage - это просто структуры без поведения.
attack<Warrior attacker, Archer target>(); - это не метод Warrior, и не метод Archer, а свободая функция. Она может лежать в любом файлеМинус в том, что нельзя открыть файл с этой структурой и увидеть всё, что эта структура умеет. Но это решается поиском в IDE

xSVPx
24.07.2026 10:03Ну т.е. эти функции хаотично размазаны по файлам и заранее непонятно где какая окажется ?
Искать ide это отлично. Но чтобы искать надо знать что искать.

xSVPx
24.07.2026 10:03Я тут понял, что одна функция с кучей свичей внутри была бы гораздо-гораздо удобнее и безопаснее "на дистанции" :). Да - уродство. Да - бойлерплейт огромный. Но зато все варианты поведения сразу перед тобой и не надо искать в 100500 файлах, а не притаился ли там нежданчик какой. При этом что-то добавляя ты прям видишь как это в прошлом обрабатывалось в каких случаях.
Потому как одно дело "красиво нафигачить", а другое - исправить потом через 10 лет когда фигачил не ты.
Ну т.е. где-то это безусловно оправдано(для любого подхода можно придумать ситуацию когда нужен именно он), но вот где, так сразу в голову и не приходит :(

kreofil
24.07.2026 10:03При любой парадигме вряд ли кто разбрасывает код по файлам, модулям или классам хаотично. Порядок всегда есть и связан с архитектурой проекта. Поэтому распределение по файлам может быть практически аналогично тому, как распределяются базовый и производные классы. Здесь нет противоречий между парадигмами. Все определяется культурой использования модулей.

xSVPx
24.07.2026 10:03Но в тот момент, когда вы начнете складывать весь код скажем "к актору", всё это станет совсем уж "малонужным'.
Я не против подхода, я просто не могу сообразить какой в нём толк. Ощущение, что это только запутывает логику, только отдаляет ее от разработчика.
ЗЫ. Вообще есть ощущение что это должно как-то иначе обрабатываться. Т.е. кроме attack должно быть attackedBy и какой-то контракт чтобы везде не проверять кто как и кого.

kreofil
24.07.2026 10:03Кто, как и кого...
Это как раз тот вопрос, который рассматривается во всех подходах. И каждый выбирает свой вариант. Для перебора используется централизация, мультиметоды, диспетчеризация. Добавление новых сущностей требует модификации кода или специальных приемов. Вы по сути предлагаете ОО обмен сообщения, глядя на сущности как объекты. Но можно на взаимодействие смотреть как на процесс в котором описывается взаимодействие напрямую с анализом данных обоих сущностей определенной комбинации. Выбор техники кодирования определяется разработчиком. Меня больше устраивает процедурно-параметрический вариант. Он более гибкий при добавлении новых функций и новых альтернатив, позволяя при этом не изменять ранее написанный код.

APh
24.07.2026 10:03Мне кажется, что в ПО больше проблем от архитектуры, нежели от используемого метода программирования.
И сам по себе выбор какого-то подхода или методологии не уничтожает проблему плохих мыслей.
Можно посадить опытного лысыватого толстечка за Паскаль/Дельфи с приказом использовать процедурный подход и метод водопада из 70-х и он сварганит отличный сопровождаемый код за месяц.
А, можно взять целую прыщавую бригаду с лавандовым рафом, чтобы они использовали канбаны, эджайлы, супер-пупер новомодные подходы и шаблоны с интерфейсами и через месяц получить помойку с кучей не отлаженных ошибок, которую для добавления новой фичи надо переписать наполовину.
Сам по себе синтаксис или парадигма какая не смогут заставить дуболомов написать хороший код.
Если появляется какой-то новый подход, который надо заново изучать, а он позволяет упростить лишь малое и редкое число случаев, при этом ломая все старое и не помогая в др. случаях или даже усложняя их, а то же самое уже можно сделать на существующем условном Си++, пусть и немного большим кол-во символов, то я против!
А, по поводу текущего предложения: Си должен развиваться в сторону метапрограммирования, однозначно.
ООП и Си++ не решил всех проблем, но кучу сложностей нагородил.
Хочу гибкий Си с новыми плюшками, но совместимый с предыдущими. Или почти совместимый, чтобы не переписывать логику, а лишь изменить оформление из-за нового синтаксиса, если изменения уж совсем радикальные.

kreofil
24.07.2026 10:03Парадигмы определяют рациональность техники кодирования, убирая лишние телодвижения и влияют на архитектуру. Они взаимосвязаны. Процедурное программирование привело к структурному проектированию сверху вниз. ООП привело к ОО методологии проектирования. И так далее. Знание техники улучшает проектные решения ("чистый код" влияет на "чистую архитектуру") и наоборот. Процедурно-параметрический код тоже изменяет подходы к разработке и формированию структуры программы.
Метапрограммирование не обеспечивает поддержку динамического полиморфизма. Поэтому оно не решает проблем гибкого развития программ, когда альтернативные данные появляются во время выполнения. Только параметрический полиморфизм. Вряд ли метапрограммирование нужно в Си. Это больше для языков прикладного уровня.
PPC расширяет Си динамическим полиморфизмом. При этом сохраняется полная совместимость с Си. Возможно есть что-то ломающие косяки и синтаксис до конца не отработан. Но разные сишные библиотеки (ncurses, SDL, OpenMP и ряд других) подключались и запускались. Совместимость отслеживается специально.
Dhwtj
https://en.wikipedia.org/wiki/Expression_problem
kreofil
В СССР это направление называлось эволюционной разработкой. Были работы:
Фуксман А.Л. Технологические аспекты создания программных систем.
Горбунов-Посадов, М. М. Расширяемые программы.
Процедурно-параметрический подход решает многие проблемы, представленные в Expression Problem.