Documentation / Memory and lifetimes

Memory and lifetimes

Moth defaults to reference semantics, explicit copies and inferred moves. Existing values use shared read-only access. Mutation requires explicit exclusive access. The compiler infers moves while keeping copies explicit.

These pages cover source-facing memory behaviour. For the formal memory model, borrow validation and backend lowering, see Memory management.

Choose the explanation level: Reference semantics

Reference semantics

Moth shares existing values by default. Here's what that means in practice.

When you assign one variable to another, both read the same data:


    original = "hello"
    view = original
    

No copy happens here. view reads the same value as original.

When shared access blocks mutation

A shared alias prevents mutation of the same data while it is still in use. Once the alias is no longer needed, mutation becomes valid again.


    items ~= {1, 2, 3}
    view = items
    -- ~items.push(4)  -- would fail: view is still live
    inspect(view)       -- last use of view
    ~items.push(4) catch:      -- valid now
        assert(false, "unexpected push failure")
    ;
    

Moth defaults to reference semantics. Reading, binding, passing, returning or storing an existing value uses shared reference semantics unless exclusive access or explicit copy applies.

Shared access by default

Existing values use shared, read-only access. Multiple shared aliases may coexist. A shared alias doesn't imply ownership or implicit copy.


    original = load_items()
    view = original

    inspect(original)
    inspect(view)
    

view is a shared read-only alias of the same source value. It doesn't clone the data.

Non-lexical alias activity

Alias activity is non-lexical and control-flow-sensitive. A shared alias blocks overlapping mutation only until its last potential use on the relevant path. An unused shared alias doesn't block later mutation.


    items ~= load_items()
    view = items       -- shared alias
    inspect(view)      -- last use of view
    ~items.push(42) catch:    -- valid: view is no longer live
        assert(false, "unexpected push failure")
    ;
    

Branches, joins and loops

Branches are analysed independently. At a control-flow join, alias activity is conservatively retained when any incoming path may still use the alias. Loops use fixed-point future-use reasoning, so an alias that survives one iteration stays live for the whole loop body.

References are real

Moth omits explicit reference types and lifetime syntax, not references themselves. The language doesn't expose reference type constructors, pointer syntax or lifetime annotations. References are implicit, compiler-tracked and safe.

This is a source-semantic rule. Backends may use different representations (GC, handles, stack storage) as long as they preserve the same observable behaviour.

No source-level reference, move or lifetime syntax

Moth has no source-level:

  • & or &mut reference type constructors
  • lifetime annotations or lifetime parameters
  • temporary-reference syntax
  • explicit move keyword or operator
  • separate borrowed and owned function signatures

These are permanent design boundaries, not merely unimplemented features.

Backend representation doesn't change source semantics

A backend may represent a shared alias as a pointer, a handle, a GC reference or direct scalar storage. The source rule stays fixed: ordinary binding creates shared access to the same value, never an implicit copy.

Read next

For the formal memory model, see Memory management.

Choose the explanation level: Copy and exclusive access

Copy and exclusive access

Copies in Moth are always explicit. Ordinary assignment doesn't clone data.

Making an independent copy

Use copy when you need a separate version of a value:


    original ~= {1, 2, 3}
    independent = copy original

    ~independent.push(4) catch:
        assert(false, "unexpected push failure")
    ;
    -- original still has 3 items
    

Without copy, both names point at the same collection.

Mutating existing values with ~

To change an existing value, use ~ before the value name:


    items ~= {1, 2, 3}
    ~items.push(4) catch:
        assert(false, "unexpected push failure")
    ;
    

The ~ tells the compiler you want exclusive access to mutate the value.

Fresh values don't need ~

When you create a new value from scratch, it doesn't need ~:


    fresh = {1, 2, 3}   -- new collection, no ~ needed
    fresh_with_type ~{Int} = {1, 2, 3}  -- mutable binding, no ~ on the initializer
    

Explicit copy

