Skip to content

Ownership and Resources

Accepted

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

Catalyst wants explicit ownership vocabulary without adopting pervasive lifetime proofs.

This page owns the V1 resource model: how resource types are declared, when cleanup is semantic lowering for compiler-owned hidden values, which ordinary cleanup patterns are lintable, and how explicit move interacts with copy prohibition.

V1 Direction

  • pointers and slices are borrowed by default
  • no pervasive borrow checker
  • no forced lifetime annotations
  • ownership is modeled through conventions or explicit library/resource types
  • resource-owning types opt into copy prohibition through declaration metadata
  • ownership transfer is explicit with move
  • optional lint tooling should catch obvious ownership and resource mistakes

See Reference and Mutability for the canonical pointer, slice, and value semantics.

Ownership Surface

Owned pointer and owned slice modifiers are not the baseline direction.

Not the V1 baseline:

own *T
own []T

These make ownership visible in APIs, but risk becoming pervasive smart-pointer syntax without enough added correctness.

Preferred baseline:

Buffer(u8)
Unique(File)

This keeps the core language smaller and models ownership through ordinary types. Optional lint tooling can then make the conventions reliable for projects that enable it.

Resource Naming Conventions

Catalyst prefers ordinary naming conventions over special constructor syntax:

fn init(...) Self
fn create(alloc: *impl Allocator, ...) Self
fn create(alloc: *impl Allocator, ...) *Self
fn dispose(self: *Self) void
fn destroy(self: *Self, alloc: *impl Allocator) void

Conventions:

  • init returns Self
  • create performs allocation and returns an owning result
  • dispose is the Disposable cleanup operation; it ends the value's owned payload/resource lifetime and does not imply freeing the storage backing self
  • destroy calls dispose and frees a heap-allocated self

These are naming conventions, not magic methods. Lint tooling may check them consistently once the language supports it, but cleanup hooks are still based on Disposable, not on a method merely named destroy.

Disposable is the only formal cleanup contract in V1. destroy remains convention and lint vocabulary for APIs that receive a heap-allocated *Self plus whatever freeing authority they need. It is not a separate semantic contract in V1.

Resource Type Declarations

Resource-owning types use the prelude-defined @resource type-constructor attribute. The canonical attribute target, repeatability, and provider rules are documented in Prelude Attributes and Attribute Provider Model.

pub const File = @resource struct(pub Disposable) {
  fn dispose(self: *Self) void {
  }
}

The compiler understands the resulting target metadata, not the name @resource.

Disposable is the semantic cleanup contract:

const Disposable = contract {
  fn dispose(self: *Self) void
}

Implementing Disposable means values of that type require dispose before their lifetime is ended by an owner. Ordinary user-visible local bindings still use explicit cleanup and linting, but hidden owners and compiler-generated owner temporaries that are responsible for ending a value's lifetime must honor Disposable.

@resource requires the marked type to explicitly require or implement Disposable; a matching dispose method alone is not enough:

const Scratch = @resource struct(Disposable) {
  fn dispose(self: *Self) void {
  }
}

For public resource types, the Disposable implementation must be public:

pub const File = @resource struct(pub Disposable) {
  fn dispose(self: *Self) void {
  }
}

The visibility and Disposable checks are performed by the @resource attribute implementation through reflection, not by compiler special cases for the attribute name.

@resource does not add automatic cleanup for ordinary user-visible bindings. Cleanup remains explicit:

var file = try File.open(path)
defer file.dispose()

Because Disposable.dispose has receiver self: *Self, calling dispose() follows the ordinary pointer-receiver addressability rule. A resource rvalue cannot be disposed through receiver sugar in V1:

File.open(path).dispose() // error when dispose(self: *Self)

Use a named binding for explicit cleanup:

var file = try File.open(path)
defer file.dispose() // ok

Missing cleanup, double cleanup, use after cleanup, and live-resource overwrite are lintable ownership patterns.

Cleanup Boundaries

Semantic boundaries:

  • lints are optional tooling findings, not source-validity rules.
  • lint findings may be configured as warnings or errors by project policy when a lint engine exists.
  • compiler hard semantics stay limited to copy prohibition, explicit move state, and generated cleanup owned by language constructs.

Generated cleanup rules:

  • compiler-generated temporaries may be cleaned up by the construct that creates and owns them when their type implements Disposable.
  • collection for disposes a hidden iterator returned by iter or iter_mut if that iterator implements Disposable.
  • collection for may dispose a materialized hidden receiver temporary after the iterator.
  • generated cleanup is based on semantic conformance, not on a method merely named dispose.

Generated cleanup exists only for values owned by the compiler construct. Ordinary user-visible bindings remain explicitly cleaned by source code; optional lint tooling may report suspicious cleanup patterns.

Loop Receiver Ownership

