Skip to content

Reference and Mutability

Accepted

Accepted for V1 source semantics; optional lint candidates are cataloged in Lint Catalog.

Catalyst separates local binding mutability, value/reference behavior, referent mutability, and ownership. The goal is to keep mutation visible without adopting pervasive lifetime proof machinery.

Binding Mutability

var declares mutable local storage:

var x: i32 = 1
x = 2

const declares a binding that cannot be reassigned:

const y: i32 = 1
y = 2 // error

This is shallow binding mutability. It controls whether the local binding can be assigned, not whether every reachable value may be mutated.

Value Semantics

Ordinary non-reference values have value semantics:

var a = Point{ .x = 1, .y = 2 }
var b = a
b.x = 3

b is an independent value. a is unchanged.

Types are copyable by default. Resource-owning types can opt out of implicit copying through metadata such as target.set_copyability(.forbidden), usually set by the prelude @resource type-constructor attribute. Ownership transfer from a named binding is explicit with move.

var file2 = file      // copy; error if copy is forbidden
var file2 = move file // transfer; `file` is unusable afterward

Pointers

Pointer syntax uses *T:

var p: *Point = &point

Plain pointers are borrowed by default. A pointer does not own the allocation or resource it refers to.

Pointer assignment copies the pointer value, not the pointee:

var a: *Point = get_point()
var b = a
b.x = 3 // mutates the same Point observed through a

*T provides mutable access to the referent. *const T provides readonly access to the referent:

var p: *const Point = &point
p = get_other_point() // allowed if p is a var binding

Mutating through *const T is invalid:

p.x = 1              // error: referent is readonly

*T may coerce to *const T. *const T must not coerce to *T.

Opaque Pointer Conversion

opaque is the erased pointee type for low-level raw storage and dynamic dispatch data pointers. *opaque and *const opaque are ordinary pointer values whose pointee type has been erased.

Typed pointers coerce to opaque pointers when the expected type requests erasure:

var p: *Header = &header
var raw: *opaque = p

var cp: *const Header = &header
var craw: *const opaque = cp

Mutable typed pointers may also coerce to readonly opaque pointers:

var p: *Header = &header
var craw: *const opaque = p

Readonly typed pointers do not coerce to mutable opaque pointers:

var cp: *const Header = &header
var raw: *opaque = cp // error: cannot remove const

*opaque and *const opaque expose the V1 typed-recovery operations:

raw.as_type(Header)
raw.assume_aligned(Header)
raw.cast(Header)

Typed-recovery operations are pointer-fact operations:

Operation Meaning
as_type(T) Changes only the static pointee type. *opaque.as_type(T) returns *T; *const opaque.as_type(T) returns *const T.
assume_aligned(T) Preserves the receiver pointee type and constness, and asserts that the address is suitable for T's required alignment.
cast(T) Equivalent to assume_aligned(T).as_type(T): asserts alignment for T and returns a typed pointer to T, preserving constness.

as_type(T) does not remove const, check alignment, initialize storage, or prove that a T value actually lives at the address. In Checked safety mode, a failed assume_aligned(T) assertion traps when the check is dynamically available. In Unchecked safety mode, a failed alignment assertion is illegal behavior.

var raw: *opaque = try alloc.allocate(size, align)
var p: *Header = raw.cast(Header)

These operations exist only on *opaque and *const opaque. Normal typed pointers do not receive cast, as_type, or assume_aligned members in V1, so typed-to-typed reinterpretation must pass through an explicit opaque pointer value.

Typed recovery is a promise

Recovering *T from *opaque does not prove alignment, initialization, provenance, or true pointee type beyond the facts explicitly asserted by the operation. The programmer is responsible for making those facts true.

Recovering a typed pointer does not start a value lifetime. Writing through the typed pointer starts the lifetime when the storage is uninitialized:

var raw: *opaque = try alloc.allocate(size, align)
var p: *Header = raw.cast(Header)
p.* = Header{ .len = 0, .flags = 0 }

Reading through the recovered pointer before initialization is illegal behavior and should be diagnosed when statically obvious.

Address-Of and Dereference

Catalyst address-of preserves the mutability of the addressed binding:

