Memory
The three modes, ARC, weak references, places and ref, escape analysis, the raw tier, and device memory.
Every value in a sysl program lives in one of three ways, and the declaration that names it is what says which. This page is the whole of that model: what each mode costs, what it guarantees, how a reference is made, when storage moves without being asked, and where the guarantees stop.
If you do not write *T, you cannot segfault. The safe subset — values, references, weak
references, arrays, and slices — has no use-after-free, no null dereference, no out-of-bounds access,
and no dangling pointer. The single unsafe primitive is the raw pointer, and it is greppable: a
program’s exposure to C’s hazards is exactly the lines with a * in the type. A driver has them and
an application does not, and you can tell which by looking.
The three modes
| mode | what it is | who frees it | needs an allocator |
|---|---|---|---|
T | a value — on the stack, in a register, or inline in something bigger | nobody; it goes when its frame or its container does | no |
&T | a counted reference to a heap object | the compiler, when the last reference goes | to make one |
*T | a raw machine pointer | you | no |
struct Point
x: int
y: int
var here = Point(1, 2)
var shared: &Point = Point(3, 4)
var raw: *Point = &here
print(here.x, shared.x, raw.x)
1 3 1
The choice is per declaration, not per type. The same struct Point is a value in the first
line, a heap object in the second, and a pointer’s target in the third. This is C’s arrangement
rather than Swift’s or Scala’s, where a type is struct or class once and for all, and systems
code depends on it: a page-table entry, a temporary in a loop, and a shared configuration object may
all be the same type held three ways.
Value is the unmarked default, and the kernel is why. A module that has given up the allocator
cannot make a reference. If the reference were the bare default, the unmarked spelling would be
the one that is illegal exactly where value semantics matter most — every kernel struct would need a
mark, and would hit “references need an allocator” on its most natural form. With value as the
default, T is usable in both worlds, *T covers pointers, and &T appears in allocator-free code
only where something else handed one over.
T — values
A value lives inline: in a stack slot, in a register, or embedded in the struct or array that contains it. Assignment and argument passing copy it, exactly as in C.
struct Counter
n: int
var a = Counter(1)
var b = a
b.n = 99
print(a.n, b.n)
1 99
There is no move semantics and no use-after-move. a is still a whole Counter after b = a,
and it always will be — the concept of a value that has been given away does not exist here, so
neither does the class of error built on it.
A value containing a &T field is still a value: copying it retains that field, so the copy is
independently safe and the original is untouched. That is the one thing a copy does beyond moving
bytes, and it is what makes “values are simple” true rather than approximately true.
Values need no allocator and are always safe.
&T — counted references
A &T is a reference to a heap object managed by ARC: the compiler emits the retains and
releases, and the object is destroyed the moment the last strong reference to it goes away. It is:
- shared and freely aliased — many references may point at one object;
- mutable through any alias — there is no borrow checker and no exclusivity rule;
- non-null — a
&Talways points at a live object; - automatically managed — no
free, and no place to forget one.
struct Counter
n: int
var a: &Counter = Counter(1)
var b = a
b.n = 99
print(a.n, b.n, a == b)
99 99 true
Compare that with the value example above: the same two lines, and the declared mode is the only
difference. b = a copied a Counter there and made a second reference here.
Two live mutable aliases is what Rust exists to prevent, and permitting them is the deliberate trade. Reference counting pays for it at run time — a retain here, a release there — and buys a language with no lifetimes, no borrow checker, and nothing to prove to a checker as you write.
References compare by address. == and != on a reference or a pointer ask whether the two
name the same object, which is the only question a bare address can answer, and there is no ordering
on either.
There is no allocation keyword
A reference is made by writing an ordinary construction where a &T is expected. The expectation
is what puts the object on the heap:
struct Process
id: int
priority: int
spawn(id: int) -> &Process = Process(id, 0)
var p = spawn(7)
var q: &Process = Process(8, 1)
var v = Process(9, 2)
print(p.id, q.id, v.id)
7 8 9
Process(id, 0) in the body allocates because the return type says &Process. Process(8, 1)
allocates because the annotation says so. Process(9, 2) does not, because nothing asked — with no
expectation at all, a construction is a value.
No new, no malloc, no Rc::new, no .clone(), and no wrapper type. The positions that fix the
expectation are the ones that already state a type for generic inference:
| position | example |
|---|---|
| a declared local | var p: &Point = Point(1, 2) |
| a parameter | f(p: &Point), called as f(Point(1, 2)) |
| a return type | -> &Point, with a construction as the result |
| a struct field | Item("a", Node(1)) where the field is &Node |
| an enum variant’s payload | Some(Node(1)) where the payload is &Node |
An element of an array or slice, a part of a tuple, a generic argument, and a parameter of a callable
type all ask the same way. The rule is about the types and not about the syntax: a T written
where a &T is expected goes on the heap, whatever produced it.
A prefix & on a construction was considered as an explicit mark and not taken — it would collide
with address-of, which is a different operation producing a different type.
The expectation reaches each branch
An if, a match, and a loop yield their value through their branches, so a &T expectation
reaches each branch on its own rather than the expression as a whole:
struct Point
x: int
y: int
var origin: &Point = Point(0, 0)
var far = true
var p: &Point = if far then Point(9, 9) else origin
print(p.x, p.y)
9 9
That is what lets a value branch and an already-reference branch meet at &Point. Boxing the whole
expression instead would fail, because a branch that is already a &Point cannot un-become a value —
something that is already a reference passes through untouched.
It is also what lets a scalar be referenced without int needing a constructor of its own:
var n: &int = 41
print(*n + 1)
42
A reference is never null
There is no null in the safe subset and no way to make one, so there is no null dereference to
diagnose. Something that may be absent is an Option[&T], and getting at it is a match:
struct Item
label: string
show(o: Option[&Item]) -> string
o match
Some(i) -> i.label
None -> "(none)"
var it: &Item = Item("a")
print(show(Some(it)), show(None))
a (none)
The match is not ceremony around a null check. It is the only route to the item, so the absent case
cannot be forgotten rather than merely being unwise to forget.
The one place a &T reads as null is storage the language did not lay out and the program zeroed —
below, with is_null, the raw
tier’s question for it, and release, the way such a field is emptied again.
A box whose allocation fails traps
The heap can refuse. A box is made from whatever the program’s allocator answers, and the answer is
checked before anything is stored through it: a null traps, exactly as a failed array allocation does
(arrays). So a &T is never built over a null, which is what lets the section above say
a reference is never null; the allocator-failure case is a trap, not a value the program must handle.
What a heap object costs
Every ARC object carries three header words: the strong count, a pointer to the function that destroys it, and a weak count. Release decrements the strong count; at zero it calls through the hook, which releases whatever the payload holds and returns the storage to the heap the object came from — one heap for the whole program, libc’s unless a package named another pair.
Putting the destructor behind a hook rather than inline at each release site is what makes letting go
of a reference type-erased — one instruction sequence, no static type. Slices need exactly that,
since a []T gives no clue what type of object its owner word points at.
One word per object buys three things:
- ARC works the same everywhere. A module that never allocates can still retain and release, because the free path calls back into the heap the object came from. The operations are a few instructions and depend on no runtime.
- Several heaps coexist. A kernel heap, a server’s heap, and an arena are different allocators, and an object frees itself into the one that made it, wherever it is dropped.
- There is no boundary rule to learn. Ownership crosses an allocator-free edge like any other value.
Teardown is iterative, so depth is bounded. Destroying the head of a long chain of references would recurse one frame per node if a destructor called the next destructor. It does not: an object whose count reaches zero is pushed onto a worklist, reusing its now-dead refcount slot as the link, and the first release to hit zero drains the list in a loop. A structure of any depth comes apart in O(1) stack. The worklist is per thread, because it is scratch space a drain uses rather than state anything shares.
On a freestanding target the worklist is asked for rather than assumed, since there is no thread
pointer to key it on. A &sync release that reaches zero fetches it through one weak symbol,
__sysl_arc_reaper, which answers with a head pointer and a draining flag belonging to the
running task:
struct sysl_arc_reaper { void *head; unsigned char draining; };
/* FreeRTOS, say: whatever this runtime keeps per task. The storage is the port's to give out and
must last as long as the task — set when the task is created, never shared with another:
static struct sysl_arc_reaper slots[MAX_TASKS];
vTaskSetThreadLocalStoragePointer(task, 0, &slots[n]); */
struct sysl_arc_reaper *__sysl_arc_reaper(void) {
return pvTaskGetThreadLocalStoragePointer(NULL, 0);
}
Define nothing and the program uses the single slot it carries, which is right for a machine where
nothing schedules — the overwhelming case, and why the symbol is weak. Define it in an RTOS port and
&sync T means on that target what it means everywhere else. This is the arrangement &sync‘s
counts are already under there: with no ldrex/strex beneath it an atomic increment becomes a call
to an __atomic_* the board defines, and on a two-core part that needs a hardware spinlock.
A scheduler written in sysl defines the hook with @export, as
__sysl_arc_reaper() -> *T where T is a struct of the same two fields. The program’s definition is
then the only one: the compiler leaves its weak default out, and every drain calls the program’s.
struct Reaper
head: *u8
draining: u8
// Whatever the scheduler keeps per task.
extern "current_task_reaper" current_task_reaper() -> *Reaper
@export("__sysl_arc_reaper")
arc_reaper() -> *Reaper = current_task_reaper()
struct Job
n: int
@export("run_job")
run_job(n: int) -> int
val job: &sync Job = Job(n)
job.n
The header is the same three words for every object whether or not anything weakly references it,
which is what keeps the type-erased release path expressible at all. The cost is eight bytes on an
allocation that already cost a malloc, and it lands only where the feature is used: values, fixed
arrays, and *T buffers have no header, so allocator-free code pays nothing for a mechanism it never
touches.
Passing a reference to a call usually costs nothing
The obvious way to count a by-value argument is for the function to take a share of it on the way in
and give it back on the way out, so that a parameter is a thing the callee owns. sysl does not do
that, because the caller is still standing there: an argument is handed over under a guarantee that
it stays alive for the length of the call, and the callee reads it through the count the caller is
already holding. That is Swift’s guaranteed convention and Rust’s &self borrow, arrived at the
same way — it is the caller, not the callee, that knows where the value came from.
What the caller has to look at is one question with three answers:
- a temporary — a call’s result, a newly built object, a value out of an
if— is a count this frame already took and does not give back until the statement ends, which is after the call returns. Nothing to do. - a local or a parameter whose address the caller never let out keeps its count in a slot nothing the callee can reach has a name for. Nothing to do.
- anything else — a field, an element, a global, something behind a
refor a&the caller handed out — keeps its count in memory the call itself may write. That one costs a retain before the call and a release after it, and it is the only shape that does.
A call to an async function is the one place the first answer changes. Its
task keeps a count of every parameter of its own, since its body runs after the call has returned, so
a temporary handed to it moves into the task: the caller gives its count back as soon as the call
returns instead of holding it to the end of the statement. What the task was handed goes when the
task lets go of it — at its return — and not when the statement that made the call has run to its
end. A local or a parameter handed to an async call is still the caller’s, and keeps its count where
it always does; a plain call keeps its temporaries to the end of the statement, as above.
struct Noisy
id: int
impl Drop for Noisy
drop(self)
print(s"dropped ${self.id}")
async keep(n: &Noisy) -> int = n.id
plain(n: &Noisy) -> int = n.id
async run()
print(s"async ${await keep(Noisy(1))}")
print(s"plain ${plain(Noisy(2))}")
val named: &Noisy = Noisy(3)
print(s"named ${await keep(named)}")
print("end of run")
block_on(run())
dropped 1
async 1
plain 2
dropped 2
named 3
end of run
dropped 3
The third case is not a technicality. remember below is handed a field of module storage and then
assigns to that very field, so the count the argument was resting on is given back while the callee
is still running — and the read on the next line would be a read of freed storage if nobody else
were holding one:
struct Node
v: int
struct Holder
r: &Node
var latest: Holder = Holder(Node(0))
remember(h: Holder) -> int
latest = h
h.r.v
var first: &Node = Node(1)
var box = Holder(first)
print(remember(box), latest.r.v)
1 1
box is a local nobody took the address of, so this particular call is free at both ends; written
remember(latest) it would cost the pair, for the reason above.
Nothing about this is something to write down or reason about while programming. It is not a mode, it is not spelled in a signature, and two implementations of one trait method may disagree about it without anything noticing — the obligation is the caller’s and is the same whether the callee is a name, a vtable slot or a function pointer. What it is worth knowing is the shape it rewards: a hot loop that builds values and pushes them into a container passes temporaries and locals, and pays no reference traffic at all for the handover.
Two things a callee still takes a count for, and both are the same reason — it is about to give one back:
- a parameter it assigns to.
h = otherreleases whatever was in the slot, and under a borrow that would be the caller’s count, so a body that rebinds a parameter owns it instead. - a parameter of a function something outside the program may call —
@export, a@section, or one whose address is taken for a*fn. There is no sysl caller on the other side of those to make the guarantee, so the function makes it for itself.
Storing a parameter is not one of them: an assignment takes a count for the value arriving, so
latest = h above is where the new owner’s share comes from, and the callee never held one.
A count that would be taken and given straight back is not taken
Binding a call’s result to a val, or returning a local through an if or a match, moves a value
from one owner in the frame to another: the new owner takes a share and the old one gives its share
back at the end of the statement or scope. On a &sync T each of those is an atomic operation on a
count every thread shares, so a value copied up a recursive search would pay two contended atomics
per level for a count that ends where it began. The compiler drops such a pair. A retain is
cancelled against a later release of the same reference when nothing between them can give back the
count that release would have given back:
- loads and stores of the frame’s own slots, arithmetic, and other retains never can;
- the release of a different value may run a destructor, which is arbitrary code, but it cannot reach a slot of this frame whose address never left it. So it may sit between the pair, unless the stretch between them also writes to memory outside those slots;
- any other call ends the stretch.
Where the pair would be split by the arms of an if or a match, the releases at the end of the scope
are given back at the end of each arm instead, so that the arm which returns a local takes no count
for it. A count therefore never reaches zero at a different point, and no destructor runs earlier,
later or in a different order than it would with every count taken. Nothing about it is visible
except the time it saves: on a recursive search over a tree of &sync shapes it removes three of every
four atomic operations.
struct Tag
id: int
impl Drop for Tag
drop(self) = print("drop", self.id)
struct Hit
t: int
tag: &sync Tag
probe(t: int, tag: &sync Tag) -> Option[Hit] = Some(Hit(t, tag))
pick(near: bool) -> Option[Hit]
val x = probe(1, Tag(1))
val y = probe(2, Tag(2))
if near then x else y
show(near: bool)
val h = pick(near)
print("got", h.unwrap().t)
show(true)
show(false)
drop 2
got 1
drop 1
drop 1
got 2
drop 2
A view of something the frame already holds takes no count
A slice of a slice retains the owner, and a val copied out of a field retains what it refers to.
Where the value it was read from is a parameter or a val — written once, in a slot nothing
outside the function can reach — that value’s own count already keeps the object alive, and a
share taken from it and given back before it is given back its own is dropped, however far apart
the two are: across a for that walks the view, or the rest of a loop’s round. A view taken every
time round a loop then costs nothing:
- the parent must stay put: a
varassigned in the loop, or one whose address is taken, keeps every count; - a view that leaves the function, or is stored anywhere but a local, keeps its count;
- a count can be looked at, so nothing between the share and its return may look: storage is let
go of by
slot_vacateonly where the strong count is one, and a view whose share had been dropped would leave it free to give back slots the view still reads. So the stretch may hold arithmetic, reads of locals and of fields and elements, and calls to functions that release nothing and write nothing but their own locals; any other call, and any other release (a destructor is code too), keeps the count.
The parent’s count holds the object from before the share is taken until after it would have been
given back, so the object’s count never reaches zero inside, nothing inside reads it, and no
destructor moves. Below, sums takes no count at all; label‘s print is a call that could, so
t keeps its share there.
import sysl.buf.{Buf, buf}
struct Tag
id: int
impl Drop for Tag
drop(self) = print("drop", self.id)
struct Shelf
tag: &Tag
sums(src: []const u8, most: usize) -> u32
var t: u32 = 0
var i: usize = 0
while i + most <= src.len
val here = src[i..<i + most]
for x in here
t += u32(x)
i += most
t
label(s: Shelf, rounds: int)
for k in 0..<rounds
val t = s.tag
print("round", k, t.id)
var b: Buf[u8] = buf()
for k in 0..<10
b.push(u8(k))
print(sums(b.view(), 5))
label(Shelf(Tag(7)), 2)
print("done")
45
round 0 7
round 1 7
drop 7
done
weak T — breaking cycles
Reference counting has one honest weakness: a cycle of strong references keeps itself alive. A parent holding its children while each child holds its parent is the shape that does it, and it is common enough in systems code — a back-link in an intrusive list, a process’s pointer to its parent — to have a purpose-built answer.
A weak T does not keep its referent alive. When the last strong reference goes, the object is
destroyed and every weak reference to it becomes empty.
struct Node
label: string
parent: weak Node
parent_of(n: &Node) -> string
n.parent.get() match
Some(p) -> p.label
None -> "(none)"
var root: &Node = Node("root", None)
var kid: &Node = Node("kid", root)
print(parent_of(root), parent_of(kid))
(none) root
weak T is a type, not an operator. There is nothing that makes one; a &T becomes a weak T
wherever a weak T is what the context asked for — the struct field above, an argument, a declared
local, a returned value. That is the same rule that makes a &T out of a T, so both & and weak
live at the boundaries a program annotates and neither appears in a body.
The conversion goes one way. A weak T is not a &T and never silently becomes one, because
becoming one is the operation that can fail.
The & is in the mode already, so weak &T is the word said twice — and it is refused rather
than read as a weak edge to a box holding a reference, which is not a thing sysl has:
struct Node
v: int
var r: &Node = Node(1)
var w: weak &Node = r
'weak Node' is already a weak edge to a counted Node, so the '&' says the mode a second time — write 'weak Node'
Reading one is a question, and it is get(). The answer is Option[&T]: a live strong reference
with a count taken for the caller, or None. Nothing else may be done to a weak reference — no field
selection, no method call, no ==:
struct Node
label: string
parent: weak Node
var root: &Node = Node("root", None)
var kid: &Node = Node("kid", root)
print(kid.parent.label)
may be gone, so nothing is read off one directly
Every road to the object goes through the Option, which is what makes “a weak reference never
dangles” a fact about the language rather than a promise about the programmer.
The parentheses are not decoration. .len is a property — a fact about the value, the same answer
every time it is asked — while get() is a question about the world: two calls a moment apart may
disagree, and the answer costs a count. sysl puts the parentheses on the second kind.
An empty weak reference is written None, and it is the same state whether the object is gone or
there never was one. This None is not an Option — it is the empty value of the weak reference
itself, chosen because it is exactly what get() will hand back for it.
It is also the zero value of weak T, which is the one place weak T parts company with &T:
there is no such thing as a reference to nothing, so &T has no zero. A struct with a weak field
therefore still has one, and an uninitialized declaration of it is still a declaration:
struct Node
label: string
parent: weak Node
var n: Node
n.parent.get() match
Some(p) -> print("held")
None -> print("empty")
empty
A weak reference may not be made from a value with nowhere else to live. Constructing into a weak position would box the value and then weaken it, leaving the weak edge as the object’s only holder — and the object dead before the statement ended:
struct Node
label: string
parent: weak Node
var kid: &Node = Node("kid", Node("root", None))
print(kid.label)
a weak reference does not keep Node alive, and nothing else here holds this one
The only default a weak T parameter can have is None, and that falls out rather than being
decided. A default is produced afresh at each call that omits it, in a scope holding none of the
caller’s locals, so what it names would have to outlive every frame — and every candidate that names
an object is closed off. A construction is refused by the rule above; a top-level var is a local
of the entry point; and a module-level val counts nothing, which a reference does. What is left is
the one value that holds nothing:
struct Node
label: string
parent: weak Node
adopt(child: string, parent: weak Node = None) -> string
parent.get() match
Some(p) -> child + " of " + p.label
None -> child + " of nobody"
var root: &Node = Node("root", None)
print(adopt("a"), adopt("b", root))
a of nobody b of root
weak sync T is refused, naming the concurrency chapter. Upgrading an atomic weak reference is a
compare-and-swap loop against a count another thread may be driving to zero underneath it, and that
is written when there is something to race with.
Everything a &T may point at, a weak T may be taken of: a struct, a scalar, an array, a generic
instantiation, a trait object, and a type parameter.
*T — the raw pointer
*T is a bare machine pointer, exactly like C’s: no length, no count, no checks, manual lifetime. It
is the only unsafe primitive over data, it needs no runtime, and it is how a kernel, a driver, or an
allocator’s own internals are written.
bump(n: *int)
*n += 1
var counter = 41
bump(&counter)
print(counter)
42
Anything C can do through a pointer, sysl can. The pointer is where the language’s guarantees stop, so it carries C’s whole surface rather than a safer subset of it — a subset would only mean a kernel that could not be written here.
null exists, and only here
null is the absent raw pointer. It has no type of its own and takes the *T its context expects,
the way a bare None takes its type argument:
struct Node
value: int
next: *Node
var third = Node(3, null)
var second = Node(2, &third)
var first = Node(1, &second)
var walk = &first
var total = 0
while walk != null
total += walk.value
walk = walk.next
print(total)
6
That program is also the answer to “how does a type reach itself” — see recursive types below.
A generic parameter is a context like any other, and it answers late. A callee still being solved has no type at that position yet, so an argument with none of its own is set aside until the ones that have have been read, and is then taken against the parameter it turned out to stand at:
two[T](a: *T, b: *T) -> bool = a == b
var x: int = 3
print(two(&x, null), two(null, &x))
false false
Either order answers alike, because waiting is not queueing. Setting an argument aside cannot lose the solution — one with no type of its own has nothing to contribute to it — so the parameter is settled by the other arguments or by nothing at all. Nothing at all is refused rather than defaulted: a call with no other argument to read is asked what the pointer points at.
one[T](a: *T) -> bool = true
print(one(null))
'null' takes its type from its context, and there is none here
Arithmetic: two directions, one spelling each
var buf: [8]int = [10, 20, 30, 40, 50, 60, 70, 80]
var base = &buf[0]
var third = &buf[2]
print(*third, third - base, base - third, *(&base[3]))
30 2 -2 40
p - q between two pointers of the same pointee is an isize, and it counts elements, not bytes
— C’s ptrdiff_t. It is signed because the order of the operands is the programmer’s, and it is the
exact inverse of &p[n]: indexing takes an address and a count to an address, and the difference
takes two addresses back to a count, both striding by the pointee.
It is here because the interior-pointer half of libc needs it. memchr, strchr, strrchr,
strstr and memmem all hand back a pointer into a buffer the caller owns, and without a
difference every one of them is callable and useless, because nothing could turn the answer into an
index.
Offsetting stays &p[n]. p + n is not spelled, deliberately: indexing already exists and
already strides by the pointee, and a second spelling for one address would be a second thing to keep
in step. p + q names no address and is refused too.
Two pointers of different pointee types have no shared element to count, and are refused by the ordinary matching-types rule rather than by one of this operator’s own:
var n = 1
var c = 'x'
var pn = &n
var pc = &c
print(pn - pc)
'-' needs matching types, got *int and *char
A counted &T has no arithmetic at all, keeping the equality it always had and nothing more:
struct Point
x: int
var a: &Point = Point(1)
var b: &Point = Point(2)
print(a - b)
operator '-' is not defined for &Point
Arithmetic is a property of the unsafe mode, not of holding an address.
Whether two pointers into unrelated objects may be subtracted is the programmer’s business, as p[i]
past the end already is.
Bounds safety follows length, not pointers
The out-of-bounds hazard is governed by whether a value carries its length, independently of where it was allocated:
- arrays and slices carry their length, so indexing is bounds-checked in every context — hosted or allocator-free, over static, stack, or heap memory;
*Tcarries no length, so it is the one unchecked primitive.p[i]reads theith element andp[0..<n]viewsnof them, both exactly as C does and both unchecked, because there is no length to check against and supplying one is the programmer’s assertion.
So even low-level, allocator-free code stays bounds-safe by choosing slices, and reaches for *T
where it must — an MMIO window, a page table, a buffer a C function filled. That choice is the point.
Places
A place is something with an address: a local or parameter, a dereference, an element, and a
field of any of them. Everything else — a call result, an arithmetic result, a freshly built struct —
is a value with no address of its own, and & in front of one makes it a place rather than
refusing it; Addressing a value below is that rule.
| operation | what it does |
|---|---|
&place | yields a *T — C’s address-of, with C’s result |
&value | writes the value into a hidden local of this scope, and yields a *T to that |
*p | reads through a *T or a &T, and is itself a place |
p.f | selects, dereferencing one level automatically on both *T and &T |
& on a place yields a raw pointer, never a reference. A place lives in a frame or inside
another object, so there is no count to take a share of. Reaching a &T means being handed one or
constructing one, and taking the address of a local is therefore inherently in the unsafe tier —
which is right, because it can dangle and nothing promotes it.
Selection is the only implicit dereference. p.x is (*p).x and p.x = 9 writes through the
pointer — Go’s rule, with no ->. Matching a reference to an enum against its variants is match *e.
The shorthand stops at one level, so reaching through a **T is written:
struct Point
x: int
y: int
var p = Point(1, 2)
var pp = &p
var ppp = &pp
print(pp.x, (*ppp).x)
1 1
Leaving the step out is a diagnostic rather than a second implicit dereference, and it says how many levels are left:
struct Point
x: int
y: int
var p = Point(1, 2)
var pp = &p
var ppp = &pp
print(ppp.x)
selection reaches through one level of indirection and **Point has more
Assignment, compound assignment, and ++/-- all take a place, so the same three forms work on a
variable, on a field, and through a pointer with nothing special said about any of them.
An element carries one wrinkle the other three do not. A slice’s elements and a pointer’s live
wherever the storage is, which is somewhere the expression naming them is not, so they have an
address whether or not the expression naming them does. That is what makes rows(g)[i] = v write
through to the grid rather than into the view the call handed back. An array’s elements are the
array, so they are places exactly when the array is. A string’s bytes are never one: writing a byte
of UTF-8 is how a string stops being UTF-8, and that is refused as immutability rather than as the
absence of an address.
The hazard on the other side of that rule is the one dangle a single statement can produce. If a
temporary view is the only holder of its buffer, the buffer is released at the end of the statement,
and &f()[i] is a pointer to freed storage before the next line runs. That is the unsafe tier
behaving as advertised — & yields a raw pointer, a raw pointer can dangle, nothing promotes it —
and it is called out because every other dangle needs the pointer to be carried somewhere first.
Addressing a value
& in front of something with no address of its own does not refuse — it makes the storage. The
value is written into a hidden local of the scope the & stands in, and what comes back is that
slot’s address:
trait Shape
area(self) -> int
struct Rect
w: int
impl Shape for Rect
area(self) -> int = self.w
var s: *Shape = &Rect(2)
print(s.area())
2
So &Rect(2) is var t = Rect(2) and &t with the name left out, and the name is the only thing
it leaves out: the slot is laid down for the frame, the count a counted value carries is taken
exactly as a var‘s initializer takes one, and it is released where the scope ends.
Any value, not only a construction. Rect(2) and make() are the same expression as far as the
language is concerned — a call — so a rule that took one and refused the other could only be stated
as “you may address a constructor call but not a function call”. A literal and an arithmetic result
are the same case again:
mid(p: *int) -> int = *p
var a = 3
var b = 4
print(mid(&1), mid(&(a + b)))
1 7
It is storage rather than a copy handed out to be read, so a program may write through it, and the write is there when the pointer is read again:
var p: *int = &1
*p = 9
print(*p)
9
The operand is evaluated once, at the &, and not again per use of the pointer:
var calls = 0
next() -> int
calls += 1
7
var p: *int = &next()
print(*p, *p, calls)
7 7 1
The scope is the one the & was written in, so a pointer carried out of that scope is stale —
which is the raw tier behaving as advertised rather than a hole this form opens. The same pointer can
be had by naming the local yourself and taking its address, and neither is checked in general — what
a *T promises is spelled out above, and it is not lifetime. The one route that is checked is the
function’s own answer, below.
A name is answered rather than copied. &x, &t.f, &xs[i] and &*p are asking for that
thing’s address, so they go on meaning what they always did — and go on being refused where what
they name has no address. A property is the case that makes this a rule rather than a detail:
t.f at a property and t.f at a field are one spelling, and handing the first a pointer into a
copy would make a write through it go nowhere while looking exactly like a write that lands.
struct Temp
c: int
f -> int = self.c * 9 / 5 + 32
var t = Temp(0)
var p = &t.f
print(*p)
something with an address
A constant is the same shape and the same answer: &capacity reads as storage and there is none,
while &8 — the value it stands for, written out — makes a slot like any other value does.
Module storage has no such scope, and is refused. An initializer there runs in a prologue, so the slot would be that prologue’s frame and the pointer would be stale by the program’s first statement:
struct Rect
w: int
static var p: *Rect = &Rect(2)
print(p.w)
module storage has no such scope
Give the value a name that lasts the run instead, and take the address of that:
struct Rect
w: int
static var r: Rect = Rect(2)
static var p: *Rect = &r
print(p.w)
2
An address of the frame’s own storage is not an answer
A parameter passed by value — (self) among them — and a local var live in the function’s frame,
and the frame is gone the moment the function returns. So the address of one, or of a field or an
element of one, may not be what a function returns: whether it is the body’s value, a return‘s,
the tail of an if or match arm, or wrapped in ptr_cast. A (self) method handing out a field is
the usual way to write it by accident, since self reads like the caller’s value and is a copy:
struct Rec
a: int
b: int
struct Ev
rec: Rec
key(self) -> *u8 = ptr_cast(&self.rec)
the address of 'self' is returned, but '(self)' is a copy of the receiver in this frame, and its storage ends when the function returns — return the value itself, or take '*self', so the address is of the caller's value
Storage reached through a pointer or a reference is wherever the address points, which is the
caller’s to keep alive, so *self, &self, a *T or &T parameter and an element of a []T are all
fine — as is module storage, which outlives every frame:
struct Rec
a: int
b: int
struct Ev
rec: Rec
key(*self) -> *int = &self.rec.b
var e = Ev(Rec(1, 2))
val p = e.key()
*p = 9
print(e.rec.b)
9
&make() makes a slot in the frame (§ Addressing a value), so returning it is refused the same way.
What is checked is the result only: a pointer to a local stored into module storage or into a struct
somewhere else is still the raw tier’s bargain.
The & in a type is a different thing, and neither is a way of writing the other. &T is a
counted box, which owns what is in it; the operator takes an address, which owns nothing. So a box
is not made by writing the operator in front of a value — it is made by the value arriving where a
&T is wanted, with no operator at all:
trait Shape
area(self) -> int
struct Rect
w: int
impl Shape for Rect
area(self) -> int = self.w
var s: &Shape = &Rect(2)
print(s.area())
an address is not a way of making a box
Which is the whole of the fix — the same program with the operator dropped:
trait Shape
area(self) -> int
struct Rect
w: int
impl Shape for Rect
area(self) -> int = self.w
var s: &Shape = Rect(2)
print(s.area())
2
ref — a name for a place
A place can be deep, and until ref the two ways to shorten one both cost something.
var t = self.tasks[i] binds a copy, so every read and every write walks the path from the table
again; &self.tasks[i] gives the name back and gives up bounds checking, within checking,
invariant re-checking, and the guarantee at the top of this page, all in one step. That is a cliff
rather than a gradient.
ref binds a name to a place.
struct Task
state: int
prio: int
struct Kernel
tasks: [4]Task
advance(*self, i: usize)
ref t = self.tasks[i]
t.state = 2
t.prio = t.prio + 1
end Kernel
var k = Kernel([Task(0, 7); 4])
k.advance(2usize)
print(k.tasks[2].state, k.tasks[2].prio, k.tasks[0].prio)
2 8 7
The place is evaluated once, where the binding is written — the index is computed once, the bounds
are checked once — and what the name means afterwards is the storage that was found, not the
expression that found it. A later i += 1 leaves t naming the element it always named. Scala’s
by-name parameter is the thing this is not: x: => T re-evaluates at every use, which would save
no check and would silently make t follow a later change to i.
It is a declaration, never a type
This is the whole of why it can exist in a language with no borrow checker. ref may be written
only as a local declaration. There is no ref type, so a ref cannot be a field, a parameter, a
return type, an element, or a type argument; it cannot be re-pointed at a second place once bound; it
cannot be captured by a closure; and it does not outlive the block that declares it.
A ref of a ref is not a re-pointing and is ordinary — one more name for the place the first one stands for, with the walks following through it:
var xs: [3]int = [1, 2, 3]
ref a = xs[1]
ref b = a
b = 42
print(xs[0], xs[1], xs[2])
1 42 3
That restriction is what keeps the compiler’s knowledge complete. A *T is a type, so the moment one
exists it can be carried somewhere the compiler has lost the path it came from. A ref never travels,
so the analyzer still holds the place expression, at the point it was written, in the same body.
At run time a ref stores an address. At compile time it remembers a place. Both halves are
load-bearing. The address is what makes sixty-five path walks one. The remembered place is what keeps
every check a *T would have severed:
- a write through the ref re-runs the
invariantclauses of every struct the place lies inside, found by the same outward walk, because the walk still has the whole place to walk; - a ref into a
val, or into an element of one, is read-only, since reaching into read-only storage keeps the property; - a ref to a
within-constrained slot is checked on assignment exactly as the slot is; - and the bounds check a subscript owes is paid once, at the binding, rather than not at all.
A ref is therefore not a fourth memory mode. It introduces no representation, no new type, and nothing that can be stored: it is a second way to say a place the three modes already describe.
What may be written
A ref’s initializer must be a place, and a call result has no address for the name to mean:
make() -> int = 7
demo()
ref x = make()
print(x)
demo()
'ref' names a place — a local, a field, an element, or a dereference — and this expression has no address for 'x' to name
A ref inherits the place’s writability and gets no modifier of its own. A ref into a val may be
read and not written, exactly as the val may. Stating it twice would only create the chance to
state it wrong.
A user type’s subscript is a place when the type implements IndexPlace, whose method answers
the element’s address: b[i] is *b.index_place(i), a dereference, so a ref names the element
where it lives. Buf is one (arrays). Through Index alone the subscript is a
call answering a value, and there is nothing there for a name to mean.
import sysl.buf.{Buf, buf}
struct Item
block: u64
data: Buf[u8]
var items: Buf[Item] = buf()
items.push(Item(1, buf()))
ref t = items[0]
t.block = 41
t.block += 1
print(items[0].block)
42
The one rule: what may move underneath it
A stored address is only as good as the storage staying where it is. C# has ref locals and needs no rule like this, because a tracing collector keeps the old array alive when the variable is pointed at a new one. sysl has no collector in this tier, so the same program would dangle:
struct Table
cell: []int
swap(*self)
ref e = self.cell[0]
self.cell = [0; 8]
print(e)
end Table
var t = Table([1, 2, 3])
t.swap()
'e' is a 'ref' standing on storage this assignment would release, so the name would be left pointing at freed memory
While a ref is live, no step of its place that could come to name different storage may be assigned, and no mutating method may be called on a prefix of one. The check is decided from types (and, for a call, from what the callee’s body writes), and never asks where the ref is used — which is the question that would need lifetimes.
Which steps are hazards falls out of the model rather than being a list, and the discriminator is ownership, not indirection. Only a step that releases something when it is overwritten can strand a ref:
| step | hazard? | why |
|---|---|---|
a &T | yes | overwriting it drops what it held, and that release may be the last |
a view ([]T) | yes | the same — a view owns its buffer |
a *T | no | a raw pointer owns nothing, so p = q frees nothing |
| a field, a fixed array, or an element of one | no | that storage is the enclosing object’s bytes |
Two words carry the rule, and between them they keep the set small. A step is held still when it is
an owning step that the path goes through — so the place itself is never in the set, because
an assignment to it is what writing through the ref is. ref r = h.node names the slot, so
r = other and h.node = other are one statement written two ways and neither is refused, while
h = other on a &Holder is.
The *T exclusion is not a concession. A program that reaches its tables through a *Self receiver
has no owning step anywhere in its chains, so it is asked for nothing at all — which is exactly the
advance above, where the rule costs the program nothing.
The mutating-call half is the conservative one, deliberately. A *self method may write any part of
its receiver, so a live ref into that receiver is refused the call whether or not that particular
method reassigns anything:
struct Table
cell: []int
grow(*self)
self.cell = [0; 8]
var t = Table([1, 2, 3])
ref e = t.cell[0]
t.grow()
e = 5
'e' is a 'ref' standing on storage this call could release, since a '*self' method may write its receiver
Deciding otherwise would mean reading the callee’s body, and a module compiles against its imports’ signatures and never their bodies. The cost is one refusal in a program that could have written the ref one line later.
A container’s storage moves under a @relocates member, whatever its receiver mode. A Buf
is a handle, so its push takes self and still reallocates what the handle points at. Each member
that can move the storage or give an element up is marked, and the mark is what the check asks:
import sysl.buf.{Buf, buf}
var b: Buf[int] = buf()
b.push(1)
ref first = b[0]
b.push(2)
first = 5
'first' is a 'ref' standing on an element whose storage 'push' could move or give up, since it is '@relocates'
What this does not do is make a *T safe, and it does not try. A ref’s place may be rooted at a
pointer, and whether that points anywhere is the raw tier’s ordinary bargain.
Writing a value that holds an owning step by value writes the step too. With h a local H,
h = H(…) replaces h.node as surely as h.node = … does, so it is refused on the same terms.
A second name for the same object is held to the rule as well. A &T or a Buf may be held
under many names, and whether two of them name one object is a run-time fact, so the question is
asked of types: while a ref stands inside storage that a value of type T holds, writing storage
held in any T other than along the ref’s own path is refused. Here n is a second name for
h.node:
struct Node
cell: []int
struct H
node: &Node
var h = H(Node([1, 2, 3]))
ref e = h.node.cell[0]
val n = h.node
n.cell = [7; 8]
e = 5
'e' is a 'ref' standing on storage held in a 'Node', and this assignment writes one that may be the same object by another name
A call is asked what its callee’s body can write. One that may write a value of such a type — directly, or through anything it calls — is refused while the ref is live; one that only reads, or writes only its own locals and types unrelated to the ref’s storage, is not. A call through a closure or a trait object names no body to ask, so it is refused. A function with no body in the program — a library’s already compiled, or a foreign one — was compiled without the program’s types, so it is judged by its arguments: it is refused when one of them can reach such a value.
struct Node
cell: []int
struct H
node: &Node
alias(n: &Node) = n.cell = [0; 8]
var h = H(Node([1, 2, 3]))
ref e = h.node.cell[0]
alias(h.node)
e = 5
'e' is a 'ref' standing on storage held in a 'Node', and 'alias' may write a 'Node'
A second handle on a Buf is the same case: val b2 = b makes b2.push(…) a @relocates call on
the buffer a ref r = b[0] stands in, and it is refused with the @relocates message.
What stays open is everything that cannot be the ref’s storage: other fields of the ref’s own object
reached along its path (h.node.count = 2 beside ref e = h.node.cell[0]), values of unrelated
types, calls whose bodies only read, and anything at all once the ref’s block has closed:
struct Node
cell: []int
count: int
struct H
node: &Node
look(n: &Node) -> int = n.count + 1
var h = H(Node([1, 2, 3], 0))
if true
ref e = h.node.cell[1]
h.node.count = 2
e = look(h.node)
val n = h.node
n.cell = [7; 2]
print(n.cell, h.node.count)
[7, 7] 2
What this does not make safe is a *T, as above: a ref rooted at a raw pointer, or storage
reached through a reinterpreting cast, is the raw tier’s ordinary bargain.
A ref to a slot that holds a reference
ref r = self.node, where node is a &Node, names the slot, not the object in it. So the
binding takes no count — nothing new holds the object — and r = other is the assignment
self.node = other by another name, releasing what was there and retaining what arrives, in that
order. Reading r produces the reference and takes a count for the reader exactly as reading the
field would.
The distinction matters because the other reading is available and wrong: if binding retained, a ref
would be a &T with extra steps, and the count it took would keep an object alive past the write
that replaced it.
Where it comes from
The form is old and lives outside this language’s usual references, none of which have it. Ada’s
renames is the general case and evaluates the name once at the declaration; Fortran’s
ASSOCIATE is the closest in shape, being block-scoped and deliberately not a type; C#’s ref
locals are the closest in spelling. C# also shows what the restriction is worth: having added ref
returns and ref fields, it spent several releases building the escape analysis that keeping the form
local avoids entirely.
Recursive types
A type may reach itself through an indirection, and only through one. next: *Node is
pointer-sized and legal, as is next: Option[&Node]; a struct holding itself by value has no finite
size:
struct Node
value: int
next: Node
print(1)
type 'Node' contains itself, so it has no finite size
The rule is per cycle rather than per field: a cycle is legal as soon as one edge on it is a *T,
a &T or a []T, so mutually recursive types work as long as the loop passes through one of the
three somewhere. A slice is on that list because it is a pointer and a length — it points at its
elements rather than holding them — while a fixed array [4]T holds them and is not.
struct Node
tag: int
kids: []Node
var n = Node(1, [])
print(n.tag)
1
A type argument is not containment
Whether Wrap[Node] contains a Node is Wrap‘s business, not something the argument list can
say. Buf[T] reaches its elements through a []T, so a node whose children are a growable sequence
of itself is finite — and it is the shape a syntax tree has:
import sysl.buf.{Buf, buf}
enum Json
Num(v: int)
Arr(items: Buf[Json])
total(j: Json) -> int
j match
Num(v) -> v
Arr(items) ->
var sum = 0
for i in 0..<items.len()
sum += total(items.at(i))
sum
end total
var xs: Buf[Json] = buf()
xs.push(Num(1))
xs.push(Num(41))
print(total(Arr(xs)))
42
Two types reaching each other is the same rule, and the loop may pass through a third type’s
argument list. A real document has objects in it, so Json needs a member — and a member holds a
Json by value, which closes a cycle that goes Json → Buf[Member] → []Member → Member →
Json. The []Member inside Buf is the edge that makes it finite, exactly as the []Json above
was. Which of the two is written first decides nothing: declaration order has never meant
anything in sysl and it does not start meaning something here.
import sysl.buf.{Buf, buf}
struct Member
key: string
value: Json
enum Json
Null
Num(v: int)
Obj(members: Buf[Member])
var members: Buf[Member] = buf()
members.push(Member("answer", Num(42)))
print(members.at(0).key)
answer
A generic that holds its parameter by value is the other answer, from the same argument list — and the refusal names the field that holds it rather than the argument that was passed:
struct Wrap[T]
x: T
struct Node
w: Wrap[Node]
var n: Node
print(1)
type 'Node' contains itself, so it has no finite size
Slices keep their backing alive
A slice is three words, not two:
[]T = { owner: *Buf, ptr: *T, len: usize } // 24 bytes on a 64-bit target
ptr and len name the range; owner keeps the bytes alive. A bare {ptr, len} view can
outlive the buffer it views — Go gets away with it because its collector finds the object from an
interior pointer, and sysl has no collector — so the slice carries the owning reference itself.
Taking a slice retains, dropping one releases, slicing stays O(1) and allocation-free, and “a slice
never dangles” becomes true rather than aspirational.
tail() -> []int
var xs: []int = [1, 2, 3, 4]
xs[2..]
var t = tail()
print(t.len, t[0], t[1])
2 3 4
The buffer xs named is gone as a name before print runs, and the slice is still valid, because
the slice is one of its owners.
owner is null when there is nothing to keep alive — a slice of static data, of a *T buffer, or
of a fixed array whose storage outlives every view of it. Retain and release on such a slice are
no-ops, so allocator-free code slicing a static or stack buffer pays nothing at all. This is the same
immortality rule string literals use, generalized.
A string is then exactly an immutable, validated []u8, and the two share one representation
and one implementation.
A view borrowed from its receiver
A null owner is exactly right for static data and exactly wrong for memory a C library owns: an
object wraps the library’s handle, frees it in its destructor (below), and hands out a view
of what the handle points at. Nothing in that view keeps the object alive, so the view can be read
after the destructor has run. @views(self) on the member says so, and the compiler holds every
caller to it:
extern "malloc" c_malloc(n: usize) -> *u8
extern "free" c_free(p: *u8)
struct Column
p: *u8
n: usize
@views(self)
bytes(&self) -> []const u8 = self.p[0..<self.n]
impl Drop for Column
drop(self)
print("freed")
c_free(self.p)
open(n: usize) -> &Column
val p = c_malloc(n)
for i in 0..<n do p[i] = u8(65 + i)
Column(p, n)
sum(xs: []const u8) -> int
var t = 0
for x in xs do t += int(x)
t
run()
val c = open(3)
val v = c.bytes()
print(v.len, v[0], sum(v[1..<3]))
run()
3 65 133
freed
The view is an ordinary []const u8 — no extra word, no count, nothing at run time. What the mark
adds is a fact the compiler keeps about the names in one body: the view lives no longer than the
name it was called through, because that name holds the count that keeps the object alive. The
check is lexical, the same one a ref gets (above), and it refuses four things.
A receiver that is not a name. A temporary is freed at the end of its statement, so a view of it is refused at the call, naming it:
extern "free" c_free(p: *u8)
struct Column
p: *u8
n: usize
@views(self)
bytes(&self) -> []const u8 = self.p[0..<self.n]
impl Drop for Column
drop(self) = c_free(self.p)
open() -> &Column = Column(null, 0)
print(open().bytes().len)
'bytes' answers a view borrowed from what it is called on, and that is the value 'open' answers — a temporary, freed at the end of this statement
A view leaving the function that holds its receiver. A return, or what the body comes to:
extern "free" c_free(p: *u8)
struct Column
p: *u8
n: usize
@views(self)
bytes(&self) -> []const u8 = self.p[0..<self.n]
impl Drop for Column
drop(self) = c_free(self.p)
first() -> []const u8
val c: &Column = Column(null, 0)
c.bytes()
this returns a view borrowed from 'c', and nothing keeps 'c' alive once this function returns
Only a member that is itself marked @views(self) hands one back, and only one borrowed from
self — which is how a wrapper passes the borrow on to its own caller. A free function handed the
receiver as a parameter does not: the caller has no way to know its result is tied to that argument.
A view stored where it outlives the receiver. It may go into a local bound after the receiver, or into a struct value held by one, since those close first. A local bound before it, a field reached through a reference or a pointer, and module storage are refused:
extern "free" c_free(p: *u8)
struct Column
p: *u8
n: usize
@views(self)
bytes(&self) -> []const u8 = self.p[0..<self.n]
impl Drop for Column
drop(self) = c_free(self.p)
run()
var keep: []const u8 = []
val c: &Column = Column(null, 0)
keep = c.bytes()
'keep' is bound before 'c', so it outlives it, and this stores in it a view borrowed from 'c'
What a view is copied into is borrowed from the same receiver — a sub-slice, a struct or Option
built around it, a closure that captures it, a name a pattern binds out of it, a loop variable
walking a view of views — so each of those is held to the same places. A value that cannot hold a
view (v.len, v[0], a string rendered from it, a byte a loop takes out of it) is not borrowed from
anything:
struct Table
rows: []const []const u8
@views(self)
all(&self) -> []const []const u8 = self.rows
run()
var keep: []const u8 = []
val t: &Table = Table(["ab".bytes, "cde".bytes])
for row in t.all() do keep = row
'keep' is bound before 't', so it outlives it, and this stores in it a view borrowed from 't'
A trait member may carry the mark too, and then a call through a trait object (&Viewer) is held
to it exactly as a call on the type is. An implementation of a marked member inherits the mark, so
it need not repeat it. The other way round is refused — a call through the object reads only the
trait’s member, so a mark the trait does not carry would hold nobody to anything:
trait Viewer
look(&self) -> []const u8
struct S
n: int
impl Viewer for S
@views(self)
look(&self) -> []const u8 = []
'look' of 'impl Viewer for S' is marked '@views(self)', but trait 'Viewer' declares 'look' without it
A marked member is only ever called on a value: Column.bytes(c) is refused, as every method
reached through its type is.
Freeing the receiver while the view is in scope. Assigning the name it was called through, a
*self call on it, and a call that may write an object of its type — which a member that frees the
memory does, and a call through a closure or a trait object might, naming no body to ask — are
refused for as long as a local holding the view is in scope:
extern "free" c_free(p: *u8)
struct Column
p: *u8
n: usize
@views(self)
bytes(&self) -> []const u8 = self.p[0..<self.n]
impl Drop for Column
drop(self) = c_free(self.p)
run()
var c: &Column = Column(null, 0)
val v = c.bytes()
c = Column(null, 0)
print(v.len)
'v' is a view borrowed from 'c', and this assignment could free what it views
Put the view in a block that closes first, and the name is free again once it has.
This is the whole of it, and it is narrow on purpose. The mark names only self, it marks only
a member whose result is a view ([]T, []const T, string), and nothing about the borrow is in a
type: there are no lifetime parameters, and no borrow is tracked through a struct a member returns.
A view of a *T region that no member marked stays the programmer’s problem, as every *T is.
Escape analysis
The owner word answers “who keeps this alive” for every slice except one: a slice of a local
fixed array. That storage is a stack slot, valid until the frame returns and no longer.
| slice of | owner | safe to keep? |
|---|---|---|
| a heap buffer | the buffer | yes — retained |
| static data, a string literal | null (immortal) | yes — never freed |
a *T region | null | the programmer’s problem, like every *T — unless a @views(self) member hands it out, which ties it to the receiver (above) |
| a local fixed array | null | only while the frame lives |
The compiler finds the slices for which that matters, and the programmer writes nothing. There is
no @escaping, no lifetime parameter, and no “this result views that argument” marker anywhere in a
signature. Go is the precedent; Rust’s lifetimes and Swift’s @escaping are the alternative, and both
charge the programmer for what a compiler can work out. (@views(self), above, is about memory no
frame owns — a C library’s — which is exactly what the compiler cannot work out from a body.)
What escapes
A slice whose origin is a local array escapes if it can be reached after the frame returns — that is, if it is:
- returned, directly or nested inside whatever carries it back: a struct, an enum, an
Option, a tuple, or one slot of a multi-result list; - stored into anything that outlives the frame — a global, a field reached through a
&T, or an aggregate that is itself stored somewhere that outlives the frame; - passed as an argument the callee keeps;
- captured by a closure that itself escapes;
- assigned into a local that escapes — the rule is transitive, resolved as a fixpoint over the function’s own locals.
Everything else needs no allocation: indexing, iterating, sub-slicing into a local that stays local, comparing, and passing to a callee that only reads.
Crossing a call
Two facts about a function are enough to keep the caller’s analysis local and exact, and both are inferred from the body rather than written:
- per parameter — does the callee let this argument outlive the call?
- for the result — which parameters may the returned value be a view of?
They are computed bottom-up over the call graph and recorded in module metadata. Recursion starts optimistic and iterates to a fixpoint, so a self- or mutually-recursive function converges on the truth rather than on the conservative answer.
Two cases get the pessimistic answer instead — every parameter kept, the result viewing everything:
- a call through a trait object, because which body it reaches is a word read at run time, so there is no one summary to consult. Reading through a concrete type or through a bounded type parameter is unaffected, since monomorphization turns the second into a direct call too;
- a function whose body is not available — an
extern, which is the declaration form for exactly that. The foreign side may retain what it was given, and nothing here can tell.
What happens when a slice escapes
With an allocator, the array is promoted. It is allocated as an ARC buffer instead of a stack
slot, the slice’s owner points at it, and nothing else about the program changes:
render() -> []u8
var line: [8]u8
line[0] = 104u8
line[1] = 105u8
line[0..<2]
var l = render()
print(l.len, l[0], l[1])
2 104 105
The array keeps its type, and only its storage moves. A promoted [8]u8 is still a [8]u8: its
length is still a compile-time constant, it is still a value that copies on assignment, and every
index and store is emitted exactly as an unpromoted one’s is. What changes is where the name’s
address points, and that a view of it carries that buffer as its owner. Rewriting the declaration
into a []u8 would have been the other way to do it and is wrong — it changes the type at every use,
and an array is a value type where a view is not.
Which array moves is decided by the root of the escaping view. An index step is walked through, because an element of a local array of arrays is part of that array’s storage; a field step is not, because that storage belongs to a struct. Only arrays that are both sliced and escaped are promoted.
A TEMPORARY is promoted too, and it has no declaration to promote. A whole array the frame
computed and nobody named — an array literal, a call’s answer, a field of either — is a temporary of
the frame that made it, which is exactly what promotion exists for, so a view of it that gets out is
given storage of its own: the same storage a literal written where a []T is expected already gets.
What makes something a temporary is having no address of its own. That is the same line the paragraph above draws from the other side: a local array, a field of one, an element of one and a parameter are all storage that is somewhere, and the question for each is whose it is to move. A temporary is nowhere yet, so it is nobody’s, so it is this frame’s:
trait Sink
take(self, xs: []const int) -> int
struct Adder
base: int
impl Sink for Adder
take(self, xs: []const int) -> int = self.base + int(xs.len)
var a = Adder(10)
val d: &Sink = a
print(d.take([4, 5, 6][..]))
13
This is the case a variadic call meets without trying: xs: ...int packs a call’s trailing
arguments into exactly [a, b, c][..], so a ...T member reached through a trait object is a
literal that has to outlive the frame. It costs an allocation there and nothing at a call whose slice
stays put.
A literal written where a []T is expected is the same temporary, and it is laid in the frame
on the same terms: handed straight to a call that keeps nothing of it, and whose answer could not
carry a view of it out, it is the array val xs: [N]T = [...] would have been, viewed whole, and
allocates nothing. Its elements may be anything the frame computes:
sum(xs: []const int) -> int
var s = 0
for x in xs
s += x
s
val g = 3
print(sum([g, g * 2, sum([g, 1])]))
13
Everywhere else it is a buffer of its own, as before — bound to a name, returned, stored, handed to a
callee that keeps it, to an async function (whose task holds its arguments until it runs), or to a
call whose answer may be a view of it, such as first([a, b]) answering []const int. Those can
outlive the statement that made them, and a slot cannot.
A call’s answer is the same temporary written a different way, and it is the one a reader meets
first: a digest is a [32]u8, so slicing one straight into something that keeps it is a view of an
array with no declaration behind it.
import sysl.crypto.sha256
static var fingerprint: []const u8 = []
record(data: []const u8)
fingerprint = sha256(data)[..]
record("abc".bytes)
print(fingerprint.len, fingerprint[0], fingerprint[31])
32 186 173
Binding it to a name first would have worked all along, and that is the point: val d = sha256(data) then d[..] is a declared array, promoted by the paragraph above. The two spellings are
one frame slot, so refusing the shorter one bought nothing.
Two roots have nowhere to be promoted to, and are diagnostics rather than promotions — an array a caller passed by value, which is the caller’s layout, and an array that is a field of a named struct on the frame, where moving the field alone or the struct with it is the choice this reference leaves unspecified. A part of a temporary is not one of them: the whole temporary moves, and the view is narrowed over the buffer it moved into.
first_two(a: [4]int) -> []int = a[0..<2]
print(first_two([1, 2, 3, 4]).len)
a slice of an array this frame owns is returned, so it would outlive the array, and the storage is not this body's to move — it is a field of a value or an array a caller passed by value, which has no declaration to move. Declare it as a '[]T', which makes a buffer of its own and owns it, or as a '&[N]T' where the length is fixed
Module storage is never a root, whatever the path to it. It lasts the whole run, so a view of it outlives every frame without moving anywhere: a field of a module array’s element is as static as the array, and a slice of it goes through a trait object, or anywhere else, with nothing promoted.
trait Sink
take(*self, words: []const u32)
struct Count
n: usize
impl Sink for Count
take(*self, words: []const u32) = self.n += words.len
struct Row
words: [4]u32
len: usize
var rows: [2]Row = [Row([0; 4], 0); 2]
var sink: Count = Count(0)
feed(s: *Sink, i: usize)
s.take(rows[i].words[0..<rows[i].len])
rows[1].len = 3
feed(&sink, 1)
print(sink.n)
3
Without an allocator it is always a compile error. Under no alloc there is nothing to promote
into, so every promotion becomes a refusal reported at the view that leaves the frame — and so does
every literal argument a callee keeps. A machine whose manifest took the heap away (capabilities { heap = false }) is held to the same line in every module it builds, without a clause written
anywhere: the refusal names the machine, 'thumbv7em-freestanding' provides no allocator, rather
than leaving the link to say undefined symbol: malloc. That is how
every other allocation-gated feature behaves in the allocator-free subset — growable arrays, escaping
closures, and &T creation are all compile errors there — so it introduces no new rule.
A view that outlives its scope is promoted too
Leaving the frame is the widest way for a view to outlive its storage, and not the only one. The rule is the same at every width: storage whose view outlives the storage’s scope is promoted.
A temporary‘s scope is its statement. What it holds is let go of at the end of it, and the slot it sat in is reused, by the next statement or by the same one on the next round of a loop. A view of it kept in a local is read on later lines, so the temporary moves to the heap and the local reads what was written:
id(xs: []string) -> []string = xs
val s = id([str(7) + "y"][..])
print(s[0])
first() -> int
var keep: []int = []
for i in 0..<3
if i == 0 then keep = [i * 10][..]
keep[0]
print(first())
7y
0
A named array‘s scope is the block that declares it. One declared in a loop’s body or an if‘s
branch is gone when that block closes, so a view of it kept in a local of an enclosing block moves it
the same way. A part of either is the same case: the whole array moves, and the view names its part
of the buffer.
first() -> int
var keep: []int = []
for i in 0..<3
var row = [i * 10, i]
if i == 0 then keep = row[..]
keep[0]
tail() -> []string
var keep: []string = []
if true
keep = [str(1), str(2) + "b", "c"][1..<2]
keep
print(first(), tail()[0])
0 2b
Nothing moves where nothing outlives anything: a view used within its own statement, and a view of a named array kept while that array is still in scope, stay where they are.
id(xs: []string) -> []string = xs
print(id([str(7) + "y"][..])[0])
val a = [str(7) + "y"]
val s = a[..]
print(s[0])
7y
7y
Without an allocator, each of these promotions is a compile error at the view, as an escape from the
frame is. A machine whose manifest says heap = false is told that it provides no allocator, and a
module that declared @no_alloc is told that it did:
@no_alloc
first() -> int
var keep: []int = []
for i in 0..<3
if i == 0 then keep = [i * 10][..]
keep[0]
print(first())
this view of an array literal is kept in 'keep', which outlives it, so the array would move to the heap to outlive its statement — and this module declared '@no_alloc', so there is nothing to move it into
Promotion is silent, not hidden
Silent promotion earns the obvious objection: an allocation appears that nothing in the source asked
for. The answer is discoverability rather than ceremony — --explain-escapes reports every
promotion the compiler made and the route that forced it:
$ sysl build --explain-escapes tty.sysl
tty.sysl:31:12: 'buf' is promoted to the heap, because this view of it is returned
One line per array, in source order, on stderr, accepted by every subcommand. The position is the
view that forced the move rather than the declaration, because that is the half a reader cannot
work out for themselves. This is Go’s -m, and it is the right shape: the common case costs no
reading, and “why did this allocate?” always has an answer. A program that must not allocate says so
with no alloc, and then the compiler enforces it rather than reporting it.
The idiom worth reaching for first, even where an allocator exists, is the last one: return a count
and let the caller slice its own buffer. That is what snprintf does, what Rust’s buffer writers
do, and what most kernel code wants.
Reinterpreting storage
An allocator carves bytes and hands back a typed pointer — that is the whole of what an allocator does. A driver takes an address the datasheet gives as a number and reaches the register block at it. Both need a way to say which type some bytes are, and both are cases the raw tier already committed to.
Three directions, two spellings, because they are not equally dangerous:
| written | direction | tier |
|---|---|---|
usize(p) | a pointer as a number | an ordinary conversion |
ptr_cast(n) | a number as a pointer | unsafe |
ptr_cast(p) | one pointee type as another | unsafe |
A pointer becomes an integer through the ordinary conversion syntax, because it is an ordinary
conversion: usize is wide enough to hold any address by definition, so it is total and loses
nothing, and the result is a number that cannot be dereferenced. isize takes one too, which is what
a program comparing addresses against a signed offset wants.
struct Node
value: int
var arena: [64]u8 = [0u8; 64]
var n: *Node = ptr_cast(&arena[0])
n.value = 42
print(n.value, sizeof(Node), usize(n) == usize(&arena[0]))
42 4 true
The address of a function converts as well, and for the same reason: &f is an address, it is a
pointer’s width, and the number that comes out cannot be dereferenced any more than the one above
can. It is what a binding reaches for when a C library wants a callback registered through something
that takes a number, and it is the same row of the table rather than a special case.
The target type comes from whatever receives the result — the same way va_arg, a bare None,
and a bare null all take theirs. Where that is not where a reader wants to say it, it may be
written on the form: ptr_cast[*Node](&arena[0]) is
the written type-argument list at a special form. Where neither says which
pointer is wanted, the program is told to annotate what receives it:
var arena: [64]u8 = [0u8; 64]
var p = ptr_cast(&arena[0])
print(1)
'ptr_cast' reads an address as a pointer to some type, and nothing here says which
ptr_cast never produces a &T. A reference is a safe-tier value — non-null, refcounted, and
relied on by everything the safe subset promises — and an address invented from bytes has no count for
ARC to own and no object to be non-null about:
struct Node
value: int
var arena: [64]u8 = [0u8; 64]
var n: &Node = ptr_cast(&arena[0])
print(n.value)
'ptr_cast' never produces a reference: a '&T' is counted and non-null, and an address read out of bytes carries no count for anything to own — read it as a '*T'
A weak T is refused for the same reason, and the fat types — a slice, a string — for that reason
and one more: they are wider than an address, so there is nothing to reinterpret. What comes out is a
*T, and reaching anything else from it is the ordinary route through *p.
Storage the compiler did not lay out may not hold a counted value
ptr_cast says which type some bytes are. It does not make those bytes into storage the language
manages, and the difference has one consequence worth stating on its own: a &T, a weak T, a
slice or a string may not be written into memory reached that way — nor may a struct or an enum
that carries one.
The reason is what an assignment to such a field does. It releases whatever occupied the slot before writing the new value, so the release walks whatever the bytes happened to look like.
Nothing refuses this, and that is why it is written here. The compiler knows the field’s type but
not where the pointer came from: p.next = other through a p that is &local writes a field it
laid out, whose previous occupant is a real box, and the release is correct; through a p that is
ptr_cast over an arena the same line walks raw bytes. Telling the two apart needs provenance the
front end does not track.
Zeroed storage is the exception, and it is the one a collector or a calloc gives. A counted
reference that is all zero bits names nothing, and giving back a share of nothing is nothing — so a
&T field (or a Buf, Map or Deque, each of which is one) in storage zeroed before its first use
takes its first assignment safely, and string and slice fields already did. Until it is assigned it
is the one &T that is null, and is_null(r) is how a program asks: one compare of the address
with null, no share taken. An ordinary &T — one the language made — is never null, and is_null
answers false for it; a trait object and a weak T are refused, the latter answering through
.get(), and anything that is not a reference is refused naming its type.
extern "calloc" c_calloc(n: usize, size: usize) -> *u8
extern "free" c_free(p: *u8)
struct Item
label: string
struct Slot
item: &Item
val s: *Slot = ptr_cast(c_calloc(1, sizeof(Slot)))
print(is_null(s.item))
s.item = Item("a")
print(is_null(s.item), s.item.label)
release(s.item)
print(is_null(s.item))
c_free(ptr_cast(s))
true
false a
true
The storage is still the program’s to free, and freeing it releases nothing: a counted field there is
given back before the block goes, and release(place) is how — is_null‘s other half. It gives
back the share the &T place held, running the destructor when that was the last one, and leaves the
place naming nothing, exactly as the zeroed storage started; nothing is allocated, which is why a
finalizer calls it rather than assigning a fresh value it would only lose. A place released twice gives
back nothing the second time, and one released may be assigned again. A container gives its share back
through its own release() member, and an Option or a weak T is emptied by assigning None, so
release is refused on those — and on anything that is not a place, or not a counted reference at all:
import sysl.buf.{Buf, buf}
struct Holder
names: Buf[string]
val h = Holder(buf())
release(h.names)
'release' gives back what a '&T' place holds, and sysl.buf.Buf[string] gives its share back through its own member — call '.release()' on it
Like is_null, it is a form the compiler answers only where nothing else claims the name: a function
or a local called release is what a call reaches.
Keep an index, a raw pointer, or a copy instead. An arena of nodes linked by index is the ordinary answer, and it costs nothing a box was buying:
struct Node
value: int
next: usize // an index into the arena, not a '&Node'
const nowhere: usize = 999
main()
var arena: [4]Node = [Node(0, nowhere); 4]
arena[0] = Node(10, 1)
arena[1] = Node(20, 2)
arena[2] = Node(30, nowhere)
var at: usize = 0
var total = 0
while at != nowhere
total += arena[at].value
at = arena[at].next
print(total)
60
The same rule read from the other side: a type whose fields are all scalars, raw pointers and arrays of those is free to live in storage you carved. That covers every C ABI struct, which is what the raw tier is mostly for.
sizeof, alignof and offsetof, which measure what is being carved, are on the
expressions page.
Slot storage: room that holds no value yet
A growable container needs storage whose spare end holds nothing — not a fill value repeated into
every slot, which for a counted element would keep whatever it names alive until the slot is
overwritten. Three forms give the raw tier exactly that, and they are what
sysl.buf.Buf is written with:
slot_storage(n)(orslot_storage[T](n)) is a[]Tofnslots of which none is live. The element type comes from the position, the wayptr_cast‘s does.slot_place(xs, i, v)putsvin sloti. The first slot past the live ones is filled without letting go of anything, and the live run grows by one; a live slot is an ordinary assignment; a slot further on would leave a gap, so it traps.slot_vacate(xs, n)lets go of every live slot fromnon and shortens the live run ton— but only where nothing else holds the storage. A view handed out earlier still reads those slots, so while one exists the form does nothing, and the next vacate after the sharing ends catches up.
struct Node
n: int
var xs: []&Node = slot_storage(4)
slot_place(xs, 0, Node(1))
slot_place(xs, 1, Node(2))
print(xs.len, xs[0].n + xs[1].n)
slot_vacate(xs, 1)
slot_place(xs, 1, Node(5))
print(xs[0].n + xs[1].n)
4 3
6
The live run is the storage’s own, not the slice’s: the buffer’s box carries the count of live
slots, and its destructor releases exactly those, so storage dropped with two of four slots placed
releases two. The slice’s len is still the whole room. What the compiler cannot check is the one
thing the caller owes: nothing reads a slot past the live run, whose bytes are whatever the
allocator left. That is the raw tier’s bargain, as it is for ptr_cast — the forms are greppable, and
Buf is what writes them so that a program does not have to.
A form with no element type to make is refused at the call:
var xs = slot_storage(4)
'slot_storage' makes storage for elements of some type, and nothing here says which
On storage that slot_storage did not make — an array, a frame, a view of a struct’s field — the
two writing forms are an ordinary assignment and nothing at all, since there is no live run to move.
Device memory
ptr_cast gets a driver to the register block. It does not get it a correct driver, and the missing
half is this: an optimizer is entitled to assume that reading the same storage twice yields the same
value, that a store nobody reads is a store nobody needs, and that two accesses in a row may be one
wider access. Every one of those assumptions is false at a device. while regs.status == 0u32 do ()
is a loop that reads a register until the hardware changes it, and a compiler that hoisted the read
out would spin forever on the first value it saw.
So sysl has C’s qualifier, spelled the way C spells it — in the type:
struct Uart
status: volatile u32
data: volatile u32
baud: u32
var block = Uart(1, 0, 115200)
var regs: *Uart = &block
regs.data = 65
print(regs.status, regs.data, regs.baud)
1 65 115200
A
volatileplace is one whose reads and writes are effects, not value computations. It may change without the program changing it, and reading it may itself do something. So the compiler emits exactly the accesses the source wrote, exactly once each, in the order written — never adding, dropping, merging, or moving them relative to one another.
It constrains the compiler, not the machine. No atomicity, no ordering against another core, no
protection from a torn read. C spent two decades learning this; for talking to another thread the
tools are &sync T, Mutex[T], Atomic[T] and explicit orderings, and none of them is spelled
volatile. A program that reaches for this word to share a counter has written a race with a keyword
in front of it.
It qualifies storage, and a value read out of storage is an ordinary value
This is the rule everything else follows from. regs.status above has type u32 — not
volatile u32 — because what a load hands back is a number, and a number is not somewhere a device
can write. What is qualified is the place, and the qualifier lives in the three types that name a
place somebody else owns, and on the one declaration that names storage where it stands:
| written | what is qualified |
|---|---|
status: volatile u32 | a struct field |
bank: [4]volatile u32 | an element — a GPIO bank |
p: *volatile u32 | a pointee — the lone register |
var tick: volatile u64 | a module’s own storage — a word an interrupt handler writes |
Everywhere else the type being written is the type of a value: what a local var holds, what a
parameter receives, what a function hands back, what a type argument stands for.
var x: volatile u32 = 0u32
print(x)
'volatile u32' is the type of *storage*, and this is a value — what a read of a volatile place hands back is an ordinary 'u32'. The qualifier goes where the storage is named: a struct field, an element, or the pointee of a '*T', as '*volatile u32'
The diagnostic says which spelling was wanted, because a program that writes var x: volatile u32
in a body almost always meant *volatile u32.
Module storage shared with an interrupt handler
A module var — or a static var in the file a program starts in — is storage, named where it
is declared, so it may be volatile. It is how a kernel or a bare-metal program shares a word with
an interrupt handler before anything heavier exists: the handler writes it, the program polls it, and
the poll has to read memory every time round.
var ticks: volatile u64 = 0
@export("on_timer")
on_timer()
ticks += 1
@export("wait_for")
wait_for(n: u64)
while ticks < n do ()
Every read of ticks is one volatile load and every write one volatile store, of the whole width.
At -O2 the loop in wait_for stays a loop that loads ticks each time round; with a plain var
the optimizer is entitled to load it once and branch to itself for ever, since nothing in the program
it can see writes it. What a read hands back is an ordinary u64, and &ticks is a
*volatile u64, exactly as & of a volatile field is.
ticks += 1 is a volatile load and then a volatile store — it is NOT atomic. An interrupt taken
between the two, whose handler writes ticks as well, loses that write. That is fine where only the
handler writes and the program only reads, which is the shape above; where both sides write, or two
cores share the word, the tool is Atomic[T] from sysl.sync, and the qualifier says nothing about
ordering against another core.
The type is held to what a field is held to — a scalar or a raw pointer — since the promise is one access of the whole width. A struct is refused as a whole, and the refusal points at the spelling that works, a struct whose fields are volatile:
struct Counts
ticks: u64
irqs: u64
static var counts: volatile Counts
'volatile Counts' cannot be the type of 'counts': a volatile access is one load or one store of the whole width, and 'Counts' is a struct. Write 'volatile' on its fields instead, as 'n: volatile u64', and declare the storage as a plain 'Counts'
A val is refused: it is a constant, so nothing can write it behind the program’s back.
val limit: volatile u64 = 3
'limit' is a 'val', and a 'val' is a constant: nothing writes it once it starts, so no access to it can be one the program did not make, and 'volatile' has nothing to say. Storage an interrupt handler or a device writes is a 'var'
Per field, not per aggregate. C also allows volatile struct Uart; sysl does not, and the block
above shows why. baud is a shadow value the driver keeps in ordinary memory beside the registers,
and every real device header has one — a reserved word, a cached configuration, a software flag. A
qualifier on the whole struct would sweep it in:
struct Uart
status: u32
var p: *volatile Uart = null
print(1)
a register block is qualified one field at a time
Qualifying per field is the same power with the opt-out, it is what CMSIS and every other vendor header already does, and it makes the restriction below a check on one scalar instead of a walk of a type.
Only a scalar or a raw pointer may be qualified. The promise is about the load and the store the source wrote, so it is only meaningful where an access is one instruction:
- a counted value is refused outright — a
&T, aweak T, a slice and astringcome with retains and releases the compiler places, and a retain that may not be elided is not a request anybody could act on; - a trait object is refused for a plainer reason: it is two words, so touching one is two accesses whatever the source says, and the table beside the value is this program’s rather than a device’s;
- a constrained subtype is refused, and this one is about trust rather than instructions. A
Level = int within 0..7is the claim that a value has been checked; a register holds whatever the device put there. So the register is declared at the base type and what comes back is converted — one written conversion, checked, at the point the value arrives; - a data enum is refused — a tag beside a payload is more than one access however it is written.
A simple enum is admitted, since it is its underlying integer: it is one load, and it is the
spelling a register’s mode field wants. So is a bitfield, which is the one case where the access
reaches more than the field named — the qualifier applies to the container the field is bits of, so a
write is a read-modify-write of the whole of it. @packed on
attributes states that rule and what it costs a driver.
A struct that holds a register carries no invariant. A check is a call taking every field, so it
reads the whole block however few fields the clause names — an invariant written over the shadow value
beside the registers would make writing that shadow an access to the device. There is nothing to hold
the clause true either: a device changes a register between the check and the instruction after it. A
register is checked where it is read.
What the compiler does with it
A qualified access lowers to LLVM’s load volatile / store volatile, which is exactly the barrier
this needs: it stops the reordering, elision and merging above, and stops nothing else. There is no
runtime cost and no runtime component.
- A qualified field is reached at its own address, not lifted out of the block. Reading a
Uartto find out what is instatuswould also readdata, and reading a data register is how a FIFO is popped — so a qualified field gets a place walk, onegetelementptrand oneload volatile, with nothing else touched. - A whole block copied is a copy of every register in it.
var u = *regsis one access to each, which is as much an effect as one access to one of them, so the aggregate access is marked too. Whether a driver wants that is the driver’s business; what it does not get is a silent unqualified read of hardware. - An address taken of a register is the address of a register.
®s.statushas type*volatile u32, so a driver may hand one register to a helper and every access the helper makes is still an access to a device. Without that, a driver would have to be one function. - A type parameter never binds to a qualified type. The loads and stores a generic body emits are its accesses, written once and shared by every instantiation, so it cannot promise to have written the ones a particular caller had in mind.
volatile is not reserved. It is special only in front of another type, so a program with a
variable, a field, a function, or a type of its own by that name still compiles — the same arrangement
sync has after &:
var volatile = 7
print(volatile + 1)
8
[]const T composes with it and means a different thing. const is a property of the view —
these elements may not be written through this handle — while volatile is a property of the
element. A read-only device register is []const volatile u32, and both words are doing work.
Memory a device reads and writes is a library type, not a qualifier
volatile is about the device’s registers. The memory a device reaches by DMA — a buffer, a
descriptor ring — is ordinary memory the processor reads and writes normally; what it needs is an
address the device can use and a handover that keeps the caches honest, and neither is a property
of a place. So it is sysl.dma‘s Region: it carries the device’s address of its
first byte, is made only by adopt from memory somebody knows to be device-reachable, and an ordinary
[]u8 is not one. No qualifier on *T or []T says where storage is, because a slice’s type cannot
know whether it was cut from a stack, a heap block or a frame — the region’s maker does.
Where defer sits
defer is the model’s answer for what the language does not own: a
descriptor from open, a FILE* from fopen, a block from malloc, a lock taken from a mutex. ARC
gives back a reference, a string, and a slice’s backing without being asked, and knows nothing about
those.
Three facts place it against everything above:
- A deferred statement runs before the block’s ARC releases, so every local it names is still alive when it runs — including the one holding the resource it is closing. Leaving from the middle unwinds outward: the innermost block runs its deferred statements and then gives up its counts, then the block outside it does the same.
- It owns nothing and allocates nothing.
defertakes no count, makes no box, and adds no word to any value; a program that does not use it emits nothing for it. That is what keeps it available underno alloc, where the resources it releases are the only ones there are. - A trap runs nothing. A trap aborts without stack cleanup, and
deferdoes not qualify that: a broken invariant means the program’s model of itself is already wrong, and running cleanup against that state is how a corrupt program writes its corruption to disk on the way down.
What it is not is a destructor. A destructor belongs to a type and runs wherever a value of that
type dies; defer belongs to one place in one body and runs for the resource that body took. Both
exist, and the next section is why neither replaces the other.
A destructor
impl Drop for T says what a type does when the last reference to one of its values goes. It
declares one member, drop(self), and it answers nothing:
struct Handle
id: int
impl Drop for Handle
drop(self) = print("closing", self.id)
hold()
var h: &Handle = Handle(7)
print("working")
hold()
print("done")
working
closing 7
done
It is for the resource the language does not manage. ARC returns the storage and releases
whatever the value holds; what it cannot do is close a descriptor, unmap a region, or hand a handle
back to the C library that made it. Those live at the far end of a *T or behind an integer, and
nothing about either says it is owned. The destructor is where that is said, once, beside the type.
defer is the other way to say it, and neither replaces the other. defer close(f) covers every
site a program can name. Under ARC a value can also die where there is no site to write one — and
that case is the whole argument for this being a capability rather than a shorthand:
struct Handle
id: int
impl Drop for Handle
drop(self) = print("closing", self.id)
hold()
var xs: []&Handle = [Handle(1), Handle(2)]
print("holding", xs.len)
hold()
print("done")
holding 2
closing 2
closing 1
done
Nothing in that program names the moment either element dies. The slice goes, and its elements go
with it — so no defer could have been written, and a leak there would not be something the author
could have prevented. Where both would work, the destructor is the better one: it is written once,
and a caller cannot forget it.
It costs nothing to have. Release already calls through a per-payload hook, and a destructor is a
call at the top of that hook. A type without one produces exactly the hook it always did. And a
destructor is in the program only where the program holds a value of its type — made, stored, carried
in a field, a container, an enum payload or a closure — so a library type’s destructor costs a program
that never makes one nothing at all: one that calls sysl.process.pid carries no Child.drop, and on
a bare-metal target is not asked to answer the hooks that destructor would call.
The four limits, each a consequence of where it runs
It runs before the value’s own references are released, so it is handed self intact and a field
may be read to close what it names. It borrows rather than taking a count — the count is already
zero, and taking one would resurrect the object into a second teardown.
It is not called for a value that never reached the heap. A value type is copied, and a copy is
not a second resource: there is no single point of death to hook, and running it per copy would close
one descriptor several times. So a destructor is for a type held behind a &T, and a program that
puts one on a type it then passes by value gets no destructor rather than a wrong one.
A constructor for a type with a destructor returns &T
The paragraph above is a rule about where a destructor runs. This is the same rule said as the consequence somebody writing a binding actually meets, because that is the form of it that is worth having:
struct Encoder
state: *u8
impl Drop for Encoder
drop(self) = print("closing")
open_bad() -> Result[Encoder, string] = Ok(Encoder(null)) // the destructor never runs
open_good() -> Result[&Encoder, string] = Ok(Encoder(null)) // it does
Nothing about the first line looks wrong, which is why this is stated rather than left to be
derived. The impl Drop is right there, the resource is handled as far as any reader can see, and
the program is correct in every respect but the one nobody checks. A binding shipped that way
compresses, parses and queries perfectly while leaking a handle per call.
The compiler says so now, which is its only warning that carries a position:
warning: 'open_bad' hands back 'Encoder' by value, and 'Encoder' has a 'Drop' — a destructor runs
when a box's count reaches zero, so nothing here will ever call it. Return '&Encoder' instead, or
take the resource apart before returning it
A type annotation at the call site is not a fix, and it looks like one. val e: &Encoder = open_bad()? compiles whichever way the constructor is declared, because reading a value into a &T
binding boxes a copy — so the defensive move leaks exactly as much as no move at all. The
declaration is what has to change.
And an error path needs closing by hand. Where a C call hands back a handle and fails — a
refused connection whose connection object still has to be freed — the Result is an Err, no value
ever owns the handle, and no box is ever made for a destructor to hang off. Read what you need from
it and close it where it stands.
Boxing the holder does not save a field held by value, which is the same mistake one level in and reads more convincingly than the last one:
struct Thing
n: int
impl Drop for Thing
drop(self) = print("drop", self.n)
struct ByValue
inner: Thing // no destructor ever runs
struct Boxed
inner: &Thing // it runs when the holder dies
hold()
val a: &ByValue = ByValue(Thing(1))
val b: &Boxed = Boxed(Thing(2))
hold()
drop 2
The ByValue holder really is on the heap and its count really does reach zero — and the Thing
inside it is a copy in that box, not a box of its own, so there is nothing whose count could reach
zero for it. The rule is the type at every level that owns a resource, not the outermost one, and
“I boxed it” settles the question for the holder alone.
A container is the same shape. A Buf[Thing] destructs nothing whatever happens to it; a
Buf[&Thing] destructs each element when the buffer dies. The compiler’s warning sees none of these
— it reads declared return types — so a field and a container are the two places to check by hand.
A handle that hands out a second value does NOT need a second type, and reaching for one is the
mistake this paragraph exists to head off. A method taking self has a copy and cannot name the box
it was called through, so building a value around the same resource there would hand out a second
owner and the destructor would run twice. The obvious escape is to split the type — a private
handle carrying the Drop, and a copyable value holding a & to it — and it works, and it is
unnecessary: a &self method
is handed the box itself, so the value it constructs takes a share rather than a second owner. One
type, one destructor, and the resource outlives everything that kept a share of it. Two of the org’s
own bindings carried the split before anybody checked.
It is not called for a value in a reference cycle, whose count never reaches zero. That is not a
new consequence of this feature but the existing cost of counting rather than collecting — the
storage already leaks there. A weak T is what breaks a cycle.
It is not called for module storage when the program ends. Storage that lasts the whole run is never let go of, so its count never reaches zero. There is no exit pass and there will not be one: a process exiting is what returns what it held, and running destructors at exit is the feature C++ has spent decades regretting, because the order two statics come apart in has no good answer. What it costs is that a buffered writer held in a static is not flushed at exit; the program flushes it.
No order is promised among siblings. Two values that die together — the elements of a slice, the fields of a struct — come apart in an order that follows the teardown worklist, which today makes it last-to-first. A program that needs one before another has to say so.
Crossing a concurrency domain
A &T permits aliasing and mutation through any reference, which is safe within one concurrency
domain and nowhere else: its refcount is non-atomic, and two threads touching it would race. So a
&T may not leave its domain, and crossing one copies by default — which is what process IPC does
anyway, and what keeps the ordinary path free of atomics.
The exception is &sync T, whose refcount is atomic. It is a distinct type from &T with no
conversion either way, and which one an object is is chosen where it is allocated:
struct Cell
n: int
send(c: &sync Cell) -> int = c.n
var c: &Cell = Cell(1)
print(send(c))
'&Cell' and '&sync Cell' are distinct types, and neither converts to the other: a count is atomic or it is not from the moment the object is allocated, and a conversion would put an ordinary retain beside an atomic one. Allocate Cell as a '&sync Cell' where it is constructed
Taking a share of a &sync T is a relaxed increment, since a count you already hold cannot reach
zero underneath you. Giving one back is a decrement with release ordering, and the share that takes
the count to zero acquires before destroying the object, so the thread that frees it sees every
other thread’s writes to it. The acquire is a fence acquire — except in a build whose
SYSL_EXTRA_CFLAGS ask for -fsanitize=thread, where it is an acquire load of the count instead.
The two order the same things, but ThreadSanitizer does not model a standalone fence, and with it
every &sync object freed on another thread than its last user’s would be reported as a race.
A conversion would put an ordinary retain beside an atomic one, so the count is atomic or it is not
from the moment the object exists. &sync T makes the reference safe to share, not the object
safe to mutate — that still wants a Mutex.
A &T held in a @thread_local is not a domain crossing and needs no &sync, the storage
belonging to one thread by construction.
Module storage reached from another domain
A closure erased into a &sync Fn is asked about what it captures — and equally about the module
storage it reaches, however many calls down. A module var is one object the whole program shares,
so a count in it that is not atomic is the race a capture would have been:
struct Cell
n: int
static var shared: &Cell = Cell(1)
bump()
shared.n += 1
val job: &sync Fn() -> unit = () -> bump()
job()
print(shared.n)
a closure shared between two domains may be called from either, so every count it reaches has to be atomic — but the module storage 'shared' it reaches, which 'bump' reads, is a '&Cell', whose count is not. Hold it as a '&sync Cell' ('06')
Hold it as a &sync Cell and the program prints 2. What may be reached is what may be captured: a
scalar, an Atomic, a &sync T, a raw pointer, and anything in @thread_local storage, which is
one copy per thread; a val laid down as constant data is not asked either. Two kinds of body are
held to it — whatever is erased into a &sync object (a closure, a named function read into a
&sync Fn), and an interrupt handler, which the processor enters on top of whatever was running —
one written in the interrupt convention, or an ordinary function marked
@domain(interrupt),
the shape a vector table written in assembly calls. A bare @export is not: it is how C enters
sysl at all, almost always on the program’s one thread, and nothing says a second domain exists until
@domain does:
struct Cell
n: int
var shared: &Cell = Cell(1)
@domain(interrupt)
@export("on_timer")
on_timer()
shared.n += 1
'on_timer' is entered in the 'interrupt' domain, on top of whatever was running, so every count it reaches has to be atomic — but the module storage 'shared' it reaches is a '&Cell', whose count is not. Hold it as a '&sync Cell' ('06')
Without the annotation the same archive builds, and the race it describes is the reader’s to know about.
A val over a slice literal is constant data, so it crosses. Its elements are laid down as an
array in the object file and the view over them has no owner, exactly as a string literal’s view has
none — there is no count in it to race on, and nothing runs at startup to build it:
static val colors: []const string = ["red", "green"]
known(c: string) -> bool
for x in colors
if x == c then return true
false
val job: &sync Fn() -> unit = () -> print(known("green"))
job()
true
The elements have to be laid down themselves — literals, and the arrays, structs and views built
from them. A val over a call is filled at startup with a counted owner like any other, and is
refused as a var would be. A []T without const can be written through, so its array goes in
writable data, and a closure shared between domains may reach it only where its elements hold no
count: a store into a []string puts a heap string there.
A guarded value crosses with its lock
A Map shared through a count that is not atomic may not be reached from another domain — but a
Mutex[Map[...]] may, whatever it holds. Mutex[T] is declared @guards(inner), over a
Guarded[SleepLock, T] that is itself @guards(value): its own members are the only way to the T,
and each takes the lock first, so sharing the lock does not share the value. A guarded field that is
itself a guarding struct is looked through, so what leaves Mutex.with is held to the rule the T
inside sets:
import sysl.container.{map, Map}
import sysl.threads.Mutex
static var names: Mutex[Map[string, int]] = Mutex.new(map())
lookup(s: string) -> int = names.with((t) -> t.get(s).unwrap_or(0))
val job: &sync Fn() -> unit = () -> print(lookup("a"))
job()
0
What leaves through with or try_with is what is held instead, at the call: once the lock is
let go, the value it answered belongs to whoever holds the lock next as much as to the caller, so it
is held to the rule a capture is. An int or a bool passes on its type, and so does a value built
fresh inside the closure — a string literal, which is immortal, or a .copy() of a string, which
gives the bytes an owner of their own. A string read out of the map still shares its count with the
one inside, and is refused:
import sysl.container.{map, Map}
import sysl.threads.Mutex
static var names: Mutex[Map[string, string]] = Mutex.new(map())
print(names.with((t) -> t.get("a").unwrap_or("none")))
'sysl.threads.Mutex.with' answers a value out of the 'value' it guards, and once the guard is let go that value is shared with whoever holds it next, so every count it reaches has to be atomic — but this answers a 'string', which may share its bytes' count with the guarded one. Answer a fresh string instead — '.copy()' inside the closure gives the string an owner of its own, and a literal is immortal
t.get("a").unwrap_or("none").copy() is accepted, and so is the Map itself held as a &sync Mutex[...] and captured. A struct of a program’s own opts in the same way — @guards(items) above
it names a field that is private, and every member that answers something is held to the rule at
its calls. What is not checked is a closure that stores something read out of the guarded
value where it outlives the call — through a pointer it captured, say: nothing follows a counted
value from the field to the store, so that one is the reader’s to know about.
A table whose every record has its own lock
A buffer cache, an inode table, a page cache: each record is locked on its own, so two processors
touch two records at once. sysl.sync.Guarded[L, T] is that record — a value, not a box, with
the lock laid inline beside the T — and it is declared @guards(value) exactly as Mutex[T] is.
The lock is any type implementing the Lock trait (acquire, try_acquire, release, each taking
*self): a SpinLock, a sysl.threads.SleepLock, or a lock a package writes for itself.
Nothing new decides that the table crosses: an array is its elements, so sharing a
[4]Guarded[...] shares four locks, and sharing a lock does not share what it guards. The records
may hold a Buf, a string, a &T — whatever they hold is reached only through with:
import sysl.buf.{Buf, buf}
import sysl.sync.{Guarded, SpinLock}
struct Item
hits: int
log: Buf[int]
static var table: [4]Guarded[SpinLock, Item] = [
Guarded.new(SpinLock(0), Item(0, buf())), Guarded.new(SpinLock(0), Item(0, buf())),
Guarded.new(SpinLock(0), Item(0, buf())), Guarded.new(SpinLock(0), Item(0, buf()))]
touch(i: int) -> int = table[i].with((r) ->
r.hits += 1
r.log.push(i)
r.hits)
val job: &sync Fn() -> unit = () -> print(touch(2))
job()
job()
1
2
The same table of bare Items is refused, naming the Buf that two domains would share:
import sysl.buf.{Buf, buf}
struct Item
hits: int
log: Buf[int]
static var table: [2]Item = [Item(0, buf()), Item(0, buf())]
touch(i: int) = table[i].log.push(i)
val job: &sync Fn() -> unit = () -> touch(1)
job()
the module storage 'table' it reaches, which 'touch' reads, holds a 'sysl.buf.Buf[int]' in its 'log', which is shared through a count that is not atomic
with hands the closure the record in place — its address, lent for the call — so a field
written inside it is a store into the table: the record is not copied and nothing it counts is
retained. What leaves with (and try_with) is held to the rule Mutex.with‘s result is, at the
call, so the Buf itself may not come out:
import sysl.buf.{Buf, buf}
import sysl.sync.{Guarded, SpinLock}
struct Item
log: Buf[int]
var cell = Guarded.new(SpinLock(0), Item(buf()))
val b = cell.with((r) -> r.log)
'sysl.sync.Guarded.with' answers a value out of the 'value' it guards, and once the guard is let go that value is shared with whoever holds it next, so every count it reaches has to be atomic — but this answers a 'sysl.buf.Buf[int]'
A slice or a Buf of guarded records is asked the same way: what is in the way is the view’s or the
Buf‘s own count, not the records, so one reached through a *T or held where it is constant data
crosses, and one shared through its own ordinary count does not.
On a freestanding target the table is constant data: Guarded.new is a const function, and
an instance of a generic const function is run while compiling like any other, so a kernel writes
the table as a module var and the image carries it laid out — every lock free, every record its
initial value — with nothing run before main:
import sysl.sync.{Guarded, SpinLock}
struct Inode
refs: int
mode: u16
var inodes: [64]Guarded[SpinLock, Inode] = [Guarded.new(SpinLock(0), Inode(0, 0)); 64]
@export("iget")
iget(i: int) -> int = inodes[i].with((n) ->
n.refs += 1
n.refs)
A record whose initializer is not constant — a buf() the heap fills, say — is still refused there,
with the reason the call could not be run.
A frozen buffer crosses
A Buf and a view are shared through a count that is not atomic, so neither may sit in a &sync
object — which leaves read-only heap data, decoded pixels or a list of shapes, behind a lock it does
not need. buf.freeze() takes a buffer’s elements and answers a &sync Frozen[T]: one atomic
count on the whole buffer, and nothing that writes it. It goes wherever a &sync reference goes — a
field of a &sync struct, a capture of a &sync Fn, the state handed to spawn:
import sysl.buf.{Buf, buf, Frozen}
struct Texture
width: usize
pixels: &sync Frozen[u8]
var b: Buf[u8] = buf()
for i in 0..<6
b.push(u8(i * 40))
val tex: &sync Texture = Texture(3, b.freeze())
val job: &sync Fn() -> unit = () ->
var sum = 0
for p in tex.pixels
sum += int(p)
print(tex.pixels[4], tex.pixels.len(), sum)
job()
print(b.len())
160 6 600
0
The buffer is left empty, through this name and every other name of it: the elements are copied
once into storage sized to them, so nothing that still holds the buffer — or a view taken of it
earlier — can reach the frozen storage. What a frozen buffer offers is len, is_empty, f[i]
(checked, as a Buf‘s is), for x in f, and view(), a []const T. A view keeps the buffer alive
through a small box of its own, counted the ordinary way, which holds one atomic share — so it is kept
and handed on like any other view, and costs one allocation where f[i] and for cost none. Nothing
writes one:
import sysl.buf.{Buf, buf, Frozen}
var b: Buf[int] = buf()
b.push(1)
val f = b.freeze()
f[0] = 2
assignment needs a variable, a field, an element, or a dereference — something with an address
f.push(2) is refused as a method Frozen does not have, and a store through view() is refused as
a store through any []const T.
Reading a local after freeze is refused, since all it can find is the empty buffer: freeze
is @consumes, so on every path after the call the local may not be read
until it is assigned a new value. A field, or module storage like b above, is not followed, and
reads as empty:
import sysl.buf.{Buf, buf}
pixels() -> usize
var b: Buf[u8] = buf()
b.push(1)
val f = b.freeze()
f.len() + b.len()
print(pixels())
'b' is read here after 'b.freeze()' took everything it held
A frozen buffer shares exactly what its elements share. Every thread reading one reads its
elements, so the element type is held to the rule a &sync struct’s fields are: a scalar, a &sync U (a &sync trait object among them), a raw pointer, another frozen buffer, and arrays and structs
of those. Freezing a buffer of plain references is refused at the call, naming the element:
import sysl.buf.{Buf, buf}
struct Cell
n: int
var b: Buf[&Cell] = buf()
b.push(Cell(1))
val f = b.freeze()
'&sync sysl.buf.Frozen[&Cell]' may be reached from two domains at once, so every count inside it has to be atomic — but it holds a '&Cell', whose count is not. Hold it as a '&sync Cell' ('06')
Held as a Buf[&sync Cell], it freezes. A Buf[string] or a Buf[Buf[u8]] is refused the same way,
for the count each element carries. Freezing allocates — the storage and the box — so a
@no_alloc module may not call it, and a freestanding program freezes where it has a heap.
@crossing — where the rule is asked
The rule above says what may cross. @crossing says where: it is the annotation a facility
writes above the function that hands a value to another domain, naming the parameters it hands it
through. sysl.threads.spawn is declared with one, and so is any package binding a scheduler
of its own:
struct Cell
n: int
struct State
cell: &Cell
@crossing(s)
start(s: *State) -> int = s.cell.n
var c: &Cell = Cell(1)
var st = State(c)
print(start(&st))
what 's' of 'start' points at reaches another concurrency domain, so every count inside it has to be atomic
A *T parameter is looked through, and that is the whole of what the annotation buys. A raw
pointer carries no refcount of its own, which says nothing about the object at the far end — and
the object at the far end is what crossed. Everything else is asked about the parameter’s own type.
Hold the cell as a &sync Cell and the same program compiles.
It costs nothing at run time: no code is emitted, no signature moves, and a program that satisfies it
is the program it would have been without the line. What it adds is the refusal, made at each call —
so a generic facility is asked afresh for every set of type arguments, exactly as a &sync Box[T] is.
A boundary nobody marks is not examined. The annotation is how a library author says a domain begins here, and there is no way for the compiler to guess: a scheduler in a package looks like any other function until somebody writes the line.
A member may carry it, and so may the two frames. @crossing, @reads and @writes are the
annotations that are about a parameter, which a member has exactly as a free function does — so
they are the three a method, a property or an associated function may be written above, and the only
three:
struct Chan
open: bool
@crossing(v)
send(*self, v: *int) -> int = v[0]
var c = Chan(true)
var n = 7
print(c.send(&n))
7
Everything else says something about a free function or about a type — what sysl test calls, what
recurses, what a symbol names, how fields are laid out — and none of that is something a member
supplies:
struct P
v: int
@tailrec
take(self, n: int) -> int = n
print(P(1).take(2))
the only annotations a member may carry are the ones about a parameter
The three are refused above a field and above a variant for a different reason, which the sentence says: neither has parameters for one of them to name.
sysl.threads.Channel[T] is what this changed. Its send and try_send were free functions
taking the channel by address, because the annotation had to be written on a wrapper a caller went
through; they are methods now, so a channel’s transfers and its queries read alike.
A pointer lent for one call
A *T is a type, so once one exists it can be carried anywhere — which is exactly wrong for an
address that is only good while something else holds still: the value inside a lock until the lock
is released, a slot in a scratch buffer until it is reused. Returning the address and asking the
caller to stop using it in time is a promise nothing checks.
@lends(body) turns it around: the function keeps the pointer and calls body with it, and the
compiler holds every body passed there to letting the pointer go before it returns. Inside, the body
may read and write through the pointer, and hand it to a function that only uses it:
struct Account
balance: int
edits: int
@lends(body)
edit[R](*self, body: *int -> R) -> R
self.edits += 1
body(&self.balance)
end Account
add_ten(n: *int)
*n += 10
var acct = Account(100, 0)
acct.edit((b) -> *b -= 30)
acct.edit((b) -> add_ten(b))
acct.edit(add_ten)
print(acct.balance, acct.edits, acct.edit((b) -> *b * 2))
90 3 180
sysl.threads.Mutex.with is declared this way, and its argument is the reason the annotation
exists: an address handed out by a lock() stays usable after unlock(), and one lent to a body does
not (library/threads.md § with lends the value for one call).
What the body may not do is let it out — the ways a view of a local array gets out, asked of the pointer instead: returning it, storing it anywhere but a local of the body, putting it in a box, and passing it to something that keeps it. Each is refused at the expression that does it:
struct Cell
v: int
@lends(body)
with[R](*self, body: *int -> R) -> R = body(&self.v)
var c = Cell(1)
val p = c.with((v) -> v)
lends this body a pointer that is valid only until the call returns, and this one is returned
struct Cell
v: int
@lends(body)
with[R](*self, body: *int -> R) -> R = body(&self.v)
var kept: *int = null
var c = Cell(1)
c.with((v) -> kept = v)
and this one is stored somewhere the call does not own
A call is followed into the callee, whose body says whether it keeps what it was handed — so
add_ten above is fine and this is not:
struct Cell
v: int
@lends(body)
with[R](*self, body: *int -> R) -> R = body(&self.v)
struct Holder
p: *int
keep(h: *Holder, p: *int)
h.p = p
var h = Holder(null)
val hp = &h
var c = Cell(1)
c.with((v) -> keep(hp, v))
and this one is passed to 'keep', which holds on to it
What a call answers is followed the same way. A callee whose answer is made of the pointer — the pointer itself, or the address of something inside what it points at — hands the body the lent pointer back, and the body is held to it. A pointer the callee read out of the lent value is somebody else’s address, and is the body’s to keep:
struct Node
v: int
struct Queue
head: *Node
first(*self) -> *Node = self.head
struct Guard
q: Queue
@lends(body)
with[R](*self, body: *Queue -> R) -> R = body(&self.q)
var n = Node(7)
var g = Guard(Queue(&n))
val p = g.with((q) -> q.first())
print(p.v)
7
An extern, a function pointer and a trait object have no body here to ask, so a lent pointer passed
to one is refused as kept. Converting the pointer to an integer is not following it: usize(p) is
an address as a number, which is the raw tier’s to answer for.
What is passed there has to be a body the compiler can read — a closure written at the call, one
held in a local, or a function’s name. A bare-arrow parameter is a type parameter, so each call is a
direct call of that body, which is also why a lending function costs no allocation. A closure behind a
&Fn could be any body at all:
struct Cell
v: int
@lends(body)
with[R](*self, body: *int -> R) -> R = body(&self.v)
var c = Cell(1)
val f: &Fn(*int) -> int = (v) -> *v
print(c.with(f))
lends 'body' a pointer that is valid only during the call, so what is passed there is a closure or a function's name
@lends names parameters, each once, and each a call:
@lends(body)
twice(x: *int, f: *int -> int) -> int = f(x) + f(x)
'@lends' names 'body', which is not a parameter of 'twice' — its parameters are 'x', 'f'
@lends(x)
twice(x: *int, f: *int -> int) -> int = f(x) + f(x)
'@lends' names 'x', which is not a call
Hazard summary
| segfault source | prevented in the safe subset by |
|---|---|
| use-after-free / double-free | ARC on &T; weak degrades to Option and never dangles |
| null dereference | non-null references; nullable is Option |
| out-of-bounds | length-carrying arrays and slices, checked everywhere |
| slice outliving its buffer | the slice’s owner word retains it; escaping locals are promoted |
| refcount race across threads | &sync T is atomic; a &T may not cross a domain, checked at a @crossing parameter |
| dangling or wild pointer | impossible without *T |
Only *T opts out, and it opts out visibly.
One hazard is not on that list: racing on the fields of an object two threads deliberately
share. Preventing that needs proof of exclusive access, which is the thing this language trades away,
so it is answered by a Mutex and by convention rather than by the type checker. It takes &sync or
*T to reach the situation at all, so it is at least as greppable as everything else here.
The two worlds, one language
- Application, server, utility:
Tand&T, plusweak, slices, and arrays. Safe, pleasant, ARC-managed. - Kernel, driver, allocator-free:
T,*T, fixed arrays, slices, and manualmalloc/free. A module that never creates a&Temits no allocation and no allocator dependency, exactly like C, and stays bounds-checked wherever it uses arrays and slices.
The boundary is not a convention — it is compiler-enforced through the allocator capability. What it
gates is allocation, not ownership. Allocator-free code may hold, pass, copy, and drop a &T or a
heap-backed slice that something else created: retain and release are a few instructions, and the free
path goes through the object’s own deallocation hook. What it may not do is make one. That is what
lets a driver keep the &Device its bus manager handed it, and a no alloc parser read a heap-backed
slice it was given — neither of which is expressible if ownership stops at the boundary.
Next: arrays and slices — the two sequence types, and what a view keeps alive.