Compiling a Lisp: Lambda lifting
Переписал Ghuloum-туториал на Python (~300 строк). Убрал читалку S-выражений и бинарный код — теперь текстовая ассемблерная печать.
Lambda-lifting требует:
- знать связанные переменные;
- собирать свободные переменные лямбд;
- накапливать создаваемые
code
-объекты.
Связывают let
и lambda
; для них обновляем окружение.
Lifter
class LambdaConverter:
def __init__(self):
self.labels = {}
def convert(self, expr, bound, free):
match expr:
case int() | Char() | bool():
return expr
case str() if expr in bound or expr in BUILTINS:
return expr
case str():
free.add(expr)
return expr
case ["if", t, c, a]:
return ["if",
self.convert(t, bound, free),
self.convert(c, bound, free),
self.convert(a, bound, free)]
lift_lambdas
запускает обход и возвращает (labels …)
.
Lambda
Лямбда:
- связывает параметры;
- выделяет код;
- захватывает внешнее окружение.
Пример:
(lambda () x) ; x свободна
превращается в
(labels ((f0 (code () (x) x)))
(closure f0 x))
Даже если x
связан снаружи, внутри лямбды он считается свободным.
Комментарии (15)
- Участники рекомендуют три современные книги по компиляторам, вдохновлённые статьёй Ghuloum: «Writing a C Compiler» (Sandler), «Essentials of Compilation» на Racket и Python (Siek).
- Обсуждали «lambda lifting»: преобразование, выносящее замыкания наверх, уменьшая их размер вплоть до полного исчезновения.
- Уточнили, что «lambda lifting» в статье связан с разделом 3.11 о сложных константах в Scheme.
- Разбирали, почему современный ИИ использует Python, а не Lisp: удобство как «клея» для C++/CUDA, упадок доли рынка Lisp и смена парадигмы ИИ.
Why tail-recursive functions are loops
Хвостовая рекурсия превращает рекурсию в цикл: компилятор заменяет вызов на безусловный jmp
, поэтому стек не растёт.
Обычная рекурсия кладёт промежуточные значения в стек, тратит O(n) памяти и вытесняет кэш.
Цикл же держит результат в аккумуляторе, использует O(1) памяти и линейное время.
Ключевое правило хвостовой рекурсии:
вызов должен быть последним выражением функции. Тогда компилятор может выбросить текущий фрейм и передать управление напрямую.
Пример суммы списка
Обычная версия:
(define (sum l)
(if (empty? l) 0
(+ (first l) (sum (rest l)))))
Хвостовая версия:
(define (sum l acc)
(if (empty? l) acc
(sum (rest l) (+ acc (first l)))))
Аргументы l
и acc
перезаписываются «на месте», как переменные цикла.
Упражнение 1 — счётчик чётных/нечётных:
(define (even-odd l [e 0] [o 0])
(if (empty? l) (cons e o)
(let ([x (first l)])
(if (even? x)
(even-odd (rest l) (add1 e) o)
(even-odd (rest l) e (add1 o))))))
Упражнение 2 — сглаживание дерева:
используйте аккумулятор-список и обход в обратном порядке, чтобы сохранить хвостовой вызов.
Комментарии (121)
- Теоретически хвостовая рекурсия и циклы эквивалентны: любую хвостовую рекурсию можно превратить в цикл (и наоборот), но взаимно-рекурсивные функции требуют дополнительной работы.
- На практике циклы чаще проще для чтения и не ломают стек, тогда как хвостовая рекурсия нуждается в оптимизации (TCO), которую не все языки поддерживают (Python, V8 её нет).
- Некоторые языки (Scala, Clojure, F#) дают компромиссные конструкции (@tailrec, recur), сохраняющие функциональный стиль и гарантирующие отсутствие переполнения стека.
- Вместо явной хвостовой рекурсии часто достаточно высокоуровневых комбинаторов вроде fold/map, но они не всегда позволяют досрочный выход и могут расходовать O(N) памяти.
- Участники сходятся во мнении: владеть обоими подходами полезно, выбор зависит от языка, задачи и привычек команды.