Memory, Regions, and Allocators
Status
Nocter provides deterministic ownership, explicit fallible allocation, lexical regions, storage provenance, and a statically propagated current allocation context for String, Vec<T>, and RawBuffer. Typed literals, sequence spread, and collection iteration build on that foundation.
Memory Model
Nocter does not use garbage collection. Its memory model combines:
- owned values and explicit moves
- readonly and readwrite borrows
- source-level non-lexical loan ranges
- deterministic drop at scope exit
- allocator-backed owned storage
- lexical allocation regions
- compile-time escape checking
Owned values are dropped when their scope ends unless ownership has moved. A storage-dependent value may move only to a destination outlived by every storage origin it carries.
Three Separate Concepts
Allocator, allocation context, and region are not synonyms.
- An allocator backend obtains and releases bytes at runtime.
- An allocation context selects an allocator backend, failure policy, and storage origin for an allocating operation.
- A region is a lexical child lifetime with its own allocation context and release boundary.
Borrow loans are separate again: they restrict access to source places while a borrow-like result is live. Allocator provenance does not replace loan checking, and a runtime allocator handle is not a source-level lifetime annotation.
Default Allocation Context
Every executable starts with a program-lifetime allocation context backed by the standard aborting system allocator. An allocating callable receives the current context as a compiler-propagated capability.
The current context is not:
- a mutable process-global variable
- a thread-local lookup
- selected by searching for a standard-library name
- reconstructed by the backend from source syntax
The compiler records an execution allocation requirement for each callable and statically passes the context only where required. Source functions infer that internal requirement from their bodies and callees. Trusted bodyless standard-library declarations carry compiler metadata attached to declaration identity.
Changing a helper body so that it allocates may change its compiler-owned execution requirement. The whole compile unit contains the source and summaries required to propagate that fact. Execution allocation and fresh storage retained by a result have no source-level annotation.
Result Storage Contracts
Callers see only external storage relationships they must preserve:
func len(text: &str): usize
func copy(text: &str): String
func view(text: &String): &str
func copy_with(allocator: &+Allocator, text: &str): String from allocator
from X reports a receiver, parameter, allocator capability, or static origin retained by a storage-bearing successful result projection. The clause is normally omitted. When exactly one receiver, parameter, allocator capability, or argument pack can supply the result storage, that origin is inferred. Fresh storage and static storage require no caller-managed source place and therefore require no clause.
A callable with multiple eligible inputs may omit from only when its body proves that the result retains none of them. A bodyless declaration cannot supply that proof, so an ambiguous storage-bearing result must explicitly name its retained upper bound. copy_with above has both an allocator and text input, but its owned result retains only the allocator origin. Explicit clauses remain legal for unambiguous APIs, although canonical source omits them.
For T!, the clause describes only the successful T value. Error storage remains part of compiler-owned escape analysis and does not force a from clause onto every fallible API. Omitting from does not promise allocation-free execution or storage independence from the active lexical region. The independent noalloc callable contract below provides the allocation guarantee; realtime is not part of the current language.
Body-backed summaries infer fresh storage and exact path-sensitive origins from implementations. Bodyless abstract callables use their written clause or the shared zero/one-origin elision rule. Trusted allocation and process-lifetime primitives use semantic roles attached to declaration identity. None of these internal facts causes formatter or editor signatures to synthesize source syntax that the author did not write.
Allocation Failure Policies
The standard library provides aborting and recoverable allocation capabilities over one implementation. Their exact declarations, termination behavior, error codes, and failure atomicity belong to Allocation and Failure.
The ambient allocation context selects the ordinary aborting capability. Recoverable allocation requires an explicit library API whose declared result is fallible; changing the current context alone never changes an expression between T and T!.
Standard-Library Boundary
Allocator APIs live in std/mem. Allocator, TryAllocator, Layout, and RawBuffer are ordinary standard-library names, not compiler built-ins.
The compiler's special behavior is limited to:
- allocation-effect and storage-origin metadata on trusted declarations
- statically propagating the current allocation context
- the
region name using allocator_place { ... }language construct - escape checking and ordered region cleanup
The exact declarations, layout validation, raw-buffer invariants, and aborting-versus-recoverable adapter behavior belong to the compiler-checked std/mem contract and its Allocation and Failure guide.
Lexical Regions
Syntax:
region name using allocator_place {
statements
}
Example:
region scratch using arena {
let source = read_file("main.nct")?
let tokens = lex(&source as &str)?
consume(&tokens as &[Token])
}
allocator_place must resolve to an established aborting allocator or allocation-context place. It is not an arbitrary effectful expression. The parent is evaluated and validated before the child region is entered.
Entering the statement:
- 1. creates a fresh lexical region identity
- derives a child runtime allocator from the selected parent
- binds the immutable region handle to
name - makes the child allocation context current for allocating calls in the body
The child is a non-movable compiler-owned resource. It retains the selected parent's allocator header, owns an independent allocation-list head, and presents the ordinary allocation-context header to calls. The lexical current context is selected statically at each call and destruction boundary; entering a region does not mutate a process-global or function-local ambient pointer.
The region name is an ordinary lexical binding name for lookup and diagnostics. The compiler does not infer semantics from spellings such as scratch, temp, or arena.
Rules:
- The region handle exists only inside the body.
- It cannot be reassigned, moved out, returned, or explicitly dropped by user code.
- Allocator capabilities derived from the handle carry the same region origin.
- Owned storage allocated through the current child context carries the region origin.
- Borrows, views, iterators, raw pointers, and aggregates derived from that storage preserve the origin.
- A value can leave only when every component is proven independent of the child region.
- Pure integers, booleans, and copy aggregates containing only independent fields may leave.
- Unknown provenance cannot escape.
- A nested child is shorter than its parent and may receive parent-derived values.
- A child-derived value cannot flow into its parent or an unrelated region.
At every normal exiting edge, live values owned inside the body are dropped in reverse ownership order before the child allocator releases its storage. This applies to fallthrough, return, break, continue, and ? propagation.
On arm64-darwin, each nonempty region allocation owns a mapping whose private prefix retains the previous mapping and the complete mapping byte count. Release walks that list and unmaps every entry. A failed unmap is a compiler-runtime invariant failure and terminates instead of continuing with a partially released region.
Calling a never function does not cause implicit cleanup. Allocation failure on the standard path terminates immediately without region release; the operating system reclaims process resources.
Escape Examples
Invalid owned escape:
func load_text(allocator: &+Allocator): String {
region scratch using allocator {
let text = String.copy("temporary")
return move text // error: text storage belongs to scratch
}
}
Invalid indirect escape:
struct ResultView {
text: &str
}
func load_view(allocator: &+Allocator): ResultView {
region scratch using allocator {
let text = String.copy("temporary")
return ResultView { text: &text as &str }
// error: ResultView carries a view into scratch
}
}
Valid independent result:
func count_bytes(allocator: &+Allocator): usize {
region scratch using allocator {
let text = String.copy("temporary")
return text.len()
}
}
Borrow Origins and Elision
Nocter does not expose Rust-style lifetime parameters or annotations. The compiler tracks storage origins through values and callable summaries.
Elision and inference rules:
- A borrow-like result with one borrow-like input is tied to that input.
- A method result may be tied to its borrowed receiver when that is its only declared origin.
- A concrete body can establish that a result is fresh or static, or retains none of several otherwise eligible inputs.
- A result with multiple possible inputs is constrained by all of them at the caller.
- A trusted bodyless declaration must provide compiler-owned origin metadata.
- An untrusted bodyless declaration with an ambiguous borrow-like result is invalid.
A borrow returned through a call remains a loan of the original caller place through the returned value's last source-level use. Return validation and ordinary NLL use the same callable provenance summary.
Source-level lifetime syntax may be reconsidered only when public APIs need relationships that cannot be expressed by these rules, such as multiple independently named regions in bodyless APIs, higher-order functions, or separately compiled region-parameterized types.
Explicit result provenance
An identity-based from clause expresses an otherwise ambiguous public result provenance without adding lifetime names:
pub method &self.get(key: &K): &V? from self
func choose<T>(left: &T, right: &T, first: bool): &T from left | right
An identifier after from names a receiver, ordinary parameter, or typed argument pack whose semantic value can carry storage provenance. This includes borrows, owning values, generic values, and allocator capabilities. A pack origin represents the storage carried by its elements; the ephemeral pack container itself still cannot escape the callable body. static denotes program-lifetime storage. Source-level from current is not valid; fresh ambient result storage is compiler-owned and therefore needs no public origin name. Concrete public bodies are checked against the explicit or elided origin set; bodyless interface methods use the same result-provenance rules. Origin identity follows resolved parameters and receivers rather than their formatted names. static remains accepted in explicit source, but canonical APIs omit it because callers preserve no source place for program-lifetime storage.
Result provenance applies both to source-level borrows and to pointer-backed owning aggregates. Raw pointers remain outside borrow checking, but an owning String, Vec<T>, or user-defined buffer still carries the allocation context responsible for its storage.
The execution allocation-context requirement remains compiler-owned and inferred. It is distinct from the source-visible noalloc guarantee: a callable may need the current context only to destroy existing storage without allocating new storage.
No-allocation Callable Contracts
noalloc is an optional callable guarantee. It asserts that no execution path of the callable can request new storage from a Nocter allocator:
pub noalloc func byte_count(text: &str): usize {
return text.len()
}
instance Text {
pub noalloc method &self.byte_count(): usize
}
noalloc drop Handle(&+self) {
release_without_allocation(self)
}
An unqualified callable does not promise that allocation occurs. It simply exports no allocation-free guarantee. Removing an allocation from an unqualified implementation does not change its source contract; adding an allocation to a noalloc implementation is a contract error.
The guarantee covers ordinary current-context allocation, explicit Allocator allocation, and recoverable TryAllocator allocation. Recoverable failure does not make an allocation operation allocation-free. It also covers allocation reached transitively through direct calls, selected methods, operators, coercions, typed literals, callbacks, closure bodies, interface defaults, implicit destruction, and compiler-registered primitives.
The following operations do not by themselves violate noalloc:
- stack-frame and local-value storage selected by the target ABI;
- moving, borrowing, reading, or mutating already existing storage;
- releasing existing storage without requesting replacement storage;
- a target or operating-system operation that does not request storage through a Nocter allocator;
- returning an owning value whose existing storage is moved from an input;
- returning a static view or a storage-free value.
noalloc therefore does not imply absence of deallocation, system calls, blocking, traps, recoverable failures, external side effects, or a hidden allocation-context ABI lane. Those facts must not be inferred from this guarantee.
The noalloc guarantee is transitive over every operation reachable through checked control flow. A recursive call group is allocation-free only when all of its direct operations and calls leaving the group are allocation-free. An unknown bodyless callable without a noalloc contract may allocate. Target optimization and dead-code elimination do not strengthen the source guarantee.
A noalloc body may call an unqualified source-backed helper when the complete checked program proves that helper allocation-free. An abstract interface method, bodyless primitive, or callable value has no implementation proof at that boundary and must carry noalloc explicitly before a noalloc caller can rely on it. A primitive may declare the guarantee only when its closed compiler registry entry certifies the same effect.
Callable types carry the guarantee before their invocation capability:
noalloc func(i32): bool
noalloc &func(&T): bool
noalloc &+func(&T): bool
A proven noalloc closure or callable is compatible with an otherwise identical unqualified callable contract; this erases the guarantee. The reverse conversion is invalid. Once erased by an unqualified callable binding, a later use cannot reconstruct the guarantee from a hidden witness. Generic code that invokes a callback from a noalloc body therefore requires a noalloc callable parameter contract.
An interface method marked noalloc may be implemented only by an inherent method carrying the same guarantee. A noalloc default method is checked against its body and may rely only on allocation-free operations. Public contract/private body matching requires the modifier on both declarations; neither side may silently strengthen or weaken the other.
Implicit destruction participates in the same proof. A generic noalloc body may destroy a type parameter only when its requirements establish allocation-free destruction, including the trivial case of copy T, or when ownership leaves the body without destruction. The current language does not add a separate authored drop-effect requirement; an otherwise unknown generic destructor is conservatively treated as possibly allocating.
Result provenance remains independent. A noalloc callable may return caller-owned storage, and an allocating callable may return a result derived only from an input:
noalloc func identity(value: String): String { return move value }
func view_after_work(text: &String): &str from text
Typed Literal Allocation
Typed literals use the same allocation boundary:
let values = Vec [1, 2, 3]
let values = Vec [1, 2, 3] using arena
region temp using arena {
let text = String "hello"
}
- Omitting
usingselects the current aborting allocation context. using arenaselects an established aborting allocator/context for that literal.- A region body changes the current context lexically and transitively for allocating callees.
- A literal allocated in a lexical region carries that region origin.
- Recoverable allocation uses named
try_*construction rather than changing a literal's result type according to allocator policy. - Bare
"hello"remains a static&strand performs no allocation.
The full literal and sequence rules live in Argument Packs, Literal Definitions, and Sequence Spread.
Current Non-goals
- aggregate spread or embedding
- ambient recoverable allocation contexts
- fallible
regionstatements - source-level lifetime parameters
- dynamic allocator interface dispatch or arbitrary user allocator plugins
- concurrency or thread-local context semantics
- a native backend other than
arm64-darwin