What a statement owns
A build with no collector used to refuse each of these lines:
print(count(line.split()))
print("led" in line.split())
for word in words(line).map(loud) {
print(word)
}
Each makes a list that is given to nobody. line.split() is counted and
never named. With a collector that is fine: the list is left behind and
collected later. With none, "nothing would free it" was the whole reason
for the refusal.
Now something does free it: the statement it is in.
cargo run -- build --no-collector --memory examples/words.zeno
./words
5
5
true
SET
LED
3
ON
250
["3", "250"]
["set", "led", "3", "on", "250", "end"]
set/2
refused: an empty line
stop/0
memory: 219 blocks made (3314 bytes), at most 505 bytes in use at once, no collector, 0 blocks left at the end
pool: 1160 bytes taken, in a pool of 32768
text: at most 79 bytes in use at once, in a room of 1024
examples/words.zeno splits a line from a
serial console into words nine times, and gives a name to only a few of
the lists it makes on the way. The report still ends with 0 blocks
left.
This page follows on from text.md. It is the second of five
steps that take the rules of that build from "refuse what has no owner"
to "whoever receives a value owns it".
The idea: what is given to nobody belongs to the statement
A list that was just made is nobody's yet. One of four things happens to it next, and each has an answer to "who frees it?":
| The list goes to | Example | Who frees it |
|---|---|---|
| an owner | let parts: owned list[str] = line.split() |
the owner, when its block ends |
| nobody | print(count(line.split())) |
the statement, when it is over |
a for loop |
for word in line.split() { ... } |
the loop, after its last trip |
| a place that is not an owner | let parts = line.split() |
nobody could, so it was refused. Since one-owner.md every such place is an owner |
The second row is the new one:
A value that is made in the middle of a statement and given to nobody belongs to that statement, which frees it when it is over.
Rust and C++ have the same rule and call such a value a temporary.
It is the rule you would guess. In print(count(line.split())) the list
is needed until count has answered, and there is no way to mention it
after the statement, so the end of the statement is the first moment
that is certainly late enough and the last that is certainly reachable.
Nothing new is written in the program. There is no keyword for it and no choice to make, which is the test "unambiguous and intuitive" asks for: a line that reads as "split, count, print" does exactly that, and the build that has no collector agrees with the one that has.
The files
src/checker.rs:made(which expressions make a new list),fresh_list(a made list can start life owned) andlent(an owned value that is only looked at).src/emit_c.rs, the part headed "What a statement owns":own_begin,own_end,belongs. The C that walks and fills lists is inover_listandopen_list_loop.src/emit_llvm.rs: the same three names.src/runtime.c:zeno_pieceandzeno_list_keep_text.
What makes a list
Six things make a new list:
[1, 2, 3] # a list written out
[n * n for n in items] # a comprehension
line.split() # also line.split(",")
items.map(double)
items.filter(big)
left + right # two lists joined
The compiler marks the value each of these gives as made
(Val::made). Then belongs, which every expression passes through,
asks one question: is somebody getting it? The checker has the answer.
It notes each made list that arrives where an owned list is wanted:
a let with an owned type, an owned parameter, an owned field, a
return from a function that gives back an owned list. Those start
life owned (Types::fresh), and nothing more is done here. Any other
made list becomes the statement's.
That is why this works, with no owned written on the right:
function words(line: str) -> owned list[str] {
return line.split(" ").filter(not_empty)
}
split makes a list that goes to nobody: filter only reads it. It is
the return statement's, which frees it. filter makes a second list,
and that one is wanted as an owned list, so it is given to the caller.
The C: declare, make, free
let line = "set led on"
print(longest(line.split()))
zeno_list *t1 = NULL; // where the statement begins
t1 = zeno_split_blanks(z_line);
zeno_print(1, zeno_str_int(z_longest(t1)));
zeno_list_drop_text(t1); // where it ends
The statement puts the list in a C variable of its own, and one line at
its end frees it. (The two lines about the text room that surround
these are left out. They are from text.md.)
Why declare the variable first and fill it on the next line? Because of
where a value may be made. A comparison such as a < f(x) < g([1, 2])
becomes nested ifs in C, and a list made inside an if cannot be
freed by a line after it unless the variable is declared outside. So
every such variable is declared where the statement begins, holding
NULL, and freeing NULL does nothing. own_end inserts those
declarations once the statement has been compiled, which is the first
moment it knows how many there are.
Leaving early
A statement can fail half way. In a firmware build that is a return
false in the middle of it (firmware.md):
function pick(text: str) -> str raises ValueError {
return str(count(text.split())) + first(text)
}
zeno_list *t4 = NULL;
size_t m7 = zeno_text_top;
t4 = zeno_split_blanks(z_text);
const char *t6 = zeno_str_int(z_count(t4));
const char *t5;
if (!z_first(z_text, &t5)) { zeno_list_drop_text(t4); return false; }
const char *t7 = zeno_text_return(m7, zeno_join(2, t6, t5));
zeno_list_drop_text(t4);
*zeno_result = t7;
return true;
The list is freed on both ways out. Nothing new was built for this: the
value is held from the line that makes it (hold, from
no-collector.md, "A value between two owners"), so
every early way out of the block frees it along with what the block's
owners hold. return, break, continue and a failed call all go
through that one path.
Where a statement ends
For let, an assignment and a call on a line of its own, the end is
the end of the line. The others each have a rule, and it is always
"as soon as nothing can still be looking":
- A condition is over when it has its answer.
if,else ifandwhilefree what their condition made before the block runs, and awhiledoes so on every trip. - Each side of
andandor, and whatnotis in front of, is a condition too. The right side ofandmay never run, so what it makes is freed inside theifthat guards it:
let ok = len(line) > 4 and len(line.split()) == 3
bool t4 = (zeno_len(z_line) > 4);
if (t4) {
zeno_list *t2 = NULL;
t2 = zeno_split_blanks(z_line);
bool t3 = (t2->len == 3);
zeno_list_drop_text(t2);
t4 = t3;
}
z_ok = t4;
- One item of a comprehension:
[count(w.split(",")) for w in ws]frees each little list before it works out the next item. forandmatchkeep what their first line made until their last block is over.for x in make().datawalks a list inside an object that the statement owns, so the object has to last as long as the loop does. Rust does the same.returnandraiseleave the function, so they free what the statement owns on the way out, with everything else.
Only looked at, so it cannot be kept
A value the statement owns is gone when the statement is. So it may be
looked at, and nothing may keep it. The compiler marks it
(Val::passing) and stays, which already guards every place a value
is kept, refuses it:
error: this list has no owner. Give it one, as in `let items: owned list[int] = [1, 2, 3]`. `zeno build --no-collector` leaves the collector out, so nothing would free it. docs/no-collector.md lists what such a build accepts
--> words.zeno:2:13
|
2 | let parts = line.split()
| ^
Looking is: a built-in (len, print, in, sum), an index, a loop,
a method of the list (map, filter, reduce), and a parameter that
says borrow. A borrow parameter is a promise the checker already
enforces: the function cannot keep, return or give away what it was
lent (ownership.md). So count(line.split()) is safe
by a rule that was there before this stage.
A parameter that is a plain list[str] makes no such promise. When
this page was written total(line.split()) was refused where total
takes a list[str]. The next step (one-owner.md)
made a plain list in this build an owned one, so total now takes the
list and frees it.
A field of an object the statement owns is marked the same way:
make().data may be walked and counted and not kept.
Lists of text
With no collector a list has its own copy of each text in it
(text.md, "Objects and lists"). The lists made here follow
that rule, each in the cheapest way there is:
splitand the characters offor ch in textare cut straight into the pool (zeno_pieceinruntime.c). They never pass through the text room.mapwith a function that gives text: each answer arrives in the text room, the new list takes a copy, and the room is given back before the next call. So amapover a thousand items needs room for one answer and not for a thousand:
zeno_list *t7 = zeno_list_new(t5->len);
size_t m4 = zeno_text_top;
for (int64_t t6 = 0; t6 < t5->len; t6++) {
zeno_list_append(t7, (zeno_slot){.s = zeno_text_keep(...loud(t5->items[t6].s)...)});
zeno_text_top = m4;
}
filtercopies each item it takes.+joins the two lists and then gives the new one copies of its own (zeno_list_keep_text).
Every one of them is freed by zeno_list_drop_text, which frees the
items and then the list.
Loops
A for loop walks the list as it was when the loop began, so it
walks a copy (decision 3.10a). Two kinds of list need no copy, because
nothing the body does can reach them:
- A list made right there:
for word in line.split(). The loop walks the list itself and frees it after the last trip. That was already so forfor pin in [2, 3, 5]. Now it is so for all six ways of making a list, and for an owned list that a function gives back (for word in words(line)). - A list the statement owns:
for x in make().data. The loop walks it where it is, and the statement frees the object after the loop.
map, filter and reduce skip their copy for the same reason when
the list they are called on is the statement's:
for word in words(line).map(loud) {
print(word)
}
zeno_list *t4 = NULL;
t4 = z_words(z_line); // the statement's
zeno_list *t5 = t4; // `map` walks it as it is
...the loop that fills t7...
zeno_list *t8 = t7; // the `for` loop walks what `map` made
for (int64_t t9 = 0; t9 < t8->len; t9++) {
const char *z_word = t8->items[t9].s;
zeno_print(1, z_word);
}
zeno_list_drop_text(t8); // the loop frees its list
zeno_list_drop_text(t4); // the statement frees its own
for ch in text was refused, because it makes a list of the
characters. That list is now the loop's own, like any other made list.
+= on an owned list
xs += ys means xs = xs + ys everywhere else in Zeno, and the checker
used to refuse it for an owned list. It is accepted now, in every build:
+ makes a list, and the owner takes it and frees the one it had.
var history: owned list[str] = []
history += words(line)
zeno_list *t14 = NULL;
zeno_list *t15 = z_history;
t14 = z_words(z_line);
zeno_list *t16 = zeno_list_keep_text(zeno_list_concat(t15, t14));
zeno_list_drop_text(z_history); // the old list
z_history = t16;
zeno_list_drop_text(t14); // the statement's
Where the variable or field on the left was not an owner, a build with
no collector refused the line. Every variable and field that holds a
list is an owner there now (one-owner.md).
The same rule, in every build
The checker had a refusal of its own, in every build:
error: this Packet has no owner yet. Give it a name with `let` first, and use the name here
for print(size(make())), where make gives back an owned value. That
was the same question, "who frees it?", and it now has the same answer.
The checker notes the expression (Types::lent) and both compilers
give the value to the statement, with a collector or without:
total += last(make(round)) + make(round).id % 2
z_Packet *t3 = NULL;
z_Packet *t4 = NULL;
t3 = z_make(z_round);
int64_t t5 = z_last(t3);
t4 = z_make(z_round);
z_total = zeno_add(z_total, zeno_add(t5, zeno_mod(t4->z_id, 2, 10, 38), 10, 18), 10, 9);
zeno_drop_Packet(t4);
zeno_drop_Packet(t3);
Four hundred trips of that make six megabytes and the collector never
runs. A made list that is not owned (a plain list[int]) is left to
the collector where there is one, as before.
Through LLVM
cargo run -- build --llvm --no-collector --memory examples/words.zeno
The IR keeps the value's address in a slot, as it does for an owner
(llvm.md, "Owned values"):
store ptr null, ptr %own.1 ; where the statement begins
%t1 = load ptr, ptr @z_line
%t2 = call ptr @zeno_split_blanks(ptr %t1)
store ptr %t2, ptr %own.1
%t3 = call i64 @z_longest(ptr %t2)
%t4 = call ptr @zeno_str_int(i64 %t3)
call void (i32, ...) @zeno_print(i32 1, ptr %t4)
%t5 = load ptr, ptr %own.1 ; where it ends
call void @zeno_list_drop_text(ptr %t5)
A slot, and not simply %t2, for the reason C declares its variable
first: in IR a name made in a block that may be skipped cannot be used
after it. The store ptr null line is inserted where the statement
began, as the line that notes the text room's top is.
The two builds make the same blocks in the same order, and the tests require the same memory report from both, line for line.
What it costs
Nothing, compared with naming the list:
print(len(line.split())) # 5 blocks, 67 bytes
let words: owned list[str] = line.split() # 5 blocks, 67 bytes
print(len(words))
The list is made once and freed once either way. What differs is how long it is held: to the end of the statement, or to the end of the block.
Three costs that are worth knowing:
for ch in texttakes a block for every character while the loop runs: 24 bytes of the pool each. For a line from a console that is nothing. For long text,for i in range(len(text))withtext[i]takes no block at all.filteron a list of text copies each item it takes, and+copies every item of both lists.- A
foror amatchholds what its first line made until it is over.for x in make().datakeeps the whole object for the length of the loop.
What this does not claim
- When this page was written, a plain
list[T]parameter, variable or field could not keep a made list in a build with no collector, and nested lists ([[1, 2], [3]]) were refused. Both work since the next step:one-owner.md. - The statement's end is not the last use.
print(len(a.split()), slow())holds the list whileslow()runs. Rust makes the same choice. A name and a block of your own (let) is how to say otherwise. - The text room's high-water mark can differ between the C build and the LLVM build when one statement both shows a list and builds other text (decision 5.9q, "Known gap"). The blocks and the bytes do not.
How it is tested
with_no_collector_a_statement_owns_the_lists_it_makes,..._made_lists_of_text_keep_their_own_copiesand..._what_a_statement_owns_is_freed_on_every_way_outinemit_c.rs: three programs that between them use every kind of made list in a call, a condition, a loop, a comprehension, amatch, areturn, and a statement that fails half way. Each goes throughno_collector_as_interpreter: the same output aszeno run, 0 blocks left, the same in the two builds with a collector, and the same memory report through LLVM.with_no_collector_made_lists_do_not_fill_the_pool_over_time: 5000 trips need exactly the pool that 50 trips need.the_c_for_what_a_statement_ownsandthe_ir_for_what_a_statement_ownspin down the code on this page.an_owned_value_that_is_only_looked_at_is_freed_in_every_build: with a collector, the collector has nothing to do.- The table in
what_a_build_with_no_collector_refuses_says_solost six rows and gained seven: every way a made list can be kept by something that is not an owner. - By hand: each trial program was also built with freed pool blocks
overwritten with
#and given-back text room with~, in both backends, and still printed whatzeno runprints.
Things to try
- Build
examples/words.zenowith--no-collector --memory. Then give the first list a name (let parts: owned list[str] = line.split()andprint(count(parts))). The report says the same number of blocks. Which two numbers change, and why? - In
words.zeno, changecountto take a plainlist[str]and build with--no-collector. Read the error. Then build without the flag. - Write
let both = line.split() + line.split()and read the error. Fix it two ways: with a type on thelet, and by not naming it. - Open
words.cand findz_words. Two lists are made there. Follow each to the line that frees it. One of those lines is not in this function: where is it? if "x" in line.split() or "y" in line.split()makes two lists. Look at the C. When is the second one made, and where is each freed?for ch in textmakes a list of every character. Changeopen_list_loopso that a loop over a str walks the text itself, one character at a time, withzeno_char_size. What happens to text built in the loop's first line, which is given back before the first trip today?filteron a list the statement owns copies each text it takes and then frees the original. It could move the text into the new list and leaveNULLbehind.zeno_list_drop_textalready skipsNULL. Try it, and compare the reports.print(len(a.split()), slow())holds the list whileslow()runs. Could the compiler free it afterlen? What would it have to know aboutlen, and what about a function of your own in its place?
This page is docs/temporaries.md in the language repository.