One owner
Until now a build with no collector made you write the word owned on
every list and every class, and it refused a list of objects however
you wrote it. Both are gone.
cargo run -- build --no-collector --memory examples/scheduler.zeno
./scheduler
runs: 10
blink ran 5 times: [2, 4, 6, 8, 10]
sample ran 3 times: [3, 6, 9]
log ran 2 times: [5, 10]
busiest: blink
blink now runs every 4
left: 2
retiring log
it ran 2 times
2 ran at tick 12
[0, 0, 0]
[0, 0, 7]
[1, 2, 3]
memory: 53 blocks made (1328 bytes), at most 504 bytes in use at once, no collector, 0 blocks left at the end
pool: 800 bytes taken, in a pool of 32768
text: at most 55 bytes in use at once, in a room of 1024
examples/scheduler.zeno has two classes,
a list of objects, a list inside each object and a list of lists. The
word owned is not in it. It still ends with 0 blocks left, and
zeno run prints the same thirteen lines.
This page follows on from temporaries.md. It is the
third of five steps that take the rules of that build from "refuse what
has no owner" to "whoever receives a value owns it".
The files
src/checker.rs:Rulesandcheck_byat the top. Then, under "Owned values":takes_out,walk,being_walked,item, and the end oftake.src/emit_c.rs:Drops, which names the function that frees each kind of value, andwrite_drops, which writes the ones a program needs.src/emit_llvm.rs:drop_list, the same in IR.src/main.rs:zeno check --no-collector.
The idea: a build with no collector has no shared values
Zeno has two kinds of lists and objects (ownership.md).
A shared one can be reached from many places and the collector frees
it when the last place lets go. An owned one has one owner, which
frees it where its block ends.
A build with no collector has nothing to free a shared value with. So
the old rule was "write owned everywhere", and the build refused the
program until you had. That is a rule a compiler can follow by itself:
With no collector, every list and every object has one owner: whoever received it.
| It is received by | Example | Who frees it |
|---|---|---|
| a variable | let tasks = [1, 2, 3] |
the variable's block, at its } |
| a field | Task("blink", 2, []) |
the object, when it is freed |
| a list | tasks.append(Task(...)) |
the list, when it is freed |
| a parameter | retire(last) |
the function, when it returns |
a borrow parameter |
report(task) |
not the function: the caller still owns it |
| nobody | print(len(line.split())) |
the statement (temporaries.md) |
So in this build list[int] means owned list[int] and class Task
means owned class Task. The rules of ownership.md
apply to all of them, and the two that matter day to day are:
let a = [1, 2, 3]
let b = a # the list moves to `b`
print(a)
error: `a` was given away on line 2 and holds nothing now. Lend it
instead (a `borrow` parameter), or use it before it goes
and a function that only looks says so:
function report(task: borrow Task) -> str { ... } # looks
function retire(task: Task) -> int { ... } # takes, and frees
The word owned still works in this build and changes nothing there.
In a build with a collector it means what it always did.
Fewer programs, never different ones
The same file must mean the same thing under zeno run, zeno build
and zeno build --no-collector. It does. This build adds refusals and
nothing else: a program it accepts never gives a value two owners, and
for such a program "one owner" and "shared" cannot be told apart from
the output. The tests build every program of this page both ways and
compare.
zeno check --no-collector file.zeno checks as this build would,
without building.
A list owns its items
scheduler.tasks is a list of tasks. The scheduler owns the list. Who
owns each task? The list does, and that is new in every build: an
owned list may now hold values that have one owner. (In a build with a
collector that needs the words: owned list[Packet] of an
owned class Packet.)
Four things can be done with an item, and each has one meaning:
| Written | What happens | The item belongs to |
|---|---|---|
tasks[0], for task in tasks |
a look | the list, as before |
tasks.append(task) |
the list is given the item | the list, from here on |
tasks.pop() |
the item is taken out | whoever receives it |
tasks[1] = Task(...) |
a new item takes the place of an old one | the list. The old one is freed on the spot |
A look can do everything except keep the item. It can read it, change
it (tasks[0].every = 4), call its methods and lend it to a borrow
parameter. Keeping it is the one thing refused:
let first = tasks[0]
error: this item is an owned Task, which has one owner: the list. `[ ]`
only looks at it. Use it where it is, lend it to a `borrow` parameter,
or take it out with `pop()`
Three things that would put an item in two lists are refused the same
way, each with what to write instead: a + b, a += b and filter.
map and reduce are fine: they lend each item to the function and
build something new from the answers.
Only the owner takes an item out
pop() and tasks[1] = ... both end an item's time in the list. If
anyone were still looking at that item, they would be looking at freed
memory. Three small rules make sure nobody is. Each can be checked by
reading one function, with no lifetimes to write.
1. Only the owner. A borrow parameter cannot pop, and nor can
self in a method, which is a borrow too.
function last(tasks: borrow list[Task]) -> Task { return tasks.pop() }
error: only the owner can take an item out of this list: somebody else
may have been lent it. Here `tasks` is only borrowed
2. Not while a loop is walking the list. The loop is looking at an item on every trip.
for task in tasks {
if task.name == "a" { print(retire(tasks.pop())) }
}
error: the `for` on line 8 is walking a list that `tasks` owns, and is
still looking at its items. Nothing can take an item out of this list
until the loop is over
3. Not on a line that has already looked. In
print(report(tasks[0]), retire(tasks.pop())) the first look may still
be in use when the pop happens.
error: this line has already looked at `tasks`, and the look might
still be in use. Do the other thing on a line of its own first
For the same reason the owner cannot be given a new value inside the
loop (tasks = []), or be given away there: either would free the
list, items and all, under the loop's feet.
Appending is not on this list. A for loop walks a copy of the list
(decision 3.10a), so tasks.append(...) inside the loop, or through a
borrow, moves nothing the loop can see.
These rules refuse some programs that would be safe.
tasks.append(tasks.pop()) is one: write it on two lines. Rust accepts
more, with far more machinery. Rule 3 in particular could be finer, and
that is noted in decisions.md 5.15e.
What the C looks like
Freeing a list of tasks means freeing each task and then the list. The compiler writes one small function for each kind of list that owns its items, the first time a program needs it:
static void zeno_list_drop_1_Task(zeno_list *list) {
if (list == NULL) return;
for (int64_t i = 0; i < list->len; i++) zeno_drop_Task(((z_Task *)list->items[i].p));
zeno_list_drop(list);
}
The 1 is how deep the lists go. A list[list[Task]] gets
zeno_list_drop_2_Task, which calls the one above for each row. A task
frees what it holds in the same way, and so on down:
static void zeno_drop_Task(z_Task *self) {
if (self == NULL) return;
zeno_text_drop(self->z_name);
zeno_list_drop(self->z_runs);
zeno_free(self);
}
static void zeno_drop_Scheduler(z_Scheduler *self) {
if (self == NULL) return;
zeno_list_drop_1_Task(self->z_tasks);
zeno_free(self);
}
So the one line zeno_drop_Scheduler(z_scheduler); at the end of the
program gives back the scheduler, its list, every task, every task's
name and every task's list of runs.
A new item frees the old one. scheduler.tasks[1] = Task("sample
fast", 1, []):
z_Task *t9 = zeno_new_Task("sample fast", 1, zeno_list_new(0));
zeno_slot *t10 = zeno_list_at(z_scheduler->z_tasks, 1, 91, 1);
zeno_drop_Task(((z_Task *)(*t10).p));
*t10 = (zeno_slot){.p = t9};
The new task is made first. If making it failed, the old one would still be in its place.
The copy a loop walks is freed alone. for task in scheduler.tasks:
zeno_list *t3 = zeno_list_copy(z_scheduler->z_tasks);
for (int64_t t4 = 0; t4 < t3->len; t4++) {
z_Task *z_task = ((z_Task *)t3->items[t4].p);
...
}
zeno_list_drop(t3);
zeno_list_drop, not zeno_list_drop_1_Task: the copy holds the same
tasks as the real list, and they are the real list's to free.
Giving away empties the owner. scheduler.add(slow):
z_Task *t1 = z_slow;
z_slow = NULL;
z_Scheduler_add(z_scheduler, t1);
and the zeno_drop_Task(z_slow); at the end of the program finds
nothing to free. That part is unchanged from ownership.md.
An assignment can reach through a list
points[1].x = 30 and grid[1][0] = 5 used to be refused by
zeno build (not by zeno run). Lists of objects make them everyday
lines, so the C backend compiles them now, in every build. The value is
worked out first, then the list or object, then the index, which is
the order zeno run uses. an_assignment_can_reach_through_a_list in
emit_c.rs checks it with functions that print when they are called.
Errors
The except block that catches an error owns it, and frees it when the
block ends (no-collector.md, "Errors"). That used
to be said by the compiler in its own words. It is the checker's now,
because it is the same rule as a loop's item: e is a look.
try { print(risky(5)) } except Bad as e { let again = e }
error: `e` is the error that this `except` caught. It is freed when the
block ends, so it cannot be kept, returned or given away. Copy out what
is needed from it
One thing got wider: e can be lent to a borrow parameter now.
The same, through LLVM
cargo run -- build --llvm --no-collector --memory examples/scheduler.zeno
The checker does all the deciding, so the LLVM backend needed only the
frees. drop_list writes the IR for zeno_list_drop_1_Task the first
time it is needed: a loop over the items that calls @zeno.drop.Task
for each, then @zeno_list_drop for the list. Replacing an item reads
as it does in C (this is points[0] = Point(3), from the test
the_ir_for_a_list_that_owns_its_items):
%t9 = call ptr @zeno_list_at(ptr %t7, i64 0, i32 5, i32 1)
%t10 = load ptr, ptr %t9
call void @zeno.drop.Point(ptr %t10)
store i64 %t8, ptr %t9
Both builds of the scheduler print the same and give the same memory report, line for line.
What is still refused
| Refused | Why | When |
|---|---|---|
A list or object inside an or none: var last: Task or none |
taking it out would have to leave a none behind |
step 4 |
An enum case that carries values, a list of int or none |
each sits in a block of its own | step 4 |
| A function written where a value goes | so does it, with the variables it uses | step 5 |
| A function type that mentions a list or an object | the type cannot say borrow yet |
step 5 |
| An error that is kept or raised again | its except frees it |
step 5 |
| A function that uses a list or object declared at the top of the file | the owner is the top of the file, which could give it away first | noted, see below |
| A method that pops an item or replaces a field that has one owner | self is a borrow (rule 1) |
noted |
A list or object as the T of a generic |
the copy for T would have to know it moves |
noted |
The sixth row is the one a firmware program will meet first: a table of
numbers at the top of the file that several functions read. For now,
lend it through a borrow parameter. A top-level let that is never
given away could be lent to every function without being asked, and
that is on the list for the audit that follows these five steps.
How it is tested
- The checker (
checker.rs): one test for each rule on this page, for what it accepts and for the message of what it refuses. They are the tests whose names beginwith_one_owner_, andan_owned_list_can_hold_values_that_have_one_owner,an_item_that_has_one_owner_stays_in_its_listandonly_the_owner_takes_out_what_has_one_owner. - Both compilers: three programs with nested lists and objects, with
errors raised half way through a list, and with every early way out of
a loop, plus
scheduler.zeno. Each must print whatzeno runprints, end with 0 blocks left, and give the same memory report through C and through LLVM (with_no_collector_a_list_and_an_object_need_no_word,a_list_owns_items_that_have_one_owner). - Over time: a loop that fills and empties a list of objects 50 times
and 5000 times needs the same pool
(
with_no_collector_lists_of_objects_do_not_fill_the_pool_over_time). - The table of refusals (
what_a_build_with_no_collector_refuses_says_so) lost the rows that build now, and its program of accepted lines grew by as many lines. - By hand: every program was also run with each freed block overwritten, so that a look at freed memory would print rubbish. None did.
Things to try
- In
scheduler.zeno, take the wordborrowoffreportand runzeno check --no-collector. Read the error. Why is the loop'stasknot the loop's to give? - Add
let first = scheduler.tasks[0]and read the error. Fix it two ways: once so that the task stays in the list, once so that it leaves. - Make a second list,
var done: list[Task] = [], and move the last task across withdone.append(scheduler.tasks.pop()). That builds. Now tryscheduler.tasks.append(scheduler.tasks.pop()). Which rule refuses it, and is the program it refuses really unsafe? - Build the scheduler and open
scheduler.c. Before you search for it, write down whatzeno_list_drop_2_Taskmust look like. Then addlet rows = [[Task("x", 1, [])]]to the program and compare. - Give
Schedulera methodremove_last(self) -> Taskthat pops. Rule 1 refuses it. Rust would have you write&mut selfto promise that nobody else is looking. What would Zeno need: a new word, or a rule about who may be lent a scheduler while a method runs? Write the error message you would want to read first. for task in scheduler.taskscopies the list before walking it, so that anappendin the loop cannot disturb the walk. When the body never appends, the copy is wasted. Findopen_list_loopinemit_c.rsand decide what the checker would have to tell it.- Build
examples/tour.zenowith--no-collectorand read what it is refused for. That is step 4.
This page is docs/one-owner.md in the language repository.