Memory Safety

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:

  1. Value Types: Primitives and classes are value types, allocated on the stack by default
  2. Move Semantics: Class-typed variables transfer ownership when assigned to another variable
  3. 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 42

Classes

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 false

Either 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 -> A

for (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:

ComponentResponsibility
BorrowCheckerAST traversal, rule enforcement, error collection
BorrowStateTracks active loans per variable, validates borrow requests
LoanRepresents 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 borrowed

The 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 frame

The 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 scope

Two 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 statement

A 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 method or 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> where T is a class. Nothing says the pointer is non-null, aligned, or still pointing at a live T.
  • 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 borrowed

It 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:

  1. Resolve target: If x is itself a reference, follow the chain to find the actual borrowed variable
  2. Check conflicts: Query BorrowState for active loans on the target variable
  3. Record loan: If allowed, create a Loan record 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

FeatureRustSun
Explicit lifetimesRequired for complex casesNever needed
References in structsYes (with lifetimes)Yes (object borrows its inputs)
Return referencesYes (with lifetimes)Yes (borrows all by-ref inputs)
Mutable borrow exclusivityEnforcedEnforced
Shared borrows (&T)YesYes (const ref T)
Move semanticsYesYes (for classes)
Use-after-move detectionYesYes
Learning curveSteepModerate

Why These Restrictions?

Sun's borrow checker is intentionally simpler than Rust's:

  1. No lifetime annotations means easier learning curve and cleaner syntax
  2. Scoped borrows only eliminates dangling reference bugs
  3. Returned refs borrow every by-ref input so no lifetime tracking is needed across function boundaries
  4. 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
}