← Code Compare

Strings & Their Ownership

A string is just bytes plus a way to know where it ends - and that choice drives the memory model. C uses a bare char* that is NUL-terminated: the length is implicit (you scan for the \0), and you own every byte by hand. C++ std::string owns a heap buffer and frees it via RAII. Zig, Hare, and Odin use slices ([]u8) that carry an explicit length alongside the pointer, so there is no terminator to forget - but a heap-allocated slice is still a resource someone must free. We build the same greeting "Hello, C!" in all seven, concatenating "Hello, " with a name, printing it, and releasing any heap it took.

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

int main(void) {
    const char *name = "C";

    /* A C string is a char* to NUL-terminated bytes: the length is
     * NOT stored, so we must compute it and budget +1 for the '\0'. */
    size_t len = strlen("Hello, ") + strlen(name) + strlen("!");
    char *greeting = malloc(len + 1);   /* +1 for the NUL terminator */
    if (greeting == NULL) {
        perror("malloc");
        return 1;
    }

    /* Build it; snprintf always writes the terminating '\0'. */
    snprintf(greeting, len + 1, "Hello, %s!", name);

    printf("%s\n", greeting);           /* prints: Hello, C! */

    free(greeting);                     /* we malloc'd it, we free it */
    greeting = NULL;                    /* no dangling pointer */
    return 0;
}

A C string is a raw char* whose end is marked by a '\0', so the length is implicit and you must size the buffer yourself, always adding +1 for the terminator. malloc makes you the owner of those bytes, and exactly one free must match it - forget it and you leak, miscount and you overflow.

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

int main() {
    std::string name = "C";

    // std::string owns a heap buffer and tracks its length (size()).
    // operator+ allocates a new buffer for the concatenation.
    std::string greeting = "Hello, " + name + "!";

    std::cout << greeting << '\n';   // prints: Hello, C!

    // No free/delete: ~string releases the buffer at scope exit (RAII),
    // even if an exception unwinds. Length is stored, not scanned.
    return 0;
}

std::string is a length-carrying, owning container: it holds a heap buffer (or small-string-optimized inline storage), and operator+ builds a fresh one for you. The destructor frees it automatically via RAII - no manual free, no terminator to manage, no leak.

HolyC
// HolyC (TempleOS): strings are NUL-terminated U8* like C.
// MAlloc/Free are the per-task heap primitives; no RAII.
U8 *name = "C";

// StrLen ignores the NUL; budget +1 for the terminator.
I64 len = StrLen("Hello, ") + StrLen(name) + StrLen("!");
U8 *greeting = MAlloc(len + 1);   // we own this block

StrPrint(greeting, "Hello, %s!", name); // writes the bytes + '\0'

"%s\n", greeting;                 // HolyC Print shorthand -> Hello, C!

Free(greeting);                   // return it; Free(NULL) is a safe no-op

HolyC strings are C-style NUL-terminated U8*, with the length unstored, so you size the block with StrLen + 1 and own it explicitly. MAlloc takes from the per-task heap and Free returns it; there is no GC or destructor, though a dying task reclaims its whole heap at once.

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

pub fn main() !void {
    // Allocator is explicit in Zig - no hidden allocations for strings.
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();           // reports leaks at program end
    const allocator = gpa.allocator();

    // Zig strings are slices: []const u8 carries a pointer AND a length.
    const name: []const u8 = "C";

    // allocPrint heap-allocates the result; returns ![]u8 (may fail).
    const greeting = try std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
    defer allocator.free(greeting);   // free the SAME slice, at scope exit

    std.debug.print("{s}\n", .{greeting}); // prints: Hello, C!
}

Zig strings are slices []const u8 that carry an explicit length (no NUL needed), and string literals are slices into read-only memory. std.fmt.allocPrint allocates the joined result through the explicit allocator and returns ![]u8; defer allocator.free(greeting) frees that exact slice - pairing the one allocation with one free.

Hare
use fmt;
use strings;

export fn main() void = {
	// Hare's str is a slice with a stored length (no terminator scan).
	const name: str = "C";

	// strings::concat allocates a new string on the heap.
	const greeting = strings::concat("Hello, ", name, "!");
	defer free(greeting);          // we own it; release at scope exit

	fmt::println(greeting)!;       // prints: Hello, C!  (! propagates I/O errors)
};

A Hare str is a length-carrying slice, so concatenation works on known lengths rather than hunting for a '\0'. strings::concat returns a newly alloc'd string that the caller owns; defer free(greeting) guarantees the single matching free at scope exit, since Hare is GC-free.

Odin
package main

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

main :: proc() {
	// Odin's `string` is a slice: a pointer plus a stored length.
	name := "C"

	// concatenate allocates via the implicit context.allocator.
	greeting := strings.concatenate({"Hello, ", name, "!"})
	defer delete(greeting)         // free with the same allocator, once

	fmt.println(greeting)          // prints: Hello, C!
}

An Odin string is a (ptr, len) slice, so the length is explicit and string literals are non-owning views. strings.concatenate allocates the result through the implicit context.allocator, transferring ownership to you; defer delete(greeting) returns it to that same allocator exactly once.

Forth
\ Forth strings are address+length pairs on the stack ( c-addr u ),
\ so the length is explicit -- not NUL-terminated. There is no GC.
\ We reserve a fixed buffer and build the greeting by hand.

CREATE GREETING  32 ALLOT   \ static buffer in the dictionary
VARIABLE GLEN  0 GLEN !      \ track the live length ourselves

: +STR ( c-addr u -- )      \ append a string to GREETING
  DUP >R                    ( src u -- src u ) save u on return stack
  GREETING GLEN @ +         ( src u -- src u dst )  dst = GREETING+GLEN
  SWAP CMOVE                ( src u dst -> src dst u ) copy u bytes
  R> GLEN +! ;             \ add the saved length to GLEN

S" Hello, " +STR
S" C"       +STR
S" !"       +STR

GREETING GLEN @ TYPE  CR    \ print address+length -> Hello, C!
\ No FREE: ALLOT space lives until FORGET/MARKER reclaims it in bulk.
\ True heap strings would use ALLOCATE / FREE (optional word set) like C.

Forth represents a string as an explicit ( c-addr u ) address-and-length pair on the stack, so the length is carried, not terminated - the opposite of C's '\0'. The core language has no heap: we build into a fixed CREATE/ALLOT buffer that is never individually freed (it is reclaimed in bulk by FORGET/MARKER); for true malloc-style strings you would reach for the optional ALLOCATE/FREE word set and pair them by hand.