Реактивное программирование и 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) меняются извне, остальное производно.
country"] C["корзина
cart"] end subgraph derived["Производные — чистые вычисления"] E["ставка НДС"] F["сумма без налога"] G["итог к оплате"] end subgraph eff["Эффекты — граница с внешним миром"] I["рендер строки итога"] J["метрика в аналитику"] end B --> E --> G C --> F --> G G --> I G --> J classDef s stroke:#3b82f6,stroke-width:2px classDef d stroke:#10b981,stroke-width:2px classDef e stroke:#f59e0b,stroke-width:2px,stroke-dasharray: 5 3 class B,C s class E,F,G d class I,J e
Три класса узлов — это «функциональное ядро в императивной оболочке» из 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 это редко проблема, в бэкенде и обработке данных — основная.
Ответ индустрии — спецификация 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?
Источники
- Conal Elliott, Paul Hudak. Functional Reactive Animation (ICFP 1997) — первоисточник FRP.
- Conal Elliott. Push-pull functional reactive programming (Haskell Symposium 2009).
- Reactive Streams Specification — читается за 20 минут и окупается многократно.
- RxJS: операторы, Learn RxJS и Kotlin Flow — практические справочники.
- Project Reactor Reference — лучший разбор backpressure в JVM.
- Akidau et al. The Dataflow Model (VLDB 2015) — event time, окна, водяные знаки.
- Halbwachs et al. The synchronous dataflow programming language LUSTRE (IEEE 1991).
- Umut Acar. Self-Adjusting Computation — теория, на которой стоят Incremental и Salsa.
- Andre Staltz. The introduction to Reactive Programming you’ve been missing.
Что дальше
Мы разобрали парадигму, где программа — граф зависимостей между значениями. Дальше сменим угол зрения радикально: посмотрим на
код, который пишется не про данные, а про другой код. Как написать один алгоритм сразу для всех типов, что такое
мономорфизация и стирание типов, чем макросы Rust и Elixir отличаются от #define и почему кодогенерация — одновременно самый
мощный и самый опасный инструмент в арсенале.