Rules:

  • a named collection receiver is not loop-owned unless ownership is explicitly transferred with move.
  • for item in move xs and for var item in move xs transfer ownership to the loop.
  • ownership transfer follows normal move semantics for any movable receiver, including copyable and non-Disposable receivers.
  • after transfer, the original binding is moved and unusable after the loop.
  • the loop disposes the hidden moved receiver temporary after iteration only if it implements Disposable.
  • for mutable iteration, the moved receiver is materialized as mutable hidden storage.
  • existing-iterator for, including for item in it_box where it_box: Box(dyn Iterator(Item)), does not make the loop own the iterator by default.
  • callers remain responsible for explicit iterator disposal unless ownership is transferred with move, such as for item in move it_box.

Lintable Resource Patterns

Ordinary user-visible resource cleanup is not core type-state semantics. For ordinary bindings, a call to dispose is still an ordinary method or contract-operation call in the source semantics. Optional lint tooling may model cleanup conventions and report suspicious patterns.

Required V1 semantics are:

  • copy prohibition and explicit move state are hard semantic rules
  • generated cleanup for compiler-owned hidden temporaries is hard lowering semantics
  • cleanup obligations for ordinary bindings are explicit source conventions

The lint identities for this section are cataloged in Lint Catalog. Suggested severities in that catalog are advisory tooling policy, not V1 source-validity rules.

For lint classification, a value may be considered resource-like when:

  • its type explicitly implements or requires Disposable
  • its type carries @resource provenance
  • its type structurally contains resource-like fields or elements

Copy prohibition alone does not make a value resource-like. Other providers may forbid copying for non-resource reasons such as unique identity, pinning, or capability-token semantics.

Cleanup State

A linter can track obvious local states for resource-like values:

  • live
  • moved or uninitialized
  • cleaned up
  • maybe live
  • maybe cleaned up
  • pending deferred cleanup

Analysis boundary

This is best-effort cleanup analysis for optional tooling, not a full linear ownership proof. V1 does not require the compiler to prove exactly-once cleanup for ordinary user-visible bindings.

Cleanup candidates should be recognized only through resolved Disposable conformance, not through method names alone:

file.dispose()            // lint-tracked when this resolves to Disposable.dispose
Disposable.dispose(&file) // lint-tracked
file.dispose()            // not lint-tracked when it is only an inherent method name

Direct local cleanup of a known binding can mark that binding cleaned up for lint analysis:

var file = try File.open(path)
file.dispose()

file.read(buf) // lint candidate: ownership/use-after-cleanup
file.dispose() // lint candidate: ownership/double-cleanup

After cleanup, a var binding's storage may be reused with a fresh value:

var file = try File.open(path)
file.dispose()
file = try File.open(other)
defer file.dispose()

A const resource-like binding is allowed but lintable because Disposable.dispose(self: *Self) requires mutable access. The lint should not fire when every local path moves the value onward before cleanup would be needed:

const file = try File.open(path) // lint candidate: ownership/const-resource-binding unless moved onward
return move file

Passing a mutable pointer does not imply disposal or ownership transfer:

fn fill(file: *File) void

fill(&file)
file.read(buf) // no cleanup-state change is assumed

Interprocedural cleanup effects are not inferred by V1 semantics. A linter should not mark file cleaned after close(&file) unless future effect metadata or lint configuration explicitly models that API. A linter may still track direct local aliases when the alias is obvious and not reassigned:

var p = &file
p.dispose() // may mark file cleaned when p is a clear local alias

Missing Cleanup

A live resource-like value that may exit its local lifetime without being disposed or moved onward is lintable:

fn read(path: Path) void {
  var file = try File.open(path)
  file.read(...)
} // lint candidate: ownership/missing-cleanup

Moving a resource transfers cleanup obligation to the destination:

var file = try File.open(path)
var owner = move file
// no missing-cleanup finding for file; owner now carries the convention

Returning a fresh resource value or explicitly moved resource transfers the cleanup obligation to the caller:

fn open(path: Path) File!OpenError {
  return try File.open(path)
}

var file = try File.open(path)
return move file

Returning a named non-copy resource without move remains a hard copy-prohibition error.

Live Overwrite

Overwriting a live resource-like value without cleanup is lintable:

var file = try File.open(path)
file = try File.open(other) // lint candidate: ownership/live-overwrite

The lint candidate applies to obvious local cases:

  • local binding reassignment
  • field assignment when the old field value is visibly resource-owning
  • array or slice element assignment when the element type is resource-owning
  • aggregate whole-value overwrite when the old aggregate structurally contains live resources

No live-overwrite finding should be emitted when the old value was already cleaned up or moved:

var file = try File.open(path)
file.dispose()
file = try File.open(other)

var old = move file
file = try File.open(path2)

Defer Cleanup

Canonical defer source semantics are documented in Defer. This section records lintable cleanup-defer patterns.

The canonical local cleanup idiom is:

var file = try File.open(path)
defer file.dispose()

defer file.dispose() schedules cleanup for scope exit. For lint analysis, it does not mark file cleaned immediately; the value remains live and usable until the defer runs:

var file = try File.open(path)
defer file.dispose()
file.read(buf) // ok

