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:

  1. Holds a value that is never mutated in place (reassignment replaces the value, it does not modify it)
  2. Can only reference earlier bindings (forward-only)
  3. 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

PatternData FlowCycle Prevention
{ a \n b \n c }Linear sequenceEach step sees only prior bindings
try { a? \n b? \n c? }Linear with early exitSame as blocks
match x { ... }BranchingEach branch is independent
recurse(...)IterationState passed explicitly, no self-reference
parallel(...)Fan-out/fan-inResults 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:

  1. Sequences enforce DAGs — Data flows forward through run/try/match
  2. Value capture prevents closure cycles — No reference back to enclosing scope
  3. Type restrictions prevent structural cycles — Self-referential types forbidden
  4. 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

OperationEffect
Value creationCount = 1
Reference copyCount + 1
Reference dropCount - 1
Count = 0Value 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.

OperationAtomic InstructionMemory Ordering
IncrementFetch-addAcquire
DecrementFetch-subRelease
Deallocation checkFence before freeAcquire

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:

ContextTiming
Local binding out of scopeImmediately at scope end
Last reference droppedImmediately after drop
Field of struct droppedAfter struct destructor
Collection elementWhen 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):

  1. That panic propagates normally
  2. Other values in scope still have their destructors run
  3. Each destructor runs in isolation

If a destructor panics while already unwinding from another panic (double panic):

  1. The program aborts immediately
  2. No further destructors run
  3. 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:

  1. Every reference-counted value is deallocated no later than the end of the scope in which it becomes unreachable
  2. Drop.drop is called exactly once per value, in the order specified by § Destruction Order
  3. No value is accessed after deallocation

The following optimizations are permitted:

OptimizationDescription
Scalar elisionNo reference counting operations for scalar types (see § Type Classification)
Borrow inferenceOmit increment/decrement for parameters that are borrowed and do not outlive the callee
Move optimizationElide the increment/decrement pair when a value is transferred on last use
Redundant pair eliminationRemove an increment immediately followed by a decrement on the same value
Constructor reuseReuse the existing allocation when the reference count is one (requires a runtime uniqueness check)
Seamless slicingSlice 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 inliningSmall values (e.g., short strings) may be stored inline without heap allocation. The threshold is implementation-defined.
Early dropDeallocate 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:

  1. Values are never mutated in place — reassignment produces new values, preventing in-place cycle formation
  2. No shared mutable references — single ownership of mutable data
  3. 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
  • unit and never
  • 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.

TypeClassificationReason
intScalarPrimitive
(int, float, bool)ScalarAll fields scalar
{ x: int, y: int }ScalarAll fields scalar
strReferenceHeap-allocated
{ id: int, name: str }Referencename is reference
Option<str>ReferenceInner type is reference
Option<int>ScalarInner type is scalar
[int]ReferenceList is heap-allocated
Result<int, str>Referencestr 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:

  1. Be redesigned to maintain the invariant
  2. Provide equivalent cycle prevention guarantees
  3. Be rejected