← Code Compare

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.

Show: CC++HolyCZigHareOdinForth
C
#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.

C++
#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
// 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.

Zig
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.

Hare
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.

Odin
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
\ 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.