const a: Point = ...
var b: Point = ...

&a // *const Point
&b // *Point

Ordinary function arguments require explicit address-of syntax:

fn move_by(p: *Point, dx: f32, dy: f32) void

var p = Point{ .x = 1, .y = 2 }
move_by(&p, 1, 2) // allowed

Passing the value where a pointer is required is invalid:

move_by(p, 1, 2)  // error

Dereferencing a pointer value uses p.*:

var value = p.*

Field and method access through pointers may auto-dereference:

p.x = 3   // sugar for p.*.x
p.len()  // method lookup through the pointed-to value

In V1, this auto-dereference applies through at most one raw pointer layer:

var p: *Point = &point
p.x // ok, sugar for p.*.x
p.len() // ok, method lookup through p.*

Deeper pointer layers require explicit dereference:

var pp: **Point = &p
pp.x    // error
pp.len()   // error
pp.*.x  // ok
pp.*.len() // ok

Field access and method receiver lookup use the same one-layer rule. This auto-dereference is for field and method access only. It must not silently convert pointer arguments to values or values to pointer arguments.

Field Mutation

Field assignment requires a mutable place:

var p = Point{ .x = 1, .y = 2 }
p.x = 3 // allowed

Assigning through a const root is invalid:

const q = Point{ .x = 1, .y = 2 }
q.x = 3 // error

Field-level const prevents assignment through that field even when the containing place is mutable:

const Header = struct {
  const sample_rate: f32
  channels: u32
}

var h = Header{ .sample_rate = 48_000, .channels = 2 }
h.channels = 1       // allowed

h = Header{ .sample_rate = 44_100, .channels = 1 } // allowed if Header is assignable

Assigning through the const field slot is invalid:

h.sample_rate = 96000 // error

Field-level const is shallow. It prevents assigning to the field slot. It does not change the deep mutability of the field value's type. For example, a const data: []f32 field cannot be reassigned, but the []f32 value still describes mutable element access through the slice.

For pointer access:

var rp: *Point = &p
rp.x = 4 // allowed

Mutating through a const pointer is invalid:

var cp: *const Point = &p
cp.x = 4 // error

A place is mutable when the root storage is mutable and every access step preserves mutable access.

Method Receivers

Methods are functions namespaced inside a type. Method-call syntax may apply receiver sugar only for the first parameter:

const Buffer = struct {
  fn len(self: *const Self) usize
  fn clear(self: *Self) void
}

var buf = Buffer.create(...)
buf.len()   // sugar may pass &buf as *const Buffer
buf.clear() // sugar may pass &buf as *Buffer

Receiver rules:

  • self: Self receives a value.
  • self: *const Self may auto-address mutable or const receiver places.
  • self: *Self may auto-address only mutable receiver places.
  • Pointer receiver auto-addressing requires an addressable receiver place in V1.
  • Non-receiver arguments still require explicit &.

Receiver auto-addressing is method-call sugar only:

var buf: Buffer = ...
buf.clear()        // ok, sugar for Buffer.clear(&buf)
Buffer.clear(&buf) // ok

The explicit non-receiver argument still needs &:

Buffer.clear(buf)  // error when self: *Buffer

It applies only to the receiver slot. Ordinary function arguments, including non-receiver method arguments, never auto-address.

Receiver rvalues are not auto-addressed for pointer receivers in V1:

Buffer.create(...).len()   // error when self: *const Buffer
Buffer.create(...).clear() // error when self: *Buffer

Named or otherwise addressable receiver places are valid:

var buf = Buffer.create(...)
buf.len()   // ok
buf.clear() // ok

Methods with self: Self may still be called on rvalues because they receive the value directly. Pointer receiver calls require a named or otherwise addressable place, avoiding hidden temporary lifetime and cleanup rules.

General extension methods are deferred to CEP-0011: General Extension Methods. The current model only covers functions namespaced inside the receiver type and visible contract operations.

Loops

for iteration binds the item type produced by the receiver's iterator:

for item in items {
  use(item)
}

