Вивчай Rust, читаючи risp — справжній інтерпретатор Lisp (не черговий іграшковий проєкт)

risp, Lisp-інтерпретатор Rust без залежностей, постачає learn/: порядок читання (error.rs→eval.rs) і 19 уроків, кожен прив'язаний до реального файлу та рядка.

Джерело: github.com/kissishka/risp

Більшість ресурсів «вивчи Rust» підсовують тобі іграшкові програми: конвертер температури, гру у вгадайку, клон grep, який ти кидаєш на шостому розділі. risp робить протилежну ставку. Це справжній інтерпретатор Lisp лише на std — не той знаменитий «Risp» із блогопосту 2019 року, що вмістився в gist, а крейт на кілька тисяч рядків із лексером, парсером рекурсивного спуску, обчислювачем із явним стеком, байткод-VM, що обганяє CPython 3.14, та необов’язковим JIT на Cranelift — і він постачає каталог learn/, який перетворює цей робочий артефакт на курс Rust. Ідея проста: мову вивчаєш швидше, читаючи код, який щось робить, ніж пишучи код, який не робить нічого — за умови, що хтось підкаже тобі порядок, у якому його читати. risp підказує. Цей допис проходить цей порядок.

Один конвеєр, читай від найлагіднішого

Увесь проєкт — це єдиний конвеєр, описаний у lib.rs:

text → lexer → parser → Value → eval → Value

Читання його в порядку виконання кинуло б тебе одразу на обчислювач, який є найважчим файлом у репозиторії. Тож learn/reading-order.md робить навпаки — він сортує файли від простого до складного за складністю Rust, а не за потоком даних. Ти починаєш з error.rs, 129 рядків, найлагіднішого файлу і, не випадково, того, чиї ідіоми зустрічаються в кожному іншому файлі. Саме тут ти знайомишся з варіантами enum, що несуть дані, як-от Arity { name, expected, got }, написаним вручну трейтом Display, функціями-конструкторами та псевдонімом RispResult, протягнутим крізь усю кодову базу. Уся історія помилок у Rust — це Result<T, E>; цей файл і є тим E. Опануй ідіоми тут — і решта шести файлів перестане здаватися чужою.

Повний рекомендований шлях такий:

  1. error.rs (129 рядків) — enum, що несуть дані, Display, псевдонім RispResult.
  2. lexer.rs (209 рядків) — володіння та зрізи: &str проти String, запозичення вхідних даних замість їх копіювання.
  3. value.rs — серце, читається у два проходи (більше нижче).
  4. parser.rs (399 рядків) — рекурсія та Result: рекурсивний спуск, протягування ?, побудова cons-комірок Rc<Pair>.
  5. env.rs (217 рядків) — внутрішня мутабельність Rc<RefCell<...>>, патерн, на якому спотикається кожен початківець.
  6. eval.rs (~700 рядків) — винагорода: машина з явним стеком, а не рекурсивний деревообхід.

Після цього є необов’язковий просунутий трек — bytecode.rscompiler.rsvm.rs (другий рушій, байткод-VM, що обганяє CPython 3.14), а потім jit.rs (нативна кодогенерація за --features jit), плюс мережевий шар, чиї варіанти Bytes і Tcp типу Value задокументовано в дописі про HTTP-сервер. Але шість основних файлів — це хребет.

value.rs — це серце, читай його двічі

Порядок читання ставить value.rs третім і каже читати його у два проходи, бо це файл, який імпортує кожен інший файл. Один enum, Value, є водночас і розібраним AST, і значенням під час виконання — гомоіконічність, втілена буквально — і він має тринадцять варіантів:

/// The one enum that is both AST and runtime value.
#[derive(Clone, Debug)]
pub enum Value {
    Nil,
    Bool(bool),
    Int(i64),
    Float(f64),
    Str(Rc<str>),
    Symbol(Rc<str>),
    Pair(Rc<Pair>),
    Builtin(Rc<Builtin>),
    Closure(Rc<Closure>),
    Macro(Rc<Closure>),
    Compiled(Rc<ClosureObj>),
    Bytes(Rc<[u8]>),
    Tcp(Rc<std::cell::RefCell<TcpKind>>),
}

Перший прохід, рядки 1–120, про те, чому кожен важкий вантаж лежить за Rc. Це центральний урок Rust усього проєкту: спільне володіння без збирача сміття. Оскільки Str, Symbol, Pair, Closure та решта — усі є вказівниками з підрахунком посилань, Value: Clone має складність O(1) — клонування значення копіює вказівник і збільшує лічильник посилань, але ніколи не копіює структуру, на яку він указує. Скалярні варіанти (Int, Bool, Float) — це тривіальне побітове копіювання. У будь-якому разі нічого не дублюється, і саме так мова без GC дозволяє кільком спискам спільно використовувати один хвіст. Цей єдиний факт пояснює тисячу викликів .clone(), розкиданих по кодовій базі, які інакше виглядали б марнотратними.

