Memory Safety
Sun implements a simplified Rust-style ownership system that provides memory safety guarantees without requiring explicit lifetime annotations. This page covers ownership and moves, references and borrowing, the rules the borrow checker enforces, and the unsafe block that steps outside them.
Sun is inference-first: most code never writes a lifetime. References are scoped and cannot escape their defining context. Optional Rust-style lifetime names ('a, 'this) exist for one purpose — relating two positions of one signature, so a callee that stores one argument into another can say so (see Named Lifetimes).
Overview
Sun's ownership model is built around three key concepts:
- Value Types: Primitives and classes are value types, allocated on the stack by default
- Move Semantics: Class-typed variables transfer ownership when assigned to another variable
- References: Temporary borrows of variables, tracked by the borrow checker
Value Types and Move Semantics
Primitives
Primitive types (i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, bool) are copied on assignment:
var x: i32 = 42;
var y: i32 = x; // y gets a copy of x
x = 100; // x is now 100, y is still 42Classes
Classes are value types that use move semantics. When you assign a class instance to another variable, ownership is transferred and the original variable becomes invalid:
class Point {
var x: i32;
var y: i32;
init(px: i32, py: i32) {
this.x = px;
this.y = py;
}
}
function main() i32 {
var p1 = Point(3, 4); // p1 owns the Point
var p2 = p1; // Ownership moves to p2
// p1.x; // ❌ ERROR: use of moved value 'p1'
return p2.x; // ✓ OK: p2 now owns the Point
}Error:
error: use of moved variable 'p1'. Ownership was transferred in a previous assignment.This prevents use-after-move bugs at compile time.
Writing to a Field
Assigning to a field moves the new value in and drops the value the field held before, so the old one is released exactly once. A constructor's first write to a field is the exception: the field has never held a value at that point, so there is nothing to release and nothing is dropped.
class Holder {
var res: Res;
init() {
this.res = Res(1); // starts the field's life — nothing dropped
}
method replace(r: Res) void {
this.res = r; // replaces what the field holds, dropping it
}
}In a constructor every write to an owning field has to be one of those two, and the compiler has to be sure which. A second write is fine when the field certainly holds a value by then, because what it replaces is known:
init() {
this.res = Res(1); // starts its life
this.res = Res(2); // certainly replaces Res(1), which is dropped
}What is rejected is a write the compiler cannot place — where some path reaching it assigned the field and some did not:
init(flag: bool) {
if (flag) { this.res = Res(1); }
this.res = Res(2); // ❌ did the branch already assign it?
}Branches are alternatives, so one assignment in each is still one assignment, and every path arrives in the same state:
init(flag: bool) {
if (flag) { this.res = Res(1); } else { this.res = Res(2); }
}try expression exits a fallible constructor on error and drops only the fields
initialized so far. A match that handles the error locally must leave fields
initialized on every path that continues.
A loop is the same question: its body may be the first pass or a later one, so it needs the field settled before the loop, or the value settled in a local and assigned once.
init(n: i32) {
var chosen = Res(0);
for (var i: i32 = 1; i < n; i = i + 1) { chosen = Res(i); }
this.res = chosen; // one assignment, whatever the loop did
}Only fields that own something are held to this. A field holding a number releases nothing when it is written again, so accumulating a total in a loop is fine.
A constructor's first direct assignment starts a field's lifetime without destroying its storage. Later assignments destroy the previous value, unless ownership has moved away.
Moving Values Out
Moving a value transfers responsibility for destroying it. The old location must not run its destructor. Assigning a new value there restores ownership; the new value is destroyed normally.
A locally owned class without a custom deinit can have a field moved out.
The remaining fields are still destroyed. The moved field cannot be read,
and the whole object cannot be used, until that field is assigned again.
Moving a field out of a class with deinit is rejected: its destructor may
need that field. Moving through ref or this, or out of an indexed
element, is also rejected. Use std.replace to leave a valid replacement:
var previous = std.replace(holder.value, replacement);For a vector, remove(index), swap_remove(index), and pop() transfer
ownership and shrink the vector. Vec.take() is no longer available:
zeroed storage is not an empty value of an arbitrary type.
When positions must stay stable, use Vec<Option<T>> and replace the
occupied slot with None:
var empty: Option<TcpStream> = Option.None;
var previous = slots.replace(index, empty);Vec.replace returns the old element without destroying it and keeps the
size unchanged. Clearing the vector later destroys only values still
present in its optional slots.
Moving on Only Some Paths
A branch can move a value on one path and leave it alone on another. The paths that did not move it still own it, so it is dropped where they end.
function maybe(take: bool) void {
var r = Res(1);
if (take) { sink(r); } // moved here, still owned on the other path
} // dropped here when `take` was falseEither way exactly one owner is left, so deinit runs exactly once. After the
if, r counts as moved whichever way the branch went, so nothing may read it
— see the rule above.
Whether the value was moved depends on the path taken, so the compiler cannot always answer it while compiling. When it cannot, it puts a flag beside the value — in the stack frame, never inside the class, so layout is untouched — that records whether this call still owns it, and the drop reads the flag. Only values with a genuinely path-dependent move get one: a move on every path, or on none, stays a decision made while compiling and costs nothing at run time.
See Constructors for the rule this leans on: every field is assigned before the object may be used.
Returning Classes from Functions
Functions can return class instances, transferring ownership to the caller:
function create_point(x: i32, y: i32) Point {
return Point(x, y); // Ownership transfers to caller
}
function main() i32 {
var p = create_point(5, 10);
return p.x + p.y; // Returns 15
}The caller owns the result even when it ignores it. A call used as a
statement (create_point(1, 2);) produces a temporary that nobody takes,
and it is dropped — its deinit runs — at the end of the enclosing scope,
the same as a discarded constructor temporary.
Constants
A binding declared with const instead of var never changes: it cannot be
assigned to (=, +=), none of its fields or elements can be assigned, no
field can be moved out of it, nothing may take a mutable ref to it, and only
const methods may be called on it. It can be read,
borrowed with const ref, copied if it is a scalar, and — for a local — moved
as a whole, which ends the binding.
const LIMIT: i32 = 100; // a constant global
function main() i32 {
const p = Point(3, 4);
const n = p.x + LIMIT; // ✓ reads are fine
var q = p; // ✓ a whole-value move ends `p`
// p.x = 5; // ❌ ERROR: cannot assign to field 'x' of constant 'p'
// LIMIT = 1; // ❌ ERROR: cannot assign to constant 'LIMIT'
return n;
}A global whose initializer the compiler can work out, such as
const LIMIT: i64 = BASE * 2 + 1;, is computed at compile time and written into
the program. That covers literals, operators, other compile-time constants,
array literals and array elements, and calls to plain functions that only
compute: a function that takes and returns values, has no side effects, and touches
nothing but its own variables and compile-time constants. Branches, loops and
recursion are all fine. Any other global is given its value when the program starts, before
main runs, in the order the globals are written. That includes a const:
const means the variable is never reassigned, not that its value is known at
compile time.
Like functions and classes, a global can be used before the line that declares it. Two things are errors: a global whose initializer leads back to itself, and a global initialized at startup whose initializer reads one that startup initializes later, because it would still be zero.
const DOUBLE: i64 = BASE * 2; // ✓ BASE is declared below
const BASE: i64 = 10;
// const A: i64 = B; const B: i64 = A; // ❌ ERROR: 'A' depends on itself: A -> B -> Afor (const x: T in c) makes the loop binding constant for each element.
References
References provide temporary, scoped access to variables without transferring ownership. Create a reference using the ref keyword:
function main() i32 {
var x: i32 = 42;
ref r = x; // r references x (mutable borrow)
r = 100; // Modifying through r changes x
return x; // Returns 100
}References allow you to access and modify variables without copying or moving:
function increment(x: ref i32) void {
x = x + 1;
}
function main() i32 {
var count: i32 = 0;
increment(count); // count is now 1
increment(count); // count is now 2
return count; // Returns 2
}A declaration annotated ref T borrows too, which is what lets a borrow carry
an explicit type or come from a call that returns one:
function main() i32 {
var p = Point(1, 2);
var r: ref i32 = p.x; // borrows the field, same as `ref r = p.x`
r = 5;
return p.x; // Returns 5
}Either form may pick its target with a conditional, as long as both branches are themselves borrowable. The reference binds whichever slot the condition selects, and both objects count as borrowed for its lifetime:
var pick: ref String = h.flag ? h.a : h.b;The target must have storage: a temporary (var r: ref String = String(alloc, "x")) is rejected, since the value it borrows would die at the end of the
statement.
Constant References
const ref borrows a value for reading only. The statement form is
const ref r = x;, the type is const ref T, and either may bind a
constant, a variable, or a field:
function total(v: const ref Vec<i32>) i64 {
return v.size(); // ✓ size() is a const method
// v.push(1); // ❌ ERROR: cannot call non-const method 'push'
}
function main() i32 {
var x: i32 = 1;
const ref r = x; // shared borrow: x may still be read and assigned
var s: const ref i32 = x; // same, with an annotation
// r = 2; // ❌ ERROR: cannot assign through const reference 'r'
return r + s;
}Assigning to x while it is shared-borrowed is fine for a scalar like this
one; a borrowed compound value cannot be replaced at all — see
Rule 4.
Through a const ref nothing can be assigned, no mutable ref can be taken,
and only const methods can be called. A ref T may be passed or rebound as
const ref T, never the other way round. When a const method returns a borrow
and the receiver is constant or a const ref, the result is the const view
of the declared type: get(i) ref T yields const ref T, and first() Option<ref T> yields Option<const ref T>, so a match on it binds a
read-only element.
Several const refs to one value may be alive at once; a mutable ref cannot
coexist with any of them.
Reference Rebinding
You can create a reference from another reference. The borrow kind may be downgraded (ref to const ref) but never upgraded:
function main() i32 {
var x: i32 = 10;
ref r1 = x; // Mutable borrow of x
ref r2 = r1; // ❌ ERROR: x is already borrowed
const ref r3 = r1; // ✓ OK: downgrade to a shared borrow
return x;
}Borrow Checker Architecture
Sun's borrow checker is a compile-time analysis pass that validates reference safety. It runs after semantic analysis and before code generation.
Core Components
The borrow checker consists of three main components:
| Component | Responsibility |
|---|---|
| BorrowChecker | AST traversal, rule enforcement, error collection |
| BorrowState | Tracks active loans per variable, validates borrow requests |
| Loan | Represents a single active borrow with metadata |
Analysis Flow
The borrow checker performs a single pass over the AST:
Scope Tracking
Borrows are tied to lexical scopes. When a scope exits, all loans created in that scope are invalidated:
Borrow Rules
Sun enforces Rust-style borrow rules at compile time:
Rule 1: Single Mutable Borrow
At any program point, a variable can have at most one mutable borrow:
function main() i32 {
var x: i32 = 10;
ref r1 = x;
ref r2 = x; // ❌ ERROR: x is already borrowed
return r1;
}Error:
error: cannot borrow 'x' as mutable because it is already borrowedThe borrow checker tracks this via the BorrowState.addBorrow() method, which checks for conflicting active loans before allowing a new borrow.
Rule 2: Sequential Borrows Are Allowed
When a reference goes out of scope, the borrow ends. You can then create a new reference:
function main() i32 {
var x: i32 = 10;
if (true) {
ref r1 = x;
r1 = 20;
} // r1 goes out of scope, borrow ends
ref r2 = x; // ✓ OK: x is no longer borrowed
r2 = 30;
return x; // Returns 30
}This works because exitScope() marks loans as inactive when their defining scope ends.
Rule 3: A Returned Reference Borrows the Call's Inputs
A function may return a reference, but only one that lives in something the
caller passed in — a ref parameter, or the receiver of a method. Which
input it points into is not knowable at the call site, so binding the result
to a name conservatively borrows every variable the call could hand back
a reference into:
function longest(a: ref String, b: ref String) ref String {
if (a.length() > b.length()) { return a; } else { return b; }
}
function main() void {
var a: String = `.`;
var b: String = `..`;
var c: ref String = longest(a, b); // both a and b are borrowed
// a = `x`; // ❌ ERROR: 'a' is borrowed
// b = `y`; // ❌ ERROR: 'b' is borrowed
c = `z`; // ✓ replaces whichever c points at
println(c);
}The same applies to container accessors: var e: ref T = vec.get(i) borrows
vec until e goes out of scope. When you only need the value of a scalar
element, annotate the scalar type instead — var e: i32 = vec.get(i) copies
it out and takes no borrow.
Rule 4: A Borrowed Compound Value Cannot Be Replaced
Assigning a new value to a class, payload enum, or interface variable drops the old value — including the storage every live borrow points into. So while such a variable is borrowed, assigning to it is rejected; assign through the borrow instead, which correctly drops the old referent and moves the new value in:
function main() void {
var b: String = `..`;
var c: ref String = b;
// b = `new`; // ❌ ERROR: cannot assign to 'b' because it is borrowed
c = `new`; // ✓ replaces b through the borrow
}Scalars are different: overwriting an i32 leaves its storage in place, so
assigning to a borrowed scalar stays legal and live borrows simply observe
the new value:
function main() i32 {
var x: i32 = 10;
ref r = x;
x = 20; // ✓ scalar write; nothing is dropped
return r; // Returns 20
}Rule 5: A Class That Stores References Is Itself a Borrow
A class may have reference-type fields. Such a value points into storage it does not own, so it is treated like a borrow: constructing it takes a loan on every variable passed by ref to the constructor, and those loans last until the enclosing scope ends. The value must also never be held by a variable declared in an outer scope than what it borrows — the holder would keep the borrowed storage's address past its death:
var a = Inner(1);
var h = Holder(a); // ✓ h and a live equally long
if (true) {
var b = Inner(2);
h = Holder(b); // ❌ ERROR: h outlives b, the stored ref would dangle
}The bound follows the value when it moves: a holder built in an inner scope may move outward exactly as far as the outermost variable it borrows.
class Holder {
public var x: ref String;
init(x: ref String) { this.x = x; }
}
function main() void {
var a: String = `a`;
var b: String = `b`;
var t = Holder(a); // t stores a ref into a: a is borrowed
// b = a; // ❌ ERROR: cannot move out of 'a' because it is borrowed
// ref r = a; // ❌ ERROR: 'a' is already borrowed
println(t.x); // ✓ reads through the stored ref
}And like a lambda with a capture list, such a value cannot leave the frame that built it — what its refs point into dies when the function returns:
function make() Holder {
var a: String = `dies here`;
return Holder(a); // ❌ ERROR: cannot return a value that stores references
}This holds transitively: a class whose field is (or contains) a ref-storing class is subject to the same rules.
An Unsized Array Is a View
A sized array<T, N> owns its elements and moves like any other compound
value. An unsized array<T> owns nothing: it is a view of some sized array
with its rank erased, and it may only be written behind ref. A
ref array<T> parameter borrows the array it is given; a ref array<T> field
is a reference field, so the class is a holder under Rule 5; a
ref array<T> return is a reference return under Rule 3.
class Holder {
var xs: const ref array<i64>; // a view: Holder is a holder
init(xs: const ref array<i64>) { this.xs = xs; }
}
function make() Holder {
var local: array<i64, 3> = [10, 20, 30];
return Holder(local); // ❌ ERROR: cannot return a value that stores references
}
function first(xs: const ref array<i64>) array<i64> { // ❌ ERROR: array<T> may only
return xs; // be used behind ref
}
function main() i32 {
var a: array<i64, 3> = [10, 20, 30];
var h = Holder(a); // ✓ h borrows a until the end of main
var b = a; // ❌ ERROR: cannot move out of 'a' because it is borrowed
return 0;
}Rule 5b: An Anonymous Lambda Lifetime Is Frame-Bound
A lambda that carries a captured environment — a [ref x] or owned [x]
capture list, or a bound method holding its receiver — has the type
<'_>(…) => …, while a plain (…) => … annotation admits only
environment-free lambdas (an environment-free lambda widens into a <'_>
parameter, never the reverse). Because the type says the value may point into
a stack frame, the compiler bars it from everything that outlives one:
function make() <'_>() => i32 { ... } // ❌ ERROR: <'_> cannot be a return type
var g: <'_>() => i32 = ...; // ❌ ERROR: a global would outlive the frameThe property is transitive, exactly like Rule 5: a class with a <'_>
field, an enum with a <'_> payload, or a container instantiated over a
<'_> lambda type (Vec<<'_>() => i32>) is frame-bound as a whole — it
works freely inside the frame, but cannot be returned, become a global, or
convert to an interface (which would erase the marker from the type).
'_ is a fresh anonymous lifetime, unrelated to every other position in
the signature. At a call, the checker therefore makes no guess that an
anonymous callback might flow into a receiver or ref argument. Inside the
callee, the same lack of a relationship means the callback cannot be stored
into storage that may outlive the call:
class Bus {
var cb: <'_>() => i32;
public method invoke(cb: <'_>() => i32) i32 {
return cb(); // ✓ calling does not retain the callback
}
public method badSubscribe(cb: <'_>() => i32) void {
this.cb = cb; // ❌ nothing says cb outlives this object
}
public method subscribe(cb: <'this>() => i32) void {
this.cb = cb; // ✓ the signature promises cb outlives this
}
}An anonymous callback may still be assigned to a local declared in the same or
a narrower scope, or forwarded to another anonymous callback parameter. Those
destinations cannot outlive the call. A storing free function names both the
callback and destination with the same declared lifetime; a storing member
usually uses 'this.
Within one frame, the destination must also not be declared in an outer scope than the environment — an inner scope's locals die at its closing brace, while the destination lives on:
var bus = Bus();
if (true) {
var n = Node();
bus.subscribe(n.onMsg); // ❌ ERROR: bus outlives n
}
// ✓ legal when bus and n are declared in the same scopeTwo names declared in the same scope are not ordered by this rule: they drop in reverse declaration order at the scope's end, and the checker does not chase which of two siblings dies first. Keep a callback and its bus in the order that lets the bus drop first, or drop the bus explicitly.
Named Lifetimes Relate Two Positions of One Signature
The rules above stop at a function boundary: a helper that stores one
argument into another is invisible at its call sites. A lifetime name
makes the entanglement part of the signature, so every call is checked
against the arguments' real scopes. Lifetimes are declared in the angle
brackets with a leading apostrophe (function wire<'a>, class Bus<'a>);
inside class and interface members the builtin 'this names the receiver's
lifetime and needs no declaration:
class Bus<'a> {
var cb: <'a>(i32) => i32; // stored callbacks must outlive 'a
public method subscribe(cb: <'a>(i32) => i32) void { this.cb = cb; }
public method publish(x: i32) i32 { var f = this.cb; return f(x); }
}
class Node {
public method onMsg(x: i32) i32 { ... }
/* Bus<'this> binds the bus's slot to this node's lifetime: the bus may
only store things this node outlives */
public method attach(bus: ref Bus<'this>) void {
bus.subscribe(this.onMsg); // ✓ the signature carries the proof
}
}
var bus = Bus();
if (true) {
var n = Node();
n.attach(bus); // ❌ ERROR: n dies before bus, and attach's
} // signature says the bus may keep n's method
bus.publish(1);Call sites never write lifetime arguments — the compiler binds each name to
the concrete scopes of the arguments and requires every environment flowing
into a name to outlive every destination sharing it. A callee, in turn, may
only store a named parameter where the destination's declared lifetime is
the same name (its own class's declared lifetimes count for fields of
this), so one caller lifetime can never launder into another. Interfaces
state the same contracts, and an implementing class must repeat the
interface's names exactly. Each <'_> occurrence remains unrelated to the others; only a repeated
declared name creates a relationship. An anonymous return stays banned because
it names no input frame.
Some values are frame-bound without their type showing it: the handle
spawn returns for a thread over a capture-list lambda hides that lambda
behind runtime storage. Such a value is tracked per frame instead, and since
a callee cannot be checked against what its parameter type does not say, a
frame-bound value may not be passed to any by-value parameter — nor
stored in a field, an indexed slot, or a global. Pass it by ref, or give
the lambda its data as spawn arguments instead of captures (see
Threads).
Rule 6: Use-After-Move Detection
When a class-typed variable is assigned to another variable, the original is marked as moved and cannot be used:
function main() i32 {
var p1 = Point(1, 2);
var p2 = p1; // p1 is moved
return p1.x; // ❌ ERROR: use of moved variable
}Error:
error: use of moved variable 'p1'. Ownership was transferred in a previous assignment.A lambda's capture list is another place a value moves. An entry that says
ref borrows — [ref x] mutably, [const ref x] read-only — and an entry
that says neither gives the value to the closure:
function main() i32 {
var p = Point(1, 2);
var f = [p]() => i32 { return p.x; }; // p moves into the closure
return p.x; // ❌ ERROR: use of moved variable
}The closure owns what it took, so it may change it, and it is dropped when the
closure's scope ends. A scalar has nothing to move, so [x] copies it. A
compound value is never picked up implicitly: a lambda that uses one without
naming it in the capture list is rejected, so you have to say which of the
three you meant.
Scope-Based Borrowing
Borrows are automatically invalidated when the reference goes out of scope:
function main() i32 {
var x: i32 = 10;
if (condition) {
ref r = x; // Borrow starts (scope depth increases)
r = 20;
} // Borrow ends (scope depth decreases)
x = 30; // ✓ OK: x is no longer borrowed
return x;
}This works naturally with loops - each iteration creates a fresh borrow:
function main() i32 {
var sum: i32 = 0;
var i: i32 = 0;
while (i < 5) {
ref r = sum; // New borrow each iteration
r = r + i;
i = i + 1;
} // Borrow ends each iteration
return sum; // Returns 10 (0+1+2+3+4)
}Passing References to Functions
References can be passed to functions that accept ref parameters:
function swap(a: ref i32, b: ref i32) void {
var temp: i32 = a;
a = b;
b = temp;
}
function main() i32 {
var x: i32 = 1;
var y: i32 = 2;
swap(x, y);
// x is now 2, y is now 1
return x;
}When you pass a variable to a function expecting a ref parameter, the borrow lasts only for the duration of the function call.
Unsafe Blocks
A few operations sit outside what any of this can prove — calling into C,
reading through a raw pointer, touching a word atomically. Sun does not forbid
them; it asks you to mark them with unsafe, so the places where
the compiler stopped proving things are visible in the source.
For a single expression, omit the braces:
var byte = unsafe text.unsafe_at(i);
unsafe text.unsafe_set_at(i, byte);unsafe binds like a unary operator. Calls, member access, and indexing belong
to its operand: unsafe reader.read() + other() permits unsafe operations only
in reader.read(). Use parentheses to include a larger expression:
unsafe (reader.read() + other()). The permission ends with the operand and
does not extend into the bodies of functions or lambdas defined there.
An unsafe block is an expression, so it appears in both of the places an
expression can, and every statement inside the braces keeps its own
semicolon. This is the part that is easy to get wrong:
unsafe { _free(mem); }; // statement: ends the call, then the statement
var mem = unsafe { _malloc(n); }; // value: the block is the last expression
var mem = unsafe { _malloc(n) }; // ❌ ERROR: expected ';' after expression statementA block may hold several statements; the last one is its value. Names declared
inside stay inside, like any other block body — but the value leaves, and
ownership of it leaves too, so var s = unsafe { make(); }; keeps its string.
A return inside the block returns from the enclosing function — there is
no block-local return — which is what makes unsafe { return c_abs(x); }; the
usual way to wrap a C call. It also means a block that always leaves the
function never produces a value, so binding one is rejected:
var x = unsafe { return 0; }; // ❌ ERROR: the block never produces a value
var y = unsafe {
if (tooBig) { return 0; } // ✓ conditional early exit; y binds otherwise
c_abs(n);
};What requires a block
The following operations:
- Calling an
unsafe methodor an unsafe callable value. The caller must uphold the documented safety contract. - Calling an
extern "C"function. C is outside everything Sun proves. See C FFI. - Reading a field through a
raw_ptr<T>whereTis a class. Nothing says the pointer is non-null, aligned, or still pointing at a liveT. - An intrinsic that reads or writes memory nothing has checked — through a
raw pointer (
_load<T>,_store<T>,_to_ref<T>,_init<T>,_deinit<T>,_load_i64,_store_i64,_memcpy,_memset,_ptr_offset), on the heap (_malloc,_free), across threads (_spawn,_thread_join*, the_atomic_*and_futex_*intrinsics), or across the boundary into libc and the kernel (every__-prefixed file and socket intrinsic).
An intrinsic that only computes needs no block: _sizeof<T>, _is<T>,
_address_of<T>, _ptr_as_raw<T>, _convert<T>, _bitcast<T>,
_target_is, the print intrinsics and the bit intrinsics. Taking an address is
safe; reading through it is not. See Compiler Intrinsics.
Unsafe methods
Declare a method unsafe when it relies on conditions that safe callers could
violate. Calling it requires unsafe <expr> or an unsafe { ... } block,
even within another unsafe method. A method body still needs explicit unsafe
for raw memory access.
class ByteReader {
/* Read a byte from a live, initialized buffer at a valid index. */
public const unsafe method read(data: raw_ptr<u8>, index: i64) u8 {
return unsafe { _load<u8>(data, index); };
}
}The modifier order is public const unsafe method; omit modifiers that do not
apply. Interface methods can also be unsafe. An unsafe implementation cannot
satisfy a safe interface method.
The requirement follows a stored method reference. Its type can be inferred or
written explicitly as unsafe <'_>(i64) => u8; assigning it to a safe callable
type is rejected. A generic callback does not remove the requirement. An unsafe
callback must be wrapped in a lambda that upholds its contract before it can be
passed to spawn.
String.at() and String.set_at() check the index against length() and return
AccessResult.OutOfBounds(IndexOutOfBoundsError) on failure. Their unchecked counterparts are
unsafe_at() and unsafe_set_at(), both requiring explicit unsafe. The
get_unchecked() and set_unchecked() methods on Vec and ContiguousBuffer
also require explicit unsafe.
What it does not turn off
unsafe is a narrow permission, not a mode. The borrow checker still runs,
values still move rather than copy, and type checking, visibility and constness
are unchanged:
var b = Box(1);
unsafe {
var r: ref Box = b;
var r2: ref Box = b; // ❌ ERROR: cannot borrow 'b' as mutable
}; // because it is already borrowedIt is also lexical: calling a safe Sun function from inside a block does not make that function's body unsafe — which is the point of a safe wrapper. A lambda body is checked on its own, so the block goes inside the lambda:
var g = (x: i32) => i32 { return unsafe { c_abs(x); }; };The useful pattern is to keep unsafe small and behind a safe signature, so
callers get a checked API and there is one place to audit. That is how the
standard library is built: Vec<T>, String and Unique<T> are safe types
whose bodies hold the raw loads and stores. A safe wrapper is a claim, and the
claim is yours — the compiler checks that the block is there, not that what is
inside it is correct.
How the Borrow Checker Works Internally
1. Loan Creation
When ref r = x is encountered:
- Resolve target: If
xis itself a reference, follow the chain to find the actual borrowed variable - Check conflicts: Query
BorrowStatefor active loans on the target variable - Record loan: If allowed, create a
Loanrecord with the current scope depth
// Simplified from borrow_state.cpp
BorrowCheckResult BorrowState::addBorrow(const std::string& borrowedVar,
const std::string& refName,
BorrowKind kind,
size_t scopeDepth,
const SourceLoc& loc) {
auto& varLoans = loans_[borrowedVar];
// Check existing borrows
for (const auto& loan : varLoans) {
if (!loan.isActive) continue;
if (kind == BorrowKind::Mutable) {
// Mutable borrow requires no existing borrows
return BorrowCheckResult::error(
"cannot borrow '" + borrowedVar + "' as mutable because it is already borrowed",
loan);
}
}
// Create the loan
varLoans.push_back(Loan(borrowedVar, refName, kind, scopeDepth, loc));
return BorrowCheckResult::ok();
}2. Scope Exit
When leaving a scope (e.g., end of if block, loop iteration, function):
void BorrowState::exitScope(size_t scopeDepth) {
for (auto& [var, varLoans] : loans_) {
for (auto& loan : varLoans) {
if (loan.isActive && loan.scopeDepth >= scopeDepth) {
loan.isActive = false; // Invalidate the loan
}
}
}
}3. Move Tracking
When assigning a class-typed variable:
void BorrowChecker::checkVariableCreation(const VariableCreationAST& var) {
if (var.get_value() &&
var.get_value()->getType() == ASTNodeType::VARIABLE_REFERENCE) {
const auto& srcRef = static_cast<const VariableReferenceAST&>(*var.get_value());
auto srcType = var.get_value()->getResolvedType();
if (srcType && srcType->isClass()) {
movedVariables_.insert(srcRef.getName()); // Mark as moved
}
}
}Comparison with Rust
| Feature | Rust | Sun |
|---|---|---|
| Explicit lifetimes | Required for complex cases | Never needed |
| References in structs | Yes (with lifetimes) | Yes (object borrows its inputs) |
| Return references | Yes (with lifetimes) | Yes (borrows all by-ref inputs) |
| Mutable borrow exclusivity | Enforced | Enforced |
Shared borrows (&T) | Yes | Yes (const ref T) |
| Move semantics | Yes | Yes (for classes) |
| Use-after-move detection | Yes | Yes |
| Learning curve | Steep | Moderate |
Why These Restrictions?
Sun's borrow checker is intentionally simpler than Rust's:
- No lifetime annotations means easier learning curve and cleaner syntax
- Scoped borrows only eliminates dangling reference bugs
- Returned refs borrow every by-ref input so no lifetime tracking is needed across function boundaries
- Ref-storing classes are borrows - they lock their inputs for the scope and cannot escape it, so no struct lifetime analysis is needed
These restrictions handle ~80% of common use cases while being significantly easier to understand than full lifetime tracking.
Best Practices
Do: Use References for In-Place Modification
function double_array(arr: ref matrix<i32, 10>) void {
for (var i: i32 = 0; i < 10; i = i + 1) {
arr[i] = arr[i] * 2;
}
}Do: Keep Borrows Short-Lived
function process(data: ref i32) void {
// Use data immediately
var result = data * 2;
// Don't hold onto the reference longer than needed
}Do: Use Scopes to Control Borrow Lifetime
function main() i32 {
var x: i32 = 0;
// Scope limits the borrow
if (true) {
ref r = x;
r = 42;
}
// x is free to use again
return x;
}Don't: Store References When a Value Will Do
A class with ref fields borrows whatever it was built from, which locks those variables for the object's whole scope and keeps the object from being returned. Store the value unless you really want a view:
// Locks its constructor argument for the scope, cannot be returned
class Cache {
var cached: ref i32;
}
// ✓ Owns its data, moves freely
class Cache {
var cached: i32;
}Don't: Use Variables After Moving
// ❌ This won't compile
function main() i32 {
var p1 = Point(1, 2);
var p2 = p1;
return p1.x; // Error: p1 was moved
}
// ✓ Use the new owner
function main() i32 {
var p1 = Point(1, 2);
var p2 = p1;
return p2.x; // OK: p2 owns the value
}