docs / guide / The Cajeta Guide
Chapters — 11. Ownership & borrowing
- Introduction
- 0. Introduction
- 1. Installation
- 2. Kick the tires
- 3. Your first project
- 4. Running your application
- 5. Debugging
- 6. Keywords
- 7. Comments
- 8. Native types
- 9. Type kinds
- 10. Allocation
- 11. Ownership & borrowing
- 12. Control flow
- 13. Strings & formatting
- 14. Templates & wildcards
- 15. Lambdas & captures
- 16. Operator overloading
- 17. Inheritance
- 18. Annotations & synthesis
- 19. DI & aspects
- 20. Error handling
- 21. Reflection
- 22. Differentiation
- 23. Code coverage
- 23. Profiling
- 24. Notebooks
- 25. Writing XPU Kernels
11 — Ownership & borrowing
Every heap value has exactly one owner at any time. = is always a borrow —
ownership stays with the right-hand side. The # operator is a passthrough of
whatever the source holds: a transfer when the source owns, a borrow handed
along when it doesn’t. The spelling rules are checked at compile time. Whether
a call or a return carries a title can be decided at run time, so a few
ownership bits travel with the value.
public class Point {
public int32 x;
public int32 y;
public Point(int32 x, int32 y) {
this.x = x;
this.y = y;
}
public int32 distSq() {
return this.x * this.x + this.y * this.y;
}
}
Borrow by default, transfer with #
Point a = heap Point(7, 24);
Point b = a; // borrow — a still owns; b must not outlive a
Point c #= a; // passthrough — a owns here, so the title moves to c
int32 d = c.distSq();
After the transfer, a is demoted to a borrow of the same live instance —
reading it is still legal, but transferring it again is a compile error:
Point p = heap Point(7, 24);
Point q #= p;
int32 v = p.x; // legal — `p` is now a BORROW of the same instance
Point r #= p; // ERROR — CAJETA_ERROR_MOVE_OF_BORROW
Where # goes
# marks where a title changes hands. It appears in exactly four places:
- A store —
Point c #= a,this.held #= v,this.data[i] #= v. The destination records the source’s mode. It takes the title when the source owns one, and it records a borrow when the source was lent.#=is one token: an ownership store cannot be half-written. From a conditional,this.data[i] #= lt ? a : bacts on the arm that runs. That arm’s title moves, a lent arm records a borrow, and the other arm keeps its title. - A move expression —
this.consume(#a),return #a,#this.data[i]. The source is moved; its drop entry is deactivated. This is the spelling at call arguments, returns, and slot extractions — none of which are assignments. - A parameter type —
void consume(#Point p). The callee demands ownership; a caller that passes plainxgetsCAJETA_ERROR_TRANSFER_REQUIRED. Its opposite isvoid watch(^Point p), which only ever borrows (see Keeping a value in a field). - A return type —
#Point make(). The callee hands ownership to the caller.
The first two split by position, and the split is the whole rule: a store
uses #=; everything else uses #v.
It never goes in the receiving local’s type — #Point q = … is
CAJETA_ERROR_TYPE_TRANSFER_RETIRED (“a local’s role comes from its
initializer”). The binding does carry a marker when the callee declares a
transfer: a #T result must be received with #= — Point q #= this.make();
— because that is what makes the acquisition visible without opening the
callee. A plain = there is CAJETA_ERROR_OWNED_RESULT_NEEDS_TRANSFER
(spec §4.6). A plain-T result still binds with a plain =, since it carries
whatever mode the callee’s frame held.
Deprecated: stores were once spelled
dst = #v. That still compiles and still transfers, but warns and becomes an error in a later release. Writedst #= v.
public class Owners {
public int32 consume(#Point p) {
return p.distSq(); // p is owned here; it drops when consume returns
}
public #Point make() {
return heap Point(3, 4); // fresh heap value promotes implicitly
}
public void run() {
Point a = heap Point(7, 24);
int32 d = this.consume(#a); // a is moved from this line on
Point q #= this.make(); // `#Point` result — `#=` records the acquisition
int32 e = q.distSq();
}
}
A plain T parameter can also accept an offered #x — the caller
surrenders ownership and the value drops in the callee’s frame. The tour’s
OwnershipDemo
uses exactly that shape.
# forwards the arrived mode
Because # is a passthrough, wrappers forward ownership without knowing which
mode they were handed. A plain formal’s ownership is decided at the call
site — f(x) lends, f(#x) transfers — and inside the callee, #p (or
this.f #= p) hands along whichever mode actually arrived: a lent value stays
lent, an owned one transfers. Only a value the compiler can see is purely a
borrow — a local borrowing another local, or a borrow returned by a plain
method — refuses the # with CAJETA_ERROR_MOVE_OF_BORROW: that surrender
would be a lie.
Keeping a value in a field
A store into a field, an array slot or a static is where a value starts to outlive the call that produced it. The compiler checks every such store.
- A parameter kept with
=must be spelled^T. A^Tparameter is only ever borrowed. A caller cannot pass#x, a freshheapvalue, an owned#Tresult or a+String into it (CAJETA_ERROR_TRANSFER_INTO_BORROW_PARAM). - A
Tor#Tparameter that is kept is stored with#=. This is the sink and setter shape.set(x)lends andset(#x)transfers. - An owned local (bound from
heap, a call,#=or a+) stored into a field or slot that outlives it is stored with#=. - A value read out of a parameter or out of an owned local (
b.child) is never stored with=. For aTor#Tparameter, spell it^Tso the caller keeps the root alive. For either,#=takes the title the slot held. - Strings follow every rule here, like every other type.
=never moves. Storing an owned local with=is an error even at its last use. Spell it#=.- A producer written in place takes the title with
=. That covers a literal, a primitive,null, aheap T(...), a call and a String+. A local bindingT b = a;lends, and an argumentf(a)lends. - A local holder whose field or slot is stored into counts as a field when
it escapes. It escapes when it is returned, passed or stored with
#, or stored into a field, a slot, a static or another holder that escapes. A holder bound from a parameter, a field read or a call is not the frame’s own, so a store into it is a field store. A holder declared in an outer block that keeps an owned local from an inner block outlives that local, so it needs#=too. A holder that stays local is exempt.
public class Block {
public Block child;
public Block() { }
}
public class Holder {
Block kept;
Block seen;
int32 count;
public void keep(Block b) { this.kept #= b; } // the caller chooses: lend or transfer
public void watch(^Block b) { this.seen = b; } // only ever a borrow
public void setCount(int32 n) { this.count = n; } // a primitive carries no title
}
| Store | Verdict |
|---|---|
void f(Block b) { this.b = b; } | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: use #=, or spell ^Block |
void f(#Block b) { this.b = b; } | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: use #= |
Block l = heap Block(); this.b = l; | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: use #= |
void f(Block b) { this.c = b.child; } | CAJETA_ERROR_INTERIOR_KEEP_NEEDS_BORROW_PARAM: spell ^Block, or use #= |
Person o = Persons.load(); this.name = o.name; | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: use #= |
String w #= s.substring(1, 9); this.v = w; | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: use #=, even when w is not read again |
Node n = heap Node(); n.c = p; return #n; | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: n escapes, use #= or spell ^Cell |
Node h = heap Node(); { Cell l = heap Cell(1); h.c = l; } return h.c.n; | CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE: h outlives l, use #= |
void f(^Block b) { this.b = b; } | correct, the field borrows |
Block o = heap Block(); this.c #= o.child; | correct, the field takes the title the slot held |
Node h = heap Node(); h.c = p; return h.c.n; | correct, h stays local |
Node h = heap Node(); Cell l = heap Cell(1); h.c = l; return h.c.n; | correct, h stays local in l’s block |
this.b #= b; in any of the above | correct |
In the holder rows, p is a plain Cell parameter, Node has a field
Cell c, and Cell has a field n.
Spelling the parameter #T is not a fix on its own. A #T parameter owns its
argument and frees it at return, so #T with = still leaves the field
dangling.
A holder that escapes is spelled the same way as a field.
public class Chain {
public static #Block wrap(Block b) {
Block h = heap Block();
h.child #= b; // h escapes, so this store follows the field rules
return #h;
}
}
Strings follow the same spellings. A #= of an owned window, such as a
substring result, moves the window and its shared stake on the root. A lent window
stored with #= resolves. It becomes a copy up to 256 bytes and a shared
stake on the root above that. Bytes from the frame arena always copy when
they escape. A String = into a field is a plain borrow and makes no
copy. To keep your own String past its source, write
this.v #= o.v.clone(). A String array slot follows the same rule: each
slot carries its own title, so a[i] = s borrows and a[i] #= s takes
the title or resolves a lent source.
When a fresh node is both registered and linked, register it first and link
it with #=. The transfer demotes the local, so the link records a borrow.
this.nodes.add(#node); // the registry takes the title
this.root #= node; // node is now a borrow, so the link records a borrow
^ is an error on a primitive or value-type parameter
(CAJETA_ERROR_BORROW_MARK_ON_VALUE) and on varargs
(CAJETA_ERROR_BORROW_MARK_ON_VARARGS). It never marks a type-parameter
declaration (class C<^K> is an error). A parameter typed by a template
parameter may be spelled ^K key, and the mark does nothing when K is a
primitive. An override must spell the same ^ marks as the method it
overrides (CAJETA_ERROR_BORROW_MARK_MISMATCH). Moving a ^T parameter with
#p is CAJETA_ERROR_MOVE_OF_BORROW.
The compiler cannot see two hazards. When a caller lends x to a holder that
outlives x, the holder dangles. The lend may reach the holder through a
^T parameter, or through a call such as h.keep(x) whose callee keeps it.
The compiler catches only the case where the method that owns x returns
h (CAJETA_ERROR_DANGLING_LEND). The rest needs lifetimes the language
does not track. The second hazard is a sibling field
alias. this.a = this.b is a legal borrow. Replacing this.b afterwards
frees what this.a points at, and the compiler does not track that.
Drops at scope exit
Owners are reclaimed automatically at the closing } of their declaring
block, in reverse declaration order (LIFO). A throw unwinds the same chain,
so cleanup runs on the exception path too. A moved-from local’s drop entry is
deactivated — no double free is possible. There is no delete.
An owned temporary has no name and no block of its own. It is a builder, or
any #T call result, used as the receiver of a further call. It is freed at
the end of its statement. In an if, while, do or for condition it is
freed right after the condition is evaluated, so a loop frees it on every pass.
The borrow checker
The checker is static and scope-based. Beyond transfer-from-a-borrow
(CAJETA_ERROR_MOVE_OF_BORROW) it rejects:
- Borrow escape — returning or storing a borrow that would outlive its source (including the stack-local return from chapter 10).
- Alias-mutation — writing through a path while a live borrow into it
exists (e.g.
list.add(...)inside aforiteratinglist). - Definite assignment — reading a local before it is assigned.
- Borrow of a temporary (
CAJETA_ERROR_BORROW_OF_TEMPORARY). A plain result that reaches an owned temporary can be used inside its statement. Binding it to a local, storing it in a field, or returning it is rejected, because the temporary is freed when the statement ends. End a builder chain with itsbuild()in the same statement. Or bind the temporary to a local first and configure it in later statements. Or call a method that returns an owned#Tcopy.
import cajeta.io.net.Server;
import cajeta.io.net.ServerBuilder;
import cajeta.io.net.TcpStream;
public class Boot {
public static void handle(TcpStream conn) { }
public static #Server open() {
Server s #= Server.builder()
.bind("127.0.0.1:0")
.handler((TcpStream c) -> { Boot.handle(c); })
.build(); // build() is #Server, so s owns it
return #s;
}
public static #ServerBuilder configure() {
// ServerBuilder b #= Server.builder().bind("127.0.0.1:0"); // CAJETA_ERROR_BORROW_OF_TEMPORARY
ServerBuilder b #= Server.builder(); // bind the temporary first
b.bind("127.0.0.1:0");
return #b;
}
}
Slices and the shared state
arr[a:b] yields a Slice<T> — three machine
words (buffer, offset, length) windowing the array’s storage. No element
copy; indexing is window-relative and bounds-checked; sub-slicing composes
against the root in O(1). substring() and trim() return windowed strings
the same way (chapter 13).
public class Windows {
public int32 firstOfMiddle(int32[] xs) {
Slice<int32> mid = xs[2:5]; // zero-copy window
int64 n = mid.count(); // 3
return mid[0]; // xs[2]
}
}
A slice that stays local is a plain borrow — it dies with its scope, no
bookkeeping. When a slice escapes (stored in a field or container), the
compiler resolves the escape: small payloads copy; large buffers promote
the root from owned to shared — refcounted, freed at the last drop.
The promotion is automatic; shared here is an ownership state the
compiler tracks, not something you write (unrelated to the @Kernel
placement keyword).
The store-past-scope idiom is transfer: hand the window to the container
with #, and the compiler keeps a stake on the root so the view survives
its source’s drop.
import cajeta.collection.ArrayList;
public class Grams {
// Rolling n-grams of a dying local — the views outlive `lower`.
public #ArrayList<String> grams(String key, int32 n) {
ArrayList<String> out = heap ArrayList<String>();
String lower #= key.toLowerCase();
int32 len = (int32) lower.count();
int32 i = 0;
while (i + n <= len) {
String g #= lower.substring(i, i + n);
out.add(#g); // transfer — g escapes into the list
i = i + 1;
}
return out; // root buffer lives until the last view drops
}
}
Spec: slice-spec.
Details: MemoryModel, OwnershipTransfer, and FieldOwnership for how fields own or alias.
Next: Control flow.