Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running a program

The previous chapters showed how the type checker verifies that a program is well-formed. But checking types is only half the story – we also want to run programs. The interpreter takes a type-checked program and evaluates it, producing a result.

Here is a simple program that creates a Point and returns it:

interp_point_example [src]
crate::assert_interpret!(
    {
        class Point {
            x: Int;
            y: Int;
        }

        class Main {
            fn main(given self) -> Point {
                let p = new Point(22, 44);
                p.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let p = new Point (22, 44) ;
        Output: Trace:   p = Point { x: 22, y: 44 }
        Output: Trace:   p . give ;
        Output: Trace: exit Main.main => Point { x: 22, y: 44 }
        Result: Ok: Point { x: 22, y: 44 }
        Alloc 0x06: [Int(22), Int(44)]"#]]
);

The interpreter starts by creating a Main() instance and calling its main method. The method creates a Point, gives it away as the return value, and the interpreter displays the result: Point { flag: Given, x: 22, y: 44 }. The flag: Given tells us this is a uniquely owned value.

The memory model

The interpreter models memory as a collection of allocations. Each allocation is a flat array of words – there are no pointers between fields, no type tags in memory, and no named field maps. This mirrors how a real machine represents values.

An Alloc is a flat vector of words:

Alloc [src]
/// A flat array of words representing a value in memory.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Alloc {
    data: Vec<Word>,
}

Each word is one of:

Word [src]
/// A single word of memory.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Word {
    Int(i64),
    Flags(Flags),
    Pointer(Pointer),
    MutRef(Pointer),
    RefCount(i64),
    Capacity(usize),
    Uninitialized,
}
  • Int(n) – an integer value.
  • Flags(f) – a permission flag for unique objects.
  • Uninitialized – the slot has been moved or cleared.

The Flags enum tracks the permission state of a unique object:

Flags [src]
/// Permission flag for unique objects.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Flags {
    /// Indicates that this boxed value has been moved or dropped.
    /// Safe to skip during cleanup, no refcount to decrement.
    Dropped,

    /// Indicates that the data here is fully owned.
    Given,

    /// Indicates that the data here has shared ownership.
    Shared,

    /// Indicates that the data here is a borrowed reference.
    Borrowed,
}
  • Given – the value is uniquely owned.
  • Shared – the value has been shared (copyable).
  • Borrowed – the value is a read-only reference copy.
  • Uninitialized – the value has been moved away.

A Pointer identifies a position within an allocation:

Pointer [src]
/// Identifies a position within an allocation.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct Pointer {
    index: usize,
    offset: usize,
}

Object layout

Unique classes (regular class and given class) are laid out with a flags word followed by their fields:

+-------------------+
| Flags(Given)      |   <- flags word
| field 0 words...  |
| field 1 words...  |
| ...               |
+-------------------+

Shared classes (shared class) have no flags word – they are always copyable, so no permission tracking is needed:

+-------------------+
| field 0 words...  |
| field 1 words...  |
| ...               |
+-------------------+

An Int is a single word [Int(n)]. A unit value () is an empty allocation (zero words).

Types flow through evaluation, not memory

The interpreter does not store type information in allocations. Memory is just words – the type exists in the evaluator’s head. A TypedValue pairs a pointer with the type needed to interpret it:

TypedValue [src]
/// A pointer paired with the type needed to interpret the words.
/// For boxed types (e.g., arrays, mut-refs), this will be a pointer to a pointer.
/// For inline objects, this will be a pointer to the object data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectValue {
    pointer: Pointer,
    ty: Ty,
}

The stack frame maps variables to TypedValues, so we always know both where a value lives and what type it is:

StackFrame [src]
pub struct StackFrame {
    env: Env,
    variables: Map<Var, Pointer>,
}

The interpreter and stack frames

The interpreter holds a reference to the program, a type system environment (used to check whether types are copyable), and the collection of allocations:

Interpreter [src]
pub struct Interpreter<'a> {
    program: &'a Program,
    allocs: Vec<Alloc>,
    output: String,
    indent: usize,
}

Each method call creates a StackFrame that maps variable names to typed value pointers.

Walking through evaluation

Let’s trace through the example above step by step.

Entry point

