sysl

Memory

Three modes, chosen per declaration. No garbage collector, no borrow checker, and no allocation keyword.

This is the chapter that makes sysl a different language rather than a different syntax. Everything before it would have read much the same in half a dozen languages; nothing after it reads right until you know this.

A systems language is used for the control it gives you over where things live and when they die. The two well-known ways to keep that control and stay safe are a garbage collector, which takes away the when, and a borrow checker, which keeps both but asks you to prove your program correct to a checker as you write it. sysl takes a third road: reference counting, with the choice of mode written on each declaration.

The three modes

modewhat it iswho frees it
Ta value — on the stack, in a register, or inline in something biggernobody; it goes when the frame does
&Ta counted reference to a heap objectthe compiler, when the last reference goes
*Ta raw pointer — C’s pointeryou

The choice is per declaration, not per type. The same struct Point can be a value here and a heap object there, and it is the declaration that says which:

struct Point
    x: int
    y: int

var here = Point(1, 2)              // a value, in this frame
var shared: &Point = Point(3, 4)    // on the heap, counted

print("value:", here.x, "reference:", shared.x)
value: 1 reference: 3

Look closely at those two lines, because the difference between them is the whole idea: the constructions are identical. Point(3, 4) did not ask to be on the heap. The annotation &Point is an expectation, and writing an ordinary construction where a &T is expected is what puts the object there.

No new, no malloc, no Rc::new, no .clone(), no wrapper type to learn. The positions that create a reference are the ones that already state a type — a declared local, a parameter, a return type, a struct field, an enum variant’s payload. A function whose return type reads -> &Process allocates in its body and one reading -> Process does not, with the same construction written in each. Somewhere with no expectation at all, a construction is a value. That is the whole rule.

References are shared, and never null

There is no borrow checker, so there is no exclusivity rule to satisfy. Many references may point at one object, and any of them may write to it:

struct Account
    owner: string
    balance: int

deposit(a: &Account, amount: int)
    a.balance += amount

var shared: &Account = Account("ada", 100)
var alias = shared

deposit(alias, 40)

print("balance:", shared.balance, "same object:", shared == alias)
balance: 140 same object: true

alias = shared did not copy the account — it made a second reference to it, and incremented the count. Compare that with the struct chapter, where b = a on a value copied it. Same syntax, and the declared mode is what decides.

If you are coming from Rust: yes, that is two live mutable aliases, and yes, it is allowed. Handing that back is the deliberate trade. Reference counting pays for it at run time — a retain here, a release there — and buys a language you can be productive in on the first day.

A &T always points at a live object. There is no null reference to check for and no way to make one, which removes an entire category of bug rather than diagnosing it.

Something that may be absent is therefore an Option, and that is what makes a linked structure look the way it does:

struct Item
    label: string
    rest: Option[&Item]

count(list: Option[&Item]) -> int
    list match
        Some(item) -> 1 + count(item.rest)
        None -> 0

var last: &Item = Item("c", None)
var middle: &Item = Item("b", Some(last))
var items: &Item = Item("a", Some(middle))

print("length:", count(Some(items)), "head:", items.label)
length: 3 head: a

The match is not ceremony around a null check — it is the only way to get at the item, so the absent case cannot be forgotten.

Cycles, and weak

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 to have a purpose-built answer.

A weak T is a non-owning reference. It does not keep its referent alive, and when the last strong reference goes away it simply 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 child: &Node = Node("child", root)

print("root's parent:", parent_of(root))
print("child's parent:", parent_of(child))
root's parent: (none)
child's parent: root

Reading one is a question, asked with get(), and the answer is Option[&T] — a live strong reference, or None. So a weak reference can never dangle: by the time you have something to use, you have proved it is still there.

There is no operator that makes a weak reference. A &T becomes one wherever a weak T is what was asked for, which above is the struct field. Reach for weak when you have a genuine cycle, and not before.

When the last reference goes

ARC returns the storage and releases whatever the value held. 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 behind a raw pointer or an integer, and nothing about either says it is owned. impl Drop for T is where that is said, once, beside the type:

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

defer (in statements) is the other way to say it, and neither replaces the other: defer covers every site a program can name, and a destructor covers the deaths it cannot — an element of a container that goes out of scope dies at a point with no expression in the source. The reference has the four limits, of which the one to know first is that a destructor runs for a value held behind a &T.

*T — the way out

The third mode is C’s pointer, and it is exactly as safe as C’s pointer. It is spelled with a sigil you can grep for, which is the point: the unsafe tier is visible in the source rather than hidden behind a keyword nobody scans for.

bump(n: *int)
    *n += 1

var counter = 41

bump(&counter)

print("counter:", counter)
counter: 42

&x takes the address of something and *p reads or writes through a pointer, both as in C. Nothing is counted, nothing is checked, and a pointer can dangle. In exchange it costs nothing at all, which is what a device driver, an allocator, or the inside of a data structure sometimes needs.

A pointer is also how a type reaches itself without an Option — and selecting through one needs no ->, because there is nothing else . could mean:

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("chain:", total)
chain: 6

null exists for *T and only for *T. That is the trade the three modes make explicit: the one place a null can appear is the one place you asked for C’s rules.

A place deep inside a structure can be given a shorter name with ref, which binds the storage rather than a copy of what is in it and keeps every check a *T would have severed. It is a local declaration and never a type, which is what lets it exist in a language with no borrow checker — the reference has it, along with volatile for storage the program is not the only one writing.

What this costs

Less than you would think, because the compiler knows when counting is pointless. A local that never escapes its frame is not heap-allocated at all; a closure that does not outlive its frame is inlined rather than boxed; a slice of a local array stays on the stack when nothing takes a view of it away. You do not annotate any of that, and you cannot get it wrong — try to return a view of a local buffer and the compiler will tell you.

What remains is a retain and a release at the points where a reference is genuinely shared. That is the price of not having to prove anything to a borrow checker, and it is the trade the whole language is built around.


Next: arrays and slices — two types that lean on everything in this chapter.

Search

Esc
↑↓ to navigate ↵ to open Esc to close