Trivial, and the Values a Region Can't Hold
A region works by bumping a pointer and freeing the whole area at once — which is only sound for values that need no cleanup. But a database connection, a file, a socket must be closed. Mere's answer is a clean split of responsibility: region manages memory, `with` manages cleanup, and the constraint that keeps them apart has a name — Trivial.
The last episode collapsed arena and region into one concept and left a question sharpened on the table: what is actually allowed to live in a region? A region works by bumping a pointer and, when its block ends, throwing the whole area away in one move. That is fast precisely because it runs no per-value cleanup — it just resets a pointer. Which means it is only sound for values that need no cleanup.
Plenty of values don’t fit that. A database connection has to be closed and its open transaction aborted. A file has a descriptor to release. A buffered logger has to flush. A socket has to shut down its session. These values own something outside memory, and freeing the memory they sit in does not release it. Reset the pointer and you leak a connection, or worse.
So the region model has a hole exactly where the interesting resources are. This episode is about the constraint that names the hole, and the mechanism that fills it.
Trivial: the price of bump-and-forget
The constraint is called Trivial: a type is Trivial if it needs no cleanup
— nothing to run when it dies, just memory to reclaim. Integers, tuples of
integers, string slices that borrow their bytes from elsewhere, a node in a
parse tree — all Trivial. Only Trivial types may live in a region.
This is not a limitation the design apologizes for; it is the whole reason a region is cheap. Bulk-free is only correct when there is nothing to free but the bytes. Trivial is the property that makes “throw the area away in one move” sound, stated as a rule the type system can check instead of a convention you have to remember. It is the same move the language makes everywhere: take the condition that has to hold for an optimization to be valid, and make it explicit in the types rather than implicit in your discipline.
The opposite of Trivial is Drop: a type with cleanup to run. And capabilities
— the values that carry the right to touch the outside world, which a later
episode is about — are mostly Drop. That is the tension in one line: regions
want Trivial, and the resources worth managing are Drop. Where do the Drop
values go?
The decision: region for memory, with for cleanup
There were three ways out, and it is worth seeing them because the choice is really a choice about how many concepts the language carries.
One option was to let regions hold Drop values after all, and have the region
call each value’s cleanup in reverse order when it tears down. That collapses
everything into one construct — no second mechanism to learn — but it also
destroys the property that made regions cheap. A region would now carry a list
of cleanups to run, its teardown is no longer just a pointer reset, and the
Trivial optimization is gone. It also fixes cleanup order to “reverse of
allocation,” which isn’t always the order dependencies need.
Another option was to bring back two kinds of area — a Trivial-only fast one
and a Drop-tolerant general one — which is exactly the split the previous
episode spent its whole length arguing out of existence. Reintroducing it to
solve this would trade the win back.
Mere took the third path: keep the region strictly Trivial, and give cleanup
its own construct — with. A value that needs cleanup is bound with with,
and its cleanup runs when the with scope ends.
with db = Database.connect("..."),
logger = Logger.buffered_stdout()
in {
region req_r {
// allocate request-scoped Trivial data into req_r
handle_request(req, db, logger, &req_r)
}
// req_r freed here: one pointer reset, no cleanup — it was all Trivial
}
// with scope ends: logger flushed, then db closed
What this buys is not fewer keywords — it is two mechanisms with one sentence
each of responsibility. region manages memory: where bytes live and when
the area is reclaimed. with manages cleanup: which resources are live and
when they are released. A reader looking at code can say, in one line, what each
construct is accountable for, and the two never blur into each other. That
legibility is the same thing the language optimizes for everywhere; here it is
bought by not merging two responsibilities that happen to both fire “at the
end of a scope.”
Cleanup order, and paths that don’t reach the end
Cleanup runs in LIFO order — the reverse of the with bindings. In the
example, logger is released before db, because logger was bound second.
This is the same rule as RAII in C++ and Drop in Rust, and it is the right
default: you tear down in the reverse of the order you built up, so a resource is
never released while something constructed after it might still be using it.
The part that matters more than the happy path is what happens when control
doesn’t reach the end of the block. A cleanup guarantee that only holds on the
normal exit is not a guarantee. So with releases its values on every path
out of the scope:
with file = File.open("a.txt"),
db = Database.connect("...")
in {
if some_condition {
return error_case // db closed, then file closed — on the way out
}
process(file, db)
// normal exit: db closed, then file closed
}
Early return, normal fall-through, or a panic unwinding through the frame —
all of them run the same LIFO cleanup. The value is bound to the scope, not to
one exit from it.
When cleanup is a decision, not a default
Some resources have more than one way to close. A database transaction can be committed or rolled back, and which one happened is a decision the program makes, not a default the runtime should pick. Mere handles this by letting you consume the value:
with tx = db.begin() in {
if ok {
tx.commit() // consumes tx (an owned move) — its Drop will not run
}
// a tx that was never committed is rolled back by its Drop
}
commit takes tx by owned move, so after it, tx is gone and there is
nothing left for the scope to clean up. A tx that falls off the end without
being committed still has its cleanup run — and that cleanup is a rollback. The
safe outcome is the default; the other outcome is something you have to say. The
ownership rules from the memory model and the cleanup rules here are the same
machinery, seen from two sides.
What this settles
With Trivial and with in place, the region model is finally complete enough
to reason about. A region holds Trivial data and reclaims it for free; anything
with real cleanup is held by with and released in LIFO order on every exit
path; and the ownership system lets you opt a value out of automatic cleanup by
consuming it. Two constructs, one responsibility each, and a named constraint —
Trivial — marking exactly where the line between them falls.
There is still one question the previous two episodes kept deferring: what does it mean to hold a reference into a region, and how does the language stop such a reference from outliving the region it points into? That is a small theory of its own. Next: view types, and the three rules that make a borrow into a region safe.