copy creates an independent value graph. Ordinary assignment and argument passing don't clone existing values. Fresh aggregates built from shared inputs don't implicitly copy those inputs.


    original ~= {1, 2, 3}
    independent = copy original

    ~independent.push(4) catch:
        assert(false, "unexpected push failure")
    ;
    -- original is unchanged
    

copy performs a semantic deep copy of the complete copyable runtime value graph reachable from the place:

  • every copyable allocation in the graph is duplicated into the destination lifetime region
  • internal alias topology is preserved: repeated source references to one allocation become repeated references to one copied allocation
  • same-region cycles are preserved in the copied graph
  • the copied graph shares no mutable allocation with the source graph
  • a reactive source is copied as its current value, not as the same reactive source identity
  • an external opaque handle or resource that doesn't define a genuine independent-copy operation produces a source diagnostic
  • a non-copyable graph member produces a source diagnostic, and the compiler won't silently retain an alias instead

A copy result may be allocated directly into an inferred or declared destination region.

copy accepts a visible binding, field projection or parenthesised place. Literals, templates, calls and computed expressions aren't copy places and are rejected.

Exclusive access with ~

~place requests temporary mutation-capable access to an existing mutable place. It excludes conflicting access while active.


    values ~= {1, 2, 3}
    ~values.push(4) catch:
        assert(false, "unexpected push failure")
    ;
    

~ isn't a move marker or a type constructor. It applies to an existing mutable place and requests exclusive access for one operation.

Mutable aliases versus fresh mutable slots

alias ~= source creates an exclusive write-through mutable alias to an existing place. Assignment through a mutable alias writes through to the referent.

A mutable declaration initialised from a fresh value creates an independent mutable slot.


    original ~= 10
    alias ~= original  -- mutable alias
    alias = 20         -- writes through to original

    fresh ~= 0         -- independent mutable slot
    fresh = 1          -- doesn't affect original
    

Mutable receivers

Mutable receiver calls require explicit mutable or exclusive receiver syntax:


    items ~= {1, 2, 3}
    ~items.push(4) catch:     -- valid: explicit mutable access
        assert(false, "unexpected push failure")
    ;

    -- items.push(4)   -- invalid: missing ~
    

Fresh rvalues can satisfy mutable parameters without ~. Existing places still require explicit ~. Temporaries and rvalues cannot be mutable receivers.

A fresh rvalue passed to an ordinary mutable parameter is materialised into a compiler-introduced hidden local before borrow analysis. That hidden local isn't source-visible lifetime syntax, and it doesn't make temporaries valid mutable receivers.

Call access summary

  • a shared parameter from an existing place needs no ~
  • a mutable parameter from an existing place requires ~place
  • a mutable parameter from a fresh value uses the plain value
  • a mutable receiver uses ~value.method(...)
  • an immutable receiver uses value.method(...)
  • mutable receivers require an existing mutable place

Read next

For the formal access rules, see Access and aliasing.

Choose the explanation level: Lifetimes and result shapes

Lifetimes and result shapes

Values in Moth have different lifetimes depending on how they are created.

New values

When you create a value from scratch, it is a fresh result:


    name = "Priya"
    greeting = [: Hello, [name]]
    items = {1, 2, 3}
    

A fresh result has a new root allocation. It may still hold references to older values it read while building. Use copy when you need a value with no shared references to pre-existing data.

Aliases to existing values

When you read or pass an existing value, you get a shared alias:


    original = load_items()
    view = original
    

view and original point at the same data. No copy happens.

Values inside collections and structs

Values stored inside collections, maps and structs keep their shared reference semantics. Inserting a value into a collection doesn't copy it.


    name = "Priya"
    names ~= {name}     -- the collection holds a reference to the same string
    

Use copy when you need an independent duplicate inside a container.

Mandatory topology, optional optimisation

Lifetime-region and escape validation runs mandatory and backend-independent. It proves every retained reference is legal under one lifetime owner. GC cannot bypass it.

