Sakura Lang

Memory model

Value semantics, var rebinding, compiler-inserted reference counting with Perceus reuse, cycles through Graph, scope-bound resources, and backends in Sakura Lang.

Pre-alpha

The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.

The compiler owns memory. There are no lifetimes, no borrow annotations, no Rc/Arc, no pointers, and no reference cells in user code. The price is a restricted data model: no aliasable mutation, no cycles in the heap, and explicit scopes for resources.

Value semantics

Every value behaves as an independent value. Assigning, passing, capturing, and returning never create an observable alias. Records, variants, lists, maps, and strings are deeply immutable from the program's point of view. Physical sharing is an optimization the program cannot detect.

let bindings are immutable. A var binding can be reassigned, which changes the binding and nothing else:

type User = { id: Int, name: String, handle: String }

fn rename(u: User, name: String) -> User {
  var v = u
  v.name = name          // sugar for v = User { ..v, name: name }
  v
}

test "updates never alias" {
  let a = User { id: 1, name: "Ada", handle: "ada" }
  let b = rename(a, "Ada L.")
  expect_eq(a.name, "Ada")
  expect_eq(b.name, "Ada L.")
}

Collections grow the same way: xs = List.push(xs, 1). List.push returns a new list, so calling it as a statement and dropping the result is error V2040, which catches the forgotten reassignment:

fn evens(n: Int) -> List<Int> {
  var xs: List<Int> = []
  for i in 0..n {
    if i % 2 == 0 { xs = List.push(xs, i) }   // List.push(xs, i) alone would be V2040
  }
  xs
}

Closures capture values

A closure captures var bindings by value at creation. It cannot assign to an outer var (V2041); accumulate with a var in the same function body, or with List.fold. A try body follows the same rule: it may stop at any failing call, so it cannot assign to a var declared outside it either (V2041). Assign after the try instead: let v = try { … } catch { … } and then total = total + v.

test "closures see the value at creation" {
  var greeting = "hello"
  let say = fn() { greeting }
  greeting = "bye"
  expect_eq(say(), "hello")
}

The only stateful constructs are handler var state and Graph, and both are confined. Shared mutable state, such as a counter or a cache, lives in a handler instance:

effect Counter { fn next() -> Int }

fn counter() -> Handler<Counter> {
  handler Counter {
    var n = 0
    fn next() { n = n + 1; n }
  }
}

The state is private to the instance, initialized once per with, and released when the with exits. See Handlers.

Reference counting and reuse

With no aliasable mutation and strict evaluation, the heap is an acyclic graph: a value can only reference values that existed before it. That makes reference counting complete without a cycle collector. The plan for native and WASM (design §8.2):

  • Non-atomic counts per task. The compiler inserts dup/drop after Core lowering (Perceus). Values sent across worker threads are promoted to atomic counts on send.
  • Same-constructor reuse. When a value is uniquely referenced, an update or a match that rebuilds a value of the same shape reuses its memory.
  • Copy-on-write buffers for List, Map, and String. A var xs whose last use is List.push(xs, v) appends in place when the count is one, which gives amortized O(1) appends without exposing uniqueness in the language.
type Tree = Leaf | Node(Tree, Int, Tree)

fn increment(t: Tree) -> Tree {
  match t {
    Leaf => Leaf
    Node(l, v, r) => Node(increment(l), v + 1, increment(r))
  }
}

If the caller passes its last reference to t, native code can rebuild each Node in the cell it just took apart. If the tree is still used elsewhere, the update copies and the original stays intact. Either way the program's behavior is identical; reuse is never observable.

When an update in a loop is forced to copy because an older value is still live, the compiler emits warning W8001 copy_on_write, pointing at the use that retains it.

The JavaScript backend ignores all of this and uses the host's garbage collector. Semantics are the same on every backend.

Cycles: use Graph

Reference counting cannot free cycles, and Sakura Lang's data model cannot build them. Cyclic structures use std/graph: a Graph<N> owns a table of nodes, and edges are NodeIds (slot plus generation) validated against the graph. There are no weak pointers in v0.

import std/graph.{Graph, NodeId}

type Person = { name: String }

fn befriend(g: Graph<Person>, a: Person, b: Person) -> (Graph<Person>, NodeId, NodeId) {
  let (g1, ia) = Graph.insert(g, a)
  let (g2, ib) = Graph.insert(g1, b)
  (Graph.connect(Graph.connect(g2, ia, ib), ib, ia), ia, ib)
}

ia and ib refer to each other through the graph, but no value points to itself. Graph.get(g, id) returns Option<N>, so a stale id is a None, not a dangling pointer.

Resources

Files, sockets, database connections, HTTP bodies, and fibers are resource types: external types, declared without a body, whose representation belongs to the host. They are affine (used at most once as a whole), are not Eq, Show, or Schema, and are bound to a scope provided by their effect:

effect Fs {
  fn with_file<T, e>(path: Path, mode: Mode, body: fn(File) -> T ! e) -> T ! {Fail<FsError> | e}
}

The excerpt above is from the std Fs declaration (design §10.2). Sql.transaction(fn() { … }) follows the same pattern. The closure's result may not contain the resource (V6003, the same escape check that keeps capabilities in scope). Resources are released on normal exit, fail, defect, and cancellation, in reverse order of acquisition. A failed release is logged as a defect, never ignored.

Regions

Not in v0. The region keyword is reserved. Request-scoped arenas may arrive with the native backend as an internal optimization first, and become user-visible only if measurements justify it.

Concurrency and determinism

There is no shared mutable memory, so tasks cannot race on data. Tasks communicate through their results (Task.join, Task.all, Task.race) and through handler state, whose clauses run one at a time per instance. There is no channel type in v0. Evaluation is strict and left to right, arguments before the call, and Map/Set iterate in insertion order on every backend. See Concurrency.

Backends

BackendMilestoneMemory management
JavaScript (ES modules, trampolined CPS)first, M0 onwardhost GC
WebAssembly (wasm-encoder, runtime in linear memory)M4non-atomic RC, no WASM GC required
Native (Cranelift)M5Perceus RC with reuse

In v0 the only build target is JavaScript:

sakura build --target js

Costs and limits

  • Reference counting costs something on every dup and drop. Reuse is what makes it competitive; how well it works is unmeasured.
  • Sharing a large value and then updating it copies. W8001 points at loops where this happens.
  • Cyclic data needs Graph and NodeIds instead of direct references.
  • On the JS backend, Int arithmetic and literals beyond ±(2^53−1) are defect D003 until a wider representation lands. Parsing is the exception: String.parse_int returns None for such a value, because parsing is where untrusted input enters.
  • Native handler state uses a per-instance lock. Whether that holds up under parallelism is an open question (design §16).

What the compiler checks

  • There is no syntax for mutation of shared values or pointer identity, so aliasing bugs cannot be written.
  • A dropped non-Unit result is an error (V2040), which catches List.push(xs, v) without reassignment.
  • Neither a lambda nor a try body can assign an outer var (V2041).
  • A resource cannot escape its scope (V6003).
  • Reference counting is inserted automatically and is never visible in source. Reuse is an optimization only.

The planned WASM runtime in M4 uses conservative reference counting with explicit ownership. Borrow inference and in-place Perceus reuse remain M5; current execution uses JavaScript GC.

Next

Concurrency: structured tasks, cancellation, timeouts, and retries.

Edit on GitHub

On this page