Парадигмы разработки Реактивное программирование и dataflow
0%

Реактивное программирование и dataflow

Реактивное программирование и dataflow

Электронная таблица как самая успешная реактивная система в истории

Введите в ячейку A1 число 10, в B1 — формулу =A1*2, в C1=B1+A1. Теперь поменяйте A1 на 20. Что вы сделали, чтобы обновились B1 и C1? Ничего. Вы не писали updateB(); updateC(); и не забыли обновить одну из них. Это реактивное программирование в чистом виде: вы описали зависимости между значениями, а пересчётом и его порядком занимается среда. Excel — система, которой пользуются сотни миллионов человек, не считающих себя программистами, и большинство их «программ» работает. Причина не в дисциплине бухгалтеров, а в парадигме.

Сравните с типичным императивным UI-кодом:

let price = 100, qty = 2;

function setQty(newQty) {
  qty = newQty;
  totalEl.textContent = price * qty;        // не забыть
  taxEl.textContent  = price * qty * 0.2;   // не забыть
  updateCheckoutButton();                   // не забыть
  // …а ещё аналитика и бейдж корзины. Забыли одно — рассинхрон.
}

Каждое место, меняющее qty, обязано помнить полный список последствий. Это знание не выражено в коде: оно живёт в голове разработчика и рассыпается на третьем месяце проекта (симптом — «сумма в корзине не совпадает с суммой в чекауте»). Реактивный подход инвертирует ответственность: не отправитель изменения перечисляет последствия, а каждое производное значение объявляет, от чего зависит.

const price = signal(100), qty = signal(2);
const total = computed(() => price() * qty());     // total сам знает свои входы
const tax   = computed(() => total() * 0.2);
effect(() => { totalEl.textContent = total(); });  // эффект сам знает, когда перерисоваться
qty.set(3);                                        // всё остальное — обязанность среды

Мы перешли от «как обновлять» к «чему равно» — тот же сдвиг к декларативности, что в https://courses.digitable.life/post/paradigms/04-declarative-and-logic/, но применённый к времени и изменению. Общая карта парадигм — https://courses.digitable.life/post/paradigms/00-overview/.

Определение через две оси

Слово «реактивный» перегружено до бессмысленности. Разложим его на два независимых понятия, и путаница уйдёт.

Ось 1 — что представляет узел.

  • Behavior (сигнал, ячейка, Signal, BehaviorSubject, StateFlow) — величина, которая всегда имеет значение и меняется во времени: корзина, ширинаОкна. Математически — функция Time → A.
  • Event stream (Observable, Flow, Stream) — дискретная последовательность моментов с полезной нагрузкой: клики, входящиеСообщения. Математически — [(Time, A)].

Различие практическое. У сигнала осмысленно спросить «сколько сейчас» — у потока кликов нет. Поток кликов осмысленно считать («сколько за 5 минут») — а «сколько раз ширина окна была равна 800» вопрос без ответа. Половина ошибок в реактивном коде — обращение с одним как с другим (см. ошибку про Subject ниже).

Ось 2 — кто инициирует передачу. Push: источник, изменившись, будит потребителей — низкая задержка, но потребителя может «залить». Pull: потребитель спрашивает сам (итераторы, генераторы, ленивые списки) — перегрузки нет, но есть задержка. Push-pull гибрид: источник проталкивает лишь уведомление о протухании, а пересчёт идёт, когда значение реально запросили. Гибрид обычно оптимален, на нём стоят современные библиотеки сигналов и системы инкрементальных вычислений.

Dataflow-программирование — более общий и старый термин: программа задаётся как направленный граф, в узлах которого операции, а по рёбрам текут данные; узел срабатывает, когда на входах есть данные. Реактивность — это dataflow, у которого узлы обновляются в ответ на внешние изменения. Из того же корня растут Apache Flink, Kafka Streams, графы TensorFlow, make/Bazel и схемотехника на Verilog.

Немного истории: это старше, чем ваш фронтенд-фреймворк

Два вывода. Первый: синхронные языки (Lustre, Esterel, SCADE) — самая строгая ветвь реактивности; на них написана управляющая логика Airbus A340/A380, потому что для них доказуемо, что реакция укладывается в такт. Второй: индустрия дважды прошла цикл «сигналы → отказ от них → возврат», и второй возврат (2021–2023) случился, когда научились делать распространение мелкогранулярным и glitch-free.