Plain static for uses Iterable(Item).iter. Mutable static iteration uses for var and MutableIterable(Item).iter_mut. Dynamic iteration means explicitly calling fallible iter_dyn / iter_dyn_mut operations to create owning Box(dyn Iterator(Item)) values, then iterating those boxes. The boxes must be disposed.

for item in slice {
  // item: *const T, because slice implements Iterable(*const T)
}

for var item in mutable_slice {
  // item: *T, because mutable_slice implements MutableIterable(*T)
  item.* = new_value
}

If a type has multiple iterable modes, the loop item type can be written to disambiguate:

for item: Rune in text {
}

for var item: *u8 in bytes {
}

Loop bindings are const bindings in V1, including value-yielding iterators. The loop binding itself is not reassignable:

for var item in mutable_slice {
  item = other_pointer // error
  item.* = new_value   // allowed when item: *T
}

Explicit value-copy iteration syntax is deferred. A local copy can be created inside the loop. When the iterator item is a pointer, copy the pointee explicitly:

for item in items {
  var copy = item.*
}

For value-yielding iterators, the item itself can be copied:

for item in range {
  var copy = item
}

Slices

Slices are transparent borrowed-view descriptors by default:

[]T       // mutable element access
[]const T // readonly element access

A slice is a pointer plus length descriptor with source-visible fields:

[]T       ~= { ptr: *T, len: usize }
[]const T ~= { ptr: *const T, len: usize }

Rules:

  • assigning or passing a slice copies only the descriptor
  • slices do not own storage
  • slices do not carry capacity
  • .ptr and .len are ordinary field access on the descriptor
  • []T may coerce to []const T
  • []const T must not coerce to []T
  • no hidden allocation is introduced

Manual descriptor manipulation is allowed once aggregate construction exists, but suspicious pointer/length construction or mutation should be linted.

C backend lowering should preserve the explicit pointer+length model. The exact C ABI representation is a backend/ABI decision, but it must be deterministic and must not infer ownership from slice type alone.

See Arrays, Slices, Ranges, and Indexing for operational indexing, slicing, loop, contract, and lowering rules.

Ownership

Ownership is modeled through ordinary resource-owning types and API conventions, not through owned pointer or owned slice modifiers:

var buf = Buffer.create(alloc, len)
defer buf.dispose()

var view: []u8 = buf.slice()

Pointers and slices are borrowed views. Owning values expose borrowed views through explicit APIs.

Constructor/resource conventions:

  • init returns Self
  • create performs allocation and returns an owning result
  • dispose is the Disposable cleanup operation
  • destroy releases internals and frees a heap-allocated self

Optional lint tooling can catch obvious resource mistakes:

  • resource-like value is not cleaned up
  • resource-like value is overwritten while still live
  • resource-like value is used after cleanup
  • resource-like value is moved while an active cleanup defer still targets it
  • borrowed pointer is stored beyond a likely lifetime boundary
  • public API has unclear ownership convention

Definite local unsafety, such as returning a pointer to local storage, may be a hard compiler error where it is straightforward to prove. Cleanup-convention findings for ordinary user-visible resource bindings remain lint candidates.

Ownership source semantics use these boundaries:

  • copy prohibition and explicit move are hard semantic rules once declaration metadata sets copy = .forbidden.
  • cleanup obligations for ordinary user-visible bindings are explicit source conventions and optional lint candidates.
  • cleanup lints, when enabled, should track resolved Disposable.dispose calls, including method-call sugar and qualified contract calls.
  • defer value.dispose() schedules cleanup for scope exit; lint tooling can treat it as satisfying early-exit cleanup paths without marking the value cleaned immediately.
  • V1 has no defer cancellation; moving a resource while an active cleanup defer still targets that binding is lintable.
  • compiler-generated temporaries owned by a construct may use Disposable conformance for generated cleanup, as with hidden loop iterators.

See Ownership and Resources for cleanup lint candidates and Lint Catalog for lint identities.

Future Decisions

Deferred post-V1 reference and mutability decisions are listed here, with CEP links where a focused proposal exists:

Until the aliasing/noalias and observable-access models are decided, backend metadata must be conservative. The compiler should not emit restrict, noalias, volatile, atomic, or equivalent exclusivity/observability assumptions for ordinary *T or []T parameters without an explicit future annotation.