Другий прохід — який документ радить відкласти, доки Rc не стане природним, — це написаний вручну Drop для Pair та його помічник dismantle. Я детально розбираю «чому» в дописі про відсутність переповнення стеку; тут достатньо знати, що порядок читання позначає це як просунуте і радить повернутися пізніше. Сенс двох проходів — у педагогічній чесності: ти можеш зрозуміти форму enum Value першого дня, а його руйнування — десятого дня, а вдавати протилежне означає лише отримати читача, який відскакує від рядка 110 і кидає все.

Навчальна програма: дев’ятнадцять уроків, кожен прив’язаний до рядка

Дехто мислить файл за файлом; інші — концепція за концепцією. Для другого типу learn/curriculum.md проходить той самий код у вигляді дев’ятнадцяти уроків у п’яти частинах. Два документи навмисно переплітаються — parser.rs з’являється в навчальній програмі під часами життя (L5) та ok_or (L6), а не як окремий розділ — тож ти можеш перемикати треки на ходу.

Те, що змушує навчальну програму працювати, — це правило прив’язки, заявлене з самого початку: сніпет скорочено; справжній урок — це вказівник file.rs:line. Кожен урок цитує точні рядки, дає практичну вправу й завершується перевіркою cargo. Урок 1, керування потоком, указує на main.rs:17 для зрізових патернів і захисних умов (guards), eval.rs:606 для let-else та compiler.rs:92 для let-ланцюжків:

let result: Result<(), RispError> = match args.as_slice() {
    [] => repl::run(),                                     // slice pattern: zero args
    [flag, expr] if flag == "-e" => runner::run_str(expr), // guard
    [path] if !path.starts_with('-') => runner::run_file(path),
    _ => { /* usage error */ }
};
let Value::Symbol(sym) = &head else { return Err(/* … */) };  // let-else: bind-or-bail

Вправа — додати гілку [flag] if flag == "--version" і запустити cargo run -- --version. Це вся педагогіка в мініатюрі: прочитай цитовані рядки, зміни одну річ, дай компілятору сказати, чи ти зрозумів. Власна інструкція навчальної програми безапеляційна — «не просто читай; зміни код і дай cargo виправити тебе. Цей цикл зворотного зв’язку і є курсом».

Частина 1 (L1–5) — це основи: керування потоком і зрізові патерни, володіння та &str проти String у lexer.rs:32, enum із даними, структури, блоки impl та конструктори у value.rs:94, а також часи життя у parser.rs:20, де Parser<'a> запозичує свій зріз токенів. Звідти вона піднімається: Частина 2 — це помилки та трейти (Result, ?, map_err, власний enum помилок, потім Display/Error/From/Default); Частина 3 — це модель пам’яті: Rc, потім патерн Rc<RefCell<...>> для ланцюжка областей видимості у env.rs:15, потім колекції, ітератори та замикання; Частина 4 — це модулі, поділ lib/bin та умовна компіляція з можливостями Cargo. Кожна сходинка прив’язана, кожна має вправу, кожна завершується перевіркою.

Частина 5: фірмові трюки

Просунуті уроки — це місце, де risp перестає бути загальним підручником із Rust і починає навчати власного, вистражданого дизайну. Два трюки повторюються, і обидва походять з одного правила — коли рекурсія живе в даних, ти не можеш дозволити собі рекурсію в коді.

Урок 15 — це власний Drop. Правильний список із 200 000 cons-комірок — це граф Rc глибиною 200 000, і згенерований компілятором рекурсивний деструктор рекурсував би на один кадр на комірку й переповнив би стек тієї ж миті, коли ти його звільнив би. Виправлення risp — написане вручну, ітеративне руйнування:

impl Drop for Pair {
    fn drop(&mut self) {
        // Seed the shared iterative teardown with this cell's two slots so a long
        // `cdr` spine or deep `car` nest is reclaimed without recursing.
        dismantle(vec![
            Teardown::Val(std::mem::replace(&mut self.car, Value::Nil)),
            Teardown::Val(std::mem::replace(&mut self.cdr, Value::Nil)),
        ]);
    }
}

Drop — це той єдиний трейт, який ти ніколи не можеш вивести (derive) — ти завжди пишеш його вручну, — і вправа вбиває це в голову брутально: заміни тіло на наївну рекурсивну версію, подивись, як тест глибокої структури переповнює стек, потім поверни назад. Є справжній регресійний тест у value.rs:613, який будує структури глибиною 200 000 у потоці зі стеком на 256 KiB і доводить, що побудова, порівняння і звільнення — усі виконуються у сталому стеку.

Урок 16 застосовує ту саму ідею до обчислення. eval.rs:114 — це run_loop, машина CEK із явним стеком: керувальний регістр із двома варіантами (St::Eval / St::Ret) та Vec<Frame> продовжень (continuations) у купі. Вкладений виклик нарощує Vec, а не стек викликів Rust, тож глибока рекурсія Lisp, яка підірвала б деревообхід, виконується в обмеженому просторі — а хвостові виклики виконуються у сталому просторі. Вправа змушує тебе друкувати stack.len() під час виконання хвостоворекурсивного циклу на 100 000 ітерацій і дивитися, як стек кадрів залишається майже пласким.

