Chapter 17: unique ownership
Reference counting lets any number of bindings share a class instance. That flexibility has two costs: a count in every object and an atomic increment or decrement whenever a reference is copied or dropped. For values that only ever have one owner — a connection, a file, a buffer being filled, a job moving through a queue — unique removes both. A unique value has exactly one owner at a time, the compiler tracks every move, and a native binary keeps no reference count for it.
This chapter covers unique let and unique var bindings, unique class, how unique values move through functions, loops, collections and closures, consume for handing a value over to ARC, and move-only structs. The normative text is the Language Reference §3-2.4.7 and §1-8.11.
Where unique is available:
| Feature | Modes |
|---|---|
unique let / unique var bindings, unique var struct fields |
every mode |
unique class |
host.native and freestanding (E_UNIQUE_CLASS_ARC_BINDING elsewhere, chapter 2) |
consume (downgrade to ARC) |
host.native and host.dual (E_CONSUME_NOT_AVAILABLE elsewhere) |
unique let and moves
unique let binds a class instance to a single owner. Assigning it to another binding moves it: the new binding owns the instance and the old one can no longer be used.
@mode(host.native)
class Connection {
pub let addr: string;
new(addr: string) { this.addr = addr; }
}
func describe(c: Connection): string {
return i"connection to {$ c.addr $}";
}
func main(): void {
unique let primary = Connection("db1:5432");
println(describe(primary)); # a reads borrow: primary is still ours
unique let current = primary; # move
println(describe(current));
}
Listing 17-1: A unique let binding, borrowed and then moved
connection to db1:5432
connection to db1:5432
Passing primary to describe does not move it: a parameter without a verb is a reads borrow (chapter 15), and the caller keeps ownership. Only an assignment, a takes argument, an insertion into a collection, or a closure capture moves a unique value.
Mistake: using a value after moving it
@mode(host.native)
class Connection {
pub let addr: string;
new(addr: string) { this.addr = addr; }
}
func main(): void {
unique let primary = Connection("db1:5432");
let current = primary;
println(current.addr);
println(primary.addr);
}
main.lphc:12:13: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'primary' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership
Note that current is a plain let, and the assignment still moves: a binding initialized from a unique binding is itself unique (ruling UNQ-2). The only way to get an ordinary shared reference out of a unique value is the explicit consume, covered below. After the move, use current.
unique var is the reassignable form. Assigning a fresh value replaces the old one, which a native binary frees on the spot:
func main(): void {
unique var conn = Connection("db1:5432");
println(conn.addr);
conn = Connection("db2:5432"); # the first connection is released here
println(conn.addr);
}
Listing 17-2: Replacing the value of a unique var
db1:5432
db2:5432
Note. A
unique letbinding of an ordinary class is still treated as an ARC receiver when you call amodifiermethod on it, so the call needs asafe { }block or asafe func(chapter 15). Aunique classinstance, below, does not.
unique class
A unique class can only exist in single-owner form. Every binding of one is unique, whether or not it says so, and there is no reference-counted version of it. Use it for types that must never be shared: file handles, locks, device mappings, builders that are filled and then handed off.
@mode(host.native)
unique class Batch {
var items: list[int] = [];
pub modifier func add(x: int): void {
this.items.push(x);
}
pub func total(): int {
var sum = 0;
for (x in this.items) {
sum = sum + x;
}
return sum;
}
pub func count(): int {
return this.items.length();
}
}
func fill(edits b: Batch, n: int): void {
for (i in 1..n + 1) {
b.add(i * 10);
}
}
func report(b: Batch): void {
println(i"{$ b.count() $} items, total {$ b.total() $}");
}
func submit(takes b: Batch): int {
return b.total();
}
func main(): void {
let batch = Batch();
batch.add(5);
fill(edits: batch, 3);
report(batch);
let sent = submit(takes: batch);
println(i"submitted {$ sent $}");
}
Listing 17-3: A unique class passed by reads, edits and takes
4 items, total 65
submitted 65
Three things differ from an ordinary class:
batch.add(5)needs nosafe { }block. The compiler knowsbatchis the only reference, so nothing else can be changing the object at the same time.fillchanges the batch througheditswithout being asafe func.submit(takes: batch)is a real move. In a native binary nothing is counted; ownership passes tosubmit, which frees the batch when it returns.
Mistake: using a value after takes
@mode(host.native)
unique class Batch {
pub var size: int = 0;
}
func submit(takes b: Batch): int {
return b.size;
}
func main(): void {
let batch = Batch();
let sent = submit(takes: batch);
println(i"{$ sent $} sent, {$ batch.size $} left");
}
main.lphc:14:35: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'batch' was consumed by a `takes` parameter and cannot be used again
Read what you need before the call (let left = batch.size;). Since Loup 1.24 only takes consumes; reads, edits and shares arguments leave the binding usable.
Moves inside loops
A move inside a loop body happens on every iteration, so the second iteration would use a value the first one already gave away:
@mode(host.native)
unique class Job {
pub let id: int;
new(id: int) { this.id = id; }
}
func run(takes j: Job): void {
println(i"running job {$ j.id $}");
}
func main(): void {
let job = Job(1);
for (round in 0..3) {
run(takes: job);
}
}
main.lphc:15:20: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'job' was consumed by a `takes` parameter in a previous iteration of the loop and cannot be used again
The message says which iteration is the problem (ruling LOOPMV-1). Either create the value inside the loop, or give a var a fresh value on every path back to the top of the loop:
func main(): void {
var job = Job(1);
for (next in 2..4) {
run(takes: job);
job = Job(next); # re-initialized before the next iteration
}
run(takes: job);
}
Listing 17-4: Re-initializing a moved var inside the loop
running job 1
running job 2
running job 3
del and finalize
del ends a unique value's life at once: it runs the class's finalize() method and frees the instance, and the binding cannot be used afterwards.
unique class TempFile {
pub let path: string;
new(path: string) {
this.path = path;
println(i"create {$ path $}");
}
func finalize(): void {
println(i"remove {$ this.path $}");
}
}
func main(): void {
let scratch = TempFile("/tmp/build-42");
println(i"writing to {$ scratch.path $}");
del scratch;
println("done");
}
Listing 17-5: del runs finalize() immediately
create /tmp/build-42
writing to /tmp/build-42
remove /tmp/build-42
done
Using the binding after del is E_USE_AFTER_DEL: use of binding 'scratch' after del— the binding is uninitialized once it has been passed todel (§1-6.11).
Note. In a native binary, a
uniquevalue that goes out of scope withoutdelis finalized and freed at the end of its scope.loup rundoes not do this: the interpreter callsfinalize()only for an explicitdel. Usedelwhere cleanup must happen at a known point on every delivery path. Chapter 9 coversfinalize()anddelfor ordinary classes.
consume: handing a value over to ARC
Sometimes a value starts with one owner and later needs to be shared — a connection that is configured exclusively and then placed in a pool. consume converts a unique binding into an ordinary reference-counted reference. It is one-way: after it, the compiler no longer tracks uniqueness.
class Connection {
pub let addr: string;
pub var retries: int = 0;
new(addr: string) { this.addr = addr; }
}
safe func configure(edits c: Connection): void {
c.retries = 3;
}
func main(): void {
unique let conn = Connection("db1:5432");
configure(edits: conn); # exclusive setup
let pooled: Connection = consume conn; # now an ordinary ARC reference
let alias = pooled; # sharing is allowed again
println(i"{$ alias.addr $} retries={$ pooled.retries $}");
}
Listing 17-6: Configuring a value exclusively, then sharing it
db1:5432 retries=3
Mistake: a reserved word as the binding name
The specification's own consume example names its result shared. That does not compile, because shared is a reserved word (it declares shared class, chapter 20):
@mode(host.native)
class Connection {
pub let addr: string;
new(addr: string) { this.addr = addr; }
}
func main(): void {
unique let conn = Connection("db1:5432");
let shared: Connection = consume conn;
}
main.lphc:10:9: error[E0001]: `shared` is a reserved word (§1-3.4) and cannot name a binding
note: rename the binding — e.g. `shared_value`
local and template are reserved for the same kind of reason. Pick another name, as Listing 17-6 does.
Mistakes with consume
consume only applies to a unique binding of an ordinary class:
@mode(host.native)
class Connection {
pub let addr: string;
new(addr: string) { this.addr = addr; }
}
func main(): void {
let conn = Connection("db1:5432");
let pooled: Connection = consume conn;
}
main.lphc:10:30: error[E_CONSUME_NON_UNIQUE]: E_CONSUME_NON_UNIQUE: 'consume' can only be applied to 'unique' bindings, but 'conn' is not unique
conn is already an ordinary reference, so there is nothing to convert: drop the consume. The opposite case, a unique class, cannot be converted at all, because no reference-counted form of the class exists:
@mode(host.native)
unique class Lock {
pub let name: string;
new(name: string) { this.name = name; }
}
func main(): void {
let lock = Lock("db");
let pooled = consume lock;
}
main.lphc:10:18: error[E_UNIQUE_CLASS_CONSUME]: E_UNIQUE_CLASS_CONSUME: `consume` on unique class binding 'Lock' is not allowed — unique class instances cannot be downgraded to ARC
main.lphc:10:18: error[E_CONSUME_NON_UNIQUE]: E_CONSUME_NON_UNIQUE: 'consume' can only be applied to 'unique' bindings, but 'lock' is not unique
The first message is the real one. If a type must sometimes be shared, make it an ordinary class and put unique on the bindings that need it.
Known gap. The specification says that using a binding after
consumeisE_USE_AFTER_MOVE. Loup 1.25.1 does not report it:println(conn.addr)afterconsume conncompiles. Treatconsumeas the binding's last use.
Move-only structs
A struct field may be declared unique var. The struct then owns that value, and the whole struct becomes move-only: assigning it moves it instead of copying it, because a copy would give the unique value two owners.
@mode(host.native)
class Handle {
pub let fd: int;
new(fd: int) { this.fd = fd; }
}
struct Resource {
name: string;
unique var handle: Handle;
}
func reopen(edits r: Resource, takes h: Handle): void {
r.handle = h; # the old handle is released
}
func close(takes r: Resource): int {
return r.handle.fd;
}
func main(): void {
var res = Resource(name = "db", handle = Handle(3));
let fresh = Handle(7);
reopen(edits: res, takes: fresh);
println(i"{$ res.name $} -> fd {$ res.handle.fd $}");
println(i"closed fd {$ close(takes: res) $}");
}
Listing 17-7: A move-only struct with a unique var field
db -> fd 7
closed fd 7
Mistake: copying a move-only struct
@mode(host.native)
class Handle {
pub let fd: int;
new(fd: int) { this.fd = fd; }
}
struct Resource {
name: string;
unique var handle: Handle;
}
func main(): void {
var primary = Resource(name = "db", handle = Handle(3));
var backup = primary;
println(primary.name);
}
main.lphc:16:13: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'primary' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership
An ordinary struct would have been copied. This one moved, because it holds a unique field. Build a second resource with its own handle if you need two.
Mistake: moving a field out of a borrowed struct
A function that receives a move-only struct as a borrow may read its fields but not move them out:
@mode(host.native)
unique class Socket {
pub let port: int;
new(port: int) { this.port = port; }
}
struct Pending {
name: string;
unique var socket: Socket;
}
func accept(p: Pending): Socket {
println(i"accepting {$ p.name $}");
return p.socket;
}
func main(): void {
let p = Pending(name = "web", socket = Socket(8080));
let s = accept(p);
println(s.port);
}
main.lphc:15:12: error[E_MOVE_OUT_OF_BORROW]: E_MOVE_OUT_OF_BORROW: cannot move `p.socket` out of the borrowed parameter `p` — only a `takes` parameter owns its value (§1-8.11); declare it `takes p`
accept hands the socket to its caller, so it must own the struct (ruling MOVEB-1). Declare func accept(takes p: Pending): Socket and call it as accept(takes: p); the program then prints accepting web and 8080.
The field rules, all checked:
- a
uniquefield must beunique var(E_UNIQUE_FIELD_LETforunique let); uniqueapplies only to class types (E_UNIQUE_NOT_CLASS: 'unique' can only be applied to class types, not 'int');- an ordinary class may not have a
uniquefield, because the class itself can be shared (E_UNIQUE_CLASS_FIELD); aunique classmay.
⚠ Planned.
consume r.handle, downgrading one field of a move-only struct while keeping the rest, is described in earlier editions but not implemented: Loup 1.25.1 reportsE_CONSUME_NON_UNIQUEfor it.
Collections of unique values
A list can hold unique values (ruling UNIQCOLL-1). The collection becomes the owner, and the compiler tracks what goes in and comes out:
- inserting (
push, an element assignment, a list literal) moves the value in; popandremovemove a value out;- indexing,
get,first,lastand aforloop variable borrow an element in place.
@mode(host.native)
unique class Job {
pub let id: int;
new(id: int) { this.id = id; }
}
func main(): void {
var queue: list[Job] = [];
queue.push(Job(1));
queue.push(Job(2));
queue.push(Job(3));
for (j in queue) {
println(i"queued {$ j.id $}");
}
println(i"next up: {$ queue[0].id $}");
let last = queue.pop();
println(i"took job {$ last.id $}, {$ queue.length() $} left");
}
Listing 17-8: A queue that owns its jobs
queued 1
queued 2
queued 3
next up: 1
took job 3, 2 left
Taking an element out by indexing, or copying the collection, would leave two owners, so all of these are refused:
@mode(host.native)
unique class Job {
pub let id: int;
new(id: int) { this.id = id; }
}
func main(): void {
var queue: list[Job] = [Job(1), Job(2)];
let first = queue[0];
let copy = queue.clone();
let head = queue[0:1];
}
main.lphc:10:17: error[E_MOVE_OUT_OF_BORROW]: E_MOVE_OUT_OF_BORROW: cannot move `queue[…]` out of the collection `queue` — an element accessed in place is a borrow (§1-8.11); move it out with `remove` or `pop`
main.lphc:11:16: error[E_MOVE_OUT_OF_BORROW]: E_MOVE_OUT_OF_BORROW: cannot move the elements of `queue` out through `clone()` — a collection holding a `unique` value is move-only (§1-8.11); move elements out with `remove` or `pop`, iterate with `for` (a borrow)
main.lphc:12:16: error[E_MOVE_OUT_OF_BORROW]: E_MOVE_OUT_OF_BORROW: cannot move the elements of `queue` out through a slice — a collection holding a `unique` value is move-only (§1-8.11); move elements out with `remove` or `pop`, iterate with `for` (a borrow)
Each message names the replacement. Use queue.remove(0) to take the first job out, and read fields in place (queue[0].id) when you only need to look. Inserting a value is a move too: after queue.push(job), using job is E_USE_AFTER_TAKE. A collection holding unique values is itself move-only, so let other = queue; moves the whole queue.
Closures capture by moving
A closure that uses a unique value captures it by moving it in (ruling CAPMV-1). The closure body only borrows it, so the closure can be called many times, but the enclosing function cannot use the value afterwards:
@mode(host.native)
unique class Logger {
pub let name: string;
new(name: string) { this.name = name; }
pub func log(msg: string): void { println(i"[{$ this.name $}] {$ msg $}"); }
}
func main(): void {
let logger = Logger("app");
let report = func(msg: string): void {
logger.log(msg);
};
report("started");
logger.log("finished");
}
main.lphc:15:5: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'logger' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership
The capture is a move even though the closure never leaves main, and even when the closure is passed straight to a function. Route every use through the closure, or keep the closure's work separate from the value's other uses:
func main(): void {
let logger = Logger("app");
let report = func(msg: string): void {
logger.log(msg);
};
report("started");
report("finished");
}
Listing 17-9: After the capture, the closure owns the logger
[app] started
[app] finished
A closure may not capture a unique value that the function only borrows: capturing parameter l of func useIt(l: Logger) is E_MOVE_OUT_OF_BORROW, with the same advice to declare it takes l.
Moving a unique value into a thread works the same way, with one more rule: the captured type must be Sendable (chapter 20). A unique class that declares implements Sendable can be moved into a Thread.spawn closure, and using it afterwards is E_USE_AFTER_MOVE.
When to use unique
Reach for unique when a value has one owner at every moment and you want the compiler to prove it: resources with a finalize, buffers being built, jobs moving through a pipeline. Keep ordinary classes for data that many parts of the program read, for object graphs, and for anything you store in several places. Mixing is normal: unique while a value is being set up or handed along, consume at the point where it becomes shared.
Error codes
| Code | Meaning |
|---|---|
E_USE_AFTER_MOVE |
a binding used after it was moved by assignment or closure capture |
E_USE_AFTER_TAKE |
a binding used after a takes argument or an insertion into a collection, including "in a previous iteration of the loop" |
E_USE_AFTER_DEL |
a binding used after del |
E_MOVE_OUT_OF_BORROW |
moving a unique value out of a borrowed parameter, a collection element or a slice/clone |
E_CONSUME_NON_UNIQUE |
consume of a binding that is not unique |
E_UNIQUE_CLASS_CONSUME |
consume of a unique class instance |
E_CONSUME_NOT_AVAILABLE |
consume outside host.native and host.dual |
E_UNIQUE_CLASS_ARC_BINDING |
unique class outside host.native and freestanding |
E_UNIQUE_NOT_CLASS |
unique on a non-class type |
E_UNIQUE_FIELD_LET |
a unique let field |
E_UNIQUE_CLASS_FIELD |
a unique field in a class that is not a unique class |
E_UNIQUE_NOT_NATIVE, which older editions listed, was removed in v1.17: unique bindings work in every mode.
Summary
unique let/unique vargive a class instance a single owner; assignment,takes, insertion into a collection and closure capture move it, and a later use is an error.- A
unique classhas no shared form; itsmodifiermethods need nosafecontext, and it is available inhost.nativeandfreestanding. - Moves inside loops, moves out of borrowed parameters and moves out of collection elements are all caught (
E_USE_AFTER_TAKE"in a previous iteration",E_MOVE_OUT_OF_BORROW). consumeconverts auniquebinding of an ordinary class into an ARC reference, one way; using the binding afterwards is not yet reported.- A struct with a
unique varfield is move-only; collections ofuniquevalues are move-only too. delrunsfinalize()at once; underloup runthat is the only timefinalize()runs.- Chapter 18 introduces lifetimes, which let borrows outlive a single call.