Resource Cleanup: defer vs RAII vs manual
The same job in seven languages: acquire a resource (allocate a buffer, then open a file), use it, and guarantee the buffer is freed and the file closed on every exit path - including early return and error. This is the core memory-management question: who owns the resource, and what runs the cleanup? Compare C's goto cleanup ladder, C++ RAII (destructors that run automatically as scopes unwind), Zig's defer/errdefer, the defer of Hare and Odin, and the fully manual paths in HolyC and Forth.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Manual cleanup with a single goto ladder: acquire in order,
* release in reverse, and have every error jump to the right rung
* so nothing acquired so far is leaked. */
int load_config(const char *path) {
int rc = -1; /* assume failure */
char *buf = NULL;
FILE *f = NULL;
buf = malloc(4096); /* 1: heap buffer */
if (buf == NULL)
goto out; /* nothing to undo yet */
f = fopen(path, "rb"); /* 2: file handle */
if (f == NULL)
goto free_buf; /* undo step 1 only */
size_t n = fread(buf, 1, 4096, f);
if (ferror(f))
goto close_file; /* undo steps 2 then 1 */
printf("read %zu bytes\n", n);
rc = 0; /* success */
close_file:
fclose(f);
free_buf:
free(buf); /* free(NULL) is a no-op */
out:
return rc;
}C has no automatic cleanup, so the idiom is a goto-cleanup ladder: acquire resources in order, label each release, and have every failure jump to the rung that unwinds exactly what was acquired. free(NULL) and the reverse-order releases keep it leak-free and double-free-free.
#include <cstdio>
#include <memory>
#include <stdexcept>
#include <vector>
// RAII: ownership lives in objects, and their destructors run
// automatically as the scope unwinds -- on normal return AND on
// any exception. No goto, no manual free/close, no leak paths.
struct FileCloser {
void operator()(std::FILE *f) const noexcept { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
void load_config(const char *path) {
// unique_ptr owns the heap buffer; its dtor frees it.
auto buf = std::make_unique<std::vector<char>>(4096);
// unique_ptr with a custom deleter owns the FILE*; its dtor closes it.
FilePtr f(std::fopen(path, "rb"));
if (!f)
throw std::runtime_error("open failed"); // buf still freed by its dtor
std::size_t n = std::fread(buf->data(), 1, buf->size(), f.get());
if (std::ferror(f.get()))
throw std::runtime_error("read failed"); // f closed, buf freed on unwind
std::printf("read %zu bytes\n", n);
} // f.~FilePtr() then buf.~unique_ptr() run here, in reverse order
RAII makes cleanup the destructor's job: unique_ptr owns the buffer and a custom-deleter unique_ptr owns the FILE*. Whether the function returns normally or throws, destructors fire in reverse construction order during stack unwinding, so there is no explicit free/fclose and no leak path to forget.
// HolyC has no defer and no RAII -- cleanup is fully manual, like C,
// but with no memory protection, so a missed Free or double-Free can
// corrupt the whole ring-0 system. Acquire in order, Free in reverse.
I64 LoadConfig(U8 *path)
{
I64 rc = -1; // assume failure
U8 *buf = MAlloc(4096); // per-task heap; MAlloc never returns NULL in TempleOS
CFile *fd; // FOpen returns a CFile*, not a descriptor
fd = FOpen(path, "r"); // open the file
if (!fd) {
Free(buf); // undo the buffer before bailing out
return rc;
}
I64 size = FSize(fd); // file size in bytes
if (size > 4096) { // too big for our 4096-byte buffer: bail out
FClose(fd); // reverse order: close first...
Free(buf); // ...then free (Free(NULL) is allowed)
return rc;
}
// FBlkRead reads whole BLK_SIZE (512-byte) sectors: (file, buf, start, cnt).
// 4096 bytes is 8 blocks, so reading 8 blocks fills the buffer safely.
FBlkRead(fd, buf, 0, 8); // read blocks [0,8) into buf (zero-filled past EOF)
Print("read %d bytes\n", size);
rc = 0; // success
FClose(fd); // single success-path cleanup, in reverse
Free(buf);
return rc;
}HolyC offers no defer or destructors, so cleanup is hand-written exactly as in C: pair every MAlloc with a Free and every FOpen with a FClose on each return path. Because TempleOS runs in ring 0 with no memory protection, a leaked or double-freed pointer can corrupt the entire system.
const std = @import("std");
// defer runs on EVERY scope exit; errdefer runs only when the scope
// exits via an error. Allocation is explicit through the allocator,
// so cleanup sits right next to acquisition and never goes hidden.
fn loadConfig(allocator: std.mem.Allocator, path: []const u8) !void {
const buf = try allocator.alloc(u8, 4096); // explicit allocation
defer allocator.free(buf); // freed on any exit, success or error
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // closed on any exit
const n = try file.readAll(buf); // 'try' propagates errors; defers still run
std.debug.print("read {d} bytes\n", .{n});
}
// errdefer is for the half-built case: keep the resource on success,
// release it only if a LATER step fails.
fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
const buf = try allocator.alloc(u8, 4096);
errdefer allocator.free(buf); // freed ONLY if something below errors
try validate(buf); // on error: errdefer frees buf, then error propagates
return buf; // on success: caller now owns buf (no free here)
}
fn validate(buf: []u8) !void {
if (buf.len == 0) return error.Empty;
}Zig has no RAII; instead defer schedules cleanup that runs at scope exit and errdefer runs cleanup only on the error path. Because the Allocator is passed in explicitly there are no hidden allocations, and errdefer cleanly handles "free the half-built object, but transfer ownership to the caller on success."
use fmt;
use fs;
use io;
use os;
// Hare has defer (Zig/Go-style) but no errdefer and no RAII.
// alloc/free are manual; defer keeps the free next to the alloc and
// runs it when the function's scope exits, on every path.
fn load_config(path: str) (void | fs::error | io::error) = {
let buf: []u8 = alloc([0u8...], 4096); // explicit heap allocation
defer free(buf); // freed on every scope exit
const file = os::open(path)?; // '?' returns the error if open fails
defer io::close(file)!; // closed on every scope exit
// read into the buffer; '?' propagates errors, deferred frees still run.
const n = io::read(file, buf)?;
match (n) {
case io::EOF =>
fmt::println("empty file")!;
case let got: size =>
fmt::printfln("read {} bytes", got)!;
};
};Hare uses defer to attach cleanup to a scope: defer free(buf) and defer io::close(file)! run on every exit, so the ? error-propagation operator can bail out at any line without leaking. There is no RAII and no errdefer, so cleanup that must differ between success and failure has to be coded by hand.
package config
import "core:fmt"
import "core:os"
// Odin has defer (runs in reverse order at scope exit) but no RAII.
// new/make/free/delete route through the implicit context.allocator,
// so allocation is explicit yet the in-scope allocator is threaded for you.
load_config :: proc(path: string) -> bool {
buf := make([]u8, 4096) // uses context.allocator
defer delete(buf) // freed at scope exit, on every return path
fd, err := os.open(path, os.O_RDONLY)
if err != os.ERROR_NONE {
return false // 'buf' still freed by the deferred delete
}
defer os.close(fd) // closed at scope exit (after delete, reverse order)
n, rerr := os.read(fd, buf)
if rerr != os.ERROR_NONE {
return false // both defers run: close fd, then delete buf
}
fmt.println("read", n, "bytes")
return true
}Odin schedules cleanup with defer, which runs in reverse registration order at scope exit on every return. Allocation is manual (make/delete, new/free) but routes through the implicit context.allocator; swapping that for an arena lets you skip per-resource frees and reclaim everything in one reset instead.
\ Forth has no defer and no RAII. Heap memory comes from the optional
\ ALLOCATE/FREE word set (C-style malloc/free), and you must pair every
\ ALLOCATE with a matching FREE by hand -- there is no automatic unwind.
\ The closest idiom to scoped cleanup is a disciplined single exit point:
\ acquire, do the work, then FREE unconditionally before returning.
: load-config ( c-addr u -- flag ) \ filename addr/len -> success?
2drop \ (demo) drop the name; pretend it is open
4096 allocate ( buf ior ) \ request 4096 bytes
if drop false exit then \ alloc failed: nothing to free, bail out
( buf )
dup 4096 read-into ( buf -- buf n ) \ app word: fill buffer, leave byte count
." read " . ." bytes" cr \ ( buf ) print the count
free drop \ ALWAYS free the buffer; drop the ior
true ;
\ Note: because FREE sits on the single fall-through path, an early
\ ABORT or exception would skip it -- robust Forth wraps the body in
\ CATCH and FREEs in the handler, manually mimicking try/finally.Forth has neither defer nor RAII; heap memory uses the optional ALLOCATE/FREE word set and every ALLOCATE must be matched by a hand-written FREE. The cleanest analogue to scoped cleanup is a single exit point that always frees; for true "run on any exit" behavior you wrap the body in CATCH and FREE in the handler, manually emulating try/finally.