Optional ownership optimisation (final-use transfer, deterministic drops, stack or arena allocation, collector elision) layers over a topology already proven legal. When optimisation proof is unavailable, the value stays GC-managed without changing program behaviour.

One semantic lifetime owner

Every runtime allocation has exactly one semantic lifetime owner. Multiple bindings, fields, elements or returned values may alias one allocation without becoming additional owners. The ownership bit records destruction responsibility only. It doesn't prove uniqueness.

The retained-edge outlives rule

An object may retain a reference only when the referenced allocation belongs to the same lifetime region or to a region statically known to outlive the retaining object.


        R_value >= R_container
    

where R_value lives at least as long as R_container.

Valid edges include a local retaining a longer-lived parameter, a child-group value retaining a parent-group value, or two fields of one page-owned aggregate sharing one page-owned child.

Invalid edges include a longer-lived object retaining shorter-lived storage, a parent retaining a child-group value, or a returned local outliving its source region without independent storage.

Lexical scope doesn't define allocation lifetime

Shared aliases may outlive the lexical binding that first named the storage. Escaping or retained aliases remain under one lifetime owner and must satisfy the stored-edge outlives rule. Lexical scope controls name visibility and control-flow exits, not allocation lifetime.

Nearest-existing-ancestor widening

An ordinary allocation begins in the narrowest inferred region capable of owning its initial uses. The compiler may widen that allocation only to the nearest existing ancestor on the same ordered owner chain that outlives every retained observer.

Widening follows one ordered owner chain only. The compiler must not widen farther than necessary or invent a page-, application- or process-lifetime owner merely to avoid a diagnostic.

Siblings don't form one ordered chain. Independently ending lifetime domains cannot be laterally promoted across each other. Sharing across sibling domains requires one of:

  1. an already-existing common semantic owner
  2. an enclosing declared memory group
  3. a builder-declared common lifecycle
  4. independent storage created by copy

Fresh result roots

A fresh result root has a new root allocation but may retain legal references to older allocations. Fresh results include literals, templates, constructor calls and computed aggregates.


    name = "Priya"                    -- fresh string slice
    greeting = [: Hello, [name]]      -- fresh owned template
    items = {1, 2, 3}                 -- fresh collection
    

A fresh result root may retain parameters or other pre-existing values only when every retained edge satisfies the destination lifetime's outlives constraints.

Alias results

An alias result reuses an existing root or projection. Shared bindings, field access and shared function returns produce alias results. An alias result stays tied to its existing lifetime owner and cannot become new group-owned storage through into.


    original = load_items()
    view = original                   -- alias result
    first = original.get(0) catch:    -- alias result (shared access)
        assert(false, "known valid index")
    ;
    

Projection roots

Interior projections remain rooted in their containing allocation family. Returning, storing or escaping a projection retains that family. A projection doesn't silently become an independent allocation or independent copy.

Independent result graphs

An independent result graph has no retained Moth reference to pre-existing storage. copy produces independent results. WIT value-only lifting produces independent results. An independent graph may enter an unrelated destination lifetime without retained-edge constraints to its source graph.

Aggregate storage

Existing values stored in structs, choices, collections, maps, tuples, templates or other aggregates retain shared reference semantics by default. copy creates independent storage.

Maps own their entry structure while keys and values follow the same shared/copy/inferred-transfer rules. Map lookup keys are borrowed. get returns a shared alias. remove returns the removed value under normal lifetime and ownership rules.

Return and multi-return aliasing

Function lifetime summaries cover fresh result roots, parameter aliases, projection aliases, result-to-result aliases, retained-parameter constraints and required outlives relationships.

Multiple return values may alias one allocation when they remain under one caller lifetime owner. A caller may place a fresh result root directly into a destination region only when every retained edge in the result is legal for that destination.

Cycles

Cross-region cycles are invalid. Every strongly connected allocation graph must belong to one lifetime region. Same-region cycles are lifetime-safe.

