Manual Memory Management
malloc/free, new/delete, alloc/free across seven GC-free systems languages - the discipline, the failure modes, and the one question that matters: who frees this?
Every language on C-lingua ships without a garbage collector. That single fact is the soul of systems programming and the reason this article exists. When there is no GC, a heap allocation is not a convenience the runtime cleans up behind you - it is a resource you own, a promise that someone, someday, will hand it back exactly once. No more (that is a double-free), no fewer (that is a leak).
This article walks the same primitive across seven languages - C, C++, HolyC, Zig, Hare, Odin, and Forth - and asks two questions of each: who owns the block, and who is responsible for freeing it? The shape of each answer is the language's philosophy of memory in miniature.
The contract: request bytes, return them exactly once
The C heap API is tiny and brutally honest, and everything else here is a reaction to it. malloc(size) returns a pointer to at least size uninitialized bytes, or NULL if it cannot. calloc(n, size) allocates n * size bytes and zeroes them. realloc(ptr, newsize) resizes a block, possibly moving it - so you must use the returned pointer and discard the old one. free(ptr) returns a block to the allocator.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof *p); /* size from the pointer, not the type */
if (p == NULL) { /* malloc CAN fail: always check */
perror("malloc");
return 1;
}
*p = 42;
printf("%d\n", *p); /* prints 42 */
free(p); /* YOU free what YOU malloc */
p = NULL; /* defuse the dangling pointer */
return 0;
}
Three corollaries fall out of that contract, and you violate them at your peril:
- Check the return value.
mallocreturnsNULLon failure; dereferencingNULLis undefined behavior. mallocdoes not zero memory. Reading an unwritten block yields garbage. Usecalloc, or write every byte before you read it.freeneeds no size. The allocator records each block's size in hidden bookkeeping beside the data, which is exactly why you must pass back the pointermallocgave you - never one computed by arithmetic into the middle of the block.
Note the idiom malloc(sizeof *p): sizing the allocation from the pointer means the type is written once. And note p = NULL after free - free(NULL) is a guaranteed no-op, while free-ing a stale pointer is not.
Ownership and lifetime: the policy malloc won't give you
malloc/free give you a mechanism but no policy. The policy questions are ownership (who must eventually free this block?) and lifetime (over what span is this pointer valid?). Get them right on paper and most bugs evaporate; get them wrong and no language feature will save you.
There is normally exactly one owner. A function may borrow a pointer (read or write through it without freeing) or take ownership (promising to free it, or pass it on). C encodes this only in conventions - names, comments, signatures:
size_t count_words(const char *text); /* BORROWS: does not free */
char *read_file(const char *path); /* TRANSFERS ownership to caller */
void list_push(List *l, char *owned); /* TAKES ownership of `owned` */
void use(void) {
char *body = read_file("in.txt"); /* we now own `body` */
if (!body) return;
size_t n = count_words(body); /* borrow: still ours after */
printf("%zu words\n", n);
free(body); /* we free, because we own */
}
Lifetimes also form a hierarchy. If a struct owns pointers to other heap blocks, a destructor must walk the whole ownership tree - children first, then the array, then the struct:
typedef struct { char **lines; size_t len; } Doc;
void doc_free(Doc *d) {
for (size_t i = 0; i < d->len; i++)
free(d->lines[i]); /* free children first */
free(d->lines); /* then the array of pointers */
free(d); /* then the struct itself */
}
The single most common cause of leaks and double-frees is two pieces of code that each think they own the same block. Every safety mechanism below - RAII, defer, arenas - is ultimately a way to make ownership and lifetime automatic, checked, or at least obvious, instead of living only in comments and in your head.
C++: new/delete, and why idiomatic code hides them
C++ inherits C's heap but adds two things: typed allocation that runs constructors and destructors, and RAII (Resource Acquisition Is Initialization) to make cleanup automatic.
The raw primitives are new/delete for single objects and new[]/delete[] for arrays - and mismatching them (delete on a new[] block) is undefined behavior:
int *one = new int(42); // allocate + construct
int *many = new int[64]; // array form
delete one; // destruct + free, matching `new`
delete[] many; // matching `new[]` - NOT plain delete
But idiomatic modern C++ almost never writes those. Instead it wraps memory in a smart pointer whose destructor runs delete deterministically when the object leaves scope - on every exit path, including an exception unwinding the stack:
#include <iostream>
#include <memory>
int main() {
auto p = std::make_unique<int>(42); // unique owner, freed by ~unique_ptr
std::cout << *p << '\n'; // prints 42
return 0; // delete happens automatically here
} // no leak, no double-free, no manual free
The ownership models are encoded in the type:
std::unique_ptr<T>- a single owner, move-only. Copying is forbidden (which is what prevents a double-free); moving transfers ownership. Freed by its destructor.std::shared_ptr<T>- reference counted. The block is freed when the last owner dies. The cost is an atomic increment/decrement per copy, plus a blind spot: a cycle ofshared_ptrs never reaches zero and leaks forever (break it withstd::weak_ptr).
The trade-off: cleanup logic lives once, in the type, and applies everywhere - but the rule is invisible at the use site (you must know that vector and unique_ptr own heap memory), and copy/move semantics must be correct or you get a double-free.
HolyC: classic C, from the task heap, in ring 0
HolyC is Terry A. Davis's C dialect, the systems language of TempleOS. It is genuinely impressive work - Davis wrote the kernel, the compiler, and the language alone - and its memory model is essentially classic C with capital-cased builtins and one important twist.
Allocation is MAlloc (uninitialized) and CAlloc (zeroed); release is Free. As in C, Free(NULL) is a safe no-op. We highlight HolyC as C because the grammar is C:
// Top-level code runs directly in TempleOS - no main() needed.
I64 *p = MAlloc(sizeof(I64)); // 8 bytes from this task's heap
*p = 42;
Print("%d\n", *p); // prints 42
Free(p); // return it; Free(NULL) is allowed
The twist is the heap is per-task. MAlloc draws from the current task's data heap by default (you can target another task by passing it explicitly). This means an allocation's lifetime can be tied to its task: when the task dies, its entire heap is reclaimed wholesale, so a short-lived task is forgiven its individual leaks. The discipline for long-lived allocations is otherwise identical to C - one MAlloc, one Free.
What HolyC deliberately omits is protection. TempleOS runs everything in ring 0 with a single address space and no virtual memory, by design - it was conceived as a simple, transparent machine. The upside is directness; the cost is that a double-free or a stray write can corrupt the whole system, with no MMU to catch it. The model is honest and minimal, in keeping with the rest of the project.
Zig: no hidden allocator, failure in the type system, defer
Zig's central memory rule is radical and clarifying: there is no global allocator and no hidden allocation anywhere in the language or standard library. Any code that allocates takes an std.mem.Allocator parameter, so the caller always chooses the strategy.
Because allocation can fail, it returns an error union you must try, and defer schedules the matching free right beside the allocation:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // reports leaks at exit
const allocator = gpa.allocator();
const p = try allocator.create(i32); // !*i32 - allocation may fail
defer allocator.destroy(p); // runs at scope exit, every path
p.* = 42;
std.debug.print("{d}\n", .{p.*}); // prints 42
}
The vocabulary: create/destroy for a single object, alloc/free for a slice of n items. defer runs at scope exit in reverse order of registration; errdefer runs only when the scope exits via an error - perfect for rolling back a half-built object while leaving it intact on the success path.
Two things make this more than ergonomic C. First, allocation failure is in the type system: you cannot forget to handle it, because !*i32 won't compile if ignored. Second, the GeneralPurposeAllocator detects leaks, double-frees, and use-after-free in debug builds - the matching free is enforced, not hoped for. Swap the GPA for std.heap.ArenaAllocator and arena.deinit() frees every allocation at once, no per-object destroy required.
Hare: C's model, but alloc and free are keywords
Hare keeps C's manual, GC-free model but folds allocation into the language rather than a library. alloc and free are keywords: alloc(init) allocates storage and initializes it in one step, computing sizes automatically, and yields a typed pointer.
use fmt;
export fn main() void = {
let p: *int = alloc(42)!; // heap int initialized to 42; auto-sized
defer free(p); // manual ownership, defer'd release
fmt::println(*p)!; // prints 42
};
The contract differs from C in one notable way: allocating a non-nullable pointer, alloc returns a tagged union carrying a nomem error, so you must address the failure - the idiomatic ! you see above asserts it, aborting the program on out-of-memory rather than letting you silently proceed on a bad pointer. (Want to handle exhaustion yourself? Assign into a nullable pointer instead - let p: nullable *int = alloc(42); - and on OOM you get null rather than an abort.) defer keeps the matching free beside the allocation and runs it on scope exit, exactly as in Zig and Odin. Slices are auto-sized too - alloc([0...], 64)! gives a 64-element slice - so you never write 64 * size(int) by hand. Ownership is still entirely yours to track; Hare just removes the size arithmetic, not the obligation to free.
Odin: the implicit context allocator
Odin threads an implicit context through every procedure call, and that context carries an allocator. The built-ins new (one object), make (slice/map/dynamic array), free, and delete all use context.allocator with no argument:
package main
import "core:fmt"
main :: proc() {
p := new(int) // allocates via context.allocator; returns ^int
defer free(p) // returns it to that same allocator
p^ = 42
fmt.println(p^) // prints 42
}
The power is that you can swap the allocator for a scope and every nested call inherits it, no parameter passing required:
import "core:mem/virtual"
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
context.allocator = virtual.arena_allocator(&arena)
defer virtual.arena_destroy(&arena) // one call frees the whole region
// every new/make below now allocates from the arena, then dies with it
Odin also ships context.temp_allocator, a growing arena you clear in bulk with free_all(context.temp_allocator). Use new/make to allocate and free/delete to release individually, or skip per-object frees entirely by letting an arena reclaim everything at once. There is no GC and no destructor running behind your back - defer is the only cleanup hook, and it is explicit.
Forth: there is barely an allocator at all
Forth strips memory to the metal. The standard offers an optional dynamic-memory word set - ALLOCATE, FREE, RESIZE - that mirrors malloc/free/realloc. Crucially, these don't signal failure with a sentinel value; they push an address and an ior status onto the stack, and THROW turns a nonzero status into an exception:
\ Heap allocation via the optional ALLOCATE/FREE word set.
: DEMO ( -- )
1 CELLS ALLOCATE THROW ( -- addr ) \ room for one cell; THROW on error
42 OVER ! ( addr -- addr ) \ store 42 at addr ( ! = store )
DUP @ . ( addr -- addr ) \ fetch ( @ ) and print -> 42
FREE THROW ( addr -- ) \ hand the block back
;
DEMO
Every ALLOCATE is paired with exactly one FREE, by hand, just like C - and there is no type system, so an address is just a number and a wrong @/! reads or writes raw memory with no complaint.
The older, more primitive form allocates from the dictionary itself. HERE is the address of the next free byte; ALLOT advances it to carve out space; CREATE names that address:
CREATE BUF 256 CELLS ALLOT \ name a 256-cell buffer in the dictionary
42 BUF ! \ store 42 in the first cell
BUF @ . \ fetch and print -> 42
Dictionary space carved with ALLOT is never individually freed - it lives for the program's life (you can only reclaim it by negative ALLOT or by FORGETting words back to a marker). It is memory management with no safety net whatsoever: you are the allocator.
The classic failure modes
Manual memory gives you the same recurring bugs in every language above. Each is a violation of the ownership/lifetime rules; learning to see them is half the battle, and sanitizers catch the rest.
- Leak - allocate, never free. The block is lost until the process exits. Long-running servers die slowly of this.
- Use-after-free (dangling pointer) - keep using a pointer after the storage is freed. The block may already be reused for something else, so reads return garbage and writes corrupt unrelated data - a favorite of exploit writers.
- Double-free -
freethe same block twice, corrupting the allocator's free list and often crashing later in unrelated code. - Buffer overflow - read or write past the end of a block. C and friends do no bounds checking, so a one-byte overrun silently smashes the next block's bookkeeping.
- Uninitialized read - read a freshly
malloc'd block before writing it, getting leftover bytes. Nondeterministic and maddening - which is whycallocand Zig'stry allocator.allocpatterns exist.
char *p = malloc(16);
free(p);
strcpy(p, "oops"); /* USE-AFTER-FREE: p points to reclaimed memory */
free(p); /* DOUBLE-FREE: undefined behavior */
The cheapest defensive habit in C/HolyC is p = NULL; right after free(p): it neuters double-free (free(NULL) is a defined no-op) and, on a protected OS, turns a later use-after-free into an immediate, loud fault on the NULL dereference instead of a silent corruption. (On TempleOS that second benefit is weaker - with no MMU, a write through NULL is just a write to address 0 and faults nothing - but the Free(NULL) no-op still holds.) And always build with AddressSanitizer (-fsanitize=address) or run under Valgrind during development - they instrument every allocation and access and report the exact line of a leak, overflow, or use-after-free. Zig's debug GeneralPurposeAllocator does the same natively.
Who owns, who frees: the seven side by side
Line the languages up by who decides the strategy and who is forced to clean up, and the spectrum is clear:
- C / HolyC - you do everything, by hand, with no checks. (HolyC additionally ties allocations to a per-task heap that dies with the task.)
- C++ - a type (the destructor) cleans up for you; ownership is encoded in
unique_ptr(unique) orshared_ptr(ref-counted). The rawnew/deleteescape hatch remains for interop. - Zig - the caller picks the allocator and passes it in explicitly;
defer/errdeferclean up; allocation failure lives in the type system; the GPA catches your mistakes. - Hare - like C, but
alloc/freeare keywords with auto-sizing, aborting on OOM, plusdefer. - Odin - an implicit context carries the allocator so a whole call tree shares one strategy;
defercleans up; arenas make per-object frees optional. - Forth - there is no real allocator:
ALLOCATE/FREEif you want a heap, or moveHEREwithALLOTand address raw cells with@/!.
From Forth's total exposure of raw memory, through C's manual contract, to Zig/Hare/Odin's explicit but ergonomic allocators, to C++'s automatic destructor-driven RAII - none of them adds a garbage collector. That is the entire point of a systems language: you trade the safety net for the control.
Mastering manual memory means being able to read any snippet above and instantly answer the two questions from the start: who frees this, and until when is it valid? Decide when each value dies before you write the code that creates it, choose the cheapest tool that honors that lifetime - stack for a call, arena for a phase, scope-bound cleanup for an owned object, ref-counting for shared ownership - and write the ownership down in a type, a name, or a comment. Do that, and the heap stops being a minefield and becomes exactly what it is: a fast, flexible, fully controllable resource.