Граф зависимостей

Формально реактивная программа — направленный ациклический граф G = (V, E), где вершины суть значения, а ребро u → v означает «v вычисляется через u». Источники (входящая степень 0) меняются извне, остальное производно.

Три класса узлов — это «функциональное ядро в императивной оболочке» из https://courses.digitable.life/post/paradigms/03-functional/, развёрнутое во времени. Отсюда правило: в производных узлах не должно быть побочных эффектов, иначе движок, имеющий право пересчитывать их когда угодно и сколько угодно раз, начнёт дублировать ваши HTTP-запросы. Ключевой же вопрос реализации — когда источник изменился, в каком порядке пересчитывать затронутые узлы?

Глитчи: почему «обойти в глубину» — неправильный ответ

Возьмём ромб: a → b, a → c, а d = c - b. Наивная реализация проталкивает изменение вглубь сразу по каждому ребру:

class Cell:
    """Наивная ячейка: сразу проталкивает изменение вглубь по одному ребру."""
    def __init__(self, compute=None, value=None):
        self.compute, self._v, self.subs = compute, value, []
    def get(self): return self._v
    def set(self, v):
        self._v = v
        for s in self.subs: s.refresh()      # обход в глубину — источник ошибки
    def refresh(self): self.set(self.compute())

a = Cell(value=1)
b = Cell(compute=lambda: a.get() * 2,       value=2)
c = Cell(compute=lambda: a.get() + 10,      value=11)
d = Cell(compute=lambda: c.get() - b.get(), value=9)
a.subs += [b, c]; b.subs.append(d); c.subs.append(d)
d.subs.append(Cell(compute=lambda: print("render d =", d.get())))

a.set(2)
# render d = 7   <-- глитч: c ещё старое (11), b уже новое (4)
# render d = 8   <-- настоящий ответ

Значения d = 7 не существует ни при каком согласованном состоянии входов: при a = 1 ответ 9, при a = 2 — 8. Семёрка — артефакт порядка обхода. Такое промежуточное несогласованное значение называется глитчем.

Глитч в ромбе зависимостей и его устранение топологическим порядком

Пока d только рисуется на экране, глитч — мигание пикселей. Но если на d висит эффект «отправить заказ» или «записать в аудит», наружу утекает значение, которого никогда не было: дублирующиеся HTTP-запросы, всплеск ложных алертов, мусор в аналитике. Лечение простое по формулировке — обновлять узлы в топологическом порядке, то есть только после всех входов; реализуют это через высоту (height, она же rank, — длину самого длинного пути от источника) и очередь с приоритетом по высоте.

Работающий движок сигналов

Мини-движок в духе SolidJS/Vue: автоматическое отслеживание зависимостей, glitch-free распространение по высоте, батчинг, динамический граф. Код рабочий, запускается как есть.

import heapq, itertools
from contextlib import contextmanager

_tracking, _queue, _seq, _batch = [], [], itertools.count(), 0

class Node:
    """Вершина dataflow-графа."""
    def __init__(self):
        self.subs, self.deps = set(), set()   # кто зависит от меня / от кого завишу я
        self.height = 0                       # длина самого длинного пути от источника
        self.queued = False

    def _observe(self):
        """Регистрируем себя как зависимость вычисления, идущего прямо сейчас."""
        if not _tracking: return
        c = _tracking[-1]
        self.subs.add(c); c.deps.add(self)
        c._raise(self.height + 1)

    def _raise(self, h):
        """Высота потребителя строго больше высоты входа — иначе топология сломается."""
        if h <= self.height: return
        self.height = h
        for s in self.subs: s._raise(h + 1)

    def _rebind(self, fn):
        """Пересчёт с перезахватом зависимостей: граф динамический."""
        for d in self.deps: d.subs.discard(self)
        self.deps.clear()
        _tracking.append(self)
        try: return fn()
        finally: _tracking.pop()

def _schedule(node):
    if node.queued: return           # ключ к отсутствию глитчей: узел в очереди не более раза
    node.queued = True
    heapq.heappush(_queue, (node.height, next(_seq), node))

def _flush():
    if _batch: return
    while _queue:
        _, _, node = heapq.heappop(_queue)   # наименьшая высота = топологический порядок
        node.queued = False
        node.update()

