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