21 Memory model
Ori uses Automatic Reference Counting (ARC) without cycle detection. This is made possible by language design choices that structurally prevent reference cycles.
21.1 Why pure ARC works
Most languages using ARC require either cycle detection (Python, PHP) or manual weak reference annotations (Swift, Objective-C). Ori requires neither because its execution model produces directed acyclic graphs (DAGs) by construction.
21.1.1 The problem: object graphs
Traditional object-oriented languages build reference graphs where objects hold references to other objects. Cycles form naturally:
┌──────────┐
│ Parent │
└────┬─────┘
│ children
▼
┌──────────┐
│ Child │──── parent ───┐
└──────────┘ │
▲ │
└─────────────────────┘
Common cycle sources in other languages:
- Closures capturing
self/this - Parent-child bidirectional references
- Observer/delegate callback patterns
- Event emitter subscriptions
21.1.2 The solution: sequential data flow
Ori’s function sequences (run, try, match) enforce linear data flow:
input ──▶ step A ──▶ step B ──▶ step C ──▶ output
Each binding in a sequence:
- Holds a value that is never mutated in place (reassignment replaces the value, it does not modify it)
- Can only reference earlier bindings (forward-only)
- Is destroyed when the sequence ends
@process (input: Data) -> Result<Output, Error> = try {
let validated = validate(data: input)?; // A: sees input
let enriched = enrich(data: validated)?; // B: sees input, validated
let saved = save(data: enriched)?; // C: sees input, validated, enriched
Ok(saved)
}
There is no mechanism for saved to reference the function process, or for enriched and validated to reference each other bidirectionally. Data flows forward through transformations.
21.1.3 Structural guarantees
| Pattern | Data Flow | Cycle Prevention |
|---|---|---|
{ a \n b \n c } | Linear sequence | Each step sees only prior bindings |
try { a? \n b? \n c? } | Linear with early exit | Same as blocks |
match x { ... } | Branching | Each branch is independent |
recurse(...) | Iteration | State passed explicitly, no self-reference |
parallel(...) | Fan-out/fan-in | Results collected, no cross-task references |
21.1.4 Closures capture by value
In languages where closures capture by reference, cycles form when a closure captures self:
Object ──▶ callback field ──▶ closure ──▶ captured self ──▶ Object
Ori closures capture by value. The closure receives a copy of captured data, not a reference back to the containing scope:
let x = 5;
let f = () -> x + 1; // f contains a copy of 5, not a reference to x
This eliminates the most common source of cycles in functional-style code.
21.1.5 Closure representation
A closure is represented as a struct containing captured values:
let x = 10;
let y = "hello";
let f = () -> `{y}: {x}`;
// f is approximately:
// type _Closure_f = { captured_x: int, captured_y: str }
For reference-counted types (lists, maps, custom types), the closure stores the reference (incrementing the reference count), not a deep copy of the data.
21.1.6 Self-referential types forbidden
The one place cycles could form is in user-defined recursive types:
// Compile error: self-referential type
type Node = { next: Option<Node> }
If permitted, this would allow:
node1.next = Some(node2)
node2.next = Some(node1) // cycle
Ori forbids this at the type level. Recursive structures use indices into collections:
// Valid: indices for relationships
type Graph = { nodes: [NodeData], edges: [(int, int)] }
21.1.7 Summary
Pure ARC works in Ori because:
- Sequences enforce DAGs — Data flows forward through
run/try/match - Value capture prevents closure cycles — No reference back to enclosing scope
- Type restrictions prevent structural cycles — Self-referential types forbidden
- No shared mutable references — Single ownership of mutable data
These are not conventions — they are language invariants enforced by the compiler.
21.2 Reference counting
| Operation | Effect |
|---|---|
| Value creation | Count = 1 |
| Reference copy | Count + 1 |
| Reference drop | Count - 1 |
| Count = 0 | Value destroyed |
References are copied on assignment, argument passing, field storage, return, closure capture.
References are dropped when variables go out of scope or are reassigned.
21.2.1 Atomicity
All reference count operations are atomic. This ensures correct deallocation when values are shared across concurrent tasks.
| Operation | Atomic Instruction | Memory Ordering |
|---|---|---|
| Increment | Fetch-add | Acquire |
| Decrement | Fetch-sub | Release |
| Deallocation check | Fence before free | Acquire |
The acquire fence before deallocation ensures the deallocating task observes all prior writes to the object from other tasks.
The observable behavior shall be identical regardless of whether atomic or non-atomic operations are used.
NOTE An implementation may use non-atomic operations for values that provably do not escape the current task. This is an optimization; the observable behavior is identical.
21.3 Destruction
Destruction occurs when values become unreachable, no later than scope end.
21.3.1 The Drop trait
The Drop trait enables custom destruction logic:
trait Drop {
@drop (self) -> void;
}
When a value’s reference count reaches zero, its Drop.drop method is called if implemented. Drop is called before memory is reclaimed.
Drop is included in the prelude.
21.3.2 Destructor timing
Destructors run when reference count reaches zero:
| Context | Timing |
|---|---|
| Local binding out of scope | Immediately at scope end |
| Last reference dropped | Immediately after drop |
| Field of struct dropped | After struct destructor |
| Collection element | When removed or collection dropped |
Values may be dropped before scope end if no longer referenced (compiler optimization).
21.3.3 Destruction order
Reverse creation order within a scope:
{
let a = create_a(); // Destroyed 3rd
let b = create_b(); // Destroyed 2nd
let c = create_c(); // Destroyed 1st
// destroyed: c, b, a
}
Struct fields are destroyed in reverse declaration order:
type Container = {
first: Resource, // Destroyed 3rd
second: Resource, // Destroyed 2nd
third: Resource, // Destroyed 1st
}
List elements are destroyed back-to-front:
let items = [a, b, c];
// When dropped: c, then b, then a
Tuple elements are destroyed right-to-left:
let tuple = (first, second, third);
// When dropped: third, then second, then first
Map entries have no guaranteed destruction order (hash-based).
21.3.4 Panic during destruction
If a destructor panics during normal execution (not already unwinding):
- That panic propagates normally
- Other values in scope still have their destructors run
- Each destructor runs in isolation
If a destructor panics while already unwinding from another panic (double panic):
- The program aborts immediately
- No further destructors run
- Exit code indicates abnormal termination
21.3.5 Async destructors
Destructors cannot be async:
impl Resource: Drop {
@drop (self) -> void uses Suspend = ...; // ERROR: drop cannot be async
}
For async cleanup, use explicit methods:
impl AsyncResource {
@close (self) -> void uses Suspend = ...; // Explicit async cleanup
}
impl AsyncResource: Drop {
@drop (self) -> void = (); // Synchronous no-op
}
21.3.6 Destructors and task cancellation
When a task is cancelled, destructors still run during unwinding.
21.4 Reference counting optimizations
An implementation may optimize reference counting operations provided the following observable behavior is preserved:
- Every reference-counted value is deallocated no later than the end of the scope in which it becomes unreachable
Drop.dropis called exactly once per value, in the order specified by § Destruction Order- No value is accessed after deallocation
The following optimizations are permitted:
| Optimization | Description |
|---|---|
| Scalar elision | No reference counting operations for scalar types (see § Type Classification) |
| Borrow inference | Omit increment/decrement for parameters that are borrowed and do not outlive the callee |
| Move optimization | Elide the increment/decrement pair when a value is transferred on last use |
| Redundant pair elimination | Remove an increment immediately followed by a decrement on the same value |
| Constructor reuse | Reuse the existing allocation when the reference count is one (requires a runtime uniqueness check) |
| Seamless slicing | Slice operations (take, skip, slice, substring, trim) may return a zero-copy view into the original allocation rather than copying elements. The view shares the original allocation’s reference count. |
| Small value inlining | Small values (e.g., short strings) may be stored inline without heap allocation. The threshold is implementation-defined. |
| Early drop | Deallocate a value before scope end when it is provably unreferenced for the remainder of the scope |
These are permissions, not requirements. A conforming implementation may perform all, some, or none of these optimizations.
NOTE Constructor reuse (copy-on-write) preserves value semantics: let b = a; b = b.push(x) shall not modify a, regardless of whether the implementation copies or mutates in place. The optimization is transparent to user code.
21.5 Ownership and borrowing
Every reference-counted value has exactly one owner. The owner is the binding, field, or container element that holds the value.
21.5.1 Ownership transfer
Ownership transfers on:
- Assignment to a new binding
- Passing as a function argument
- Returning from a function
- Storage in a container element or struct field
On transfer, the previous owner relinquishes access. The reference count does not change; ownership moves without an increment/decrement pair.
21.5.2 Borrowed references
A borrowed reference provides temporary read access to a value without incrementing the reference count. A borrowed reference shall not outlive its owner.
The compiler infers ownership and borrowing. There is no user-visible syntax for ownership annotations or borrow markers.
21.6 Cycle prevention
Cycles prevented at compile time:
- Values are never mutated in place — reassignment produces new values, preventing in-place cycle formation
- No shared mutable references — single ownership of mutable data
- Self-referential types forbidden
// Valid: indices
type Graph = { nodes: [Node], edges: [(int, int)] }
// Error: self-referential
type Node = { next: Option<Node> } // compile error
21.7 Type classification
Every type is classified as either scalar or reference for the purpose of reference counting. Classification is determined by type containment, not by representation size.
21.7.1 Scalar types
A type is scalar if it requires no reference counting. The following types are scalar:
- Primitive types:
int,float,bool,char,byte,Duration,Size,Ordering unitandnever- Compound types (structs, enums, tuples,
Option<T>,Result<T, E>,Range<T>) whose fields are all scalar
21.7.2 Reference types
A type is a reference type if it requires reference counting. The following types are reference types:
- Heap-allocated types:
str,[T],{K: V},Set<T>,Channel<T> - Function types and iterator types
- Compound types containing at least one reference type field
21.7.3 Transitive rule
Classification is transitive: if any field of a compound type is a reference type, the compound type is a reference type.
| Type | Classification | Reason |
|---|---|---|
int | Scalar | Primitive |
(int, float, bool) | Scalar | All fields scalar |
{ x: int, y: int } | Scalar | All fields scalar |
str | Reference | Heap-allocated |
{ id: int, name: str } | Reference | name is reference |
Option<str> | Reference | Inner type is reference |
Option<int> | Scalar | Inner type is scalar |
[int] | Reference | List is heap-allocated |
Result<int, str> | Reference | str is reference |
Classification is independent of type size. A struct with ten int fields is scalar. A struct with one str field is a reference type regardless of its total size.
21.7.4 Generic type parameters
Unresolved type parameters are conservatively treated as reference types. After monomorphization, all type parameters are concrete and classification is exact.
21.8 Constraints
- Self-referential types are compile errors
- Destruction in reverse creation order
- Values destroyed when reference count reaches zero
21.9 ARC safety invariants
Ori uses ARC without cycle detection. The following invariants shall be maintained by all language features to ensure ARC remains viable.
21.9.1 Invariant 1: value capture
Closures shall capture variables by value. Reference captures are prohibited.
let x = 5;
let f = () -> x + 1; // captures copy of x, not reference to x
This prevents cycles through closure environments.
21.9.2 Invariant 2: no implicit back-references
Structures shall not implicitly reference their containers. Bidirectional relationships require explicit weak references or indices.
// Valid: indices for back-navigation
type Tree = { nodes: [Node], parent_indices: [Option<int>] }
// Invalid: implicit parent reference would create cycle
type Node = { children: [Node], parent: Node } // error
21.9.3 Invariant 3: no shared mutable references
Multiple mutable references to the same value are prohibited. Shared access requires either:
- Copy-on-write semantics
- Explicit synchronization primitives with single ownership
21.9.4 Invariant 4: value semantics default
Types have value semantics unless explicitly boxed. Reference types require explicit opt-in through container types or Box<T>.
21.9.5 Invariant 5: explicit weak references
If weak references are added to the language, they shall:
- Use distinct syntax (
Weak<T>) - Require explicit upgrade operations returning
Option<T> - Never be implicitly created
21.9.6 Task isolation
Values shared across task boundaries are reference-counted. Each task may independently increment and decrement the reference count of a shared value. Atomic reference count operations (see § Atomicity) ensure that deallocation occurs exactly once, regardless of which task drops the last reference.
A task shall not hold a borrowed reference to a value owned by another task. All cross-task value sharing uses ownership transfer or reference count increment.
See Concurrency Model § Task Isolation for task isolation rules.
21.9.7 Handler frame state
Stateful handlers (see Capabilities § Stateful Handlers) maintain frame-local mutable state within a with...in scope. This state is analogous to mutable loop variables: it is local to the handler frame, not aliased, and not accessible outside the with...in scope. Handler frame state does not violate Invariant 3 (no shared mutable references) because the state has a single owner (the handler frame) and is never shared.
21.9.8 Feature evaluation
New language features shall be evaluated against these invariants. A feature that violates any invariant shall either:
- Be redesigned to maintain the invariant
- Provide equivalent cycle prevention guarantees
- Be rejected