Lowent Manual←↑→

19 Ownership — one party responsible for disposal

What to know first

chapter 12, Borrowing · many readers or one writer
chapter 18, Regions · a region is reclaimed all at once when it ends
chapter 17, Designing failure · someone has to receive a failure

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

The old names of memory defects are double free and missed free, and the newer one is use after move. All three arise when “who is responsible for disposing of this value now?” has more than one answer. Lowent checks at translation that there is always exactly one answer. It adds one more thing — if disposing can fail, it is not done silently. Regions (chapter 18) handled values that die together; this chapter handles values that die one by one.

By the end of this chapter

You will learn what 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

  1. 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 refused

By 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.

KindExamplesHow it is handled
ReleaseGiving memory backCannot fail, so it may happen silently where the lifetime ends
CompletionClosing a file, flushing a buffer, committing a transactionCan 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 valuewhen passedwhen it ends
no ownership (u64 · point …)copied; the original name stays usablenothing to do
owned, needs release onlymoved; the original name is emptiedreleased silently where its lifetime ends
owned, needs completionmoved; the original name is emptiedthe 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

A C++ destructor or Rust 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.

RuleStrengthMeaning
Borrow exclusivity · no reference escapestatic + provenStopped by translation; the rules’ soundness is proven in Coq (sequential model)
Dispose exactly once · no region escapestaticStopped by translation
Slice bounds · contractsdynamicChecked at run time; the check disappears when proven
Dangling generational handlesdynamicGenerations 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#

ShapeMeaningWhy
input h owned buffer . · var h owned buffer be v .a value with ownershipone name is responsible for destroying it
consume hpassing moves ownershipno use after the move — E-OWN-MOVED
drop h .say it is destroyed nowdestroying twice is rejected
set h v . (after a move)refill the moved placea loop body ends in the same shape
different ownership states per branch after ifrejected (E-OWN-JOIN)no hidden “was it dropped” flag
fn finish input j owned journal . output result …declares that finishing this type can failcompletion declared without a new word
leaving scope without calling completionrejected (E-OWN-INCOMPLETE)a failing finish is never swallowed
leaving without a word, when no completion is neededreleased quietlyrelease 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.