aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design/destructors.md121
1 files changed, 121 insertions, 0 deletions
diff --git a/docs/design/destructors.md b/docs/design/destructors.md
new file mode 100644
index 0000000..151ecca
--- /dev/null
+++ b/docs/design/destructors.md
@@ -0,0 +1,121 @@
+# destructors on heap objects (draft)
+
+status: design draft, no compiler code. it parks `return defer x;` and covers that use case with owned results instead. section numbers refer to `docs/SPEC.md` at `d728a06`.
+
+## the problem
+
+an owned heap object can stand for something outside the heap: a file descriptor, a socket, a lock, a native buffer, a registration in a table. today the analyzer frees the memory at scope end (6.5.1, `rt.recycle`), and the collector reclaims what nobody owns (6.9.3). neither one closes the outside resource. the caller has to remember a `defer close(x);`, and a function that opens something can't hand the cleanup up to its caller. acorn's `return defer` idea was meant to fill that gap. a destructor fills it more directly: the cleanup belongs to the type, so it follows the object wherever ownership goes.
+
+## 1. declaration: one destructor per type
+
+proposal: a method named `drop` with no parameters and no result, declared in the struct body.
+
+```zc
+struct file {
+ int fd;
+ void drop() { os.close(this.fd); }
+}
+```
+
+- each struct type has at most one `drop` (error: file: has more than one drop). a generic struct's `drop` is compiled once per instance, like its other methods (2.13.1).
+- `this` is borrowed (`.m`) inside `drop`. the body may read fields and call functions, but it can't give `this` away, store it in a global, or free it (error: drop can't give this away). the runtime frees the block after `drop` returns, as it does now.
+- `drop` is never called by name (error: drop is called by the runtime, not directly). an early close is `rt.free(x)`, which runs it.
+- a child owned by a field (`own`, `w[-2] == p`) is dropped after its parent's `drop` returns, in field order. this matches `release`/`recycle`, which already walk the children after the parent is marked going. so the parent's `drop` still sees live children.
+- types with no `drop` stay as they are.
+
+alternatives, briefly:
+- `~file() { ... }` (c++ style). needs a new token form and reads as foreign next to the rest of zc.
+- `dtor file(file*.m f) { ... }` as a top-level item. this works for types declared elsewhere, but it splits the type and allows duplicates across units.
+- a parse word registered by a library (7.2), like actor. nice for the compiler size, but every free path needs the hook anyway, so it buys little.
+- a per-object callback (`rt.on_free(p, fn)`). flexible, but costs a word on every object or a side table, and it can't be checked statically. it could still be offered later for untyped `new(n)` blocks.
+
+## 2. when it runs
+
+`drop` runs exactly once, at the moment the object dies:
+- an `m` local reaches the end of its block or an early exit (`return`, `break`, `continue`), in the same reverse declaration order as today's inserted frees (6.5.1).
+- an explicit `rt.free(x)` or `rt.recycle(x)`.
+- a temporary held in a hidden frame slot (6.5.4) is freed after its full expression.
+- a container is freed and its children go with it (6.5.3, 6.8.6). a `T[?]` of droppable elements drops them in index order. `pop` hands the element out fresh, so the container no longer drops it.
+- the collector reclaims an unowned object (section 5).
+
+## 3. moves transfer the duty
+
+the analyzer already proves that an owner is freed once: giving a value away (`.d` parameter, store into a global or container, `return`) makes the local dead (6.9.2), and the dead local isn't freed. `drop` rides on that same path, so it inherits the guarantee. no runtime flag is needed.
+
+- **partial moves.** zc fields carry no tags (2.12.2), and a field's child is owned through `own`. moving a child out (`word c = p.child; p.child = 0;`) is what code does today. proposal: moving out of a field of a droppable struct is allowed only by swapping in a replacement (`take(p.child)` sets the field to 0 and returns the old value fresh). the parent's `drop` must handle a null field. a direct field read that leaves the field still pointing at a moved child is an error when the parent type has a `drop` (error: p.child: moved out of a type with drop; use take). without that rule the parent's child walk would drop the child a second time.
+- **conditional moves.** 6.10.1 already handles a value given away on one path: the compiler frees it on the other paths on their way into the join. `drop` runs at that inserted free. a loop can't give a value away on a path that comes back around (6.10.2), so there is no "maybe moved" state at runtime, and no drop flag is needed.
+- **reassigning an owner.** today overwriting a still-owning variable is an error (6.8.5: overwriting it would leak it). keep that error. the alternative, dropping the old value implicitly on assignment, is c++-like and hides a side effect in `x = y;`. open question 3.
+- **aliases and borrows** never drop. only the owner's death does.
+
+## 4. returning an owned value
+
+a function that returns a `.u` result gives the caller the object, and with it the drop. the caller's scope end runs it:
+
+```zc
+file*.u open_log() { file*.u f = rt.new(file); f.fd = os.open("log"); return f; }
+
+fn main() {
+ file*.m log = open_log();
+ write(log, "hi");
+} // log's drop closes the fd here
+```
+
+this covers what `return defer x;` was for. the cleanup is written once on the type instead of at each return, the handoff shows up in the result type, and passing it along further is just another `return`. `return f();` hands the object up because a call to an owned-result function is fresh (6.1). if the caller binds it to an `m` local instead, the drop runs at that local's block end. this is the same as zcoder's proposed rule for `return defer`, so the semantics don't change for that use case.
+
+a cleanup with no object behind it (a log line at the caller's scope end, say) still needs a carrier: a tiny struct with a `drop`. that is the price of parking `return defer`. open question 4.
+
+## 5. the collector
+
+objects reachable only through globals, plain pointers, or unchecked code belong to the collector (6.9.3). the collector reclaims them whenever a sweep happens to run. so their `drop` runs late, or never if the program exits first. rule: **a resource that must be closed on time has to be owned by a checked local or by an owned chain from one.** the docs should say so plainly, and memdebug can warn (section 7).
+
+- **when it runs.** proposal: the sweep never runs user code. `gc()` gathers unmarked live objects whose type has a `drop` onto a finalize list instead of calling `bfree`. those blocks keep their state, so they stay out of the free lists. after `gc_active` is cleared, the runtime calls each `drop` and then frees the block. running user code mid-sweep would let it call `new` (which could reuse a block the sweep hasn't reached) or store pointers into blocks that are about to go.
+- **order.** within one sweep, finalize order is the heap address order of the sweep walk, so it is effectively unspecified. a parent and its owned child are both unmarked. the parent runs first, and its children follow through the normal walk, because the collector frees the parent and the parent's release frees the children. two unrelated unreachable objects have no defined order, and a `drop` must not assume the other is still open.
+- **resurrection.** a `drop` that stores `this` (or a child) somewhere reachable would bring it back to life. the static rule in section 1 forbids giving `this` away, but unchecked code inside `drop` could still do it. proposal: an object is finalized at most once. a header bit `F_DROPPED` is set before the call. if a later mark finds the object alive, it stays alive, but its `drop` won't run again. under memdebug, finding a dropped object reachable at the next sweep prints `zy: object revived by drop from line N`.
+- **shared objects** (`F_SHARE`) have no single owner. `free` and `release` skip them today. their `drop` runs only from the collector.
+
+## 6. interactions
+
+### defer
+at a scope end, the order is: the block's defers (newest first, 4.14.2), then the inserted frees of the block's `m` locals in reverse declaration order, each running its `drop`. this keeps 4.14.7 true: a deferred statement sees its locals still alive. so `defer flush(log);` runs before `log`'s drop closes it. an explicit `defer rt.free(x);` runs `drop` at the defer's point, and the local isn't freed again.
+
+### o3 regions
+o3 changes speed, not meaning (4.10.1), so drop calls stay. two notes: an o3 untyped `rt.new(n)` is `new_oz` with no type stamp, so it can't have a drop. and an inserted free in an o3 function of a type with a `drop` is a call out of the region, which will cost inlining in the llvm/x86 tiers. that's acceptable, since droppable objects are rarely in hot loops. the optimiser must not reorder a drop past another side effect.
+
+### unchecked and unbounded
+unchecked code has no inserted frees (6.12). what it allocates belongs to the collector or to an explicit `rt.free`. an explicit `rt.free` in unchecked code still runs `drop` (the runtime does it, not the analyzer). unbounded affects only index checks, so it has no effect here. the runtime itself is unchecked and unbounded, so it can't depend on the analyzer to drop anything for it.
+
+### traps and exit
+- a runtime trap (exit 2) does not unwind. no defers run today, so no drops run either. resources are left to the os. this matches defer.
+- a normal exit (main returns, or `rt.exit`): main's scope-end frees run their drops before exit, as usual. objects still held by globals or the collector are **not** finalized at exit by default (no final sweep). open question 6.
+- a trap inside a `drop` is a trap like any other.
+
+### recycle, quarantine, memdebug
+- `free` → `release` leaves the block quarantined until a sweep (6.9.3). `drop` runs before the quarantine, while the object is still fully valid: the header is still live, and `len` and indexing work. that is because `release` clears `n` when it marks the block quarantined.
+- `recycle` and `recycle_leaf`: run `drop` before the child walk. `recycle_leaf` is the fast path for leaf types. a type with a `drop` gets no `recycle_leaf`; the compiler routes it to the general free instead (see the cost section).
+- double free under memdebug is caught by `quar(p)` before `drop`, so a double free never runs `drop` twice. without memdebug a double free of a released block finds `n == 0` and a cleared state, and `drop` is skipped by checking `F_DROPPED`.
+- `memdebug scrub` fills the payload after `drop`, never before.
+- new: `rt.memreport()` marks leaked objects whose type has a `drop` (`leak: line N W words (drop: file)`), since those are leaked resources, not just leaked words.
+
+### actors
+- `spawn` snapshots the whole vm into a worker process. every owned object in the snapshot is copied too, so the worker's owners drop their copies, and the parent drops its own. a `drop` that closes an fd would then close it twice, once per process. the fd is duplicated across the fork, so the os is fine, but a drop that commits or deletes something external would act twice. proposal: an `actor`-safe marker is not needed yet. document that a droppable object must not be live across `spawn` unless its drop is idempotent per process, and have the analyzer warn on a live droppable local at a `spawn` call (hint level, 4.12).
+- messages copy their typed object graphs (ACTOR.md). the sender keeps and drops its graph; the receiver's copy is a new owner with its own drop. a copied fd handle is just an integer, which is wrong for real resources. proposal: a type with a `drop` can't be sent by copy (error: file: has drop and can't be copied into a message). sending it by move, when actor gains move sends, transfers the duty to the receiver and makes the sender's local dead, as for any `.d` give-away.
+- a worker exiting normally runs its main-level drops, like any program.
+
+## 7. cost
+
+- **types without drop pay nothing.** whether a type has a `drop` is known when the type is compiled. the type record (`p[-1]`) gains one field: the drop address, or 0. the compiler emits drop calls only for frees whose static type has a drop. every other free keeps today's code, including `recycle_leaf`. untyped `new(n)` and `new_oz` blocks have no type record, so they never drop.
+- **free/release/recycle.** the runtime still has to handle frees where the static type isn't known (unchecked `rt.free`, children found by the walk, the collector). each of those adds one load and test: `t = p[-1]; if (t > 0 && t[DROP]) ...`, which is cheap next to the existing `scan_ptr` walk. the `F_DROPPED` bit takes one free header flag; a header bit audit should confirm which one.
+- **the collector.** one extra test per unmarked object in the sweep, plus a finalize list that grows like the mark stack (`reserve_mark`).
+- **genome.** the parser adds about one method name check and one type record field. the analyzer adds the `take`/partial-move rule and the actor send check. the runtime adds the drop test in three places and the finalize list in `gc`. a rough guess is a few hundred words. the compiler itself defines no droppable types, so it runs no drops. zc-lines should measure the fixpoint once code exists.
+
+## open questions for acorn
+
+1. is `drop` as a method name right, or do you prefer `~T()` or a separate top-level `dtor` item?
+2. should `drop` see live children (parent first, as proposed), or should children drop first, like c++ members?
+3. reassigning a still-owning variable: keep the error (6.8.5), or drop the old value implicitly?
+4. with `return defer` parked, is a small carrier struct with a `drop` good enough for cleanups that have no object behind them?
+5. partial moves: is the `take(p.field)` rule acceptable, or should droppable types simply forbid moving children out?
+6. at a normal exit, should the runtime run one last sweep so collector-owned objects get their drop, or leave them to the os (faster exit, as proposed)?
+7. collector finalization: finalize after the sweep with a once-only `F_DROPPED` bit, as proposed, or forbid `drop` on objects that can reach the collector at all?
+8. actors: hard error on sending a droppable type by copy, and only a hint on a live droppable at `spawn`. too strict, or not strict enough?
+9. should untyped blocks get an opt-in callback (`rt.on_free`), or stay drop-free for good?