Обобщённое программирование и метапрограммирование
С чего всё начинается: одна и та же функция, написанная шесть раз
Откройте почти любую взрослую кодовую базу и поищите функции с именами вида findUserById,
findOrderById, findInvoiceById. Скорее всего, тела у них отличаются ровно одним словом — именем типа.
То же самое с maxInt/maxFloat, с тремя вариантами кэша, с десятью почти одинаковыми DTO-мапперами.
Это не лень программиста. Это дыра в выразительной силе языка: язык умеет абстрагировать значения (параметры функции), но не умеет абстрагировать типы. Пока такой дыры нет затычки, единственный способ переиспользовать алгоритм — скопировать его.
Два разных ответа на эту боль и составляют тему статьи:
- Обобщённое программирование — сделать тип таким же параметром, как значение. Один исходник, много типов, проверка на этапе компиляции. Алгоритм формулируется в терминах требований к типу, а не самого типа.
- Метапрограммирование — сделать код данными. Программа читает, порождает и преобразует другую программу: макросы, шаблоны-вычислители, рефлексия, кодогенерация.
Это две разные оси. Первая отвечает на вопрос «как написать одно вместо десяти». Вторая — «как написать то, что напишет десять за меня». Обе платят одной и той же валютой: вы обмениваете простоту чтения кода на компактность его написания, и весь инженерный смысл — в том, чтобы понимать курс обмена.
Общая карта парадигм — в https://courses.digitable.life/post/paradigms/00-overview/. Эта статья опирается на понятия из https://courses.digitable.life/post/paradigms/02-oop/ (подтипы, интерфейсы, диспетчеризация) и https://courses.digitable.life/post/paradigms/03-functional/ (классы типов, композиция).
Карта территории
над кодом)) Обобщённое программирование Параметрический полиморфизм forall a. a -> a параметричность Ограничения номинальные интерфейсы структурные типы классы типов и трейты концепты C++20 Дисперсия ковариантность out контравариантность in инвариантность Стратегии компиляции мономорфизация стирание и боксинг передача словарей Метапрограммирование Компиляция текстовые макросы синтаксические макросы AST шаблоны и constexpr кодогенерация из схем Рантайм рефлексия метаклассы и дескрипторы динамические прокси байткод-инструментация Свойства гигиена стадийность частичные вычисления
История: обобщённость придумали ради алгоритмов, а не ради контейнеров
Ключевой поворот — 1988 год, статья Дэвида Мюссера и Александра Степанова
«Generic Programming». Их тезис звучит неожиданно для тех,
кто знает STL по контейнерам: обобщённое программирование — это метод, при котором алгоритм пишется
в терминах минимального набора требований, а не в терминах конкретной структуры данных. Сначала вы
доказываете, что find нуждается ровно в «однонаправленном продвижении и сравнении на равенство»
(forward iterator), и только потом выясняется, что под это подходят и список, и вектор, и поток ввода.
Отсюда важное следствие, которое часто теряют: дженерики — это не про переиспользование кода, а про формулировку контракта. Переиспользование — побочный эффект.
Про мотивацию и формальную сторону стоит прочитать классический обзор Карделли и Вегнера «On Understanding Types, Data Abstraction, and Polymorphism» — именно там наведён порядок в словах «полиморфизм», «перегрузка», «подтип».
Часть I. Обобщённое программирование
Параметрический полиморфизм и теоремы даром
Функция параметрически полиморфна, если она работает одинаково для всех типов и ничего не может узнать о конкретном типе. Сигнатура
identity :: forall a. a -> a
говорит намного больше, чем кажется. Раз функция ничего не знает про a, у неё нет способа создать
значение этого типа, сравнить два значения или разобрать одно. Единственная тотальная функция с такой
сигнатурой — тождественная. Сигнатура доказала теорему о реализации.
Это и есть параметричность, формализованная Филипом Уодлером в
«Theorems for Free!». Ещё пример: для любой
r :: forall a. [a] -> [a] и любой f верно map f . r == r . map f. Перестановка, которую делает r,
не может зависеть от содержимого — значит, коммутирует с любым переименованием элементов. Отсюда
практический вывод для ревью кода: чем более обобщённая сигнатура, тем меньше вариантов у реализации
и тем меньше нужно тестов.
Обратная сторона: чем меньше знает функция, тем меньше она может. Полезная работа требует ограничений.
Ограничения: от «любой тип» к «любой тип, который умеет»
Существует три принципиально разных способа сказать «T должен уметь сравниваться».
Номинальный — «T должен реализовывать объявленный интерфейс, и это должно быть написано в объявлении T».
Java, C#, наследование в https://courses.digitable.life/post/paradigms/02-oop/. Плюс: явность. Минус: ретроактивность невозможна —
чужой класс из библиотеки нельзя задним числом объявить Comparable.
Структурный — «T должен иметь такие-то методы, а как он объявлен, неважно». Go-интерфейсы,
TypeScript, Python Protocol. Плюс: работает с чужими типами. Минус: случайные совпадения имён
(метод send у почтальона и у сокета — совсем не одно и то же).
Классы типов / трейты — «где-то есть отдельное объявление, что T умеет вот это». Haskell, Rust, Scala (implicits/givens), Swift. Плюс: ретроактивно, явно и проверяемо одновременно; экземпляр можно написать для чужого типа. Минус: нужно разрешать, какой экземпляр выбирать (когерентность, orphan rules).
from typing import Protocol, TypeVar, Iterable
class Comparable(Protocol):
"""Структурное ограничение: неважно, кто вы, — важно, что у вас есть __lt__."""
def __lt__(self, other, /) -> bool: ...
T = TypeVar("T", bound=Comparable)
def max_of(items: Iterable[T]) -> T:
it = iter(items)
try:
best = next(it)
except StopIteration:
raise ValueError("пустая последовательность") from None
for x in it:
if best < x: # разрешено ровно потому, что T ограничен Comparable
best = x
return best
# Время O(n), память O(1). Проверка ограничения — статическая, стоимость в рантайме нулевая.
// Go: ограничение — это интерфейс, который может содержать и методы, и множество типов.
// Тильда ~int означает «int и любой тип с базовым типом int» (например, type UserID int).
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 | ~string
}
func MaxOf[T Ordered](xs []T) (T, error) {
var zero T
if len(xs) == 0 {
return zero, errors.New("пустой срез")
}
best := xs[0]
for _, x := range xs[1:] {
if x > best { // оператор > доступен, потому что все типы в объединении его поддерживают
best = x
}
}
return best, nil
}
// Rust: трейт можно реализовать ретроактивно для чужого типа — это то,
// чего принципиально не умеют номинальные интерфейсы Java/C#.
trait Area {
fn area(&self) -> f64;
}
impl Area for (f64, f64) { // кортеж из стандартной библиотеки — не наш тип
fn area(&self) -> f64 { self.0 * self.1 }
}
fn total_area<S: Area>(items: &[S]) -> f64 {
items.iter().map(|s| s.area()).sum()
}
// C++20: концепт — это предикат над типом, проверяемый компилятором.
// Главная практическая ценность — вменяемые сообщения об ошибках вместо простыни SFINAE.
template <typename T>
concept Summable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>; // сложение существует и даёт T
T{}; // есть нейтральный конструктор
};
template <Summable T>
T sum(std::span<const T> xs) {
T acc{};
for (const auto& x : xs) acc = acc + x;
return acc; // O(n) времени, O(1) доп. памяти
}
Мини-правило
Ограничение должно быть минимально достаточным. Каждое лишнее требование сужает множество
применимых типов, ничего не давая взамен. Если алгоритму нужно только «пройти один раз вперёд»,
не требуйте RandomAccess; если нужно только равенство — не требуйте порядка.
Обратите внимание на note: репозиторий не требует от T ни сериализуемости, ни знания о SQL.
Всё лишнее вынесено в RowMapper<T> — отдельный параметр реализации, а не ограничение контракта.
Как это компилируется: три модели стоимости
Одна и та же обобщённая функция может превратиться в совершенно разный машинный код. Это не деталь реализации, а то, за что вы платите в проде.
| Мономорфизация | Стирание + боксинг | Словари | |
|---|---|---|---|
| Языки | C++, Rust, C# (value-типы) | Java, C# (ссылочные), Go до 1.18 | Haskell, Swift, Go 1.18+ |
| Размер кода | × число инстанцирований | × 1 | × число «форм» типа |
| Время компиляции | высокое, растёт сверхлинейно | низкое | среднее |
| Стоимость вызова | ноль, инлайнится | виртуальный вызов + аллокация | косвенный вызов |
| Типы в рантайме | есть (реифицированы) | стёрты | частично |
| Специализация под тип | полная | никакой | ограниченная |
Несколько практических следствий, которые бьют по реальным проектам.
Раздувание кода в C++/Rust — реальная проблема. std::vector<T> для 40 разных T — это 40 копий
всех использованных методов. Классический приём борьбы — «тонкая обёртка над толстым ядром»: обобщённая
функция немедленно стирает тип и вызывает необобщённую реализацию.
// Плохо: всё тело мономорфизируется под каждый конкретный P.
pub fn read_config<P: AsRef<Path>>(path: P) -> Result<Config, Error> {
let bytes = std::fs::read(path.as_ref())?;
parse(&bytes) // ...и весь парсер тоже копируется
}
// Хорошо: обобщённая часть — три строки, тяжёлая работа в одной копии.
pub fn read_config<P: AsRef<Path>>(path: P) -> Result<Config, Error> {
read_config_inner(path.as_ref()) // мономорфизируется только это
}
fn read_config_inner(path: &Path) -> Result<Config, Error> {
let bytes = std::fs::read(path)?;
parse(&bytes) // ровно одна копия в бинарнике
}
Стирание в Java — не эстетика, а обратная совместимость. Дженерики добавили в Java 5, и требовалось,
чтобы новый List<String> и старый сырой List жили в одной JVM. Отсюда набор ограничений, которые
удивляют каждое новое поколение: нельзя new T[], нельзя instanceof List<String>, нельзя перегрузить
метод по List<String> и List<Integer>, а List<Integer> в цикле аллоцирует объект на каждое число.
Подробный разбор — в Java Generics FAQ
Ангелики Лангер; лечение через плоские value-объекты обещает
Project Valhalla.
Go выбрал середину. Компилятор генерирует по копии не на каждый тип, а на каждую gcshape — грубо говоря, на каждую форму представления в памяти (все указательные типы делят одну копию). Недостающая информация приезжает скрытым аргументом-словарём. Это документировано в дизайн-доке реализации дженериков; исходный proposal по языку — здесь. Практический вывод для Go: дженерики не гарантируют ускорения по сравнению с интерфейсом — они дают типобезопасность, а выигрыш в производительности надо мерить, а не предполагать.
Дисперсия: почему List<Dog> не является List<Animal>
Самая частая ошибка в обобщённом коде — интуитивное «раз Dog это Animal, то и коллекция собак это
коллекция животных». Контрпример строится за три строки:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // если бы это компилировалось...
animals.add(new Cat()); // ...то здесь кот оказался бы в списке собак
Dog d = dogs.get(0); // ClassCastException в рантайме
Правило, которое стоит выучить наизусть: тип-параметр может быть ковариантным, если он встречается только в позициях вывода, и контравариантным, если только в позициях ввода. Читающая коллекция ковариантна, пишущая — контравариантна, читающе-пишущая — инвариантна.
// C#: дисперсия объявляется на интерфейсе один раз (declaration-site variance).
public interface IProducer<out T> { T Get(); } // T только на выходе → ковариантный
public interface IConsumer<in T> { void Put(T x); } // T только на входе → контравариантный
IProducer<Dog> dogSource = ...;
IProducer<Animal> anySource = dogSource; // OK: читать животных из источника собак безопасно
IConsumer<Animal> anySink = ...;
IConsumer<Dog> dogSink = anySink; // OK: сток животных примет и собаку
// Java: дисперсия объявляется в каждом месте использования (use-site variance) — это PECS:
// Producer Extends, Consumer Super.
static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T item : src) dst.add(item);
}
copy(listOfDogs, listOfAnimals); // читаем собак, пишем как животных — типобезопасно
Связь с ООП прямая: это обобщение принципа подстановки Лисков на конструкторы типов. Дисперсия аргументов и возвращаемых значений метода разбиралась в https://courses.digitable.life/post/paradigms/02-oop/.
Где обобщённость упирается в потолок
Практически все мейнстримные языки умеют абстрагировать тип, но не конструктор типа. Написать
«функция, работающая для любого контейнера F» — то есть параметризоваться по F[_], а не по F[Int] —
умеют Haskell и Scala, но не Java, C#, Go и (пока) Rust.
// Higher-kinded type: F — не тип, а «дырка, в которую можно подставить тип».
trait Mappable[F[_]]:
def map[A, B](fa: F[A])(f: A => B): F[B]
// Одна функция работает и для List, и для Option, и для Future, и для IO.
def double[F[_]](fa: F[Int])(using M: Mappable[F]): F[Int] = M.map(fa)(_ * 2)
Именно на этом стоят абстракции вроде Functor/Monad из https://courses.digitable.life/post/paradigms/03-functional/ и стиль
tagless final, где бизнес-логика пишется один раз, а исполняется и в проде (IO), и в тестах (Id).
Обходной путь для языков без HKT — генерация кода: то, что не выражается системой типов, выражается
метапрограммированием. Так мы естественно переходим ко второй половине статьи.
Часть II. Метапрограммирование
Где именно вы вклиниваетесь в конвейер
Все техники метапрограммирования различаются одним: на каком этапе жизни программы они работают. Чем раньше — тем дешевле в рантайме, но тем меньше информации доступно; чем позже — тем больше знаний, но тем дороже и тем хуже с проверками.
текстовые макросы
#define, шаблонизаторы"}} PP --> TOK["Токены"] TOK --> AST["AST — синтаксическое дерево"] AST --> MAC{{"Уровень 1
синтаксические макросы
Lisp, Elixir, macro_rules!, derive"}} MAC --> TAST["Типизированное AST"] TAST --> CT{{"Уровень 2
вычисления при компиляции
templates, constexpr, comptime"}} CT --> GEN{{"Уровень 5
кодогенерация из схем
protoc, sqlc, source generators"}} GEN -.->|"порождённые .go/.cs/.rs"| SRC CT --> IR["Промежуточное представление"] IR --> BIN["Байткод / машинный код"] BIN --> LOAD{{"Уровень 4б
инструментация при загрузке
java agent, IL-weaving"}} LOAD --> RT["Рантайм"] RT --> REFL{{"Уровень 3
рефлексия
inspect, Reflection, reflect"}} RT --> META{{"Уровень 4а
изменение семантики объектов
метаклассы, __getattr__, proxy"}} style PP fill:#ef4444,stroke:#ef4444,color:#fff style MAC fill:#8b5cf6,stroke:#8b5cf6,color:#fff style CT fill:#3b82f6,stroke:#3b82f6,color:#fff style GEN fill:#10b981,stroke:#10b981,color:#fff style REFL fill:#f59e0b,stroke:#f59e0b,color:#fff style META fill:#f59e0b,stroke:#f59e0b,color:#fff style LOAD fill:#f59e0b,stroke:#f59e0b,color:#fff
Дальше — по уровням, снизу вверх по мощности и сверху вниз по безопасности.
Уровень 0: текстовая подстановка, и почему её изгнали
Препроцессор C работает с потоком токенов и ничего не знает ни о синтаксисе, ни о приоритетах операций, ни об области видимости. Отсюда классика жанра:
#define SQR(x) x * x
int a = SQR(1 + 2); /* → 1 + 2 * 1 + 2 == 5, а вовсе не 9 */
#define SQR2(x) ((x) * (x))
int i = 3;
int b = SQR2(i++); /* → ((i++) * (i++)) — двойной побочный эффект, UB */
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int c = MAX(f(), g()); /* один из вызовов случится дважды */
Три категории ошибок сразу: нарушение приоритетов, дублирование побочных эффектов, отсутствие гигиены.
Ни одну нельзя вылечить скобками до конца. Именно поэтому все языки после C, добавлявшие макросы, делали
их синтаксическими, а не текстовыми. Если вы пишете на C и не можете без макроса — как минимум
оборачивайте аргументы в скобки, используйте do { ... } while (0) для многострочных тел и вычисляйте
каждый аргумент ровно один раз через локальную переменную (GNU __auto_type / typeof).
Уровень 1: макросы над деревом
Синтаксический макрос принимает AST и возвращает AST. Он не может «случайно» сломать приоритеты, потому что структура уже разобрана. Классика — Lisp, где код буквально является структурой данных языка; современные представители — Rust, Elixir, Scala 3, Julia.
Elixir показателен тем, что там на макросах построен сам язык: if, def, defmodule — макросы.
# Что видит компилятор, когда читает выражение: обычный кортеж {оператор, метаданные, аргументы}
iex> quote do: 1 + 2 * x
{:+, [], [1, {:*, [], [2, {:x, [], Elixir}]}]}
# (метаданные сокращены для читаемости)
defmodule Guardian do
# Макрос выполняется ВО ВРЕМЯ КОМПИЛЯЦИИ и возвращает дерево, которое подставят в место вызова.
defmacro assert_positive(expr) do
# Мы имеем доступ к самому тексту выражения — не только к его значению.
text = Macro.to_string(expr)
quote do
value = unquote(expr) # unquote вставляет пришедшее дерево внутрь нашего
if value <= 0 do
raise ArgumentError,
"ожидалось положительное значение, но #{unquote(text)} == #{inspect(value)}"
end
value
end
end
end
# Использование: сообщение об ошибке содержит исходный текст выражения,
# чего обычной функцией добиться невозможно — функция получает уже вычисленное число.
require Guardian
Guardian.assert_positive(balance - fee)
# ** (ArgumentError) ожидалось положительное значение, но balance - fee == -40
Это и есть настоящий критерий «нужен ли макрос»: макрос оправдан, когда нужна информация, которой у функции физически нет — исходный текст, отложенное вычисление аргумента, имя переменной, структура блока. Всё остальное решается функцией высшего порядка, и решать надо ею.
// Rust: декларативный макрос по образцу. Обратите внимание, что $x:expr — это
// уже разобранное выражение, а не текст: подставится оно как единое поддерево.
macro_rules! retry {
($attempts:expr, $body:expr) => {{
let mut last_err = None;
for attempt in 0..$attempts {
match $body {
Ok(v) => { last_err = None; break Some(v) }
Err(e) => { last_err = Some(e); std::thread::sleep(backoff(attempt)); }
}
}
.ok_or_else(|| last_err.unwrap())
}};
}
Гигиена: главное свойство, которое отличает макрос от беды
Проблема была формализована Кольбекером и соавторами в 1986 году
(«Hygienic macro expansion»). Суть решения: идентификатор
— это не строка, а пара «символ + метка контекста раскрытия». Два tmp с разными метками — разные
переменные, даже если пишутся одинаково.
defmodule Hygiene do
defmacro shadow_test do
quote do
x = 42 # это НЕ переменная вызывающего: она невидима снаружи
x
end
end
defmacro leaky do
quote do
var!(result) = 99 # var!/1 — явный, документированный отказ от гигиены
end
end
end
x = 1
Hygiene.shadow_test() # => 42, при этом x снаружи всё ещё == 1
Правило продакшн-кода: макрос, нарушающий гигиену, обязан явно документировать, какие имена он
вводит в область видимости вызывающего. Иначе получаются DSL, в которых непонятно, откуда взялась
переменная conn — а это самый частый источник «магии» в жалобах на Rails, Phoenix и Spring.
Уровень 2: вычисления во время компиляции
Шаблоны C++ оказались полны по Тьюрингу случайно — это обнаружил Эрвин Ун в 1994-м, а Тодд Вельдхёйзен превратил находку в технику в статье «Template Metaprograms». Двадцать лет индустрия писала вычисления на языке специализаций шаблонов, а потом языки признали, что проще разрешить обычный код на этапе компиляции.
// 1994: вычисления через рекурсивную специализацию шаблонов.
// Читается плохо, компилируется медленно, ошибки нечитаемы.
template <int N> struct Fact { static constexpr int value = N * Fact<N - 1>::value; };
template <> struct Fact<0> { static constexpr int value = 1; };
static_assert(Fact<10>::value == 3628800);
// 2020: тот же результат обычным кодом. consteval требует вычисления при компиляции.
consteval int fact(int n) { return n <= 1 ? 1 : n * fact(n - 1); }
static_assert(fact(10) == 3628800);
// Zig довёл идею до предела: comptime — это тот же язык, просто выполняемый компилятором.
// Обобщённость здесь не отдельная система, а функция, принимающая тип и возвращающая тип.
fn Stack(comptime T: type) type {
return struct {
items: []T,
len: usize,
pub fn push(self: *@This(), v: T) void {
self.items[self.len] = v;
self.len += 1;
}
};
}
const IntStack = Stack(i32); // вызов функции во время компиляции даёт новый тип
Модель стоимости. Метапрограммирование на шаблонах платит временем компиляции, причём нелинейно:
глубина инстанцирования у gcc по умолчанию ограничена 900 уровнями, а память компилятора при
неудачной рекурсии уходит в гигабайты. Сборка крупных C++-проектов на 60–80 % состоит из
инстанцирования шаблонов; отсюда индустрия precompiled headers, extern templates и, с C++20, модулей.
Прежде чем добавлять «умный» шаблон, полезно измерить сборку (-ftime-trace в clang) — иначе через
полгода команда будет ждать сборку по двадцать минут и не знать почему.
Уровень 3: рефлексия во время выполнения
Рефлексия — способность программы задавать вопросы о своей структуре: какие поля у этого класса, какие аннотации на этом методе, какой тип у этого параметра. Это самая доступная и самая злоупотребляемая техника.
import dataclasses
import functools
import typing
from datetime import datetime
def _encoder(tp):
"""Возвращает функцию-кодировщик для конкретного типа поля."""
if tp is datetime:
return lambda v: v.isoformat()
if typing.get_origin(tp) is list:
inner = _encoder(typing.get_args(tp)[0])
return lambda v: [inner(x) for x in v]
if dataclasses.is_dataclass(tp):
return to_json_dict
return lambda v: v
@functools.lru_cache(maxsize=None)
def _plan(cls: type) -> tuple[tuple[str, object], ...]:
"""
КЛЮЧЕВОЙ ПРИЁМ: рефлексия выполняется один раз на тип и кэшируется как ПЛАН.
Горячий путь потом ходит по готовому списку пар (имя, функция), а не по метаданным.
"""
hints = typing.get_type_hints(cls)
return tuple((f.name, _encoder(hints[f.name])) for f in dataclasses.fields(cls))
def to_json_dict(obj) -> dict:
return {name: enc(getattr(obj, name)) for name, enc in _plan(type(obj))}
Разница между наивным и кэширующим вариантом — обычно порядок величины: get_type_hints заново
разбирает аннотации, ходит по MRO и резолвит строковые ссылки на каждый вызов. Тот же принцип в других
экосистемах:
- .NET:
PropertyInfo.GetValueмедленно; правильный путь — построить дерево выражений один раз и скомпилировать его в делегат (Expression.Lambda<Func<T, object>>(...).Compile()). Дальше вызов идёт со скоростью обычного метода. - Java:
Method.invoke→MethodHandleс кэшированием, либо генерация байткода (так работают Jackson afterburner и большинство ORM). - Python: pydantic v2 перенёс валидационное ядро в Rust ровно потому, что рефлексивный горячий путь на чистом Python неприемлем; схема строится один раз при импорте.
Практическое правило: рефлексия допустима на этапе инициализации и недопустима в горячем цикле. Если она оказалась в цикле — вы забыли скомпилировать план.
Уровень 4: изменение семантики объектов на лету
Самый мощный и самый опасный уровень: программа меняет то, как работают базовые операции языка — создание класса, доступ к атрибуту, вызов метода.
class Column:
"""Дескриптор: перехватывает чтение и запись атрибута."""
def __init__(self, sql_type: str, primary_key: bool = False):
self.sql_type = sql_type
self.primary_key = primary_key
self.name = ""
def __set_name__(self, owner, name):
# Вызывается ровно один раз, при создании класса-владельца, и ДО __init_subclass__.
self.name = name
if "__columns__" not in vars(owner): # свой словарь, а не унаследованный
owner.__columns__ = {}
owner.__columns__[name] = self
def __get__(self, obj, objtype=None):
return self if obj is None else obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value is None and self.primary_key:
raise ValueError(f"{self.name}: первичный ключ не может быть None")
obj.__dict__[self.name] = value
class Model:
__columns__: dict[str, Column] = {}
def __init_subclass__(cls, *, table: str, **kw):
# PEP 487: хук вместо метакласса. Срабатывает при объявлении каждого наследника.
super().__init_subclass__(**kw)
cls.__table__ = table
if not vars(cls).get("__columns__"):
raise TypeError(f"{cls.__name__}: не объявлено ни одной колонки")
@classmethod
def create_table_sql(cls) -> str:
cols = ", ".join(
f"{n} {c.sql_type}" + (" PRIMARY KEY" if c.primary_key else "")
for n, c in cls.__columns__.items()
)
return f"CREATE TABLE {cls.__table__} ({cols})"
class User(Model, table="users"):
id = Column("BIGINT", primary_key=True)
email = Column("TEXT")
print(User.create_table_sql())
# CREATE TABLE users (id BIGINT PRIMARY KEY, email TEXT)
Это, в миниатюре, механика Django ORM, SQLAlchemy declarative и pydantic. Порядок хуков стоит знать точно — на нём ломаются половина самописных ORM:
Ключевые следствия из диаграммы: __set_name__ раньше __init_subclass__ (поэтому регистрировать
колонки надо в первом, а валидировать — во втором), а обе — раньше, чем кто-либо создаст экземпляр.
Официальный источник — Python Data Model
и PEP 487.
Когда метакласс не нужен. С момента появления PEP 487 (Python 3.6) подавляющее большинство задач,
для которых раньше писали метакласс, решается связкой __init_subclass__ + __set_name__ +
декоратор класса. Метакласс остаётся оправдан, только если надо менять сам процесс создания класса
(например, подменять __prepare__ для упорядоченного или ограничивающего пространства имён).
Цена метакласса — конфликты при множественном наследовании: у двух баз с разными метаклассами класс
просто не создастся.
Тот же уровень в других языках — динамические прокси. java.lang.reflect.Proxy и Castle DynamicProxy
генерируют класс-обёртку в рантайме; на этом стоят транзакции и кэширование в Spring, что мы разберём
подробнее в контексте https://courses.digitable.life/post/paradigms/08-aspect-and-event-driven/.
Уровень 5: кодогенерация — самая скучная и самая правильная техника
Порождение обычных исходников из декларативной схемы — то, что в 2020-е стало нормой промышленной сборки. Оно проигрывает макросам в элегантности и выигрывает во всём остальном.
по нему работает grep и точка останова SRC->>CC: компилируется как весь остальной код CC-->>DEV: ошибки типов ловятся ЗДЕСЬ, а не в проде SRC->>IDE: автодополнение и «перейти к определению» работают DEV->>SRC: заходит отладчиком и видит настоящие строки
Почему это выигрывает: порождённый код — это обычный код. По нему работают grep, автодополнение, точки останова, покрытие тестами, статические анализаторы и код-ревью. Ни макрос, ни рефлексия такого не дают: отладка развернувшегося макроса — это чтение того, чего нет в репозитории.
Промышленные примеры, которые стоит знать:
- Protocol Buffers / gRPC — схема сообщений и сервисов → клиенты и серверы на десятке языков.
- sqlc — вы пишете SQL, генератор выдаёт типобезопасные Go/Python-функции по реальной схеме БД. Ошибка в имени колонки становится ошибкой компиляции.
- Roslyn incremental generators в C# — то, что раньше делала рефлексия при старте, теперь делается при сборке.
- serde в Rust —
#[derive(Serialize)]разворачивается в специализированный код сериализации без единого рефлексивного вызова в рантайме. go generate+stringer, mockgen, ent — идиоматичный для Go способ обойти отсутствие выразительной системы типов.
// Скелет инкрементального генератора C#: он получает синтаксис и символы проекта,
// а на выходе просто добавляет файлы с исходниками.
[Generator]
public sealed class JsonGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext ctx)
{
// Дешёвый предикат отсеивает 99% узлов до дорогого семантического анализа —
// именно это делает генератор «инкрементальным» и не убивает время сборки.
var models = ctx.SyntaxProvider.ForAttributeWithMetadataName(
"MyApp.GenerateJsonAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (c, _) => DescribeModel(c));
ctx.RegisterSourceOutput(models, static (spc, model) =>
spc.AddSource($"{model.Name}.Json.g.cs", Render(model)));
}
}
# Правило, экономящее часы отладки: порождённый код всегда помечен и всегда воспроизводим.
# 1) заголовок «не редактировать» + маркер в имени файла (*.g.cs, *_gen.go, *.pb.go)
# 2) генерация — часть сборки, а не ручной шаг
# 3) в CI проверяем, что перегенерация ничего не меняет
go generate ./... && git diff --exit-code || {
echo "порождённый код рассинхронизирован со схемой"; exit 1; }
Как выбирать технику
Порядок перебора вариантов на практике — от дешёвого к дорогому:
- Обычная функция или функция высшего порядка. Решает 80 % случаев, ради которых тянутся к макросу.
- Дженерики с минимальным ограничением. Проверяется компилятором, стоит ноль в рантайме.
- Кодогенерация из схемы. Если структура описывается данными — генерируйте, а не рефлексируйте.
derive-макрос / аннотационный процессор. Узкая, декларативная точка расширения.- Рефлексия с кэшированием плана. Когда типы известны только в рантайме (плагины, DI-контейнер).
- Полноценные макросы / метаклассы. Только когда нужен новый синтаксис или изменение семантики, и цена задокументирована.
Типичные ошибки
- Дженерик ради дженерика.
Repository<T>с единственной реализациейRepository<User>— это не абстракция, а лишний уровень косвенности. Правило трёх работает и здесь: обобщайте на третьем случае, а не на первом. - Слишком сильное ограничение. Потребовали
Comparable, хотя алгоритму хватило быEquatable— и половина типов пользователя внезапно не подходит. - Ковариантность там, где её нельзя. Массивы в Java ковариантны (историческая ошибка), поэтому
Object[] a = new String[1]; a[0] = 42;компилируется и падаетArrayStoreExceptionв рантайме. - Рефлексия в горячем цикле. Классический профиль «40 % времени в
getattr/GetValue». Лечится вынесением плана за цикл. - Макрос вместо функции. Если аргументы всё равно вычисляются сразу и текст выражения не нужен — макрос не даёт ничего, кроме худшей отладки и невозможности передать его как значение.
- Негигиеничный макрос без документации. Появляется невидимая переменная, и через год никто
не понимает, откуда в области видимости взялся
conn. - Магия, которую нельзя загрепать. Метод, существующий только через
__getattr__илиmethod_missing, невозможно найти поиском. Это прямой удар по онбордингу. - Порождённый код в git без маркеров и без проверки в CI. Кто-нибудь обязательно поправит
.pb.goруками, и правка исчезнет при следующей генерации. - Раздувание бинарника мономорфизацией. Обобщённая функция с тяжёлым телом, инстанцированная под 30 типов. Лечится приёмом «тонкая обёртка над необобщённым ядром».
- Метапрограммирование как замена дизайну. Если для добавления поля нужно понять три уровня макросов — проблема не в языке.
Мини-итог
- Обобщённое программирование — это формулировка алгоритма через минимальные требования к типу. Переиспользование — следствие, а не цель.
- Параметричность превращает сигнатуру в теорему: чем меньше функция знает, тем меньше у неё способов ошибиться.
- Ограничения бывают номинальные, структурные и через классы типов; последние — единственные, что позволяют добавлять поведение чужим типам задним числом.
- Одна и та же обобщённая функция компилируется тремя способами с тремя разными моделями стоимости. Знать, какой у вашего языка, — обязательное условие для разговора о производительности.
- Дисперсия — не формальность:
List<Dog>не являетсяList<Animal>, и понимание, почему, экономит часы на исключениях рантайма. - Метапрограммирование ранжируется по стадии: чем раньше срабатывает, тем дешевле и безопаснее. Кодогенерация обычно предпочтительнее макроса, а макрос — рефлексии.
- Гигиена не опция. Макрос, тихо вводящий имена в область видимости вызывающего, — источник магии.
- Любая метатехника делает написание короче, а чтение — длиннее. Соотношение авторов и читателей кода всегда не в пользу автора.
Источники
- Musser D., Stepanov A. «Generic Programming», 1988 — исходная постановка задачи.
- Stepanov A., Lee M. «The Standard Template Library», 1995.
- Cardelli L., Wegner P. «On Understanding Types, Data Abstraction, and Polymorphism», 1985.
- Wadler P. «Theorems for Free!», 1989.
- Wadler P., Blott S. «How to make ad-hoc polymorphism less ad hoc», 1989 — классы типов.
- Kohlbecker E. et al. «Hygienic macro expansion», 1986.
- Veldhuizen T. «Template Metaprograms», 1995.
- Taha W., Sheard T. «MetaML and multi-stage programming», 2000.
- Go: type parameters proposal и реализация через словари и gcshape.
- Java Generics FAQ и Project Valhalla.
- C++ concepts, Rust Book: Generic Types, Traits, and Lifetimes, Rust Reference: macros by example.
- Elixir: Macros, Python Data Model, PEP 487, Zig comptime.
Языковые треки портала, где эти механизмы разбираются на конкретном синтаксисе: https://courses.digitable.life/post/golang/00-overview/, https://courses.digitable.life/post/csharp/00-overview/, https://courses.digitable.life/post/typescript/00-overview/, https://courses.digitable.life/post/elixir/00-overview/.
Что дальше
Метапрограммирование, как мы видели, умеет незаметно добавлять поведение в чужой код — прокси в Spring, макросы в Phoenix, генераторы в .NET. Если это делать системно, получается отдельная парадигма со своим словарём: срезы, советы, точки соединения. Плюс её зеркальное отражение — системы, где поток управления задаётся не вызовами, а событиями.
Аспектно-ориентированное и событийно-ориентированное программирование