Resource cleanup lints should treat return, try error propagation, break, and continue as early-exit edges. A pending defer satisfies cleanup on those exits:

var file = try File.open(path)
defer file.dispose()
try write_header(file)

No defer cancellation

V1 has no defer cancellation. An active cleanup defer remains scheduled until scope exit. Moving the binding before scope exit is lintable because the deferred cleanup would later target moved-from storage:

var file = try File.open(path)
defer file.dispose()

return move file // lint candidate: ownership/move-with-active-defer

Place-based defer

defer file.dispose() is place-based: the cleanup expression is evaluated at scope exit. Lint tooling can treat reassignment as coherent when the old value is explicitly cleaned and the binding holds a fresh live value by scope exit.

var file = try File.open(path)
defer file.dispose()

file.dispose()
file = try File.open(other) // ok; the defer cleans up the new file

Disposing without reassignment leaves the active defer as a double-cleanup risk:

var file = try File.open(path)
defer file.dispose()

file.dispose()
// lint candidate at scope exit: ownership/double-cleanup

Aggregate Resources

Resource lint candidates are structural for obvious local aggregate cases. Aggregate analysis uses the same resource-like classification from this section: Disposable, @resource provenance, or structural containment of resource-like fields or elements.

const Session = struct {
  file: File
  buffer: Box(Buffer)
}

If the aggregate type implements Disposable, calling that implementation is the canonical cleanup. The linter should not require separate field cleanup afterward:

var session = try Session.open(...)
defer session.dispose()

Aggregate cleanup convention

Public or long-lived named types that contain resource-owning fields but do not provide Disposable are lintable. This is not a hard error because local/ad hoc aggregates and deliberate manual field cleanup may still be valid.

Copyable Disposable types are legal but suspicious. Each copy carries its own cleanup convention, and live-overwrite lint candidates still apply. Public copyable types that implement Disposable are lintable unless the design clearly documents independent cleanup per copy.

Loop Items

Resource lints can track loop item exits for value-yielding resource iterators:

for item in resource_iter {
  if skip(item) {
    continue // lint candidate: ownership/resource-loop-item-exit if item is still live
  }

  consume(move item)
}

break, continue, return, and error propagation are lintable when they leave a live resource-like loop item behind. Moving the item onward or disposing it satisfies the item convention. Pointer-yielding loops, such as array and slice loops over *T / *const T, do not create ownership obligations for the pointed-to elements.

Copy and Move

Plain assignment, argument passing, aggregate construction, and returning a named binding are copy-like in the semantic model. A type may forbid copying through declaration metadata:

target.set_copyability(.forbidden)

If copying is forbidden, these are hard errors:

var b = a
consume(a)
b = a
return a

Ownership transfer uses explicit move:

var b = move a
consume(move b)
return move c

After move a, a is uninitialized and unusable until reassigned. A moved const binding cannot be reassigned and remains unusable. move is valid for copyable and non-copyable types; it always moves and invalidates the source binding.

Fresh rvalues can flow by value without move because there is no source binding to invalidate:

consume(make_file())
return File.open(path)
Owner{ .file = File.open(path) }

Movable places

move applies only to whole local bindings and by-value parameters in V1. Moving from fields, array/slice elements, optional payloads, dereferenced pointers, globals, module bindings, or captured outer variables is invalid or deferred.

The compiler tracks explicit move state for local bindings and by-value parameters with definite-assignment-style analysis:

  • initialized
  • moved/uninitialized
  • maybe moved

Move-state boundary

Use after moved or maybe-moved is a hard error. This is not borrow checking and does not require the compiler to understand cleanup methods outside explicit move/copy state.

Structural Copy Eligibility

Aggregate copy eligibility is structural:

  • a struct is implicitly copyable only if all stored fields are copyable
  • an array is copyable only if its element type is copyable
  • an optional is copyable only if its payload type is copyable
  • a union or sum type is copyable only if every payload variant is copyable

Moving the whole binding is still allowed:

var files2 = move files

Slices and pointers are borrowed descriptors/references. Copying []T, *T, or *const T does not copy the T value and remains allowed even when T is non-copy. Reading a pointee or slice element by value attempts to copy the T and is rejected if copying is forbidden. Moving out of a pointee or element is invalid in V1.

Resource-like values do not opt back into implicit copyability in V1. Duplicating ownership uses explicit APIs. Future Cloneable and Copyable capability design is tracked by CEP-0008: Copy, Clone, and Ownership Capabilities.

Non-copy does not imply Disposable. Copyability and cleanup are separate capabilities. @resource implies both copy prohibition and visible Disposable, but other compiler or prelude mechanisms may forbid copying for non-resource reasons such as unique identity, pinning, or capability-token semantics.

Generic code may copy unconstrained T; instantiation fails if the concrete T forbids copying. Future Copyable constraints are tracked by CEP-0008: Copy, Clone, and Ownership Capabilities.

Box Resource

Box(T) ownership and boxed dynamic iteration are documented in Box Resource.