Ресурсы и линейность
Некоторые значения обозначают что-то за пределами памяти вашей программы. Файловый дескриптор — небольшое целое число, которое операционная система выдаёт на открытый файл или сокет; запись о нём хранится в таблице ОС, пока вы его не закроете, и процесс, который открывает файлы и не закрывает их, рано или поздно упирается в лимит. Блокировку нужно отпустить, иначе все потоки, которые её ждут, зависнут. Транзакцию базы данных нужно зафиксировать или откатить. За каждым таким значением стоит обязательство. В C оно живёт в комментарии. В C++ и Rust его незаметно выполняет деструктор — функция, которую компилятор сам вызывает, когда значение выходит из области видимости. kola не делает ни того, ни другого. Такие значения объявляются линейными ресурсами, и компилятор требует, чтобы каждый ресурс был израсходован ровно один раз на каждом пути выполнения. Забыть что-то закрыть — ошибка компиляции. Неявных деструкторов нет: очистка — обычный вызов функции, который видно в исходном тексте.
Чем ресурс отличается от структуры
Синтаксически разница в одном слове: resource struct вместо struct. Меняются правила обращения со значением:
|
| |
|---|---|---|
Присваивание, передача | Копируется: оба имени остаются годными | Перемещается: владение уходит дальше, старое имя мертво |
Конец жизни | Выходит из области видимости | Должен быть израсходован ровно один раз, иначе программа не скомпилируется |
Для чего | Простая информация: точки, размеры, вердикты | Вещи с обязательствами: файл закрыть, блокировку отпустить, бюджет вернуть |
Для данных let b = a; создаёт вторую копию, и обе остаются годными. Для ресурса та же строка передаёт владение: теперь значение называется b, и любое последующее обращение к a — ошибка компиляции. Копии быть не может: два владельца одного файла закрыли бы его дважды. Такое же перемещение происходит в любой позиции «по значению», не только в let: в аргументе функции, в поле литерала структуры, в Some(v), в возвращаемом значении, в сообщении, отправленном в канал.
Первый ресурс
Сквозной пример — журнал упреждающей записи (write-ahead log): файл, в который база данных дописывает записи до того, как применить их, чтобы восстановиться после сбоя. Наш журнал нужно запечатать, когда работа с ним закончена. Наведите курсор на выделенные места:
use std::log;
resource struct Wal { entries: u32 }
impl Wal {
pub fn open() -> Wal !{} {
Wal { entries: 0 }
}
pub fn append(self: &mut Wal) !{} {
self.entries = self.entries + 1;
}
pub fn seal(self: Wal) !{log} {
log::info(f"sealed after {self.entries} entries");
let Wal { entries: _ } = self;
}
}
fn main() -> u8 !{log} {
let mut wal = Wal::open();
wal.append();
wal.append();
log::info(f"entries so far: {wal.entries}");
wal.append();
wal.seal();
0
}seal не принимает никакой капабилити: дескриптор сам несёт свои полномочия. Для записи в журнал тоже ничего не нужно, потому что log — эффект, а не ресурс (см. Эффекты и капабилити).
Удалите wal.seal();, и компилятор укажет на объявление:
error[card §2]: resource `wal` is not consumed on this path
--> wal.kl:21:5
|
21 | let mut wal = Wal::open();
| ^^^
= help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fnДеструктуризация
В let Wal { entries: _ } = self; слева стоит образец в форме Wal; entries: _ отбрасывает это поле, а entries: n сохранило бы его. Как только образец сопоставлен, ресурса больше нет. Это тело деструктора, только написанное там, где его видно.
Назвать нужно каждое поле: .. в значении «остальное» нет. Когда позже в структуру добавится поле, компилятор укажет на каждую деструктуризацию, где о нём нужно принять решение. Здесь расходующий commit разбирает транзакцию, одно поле отбрасывает, а другое возвращает:
resource struct Txn { id: u64, writes: u32 }
impl Txn {
pub fn begin(id: u64) -> Txn !{} { Txn { id: id, writes: 0 } }
pub fn record(self: &mut Txn) !{} { self.writes = self.writes +% 1; }
pub fn commit(self: Txn) -> u32 !{} {
let Txn { id: _, writes: w } = self;
w
}
}
test destructure_binds_a_field {
let mut t = Txn::begin(42);
t.record();
t.record();
t.record();
let n = t.commit(); // расходует t, возвращает число записей
assert n == 3;
}Пропустите поле, и образец будет отвергнут:
error[card §2]: struct pattern misses field `id`
--> txn.kl:6:9
|
6 | let Txn { writes: w } = self;
| ^^^
= help: destructuring consumes the whole value; name every field (use `_`)Израсходовать ресурс можно четырьмя способами: передать в расходующую функцию (wal.seal()), вернуть, переместить дальше (let archived = wal;, в структуру, в канал, в другую функцию) или деструктурировать. Одно из этого должно случиться ровно один раз на каждом пути через функцию, включая ранние возвраты и каждую ветку match. Компилятор отвергает и забытый случай, и двойной.
? — это ранний возврат
«Каждый путь» включает и те пути, которые вы не выписывали. Оператор ? (см. Контракты, трапы и тесты) — скрытый return Err(...), и ресурс, живой в точке ?, потерялся бы на ветке ошибки. Чекер об этом сообщает и показывает, где ресурс объявлен:
error[card §2]: `?` returns early on Err while resource `w` is live — the Err path would leak it
--> ingest.kl:10:13
|
10 | let v = step(b)?;
| ^^^^^^^^
= help: consume `w` before the `?`, or handle the Result with an explicit `match`
= note: `w` was declared here (ingest.kl:9:5)Либо закончите с ресурсом до вызова, который может не удаться, либо выпишите match явно, и тогда придётся сказать, что происходит с ресурсом в каждой ветке:
use std::log;
resource struct Wal { entries: u32 }
impl Wal {
pub fn open() -> Wal !{} { Wal { entries: 0 } }
pub fn append(self: &mut Wal) !{} { self.entries = self.entries + 1; }
pub fn seal(self: Wal) !{log} {
log::info(f"sealed after {self.entries} entries");
let Wal { entries: _ } = self;
}
pub fn discard(self: Wal) !{} { let Wal { entries: _ } = self; }
}
enum ParseErr { Short }
fn parse_len(b: &[u8]) -> Result[u32, ParseErr] !{} {
if b.len == 0 { return Err(ParseErr::Short); }
Ok(widen[u32](b[0]))
}
fn ingest(b: &[u8]) -> Result[u32, ParseErr] !{log} {
let n = parse_len(b)?; // живых ресурсов пока нет — можно
let mut w = Wal::open();
w.append();
match parse_len(b) { // `?` здесь отвергли бы: `w` жив
Ok(v) => { w.seal(); Ok(n +% v) }
Err(e) => { w.discard(); Err(e) }
}
}
test question_mark_and_linearity {
let good = ingest(&b"\x05");
assert good == Ok(10u32);
let bad = ingest(&b"");
assert bad == Err(ParseErr::Short);
}lend: представление, которое можно сохранить
Для обычного доступа ничего особенного не нужно: чтение поля (wal.entries) или вызов метода (wal.append()) заимствует ресурс на время выражения или вызова. lend нужен, когда требуется ссылка в регион &r Wal: обычно чтобы сложить представления нескольких ресурсов в структуру данных в регионе или передать одно из них функции, которая хранит ссылки. let v: &r Wal = lend wal in r; замораживает wal до закрытия региона. До тех пор wal нельзя ни изменить, ни израсходовать:
error[card §4]: `a` is frozen by a `lend` until its region closes
--> lend.kl:11:9
|
11 | a.append();
| ^Представление доступно только для чтения; lend mut не существует. Когда регион закрывается, заморозка снимается, и владельца снова можно менять и расходовать. Одалживать один и тот же ресурс в один и тот же регион дважды можно. lend никогда не считается расходованием, так что к закрытию региона обязательство никуда не девается.
Здесь представления двух журналов собираются в вектор в регионе. Ссылки на время вызова не подошли бы: вектор их хранит.
resource struct Wal { entries: u32 }
impl Wal {
pub fn open() -> Wal !{} { Wal { entries: 0 } }
pub fn append(self: &mut Wal) !{} { self.entries = self.entries + 1; }
pub fn discard(self: Wal) !{} { let Wal { entries: _ } = self; }
}
test lend_into_a_structure {
let mut a = Wal::open();
a.append();
let mut b = Wal::open();
b.append();
b.append();
region r {
let views: &r mut Vec[&r Wal] = alloc in r mut Vec::new();
views.push(lend a in r); // с этого места a и b заморожены
views.push(lend b in r);
let mut total: u32 = 0;
for i in 0..views.len {
total = total +% views[i].entries;
}
assert total == 3;
}
a.discard(); // регион закрыт: a снова доступен
b.discard();
}Затенение живого ресурса
Переопределять имя вторым let для данных можно, как в Rust. Живой ресурс так спрятать нельзя: одна невинная на вид строка привела бы к утечке.
resource struct Wal { entries: u32 }
impl Wal {
pub fn open() -> Wal !{} { Wal { entries: 0 } }
pub fn discard(self: Wal) !{} { let Wal { entries: _ } = self; }
}
test shadowing_a_live_resource {
let wal = Wal::open();
let wal = Wal::open(); // первый Wal всё ещё жив
wal.discard();
}Компилятор сообщает resource `wal` is not consumed on this path на первом let. Сначала израсходуйте первое значение, потом используйте имя заново.
Необязательные ресурсы
Ресурс часто лежит в слоте, который иногда пуст: рабочий поток, который ещё не запущен, соединение, которое то ли открыто, то ли нет. Слот — это Option, и линейность следит за тем, что в нём лежит. Option со значением None на выходе ничего не должен, а присваивание в него ничего не теряет, так что заполнить пустой слот — обычный код. Some(ресурс) несёт обязательство, и match снимает его в обеих ветках. Перезаписать слот, в котором действительно лежит ресурс, по-прежнему нельзя, как нельзя и цикл, который на каждом проходе заполняет слот, не опустошив его: он терял бы по ресурсу за итерацию.
Чтобы вынуть значение из слота, в который можно писать (&mut Option[T], поле структуры за &mut, элемент &mut Vec), есть take(): он возвращает значение и оставляет на его месте None.
resource struct Wal { entries: u32 }
impl Wal {
pub fn open() -> Wal !{} { Wal { entries: 0 } }
pub fn discard(self: Wal) !{} { let Wal { entries: _ } = self; }
}
// Опустошить слот, если в нём что-то есть, и оставить None.
fn retire(slot: &mut Option[Wal]) -> bool !{} {
match slot.take() {
Some(w) => { w.discard(); true }
None => false,
}
}
test an_empty_slot_owes_nothing {
let mut slot: Option[Wal] = None; // пуст, ничего не должен
slot = Some(Wal::open()); // теперь должен одно расходование
let retired = retire(&mut slot);
assert retired;
match slot { // чекер не знает, что слот уже None,
Some(w) => { w.discard(); } // поэтому на выходе всё равно match
None => {}
}
}Инварианты
Инвариант — условие, которое выполняется для каждого значения типа, пока это значение существует: счётчик не превышает 100, голова кольцевого буфера меньше его размера. Без поддержки языка такие условия живут в комментариях, и каждая функция либо верит им, либо проверяет заново. resource struct объявляет их последним элементом своего тела:
resource struct Counter { n: u32, inv n <= 100 }
impl Counter {
pub fn bump(self: &mut Counter) !{} {
self.n = self.n + 1;
}
pub fn done(self: Counter) -> u32 !{} {
let Counter { n } = self;
n
}
}
test invariant_holds {
let mut c = Counter { n: 0 };
c.bump();
c.bump();
let total = c.done(); // сначала привязать: assert не может расходовать и менять
assert total == 2;
}Попытка создать Counter { n: 200 } вызывает трап прямо в месте создания с сообщением inv violated on `Counter`. Поэтому любая функция, получившая Counter, может полагаться на n <= 100 без проверок: другого Counter существовать не может. Инварианты подчиняются тем же правилам, что и прочие контракты: доказываются при компиляции, где это возможно, и проверяются во время выполнения в остальных случаях.
Ресурсы и domain
Трап — реакция kola на нарушенное допущение, например переполнение, выход индекса за границы или невыполненный контракт: выполнение останавливается в этой точке, без раскрутки стека. Обычно трап завершает весь процесс. domain ограничивает его одним блоком, domain d { work(); } recover(t: Trap) { ... }, чтобы сервер мог потерять запрос, а не самого себя. Трапам посвящена глава Контракты, трапы и тесты; здесь они появляются потому, что домен взаимодействует с линейностью.
Трап покидает блок посередине. У ресурса, который был жив в этот момент, на таком пути не было бы расходования, и компилятор уже не мог бы обещать «ровно один раз». Поэтому линейное значение нельзя ни объявить, ни использовать в теле домена или в его ветке recover. Маркеры полномочий (о них ниже) — исключение: если такой потерять, ничего не пропадёт. Если же в момент трапа ресурс ещё жив внутри вызванной функции, трап фатален, и никакой домен его не поймает.
error[card §3]: `w`: a linear resource may not live inside a `domain` — a trap would strand it
--> worker.kl:9:9
|
9 | let w = Wal::open();
| ^^^
= help: domains compute over data; move this resource outside the domain, or consume it before enteringРаботает такая форма: ресурс остаётся снаружи, а домен считает над простыми данными.
use std::log;
fn checked_div(a: u32, b: u32) -> u32 !{log} {
let mut out: u32 = 0;
domain d {
out = a / b; // трап при b == 0
} recover(t: Trap) {
let k = t.kind; // Bounds, Arith, Req, Ens, Inv, Assert, ...
let site = t.site; // "module:line"
log::warn(f"recovered {k} at {site}");
out = 0;
}
out
}
test domain_bounds_the_trap {
assert checked_div(10, 2) == 5;
assert checked_div(10, 0) == 0; // трап, восстановление, работа продолжается
}Какие встроенные значения нужно расходовать
Линейность нужна не только типам, которые объявляете вы. На ней построена вся система капабилити (см. Эффекты и капабилити) и каждый дескриптор ОС, который выдаёт стандартная библиотека. Обязательства у них разные, и разница следует одному правилу: владеет ли значение чем-нибудь.
Маркеры полномочий можно бросить.
Clock,Rng,Fs,Dir,DirRo,Dial,Listen,Stdout,Stdin,Args,Env,Tty,Ping,Proc,Mmioи его окна,Cpu,Dev,Windowи самSys. Ни количества, которое нужно сверить, ни того, что нужно освободить, у них нет, поэтому бросить такой маркер ничего не стоит, и среда выполнения сама его заберёт. Маркер всё равно перемещается, а не копируется; чтобы дать рабочему потоку собственный, его ослабляют (clock.split(),dir.ro()).Memнужно вернуть. Это единственная измеряемая величина.sys.mem.split(n)отрезает бюджет памяти, аsys.mem.absorb(sub)возвращает его; если бросить его в промежутке, бюджет пропадёт до конца работы программы. Забудьтеabsorb, и получите знакомоеresource `small` is not consumed on this pathещё при компиляции.Линейные дескрипторы нужно закрыть. За ними стоит настоящий объект ОС или ключевой материал, и брошенный дескриптор — это утечка. Чаще всего встречаются такие:
File,Tmp,Sealed,FsNode(файлы);Listener,Conn,UdpSock,Poller(сеть);Thread,Tx,Rx(потоки и каналы);Child,ChildIn,ChildOut(процессы);Device(узел устройства);Secret(брошенный, он оставил бы ключевой материал нестёртым);RawMode(брошенный, он оставил бы терминал в сыром режиме). Дескрипторы GUI и пакетных сокетов подчиняются тому же правилу.
Капабилити строга не потому, что могущественна, а потому, что чем-то владеет. Clock даёт широкие полномочия, и бросить его ничего не стоит; один File даёт узкие полномочия, и закрыть его обязательно. По той же причине имена встроенных линейных дескрипторов зарезервированы: struct Thread { ... } или struct Conn { ... } компилятор отвергнет, потому что spawn выдаёт Thread, а имени типа вы при этом не пишете, и двойник с тем же именем мог бы вывести живой дескриптор из-под линейности. Единственное исключение из правила «расходуйте всё сами» — корневой набор: всё, что осталось в sys, когда main возвращается (или в sim, когда заканчивается тест), забирает среда выполнения.
Нормативное описание — SPEC-CARD §2 и §7 (kola help spec-card).