Дістатися до unsafe на межі JIT

Урок 18 — це вершина: unsafe Rust, який навчають там, де проєкт справді цього потребує, а не як абстрактну небезпеку. Документ ретельно обрамлює це правильно — unsafe не вимикає перевірку запозичень (borrow checker); він розблоковує п’ять конкретних операцій, які компілятор не може перевірити, і ти виконуєш зобов’язання коментарем // SAFETY:, що заявляє інваріант, який ти підтримуєш. risp використовує його рівно у двох місцях, обидва в категорії «виклик unsafe-функції».

Перше — це jit.rs:99, де mem::transmute переінтерпретує сирий вказівник на код як типізовану функцію extern "C", щоб VM міг викликати машинний код, скомпільований JIT:

// SAFETY: `p` is a finalized native fn compiled with this exact extern "C" ABI,
// kept alive by the JITModule; `arity` matches.
let r: i64 = unsafe { match entry.arity {
    1 => mem::transmute::<*const u8, extern "C" fn(*mut JitCtx, i64) -> i64>(p)(cx, int(0)),
    /* … */
}};

ABI має точно збігатися — це і є весь інваріант, який обіцяє коментар. Друге використання — це get_unchecked у vm.rs:246, який пропускає перевірки меж у гарячому циклі байткоду, бо компілятор уже гарантував, що кожен індекс дійсний. Допис про JIT на Cranelift занурюється глибше в бік кодогенерації; завдання навчальної програми вужче й краще пасує для навчання — змусити тебе прочитати кожен коментар // SAFETY: у vm.rs та jit.rs і заявити для кожного, що зламалося б, якби інваріант був хибним.

Це правильна висота, щоб зустріти unsafe уперше: два місця виклику, кожне захищене, кожне з заявленою причиною, що сидять поверх ядра, ітеративній дисципліні якого ти вже довіряєш.

Як цим насправді користуватися

Порядок читання та навчальна програма сходяться в одному, і це найважливіша порада в обох файлах: вчися, ламаючи це. Проєкти-віхи піднімаються від тривіального до справжнього. Перший — додавання вбудованої функції (square x) — приблизно десять рядків, але це змушує тебе торкнутися Value, RispResult та перевірки арності одразу, що є більшою частиною Частини 1 і Частини 2 в одній вправі. Останній — додавання опкоду Op::PrintTop: визнач його в bytecode.rs, випусти його в compiler.rs, обробіть його у vm.rs, що змушує пройти повне коло компілятор-до-VM і доводить, що ти зрозумів Частину 5.

Усе перевіряється тими самими чотирма командами, які документи розмістили на початку обох файлів:

cargo run                      # REPL — try (+ 1 2 3), (car '(a b c))
cargo run -- -e "(+ 1 2 3)"    # one-shot
cargo test                     # see what's verified
cargo doc --open               # the doc-comments become a browsable book

Причина, чому це працює як курс, а купа посилань «читай Rust book» — ні, у тому, що кожна концепція є несучою. Rc, який ти вивчаєш в Уроці 8, — це той самий Rc, чиє руйнування переповнює стек в Уроці 15 і чий вказівник ти transmute в Уроці 18. Ти ніколи не вивчаєш можливість заради неї самої — ти вивчаєш можливість, яка була потрібна саме цьому інтерпретатору, щоб існувати, у тому порядку, у якому вони були потрібні. Відкрий error.rs, запусти cargo doc --open поруч із ним і починай ламати речі.

Часті запитання

Як можна вивчити Rust, читаючи інтерпретатор Lisp?
risp постачає каталог learn/ із двома шляхами: reading-order.md проходить кодову базу файл за файлом від найлагіднішого (error.rs, 129 рядків) до винагороди (eval.rs, ~700 рядків), а curriculum.md навчає 19 уроків концепція за концепцією. Кожен урок прив'язаний до реального вказівника файл-і-рядок із практичною вправою та перевіркою cargo, тож компілятор виправляє вас.
Який рекомендований порядок читання файлів кодової бази risp?
Спершу error.rs для найуживаніших ідіом, потім lexer.rs для володіння та зрізів (slices), потім value.rs у два проходи як серце проєкту, потім parser.rs для рекурсії та Result, потім env.rs для внутрішньої мутабельності Rc<RefCell<...>>, потім eval.rs для машини з явним стеком. Необов'язковий просунутий трек охоплює bytecode.rs, compiler.rs, vm.rs та jit.rs.
Які концепції Rust охоплює навчальна програма risp?
Дев'ятнадцять уроків у п'яти частинах: основи на кшталт керування потоком, володіння, enum, структур і часів життя (lifetimes); помилки та трейти; модель пам'яті з Rc, RefCell, колекціями та замиканнями; модулі та можливості Cargo; а також просунуті теми, зокрема власний ітеративний Drop, обчислювач із явним стеком, байткод і unsafe Rust на межі JIT із transmute та get_unchecked.

← Усі дописи