Reactive and builder-owned lifetimes

Reactive subscriptions are read-only dependencies, not active borrow lifetimes. Builder-supplied page, mount, request, frame and arena roots are lifecycle inputs. They obey the same retained-edge rule as source regions. Builders supply lifecycle roots but cannot change source legality.

External boundaries

External bindings use closed semantic boundary profiles. They don't expose arbitrary user-defined lifetime graphs.

WIT value-only V1 imports lower shared reads into independent component values and lift results into independent Moth result graphs. No Moth alias, lifetime owner or destruction responsibility crosses the component boundary.

Restricted host-binding profiles let ordinary Moth values cross by value, while host code may not retain references into ordinary Moth storage and opaque handles represent foreign identities rather than Moth reference types.

Optional inferred transfer

Moth has no move syntax and no ordinary mandatory-consuming value operation. Inferred transfer is optional. When safe transfer isn't proven on every relevant path, the operation remains a borrow.

Immutable and mutable parameters may both receive inferred destruction responsibility at a proven final-use call site. Parameter access mode remains separate from optional ownership effect.

Source-visible lifetime consequences

  • A shared alias blocks overlapping mutation until its last potential use.
  • A live shared value returned by get prevents mutation of the same map.
  • remove returns the removed value under normal lifetime rules.
  • Aggregate storage retains shared references unless copy is explicit.

Read next

For the formal lifetime-region rules, see Lifetime regions and escape validation.

Choose the explanation level: Declared memory groups

Declared memory groups

Moth has an accepted but not yet implemented feature called declared memory groups. This page explains what they are so you know the concept exists.

What groups do

A group lets you control where values are allocated and how long they live. When you put a value into a group, that value cannot outlive the group.

Current status

Group syntax is accepted but deferred. Current programs don't use it. The compiler doesn't parse or validate group declarations yet.

You don't need groups to write Moth programs today. The compiler handles memory safety through shared access, explicit copy and ~ exclusive access.

Accepted deferred syntax

The following is accepted end-state syntax. Implementation is deferred and must not be treated as current source support.

Declared memory groups

group name: creates a declared hard lifetime region. Values placed into a group belong to that group for their full lifetime. No group-owned value, projection or alias may outlive the group.

A declaration can target a group with the into keyword:


    group request:
        parsed ParsedPost into request = parse_post(post)
        html String into request = render_post(parsed)
    ;
    

        name [access/type] into group_name = expression
    

Placement rules

  • group name: is valid only in runtime executable bodies
  • into group_name appears only on declaration receiving boundaries in V1
  • Placement may target the current group or a lexically enclosing ancestor group
  • Placement may not target a sibling, child, unrelated group or builder lifecycle root by spelling its name
  • Groups cannot be passed, returned, stored, imported, exported, compared or used as values
  • Group identity is not a type, field, parameter, generic argument, trait, allocator object or lifetime annotation
  • Group identity must not enter TypeId or a source signature
  • Group closure occurs on every exit: fallthrough, return, return!, break, recovery exit and checked-operation failure path

The group name is local to the current executable body scope. It cannot collide with a visible value, type, import, constant, reactive source or active group.

into declaration position

into group_name appears after access or type syntax and before =. V1 has no expression-site placement. Declarations are the only placement surface.


    parsed into scratch = parse_post(post)
    parsed ParsedPost into scratch = parse_post(post)
    rows ~{Row} into scratch = {}
    maybe_name String? into scratch = find_name(id)?
    

Destination scope and visibility

A declaration targeting an ancestor group is a narrow V1 escape mechanism, not a general definite-assignment system.

  • A declaration targeting an ancestor group is legal only from a straight-line nested block: or nested group: that executes at most once
  • It is invalid directly inside if branches, match arms, catch branches, loops, repeated template or runtime control flow, or any construct that may execute zero or multiple times
  • Conditional production must use one declaration in the destination scope whose initializer is a value-producing if, match or catch
  • Loop production must mutate a collection or aggregate already owned by the destination group through ordinary exclusive access
  • Name collisions and definite initialization are checked in the destination scope
  • Visibility begins at the declaration point and continues through the remainder of the destination group