@contextmanager
def batch():
    """Несколько записей — одна волна пересчёта."""
    global _batch
    _batch += 1
    try: yield
    finally:
        _batch -= 1; _flush()

class Signal(Node):
    """Источник: значение, которое меняют извне."""
    def __init__(self, value): super().__init__(); self._v = value
    def get(self): self._observe(); return self._v
    def set(self, new):
        if new == self._v: return    # equality cutoff: волна не пойдёт вообще
        self._v = new
        for s in self.subs: _schedule(s)
        _flush()

class Computed(Node):
    """Производное значение: чистая функция от других узлов."""
    def __init__(self, fn): super().__init__(); self.fn, self._v, self.stale = fn, None, True
    def _recompute(self):
        new = self._rebind(self.fn)
        changed, self._v, self.stale = new != self._v, new, False
        return changed
    def get(self):
        self._observe()
        if self.stale: self._recompute()
        return self._v
    def update(self):
        if self._recompute():        # значение не изменилось — волна дальше не идёт
            for s in self.subs: _schedule(s)

class Effect(Node):
    """Граница с внешним миром: только здесь разрешены побочные эффекты."""
    def __init__(self, fn): super().__init__(); self.fn = fn; self.update()
    def update(self): self._rebind(self.fn)
    def dispose(self):               # без этого граф держит эффект, а эффект — весь контекст
        for d in self.deps: d.subs.discard(self)
        self.deps.clear()

Проверяем на том самом ромбе, на батчинге и на динамическом графе:

a = Signal(1)
b, c = Computed(lambda: a.get() * 2), Computed(lambda: a.get() + 10)
d = Computed(lambda: c.get() - b.get())
log = []; Effect(lambda: log.append(d.get()))
a.set(2); print(log)   # [9, 8] — семёрки нет, эффект сработал ровно дважды

first, second = Signal(1), Signal(10)
total = Computed(lambda: first.get() + second.get())
runs = []; Effect(lambda: runs.append(total.get()))
with batch():
    first.set(2); second.set(20)
print(runs)            # [11, 22], а не [11, 21, 22]: промежуточной 21 наружу не было

flag, x, y = Signal(True), Signal("x"), Signal("y")
dyn = Computed(lambda: x.get() if flag.get() else y.get())
out = []; Effect(lambda: out.append(dyn.get()))
y.set("y2")            # y сейчас не зависимость — пересчёта нет
flag.set(False)        # граф перестроился: теперь зависим от y, а не от x
y.set("y3"); print(out)   # ['x', 'y2', 'y3']

