Парадигмы разработки Обобщённое программирование и метапрограммирование
0%

Обобщённое программирование и метапрограммирование

Обобщённое программирование и метапрограммирование

С чего всё начинается: одна и та же функция, написанная шесть раз

Откройте почти любую взрослую кодовую базу и поищите функции с именами вида findUserById, findOrderById, findInvoiceById. Скорее всего, тела у них отличаются ровно одним словом — именем типа. То же самое с maxInt/maxFloat, с тремя вариантами кэша, с десятью почти одинаковыми DTO-мапперами.

Это не лень программиста. Это дыра в выразительной силе языка: язык умеет абстрагировать значения (параметры функции), но не умеет абстрагировать типы. Пока такой дыры нет затычки, единственный способ переиспользовать алгоритм — скопировать его.

Два разных ответа на эту боль и составляют тему статьи:

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

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

Общая карта парадигм — в https://courses.digitable.life/post/paradigms/00-overview/. Эта статья опирается на понятия из https://courses.digitable.life/post/paradigms/02-oop/ (подтипы, интерфейсы, диспетчеризация) и https://courses.digitable.life/post/paradigms/03-functional/ (классы типов, композиция).

Карта территории

История: обобщённость придумали ради алгоритмов, а не ради контейнеров

Ключевой поворот — 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. Метапрограммирование

Где именно вы вклиниваетесь в конвейер

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

Дальше — по уровням, снизу вверх по мощности и сверху вниз по безопасности.

Уровень 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.invokeMethodHandle с кэшированием, либо генерация байткода (так работают 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, автодополнение, точки останова, покрытие тестами, статические анализаторы и код-ревью. Ни макрос, ни рефлексия такого не дают: отладка развернувшегося макроса — это чтение того, чего нет в репозитории.

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

  • 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; }

Как выбирать технику

Порядок перебора вариантов на практике — от дешёвого к дорогому:

  1. Обычная функция или функция высшего порядка. Решает 80 % случаев, ради которых тянутся к макросу.
  2. Дженерики с минимальным ограничением. Проверяется компилятором, стоит ноль в рантайме.
  3. Кодогенерация из схемы. Если структура описывается данными — генерируйте, а не рефлексируйте.
  4. derive-макрос / аннотационный процессор. Узкая, декларативная точка расширения.
  5. Рефлексия с кэшированием плана. Когда типы известны только в рантайме (плагины, DI-контейнер).
  6. Полноценные макросы / метаклассы. Только когда нужен новый синтаксис или изменение семантики, и цена задокументирована.

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

  • Дженерик ради дженерика. 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>, и понимание, почему, экономит часы на исключениях рантайма.
  • Метапрограммирование ранжируется по стадии: чем раньше срабатывает, тем дешевле и безопаснее. Кодогенерация обычно предпочтительнее макроса, а макрос — рефлексии.
  • Гигиена не опция. Макрос, тихо вводящий имена в область видимости вызывающего, — источник магии.
  • Любая метатехника делает написание короче, а чтение — длиннее. Соотношение авторов и читателей кода всегда не в пользу автора.

Источники

Языковые треки портала, где эти механизмы разбираются на конкретном синтаксисе: 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. Если это делать системно, получается отдельная парадигма со своим словарём: срезы, советы, точки соединения. Плюс её зеркальное отражение — системы, где поток управления задаётся не вызовами, а событиями.

Аспектно-ориентированное и событийно-ориентированное программирование

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

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

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

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