Теория языков программирования
Операционная семантика
1936 год. Алонзо Чёрч и Алан Тьюринг параллельно строят математические модели вычисления. Чёрч даёт лямбда-исчисление: всё вычисление - это последовательность подстановок переменных в выражения. Никаких регистров, никакой памяти, никакого процессора. Только правила переписывания term-ов. Эта формализация выглядела игрушечной 50 лет, потом породила Lisp, ML, Haskell, монады, Curry-Howard correspondence, формально верифицированный компилятор CompCert и теоремы безопасности типов Rust. А Sergey Brin, делая первый прототип PageRank в 1996, написал его на лямбда-стиле в Python - и десятилетиями позже архитекторы Stable Diffusion VAE строили generative pipelines всё ещё на тех же reduction rules. Операционная семантика 1981 года - это математическая база, на которой держится всё доказуемое в современном программировании.
- **Rust borrow checker**: формально верифицирует владение через linear logic поверх operational semantics. 350 000 строк Rust компилятора применяют reduction rules к AST для статической проверки memory safety
- **CompCert (Xavier Leroy, INRIA)**: формально верифицированный C-компилятор. 100 000 строк Coq доказательств о preservation operational semantics через каждую фазу компиляции. Используется в авионике и safety-critical
- **PyTorch torch.compile**: применяет operator fusion и graph optimization как reduction rules over computational graph. Confluence теоремы гарантируют bit-identical результаты vs eager mode
- **Lean 4 / Coq**: proof assistants, на которых формализуется математика (proof of Four Color Theorem, Feit-Thompson) и языковая семантика (CompCert, seL4 microkernel). Все стоят на operational rules
- **Speculative decoding в vLLM**: lazy evaluation для LLM inference - небольшая 'draft' модель генерирует токены лениво, основная LLM валидирует параллельно. 2-3x throughput speedup при семантической эквивалентности
Small-step: одна редукция за раз
Гордон Плоткин в 1981 году поставил вопрос ребром: что значит, что программа 'выполняется'? Не на каком процессоре, не сколько занимает в RAM - что вообще означает значение фрагмента кода `2 + 3 * 4`? Структурная операционная семантика (SOS) дала ответ: программа - это term, исполнение - последовательность переписываний term'а согласно правилам. Каждый шаг - **small-step** - заменяет одну подвыражение на эквивалентное, ближе к финальному значению. `2 + 3 * 4 -> 2 + 12 -> 14`. Не больше, не меньше. Никакой магии 'выполнения', только формальные правила переписывания.
Small-step видит каждый промежуточный шаг и позволяет рассуждать о состояниях между ними. Это критично для безопасности: если на полпути встретилось деление на ноль, в small-step мы зафиксируем точный момент остановки и состояние программы. Coq и Isabelle/HOL формализуют языки через small-step - это пристёгивает каждую теорему к точной семантике. SPARK Ada в авионике использует small-step: можно доказать, что на каждом шаге программы инвариант сохраняется.
Связь с современным ML видна напрямую. ReAct-агент (Yao et al., 2022) ровно small-step семантика: 'thought -> action -> observation -> thought'. Каждый шаг переписывает состояние агента в новое состояние ближе к ответу. Chain-of-thought - тот же принцип на уровне токенов: LLM не выдаёт ответ за один inference, а строит trace из промежуточных state-ов, каждый из которых формально аналогичен redex в operational semantics.
Программа `if (10/0 > 5) then x else y` имеет тип int. Что показывает small-step семантика в момент ошибки?
Big-step: за один прыжок к значению
Big-step семантика (также natural semantics, Kahn 1987) идёт другим путём: вместо того, чтобы фиксировать каждую микроредукцию, она напрямую отображает выражение на его конечное значение. Запись `e \Downarrow v` читается 'e вычисляется в v'. Правила выглядят как умозаключения с горизонтальной чертой: посылки сверху - вычисления подвыражений, заключение снизу - результат целого. Big-step проще пишется и читается, но теряет промежуточные состояния. Хорош для интерпретаторов, плох для рассуждений об ошибках посередине.
Big-step легко имплементируется как рекурсивный интерпретатор - каждое правило соответствует case-у в pattern match. Это популярный подход в учебниках и в реальных интерпретаторах: SML/NJ, GHCi, Racket. Минус: расходящиеся программы (бесконечные циклы) не имеют big-step дериваций - просто нет правила, выводящего значение. Поэтому термирующие fragments PL обычно описывают big-step'ом, а полные языки с возможным non-termination - small-step'ом.
Почему big-step семантика не подходит для языка с возможным non-termination (например, while-циклы или рекурсия без bound)?
Reduction rules: алгебра преобразований
Правила редукции - это шахматные ходы языка программирования. В лямбда-исчислении главное правило - **бета-редукция**: `(\x.e1) e2 -> e1[e2/x]` (заменить все x в теле на аргумент). Из этой одной формулы построено всё функциональное программирование. **Альфа-конверсия** - переименование связанных переменных (`\x.x` идентично `\y.y`). **Эта-редукция** - `\x.f x ≡ f` при условии что x не свободен в f, формализует extensionality функций.
Теорема Чёрча-Россера (1936): порядок редукций не влияет на финальный результат, если вычисления завершаются. Это **confluence** - бриллиант теории редукций. Если оптимизатор переписывает `let x = e1 in e2` в `e2[e1/x]`, теорема гарантирует, что результат идентичен. На confluence держится корректность всех современных компиляторов FP-языков. У GHC оптимизатор использует rewrite rules - локальные редукции, доказанно сохраняющие семантику. То же самое в торче: `torch.compile` применяет операторные fusion-ы, и confluence гарантирует bit-identical результаты.
В современном ML reduction rules появляются в неожиданном месте. PyTorch FX и JAX transformations работают как операционная семантика над computational graph: 'эта последовательность операций редуцируется в эту'. AOT-компиляторы для GPU (Triton, OpenAI) используют ту же идею - доказательно сохраняющие переписывания computation graph. Стандартная теория редукций 1936 года напрямую обеспечивает корректность fused kernels на H100 в 2026 году.
Компилятор Haskell переписывает `map f (map g xs)` в `map (f . g) xs`. На какой теореме держится корректность этой оптимизации?
Evaluation order: lazy vs strict
В выражении `f e` operational semantics предлагает выбор: вычислить e первым (strict/eager) или передать e в f как 'обещание', и вычислить только если f его реально использует (lazy/non-strict). Большинство языков выбирают strict - это проще для компилятора и предсказуемее по памяти. Haskell, Miranda и часть R стоят на lazy - это даёт композиционные приёмы (infinite lists, modular abstractions) ценой непредсказуемости памяти (space leaks). Скала и Swift добавляют lazy опционально через ключевое слово.
Lazy эвалюация - это операционная семантика chain-of-thought в LLM. Когда модель генерирует токены sequentially, она не строит full reasoning tree сразу - каждый следующий токен 'ленивый промис', вычисляемый только когда нужен. Speculative decoding (Vasilis et al., 2023) - буквально memoization из call-by-need: дешёвая модель предсказывает несколько токенов вперёд, expensive модель валидирует их параллельно. Если совпадение - 2-3x speedup, если нет - откатываемся к standard decoding. Семантически тождественно lazy thunks с validation.
Выбор evaluation order - один из самых глубоких архитектурных решений в дизайне языка. Python выбрал strict с опциональными generators (lazy через yield). Rust strict с future-ами (lazy через .await). JavaScript strict с promises (lazy через then). Haskell lazy по умолчанию со strict через ! и seq. Каждый выбор тянет за собой mental model программиста - и это формализуется именно через operational semantics.
Операционная семантика - чисто академическая тема. На практике достаточно знать как работает интерпретатор/компилятор конкретного языка.
Operational semantics - это техническая база, на которой держатся все формальные верификации, type system soundness proofs и компиляторные оптимизации. Без неё нельзя доказать ни одну properties about programs.
Когда GHC переписывает `map f . map g` в `map (f . g)` - это применение fold/build fusion, доказанного через operational semantics. Когда Rust borrow checker верифицирует владение - доказательство через linear logic + operational rules. Когда TLA+ проверяет распределённую систему - моделирование states через operational transitions. SPARK Ada в Airbus, Coq в CompCert (формально верифицированный C-компилятор), Lean 4 - все стоят на operational/denotational семантике. Это не теория ради теории. Это базис для всего, что доказательно работает.
PyTorch lazy evaluation для autograd: forward pass строит computational graph, gradient вычисляется только при .backward(). К какой evaluation strategy ближе всего этот pattern?
Связанные темы
Operational semantics - фундамент, на котором строятся все формальные методы programming languages:
- Лямбда-исчисление — Канонический объект operational semantics: бета-редукция как elementary step
- Денотационная семантика — Альтернативный взгляд: программа как функция в complete lattice, не процесс
- AST и компиляторы — Operational rules определены поверх AST - синтаксис задаёт термы, семантика - редукции
- Ownership и Rust — Borrow checker - operational rules + linear logic, формально проверяющие memory safety
Ключевые идеи
- **Small-step** показывает каждую микроредукцию - идеален для рассуждений об ошибках и инвариантах. Подходит для языков с возможным non-termination
- **Big-step** напрямую связывает выражение с финальным значением - проще для интерпретаторов, но не отражает intermediate states и diverging programs
- **Reduction rules** (бета, эта, альфа) - алгебра преобразований. Теорема Чёрча-Россера (confluence) гарантирует независимость финального результата от порядка редукций
- **Evaluation order** (strict vs lazy) - архитектурное решение языка. Lazy даёт композиционность (infinite lists, deferred computation), strict - предсказуемость памяти и времени
- **Практическое применение** везде, где нужно доказательно: Rust borrow checker, CompCert, torch.compile, Lean 4. Operational semantics - не теория ради теории, а инструмент
Вопросы для размышления
- В JavaScript `||` оператор использует short-circuit evaluation (правая часть не вычисляется если левая truthy). Это lazy или strict? Как формализовать это в operational semantics?
- Когда выгоднее big-step семантика, а когда small-step? Какой выбор сделают разработчики формальной верификации embedded прошивки и почему?
- PyTorch dynamic graph - call-by-need. JAX vs PyTorch: JAX использует functional pure semantics с traced functions, а PyTorch - imperative. Какие пересмотры operational rules за этим стоят?
Связанные уроки
- plt-06-lambda-calculus — Лямбда-исчисление - канонический объект формальной семантики
- plt-13-ownership — Семантика владения в Rust формализована через linear logic + operational rules
- plt-15-denotational-semantics — Денотационная семантика - альтернативный взгляд: программа как функция между доменами
- comp-16-ast — AST - синтаксическая основа, на которой операционные правила определяются
- emb-14 — Verification safety кода требует операционных правил для доказательств
- aie-17-agent-fundamentals — ReAct-агент - small-step семантика: think -> act -> observe -> think
- comp-20-semantic-passes