Ownership and Lifetimes (without a borrow checker)
Every one of these seven systems languages has ownership and lifetimes - they just refuse to check them. A tour of how C, C++, Zig, Hare, Odin, HolyC, and Forth encode 'who frees this, and how long is this pointer good for' in types, conventions, and tooling instead of in a compiler that says no.
There is a quiet misconception that ownership and lifetimes are Rust inventions. They are not. They are facts about every program that allocates: a piece of memory has exactly one party responsible for freeing it (its owner), and every pointer into that memory is only valid for some window of time (its lifetime). Those facts exist in C written in 1978 just as much as in Rust written today. The only question a language gets to answer is: who tracks them - the compiler, or you?
Rust's borrow checker is one answer: encode ownership and lifetimes into the type system and refuse to compile programs that violate them. It is a remarkable piece of engineering, and it is also the road not taken by all seven languages on this site. C, C++, Zig, Hare, Odin, HolyC, and Forth each express ownership and lifetimes - sometimes elaborately, as in C++'s smart pointers and move semantics
- but none of them enforces it the way Rust does. There is no compiler pass that proves you did not use a pointer after its owner freed it.
This article is about that gap: how each language says "this value owns that memory" and "this pointer outlives that one," using types, naming conventions, documentation, and runtime tooling - and exactly where the enforcement stops and your discipline begins.
The two facts, stated precisely
Pin down the vocabulary first, because the languages reuse the words loosely.
- Ownership is the answer to "who is responsible for freeing this allocation, and exactly once?" Ownership can be transferred (I allocate, I hand it to you, now you free it) or shared (several parties hold it; the last one out frees it). Get ownership wrong in one direction and you leak (nobody frees); in the other you double-free (two parties both free).
- A lifetime is the answer to "over what span of time is this pointer guaranteed to point at live memory?" A pointer is only safe to dereference inside the lifetime of whatever it points at. Outlive that and you have a dangling pointer; dereference it and you have a use-after-free - the single most exploited bug class in systems software.
A borrow checker is, in one sentence, a compile-time proof that no reference is ever used outside the lifetime of its owner, and that there is never both a mutable and an aliasing access at once. None of our seven languages performs that proof. What follows is what they do instead.
C - ownership lives in the comments
C has exactly one tool for ownership: the convention you write down. A char *
that you must free and a char * you must not are the same type. Nothing in
the signature distinguishes "I am giving you this buffer to keep" from "I am
lending you a view for the duration of this call." The knowledge lives entirely
in the function's documentation and in the discipline of its callers.
#include <stdlib.h>
#include <string.h>
/* OWNERSHIP: returns a heap buffer the CALLER must free().
* LIFETIME: valid until the caller frees it. */
char *dup_upper(const char *src) {
size_t n = strlen(src);
char *out = malloc(n + 1); /* caller now owns 'out' */
if (!out) return NULL;
for (size_t i = 0; i < n; i++)
out[i] = (src[i] >= 'a' && src[i] <= 'z') ? src[i] - 32 : src[i];
out[n] = '\0';
return out; /* ownership transfers on return */
}
/* OWNERSHIP: borrows 'name'; does NOT free it, does NOT keep it.
* LIFETIME: 'name' need only be valid for the duration of this call. */
void greet(const char *name) { /* 'const char *' signals "borrow" by idiom */
/* ... use name, never free(name) ... */
}
C programmers have built a remarkably consistent folklore on top of this nothing:
const T *means "I borrow, I won't free, I won't keep." It is the closest thing C has to a shared borrow, and the compiler enforces only that you don't write through it - not that the pointer stays live.- Returning
T *(non-const) often means "you now own this." The classic example isstrdup: itmallocs the copy, and the man page notes the result "can be freed with free(3)." That documented hand-off - not the type - is the entire ownership contract. - An "out-pointer" (
T **) means "I will allocate and store the owner here." Naming (_create/_destroy,_new/_free,_alloc/_release) carries the rest.
The compiler verifies none of this. You can free a borrowed pointer, keep an
owned one past its scope, or return a pointer to a local - and C will compile it.
The most insidious case is the dangling pointer to a stack frame, where the
"lifetime" the convention promised has already ended:
char *broken(void) {
char buf[64];
/* ... fill buf ... */
return buf; /* DANGLING: buf's lifetime ends at the closing brace.
The returned pointer aims at a dead stack frame. */
} /* most compilers warn here, but will still emit the code */
C's honesty is that it never pretends. There is no borrow checker because there is barely a type system standing between you and the address space.
C++ - ownership becomes a type (but lifetimes don't)
C++ is the most elaborate ownership story of the seven, and the one most often mistaken for "almost Rust." Through RAII and the smart-pointer library, C++ encodes ownership into the type itself - and then, crucially, stops there. It models who frees, beautifully. It does not model how long a reference is good for. There is no borrow checker; there is a convention dressed in templates.
unique_ptr: ownership as a move-only type
std::unique_ptr<T> is exactly one owner, expressed in the type system. It is
move-only - you cannot copy it - so the compiler does prevent the simplest
double-free: two unique_ptrs cannot both own the same object, because assigning
one to another is a compile error unless you std::move.
#include <memory>
#include <utility>
struct Widget { int id; };
std::unique_ptr<Widget> make_widget(int id) {
return std::make_unique<Widget>(Widget{id}); // sole owner; freed by ~unique_ptr
}
void take(std::unique_ptr<Widget> w); // by value => takes ownership
void demo() {
auto a = make_widget(1);
// auto b = a; // COMPILE ERROR: unique_ptr is not copyable (no double-free)
auto b = std::move(a); // ownership MOVES; 'a' is now empty (holds nullptr)
take(std::move(b)); // ownership transfers into take(); 'b' is now empty
} // ~unique_ptr on a and b is a no-op: both are empty
This is genuine ownership tracking in the type system. std::move is how you
transfer it; the moved-from unique_ptr is left holding nullptr, whose
destructor does nothing, which is precisely why there is no double-free. This is
the same problem Zig solves with errdefer and C solves with a comment - C++
folds it into the type and the move.
shared_ptr: shared ownership as a runtime refcount
When ownership genuinely is shared, std::shared_ptr<T> tracks it at runtime
with an atomically reference-counted control block. The last owner to drop frees
the object. This is shared ownership made correct - at the cost of an atomic
increment/decrement per copy and a heap-allocated control block.
#include <memory>
void shared() {
auto p = std::make_shared<int>(42); // refcount = 1
{
auto q = p; // refcount = 2 (atomic bump)
// ... p and q both valid ...
} // q dropped: refcount = 1
} // p dropped: refcount = 0 -> delete
Where C++ stops: lifetimes of references and views
Here is the borrow-checker-shaped hole. C++ has no way to prove that a T&, a
raw T*, a std::string_view, or a std::span does not outlive the thing it
refers to. These are non-owning borrows, and their validity is pure
convention. The canonical trap:
#include <string>
#include <string_view>
std::string_view first_word(const std::string &s); // borrows s, returns a view INTO s
std::string_view oops() {
std::string s = "hello world";
return first_word(s); // DANGLING: the view points into 's', whose lifetime
} // ends at this brace. Rust's borrow checker rejects the
// equivalent; C++ compiles it and hands you a time bomb.
s is destroyed at the closing brace; the returned string_view points into
freed memory. Rust rejects the analogous program at compile time because the
borrow outlives the owner. C++ emits it without comment. The committee has added
lifetime annotations and [[clang::lifetimebound]] and the Core Guidelines
lifetime profile precisely to approximate this - but they are opt-in static
analysis and warnings, not a sound, mandatory borrow checker. That is the line:
C++ enforces ownership (move-only unique_ptr) but only advises on
lifetimes.
Zig - ownership by convention, lifetimes by hygiene
Zig deletes most of C++'s machinery on purpose: no RAII, no destructors, no move semantics, and no operator overloading. A Zig struct is just bits; passing or returning it is a bit-copy, full stop. So ownership cannot live in the type the way it does in C++. Where does it live? In two places: the explicit allocator and naming conventions, both enforced only by the standard library's habits and your own.
The single most important ownership signal in Zig is that allocation is
explicit and parameterized. A function that needs to allocate takes an
Allocator. If a function's signature has no allocator, it (by convention)
cannot take ownership of new heap memory - so the signature itself tells you a lot
about who frees what.
const std = @import("std");
// Takes an allocator => it allocates. Convention: CALLER owns the result and
// must free it with the SAME allocator. (This is std's own naming idiom.)
fn dupeUpper(allocator: std.mem.Allocator, src: []const u8) ![]u8 {
const out = try allocator.alloc(u8, src.len);
errdefer allocator.free(out); // on later error: free; on success: keep
for (src, 0..) |c, i| {
out[i] = if (c >= 'a' and c <= 'z') c - 32 else c;
}
return out; // ownership transfers to the caller
}
// No allocator parameter => borrows. Convention: does not free, does not keep.
fn countUpper(s: []const u8) usize {
var n: usize = 0;
for (s) |c| if (c >= 'A' and c <= 'Z') { n += 1; };
return n;
}
A few Zig-specific points that make ownership legible without enforcing it:
- Slices (
[]T,[]const u8) carry a length but not a lifetime. A slice is a(pointer, length)pair.[]const u8is the idiomatic "borrowed view" (like C'sconst char *or C++'sstring_view) and[]u8is often an owned buffer - but, exactly as in C, the type does not say which, and a slice into freed memory is just as dangling as any raw pointer. defer/errdeferexpress the lifetime intent at the allocation site.errdefer allocator.free(out)above is Zig's answer to "keep on success, free on failure" - the same job C++ does with a move out ofunique_ptr. It is a scheduling tool, not a proof: nothing stops you from returningoutand also freeing it.- The compiler will not stop a use-after-free. What Zig offers instead is
runtime help: the
GeneralPurposeAllocator(andstd.testing's allocator) detect leaks, double-frees, and many use-after-frees at runtime, in Debug/ReleaseSafe builds. That is detection, not prevention - closer to Valgrind/ASan baked into the language than to a borrow checker.
// Runtime tooling, not compile-time proof: the GPA catches the mistake when run.
test "GPA flags the leak the type system allowed" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok); // deinit reports leaks
const a = gpa.allocator();
const buf = try a.alloc(u8, 16);
_ = buf; // we "forgot" to free(buf); the type system said nothing...
} // ...but gpa.deinit() detects the leak at runtime and asserts.
Zig's bet is that making allocation explicit and cleanup local removes most of the mistakes a borrow checker would catch, while keeping the language tiny and free of hidden control flow. The ownership is real; it just lives in the allocator parameter and the function name, with a runtime safety net under it.
Hare - ownership written into the docs, on purpose
Hare is the most explicit of the seven about the fact that it is deliberately choosing convention over enforcement. Hare's standard-library documentation uses a standardized vocabulary - "the caller assumes ownership," "this function borrows the slice," "ownership is transferred" - to describe the memory contract of every function. It is C's comment convention, promoted to a documented, consistent house style. And it is, equally deliberately, not checked by the compiler: Hare does not prevent double-free or use-after-free.
use strings;
// Hare std style: the doc comment IS the ownership contract.
// "Duplicates a string. The caller assumes ownership and must free() it."
fn dup_upper(src: const str) str = {
// strings::dup borrows 'src' and returns a NEW owned string. Since
// Hare 0.25 it returns (str | nomem); '!' asserts the alloc succeeds.
let out = strings::dup(src)!; // caller of dup_upper now owns 'out'
// ... transform in place ...
return out; // ownership transfers to the caller
};
// "Borrows 's' for the duration of the call; does not free or retain it."
fn count_bytes(s: const str) size = len(s);
The Hare manual is unusually direct about the philosophy: you, the programmer,
plan the memory; the language gives you alloc/free, defer for scheduling
cleanup, and a culture of writing the ownership semantics into each function's
documentation - and then trusts you to honor them. There is no errdefer and no
borrow checker; "keep on success, free on failure" and "don't outlive the owner"
are both your responsibility, guided by prose. Hare's small, freezable design
treats the absence of a borrow checker not as a missing feature but as a
kept promise of simplicity.
Odin - ownership by allocator and convention, no move
Odin, like Zig, has no RAII, no destructors, and no move semantics; structs
are values that copy. Ownership is again carried by convention plus the
allocator - but Odin threads the allocator implicitly through a per-call
context, so a function that allocates uses context.allocator rather than
taking it as a parameter. The flip side: the allocator is no longer a visible
ownership hint in the signature, so Odin leans even harder on naming and doc
conventions (make/delete, new/free).
package text
import "core:strings"
// Convention: returns a string the CALLER owns and must delete().
dup_upper :: proc(src: string) -> string {
// to_upper allocates via context.allocator; caller owns the result.
out := strings.to_upper(src) // ownership transfers to the caller
return out
}
// Convention: borrows 's'; does not delete, does not retain.
count_bytes :: proc(s: string) -> int {
return len(s)
}
owner_demo :: proc() {
s := dup_upper("hello")
defer delete(s) // WE own it, so WE schedule the free
// ... use s; nothing stops us from delete(s) twice except discipline ...
}
Odin's distinctive ownership story is the swappable allocator, which lets you
sidestep per-object ownership entirely. Set context.allocator to an arena and
the question "who frees each object?" collapses into "free the whole arena at the
boundary" - ownership becomes coarse and bulk, which is frequently both faster and
harder to get wrong than tracking each allocation. Odin's core:mem
tracking_allocator then plays the role Zig's GPA does: a runtime leak/
double-free detector you wrap your program in for debug builds. Detection, again
- not a compile-time proof.
HolyC - ownership in a single ring-0 address space
HolyC - Terry A. Davis's C dialect and the native language of TempleOS - handles
ownership exactly as C does, by hand and by convention, but in a runtime that
raises the stakes considerably. There is no borrow checker, no RAII, and no
defer: you pair each MAlloc with a Free, and Free(NULL) is a permitted
no-op, just as in C.
// HolyC: ownership is fully manual, as in C. Acquire, then Free on each path.
// Convention: returns a buffer the CALLER must Free().
U8 *DupUpper(U8 *src)
{
I64 n = StrLen(src);
U8 *out = MAlloc(n + 1); // from this task's data heap; caller now owns 'out'
I64 i;
for (i = 0; i < n; i++)
out[i] = ('a' <= src[i] <= 'z') ? src[i] - 32 : src[i];
out[n] = 0;
return out; // ownership transfers to the caller
}
Two facts make HolyC's lack of a borrow checker its own distinct flavor of manual ownership:
- No memory protection at all. TempleOS runs entirely in 64-bit ring 0 in a single flat address space with no MMU-backed protection, by design - Davis wanted a modern Commodore 64 that one person could fully understand. There is no hardware to convert a dangling-pointer dereference into a clean segfault: a use-after-free or double-free can silently corrupt the whole system. The lifetime of a pointer is, in a sense, "until something overwrites that memory," and nothing warns you.
- Per-task heaps soften ownership at the edges.
MAllocdraws from the current task's data heap, and when a task dies its heaps are reclaimed automatically. That gives a coarse, arena-like "free everything this task owned" for free - an ownership boundary at the task level even though per-allocation ownership is entirely on you. It is the same bulk-reclaim idea as Odin's arenas, built into the OS's process model.
Held respectfully, HolyC is a coherent design: a one-person system that trades every safety net for total simplicity and total control. Ownership and lifetimes are real and entirely yours to track, with the unusually high penalty that the machine offers no second chance.
Forth - ownership on the stack you keep in your head
Forth is the minimal extreme. Heap memory comes from the optional
ALLOCATE/FREE word set (C-style, returning an I/O result code), and there is
no type system to attach ownership to, no scopes to bind lifetimes to, and no
unwinding. An allocated address is just a number on the data stack. Ownership in
Forth is a fact you hold in your head and encode in stack comments - the
( -- ) notation that says what each word consumes and leaves.
\ Forth: ownership is documented in the stack comment, enforced by nothing.
\ dup-upper takes a string (addr len) and returns a NEW buffer the CALLER frees.
: dup-upper ( src-addr u -- buf u ) \ stack effect IS the contract
dup allocate throw ( src u buf ) \ buf: caller owns it
>r 2dup r@ swap move ( src u ) ( R: buf ) \ copy bytes in
\ ... uppercase the bytes at buf in place ...
nip r> swap ; ( buf u ) \ leave owned buf + length
\ Caller's duty, by convention only: buf u 2drop-but-FREE-the-addr-first.
There is nothing in Forth that says one number on the stack is an owned heap
address you must FREE and another is a borrowed view or just an integer - they
are all cells. The stack comment ( src-addr u -- buf u ) is the entire ownership
and lifetime contract, and the language checks none of it. Even the "scope" a
lifetime would attach to is fuzzy: the data stack is global and persistent, so a
pointer's lifetime is whatever the programmer's protocol says it is. Forth is the
purest demonstration of the article's thesis - ownership and lifetimes are
always present as facts, even when the language provides literally no construct
to name them.
The through-line: why none of them is Rust
Lay the seven side by side and a clean spectrum appears - not of whether they have ownership and lifetimes (they all do), but of how much of the contract is machine-checked.
| Language | Ownership expressed by | Lifetime expressed by | Compile-time check? | Runtime safety net |
|---|---|---|---|---|
| C | naming + comments; const T* = borrow |
comments; scope discipline | none | none (Valgrind/ASan external) |
| C++ | type: unique_ptr (move-only), shared_ptr (refcount) |
convention only (&, *, string_view, span) |
move prevents trivial double-free; lifetimes only via opt-in lints | sanitizers (external/opt-in) |
| Zig | explicit allocator param + naming; errdefer |
defer/errdefer intent; slice carries length not lifetime |
none | GPA / testing allocator (leak + double-free + UAF detection) |
| Hare | documented ownership/borrow/transfer vocabulary | docs + scope; defer |
none | none built-in |
| Odin | implicit context.allocator + naming; arenas |
defer; arena boundaries |
none | tracking_allocator (leak/double-free) |
| HolyC | manual, C-style; per-task heaps | scope + task lifetime | none | none (no memory protection at all) |
| Forth | stack comments ( -- ) |
programmer protocol; global stack | none | none |
The pattern is unmistakable. The languages differ enormously in expressiveness
- C++ encodes ownership in a move-only type, Zig in an allocator parameter, Hare
in a documented vocabulary, Forth in a stack comment - but they converge on the
same refusal: not one of them will fail to compile a program because a borrow
outlives its owner. The closest any of them gets is C++'s move-only
unique_ptr, which does make one specific double-free a compile error - and even that leaves every reference, view, and span unchecked.
Why make that choice deliberately? Three reasons recur across these designs:
- Simplicity and freezability. A sound borrow checker is a large, intrusive piece of a compiler and a heavy presence in the language's semantics. Hare and Zig and Odin are, in part, reactions toward small languages you can hold in your head - and a borrow checker is exactly the kind of large mechanism they are trying to avoid.
- Patterns the borrow checker fights. Arenas, intrusive data structures,
self-referential graphs, and "allocate freely, free at the boundary" are
bread-and-butter systems techniques that a strict borrow checker makes awkward.
Several of these languages prefer to make arenas trivial (Odin's swappable
allocator, Zig's
ArenaAllocator, HolyC's per-task heaps) rather than make per-object borrows provable. - Detection over prevention. Zig's GPA and Odin's tracking allocator embody a different bet: catch the mistakes the type system allowed, at runtime, cheaply, in debug builds - Valgrind's job, moved inside the language. It does not prevent the bug from being written; it makes it loud the first time it runs.
Ownership and lifetimes are not optional. They are the shape of every program that touches memory. What these seven languages share is the conviction that the programmer should carry that shape - aided by types, by allocators, by conventions, by documentation, and by runtime tooling - rather than be proven correct by the compiler before the program is allowed to exist. Whether that is freedom or a footgun is the oldest argument in systems programming, and it is the argument this entire site is about.