← Code Compare

Ownership & Lifetimes

How each systems language answers the central memory question: who owns this allocation, and who is responsible for freeing it? We write one task in all seven languages - a function that allocates and returns owned memory, and a caller that must release it - to compare ownership encoded in comments (C, HolyC), in the type system (C++ unique_ptr moves), and in convention plus defer (Zig allocators, Hare, Odin), versus Forth's heap-less, region-based model.

Show: CC++HolyCZigHareOdinForth
C
#include <stdlib.h>
#include <string.h>

/* OWNERSHIP CONTRACT (by comment - the only tool C gives you):
 * Returns a heap-allocated, NUL-terminated greeting for `name`.
 * OWNERSHIP TRANSFERS to the caller, who MUST free() it.
 * Returns NULL on allocation failure (caller owns nothing). */
char *make_greeting(const char *name) {
    size_t n = strlen(name) + 8;   /* "Hello, " == 7 chars, +1 for NUL */
    char *out = malloc(n);         /* allocate -> we own it... */
    if (out == NULL) return NULL;  /* ...unless this fails */
    snprintf(out, n, "Hello, %s", name);
    return out;                    /* ownership handed to caller */
}

int main(void) {
    char *g = make_greeting("Dennis");   /* caller now owns `g` */
    if (g == NULL) return 1;
    /* ... use g ... */
    free(g);                              /* caller MUST free it */
    g = NULL;                             /* avoid dangling reuse */
    return 0;
}

C encodes ownership only in comments and naming - the compiler enforces nothing. make_greeting mallocs and transfers ownership; the caller is contractually obliged to free exactly once. Forgetting leaks; freeing twice or after use is undefined behavior.

C++
#include <memory>
#include <string>
#include <iostream>

// Ownership is encoded IN THE TYPE: unique_ptr means "I own this,
// exactly one owner at a time." Returning it MOVES ownership out.
std::unique_ptr<std::string> make_greeting(const std::string &name) {
    return std::make_unique<std::string>("Hello, " + name);
    // single heap allocation; destructor knows how to free it
}

int main() {
    auto g = make_greeting("Bjarne");   // g now owns the string
    std::cout << *g << '\n';

    auto h = std::move(g);              // ownership MOVES: g is now null
    // using *g here would be a bug; the type made the move explicit
    std::cout << *h << '\n';
    return 0;                            // ~unique_ptr frees automatically (RAII)
}

Modern C++ puts ownership in the type system: unique_ptr is the single owner, copying is forbidden, and std::move makes every transfer visible. RAII guarantees the destructor frees the memory at scope exit - no manual delete, no leaks, no double-free.

HolyC
// HolyC (TempleOS): MAlloc/Free are the heap primitives.
// Ownership is by convention + comment, like C. No destructors.

U8 *MakeGreeting(U8 *name)
{// Returns a heap string the CALLER must Free().
  I64 len = StrLen(name) + 8; // "Hello, " + NUL
  U8 *out = MAlloc(len);      // we own this block...
  StrPrint(out, "Hello, %s", name);
  return out;                 // ...ownership handed to caller
}

U8 *g = MakeGreeting("Terry"); // top-level code runs; g is owned here
"%s\n", g;                     // HolyC shorthand for Print
Free(g);                       // caller MUST Free it exactly once

HolyC mirrors C's manual model: MAlloc claims a block from the heap and Free returns it, with ownership documented only in comments. There is no GC and no RAII - the function that MAllocs and the code that Frees form an unenforced contract. Top-level statements run directly in TempleOS's JIT shell.

Zig
const std = @import("std");

// The Allocator is passed IN explicitly: no hidden allocations.
// Returns owned memory; caller frees with the SAME allocator.
// The `!` means it can fail (error.OutOfMemory).
fn makeGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return std.fmt.allocPrint(allocator, "Hello, {s}", .{name});
    // allocPrint allocates -> ownership returned to the caller
}

pub fn main() !void {
    // GeneralPurposeAllocator detects leaks and double-frees in debug.
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();          // reports leaks at program end
    const allocator = gpa.allocator();

    const g = try makeGreeting(allocator, "Andrew"); // caller owns g
    defer allocator.free(g);         // freed at scope exit, deterministically

    std.debug.print("{s}\n", .{g});
}

Zig makes ownership visible at the call site: the allocator is an explicit parameter, so you always see who allocates and you free with the same allocator. defer allocator.free(g) schedules cleanup at scope exit, and the GeneralPurposeAllocator flags leaks and double-frees - use errdefer to also free on the error path.

Hare
use fmt;
use strings;

// Convention: a fn that returns alloc'd memory transfers ownership.
// Caller must free! it. Hare has no GC and no destructors.
fn make_greeting(name: const str) str = {
	return strings::concat("Hello, ", name); // allocates -> caller owns
};

export fn main() void = {
	const g = make_greeting("Drew"); // g is owned here
	defer free(g);                   // freed at scope exit, exactly once

	fmt::println(g)!;
};

Hare is manual and GC-free like C, but leans on defer for deterministic cleanup. Ownership is a documented convention: a function returning alloc'd memory (here via strings::concat) transfers it to the caller, who pairs it with defer free(g). The trailing ! on println propagates I/O errors explicitly.

Odin
package main

import "core:fmt"
import "core:strings"

// Odin threads an implicit `context.allocator` through calls.
// Convention: returning allocated memory transfers ownership to caller.
make_greeting :: proc(name: string) -> string {
	// concatenate uses context.allocator by default -> caller owns result
	return strings.concatenate({"Hello, ", name})
}

main :: proc() {
	g := make_greeting("Ginger")   // caller owns g
	defer delete(g)                // free with the same allocator, once

	fmt.println(g)
}

Odin passes an implicit context.allocator, so allocation is ergonomic yet swappable per scope. The convention is that a proc returning allocated memory hands ownership to the caller, who releases it with delete (use free for new, delete for make/library strings) - typically via defer for safe, single cleanup.

Forth
\ Forth has no malloc/free in the core: memory is hand-managed.
\ The IDIOM closest to "return owned memory" is to reserve a region
\ from the dictionary with ALLOT and hand back its address.

CREATE GREETING  64 ALLOT      \ reserve a 64-byte buffer in the dictionary

: S"->BUF ( addr len dst -- )  \ copy a string into dst
  SWAP CMOVE ;

: MAKE-GREETING ( -- addr )    \ "returns" the buffer address
  S" Hello, Forth" GREETING S"->BUF
  GREETING ;                   \ caller now holds the address (not malloc'd)

MAKE-GREETING  12 TYPE  CR     \ use it: prints "Hello, Forth"
\ No FREE needed: dictionary memory lives until FORGET/MARKER rolls it back.

Standard Forth has no heap and no malloc/free in the core, so "owned, returned memory" doesn't map cleanly. The closest idiom is reserving a region with CREATE/ALLOT and returning its address; that memory lives for the program's life and is reclaimed in bulk via FORGET/MARKER (or an optional ALLOCATE/FREE wordset where available), so ownership is positional rather than per-allocation.