Chapter 15: Ownership verbs
Every parameter in Loup carries a verb that says what the function will do with the argument: read it, change it in place, take it over, or keep a reference to it. The verb is part of the signature, and for three of the four verbs the caller repeats it at the call site, so you can see at a glance which calls can change your data. The compiler checks both ends.
This chapter covers the four parameter verbs (reads, edits, takes, shares), the modifier keyword for methods that change this, safe { } blocks and safe func, requires clauses, and synchronized classes. All of them are compile-time checks that work the same in every mode, so a program checked with loup run gets exactly the same diagnostics as one built with loup build --target native. The normative text is in the Language Reference §3-2.4.9 through §3-2.4.13.
The four verbs
| Verb | The function may | The caller writes | After the call the caller has |
|---|---|---|---|
reads (the default) |
read the value | nothing, or reads: |
the value, unchanged |
edits |
read and change the value in place | edits: |
the value, with the changes |
takes |
own the value | takes: |
nothing — the binding is consumed |
shares |
keep a reference after it returns | shares: |
the value, now also held elsewhere |
The verb goes in front of the parameter name: func grow(edits xs: list[int]). The acknowledgement at the call site looks like a named argument with the verb as its label: grow(edits: xs).
reads — the default
A parameter with no verb is a reads parameter. The function can look at the value but not change it, and the caller keeps it.
@mode(host.native)
func requestMethod(line: string): string {
let space = line.indexOf(" ");
if (space < 0) {
return line;
}
return line.substring(0, space);
}
func main(): void {
let line = "GET /api/users HTTP/1.1";
println(requestMethod(line)); # implicit reads
println(requestMethod(reads: line)); # the same call, spelled out
println(line); # still ours
}
Listing 15-1: A reads parameter needs no annotation at either end
GET
GET
GET /api/users HTTP/1.1
Writing reads explicitly is allowed on both sides and changes nothing; it is useful in a signature where the other parameters carry verbs. A reads parameter of a class type costs no ARC traffic: the callee borrows the caller's reference instead of retaining it.
"Read-only" is enforced. Here is a helper that tries to add a tag to the list it was given:
@mode(host.native)
func addDefaultTag(tags: list[string]): void {
if (not tags.contains("triaged")) {
tags.push("triaged");
}
}
func main(): void {
var tags = ["billing"];
addDefaultTag(tags);
println(tags);
}
main.lphc:5:9: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'tags' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`push` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first
The message names the parameter, says why it is read-only (it has no verb), names the offending operation, and offers both fixes. The same error fires for assigning to a field of a reads struct or class parameter, and for calling a modifier method on a reads class parameter. Assigning to the parameter itself (tags = [...]) is a different error — parameters are immutable bindings — reported as cannot assign to immutable binding 'tags'.
edits — changing the caller's value in place
If the function's job is to change the caller's value, say so with edits:
@mode(host.native)
func addDefaultTag(edits tags: list[string]): void {
if (not tags.contains("triaged")) {
tags.push("triaged");
}
}
func main(): void {
var tags = ["billing"];
addDefaultTag(tags);
println(tags);
}
main.lphc:11:19: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'tags' requires 'edits' verb acknowledgment at call site
Changing the signature is not enough: every caller must now acknowledge that the call changes its list. This is the point of the verb system — a reader of main sees which calls can modify tags without opening the callee. Add the acknowledgement:
@mode(host.native)
func addDefaultTag(edits tags: list[string]): void {
if (not tags.contains("triaged")) {
tags.push("triaged");
}
}
func main(): void {
var tags = ["billing"];
addDefaultTag(edits: tags);
addDefaultTag(edits: tags); # already tagged: no change
println(tags);
}
Listing 15-2: An edits parameter and its call-site acknowledgement
["billing", "triaged"]
The acknowledgement has to name the right verb. Writing takes: where the parameter is edits is a mismatch:
@mode(host.native)
func addDefaultTag(edits tags: list[string]): void {
tags.push("triaged");
}
func main(): void {
var tags = ["billing"];
addDefaultTag(takes: tags);
}
main.lphc:9:26: error[E0001]: E_VERB_MISMATCH: call-site verb 'takes' does not match parameter 'tags' declared as 'edits'
edits on structs
Structs are values: assigning one copies it (chapter 7). edits is how a function works on the caller's struct without copying it. A small tokenizer keeps its position in a Cursor and lets helper functions move it forward:
struct Cursor {
text: string;
var pos: int;
}
func skipSpaces(edits c: Cursor): void {
while (c.pos < c.text.length() and c.text[c.pos] == " ") {
c.pos = c.pos + 1;
}
}
func readWord(edits c: Cursor): string {
let start = c.pos;
while (c.pos < c.text.length() and c.text[c.pos] != " ") {
c.pos = c.pos + 1;
}
return c.text.substring(start, c.pos);
}
func main(): void {
var c = Cursor(text = " GET /index.html", pos = 0);
skipSpaces(edits: c);
let method = readWord(edits: c);
skipSpaces(edits: c);
let path = readWord(edits: c);
println(i"{$ method $} {$ path $} (stopped at {$ c.pos $})");
}
Listing 15-3: Helpers that advance a struct cursor through edits
GET /index.html (stopped at 19)
Without edits, skipSpaces could only return a new Cursor for the caller to store back. With it, the helpers read like methods, and the call sites still show that c changes.
edits on class instances
A class instance is shared by reference, so edits on a class parameter means "this function writes to that object". In 1.25.1 writing a field of a class instance also needs a safe context (covered below), and a function with an edits class parameter does not get one automatically:
@mode(host.native)
class Account {
pub var balance: int = 0;
}
func deposit(edits a: Account, amount: int): void {
a.balance = a.balance + amount;
}
func main(): void {
let acct = Account();
deposit(edits: acct, 25);
println(acct.balance);
}
main.lphc:8:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'balance' called on ARC receiver of type 'Account' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`
The compiler treats a write to a pub var field from outside the class like a call to a modifier method, which is why the message talks about "modifier method 'balance'". Declare the function safe func:
class Account {
pub var balance: int = 0;
}
safe func deposit(edits a: Account, amount: int): void {
a.balance = a.balance + amount;
}
func main(): void {
let acct = Account();
deposit(edits: acct, 25);
deposit(edits: acct, 17);
println(acct.balance);
}
Listing 15-4: Writing a class field through an edits parameter
42
Spec and toolchain differ. The Language Reference (§3-2.4.12) says a function with an
editsparameter is implicitly asafe func, so Listing 15-4 should not need the wordsafe. Stage0 1.25.1 still requires it. Writingsafe funcis correct under both readings.
What edits does not check yet
The Language Reference also specifies alias checks for edits: passing the same value as two edits arguments (E_EDITS_ALIAS), or as an edits and a reads argument of one call (E_EDITS_READS_ALIAS), is an error. The 1.25.1 compilers do not report either, and they also accept an edits: acknowledgement on a plain reads parameter. Do not rely on the compiler to catch these: a call like appendAll(edits: xs, xs) that iterates one list while growing the other compiles, and loops for ever.
takes — handing a value over
A takes parameter receives ownership. The caller's binding is consumed at the call, and the compiler rejects any later use of it. A log writer that flushes a batch and is done with it:
func flush(takes batch: list[string]): int {
for (line in batch) {
println(i"write: {$ line $}");
}
return batch.length();
}
func main(): void {
var pending = ["boot", "ready"];
let written = flush(takes: pending);
println(i"{$ written $} lines flushed");
pending = ["idle"]; # a var may be given a fresh value
println(pending);
}
Listing 15-5: Passing a batch to a takes parameter
write: boot
write: ready
2 lines flushed
["idle"]
Forget the acknowledgement and the compiler asks for it, just as it does for edits:
@mode(host.native)
func flush(takes batch: list[string]): int {
return batch.length();
}
func main(): void {
var pending = ["boot", "ready"];
println(flush(pending));
}
main.lphc:9:19: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'batch' requires 'takes' verb acknowledgment at call site
The more interesting mistake is using the binding after handing it over:
@mode(host.native)
func flush(takes batch: list[string]): int {
return batch.length();
}
func main(): void {
var pending = ["boot", "ready"];
let written = flush(takes: pending);
println(i"flushed {$ written $} of {$ pending.length() $}");
}
main.lphc:10:43: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'pending' was consumed by a `takes` parameter and cannot be used again
After takes:, pending holds nothing the compiler will let you read. Either read what you need before the call (let total = pending.length();), or, if the binding is a var, give it a new value first, as Listing 15-5 does. Since 1.24 only takes consumes a binding; reads, edits and shares never do.
For ordinary class instances, takes hands over the caller's reference count instead of adding one. For unique values (chapter 17) it is a true move with no reference counting at all.
Known gap. In 1.25.1 the use-after-take check covers calls to functions, but not calls to methods: after
pool.giveBack(takes: conn), a later read ofconncompiles. Keeptakesarguments to methods as the last use of the binding.
shares — keeping a reference
shares says the function will keep a reference to the argument after it returns — store it in a field, a collection, or a closure. Both sides then hold the value. An event bus that records every event it publishes:
class Event {
pub let name: string;
new(name: string) { this.name = name; }
}
class Bus {
pub var history: list[Event] = [];
}
safe func publish(edits bus: Bus, shares event: Event): void {
bus.history.push(event);
}
func main(): void {
let bus = Bus();
let deploy = Event("deploy");
publish(edits: bus, shares: deploy);
publish(edits: bus, shares: Event("rollback"));
println(deploy.name); # the caller still has it
println(bus.history.length());
}
Listing 15-6: A shares parameter that is stored past the call
deploy
2
shares must be acknowledged at the call site like edits and takes; omitting it is E_VERB_REQUIRED. On a unique value, shares gives up uniqueness for good (chapter 17).
Known gap. The Language Reference says that storing or returning a plain
readsparameter beyond the call isE_BORROW_ESCAPE. That check is not implemented in 1.25.1:bus.history.push(event)also compiles wheneventhas no verb. Writeshareswhenever a function keeps what it is given; it documents the contract and keeps your code correct once the check lands.
modifier — methods that change this
The verbs describe parameters. For the receiver of a method, Loup uses one keyword: a class method that writes any var field of this must be declared modifier. A small inventory:
class Inventory {
var counts: dict[string, int] = {};
pub modifier func add(item: string, n: int): void {
let have = this.counts.get(item).unwrap_or(0);
this.counts.set(item, have + n);
}
pub func count(item: string): int {
return this.counts.get(item).unwrap_or(0);
}
}
func main(): void {
let stock = Inventory();
safe {
stock.add("bolt", 40);
stock.add("nut", 25);
stock.add("bolt", 10);
}
println(i"bolts: {$ stock.count("bolt") $}, washers: {$ stock.count("washer") $}");
}
Listing 15-7: modifier marks the methods that change the object
bolts: 50, washers: 0
Scanning the class, you can see which methods change state without reading their bodies. The compiler holds you to it — forget modifier and the method no longer compiles:
@mode(host.native)
class Meter {
var reading: int = 0;
pub func tick(): void {
this.reading = this.reading + 1;
}
}
func main(): void {
let m = Meter();
}
main.lphc:7:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'reading' of `self` in method 'tick' of class 'Meter' requires the method to be declared as `modifier`
modifier applies to class methods only. A struct is a value, so a struct method may assign the fields of its own copy freely, and marking it modifier is an error:
@mode(host.native)
struct Meter {
var reading: int;
modifier func tick(): void {
this.reading = this.reading + 1;
}
}
func main(): void {
var m = Meter(reading = 0);
}
main.lphc:6:5: error[E_MODIFIER_ON_STRUCT]: E_MODIFIER_ON_STRUCT: `modifier` keyword on method 'tick' of struct 'Meter' is not allowed — `modifier` applies only to class methods
Drop the keyword and the struct method works on any var binding (var m = Meter(reading = 0); m.tick();).
safe contexts and safe func
A class instance can be reachable through several references at once. To keep "who may change this object, and when" under the compiler's control, a call to a modifier method on an ordinary (reference-counted) instance must happen inside a safe context. Listing 15-7 put its add calls in a safe { } block; without it:
@mode(host.native)
class Meter {
var reading: int = 0;
pub modifier func tick(): void { this.reading = this.reading + 1; }
pub func value(): int { return this.reading; }
}
func main(): void {
let m = Meter();
m.tick();
println(m.value());
}
main.lphc:11:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'tick' called on ARC receiver of type 'Meter' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`
There are two fixes, and the message names both. A safe { } block anchors a safe context for a few statements; a safe func is a safe context for its whole body, so it can call modifier methods freely:
class Meter {
var reading: int = 0;
pub modifier func tick(): void { this.reading = this.reading + 1; }
pub func value(): int { return this.reading; }
}
safe func tickN(edits m: Meter, n: int): void {
for (i in 0..n) {
m.tick();
}
}
func main(): void {
let m = Meter();
safe { m.tick(); } # anchored inline
tickN(edits: m, 4); # the safe func provides its own context
println(m.value());
}
Listing 15-8: safe { } and safe func
5
The same rule applies inside a class: a non-modifier method that calls a modifier method on this gets E_SAFE_CONTEXT_REQUIRED too. Make the calling method a modifier as well — a modifier body is already a safe context for this.
Two kinds of receiver need no safe context: a unique class instance (chapter 17), whose single owner is proven at compile time, and a synchronized class instance (below), whose lock is taken at run time.
Spec and toolchain differ. The Language Reference (§3-2.4.12) specifies more than 1.25.1 checks. A
safe funcis supposed to be callable only from a safe context, so that the requirement propagates up the call stack likeasync; Stage0 lets any function call asafe func, asmaindoes in Listing 15-8. Inside a safe context the compiler is also supposed to reject amodifiercall while another alias of the receiver is live (E_SAFE_ALIAS_MUTABLE); that check is not implemented. A setter (pub set timeout(...)) is specified as an implicitmodifier, but a setter call outsidesafe { }compiles today.
requires clauses and distinct()
A requires clause after the parameter list states contracts that span several parameters. The verb form gives a parameter its verb, exactly as writing it inline would:
func moveAll(dst: list[int], src: list[int]) requires edits(dst): void {
for (x in src) {
dst.push(x);
}
}
func main(): void {
var queue = [1, 2];
let incoming = [3, 4];
moveAll(edits: queue, incoming);
println(queue);
}
Listing 15-9: requires edits(dst) is the same as edits dst
[1, 2, 3, 4]
⚠ Planned. The rest of
requiresis specified (Language Reference §3-2.4.10) but not yet checked:distinct(a, b), which promises that two buffers do not overlap and lets the LLVM backend mark themnoalias; its errorsE_DISTINCT_ALIASandE_DISTINCT_NOT_READS_EDITS; the call-site acknowledgementsedits(dst)anddistinct(dst, src)written among the arguments; andrequireson methods. Both compilers parse adistinct(...)contract and then ignore it, so a call that passes the same list twice is accepted. Write the clause if it documents your intent, but do not count on it.
synchronized classes
safe contexts are a compile-time proof, and the proof stops at a thread boundary. An object that several threads change needs a run-time lock, and a synchronized class provides one: every modifier method takes the instance's lock on entry and releases it on return (or when an exception unwinds). Callers need no safe { } block.
Sharing an ordinary class with a thread is rejected outright:
@mode(host.native)
class HitCounter {
var hits: int = 0;
pub modifier func record(): void { this.hits = this.hits + 1; }
}
func main(): void {
let counter = HitCounter();
let worker = Thread.spawn(func() {
safe { counter.record(); }
});
worker.join();
}
main.lphc:10:18: error[E_NON_SENDABLE_CAPTURE]: E_NON_SENDABLE_CAPTURE: captured variable 'counter' of type 'HitCounter' does not implement Sendable — every value captured by a closure passed to Thread.spawn must be Sendable
note: v1.20 §2-1.8.1: classes are never implicitly Sendable. Declare `class HitCounter implements Sendable` (every `var` field must then be Mutex[T], RwLock[T] or Atomic[T]), or pass the data as a Sendable value.
The note offers Mutex/RwLock/Atomic fields (chapter 20). The other way is to make the class synchronized and declare it Sendable; the lock is what makes its var fields safe to share:
@mode(host.native)
synchronized class HitCounter implements Sendable {
var hits: int = 0;
pub modifier func record(): void {
this.hits = this.hits + 1;
}
pub modifier func total(): int {
return this.hits;
}
}
func main(): void {
let counter = HitCounter();
let a = Thread.spawn(func() {
for (i in 0..1000) { counter.record(); }
});
let b = Thread.spawn(func() {
for (i in 0..1000) { counter.record(); }
});
a.join();
b.join();
println(counter.total());
}
Listing 15-10: A synchronized counter shared by two threads
2000
Notice that total is a modifier although it only reads. In a synchronized class the lock is held only inside modifier methods, so an ordinary method that reads a var field could see a half-finished write. The compiler rejects it:
@mode(host.native)
synchronized class HitCounter {
var hits: int = 0;
pub modifier func record(): void { this.hits = this.hits + 1; }
pub func total(): int { return this.hits; }
}
func main(): void {
let counter = HitCounter();
counter.record();
println(counter.total());
}
main.lphc:6:36: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: reading `var` field 'hits' of `self` in non-`modifier` method 'total' of `synchronized` class 'HitCounter' is unsynchronized; declare the method `modifier` so the read occurs under the lock
Fields declared let never change, so ordinary methods may read them. A pub var field on a synchronized class draws the warning E_PUB_VAR_ON_SYNCHRONIZED, because code outside the class could write it without the lock.
A few facts about running Listing 15-10:
- Under
loup runthe interpreter runs threads under a global lock, so the program is correct but not parallel. A native binary uses real OS threads and the per-instance lock. - The lock is not reentrant: a
modifierthat calls anothermodifieron the samethiswould deadlock. The specified diagnostic for this,E_SYNCHRONIZED_REENTRANT, is not reported by 1.25.1, so avoid the pattern yourself. - Each
modifiercall takes and releases a lock. For a counter on a hot path, anAtomic[int]field is cheaper; chapter 20 compares the options.
Summary
- A parameter's verb says what the function does with it:
reads(default) reads,editschanges in place,takesconsumes,shareskeeps a reference. edits,takesandsharesmust be acknowledged at the call site (grow(edits: xs)); a missing acknowledgement isE_VERB_REQUIRED, a wrong oneE_VERB_MISMATCH.- Changing a
readsparameter isE_READS_MUTATION; using a binding aftertakes:isE_USE_AFTER_TAKE. - A class method that writes a
varfield is amodifier(E_MODIFIER_REQUIREDotherwise); struct methods never are (E_MODIFIER_ON_STRUCT). - Calling a
modifiermethod, or writing a field, on an ordinary class instance needssafe { }or asafe func(E_SAFE_CONTEXT_REQUIRED);uniqueandsynchronizedreceivers do not. requires edits(x)works;distinct()and the otherrequirescontracts are planned.- A
synchronized class implements Sendableis the simplest object that several threads can change; its reads ofvarfields must bemodifiermethods too. - Several specified checks (
E_EDITS_ALIAS,E_BORROW_ESCAPE,E_SAFE_ALIAS_MUTABLE,E_SYNCHRONIZED_REENTRANT, use-after-take through methods) are not implemented in 1.25.1; the text above says where.
Chapter 16 looks at borrow, excl borrow and noescape fn, the lower-level keywords underneath the verbs.