← History

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.

C++CZig

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

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.

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:

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:

// 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

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:

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

Why make that choice deliberately? Three reasons recur across these designs:

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.