ADR-0010: Destructors
Status
Implemented
Summary
Add destructor support to Gruel, enabling types to define cleanup logic that runs automatically when values go out of scope. Destructors are required for heap-allocated types like mutable strings where free() must be called. We begin with compiler-synthesized destructors for built-in types, with a path to user-defined destructors via the drop keyword.
Context
Why Destructors Now?
Gruel's affine type system (ADR-0008) establishes that types are affine by default: they can be dropped. But currently "dropped" just means "memory is deallocated"—there's no mechanism to run cleanup code.
Mutable strings are the immediate motivator. A mutable string owns a heap-allocated buffer:
struct String {
ptr: RawPtr, // Points to heap allocation
len: u64,
capacity: u64,
}
When a string goes out of scope, we must call free(ptr). Without destructors, this memory leaks.
What Exists Today
The compiler already has most infrastructure for destructors:
- Affine type tracking: We know which values are live and when they're consumed
- Scope management:
push_scope()/pop_scope()track variable lifetimes - CFG representation: Designed for "drop elaboration" (noted in gruel-cfg comments)
- Move tracking: We know when values transfer ownership
What's missing:
- Drop instructions in the IR
- CFG pass to insert drops at scope exits
- Codegen to emit drop calls
- Runtime support for built-in type cleanup
Design Philosophy
We want a path from "built-in types have compiler-synthesized destructors" to "users can define their own destructors." This mirrors the progression:
- V1: Built-in types (String, Vec) have hardcoded cleanup
- V2: User-defined
drop()method support (provisional syntax) - V3: Finalize drop API (future ADR, may integrate with traits or other mechanisms)
Decision
Drop Semantics
When a value's owning binding goes out of scope for the last time (not moved elsewhere), its destructor runs exactly once:
fn example() {
let s = String::new("hello"); // String is allocated
// ... use s ...
} // s goes out of scope, destructor runs, memory freed
Drop order: reverse declaration order (last declared, first dropped):
fn example() {
let a = String::new("first");
let b = String::new("second");
} // b dropped, then a dropped
This matches Rust and C++ (LIFO). It's required for correctness when values may reference each other.
Types That Need Destructors
Built-in types that will need destructors (when added):
String(mutable strings, planned) — frees heap bufferVec<T>(future) — drops elements, then frees bufferBox<T>(future) — drops pointee, then frees memory
Note: None of these types exist yet. This ADR provides the infrastructure they'll need.
Types without destructors (trivially droppable):
- All primitives:
i8..i64,u8..u64,bool,() - Arrays of trivially droppable types
@copystructs (implicitly trivially droppable)
User-defined types:
- Structs with fields that have destructors need synthesized destructors
- Structs can opt-in to custom destructors (Phase 3)
Synthesized Destructors
For structs containing non-trivially-droppable fields, the compiler synthesizes a destructor:
struct Person {
name: String, // needs drop
age: i32, // trivial
}
// Compiler synthesizes (conceptually):
fn drop_Person(self: Person) {
drop(self.name); // String destructor
// age is trivial, no drop needed
}
Fields are dropped in declaration order (matches C++, Rust).
Explicit Drop (V2)
In Phase 3, users can define custom destructors:
struct FileHandle {
fd: i32,
}
drop fn FileHandle(self) {
close(self.fd);
}
The syntax drop fn TypeName(self) is chosen because:
dropas keyword clearly indicates purposefnsignals it's a function definition- Takes
selfby value (consuming) - No return type (implicitly
())
Alternative considered: impl Drop for T — deferred pending decisions on traits or other abstraction mechanisms.
IR Representation
AIR: Drop Instruction
Add a Drop instruction to AIR:
The type is needed because we may drop a polymorphic value (in the future).
CFG: Drop Placement
The CFG builder inserts Drop instructions:
- Scope exits: When leaving a block, drop all live bindings in reverse order
- Early returns: Before each
return, drop all live bindings in all enclosing scopes - Loops with break: Before
break, drop bindings declared inside the loop - Conditionals: Each branch independently drops its bindings
Example CFG transformation:
fn example() -> i32 {
let s = String::new("hello");
if condition {
return 42; // Must drop s here
}
let t = String::new("world");
0
} // Must drop t then s here
Becomes:
entry:
s = String::new("hello")
branch condition -> then, else
then:
drop(s)
return 42
else:
t = String::new("world")
drop(t)
drop(s)
return 0
Codegen
Drop instructions lower to function calls:
; drop(s) where s: String
mov rdi, [rbp-8] ; load s.ptr
call __gruel_drop_String
For trivially droppable types, the drop is a no-op (elided).
Runtime Support
Drop functions for built-in types will be added to gruel-runtime as those types are implemented. The naming convention is __gruel_drop_<TypeName>. For example, when mutable strings land:
// Example: what a String drop might look like (when String is added)
pub extern "C"
Until heap-allocated types exist, the runtime won't need any drop functions.
Copy Types and Drop
Critical constraint: @copy types cannot have destructors.
If a type is Copy, it can be duplicated via bitwise copy. If it also had a destructor, the destructor would run multiple times (double-free). Therefore:
@copy
struct Bad {
ptr: RawPtr, // ERROR: @copy type with pointer? dangerous
}
We enforce: @copy structs can only contain @copy fields (already in ADR-0008), and @copy types are trivially droppable (no destructor).
Linear Types and Drop
Linear types (from ADR-0008) must be explicitly consumed—they cannot be implicitly dropped:
linear struct MustConsume { ... }
fn bad() {
let m = MustConsume { ... };
} // ERROR: linear value dropped without consumption
The destructor mechanism skips linear types; they error at implicit drop points instead.
Open question: How does consumption ultimately happen? At the end of the chain, something must run the linear value's destructor. Options include special "sink" functions, an explicit consume keyword, or allowing destructors on linear types that run when ownership is transferred to a consuming function. See Open Questions.
Panic Safety
Gruel currently aborts on panic rather than unwinding. This means:
- Destructors do not run on panic (no stack unwinding)
- A panic in a destructor simply aborts
If unwinding is added in the future, destructor behavior during unwinding will need a separate ADR.
Implementation Phases
Epic: gruel-wjha
Following spec-first, test-driven development: each phase writes spec paragraphs, then tests that reference them, then implementation to make tests pass.
Phase 1: Spec and Infrastructure (gruel-wjha.7)
Spec: Add destructor chapter to specification (section 3.9 or similar):
- When destructors run (scope exit, not moved)
- Drop order (reverse declaration order)
- Trivially droppable types (primitives,
@copystructs) - Types with destructors (structs containing non-trivial fields)
Tests: Add spec tests with preview = "destructors":
- Trivially droppable types compile and run (no behavioral change)
- Golden tests showing Drop instructions in AIR/CFG output
Implementation:
- Add
Dropinstruction to AIR - Add
type_needs_drop()method toCfgBuilder(requires access to struct/array type definitions) - Add drop elaboration pass stub in CFG builder
Phase 2: Drop Elaboration (gruel-wjha.8)
Spec: Add paragraphs for:
- Drop at end of block scope
- Drop before early return
- Drop in each branch of conditionals
- Drop before break/continue in loops
Tests: Golden tests showing correct Drop placement:
- Simple scope exit
- Early return drops all live bindings
- If/else branches drop their own bindings
- Loop with break drops loop-local bindings
Implementation:
- CFG builder inserts
Dropat scope exits - Handle early returns, conditionals, loops
Phase 3: Codegen (gruel-wjha.9)
Spec: Add paragraphs for:
- Drop calls generated for non-trivial types
- Trivially droppable types elide drop calls
Tests:
- Golden tests showing generated assembly calls
__gruel_drop_* - Tests confirming no drop calls for trivially droppable types
Implementation:
- x86_64 backend: emit calls to
__gruel_drop_* - aarch64 backend: emit calls to
__gruel_drop_* - Elide drops for trivially droppable types
- Register allocation around drop calls
Phase 4: User-Defined Destructors (gruel-wjha.10)
Spec: Add paragraphs for:
drop fn TypeName(self)syntax- One destructor per type
- Destructor runs after field destructors (or before? decide)
Tests:
- Parse
drop fnsyntax - Error on duplicate destructors
- Error on wrong signature
- User destructor runs at scope exit
Implementation:
- Parse
drop fn TypeName(self) { ... } - Semantic analysis: validate signature
- Generate drop function, wire into type's destructor
Phase 5: Integration with Built-in Types (gruel-wjha.11)
This phase is deferred until mutable strings or other heap-allocated types land. It will:
- Add
__gruel_drop_Stringto runtime - Wire String type to use it
- Verify no memory leaks (valgrind clean)
Consequences
Positive
- Memory safety: Heap-allocated types clean up automatically
- RAII pattern: Enables safe resource management (files, locks, etc.)
- Path forward: Built-in to user-defined to trait-based is incremental
- Predictable: Drop order is deterministic and documented
Negative
- Complexity: Drop elaboration touches many parts of the compiler
- Performance: Drop calls have overhead (mitigated by elision for trivial types)
- Multi-backend: Must implement in both x86_64 and aarch64 backends
Neutral
- Different from Rust: We use
drop fnsyntax instead ofimpl Drop - Simpler than Rust: No
Droptrait until we have traits
Open Questions
Allocator story:
How do we hook into malloc/free? System allocator? Custom?Resolved: System libc malloc/free. See ADR-0035.Generic drops: When we have generics, how do we call drop on
T? Monomorphization? vtable?Drop during assignment: Does
x = new_valuedrop the old value? Resolved: Yes. BothStore(variable reassignment) andPlaceWrite(field/index assignment) emit a drop of the old value when the type has a destructor. ForStore, sema tracks whether the old value was moved (viamoved_vars) and encodes this ashad_live_value: boolin the AIR instruction; the CFG builder only drops if the value was live. ForPlaceWrite, the old value is always live (you cannot write to a field of a moved value), so the drop is unconditional. See spec rules 5.2:14–15.Partial initialization: What if struct construction panics mid-way? (All constructed fields should drop.)
Arrays with destructors:
[String; 10]needs to drop 10 strings. How do we track which elements were initialized?Linear type consumption: How does consumption ultimately happen for linear types? At the end of the ownership chain, something must finalize the value. Options: (a) special "sink" functions that are allowed to drop linear values, (b) explicit
consume t;statement, (c) linear types can have destructors that run when passed to a consuming function.
Future Work
- Finalize drop API: The
drop fnsyntax is provisional. A future ADR will decide the final API, potentially integrating with traits or other abstraction mechanisms if they land. - Drop flags: Runtime tracking of whether a value needs drop (for conditional moves)
- Async drop: When we have async, dropping across await points
- Copy types with Drop: With MVS (no aliasing), maybe possible? Needs research.
References
- ADR-0008: Affine Types and MVS — Ownership foundation
- Rust Drop trait — Inspiration
- C++ Destructors — Drop order rules
- Hylo Deinitialization — MVS approach to cleanup