Generics & Polymorphism
Generics let one piece of code work over many types instead of being copy-pasted per type. The task is the same everywhere: write a generic max (and a swap) that works for int, f64, and beyond. The systems languages split into two camps for how they specialize: monomorphization (C++ templates, Zig comptime, Odin parametric polymorphism stamp out a concrete version per type at compile time - zero runtime cost, no boxing, no allocation) versus macros/textual or weak typing (C's _Generic and macros, HolyC, and Forth's untyped stack). The memory angle: true compile-time generics keep values on the stack with no hidden indirection, whereas runtime polymorphism (void *, vtables) would add pointers, boxing, and lifetimes to manage.
#include <stdio.h>
#include <string.h>
/* C has no generics. Two idioms cover the gap: */
/* 1) A type-generic MACRO: textual substitution, re-typechecked per use.
Beware double evaluation -- MAX(i++, j) would increment twice. */
#define MAX(a, b) ((a) > (b) ? (a) : (b))
/* 2) C11 _Generic: compile-time type dispatch picks a concrete function.
No runtime cost, no boxing -- it resolves to one of these calls. */
static int max_i(int a, int b) { return a > b ? a : b; }
static double max_d(double a, double b) { return a > b ? a : b; }
#define max(a, b) _Generic((a), int: max_i, double: max_d)((a), (b))
/* A truly type-agnostic swap works on raw bytes via void* + size. */
static void swap(void *a, void *b, size_t n) {
unsigned char tmp[64]; /* scratch on the stack, no malloc */
memcpy(tmp, a, n);
memcpy(a, b, n);
memcpy(b, tmp, n);
}
int main(void) {
printf("%d\n", max(7, 3)); /* _Generic -> max_i -> 7 */
printf("%g\n", max(2.5, 9.1)); /* _Generic -> max_d -> 9.1 */
int x = 1, y = 2;
swap(&x, &y, sizeof x); /* byte-wise, type-blind */
printf("%d %d\n", x, y); /* 2 1 */
return 0;
}C has no real generics, so you reach for macros (MAX, fast but unsafe: no type check, double-evaluates its args) or C11 _Generic, which dispatches on the static type to a concrete function at compile time. A type-erased swap uses void * + a size_t and swaps raw bytes on the stack - no heap, but you lose all type safety.
#include <iostream>
#include <concepts>
#include <utility>
#include <string>
// A function template, monomorphized per type at compile time.
// A C++20 `concept` constrains T to types that support `<`.
template <std::totally_ordered T>
constexpr const T& max(const T& a, const T& b) {
return (a < b) ? b : a; // by const& -> no copies of big objects
}
// `swap` is templated too; std::swap already does this via move semantics.
template <typename T>
void swap(T& a, T& b) noexcept {
T tmp = std::move(a); // move, not copy: no extra allocation
a = std::move(b);
b = std::move(tmp);
}
int main() {
std::cout << max(7, 3) << '\n'; // T = int -> 7
std::cout << max(2.5, 9.1) << '\n'; // T = double -> 9.1
std::cout << max(std::string{"ab"},
std::string{"az"}) << '\n'; // T = std::string -> az
int x = 1, y = 2;
swap(x, y);
std::cout << x << ' ' << y << '\n'; // 2 1
}C++ templates generate a fresh, fully type-checked version of max/swap for each type at compile time (monomorphization) - zero runtime dispatch. A C++20 concept (std::totally_ordered) constrains T for clear errors, const T& avoids copying large objects, and std::move swaps via moves so heap-owning types like std::string never reallocate.
// HolyC has no templates or generics -- and, unlike C, no function-like
// #define macros either ("No #define functions exist"). So the reusable
// `max` idiom is just per-type functions over its I64/F64 numeric types.
I64 MaxI(I64 a, I64 b=0) { return a > b ? a : b; }
F64 MaxF(F64 a, F64 b=0) { return a > b ? a : b; }
// A type-blind swap over raw bytes, like C's void* version.
U0 Swap(U8 *a, U8 *b, I64 n) {
I64 i;
for (i = 0; i < n; i++) {
U8 t = a[i];
a[i] = b[i];
b[i] = t;
}
}
Print("%d\n", MaxI(7, 3)); // per-type func -> 7
Print("%5.2f\n", MaxF(2.5, 9.1)); // 9.10
I64 x = 1, y = 2;
Swap(&x, &y, sizeof(I64)); // byte-wise swap on the stack
Print("%d %d\n", x, y); // 2 1
TempleOS HolyC has no generics, and (unlike C) no function-like #define macros either - Terry Davis omitted them on purpose - so the reusable idiom is per-type functions (MaxI, MaxF), here using HolyC's default args (e.g. b=0). A byte-wise Swap over U8 * + length is type-agnostic but, like C, surrenders type safety.
const std = @import("std");
// `comptime T: type` makes T a compile-time parameter: Zig stamps out a
// concrete `max` per type, like a template -- no runtime cost, no boxing.
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
// Generic swap; the pointer type carries T, so no void* / size needed.
fn swap(comptime T: type, a: *T, b: *T) void {
const tmp = a.*; // value lives on the stack, no allocator
a.* = b.*;
b.* = tmp;
}
pub fn main() void {
std.debug.print("{d}\n", .{max(i32, 7, 3)}); // 7
std.debug.print("{d}\n", .{max(f64, 2.5, 9.1)}); // 9.1
var x: i32 = 1;
var y: i32 = 2;
swap(i32, &x, &y);
std.debug.print("{d} {d}\n", .{ x, y }); // 2 1
// The std library uses the same idea: std.mem.swap(i32, &x, &y),
// and @max(a, b) is a builtin that works for any numeric pair.
}Zig generics are just functions that take a comptime T: type parameter; the compiler monomorphizes one version per T, fully type-checked, with no runtime dispatch or boxing. Everything stays on the stack - no allocator is touched - and the standard library provides std.mem.swap(T, ...) and the @max builtin out of the box.
use fmt;
// Hare has no generics. The idiom is per-type functions (often via the
// std library's typed helpers), since the type system is fully static.
fn max_int(a: int, b: int) int = if (a > b) a else b;
fn max_f64(a: f64, b: f64) f64 = if (a > b) a else b;
// A truly generic swap works over raw bytes, like C's void* version:
// take two pointers, reinterpret as byte slices, and exchange them.
fn swap(a: *opaque, b: *opaque, n: size) void = {
let pa = a: *[*]u8, pb = b: *[*]u8;
for (let i = 0z; i < n; i += 1) {
const t = pa[i]; // one byte on the stack, no alloc
pa[i] = pb[i];
pb[i] = t;
};
};
export fn main() void = {
fmt::printfln("{}", max_int(7, 3))!; // 7
fmt::printfln("{}", max_f64(2.5, 9.1))!; // 9.1
let x = 1, y = 2;
swap(&x, &y, size(int)); // byte-wise swap
fmt::printfln("{} {}", x, y)!; // 2 1
};Hare deliberately has no generics - it favors a small, explicit language - so reusable code is written as per-type functions (max_int, max_f64) or, for true type-agnostic work, over raw bytes via *opaque + a size (Hare's void * equivalent). The byte-wise swap allocates nothing; it just exchanges bytes through reinterpreted slice pointers.
package main
import "core:fmt"
import "core:slice"
// Odin's parametric polymorphism: $T is a type parameter resolved at the
// call site and monomorphized -- one concrete proc per type, no boxing.
// `comparable` (or `ordered` here via the where clause) constrains T.
max_of :: proc(a, b: $T) -> T where intrinsics.type_is_ordered(T) {
return a if a > b else b
}
// Generic swap; ^T is a typed pointer, so no void* / size dance.
swap :: proc(a, b: ^$T) {
tmp := a^ // value on the stack, no context.allocator used
a^ = b^
b^ = tmp
}
import "base:intrinsics"
main :: proc() {
fmt.println(max_of(7, 3)) // T = int -> 7
fmt.println(max_of(2.5, 9.1)) // T = f64 -> 9.1
x, y := 1, 2
swap(&x, &y)
fmt.println(x, y) // 2 1
// The std library also ships slice.swap(s, i, j) and builtin max(a, b).
}Odin spells generics with parametric polymorphism: a $T type parameter is inferred at the call site and the proc is monomorphized per type, optionally constrained with a where clause. Like the other monomorphizing languages, values stay on the stack and the implicit context.allocator is never used; ^T typed pointers make swap type-safe with no void * or size argument.
\ Forth is UNTYPED: the data stack holds raw cells (machine words). One
\ word like MAX works for any value that fits in a cell -- there are no
\ type parameters because there are no types to parameterize over.
: MAX ( a b -- max ) \ same word serves ints, addresses, chars...
2DUP < IF SWAP THEN ( a b -- big small )
DROP ( big small -- big )
;
\ `SWAP` is already a core word: it exchanges the top two stack items,
\ regardless of what they represent. That IS generic swap.
7 3 MAX . CR \ 7 (two integers)
1 2 SWAP . . CR \ prints: 1 2 (top two cells exchanged)
\ Floats live on a SEPARATE float stack, so a single MAX cannot span
\ both int and float; FMAX ( F: a b -- max ) is the float-stack version.Forth has no type system at all - the data stack holds untyped cells - so a single MAX word is implicitly 'generic': it works on any cell-sized value (ints, addresses, chars), and core SWAP is already a universal swap. The catch is the opposite of safety: nothing stops you mixing meanings, and floats use a separate float stack (FMAX), so genericity here is uniformity of representation, not parametric typing.