When one owner isn’t enough
A value in Rask sits in one place and one name is responsible for it. That covers almost everything you write.
Three situations break it, and each has one answer: Shared, Rack with Link, and Heap.
They have little in common, so there’s nothing to learn as a set. You go looking for one when you
have its problem.
Most of the time: a plain field
struct Player {
name: string
health: i64
items: Vec<string>
}
mut hero = Player(name: "Ada", health: 100, items: [])
hero.items.push("lamp")
hero.health = hero.health - 10
println("{hero.name} has {hero.health} hp and {hero.items.len()} item")
hero owns that player. The fields are reached with a dot, the Vec inside grows when you push to
it, and when hero goes out of scope the whole thing is released. Nothing more is involved.
Having many of something doesn’t change that. A Vec or a Map is an ordinary value that happens
to keep its contents on the heap, so it’s owned by one name like anything else.
So what breaks it? Another part of the program needs the same value. Or your values need to refer to each other. Or a type needs to contain itself.
When another part needs the same value: Shared
Say your settings are read in one place and updated in another. A copy for each is the wrong shape, because then there are two settings and an update to one is invisible to the other. What you want is a single value that both places reach.
struct Settings {
theme: string
retries: i64
}
let settings = Shared.new(Settings(theme: "dark", retries: 3))
That’s the whole of it for most uses. Shared.new is always a correct answer: it takes a
read-write lock, so any number of readers go at once and a writer gets it to itself. It’s safe to
send to another task.
What you give up is reaching the value with a plain dot. Every access says read or write:
let theme = settings.read().theme
println("theme is {theme}")
settings.write().retries = 2
Each of those takes the lock, does the one thing, and releases it, all inside the expression.
When you need several statements to happen under one lock, with holds it open for a block. Here
the read and the write have to be the same lock, or another task could change retries in between:
with settings.write() as s {
if s.retries < 5 {
s.retries = s.retries + 1
}
}
Taking a lock is a real cost, and a real cost is visible in Rask source. Scoping it puts the unlock where you can see it too: the block ends, the lock releases.
Shared takes a strategy that decides which lock it uses, and the default is the one above. The
other two are worth reading about the day a profiler points at the lock, and not before.
Things that point at each other: Rack and Link
Rooms with exits, nodes with children, anything where one value refers to another and values get
removed while others still refer to them. A Vec can’t do this: the index you saved means
something different after a removal, and nothing tells you.
struct Room {
name: string
exit: Link<Room>?
}
A Rack holds the values. A Link is how one of them refers to another.
mut rooms: Rack<Room> = Rack.new()
let hall = rooms.insert(Room(name: "hall", exit: none))
let cell = rooms.insert(Room(name: "cell", exit: none))
hall.exit = cell
if hall.exit? as e {
println("hall leads to {e.name}")
}
Now delete the room that hall points at:
rooms.delete(cell)
if hall.exit? as e {
println("hall leads to {e.name}")
} else {
println("hall leads nowhere")
}
The same if, run twice, prints hall leads to cell and then hall leads nowhere.
That’s the part worth keeping. Deleting a node doesn’t leave hall.exit aiming at a dead value,
and it doesn’t leave you an index that now means some other room. Every link into the deleted node
reads as absent, so the if above simply takes its other branch.
Types that contain themselves: Heap
A Heap<T> puts the value somewhere else and keeps only its address.
That’s what a recursive type needs. A struct containing itself has no size: a Step holding a
Step holds a Step, and the number never settles. An address doesn’t grow, so holding one
settles it at once.
import memory.Heap
struct Step {
label: string
next: Heap<Step>?
}
func main() {
let second = Heap(Step(label: "rinse", next: none))
let first = Heap(Step(label: "wash", next: second))
ensure drop(first)
println("{(*first).label}")
if (*first).next? as n {
println("{(*n).label}")
}
}
second is committed by the next line moving it into first, and storing it in that field hands
it over for good: the chain belongs to first now, so releasing first releases all of it.
Heap is the one of the three you release by hand, because it’s the one that owns an allocation of
its own with nothing around it to do the job. Rask has no destructors, so nothing runs behind your
back at the closing brace. That’s the ensure drop(first) above.
The ensure goes immediately after the line that allocates, and the compiler holds you to it:
error[E0882]: `frame` has no cleanup committed yet
--> docs/book/errors/beyond-one-owner/heap_no_cleanup.rk:14:5
|
12 | let frame = Heap(Frame(label: "canvas", width: 1920))
| ----------------------------------------------------- `frame` was acquired here
...
14 | println("{(*frame).label}")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ this runs while `frame` has nothing scheduled to close it
= fix: add `ensure drop(frame)` on the line after it's acquired, then use
`frame` freely
= why: a panic here would leak it — nothing is scheduled to clean it up, and
there are no destructors to fall back on
That program does release the value, on the last line. The complaint is about the lines in
between: a panic anywhere in them leaves with nothing scheduled to clean up. Putting the ensure
first costs nothing, because consuming the value later cancels it.
Choosing
Four questions, asked in order. Stop at the first yes.
- One value, one owner? A plain field. Done.
- Many values?
VecorMap, unless: - They refer to each other and can be deleted?
RackandLink. - Does another part of the program need the same value, and does it change?
Shared.
One more question sits outside that list, and asking it alongside the others is what makes these
feel harder than they are: does the type contain itself? That’s Heap, and the answer doesn’t
depend on any of the four above.
Reading it as one sentence: plain fields until you have many, Vec and Map until they refer to
each other, Rack when they do, and a lock only once a second task exists. Steps 1 and 2 are
most programs.
One thing to watch
Shared is not the only thing that holds a value still while you look at it. Reading an element
of a Vec in a with block does the same thing:
mut items: Vec<Item> = [Item(count: 1)]
with items[0] as first {
first.count = first.count + 1
items.push(Item(count: 9))
}
That doesn’t build, and the push is why:
error[E0808]: cannot push `items` inside `with` block
--> docs/book/errors/beyond-one-owner/push_inside_with.rk:14:9
|
12 | with items[0] as first {
| ------------- element borrowed here
...
14 | items.push(Item(count: 9))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ push not allowed inside with block
= fix: move the structural mutation outside the with block
= why: items can reallocate, invalidating the borrowed element. If the
elements reference each other and get deleted individually, that is
what a `Rack` is for — a node never moves, so nothing invalidates
[mem.borrowing/W2]
Pushing can move the whole buffer somewhere else, which would leave the binding pointing at storage that isn’t the element any more. Note what’s frozen: the collection, not the element. You can read and write other elements freely. It’s growth that’s the problem, because growth is what moves things.
Running it
The Shared and Rack code above is one program, and it prints:
Ada has 90 hp and 1 item
theme is dark
retries now 3
hall leads to cell
hall leads nowhere
Rules behind this page
- Shared, Rack and Heap: all three, and why you can’t write your own
- Racks: links, deletion, and what a stale one reads as
- Synchronization: the three strategies
- Borrowing: inline access and
with - Linearity: why
Heapneeds anensure