← Code Compare

Metaprogramming & Compile-Time Code

Metaprogramming is code that runs (or generates code) before your program does, moving work from run time to compile time. The task is the same in every language: build a lookup table of squares at compile time so it costs zero work and zero allocation at run time, plus show each language's generic/code-gen idiom. The split is sharp: textual macros (C's preprocessor, HolyC's #define) blindly substitute tokens; real compile-time execution (C++ constexpr/templates, Zig comptime) runs the same language at build time with full type checking; homoiconic metaprogramming (Forth's immediate words) lets the program extend its own compiler. Memory angle: a comptime/constexpr table lives in read-only static storage (.rodata) -- no malloc, no initializer loop, nothing to free.

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

/* The C "metaprogramming" engine is the PREPROCESSOR: pure textual
   substitution before the compiler ever sees real C. It has no types
   and no real loops, so it cannot compute a table by iterating. */

/* 1) A function-like macro: generic over numeric types, but unsafe --
      it double-evaluates its argument (SQR(i++) increments twice). */
#define SQR(x) ((x) * (x))

/* 2) X-macros: the closest thing to compile-time codegen in C. One list
      is expanded twice -- here to build a static, read-only table whose
      values are computed by the COMPILER (constant folding), not at run
      time. No malloc, no init loop: it lives in .rodata. */
#define SQUARES \
    X(0) X(1) X(2) X(3) X(4) X(5) X(6) X(7)

static const int square_table[] = {
#define X(n) [n] = (n) * (n),
    SQUARES
#undef X
};

int main(void) {
    printf("SQR(5)        = %d\n", SQR(5));        /* macro -> 25 */
    printf("table[6]      = %d\n", square_table[6]); /* 36, precomputed */
    printf("table entries = %zu\n",
           sizeof square_table / sizeof square_table[0]); /* 8 */
    return 0;
}

C's only metaprogramming is the preprocessor -- blind text substitution with no types or loops. SQR is a generic-but-unsafe macro (it double-evaluates its argument), while the X-macro trick expands one list into a static const table the compiler constant-folds into .rodata: zero runtime cost, no malloc, nothing to free.

C++
#include <array>
#include <print>   // std::println (C++23); use <iostream> pre-23

// Modern C++ runs REAL C++ at compile time. `constexpr` (here `consteval`-
// style) builds an std::array of squares during compilation -- the values
// land in read-only static storage, with no heap and no init loop at run
// time. This is a function template too, so it works for any size N.
template <std::size_t N>
constexpr std::array<int, N> make_squares() {
    std::array<int, N> t{};
    for (std::size_t i = 0; i < N; ++i)
        t[i] = static_cast<int>(i * i); // ordinary loop, run at compile time
    return t;
}

// `static constexpr` forces it to be a compile-time constant in .rodata.
static constexpr auto square_table = make_squares<8>();

// A constexpr generic SQR: type-checked, no double-evaluation (a real call).
template <typename T>
constexpr T sqr(T x) { return x * x; }

int main() {
    static_assert(square_table[6] == 36); // verified by the compiler itself
    std::println("sqr(5)   = {}", sqr(5));            // 25
    std::println("table[6] = {}", square_table[6]);   // 36, precomputed
    std::println("entries  = {}", square_table.size()); // 8
}

C++ executes real C++ at compile time: a constexpr function with an ordinary for loop fills an std::array, and static constexpr plants the result in read-only static storage -- no heap, no runtime init. static_assert lets the compiler prove table[6] == 36, and templated sqr is a type-safe generic with no macro double-evaluation.

HolyC
// HolyC's metaprogramming is C-style: the #define PREPROCESSOR plus the
// fact that top-level code runs immediately (TempleOS JIT-compiles each
// line), so you can fill a table at load time. There is no comptime; the
// closest "compile-time table" is a static array initialized once on load.

#define SQR(x) ((x) * (x))   // textual macro, double-evaluation caveat

#define N 8
I64 square_table[N];          // lives in the program's data segment

// Top-level loop runs once when this file is compiled+run -- no main().
I64 i;
for (i = 0; i < N; i++)
  square_table[i] = i * i;    // filled in place, no MAlloc needed

Print("SQR(5)   = %d\n", SQR(5));          // 25
Print("table[6] = %d\n", square_table[6]); // 36
Print("entries  = %d\n", N);               // 8

// (A heap version would be: I64 *t = MAlloc(N * sizeof(I64)); ...; Free(t);
//  but a fixed table needs no heap and nothing to Free.)

