19 Ownership — one party responsible for disposal
What to know first
Looking back
The regions of chapter 18 reclaim values all at once. Can resources that are disposed of one at a time, and whose closing may fail — file handles, say — be handled with regions alone?
A. A region only rewinds bytes all at once; it cannot close files or flush buffers. And if closing fails, there is no place at a region’s end to receive that failure. Such resources need ownership, where each value has a party responsible for disposing of it. That is this chapter.
The need for this chapter, and its context
By the end of this chapter
owned t is and what drop does. You will pick up why reusing a moved value or disposing twice is rejected, and why differing ownership states across branches are rejected. You will see that disposal comes in two kinds, release (cannot fail) and completion (can fail), and why silently discarding a value that needs completion is rejected. You will also sort out at what strength this language’s memory safety is guaranteed.The questions this chapter answers
- What happens when a value without ownership is passed?
19.1 owned and drop#
owned t is a value that carries ownership. A value with ownership must be disposed of exactly once.
examples/ch19/sink.low
module sink .
rem run: use_once 5
type buffer u8 .
fn consume input h owned buffer . output u8 .
do
drop h .
return 0 .
end
fn use_once input v u8 . output u8 .
do
var h owned buffer be v .
return consume h .
end
Output
$ lowentc --run use_once sink.low 5
use_once(5) = 0
consume takes an owned buffer and disposes of it with drop h .. use_once makes an owned value with var h owned buffer be v . and passes it to consume. At that moment ownership moves. Now consume is responsible for disposing of h, and use_once can no longer use h.
use_once consume
┌──────────────────┐ pass (move) ┌──────────────────┐
│ h ──▶ [ buffer ] │ ───────────────▶ │ h ──▶ [ buffer ] │ ── drop h . → gone
└──────────────────┘ └──────────────────┘
h is now an empty name the one place responsible
(using it again: E-OWN-MOVED)Think of a locker with a single key. Once you hand the key over, you no longer hold it, and only the one person holding the key can empty (dispose of) the locker. So “two people empty it” (double free), “nobody empties it” (leak) and “open it again with the key you gave away” (use after move) cannot happen.
Using a moved value again is rejected.
examples/ch19/moved.low
module moved .
rem expect: E-OWN-MOVED
type buffer u8 .
fn consume input h owned buffer . output u8 .
do
drop h .
return 0 .
end
fn reuse input h owned buffer . output u8 .
do
let a u8 be consume h .
let b u8 be consume h .
return add a b .
end
Output
$ lowentc --check moved.low
moved.low:15:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
Disposing twice is rejected too.
examples/ch19/twice.low
module twice_drop .
rem expect: E-OWN-MOVED
type buffer u8 .
fn twice input h owned buffer . output u8 .
do
drop h .
drop h .
return 0 .
end
Output
$ lowentc --check twice.low
twice.low:9:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
The diagnostic notes that the specification always called this an error and that for a while nothing enforced it. Today translation stops it.
Q. What happens when a value without ownership is passed?
A. It is copied. Values like u64 or point are copied when passed, and the original name stays usable. Only owned values move. Large values whose copying cost worries you are passed by borrowing with ref (chapter 12). And when the place where a value-producing expression will land is empty, the expression cannot fail on the way, and the place belongs to that value alone, the value is built directly in place — no intermediate temporary and no moving copy. These three conditions can be counted from the source alone.
19.2 Where branches meet#
Where an if splits and rejoins, the ownership state must be the same on every path.
examples/ch19/join.low
module join .
rem expect: E-OWN-JOIN
type buffer u8 .
fn maybe input h owned buffer . input c bool . output u8 .
do
if c . do
drop h .
end
return 0 .
end
Output
$ lowentc --check join.low
join.low:8:0 E-OWN-JOIN: this `owned` value is CONSUMED on one path of this branch but still LIVE on another where they merge — Lowent requires the ownership state to be STATICALLY consistent at a join (RFC-0044 §9.3): no hidden drop-flag decides it at runtime. Consume it on EVERY path, or make it conditionally owned (`?owned`)
On the path where c is true, h was disposed of; on the false path it is still alive. After the paths meet, nobody can say whether h is alive. Some languages keep a hidden “already dropped” flag here, but Lowent requires the ownership state to be statically one thing. Dispose of it on both paths, or pass it on both paths.
if c
┌─────┴─────┐
drop h (leave it)
h: gone h: alive
└─────┬─────┘
where the paths meet --- is h alive or not? → no single answer, so it is refusedBy the same principle, moving one owned field of an aggregate and then moving the whole aggregate again is rejected (E-OWN-PARTIAL). The receiver thinks it got a whole aggregate, but one field inside already belongs to someone else.
19.3 Release and completion#
Disposal comes in two kinds.
| Kind | Examples | How it is handled |
|---|---|---|
| Release | Giving memory back | Cannot fail, so it may happen silently where the lifetime ends |
| Completion | Closing a file, flushing a buffer, committing a transaction | Can fail, so the author must write the call |
Table 19.1 — The two kinds of disposal
Release has no failure to swallow. Completion, done silently, has nowhere to hand its failure. The criterion is one: can finishing fail?
| kind of value | when passed | when it ends |
|---|---|---|
no ownership (u64 · point …) | copied; the original name stays usable | nothing to do |
owned, needs release only | moved; the original name is emptied | released silently where its lifetime ends |
owned, needs completion | moved; the original name is emptied | the author must write the completing call (otherwise E-OWN-INCOMPLETE) |
Table 19.2 — when a value is passed and when it ends — at a glance
Which types require completion is declared by the program itself. If there is an op that takes the type as owned and returns a result, that is the declaration “finishing this can fail”. No new word is involved.
examples/ch19/complete.low
module complete .
rem run: session 3
type journal u64 .
enum flush_error do
disk_full .
end
fn finish input j owned journal . output result u8 flush_error .
errors disk_full eq j 0 .
do
rem an empty journal means there was no room to write --- so this arm really does happen
guard ne j 0 . else do
drop j .
return error disk_full .
end
drop j .
return ok 0 .
end
fn session input n u64 . output result u8 flush_error .
errors disk_full .
do
var j owned journal be n .
return finish j .
end
Output
$ lowentc --run session complete.low 3
session(3) = ok 0
Because finish takes an owned journal and returns a result, journal becomes a type needing completion. session finishes it by calling finish j and returns that (possibly failing) result as its own. Letting it go out of scope without calling the completion is rejected.
examples/ch19/incomplete.low
module incomplete .
rem expect: E-OWN-INCOMPLETE
type journal u64 .
enum flush_error do
disk_full .
end
fn finish input j owned journal . output result u8 flush_error .
errors disk_full .
do
drop j .
return ok 0 .
end
fn forget input j owned journal . output u8 .
do
return 0 .
end
Output
$ lowentc --check incomplete.low
incomplete.low:11:0 W-ERRORS-UNRAISED: this `errors` clause names a failure the body never returns. The clause is read as a promise about what this op can do, and the callers write their handling from it — a branch that can never be taken is dead code the reader cannot tell from live code. Return it (`return error <variant>`), forward one (`try`), or remove the clause
17:0 E-OWN-INCOMPLETE: this value is dropped automatically at the end of scope — but the program itself declares an op that takes this type `owned` BY VALUE and returns a `result`: finishing it CAN FAIL. An automatic drop is a RELEASE (total, non-suspending), and it has nowhere to hand you that failure — it would SWALLOW it. A fallible finish (flush/commit/close) is a COMPLETION and must be EXPLICIT: call it and handle the `result`. If you really mean to discard the value and its failure, say so with `drop`. RFC-0058
As the diagnostic says, automatic disposal is a release, and a release has nowhere to return a failure, so it would swallow the failure. Finishing that can fail — flush, commit, close — must be written and called by the author.
A common misconception. It is convenient for a destructor to close the file automatically
Drop closes a resource when it goes out of scope. What if closing fails? A destructor cannot return a value, so it ignores the failure or stops the program. That is where the fact that a full disk kept the last buffer from being written silently disappears. Lowent allows this convenience only for release and makes completion explicit. If you really mean to discard both the value and its failure, you say you are discarding it with drop — the diagnostic points that way too. Something vanishing silently and an author writing that it is discarded are different things.19.4 Not counting on the operating system to clean up#
Many programs lean on “the operating system cleans up when the process ends anyway”. Lowent does not. Because a forgotten disposal is caught at translation, the same code holds in environments without an operating system — firmware whose process never ends.
19.5 At what strength is memory safety guaranteed?#
The words “memory safe” mean something only when they say what is safe and how. This language separates the strengths.
| Rule | Strength | Meaning |
|---|---|---|
| Borrow exclusivity · no reference escape | static + proven | Stopped by translation; the rules’ soundness is proven in Coq (sequential model) |
| Dispose exactly once · no region escape | static | Stopped by translation |
| Slice bounds · contracts | dynamic | Checked at run time; the check disappears when proven |
| Dangling generational handles | dynamic | Generations compared at run time (chapter 34) |
Table 19.3 — Memory rules and their strength
So calling this language “fully statically safe” would be wrong. It is a design that mixes what is stopped statically, what is stopped at run time, and what is proven. What is proven, and what gap lies between the proofs and the compiler, is covered in chapters 42 and 50.
19.6 Common mistakes#
Counter-example. Passing an owned value inside a loop
examples/ch19/mistake_loopmove.low
module mistake_loopmove .
rem expect: E-OWN-MOVED
type buffer u8 .
fn consume input h owned buffer . output u8 .
do
drop h .
return 0 .
end
fn three_times input v u8 . output u8 .
do
var h owned buffer be v .
var i u64 be 0 .
while lt i 3 . do
rem ✘ the second round passes `h` again after the first round moved it away
let a u8 be consume h .
set i (add i 1) .
end
return 0 .
end
Output
$ lowentc --check mistake_loopmove.low
mistake_loopmove.low:16:0 E-OWN-MOVED: this `owned` value is CONSUMED inside a LOOP and never put back — the SECOND iteration would be a use-after-move. A loop body must end the way it began: consume it and REASSIGN the place (`set <name> …`, which re-initialises it), or move the consumption out of the loop
Once consume h takes ownership in the first round, h in the second round already belongs to someone else. Translation does not need to follow the rounds one by one; it sees that “the loop body ends in a different shape than it began” and rejects it with E-OWN-MOVED. There are two fixes: move the consuming call out of the loop, or refill the moved place with a new value using set, so each round ends in the same shape.
examples/ch19/loopmove_fixed.low
module loopmove_fixed .
rem run: three_times 5
type buffer u8 .
fn consume input h owned buffer . output u8 .
do
drop h .
return 1 .
end
fn three_times input v u8 . output u8 .
do
var h owned buffer be v .
var n u8 be 0 .
var i u64 be 0 .
while lt i 3 . do
set n (add n (consume h)) .
rem refill the moved place with a new value --- the round ends in the same shape it began
set h v .
set i (add i 1) .
end
drop h .
return n .
end
Output
$ lowentc --run three_times loopmove_fixed.low 5
three_times(5) = 3
Counter-example. Moving a value while thinking you only read it
examples/ch19/mistake_readmove.low
module mistake_readmove .
rem expect: E-OWN-MOVED
type buffer u8 .
fn peek input h owned buffer . output u8 .
do
rem ✘ taking the value into a name is a move too --- the `drop h` after it destroys a moved value
let v u8 be h .
drop h .
return v .
end
Output
$ lowentc --check mistake_readmove.low
mistake_readmove.low:10:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
let v u8 be h . does not look at h; it moves it into v. A value with ownership moves the moment it is stored under a name, so the later drop h tries to destroy a moved value. To only look, borrow it with ref h inside the same op. As the diagnostic notes, borrows across an op boundary are not lowered yet in this edition.
Counter-example. Believing a second name makes a copy
examples/ch19/mistake_twonames.low
module mistake_twonames .
rem expect: E-OWN-MOVED
type buffer u8 .
fn consume input h owned buffer . output u8 .
do
drop h .
return 0 .
end
fn both input h owned buffer . output u8 .
do
rem ✘ believed a second name makes a copy --- ownership moved to `h2`
var h2 owned buffer be h .
let a u8 be consume h2 .
let b u8 be consume h .
return 0 .
end
Output
$ lowentc --check mistake_twonames.low
mistake_twonames.low:17:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
A value like u64 is copied when stored under another name. A value with ownership is not copied; it moves. With two copies nobody could say who destroys it, and destroying both would destroy it twice. After var h2 owned buffer be h ., h2 is the only owner.
A common misconception. Leaving out drop leaks the value
examples/ch19/implicit_release.low
module implicit_release .
rem run: keep_or_drop 5 1
rem run: keep_or_drop 5 0
type buffer u8 .
rem the path where `c` is false leaves without `drop` --- release cannot fail, so it happens quietly where the lifetime ends
fn keep_or_drop input v u8 . input c u8 . output u8 .
do
var h owned buffer be v .
guard eq c 1 . else return 0 .
drop h .
return 1 .
end
Output
$ lowentc --run keep_or_drop implicit_release.low 5 1
keep_or_drop(5, 1) = 1
$ lowentc --run keep_or_drop implicit_release.low 5 0
keep_or_drop(5, 0) = 0
The path where c is 0 leaves without drop, yet it is neither rejected nor leaked. Release, which gives memory back, cannot fail, so it happens quietly where the lifetime ends. Only completion, which can fail, must be written by the author (incomplete.low). Where branches meet again, though, the ownership state must match (join.low). A path that leaves never meets the other, so that rule does not apply.
19.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
input h owned buffer . · var h owned buffer be v . | a value with ownership | one name is responsible for destroying it |
consume h | passing moves ownership | no use after the move — E-OWN-MOVED |
drop h . | say it is destroyed now | destroying twice is rejected |
set h v . (after a move) | refill the moved place | a loop body ends in the same shape |
different ownership states per branch after if | rejected (E-OWN-JOIN) | no hidden “was it dropped” flag |
fn finish input j owned journal . output result … | declares that finishing this type can fail | completion declared without a new word |
| leaving scope without calling completion | rejected (E-OWN-INCOMPLETE) | a failing finish is never swallowed |
| leaving without a word, when no completion is needed | released quietly | release cannot fail |
Table 19.4 — Ownership syntax — shape · meaning · why it looks this way
Recap
owned t must be disposed of exactly once and moves when passed. Reusing a moved value, disposing twice, and differing ownership state across branches are rejected. Disposal splits into release, which cannot fail, and completion, which can; a type needing completion is declared by an op that takes it owned and returns a result. Silently discarding a value that needs completion is rejected. Understand memory rules by strength: static, dynamic and proven.