Анатомія Lisp

Архітектура risp

Інтерпретатор Lisp на Rust без залежностей — одне гомоіконне значення, одне ітеративне ядро, три рушії виконання. Ось як складаються частини.

ключова ідея

Код — це дані

Один enum Value є водночас і розібраним AST, і значенням під час виконання. З цього випливають чотири наслідки — кожен окремою думкою.

a

Один тип, дві ролі

Розібране синтаксичне дерево та значення під час виконання — це той самий enum. Між кодом і виконанням немає окремого типу вузла.

pub enum Value {
  Nil,
  Bool(bool),
  Int(i64),
  Sym(Rc<str>),
  Pair(Rc<Cons>),
  Closure(Rc<Closure>),
  Builtin(BuiltinFn),
}
b

Списки — це значення

Список — це лише вкладені пари. cons будує пару; car і cdr розбирають її. Структура програми та структура даних — одна й та сама структура.

;; a list is just nested pairs
(cons 1 (cons 2 (cons 3 '())))  ;; => (1 2 3)

// …and a Pair is two slots:
struct Pair { car: Value, cdr: Value }
c

Макроси задарма

Оскільки код — це дані, макрос — це лише функція з форм у форми. Квазіцитування будує нову форму, а обчислювач її виконує.

;; a macro maps forms to forms
(defmacro unless (c body)
  `(if ,c nil ,body))

;; (unless done (step))
;;   => (if done nil (step))
d

Дешево передавати

Кожен payload у купі живе за Rc, тож передача Value клонує вказівник, а не дерево.

Pair(Rc<Cons>)        // payload behind an Rc

let b = a.clone();    // O(1): refcount++,
                      // shares the same cons

як це поєднано

Потік залежностей

Простежте дроти. Код стає одним гомоіконним значенням, і саме хаб Value — те, з чого читає все інше. Наведіть на вузол, щоб підсвітити його нутрощі.

fig · text → Value → engines
Архітектура інтерпретатора risp Вихідний текст тече через читача в єдиний гомоіконний enum Value. Хаб Value живить макророзширювач, середовище та обчислювач, який диспетчеризує до трьох рушіїв — деревного обхідника, байткодової ВМ та Cranelift JIT — що поділяють один тип помилки, одну нативну таблицю та один Lisp-прелюд і звіряються диференційним оракулом. deopt agree() код ;; risp (map f xs) читач tokenizer · Vec<Token> парсер на явному стеку 'x → (quote x) макророзширювач невобчислені форми quasiquote · depth gensym {g 0} enum Value Nil Bool Int Float Sym Str Pair(Rc) Closure Macro Builtin AST ≡ значення середовище Rc<RefCell> chain lookup define set! ітеративний 2-шляховий drop обчислювач · CEK St::Eval / St::Ret купний стек кадрів стек Rust: 3 кадри TCO in tail call деревний обхідник еталонний оракул fib(30) · 1682 ms байткодова ВМ випереджає CPython 3.14 fib(30) · 95 ms Cranelift JIT --features jit 6.3 ms · 10–23× спільне ядро — кожен рушій бачить той самий світ RispError · BuiltinFn table · prelude.lisp (fold → map · filter)
  1. 01

    Читання

    Текст → токени → одне дерево Value, на явному стеку, що не переповнюється.

  2. 02

    Розгалуження

    Хаб Value живить макророзширювач, середовище та обчислювач.

  3. 03

    Диспетчеризація

    Обчислювач передає кожну форму деревному обхіднику, ВМ або JIT.

  4. 04

    Спільне

    Усі троє бачать ті самі помилки, ті самі builtins, той самий прелюд.

усередині ядра

Усередині ядра

Три компоненти роблять реальну роботу між кодом і результатом. Кожен працює на явному стеку, тож керована користувачем глибина ніколи не сягає стеку викликів хоста.

01

Читач

Текст стає токенами, далі один цикл на явному стеку згортає їх у дерево Value. Мільйон рівнів вкладеності розбирається без рекурсивного спуску.

struct Frame {
  items: Vec<Value>,       // gathered so far
  wrappers: Vec<Rc<str>>,  // pending 'quote
  tail: Option<Value>,     // after a dot
}
// one loop — never recursive descent
02

Середовище

Області видимості — це ланцюг кадрів Rc<RefCell>. Пошук обходить батьків у циклі; замикання захоплює кадр свого визначення — лексично, не динамічно.

type Env = Rc<RefCell<Environment>>;

fn lookup(env: &Env, name: &str) -> Option<Value> {
  let mut cur = env.clone();
  loop {                  // walk parents
    if let Some(v) = cur.borrow().vars.get(name)
      { return Some(v.clone()); }
    cur = cur.borrow().parent.clone()?;
  }
}
03

Обчислювач

Машина CEK з явним стеком. Стек викликів Rust лишається три кадри завглибшки; вкладеність живе на купному Vec<Frame>, тож хвостові виклики крутяться у сталому просторі.

enum St { Eval(Value, Env), Ret(Value) }

fn run_loop(mut st: St, mut stack: Vec<Frame>) {
  loop {
    st = match st {
      St::Eval(e, env) => step_eval(e, env, stack)?,
      St::Ret(v) => match stack.pop() {
        None => return Ok(v),
        Some(f) => step_return(f, v, stack)?,
      },
    };
  }
}

три способи виконання

Три рушії, одне ядро

Деревний обхідник, байткодова ВМ та JIT — взаємозамінні фронтенди над єдиним обчислювачем, єдиною моделлю середовища та єдиною стандартною бібліотекою. Зміни рушій — мова не зміниться.

виміряно

Швидко — і доказово однаково

Кожна програма виконується на всіх трьох рушіях, і результати мусять збігатися з деревним обхідником побайтово. Швидкість ніколи не коштує правильності.

1682 ms
деревний обхідник · fib(30)
95 ms
байткодова ВМ · випереджає CPython 3.14
6.3 ms
Cranelift JIT · fib(30)
10–23×
швидше за CPython на циклах і арифметиці
0
залежностей у збірці за замовчуванням
0
панік на вводі користувача — збій є значенням

ніколи не падає

Збудовано, щоб не впасти

Глибину рекурсії задає ввід користувача, тож кожна частина ядра працює на явному купному стеку замість стеку викликів хоста.

стандартна бібліотека

Бібліотека, що рекурсує на власному стеку

Пласка нативна таблиця покриває примітиви — арифметику з перевіркою, cons / car / cdr, рівність, предикати. Усе інше — це Lisp, вбудований під час компіляції: fold хвостово-рекурсивний, а map і filter визначені через fold, тож їхня рекурсія живе на купному стеку обчислювача, а не на C-стеку хоста.

(def fold
  (lambda (f acc xs)
    (if (null? xs) acc
        (fold f (f acc (car xs)) (cdr xs)))))

(def map
  (lambda (f xs)
    (reverse (fold (lambda (a x) (cons (f x) a)) '() xs))))
map і filter — це fold; fold хвостово-рекурсивний.