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: Selfreceives a value.self: *const Selfmay auto-address mutable or const receiver places.self: *Selfmay 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
.ptrand.lenare ordinary field access on the descriptor[]Tmay coerce to[]const T[]const Tmust 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:
initreturnsSelfcreateperforms allocation and returns an owning resultdisposeis theDisposablecleanup operationdestroyreleases internals and frees a heap-allocatedself
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.disposecalls, 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
Disposableconformance 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:
- explicit value-copy loop syntax
- general extension methods, tracked by CEP-0011: General Extension Methods
- noalias or exclusivity annotations for pointers and slices, tracked by CEP-0010: Aliasing and Exclusivity Annotations
- volatile-like observable memory access for MMIO and device registers, tracked by CEP-0062: Observable Memory Accesses
- exact C ABI representation for slices and aggregate parameters, tracked by CEP-0007: C ABI Compound Types and Error Returns
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.