Wado

WEP: Value Semantics and Reference Stores

Context

Wado targets Wasm GC, where structs and arrays are reference types (heap-allocated, garbage-collected). However, the language design needs to decide on the semantics exposed to programmers:

  1. What semantics should structs have? Value (copy on assign) or reference (alias on assign)?
  2. Where are local variables allocated? Stack or heap?
  3. How to handle f(&local_var) when f might store the reference?

Survey of Other Languages

Language Default Semantics Escape Handling
Rust Move by default, Copy trait opt-in Lifetimes track reference validity
Go Value (shallow copy), reference types share underlying data Escape analysis promotes to heap
Swift Value for structs, Copy-on-Write for collections Automatic
Java (Valhalla) New value types: identity-less, copied on assign N/A (value types can't escape)
Zig Compiler chooses pass-by-value or reference Explicit pointers for mutation

The Wasm GC Constraint

In Wasm GC, structs and arrays are reference types:

This means implementing true value semantics requires explicit copying.

The Escape Problem

When a function receives &local_var, the caller needs to know if the function will store that reference:

fn caller() {
    let local = Data{};
    store(&local);  // Will `store` keep a reference to `local`?
}

If store keeps the reference, local must outlive the function call. In a GC'd language, this means local must be on the heap.

Approaches in Effect System Languages

Koka: Uses Perceus reference counting, can prove references don't escape scope via effect analysis.

Eff: Acknowledges escape analysis is undecidable; runtime errors if references escape their handler.

Scala 3 Capture Checking: Tracks captures in the type system with "capture sets":

A -> B        // Pure: does NOT capture parameters
A => B        // Impure: can capture anything
A ->{c,d} B   // Captures only c and d

Decision

1. Structs Have Value Semantics

Structs are copied on assignment, parameter passing, and return by default:

let a = Point { x: 1, y: 2 };
let b = a;  // b is a copy of a
b.x = 10;   // does not affect a

Explicit move transfers ownership without copying:

let b = move a;  // a is invalidated

Rationale:

2. Automatic Heap Promotion

When a reference escapes, the referenced value is automatically heap-promoted. The compiler detects escape through these conditions:

Escape Condition Example
Passed to function with stores[...] store(&local) where store has stores[data]
Returned from function return &local;
Stored in global variable GLOBAL = Some(&local);
Stored in struct field Container { data: &local }
Captured by escaping closure return closure that uses local
fn example() {
    let local = Data{};
    let handle = store(&local);  // local promoted to heap (store has stores[])
}

Heap promotion is automatic based on escape analysis:

Rationale:

Implementation note: Wasm GC structs are semantically heap-allocated. However, the Wado compiler MAY represent non-escaping structs as Wasm locals (decomposed fields) instead of struct.new. This is a compiler optimization, not language semantics.

3. The stores[...] Keyword for Reference Storage

Functions and functors that store references must declare this with stores[...]:

// Function that stores a reference parameter
fn store(data: &Data) -> Handle with stores[data] {
    // can store `data`
}

// Function that does NOT store (no stores declaration)
fn process(data: &Data) -> Result {
    // cannot store `data`, only use it
}

Syntax: with stores[param1, param2, ...]

Naming Rationale: The keyword is stores (not captures) because:

4. Stores Rules

Declaration Stores Behavior
Named function with &T param Must declare stores[param] if storing
Closure using outer variable Closure captures inferred from usage
Functor type (fn(...)) Must declare stores[0] etc. if it stores
Functor value itself No stores needed (functors are value types)

Note on functors: In Wasm, functors are funcref values. Storing a functor itself (not its parameters) does not require stores[...] because functors have value semantics—they are copied when assigned or passed.

Named functions:

fn store(data: &Data) -> Handle with stores[data] { ... }
fn process(data: &Data) -> Result { ... }  // no stores = cannot store

Closures:

// Closure captures inferred from usage
let f = || { return local_var; };
// Inferred type: fn() -> i32 (captures local_var)

// Explicit stores annotation for parameters
let g = |data| stores[data] { ... };

Functor types:

// Must declare stores in type (positional: 0 = first parameter)
fn take_storing(f: fn(&Data) with stores[0]) { ... }
fn take_pure(f: fn(&Data) -> Result) { ... }  // cannot store

5. Heap Promotion Based on Stores

When a reference is passed to something declaring stores[...], the referenced value is automatically heap-promoted:

fn store(data: &Data) -> Handle with stores[data] { ... }

fn caller() {
    let local = Data{};
    let handle = store(&local);  // local automatically heap-promoted
}

Rationale:

6. Closures Capture by Reference

Closures auto-capture each free variable by reference. The compiler infers the reference kind (&T for read-only, &mut T for mutating) from body usage; the closure type is fn if all captures are read-only, fn mut if any are mutating. See Closure Implementation for the full design.

let mut count = 0;
let s = "hello";

let mut inc = || count += 1;     // captures &mut count; type fn mut() -> ()
let get = || count;               // captures &count; type fn() -> i32
let greet = || println(s);        // captures &s; type fn() with Stdout

Multiple closures referring to the same outer binding automatically share state through the underlying location — no explicit &mut foo dance needed:

fn make_counter() -> fn mut() -> i32 {
    let mut count = 0;
    return || {
        count += 1;        // captures &mut count
        return count;
    };
}

The closure value itself follows Wado value semantics: deep-copied on assignment, parameter passing, and return. Because env fields hold reference values (under auto-by-reference), copying a closure copies references that alias — all copies observe the same captured bindings.

Rationale:

Note: Closures use "capture" terminology; the stores[...] keyword is for functions that store reference parameters passed to them. These are separate mechanisms.

7. Heap Promotion of Referents Captured by Closures (Non-Normative)

When a closure escapes its declaring scope (returned, stored in a struct field, etc.), the captured bindings must outlive the closure. The compiler heap-promotes them via the same machinery as any escaping reference (§2 / §5). No closure-specific lifetime tracking is required beyond the existing escape analysis.

8. Edge Cases

Returning a Reference to Local

Allowed. The compiler promotes the local to heap automatically via escape analysis:

fn make_data() -> &Data {
    let local = Data{};
    return &local;  // OK: local promoted to heap
}

The return type &Data from a function that creates the data means "heap-allocated, GC-managed reference." This is different from storing a parameter—no stores[...] declaration is needed because there's no parameter being stored. The compiler detects that local escapes via return and promotes it to heap.

Storing in Globals

Allowed. The referenced value is promoted to heap:

let mut GLOBAL: Option<&Data> = None;

fn store_global(data: &Data) with stores[data] {
    GLOBAL = Some(data);  // OK: data's source promoted to heap
}

Storing in Struct Fields

Requires stores[...] declaration:

struct Container {
    data: &Data,
}

fn make_container(data: &Data) -> Container with stores[data] {
    return Container { data };  // Must declare stores
}

Storing Through Method Calls

The method must declare stores[...]:

impl List<&Data> {
    fn push(&mut self, item: &Data) with stores[item] {
        // stores item
    }
}

fn example(list: &mut List<&Data>, data: &Data) with stores[data] {
    list.push(data);  // Caller must also declare stores
}

Generic Functions

The compiler detects stores through type propagation:

fn apply<T, R>(f: fn(T) -> R, x: T) -> R {
    return f(x);  // apply doesn't store, just passes through
}

// If f's type is fn(&Data) -> R with stores[0],
// compiler traces that x may be stored

If a generic function stores its parameter without declaring stores[...], the compiler detects this and reports an error.

References to Primitives

References to primitives (&i32, &bool, etc.) follow the same rules as references to structs:

fn store_int(x: &i32) with stores[x] {
    SAVED_INT = Some(x);  // OK: stores declared
}

fn use_int(x: &i32) -> i32 {
    return *x + 1;  // OK: no storage, no stores needed
}

Multiple Closures Sharing Mutable State

Closures auto-capture by reference (§6), so two closures naming the same outer variable share the underlying location automatically:

fn multi_share() {
    let mut x = 0;

    let mut inc = || x += 1;          // captures &mut x; type fn mut() -> ()
    let get = || x;                    // captures &x; type fn() -> i32

    inc();
    inc();
    println(get());  // Prints: 2 — both closures observe the same x
}

Each closure's environment holds a reference to x. Because references alias, every read and write lands on the same location. If the closures escape multi_share, x is heap-promoted by the existing escape rules.

9. Component Model Boundaries

The stores[...] mechanism only applies within a Wado component. External Wasm modules are protected by Component Model boundaries:

Boundary Reference Behavior stores[...] Needed?
Within Wado component GC references passed directly Yes
Wado builtins (wasm-bundled) Controlled by Wado project Annotated correctly
External Wasm module (CM) Data copied at boundary No

Why CM boundaries are safe:

At Component Model boundaries, data is copied/serialized:

use {external_fn} from "./foo.wasm" with { type: "wasm" };

fn caller() {
    let local = Data{};
    external_fn(local);  // CM boundary: local is COPIED, not referenced
}

The external component receives a copy, not a GC reference. Even if it "stores" the data, it stores its own copy—the original local is unaffected.

Consequence: stores[...] only needs to track escapes within Wado code. Cross-component calls are automatically safe.

Consequences

Positive

  1. Predictable value semantics: No aliasing surprises with structs
  2. Automatic heap promotion: Programmer doesn't manage stack vs heap
  3. Explicit store tracking: stores[...] makes storage intent clear
  4. Type-safe escaping: Can't accidentally escape references without declaration
  5. Go-like ergonomics: Escape analysis is familiar pattern
  6. C++-like syntax: stores[...] familiar to C++ developers (lambda capture syntax)
  7. Auto-capture by reference for closures: captures share Wado's general reference-aliasing semantics, with &T / &mut T kind inferred per binding from body usage (see Closure Implementation); no separate aliasing model needed for closures
  8. CM boundaries protect external calls: No annotation needed for cross-component calls
  9. Clear terminology: "stores" for function parameters, "captures" for closures

Negative

  1. Copy overhead: Value semantics may cause unexpected copies for large structs
    • Mitigation: Use move for large values, profiler will identify hotspots
  2. Learning curve: stores[...] is a new concept
    • Mitigation: Clear error messages when stores declaration is missing
  3. Verbose functor types: fn(&Data) with stores[0] is long
    • Mitigation: Type inference reduces explicit annotations
  4. Different from Rust: No lifetimes, different model
    • Mitigation: Simpler model is easier to learn

Examples

Basic value semantics:

struct Point { x: i32, y: i32 }

let a = Point { x: 1, y: 2 };
let b = a;      // copy
let c = move a; // move, `a` invalidated

Function with stores:

// Storing a functor: no stores needed (functors are value types)
fn register_callback(cb: fn(&Event)) -> Id {
    callbacks.push(cb);  // OK: cb is a funcref, copied by value
    return new_id();
}

// Functor that stores its parameter
fn register_storing_callback(cb: fn(&Event) with stores[0]) -> Id {
    // cb may store references passed to it
    callbacks.push(cb);
    return new_id();
}

fn process_once(cb: fn(&Event)) {
    cb(&event);  // uses but doesn't store
}

Closure capture inference:

fn create_adder(x: i32) -> fn(i32) -> i32 {
    return |y| { return x + y; };  // closure captures x (inferred)
}

Mixed with effects:

fn store_and_log(data: &Data) -> Handle with Stdout, stores[data] {
    println("Storing data...");
    return create_handle(data);
}

Terminology: Reference vs Pointer

Wado uses "reference" for &T, not "pointer":

Type Term Built-in Characteristics
&T Reference Yes (language) GC-managed, non-null, no arithmetic
LinearPtr<T> Pointer No (library) Linear memory, nullable, arithmetic allowed

Rationale for "reference":

LinearPtr<T> for FFI:

For interop with bundled Wasm functions that use linear memory (e.g., f64_to_buffer), a library type LinearPtr<T> wraps i32 offsets:

use {LinearPtr, linear_alloc, linear_free} from "core:memory";

fn format_float(value: f64) -> String {
    let ptr: LinearPtr<u8> = linear_alloc(64);
    f64_to_buffer(value, ptr);
    let result = read_string_from_linear(ptr);
    linear_free(ptr);
    return result;
}

This keeps the core language clean while providing escape hatch for low-level FFI.

Theoretical Relationship: Stores and Effects

Is storing an effect?

Traditional effect systems (I/O, State, Exception) treat effects as "what the function DOES." Storing is about "what the function RETAINS."

However, in capability-based systems, storing is closely related to effects:

Wado treats stores as a separate mechanism from effects:

Both use the with keyword for consistency, but they are orthogonal concerns.

References