Лексична область видимості в Lisp на Rust: середовища, замикання та drop із двома шляхами
Глибоке занурення в модель областей видимості risp — ланцюжок Rc<RefCell<Environment>>, ітеративні lookup і set!, замикання, що захоплюють кадри за посиланням, та drop із швидким/повільним шляхом, що звільняє ланцюжок областей глибиною в мільйон без рекурсії та без виділення пам'яті на гарячому шляху.
Джерело: github.com/kissishka/risp
Кожне посилання на змінну в мові має розв’язуватися проти чогось, і це щось — середовище. У risp представлення навмисно просте — хеш-мапа на область плюс вказівник на батька, — але два рішення навколо нього не такі: як замикання захоплює область, і як глибокий ланцюжок областей звільняється, не підриваючи стек. Ця стаття — про хребет.
Кадр — це мапа плюс батько
Область — це один Environment: власні прив’язки та необов’язкове посилання на
кадр, що його охоплює. Дескриптор має лічильник посилань і внутрішню змінюваність,
бо замикання роблять кадри спільними, а set! їх змінює:
/// A shared, mutable handle to an Environment frame.
pub type Env = Rc<RefCell<Environment>>;
pub struct Environment {
vars: HashMap<Rc<str>, Value>,
parent: Option<Env>,
}
Глобальний кадр не має батька; кожен інший кадр — це child області, у якій його
було створено:
pub fn new_global() -> Env {
Rc::new(RefCell::new(Environment { vars: HashMap::new(), parent: None }))
}
pub fn child(parent: &Env) -> Env {
Rc::new(RefCell::new(Environment { vars: HashMap::new(), parent: Some(parent.clone()) }))
}
Цей parent.clone() — це збільшення Rc, а не копіювання прив’язок батька: новий
кадр коштує одне виділення та один лічильник посилань, незалежно від того, скільки
області сидить над ним. Це структура даних, яку увесь обчислювач пронизує наскрізь:
def і прив’язка параметрів пишуть у локальний кадр, виклик створює дочірній, а
пошук читає вгору ланцюжком.
Пошук обходить ланцюжок, ітеративно
Читання змінної означає пошук у поточному кадрі, потім у його батькові, потім у його батькові — аж до глобального. Як і все інше, що обходить керовану користувачем глибину, цей обхід є циклом, ніколи не рекурсією — ланцюжок областей завглибшки в тисячі коштує ітерацій у купі, а не кадрів стеку:
pub fn lookup(env: &Env, name: &str) -> Option<Value> {
let mut cursor = env.clone();
loop {
let next = {
let frame = cursor.borrow();
if let Some(v) = frame.vars.get(name) { return Some(v.clone()); }
frame.parent.clone()
};
match next {
Some(parent) => cursor = parent,
None => return None,
}
}
}
Визначення та присвоєння розходяться по очевидній лінії. define завжди пише
локальний кадр — саме це дає let чи параметру затінити зовнішню прив’язку.
set! натомість обходить ланцюжок у пошуках найближчої наявної прив’язки і
змінює її на місці, повертаючи UnboundSymbol, якщо такої немає. Затінення
випливає прямо з «define локальний, lookup спершу найближчий»: дочірній кадр може
прив’язати x до нового значення, поки x батька лишається недоторканим, а
set! із дочірнього сягає вгору й переприв’язує батьків.
Замикання захоплюють кадр, а не копію
Саме для цього й потрібен Rc. Замикання — це його параметри й тіло
плюс середовище, у якому його було визначено:
pub struct Closure {
pub name: Option<Rc<str>>,
pub params: Vec<Rc<str>>,
pub rest: Option<Rc<str>>, // variadic tail
pub body: Rc<Vec<Value>>,
pub env: Env, // the captured defining scope
}
Захоплення — це просто env: env.clone(), одне збільшення лічильника посилань.
Замикання тепер співволодіє своїм визначальним кадром і тримає його живим, доки
живе саме замикання, — і це і є лексичне захоплення: повернута лямбда, що
закрилася над локальною, усе ще може читати й змінювати її, бо обидва вказують на
той самий RefCell. Застосування замикання не сягає назад туди, де його
викликали; воно робить child того, де його визначили:
Value::Closure(c) => {
let frame = child(&c.env); // extend the DEFINING scope, not the call site
bind_params(&c, &args, &frame)?;
// ... evaluate the body in `frame`
}
Цей один рядок — child(&c.env), а не child(&caller_env) — це вся різниця між
лексичною та динамічною областю видимості. Захоплений env — це відповідь на
питання «що означали вільні змінні в цій лямбді?», зафіксована в момент визначення.
Небезпека: звільнення ланцюжка областей
Спільне володіння кадрами через Rc створює ту саму пастку, що й
довгі cons-списки. Замикання
захоплює кадр; батько того кадру — інший кадр; звільнення останнього власника
найзовнішнішого замикання може запустити ланцюжок Drop, що рекурсує на один кадр
Rust на кожен рівень області. Тисяча вкладених let, захоплених замиканням, на
наївному похідному Drop рекурсувала б на тисячу кадрів углиб під час teardown.
Але змусити кожне звільнення середовища йти через ітеративний teardown
обкладало б податком звичайний випадок — виклик функції, що прив’язує два цілих
параметри й повертається, — виділенням пам’яті, якого йому ніколи не треба. Тож
Drop має два шляхи.
Drop із двома шляхами
Швидкий шлях — це чиста перевірка: якщо цей кадр не є останнім власником свого
батька (хтось інший досі тримає батька, тож звільнення не каскадуватиме вгору
ланцюжком) і жодне з його прив’язаних значень не може тримати глибокий граф Rc,
то звичайне звільнення полів нікуди не рекурсує. Дай йому статися, не виділяй
нічого:
impl Drop for Environment {
fn drop(&mut self) {
let parent_unique = self.parent.as_ref()
.is_some_and(|p| Rc::strong_count(p) == 1);
let has_deep_value = self.vars.values()
.any(|v| matches!(v, Value::Pair(_) | Value::Closure(_) | Value::Macro(_)));
if !parent_unique && !has_deep_value {
return; // the hot path: a call frame of atoms, sharing its parent
}
// Slow path: we own a (possibly deep) parent chain or a value graph.
let (parent, vals) = self.take_children();
let mut seed: Vec<Teardown> = Vec::with_capacity(vals.len() + 1);
if let Some(p) = parent { seed.push(Teardown::Env(p)); }
seed.extend(vals.into_iter().map(Teardown::Val));
dismantle(seed);
}
}
Повільний шлях переміщує подальші посилання кадру — його батька та його прив’язані
значення — на робочий стек у купі, який осушує
спільний цикл dismantle.
take_children спершу спорожнює кадр, тож на момент звільнення його полів вони
вже None/порожні й нікуди не рекурсують. Enum Teardown — це те, що дозволяє
одному циклу звільняти обидві небезпеки разом: довгий ланцюжок Value і
глибокий ланцюжок Env лягають на той самий стек, тож звільнення замикання, що
захопило тисячу кадрів let, так само пласке, як звільнення списку з тисячі
елементів.
Умови перевірки несуть основну логіку. parent_unique питає «чи я зараз запущу
каскад?» — якщо в батька є інші власники, звільнення мене зупиняється тут, і
ланцюжок стає чиєюсь чужою турботою пізніше. has_deep_value питає «чи можуть мої
власні прив’язки рекурсувати?» — атоми не можуть, а захоплене замикання чи список
можуть. Лише коли одне з цих істинне, ми платимо за ітеративний шлях.
Переважно поширений кадр — виклик, що прив’язує пару чисел і спільно володіє
глобальним як батьком, — бере ранній return і не коштує нічого.
Ціна, яку стирає VM
У цієї моделі є одна справжня ціна: кожне читання змінної — це хешування рядка та
зондування HashMap, повторюване вгору ланцюжком. Для деревообхідного
інтерпретатора це гарний компроміс — простий, коректний і безпечний для стеку.
Але це також найбільший розрив між наївним інтерпретатором і швидким, і саме тому
байткод-VM risp розв’язує кожну
змінну до фіксованого індексу слота під час компіляції й читає її як єдиний доступ
до масиву stack[base + i] — без мапи, без хешування, без обходу ланцюжка.
Ланцюжок середовищ, описаний тут, — це те, що компілятор існує, аби сплющити.
Деревообхідний інтерпретатор тримає його, бо ланцюжок — це найпростіша річ, що
очевидно коректна, а «очевидно коректне» — це еталон, проти якого швидші двигуни
диференційно тестуються.
Представлення — це
найпростіша річ, що працює (мапа й вказівник), а кмітливість обмежена двома
місцями: однорядковим child(&c.env), що дає лексичне захоплення, та drop із
двома шляхами, що тримає звичайний випадок безкоштовним, водночас усе ще
звільняючи патологічний ланцюжок без рекурсії. Зроби хребет простим і безпечним
для стеку — і оптимізувальні двигуни матимуть щось надійне, що сплющувати.
Часті запитання
- Як деревообхідний інтерпретатор представляє область видимості змінних?
- risp представляє кожну лексичну область як кадр: HashMap прив'язок плюс необов'язковий вказівник на батьківський кадр. Середовище — це Rc<RefCell<Environment>>, тож кадри спільні й змінювані. Пошук іде від локального кадру вгору ланцюжком батьків до глобального кадру.
- Як замикання захоплює своє середовище в Rust?
- Замикання risp зберігає env: Env — Rc-клон кадру, у якому його було визначено. Клонування Rc — це збільшення лічильника посилань, а не копіювання, тож замикання спільно володіє визначальною областю й тримає її живою. Захоплені змінні лишаються живими й змінюваними через цей спільний кадр.
- Як звільнити глибокий ланцюжок областей без переповнення стеку?
- Drop для Environment у risp має два шляхи. Швидкий шлях, для кадру виклику, що прив'язує лише атоми і спільно володіє батьком, дає статися звичайному звільненню полів без виділення пам'яті. Повільний шлях, коли кадр володіє глибоким ланцюжком батьків або графом значень, передає посилання в ітеративний цикл teardown.