Сложность. Пусть V', E' — вершины и рёбра затронутого подграфа (достижимого из изменённого источника). Волна распространения стоит O(V' log V' + E'): каждый узел попадает в кучу не более одного раза благодаря флагу queued, каждое ребро просматривается один раз. Замена кучи на корзины по высотам (level buckets) даёт чистые O(V' + E') — так сделано в Incremental от Jane Street. Память — O(V + E) на граф. Для сравнения, наивный DFS на ромбе глубины k делает до 2^k пересчётов: это не страшилка, а реальная причина, по которой самодельная реактивность в больших формах подвешивает браузер.

Почти весь практический выигрыш дают три оптимизации. Equality cutoff: пересчитанное значение равно старому — волна останавливается (пользователь печатает, но нормализованный запрос не изменился, и половина графа не трогается). Ленивость (push-pull): пометить «протухшим» дёшево, посчитать дорого — считаем только запрошенное, невидимая вкладка не пересчитывается вообще. Мелкая гранулярность: обновлять текстовый узел DOM, а не перерисовывать компонент — O(изменившихся значений) вместо O(размера дерева); именно этим сигнальные фреймворки бьют Virtual DOM.

Потоки событий и операторы

Сигналы отвечают на «чему равно сейчас». Второй половине реактивного мира интересно «что происходило и в каком порядке» — это потоки событий и алгебра операторов над ними (документация RxJS, ReactiveX). Традиционная нотация — marble diagram, время слева направо:

источник:  --a---b----c--d----|          внешний:   --1------2--------------|
           debounceTime(100)                        switchMap(n => запрос(n))
результат: ----a-----b------d-|          внутренние:  --r1x  (отменён двойкой)
   (c проглочено: d пришло                                  ----r2a--r2b--|
    раньше, чем истёк таймер)            результат: ---------------r2a--r2b-|

Различие, на котором ломаются почти все новички, — четыре способа обработать «событие пришло, пока предыдущая обработка ещё идёт»:

Оператор Что делает с текущей работой Когда применять
mergeMap / flatMap ничего, всё идёт параллельно независимые задачи, порядок не важен (загрузка N файлов)
concatMap ставит новое в очередь порядок критичен (последовательные записи в БД)
switchMap отменяет предыдущее актуален только последний результат (поиск по мере ввода)
exhaustMap игнорирует новое, пока идёт текущее защита от дабл-клика по кнопке «Оплатить»

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

const results$ = fromEvent(input, 'input').pipe(
  map(e => (e.target as HTMLInputElement).value.trim()),
  filter(q => q.length >= 2),          // не дёргаем бэкенд из-за одной буквы
  debounceTime(300),                   // ждём паузу в наборе
  distinctUntilChanged(),              // «клав» → «клава» → «клав»: второй раз не спрашиваем
  switchMap(q =>
    ajax.getJSON<Item[]>(`/api/search?q=${encodeURIComponent(q)}`).pipe(
      // экспоненциальная задержка с джиттером: не устраиваем DDoS своему же бэкенду
      retry({ count: 3, delay: (_e, i) => timer(200 * 2 ** i + Math.random() * 100) }),
      // ошибка ОДНОГО запроса не должна убивать весь поток: гасим её ВНУТРИ switchMap
      catchError(() => of([] as Item[])),
    ),
  ),
  startWith([] as Item[]),
  shareReplay({ bufferSize: 1, refCount: true }),  // один HTTP на нескольких подписчиков
  takeUntil(destroy$),                             // автоматическая отписка при закрытии
);
results$.subscribe(render);

Два места здесь регулярно становятся инцидентами. catchError внутри switchMap, а не снаружи: в Rx onError терминален, и ошибка, дошедшая до внешнего потока, убивает подписку навсегда — симптом в проде «поиск перестал работать после одной 500-ки, помогает только F5». И отписка: каждая живая подписка удерживает замыкание, а через него компонент и DOM-узлы; забытая отписка в списке из 500 строк — утечка, которая проявится через час работы вкладки.

Reactive Streams: backpressure как часть протокола

У push-модели есть фундаментальный изъян: источник может генерировать быстрее, чем потребитель обрабатывает. В UI это редко проблема, в бэкенде и обработке данных — основная.

Три стратегии при перегрузке: неограниченный буфер, drop и request(n)

Ответ индустрии — спецификация Reactive Streams (2013–2015), вошедшая в JDK 9 как java.util.concurrent.Flow: спрос течёт вверх по потоку явным сигналом, потребитель говорит «готов принять n элементов», издатель не имеет права отдать больше. Весь протокол — четыре интерфейса: Publisher.subscribe, Subscriber с onSubscribe/onNext/onError/onComplete, Subscription с request(n)/cancel и Processor (одновременно и то и другое):

Спецификация делает обязательными четыре правила, полезные даже тем, кто просто пользуется библиотекой: onNext вызывается последовательно, без наложения (обработчику не нужны блокировки); издатель не отдаёт больше суммарно запрошенного; onError/onComplete терминальны; request(n) и cancel можно звать из любого потока и они не должны блокировать. Практический пример на Project Reactor — чтение из БД и запись во внешний API, который держит 10 запросов в секунду:

Flux.from(repository.streamAllOrders())    // источник отдаёт десятки тысяч строк в секунду
    .onBackpressureBuffer(1_000,           // ограниченный буфер вместо неограниченного
        dropped -> log.warn("переполнение, отброшен заказ {}", dropped.id()),
        BufferOverflowStrategy.DROP_OLDEST)
    .limitRate(64)                         // это и есть request(n): просим по 64, а не по одному
    .flatMap(order -> externalApi.push(order)
            .timeout(Duration.ofSeconds(5))
            .retryWhen(Retry.backoff(3, Duration.ofMillis(200)))
            .onErrorResume(e -> Mono.empty()),   // одна неудача не рушит весь конвейер
        /* concurrency = */ 10)                  // не более 10 одновременных запросов
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe();

Без concurrency = 10 реактивный код радостно откроет тысячу соединений и положит внешний сервис. В Elixir тот же принцип встроен в GenStage на уровне модели: потребитель сам запрашивает события, а min_demand/max_demand задают окно — подробнее об экосистеме в https://courses.digitable.life/post/elixir/00-overview/.

Оговорка, которую часто пропускают. Backpressure работает, только если источник можно замедлить. БД, файл, Kafka, TCP-соединение — можно (окно TCP само сработает как обратное давление). Биржевой тикер, датчик, движения мыши, UDP-мультикаст — нельзя. Для них честный выбор один: осознанно терять данныеconflate (склеить в последнее значение), sample, throttle, dropOldest. Худшее решение — не выбрать ничего и получить неограниченный буфер: это отложенный OOM плюс задержка, растущая линейно со временем работы сервиса.

Жизненный цикл подписки

Из диаграммы прямо читаются два главных источника багов: состояние Ошибка терминально (отсюда «поток умер после одной 500-ки»), а из Активен без явного cancel выхода нет (отсюда утечки памяти).

Cold и hot. Cold-источник начинает работу на каждую подписку, у каждого подписчика своя копия (HTTP-запрос, чтение файла, Flux.range): две подписки — два запроса. Hot живёт независимо от подписчиков и вещает всем, кто опоздал — тот пропустил (клики, WebSocket, Subject, топик Kafka). Отсюда проблема №1 у новичков: подписались на холодный HTTP-Observable в трёх местах шаблона и получили три одинаковых запроса; лечится shareReplay({ bufferSize: 1, refCount: true }). Обратная ошибка тоже реальна: shareReplay без refCount держит подписку на источник вечно, даже когда подписчиков не осталось.

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

1. Побочный эффект в производном узле (движок вправе пересчитать map/computed сколько угодно раз) и 2. вложенные подписки вместо оператора (subscribe внутри subscribe — тот же callback hell, только дороже: отмена не распространяется, ошибки теряются, порядок не гарантирован):

// ПЛОХО: аналитика выстрелит столько раз, сколько движок решит пересчитать
const total$ = items$.pipe(map(items => { analytics.track('recalc'); return sum(items); }));
// ХОРОШО: чистое преобразование, эффект только на границе
const total$ = items$.pipe(map(sum));
total$.subscribe(t => analytics.track('total', t));

// ПЛОХО
userId$.subscribe(id => api.load(id).subscribe(user => render(user)));
// ХОРОШО
userId$.pipe(switchMap(id => api.load(id))).subscribe(render);

3. Subject как хранилище состояния. Subject не имеет значения и не отдаёт его опоздавшим; для состояния нужен BehaviorSubject/StateFlow/сигнал. Симптом: «компонент, отрисованный позже, показывает пустоту».

4. Забытая отписка. В Angular — takeUntilDestroyed(), в React — функция очистки в useEffect, в Kotlin — viewModelScope, вручную — CompositeDisposable. Растущее число detached DOM-узлов в DevTools почти всегда означает живые подписки.

5. Циклы в графе и лишние узлы. a зависит от b, b — от a: хороший движок бросит «cyclic dependency detected», плохой повесит вкладку (если цикл нужен по смыслу — вводите явную задержку на такт, как в Lustre/Esterel). И обратное: если значение вычисляется из аргументов и используется один раз, нужен не computed, а обычная функция — каждый узел графа стоит памяти, аллокаций и когнитивной нагрузки.

6. Блокировка event-loop и слепая отладка. subscribeOn влияет на источник, observeOn — на всё ниже по цепочке; блокирующий вызов (JDBC, File.read) на event-loop-потоке останавливает весь сервис — вы получаете худшее из двух миров: сложность реактивного кода и пропускную способность блокирующего (для блокирующего — Schedulers.boundedElastic(), Dispatchers.IO). Стек вызовов при этом бесполезен: видно планировщик, а не место, где всё началось, поэтому наблюдаемость закладывают сразу — tap с меткой, Hooks.onOperatorDebug() в Reactor, rxjs-spy, -Dkotlinx.coroutines.debug.

Где это работает в проде

Интерфейсы. SolidJS, Vue 3, Angular Signals, Svelte 5 runes, Preact Signals, MobX построены на графе сигналов с топологическим распространением; React пошёл другим путём (перерисовка поддерева и сравнение Virtual DOM), но и туда пришло предложение Signals в TC39. На мобильных — SwiftUI/Combine, Kotlin StateFlow, Jetpack Compose со своей системой снапшотов.

Бэкенд. Project Reactor + Spring WebFlux, Akka/Pekko Streams, Vert.x, RxJava. Мотив не «модно», а экономия потоков: 10 000 соединений на пуле из 8 потоков вместо 10 000 потоков по мегабайту стека. Честная оговорка: с приходом виртуальных потоков в JDK 21 значительная часть этой мотивации исчезла — блокирующий код на них даёт похожую масштабируемость при куда более простой отладке, и реактивные стеки остаются оправданы там, где нужны композиция потоков и backpressure, а не просто много соединений.

Обработка данных. Apache Flink, Kafka Streams, Spark Structured Streaming, Beam — dataflow в чистом виде: вы описываете граф операторов, среда занимается партиционированием, состоянием, чекпойнтами и восстановлением. Модель времени (event time против processing time) и водяные знаки — прямое развитие идей потоков событий; каноническая работа — The Dataflow Model, VLDB 2015.

Инкрементальные вычисления и реальное время. Тот же граф зависимостей с целью «пересчитать минимум после изменения входа»: Incremental от Jane Street в финансовых расчётах, Salsa внутри rust-analyzer (поэтому подсветка ошибок не ждёт полной перекомпиляции), Bazel и make, Turbopack и Nx. На другом полюсе — Lustre/SCADE в авионике и АЭС, где программа компилируется в конечный автомат с доказанной верхней границей времени такта, и Verilog/VHDL, где вся схемотехника есть dataflow с распространением по фронту такта.

Реактивность в семье парадигм

Реактивный код почти всегда функционален внутри (операторы — чистые преобразования) и объектен снаружи (Observable, Flux, Signal — объекты с интерфейсом). С акторами из https://courses.digitable.life/post/paradigms/05-concurrent-actors-csp/ общая идея асинхронных сообщений, но разная единица композиции: у акторов — адресуемый процесс с почтовым ящиком и своим состоянием, у реактивности — неименованный поток значений, композируемый операторами; акторы лучше для сущностей с идентичностью и жизненным циклом, потоки — для конвейеров преобразований. И отличайте реактивное от событийно-ориентированного (https://courses.digitable.life/post/paradigms/08-aspect-and-event-driven/): событийная архитектура даёт emit и on, реактивная добавляет алгебру композиции, явный жизненный цикл (завершение, ошибка, отмена) и обратное давлениеEventEmitter не умеет ни отменить незавершённую работу, ни сказать «притормози», ни сообщить «поток закончился», и это приходится изобретать заново в каждом проекте.

Мини-итог и чеклист

  • Реактивность = граф зависимостей + автоматическое распространение изменений; остальное — детали реализации.
  • Различайте behavior (всегда есть значение) и event stream (дискретные моменты): разные вещи, разные операции.
  • Наивное распространение вглубь даёт глитчи и до 2^k пересчётов; правильный порядок — топологический, по высотам, с дедупликацией очереди.
  • Push — низкая задержка, pull — защита от перегрузки, push-pull — оптимум: инвалидируем жадно, считаем лениво. В push-системах обязателен ответ на «что делать при перегрузке»: request(n), ограниченный буфер с явной стратегией или осознанная потеря данных; неограниченный буфер — отложенный OOM.
  • Производные узлы чисты; эффекты — на границе и с явным dispose. Реактивность оправдана при многих асинхронных источниках и сложной логике их комбинирования; для одного fetch это лишняя сложность.

Чеклист код-ревью: есть ли отписка на каждый subscribe? нет ли subscribe внутри subscribe? выбран ли *Map осознанно? catchError внутри или снаружи? что происходит при перегрузке? нет ли эффектов в map/computed? не блокирует ли что-нибудь event-loop?

Источники

Что дальше

Мы разобрали парадигму, где программа — граф зависимостей между значениями. Дальше сменим угол зрения радикально: посмотрим на код, который пишется не про данные, а про другой код. Как написать один алгоритм сразу для всех типов, что такое мономорфизация и стирание типов, чем макросы Rust и Elixir отличаются от #define и почему кодогенерация — одновременно самый мощный и самый опасный инструмент в арсенале.

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

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

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

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

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