Вивчай 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. Опануй ідіоми тут — і решта шести файлів перестане здаватися чужою.
Повний рекомендований шлях такий:
error.rs(129 рядків) — enum, що несуть дані,Display, псевдонімRispResult.lexer.rs(209 рядків) — володіння та зрізи:&strпротиString, запозичення вхідних даних замість їх копіювання.value.rs— серце, читається у два проходи (більше нижче).parser.rs(399 рядків) — рекурсія таResult: рекурсивний спуск, протягування?, побудова cons-комірокRc<Pair>.env.rs(217 рядків) — внутрішня мутабельністьRc<RefCell<...>>, патерн, на якому спотикається кожен початківець.eval.rs(~700 рядків) — винагорода: машина з явним стеком, а не рекурсивний деревообхід.
Після цього є необов’язковий просунутий трек — bytecode.rs → compiler.rs → vm.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.