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:
- What semantics should structs have? Value (copy on assign) or reference (alias on assign)?
- Where are local variables allocated? Stack or heap?
- How to handle
f(&local_var)whenfmight 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:
- Heap-allocated
- Managed by the garbage collector
- Variables hold references, not values directly
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:
- Value semantics are easier to reason about (no aliasing surprises)
- Aligns with Wado's "explicitness" philosophy
- Move semantics already in the spec provide escape hatch for performance
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:
- Compiler detects when a reference might outlive its scope
- Promotion is transparent to the programmer
- Similar to Go's escape analysis
Rationale:
- Automatic promotion removes burden from programmer
- GC handles the heap-allocated values
- No manual stack/heap decision needed
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, ...]
- Uses
[...](not{...}) to avoid ambiguity with function body - Familiar to C++ developers (lambda capture syntax)
storesis a keyword, not an effect interface- Only
storescan use[...]syntax; regular effects cannot
Naming Rationale: The keyword is stores (not captures) because:
- "Capture" is used in closure semantics (
let f = || x + 1capturesx) storesdescribes what the function does with the reference—it stores it- This avoids conflating two different concepts: closures capturing variables vs functions storing references
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:
- Compiler can determine heap promotion statically
- No runtime checks needed
- Transparent to programmer
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:
- Matches Rust ergonomics for capture inference, with the borrow-checker complexity dropped.
- Aliasing through
&mutcaptures is consistent with Wado's general rule that references are the only aliasing types. - Escape tracking for closures reuses the existing escape-analysis machinery — if a returned closure captures
&local, the local is heap-promoted by the same rules that govern any escaping reference.
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:
struct→record(copied)List<T>→list<T>(copied)String→string(copied)- Resources use explicit
borrow<T>/own<T>
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
- Predictable value semantics: No aliasing surprises with structs
- Automatic heap promotion: Programmer doesn't manage stack vs heap
- Explicit store tracking:
stores[...]makes storage intent clear - Type-safe escaping: Can't accidentally escape references without declaration
- Go-like ergonomics: Escape analysis is familiar pattern
- C++-like syntax:
stores[...]familiar to C++ developers (lambda capture syntax) - Auto-capture by reference for closures: captures share Wado's general reference-aliasing semantics, with
&T/&mut Tkind inferred per binding from body usage (see Closure Implementation); no separate aliasing model needed for closures - CM boundaries protect external calls: No annotation needed for cross-component calls
- Clear terminology: "stores" for function parameters, "captures" for closures
Negative
- Copy overhead: Value semantics may cause unexpected copies for large structs
- Mitigation: Use
movefor large values, profiler will identify hotspots
- Mitigation: Use
- Learning curve:
stores[...]is a new concept- Mitigation: Clear error messages when stores declaration is missing
- Verbose functor types:
fn(&Data) with stores[0]is long- Mitigation: Type inference reduces explicit annotations
- 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":
- Wasm GC uses "reference types" (
ref,ref null) &Tis safe, GC-managed, and non-null (useOption<&T>for nullable)- No pointer arithmetic on references
- Aligns with Rust's terminology for
&T
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:
- Storing enables future effects (stored reference can be mutated later)
- Tracking stores is tracking potential effects
Wado treats stores as a separate mechanism from effects:
- Effects (
with Stdout, FileSystem) = authority to interact with external world - Stores (
with stores[data]) = authority to retain references
Both use the with keyword for consistency, but they are orthogonal concerns.
