Macros, Reflection, and Eval: metaprogramming compared
How six languages let programs write, inspect, and run programs - from compile-time macros to runtime eval - and what each one deliberately leaves out.
Metaprogramming is the art of treating a program as data: generating code, inspecting types and values at runtime, or evaluating text as live source. There is no single technique, and the differences between languages are really differences in when the machinery runs - at compile time, at load time, or at runtime - and in how much of itself the language is willing to expose. Here are six languages arranged roughly from "transform the syntax tree before it runs" to "build and run new code on the fly," plus one that, by design, does almost none of it.
Rust: hygienic macros over a token stream
Rust draws a hard line: there is no runtime eval, and reflection is limited. Instead, all of Rust's metaprogramming happens at compile time. Declarative macros (macro_rules!) match against token-tree patterns and expand into more tokens before type checking:
macro_rules! my_vec {
($($x:expr),* $(,)?) => {{
let mut v = Vec::new();
$( v.push($x); )*
v
}};
}
Procedural macros go further: they receive a TokenStream and return one, so a #[derive(Serialize)] is literally a function from syntax to syntax. Macro hygiene means identifiers a macro introduces will not accidentally capture or collide with the caller's names. The cost is that macros operate on syntax, not on resolved types - a derive macro sees the text of your struct, not its checked semantics. What you get back is strong: zero runtime cost, full type checking after expansion, and no way for generated code to surprise you at runtime.
Raku: a language designed to rewrite itself
Raku (formerly Perl 6) is arguably the most metaprogramming-forward language in this set. It exposes its own grammar, and the Meta-Object Protocol (MOP) lets you reach the machinery behind every object via .^ calls: $obj.^methods, $obj.^name, $obj.^attributes. You can add methods to a class after the fact, define new operators as ordinary subs, and slot your own grammar productions into the parser. Raku's Grammar class makes parsing a first-class, named, inheritable construct:
grammar Email {
token TOP { <user> '@' <host> }
token user { \w+ }
token host { \w+ '.' \w+ }
}
say Email.parse('ada@example.com')<user>; # ada
Macros existed in early designs and remain the least settled corner, but the MOP plus run-time EVAL plus user-defined operators cover most of the ground that macros cover elsewhere. Larry Wall's guiding instinct, that a language should make the easy things easy and the hard things possible, shows here: the hard thing - reshaping the language itself - is merely possible, not forbidden.
Perl: symbol tables, typeglobs, and string eval
Perl 5 does its metaprogramming through the symbol table. Every package is a hash of names, and a typeglob (*name) is a handle on a slot in it. Assigning a code reference to a glob installs a sub at runtime - this is how Moose, Class accessors, and most of CPAN's "magic" actually work:
no strict 'refs';
*{"main::greet"} = sub { "hi $_[0]" };
print greet("world"), "\n"; # hi world
my $name = "greet";
print &{$name}("perl"), "\n"; # symbolic dispatch: hi perl
Both forms above run verified: glob installation and symbolic dispatch by computed name. Perl also has string eval, which compiles and runs source at runtime (eval "3 ** 4 + 2" yields 83), and the B:: family for introspecting the compiled optree. The danger is the same as the power: symbolic references and string eval mean names and code can come from data, so discipline (and use strict) is what keeps a program honest.
Python: reflection first, eval as a sharp edge
Python's metaprogramming is mostly reflective and dynamic rather than syntactic. Objects carry their structure as live data: getattr, setattr, dir, __dict__, and type(name, bases, ns) let you inspect and build classes at runtime, and metaclasses hook class creation itself. Decorators wrap or replace callables. And then there is the blunt instrument, eval/exec, which compiles a string and runs it in a namespace you supply:
g = Greeter()
m = getattr(g, "hello") # look up a method by name
m("world") # -> "hi world"
ns = {}
exec("def f(x):\n return x * x\n", ns)
ns["f"](7) # -> 49
All three forms above ran as shown. Reflection is idiomatic and everywhere; eval/exec are powerful but discouraged on untrusted input because they execute arbitrary code with full privileges. The Python ethos is to prefer the readable reflective API over the string-evaluating shortcut whenever you can.
Guji: no eval, no macros - structure instead
Guji is a deliberate counterpoint. It is statically typed, immutable-by-default, functional-first, and compiles ahead of time to a single native binary, so there is no runtime type information required for dispatch and no eval in the language at all. There is no macro system either; the design principle "one obvious way" treats syntactic metaprogramming as redundant surface. What Guji offers instead is two forms of compile-time structure that do much of the work people reach for macros to do.
First, first-class regular expressions with the ~~ match operator, where a literal pattern's capture names are checked at compile time:
sub main(): Int {
$line = "ada\@example.com"
$r = match $line ~~ /(?<user>\w+)@(?<host>\w+)/ {
Some($m) { "user=" ~ $m<user>.unwrap_or("?") ~ " host=" ~ $m<host>.unwrap_or("?") }
None { "no match" }
}
print($r)
0
}
That program runs on the v0 tree-walking interpreter and prints user=ada host=example. (Note the \@: in a double-quoted string @ introduces list interpolation, so a literal at-sign is escaped - a small sign of how seriously Guji takes text.) Second, grammar declarations turn parsing into a named, reusable, structured value that yields a typed Bush parse tree, the role Raku gives grammars but without any runtime code generation. When patterns must be data-driven, Regex.compile($str) builds one at runtime and returns a Result, the closest Guji comes to "code from a string" - and even that is just a regex, not arbitrary execution. The lesson Guji embodies is that a strong type system plus first-class text handling removes much of the demand for metaprogramming rather than satisfying it.
Haskell: typed macros and types as the metalanguage
Haskell (pure, lazy, statically typed, compiled with GHC) splits metaprogramming across two layers. At the value layer, Template Haskell is a typed, compile-time macro and reflection system: quotation brackets [| ... |] capture an expression as an AST of type Q Exp, splices $(...) insert generated code, and the reify function inspects declared types and definitions during compilation.
import Language.Haskell.TH
mkConst :: Int -> Q Exp
mkConst n = pure (LitE (IntegerL (fromIntegral n)))
-- usage: $(mkConst 42) expands to the literal 42 at compile time
The more distinctively Haskell layer, though, is the type layer: type classes are a form of compile-time, type-directed code selection, and with type families and GADTs you can compute with types themselves. deriving mechanisms (including DeriveGeneric plus GHC.Generics) give structural reflection over a datatype's shape without a separate macro pass, so libraries like Aeson generate JSON encoders from the type alone. Because it is statically typed and we have no GHC available here, the snippets above are written from knowledge rather than executed, but the semantics are standard: everything resolves and is type-checked before a single instruction runs.
The trade-off, in one line each
Rust transforms tokens before checking and refuses runtime eval; Raku exposes its own grammar and object model and lets you reshape the language; Perl rewrites its symbol table and evals strings; Python reflects richly and evals as a last resort; Guji declines eval and macros entirely, betting on types and first-class text; and Haskell makes the type system itself the metalanguage, with Template Haskell as a typed escape hatch. The spectrum runs from "metaprogramming is the syntax" to "metaprogramming is the type system" to "you should not need it" - and which end you prefer says as much about the problems you face as about the language you chose.