TempleOS HolyC has the C preprocessor (#define SQR, with the usual double-evaluation caveat) but no compile-time execution. Because HolyC JIT-runs top-level code as it compiles, the idiomatic "precomputed table" is a data-segment array filled by a one-time loop at load -- no MAlloc, nothing to Free. Runs only on TempleOS.

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

// `comptime` runs ordinary Zig DURING compilation. This block builds the
// squares table at compile time; the result is a const baked into the
// binary's read-only data -- no allocator, no init loop at run time.
const N = 8;
const square_table: [N]i32 = blk: {
    var t: [N]i32 = undefined;
    for (&t, 0..) |*slot, i| {
        slot.* = @intCast(i * i); // evaluated at comptime
    }
    break :blk t;
};

// A generic, type-safe SQR: `comptime T: type` monomorphizes per type.
fn sqr(comptime T: type, x: T) T {
    return x * x;
}

pub fn main() void {
    // Force-evaluate at comptime and let the compiler check the value.
    comptime std.debug.assert(square_table[6] == 36);

    std.debug.print("sqr(5)   = {d}\n", .{sqr(i32, 5)});   // 25
    std.debug.print("table[6] = {d}\n", .{square_table[6]}); // 36
    std.debug.print("entries  = {d}\n", .{square_table.len}); // 8
}

Zig's comptime runs the same language at build time: an ordinary for loop fills the table inside a comptime-evaluated block, and the const result is embedded in read-only data -- no allocator is ever touched and nothing is freed. comptime std.debug.assert proves the value at compile time, and sqr(comptime T: type, ...) is a monomorphized generic, no boxing.

Hare
use fmt;

// Hare has NO comptime metaprogramming and no macros -- it deliberately
// keeps the language tiny. The idiom for a fixed table is to write the
// constant values out (a global array), which the compiler places in
// static read-only data: no alloc, no init loop, nothing to free.
def N: size = 8;

const square_table: [N]int = [0, 1, 4, 9, 16, 25, 36, 49];

// No generics either, so a reusable square is a plain typed function.
fn sqr(x: int) int = x * x;

export fn main() void = {
	fmt::printfln("sqr(5)   = {}", sqr(5))!;             // 25
	fmt::printfln("table[6] = {}", square_table[6])!;    // 36
	fmt::printfln("entries  = {}", len(square_table))!;  // 8
};

Hare intentionally has no macros and no compile-time code execution -- there is nothing to generate code with. A precomputed table is just a const global array (literal values), which the compiler lays out in static read-only memory: no alloc, no init loop, nothing to free. Reuse is via ordinary typed functions like sqr, since Hare also has no generics.

Odin
package main

import "core:fmt"

// Odin has no general comptime-function execution like Zig, but constant
// declarations (`::`) ARE evaluated at compile time, and a constant array
// is placed in static read-only data -- no allocator, no init loop, nothing
// to free. So the precomputed table is a constant array of literal values.
N :: 8

// `::` = compile-time constant; this array lives in .rodata.
square_table :: [N]int{0, 1, 4, 9, 16, 25, 36, 49}

// Odin's real metaprogramming for code reuse is PARAMETRIC POLYMORPHISM:
// `$T` is resolved + monomorphized at the call site (one concrete proc per
// type), like a C++ template -- no boxing, no runtime dispatch.
sqr :: proc(x: $T) -> T { return x * x }

main :: proc() {
	fmt.println("sqr(5)   =", sqr(5))            // T = int -> 25
	fmt.println("sqr(2.5) =", sqr(2.5))          // T = f64 -> 6.25
	fmt.println("table[6] =", square_table[6])   // 36, compile-time constant
	fmt.println("entries  =", len(square_table)) // 8
}

Odin lacks Zig-style comptime function execution, but constant declarations (::) are evaluated at compile time and constant arrays land in static read-only data -- no context.allocator, no init loop, nothing to free. Odin's metaprogramming for reuse is parametric polymorphism: sqr(x: $T) is monomorphized per type at the call site, like a template, with no boxing.

Forth
\ Forth is the ULTIMATE metaprogrammable language: the compiler is just
\ more Forth words, and an IMMEDIATE word runs AT COMPILE TIME, letting a
\ program extend its own compiler. We use that to build a squares table in
\ data space while the source is being compiled -- no run-time init loop.

8 CONSTANT N

\ DO...LOOP are compile-only words, so the loop must live INSIDE a colon
\ definition rather than run bare in interpret state. ',' compiles one
\ literal cell into data space at the current dictionary pointer.
: FILL-SQUARES ( -- )  N 0 DO  I I *  ,  LOOP ;

\ CREATE makes a word that pushes its data-space address; running
\ FILL-SQUARES immediately after appends the 8 cells contiguously -- all
\ at compile time, so there is no run-time init loop.
CREATE SQUARE-TABLE  FILL-SQUARES

\ SQR is a normal word; ':' ... ';' is itself Forth defining new compiler
\ output. (DUP * squares the top cell -- untyped, works on any cell value.)
: SQR ( n -- n*n )  DUP * ;

\ Index helper: fetch cell #i  ( i -- table[i] )
: SQ@ ( i -- n )  CELLS SQUARE-TABLE + @ ;

5 SQR .          CR      \ 25
6 SQ@  .         CR      \ 36   (precomputed at compile time)
N .              CR      \ 8

\ Genuine compile-time codegen: an IMMEDIATE word executes during
\ compilation, so ':' ';' and the DO...LOOP above ARE metaprogramming.

Forth is homoiconic: its compiler is built from ordinary words, and IMMEDIATE words execute during compilation -- so the program can extend its own compiler. The table is generated at compile time by running a DO ... LOOP that uses , to compile each computed square into data space (CREATE ... ALLOT/,), so there is no run-time init and nothing to free. SQR is implicitly generic because the stack is untyped.