The interpreter begins by instantiating Main() – a unique class with no fields, so its allocation is just a flags word – then calling main on it. The stack frame for main starts with self bound to the Main allocation:

allocs: [ [Flags(Given)] ]
stack:  { self -> (alloc 0, Main) }

let p = new Point(22, 44)

The new expression evaluates each field argument (creating temporary allocations for each integer), then builds a flat allocation for the Point:

allocs: [ [Flags(Given)],     <- Main (alloc 0)
          [Int(22)],           <- temp for 22 (alloc 1)
          [Int(44)],           <- temp for 44 (alloc 2)
          [Flags(Given), Int(22), Int(44)] ]  <- Point (alloc 3)
stack:  { self -> (alloc 0, Main), p -> (alloc 3, Point) }

Alloc 3 holds a Point with its flags word at offset 0, x at offset 1, and y at offset 2. To access p.x, the interpreter uses the type Point to compute that field x starts at offset 1.

p.give

The give access mode copies the words to a new allocation and marks the source’s flags as Uninitialized. Since p is the last statement, this is the return value:

allocs: [ ...,
          [Flags(Uninitialized), Int(22), Int(44)],  <- alloc 3 (moved)
          [Flags(Given), Int(22), Int(44)] ]          <- alloc 4 (copy)

The method returns alloc 4 – a fresh Point with copied words. Displayed: Point { flag: Given, x: 22, y: 44 }.

Arithmetic

The interpreter supports integer arithmetic:

interp_arithmetic [src]
crate::assert_interpret!(
    {
        class Main {
            fn main(given self) -> Int {
                let x = 10;
                let y = 20;
                x.give + y.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let x = 10 ;
        Output: Trace:   x = 10
        Output: Trace:   let y = 20 ;
        Output: Trace:   y = 20
        Output: Trace:   x . give + y . give ;
        Output: Trace: exit Main.main => 30
        Result: Ok: 30
        Alloc 0x08: [Int(30)]"#]]
);

Method calls

Methods can call other methods on objects they receive. The interpreter uses the receiver’s type (not the memory contents) to resolve which class and method to call, creates a new stack frame, and evaluates the body:

interp_method_calls [src]
crate::assert_interpret!(
    {
        class Adder {
            a: Int;
            b: Int;

            fn sum(given self) -> Int {
                self.a.give + self.b.give;
            }
        }

        class Main {
            fn main(given self) -> Int {
                let adder = new Adder(3, 4);
                adder.give.sum();
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let adder = new Adder (3, 4) ;
        Output: Trace:   adder = Adder { a: 3, b: 4 }
        Output: Trace:   adder . give . sum () ;
        Output: Trace:   enter Adder.sum
        Output: Trace:     self . a . give + self . b . give ;
        Output: Trace:   exit Adder.sum => 7
        Output: Trace: exit Main.main => 7
        Result: Ok: 7
        Alloc 0x0a: [Int(7)]"#]]
);

When the interpreter encounters adder.give.sum(), it first evaluates the receiver adder.give – copying the Adder’s words to a new allocation. Then it uses the type Adder to look up sum, creates a stack frame with self bound to the copied adder, and evaluates the body.

Access modes at runtime

The type checker verifies that access modes are used correctly. The interpreter executes them – but the behavior depends on the flags of the source value. Each place operation begins by reading the source’s flags word (if the type has one) and dispatching on it.

If a place expression traverses through a field whose object has Uninitialized flags, the interpreter faults immediately. Similarly, applying any place operation directly to an Uninitialized value is a fault. The type checker prevents these cases in well-typed programs, but faulting at runtime makes it possible to fuzz the type checker for soundness bugs.

Give

give copies the value’s words to a new allocation. What happens next depends on the source’s flags:

Source flagsBehavior
GivenCopy fields, mark source Uninitialized
SharedCopy fields with flag Shared, apply share operation
BorrowedCopy fields with flag Borrowed
UninitializedInterpreter fault (the type checker prevents this)

Giving a Given value transfers ownership – the source becomes dead:

interp_give_given [src]
crate::assert_interpret!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let d = new Data(42);
                d.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let d = new Data (42) ;
        Output: Trace:   d = Data { x: 42 }
        Output: Trace:   d . give ;
        Output: Trace: exit Main.main => Data { x: 42 }
        Result: Ok: Data { x: 42 }
        Alloc 0x05: [Int(42)]"#]]
);

Giving a Shared value produces a shared copy – and since shared values are copyable, the source remains usable:

interp_give_shared [src]
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let d = new Data(42);
                let s = d.give.share;
                let x1 = s.give;
                let x2 = s.give;
                print(x1.give);
                x2.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let d = new Data (42) ;
        Output: Trace:   d = Data { x: 42 }
        Output: Trace:   let s = d . give . share ;
        Output: Trace:   s = shared Data { x: 42 }
        Output: Trace:   let x1 = s . give ;
        Output: Trace:   x1 = shared Data { x: 42 }
        Output: Trace:   let x2 = s . give ;
        Output: Trace:   x2 = shared Data { x: 42 }
        Output: Trace:   print(x1 . give) ;
        Output: shared Data { x: 42 }
        Output: Trace:   x2 . give ;
        Output: Trace: exit Main.main => shared Data { x: 42 }
        Result: Ok: shared Data { x: 42 }
        Alloc 0x0d: [Int(42)]"#]]
);

Ref

ref creates a read-only copy. The behavior depends on the source’s flags:

Source flagsBehavior
GivenCopy fields with flag Borrowed
SharedCopy fields with flag Shared, apply share operation
BorrowedCopy fields with flag Borrowed

A ref from a Given source creates a Borrowed copy while leaving the original intact:

interp_ref_given [src]
crate::assert_interpret!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let d = new Data(42);
                print(d.ref);
                d.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let d = new Data (42) ;
        Output: Trace:   d = Data { x: 42 }
        Output: Trace:   print(d . ref) ;
        Output: ref [d] Data { x: 42 }
        Output: Trace:   d . give ;
        Output: Trace: exit Main.main => Data { x: 42 }
        Result: Ok: Data { x: 42 }
        Alloc 0x07: [Int(42)]"#]]
);

A ref from a Shared source stays Shared – shared permission is “stickier” than borrowed:

interp_ref_shared [src]
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let d = new Data(42);
                let s = d.give.share;
                s.ref;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let d = new Data (42) ;
        Output: Trace:   d = Data { x: 42 }
        Output: Trace:   let s = d . give . share ;
        Output: Trace:   s = shared Data { x: 42 }
        Output: Trace:   s . ref ;
        Output: Trace: exit Main.main => shared Data { x: 42 }
        Result: Ok: shared Data { x: 42 }
        Alloc 0x07: [Int(42)]"#]]
);

Share

share is a value operation, not a place operation. To share a place, you first give it and then share the result: d.give.share.

The share operation converts a value from unique to shared ownership in place. If the flags are Given, it sets them to Shared and recursively applies the share operation to nested class fields. If already Shared or Borrowed, it’s a no-op:

interp_share_recursive [src]
crate::assert_interpret_only!(
    {
        class Inner { x: Int; }
        class Outer { inner: Inner; }
        class Main {
            fn main(given self) -> Outer {
                let o = new Outer(new Inner(1));
                o.give.share;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let o = new Outer (new Inner (1)) ;
        Output: Trace:   o = Outer { inner: Inner { x: 1 } }
        Output: Trace:   o . give . share ;
        Output: Trace: exit Main.main => shared Outer { inner: Inner { x: 1 } }
        Result: Ok: shared Outer { inner: Inner { x: 1 } }
        Alloc 0x06: [Int(1)]"#]]
);

The share operation is recursive – when sharing an Outer, its Given inner field is also set to Shared.

Drop

drop releases ownership of a value. The behavior depends on the source’s flags:

Source flagsBehavior
GivenRecursively drop fields, mark Uninitialized
SharedApply “drop shared” operation (recursive)
BorrowedNo-op

Dropping a Given value recursively uninitializes it and its fields. Dropping a Borrowed value is a no-op – you can continue using the borrow afterward:

interp_drop_borrowed_noop [src]
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let d = new Data(42);
                let r = d.ref;
                r.drop;
                r.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let d = new Data (42) ;
        Output: Trace:   d = Data { x: 42 }
        Output: Trace:   let r = d . ref ;
        Output: Trace:   r = ref [d] Data { x: 42 }
        Output: Trace:   r . drop ;
        Output: Trace:   r . give ;
        Output: Trace: exit Main.main => ref [d] Data { x: 42 }
        Result: Ok: ref [d] Data { x: 42 }
        Alloc 0x08: [Int(42)]"#]]
);

Mut

mut creates a mutable reference. It is not yet implemented in the interpreter.

Conditionals

The if expression evaluates a condition and executes one of two branches. The interpreter treats 0 as false and any other integer as true. Since if returns unit, we use assignment to communicate a result out:

interp_conditional_true [src]
crate::assert_interpret!(
    {
        class Main {
            fn main(given self) -> Int {
                let result = 0;
                if 1 { result = 42; } else { result = 0; };
                result.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let result = 0 ;
        Output: Trace:   result = 0
        Output: Trace:   if 1 { result = 42 ; } else { result = 0 ; } ;
        Output: Trace:   result = 42 ;
        Output: Trace:   result = 42
        Output: Trace:   result . give ;
        Output: Trace: exit Main.main => 42
        Result: Ok: 42
        Alloc 0x08: [Int(42)]"#]]
);
interp_conditional_false [src]
crate::assert_interpret!(
    {
        class Main {
            fn main(given self) -> Int {
                let result = 0;
                if 0 { result = 42; } else { result = 99; };
                result.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let result = 0 ;
        Output: Trace:   result = 0
        Output: Trace:   if 0 { result = 42 ; } else { result = 99 ; } ;
        Output: Trace:   result = 99 ;
        Output: Trace:   result = 99
        Output: Trace:   result . give ;
        Output: Trace: exit Main.main => 99
        Result: Ok: 99
        Alloc 0x08: [Int(99)]"#]]
);

Arrays

Array[T] is the single heap-allocation primitive in Dada. Higher-level types like Vec, String, and Box are all built on top of arrays. An array is a fixed-capacity, refcounted block of memory that holds elements of type T.

Array layout

An Array[T] value is two words – a flags word and a pointer to the backing allocation:

Array[T] value (2 words):
+-------------------+
| Flags(Given)      |   <- ownership flag
| Pointer(alloc)    |   <- points to backing allocation
+-------------------+

Backing allocation:
+-------------------+
| RefCount(1)       |   <- reference count
| Capacity(n)       |   <- number of element slots
| element 0 words   |   <- size_of(T) words per element
| element 1 words   |
| ...               |
+-------------------+

Each element slot is size_of(T) words. Elements of unique classes (like Data) start with a flags word, while copy types (like Int) have no flags:

Array[Data] backing (capacity 2):
+-------------------+
| RefCount(1)       |
| Capacity(2)       |
| Flags(Given)      |   <- element 0 flags
| Int(42)           |   <- element 0 field x
| Flags(Given)      |   <- element 1 flags
| Int(99)           |   <- element 1 field x
+-------------------+

Array[Int] backing (capacity 3):
+-------------------+
| RefCount(1)       |
| Capacity(3)       |
| Int(10)           |   <- element 0
| Int(20)           |   <- element 1
| Int(30)           |   <- element 2
+-------------------+

Creating and accessing arrays

Five operations work with arrays:

  • array_new[T](n) – allocate a new array with capacity n. All element slots start uninitialized.
  • array_set[T](a, i, v) – write value v into slot i. The slot must be uninitialized.
  • array_give[T](a, i) – read the element at slot i. Behavior depends on the element type (see below).
  • array_drop[T](a, i) – drop the element at slot i, marking it uninitialized.
  • array_capacity[T](a) – return the array’s capacity as an Int.

Here’s a simple example that creates, fills, and reads an Array[Int]:

interp_array_new_and_get [src]
crate::assert_interpret_only!(
    {
        class Main {
            fn main(given self) -> Int {
                let a = array_new[Int](3).share;
                array_set[Int](a.give, 0, 10);
                array_set[Int](a.give, 1, 20);
                array_set[Int](a.give, 2, 30);
                print(array_give[Int](a.give, 0));
                print(array_give[Int](a.give, 1));
                array_give[Int](a.give, 2);
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Int](3) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, ⚡, ⚡, ⚡ }
        Output: Trace:   array_set [Int](a . give , 0 , 10) ;
        Output: Trace:   array_set [Int](a . give , 1 , 20) ;
        Output: Trace:   array_set [Int](a . give , 2 , 30) ;
        Output: Trace:   print(array_give [Int](a . give , 0)) ;
        Output: 10
        Output: Trace:   print(array_give [Int](a . give , 1)) ;
        Output: 20
        Output: Trace:   array_give [Int](a . give , 2) ;
        Output: Trace: exit Main.main => 30
        Result: Ok: 30
        Alloc 0x1c: [Int(30)]"#]]
);

The array is created with array_new[Int](3) (capacity 3), then shared so it can be passed to multiple operations. Each array_set writes a value into a slot, and array_give reads elements back out.

Copy elements vs. move elements

array_give behaves differently depending on whether the element type is a copy type:

Int elements are copy types – giving an Int element copies it without disturbing the source. You can read the same slot multiple times:

interp_array_int_is_copy [src]
crate::assert_interpret_only!(
    {
        class Main {
            fn main(given self) -> Int {
                let a = array_new[Int](1).share;
                array_set[Int](a.give, 0, 42);
                let x = array_give[Int](a.give, 0);
                let y = array_give[Int](a.give, 0);
                print(x.give);
                y.give;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Int](1) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, ⚡ }
        Output: Trace:   array_set [Int](a . give , 0 , 42) ;
        Output: Trace:   let x = array_give [Int](a . give , 0) ;
        Output: Trace:   x = 42
        Output: Trace:   let y = array_give [Int](a . give , 0) ;
        Output: Trace:   y = 42
        Output: Trace:   print(x . give) ;
        Output: 42
        Output: Trace:   y . give ;
        Output: Trace: exit Main.main => 42
        Result: Ok: 42
        Alloc 0x14: [Int(42)]"#]]
);

The array’s own flags propagate to element access. When a shared array’s elements are accessed via array_give, the shared context overrides the element’s runtime flags – even though class elements have Flags(Given) at rest, accessing them through a shared array uses shared semantics (copy + share_op, no move). This means you can read the same slot multiple times:

interp_array_class_shared_no_move [src]
// Shared array: class elements are accessed with shared semantics —
// giving an element produces a shared copy, element remains available.
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let a = array_new[Data](1).share;
                array_set[Data](a.give, 0, new Data(42));
                let x = array_give[Data](a.give, 0);
                print(x.give);
                // Element still available — shared, no move.
                array_give[Data](a.give, 0);
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Data](1) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, Data { x: ⚡ } }
        Output: Trace:   array_set [Data](a . give , 0 , new Data (42)) ;
        Output: Trace:   let x = array_give [Data](a . give , 0) ;
        Output: Trace:   x = shared Data { x: 42 }
        Output: Trace:   print(x . give) ;
        Output: shared Data { x: 42 }
        Output: Trace:   array_give [Data](a . give , 0) ;
        Output: Trace: exit Main.main => shared Data { x: 42 }
        Result: Ok: shared Data { x: 42 }
        Alloc 0x13: [Int(42)]"#]]
);

This is the same effective-flags principle as place traversal: accessing a field through a shared path gives shared semantics, regardless of the field’s runtime flags.

Here’s an example with Data elements in a shared array – each element can be read multiple times without moving:

interp_array_class_elements [src]
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Data {
                let a = array_new[Data](2).share;
                array_set[Data](a.give, 0, new Data(42));
                array_set[Data](a.give, 1, new Data(99));
                print(array_give[Data](a.give, 0));
                array_give[Data](a.give, 1);
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Data](2) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, Data { x: ⚡ }, Data { x: ⚡ } }
        Output: Trace:   array_set [Data](a . give , 0 , new Data (42)) ;
        Output: Trace:   array_set [Data](a . give , 1 , new Data (99)) ;
        Output: Trace:   print(array_give [Data](a . give , 0)) ;
        Output: shared Data { x: 42 }
        Output: Trace:   array_give [Data](a . give , 1) ;
        Output: Trace: exit Main.main => shared Data { x: 99 }
        Result: Ok: shared Data { x: 99 }
        Alloc 0x16: [Int(99)]"#]]
);

Sharing and reference counting

A freshly created array has Flags(Given) – it is uniquely owned. Sharing converts it to Flags(Shared), and from that point on, giving the array to another variable increments the refcount rather than moving:

interp_array_shared_refcount [src]
crate::assert_interpret_only!(
    {
        class Main {
            fn main(given self) -> Int {
                let a = array_new[Int](2).share;
                array_set[Int](a.give, 0, 10);
                array_set[Int](a.give, 1, 20);
                let b = a.give;
                a.drop;
                print(array_give[Int](b.give, 0));
                array_give[Int](b.give, 1);
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Int](2) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, ⚡, ⚡ }
        Output: Trace:   array_set [Int](a . give , 0 , 10) ;
        Output: Trace:   array_set [Int](a . give , 1 , 20) ;
        Output: Trace:   let b = a . give ;
        Output: Trace:   b = shared Array { flag: Shared, rc: 2, 10, 20 }
        Output: Trace:   a . drop ;
        Output: Trace:   print(array_give [Int](b . give , 0)) ;
        Output: 10
        Output: Trace:   array_give [Int](b . give , 1) ;
        Output: Trace: exit Main.main => 20
        Result: Ok: 20
        Alloc 0x17: [Int(20)]"#]]
);

After let b = a.give, both a and b point to the same backing allocation (refcount is now 2). Dropping a decrements the refcount to 1, but b still works because the allocation is alive.

The key distinction is between convert_to_shared and share_op:

  • convert_to_shared is an in-place ownership change (Called by .share). It flips the flags from Given to Shared. The refcount stays at 1 – one reference is still one reference.
  • share_op is duplication accounting (called when copying a Shared value via .give or .ref). It increments the refcount because one reference has become two.

Given arrays

Without sharing, an array is uniquely owned. Giving it transfers ownership, and the source becomes uninitialized:

interp_array_given_move [src]
crate::assert_interpret_only!(
    {
        class Main {
            fn main(given self) -> Int {
                let a = array_new[Int](2);
                array_set[Int](a.ref, 0, 10);
                array_set[Int](a.ref, 1, 20);
                let b = a.give;
                array_give[Int](b.give, 0);
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Int](2) ;
        Output: Trace:   a = Array { flag: Given, rc: 1, ⚡, ⚡ }
        Output: Trace:   array_set [Int](a . ref , 0 , 10) ;
        Output: Trace:   array_set [Int](a . ref , 1 , 20) ;
        Output: Trace:   let b = a . give ;
        Output: Trace:   b = Array { flag: Given, rc: 1, 10, 20 }
        Output: Trace:   array_give [Int](b . give , 0) ;
        Output: Trace: exit Main.main => 10
        Result: Ok: 10
        Alloc 0x12: [Int(10)]"#]]
);

The array is Given, so let b = a.give moves it. After the move, a is dead – any access would fault.

Dropping arrays

Dropping an array decrements the refcount. When the refcount reaches zero, the interpreter walks all element slots and recursively drops any initialized elements, then frees the backing allocation:

interp_array_drop_frees [src]
crate::assert_interpret_only!(
    {
        class Data { x: Int; }
        class Main {
            fn main(given self) -> Int {
                let a = array_new[Data](2).share;
                array_set[Data](a.give, 0, new Data(1));
                array_set[Data](a.give, 1, new Data(2));
                a.drop;
                0;
            }
        }
    },
    expect_test::expect![[r#"
        Output: Trace: enter Main.main
        Output: Trace:   let a = array_new [Data](2) . share ;
        Output: Trace:   a = shared Array { flag: Shared, rc: 1, Data { x: ⚡ }, Data { x: ⚡ } }
        Output: Trace:   array_set [Data](a . give , 0 , new Data (1)) ;
        Output: Trace:   array_set [Data](a . give , 1 , new Data (2)) ;
        Output: Trace:   a . drop ;
        Output: Trace:   0 ;
        Output: Trace: exit Main.main => 0
        Result: Ok: 0
        Alloc 0x11: [Int(0)]"#]]
);

Both Data elements are recursively dropped (their flags are set to Uninitialized, fields are cleaned up), then the backing allocation is overwritten with Uninitialized words. The heap snapshot shows only the result Int – no leaked array memory.