Ordinary declarations without into retain normal lexical visibility.

Placement eligibility

A declaration may be placed into a group when its result root is fresh and every retained edge is legal for the destination group, or when it is an independent result graph such as an explicit copy.

An alias result cannot become new group-owned storage through into.

A fresh result root may retain parameters or other pre-existing values only when every retained edge satisfies the destination lifetime's outlives constraints. An independent result graph can enter an unrelated destination lifetime without retained-edge constraints to its source graph.

Nested groups and retained edges

Nested groups are valid.


    group request:
        config Config into request = load_config()

        group scratch:
            parsed ParsedPost into scratch = parse_post(post)
            html String into request = render_post(parsed, config)
        ;

        use(html)
    ;
    

For a child group nested in a parent group:

  • a child value may retain a parent value: valid
  • a child value may retain a same-child value: valid
  • a parent value may retain a child value: invalid
  • a sibling may retain a sibling-group value: invalid
  • a child may retain an unrelated shorter-lived value: invalid

A group has one lexical entry and explicit exits. Values placed into the group cannot escape it. The compiler must not silently widen a declared group. A child group ends before its parent.

No group-to-group transfer in V1

V1 has no extraction, adoption or unrestricted group-to-group transfer. A value crosses from a shorter-lived group into a longer-lived group only by producing independent storage in the destination lifetime or by invoking a fresh producer that allocates directly into the destination.

Use one of:

  • allocate the result directly into the destination group
  • copy into the destination group
  • place the whole graph in the correct common group from the start

Escapes

Lifetime-region validation rejects every path where a group-owned value or alias can outlive the group.

Invalid escapes include:

  • returning a group-owned value
  • returning a projection or alias rooted in group-owned storage
  • storing a group-owned value in a longer-lived local or aggregate
  • storing a child-group value in a parent or sibling group
  • assigning a group-owned value into longer-lived reactive storage
  • passing a group-owned value to an external call that may retain it
  • keeping a map lookup, collection element or field alias live after group exit
  • creating a longer-lived retained edge into the group

Backend GC representation does not legalise these cases.

Reassignment inside a group

V1 has no into group syntax on reassignment. A mutable binding already owned by a group may be reassigned only with:

  • a fresh result root valid for that same group
  • an independent copy allocated into that group
  • a same-group value transferred at a proven final use

Reassignment doesn't let a group-owned binding switch into a borrowed alias of ancestor, sibling or external storage. Use a separate ordinary alias binding for that purpose.

Reactive storage restrictions

V1 rejects:

  • reactive declarations placed directly into an ordinary lexical memory group
  • assigning group-owned values into reactive storage that outlives the group
  • subscriptions or mounted state retaining group-owned aliases past group exit

Calls and hidden result destinations

Functions whose result root is fresh may allocate that root directly into a caller-selected destination. The function summary classifies the result root as fresh and records retained-edge constraints separately.

A hidden destination:

  • is not part of the source signature
  • is not a region parameter visible to generics or callers
  • is not a source lifetime parameter
  • may be ignored by a GC backend
  • lets an optimising backend allocate a fresh result root directly into the caller's region when every retained edge is legal

Hidden result destinations don't become source signature parameters.

V1 restrictions

  • no expression-site placement
  • no into group syntax on reassignment
  • no extraction
  • no unrestricted group-to-group adoption

Why groups exist

Groups give authors explicit control over allocation lifetime without exposing reference types, pointer syntax or manual memory management. The compiler validates group topology as part of its mandatory lifetime-region analysis.

Read next

For the formal group semantics, see Declared memory groups. For implementation sequencing, see Grouped memory design plan.