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.
#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.
#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 (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.
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.
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.
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 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.