Metaprogramming in Zig & C++
I think you could replicate alot Zig code (with enough mental gynastics, and with a few exceptions) in C++. Even so, I think there's alot to appreciate in the Zig language.
I like Zigs simplicity. C++ has many flavours of metaprogramming (templates, reflection, constexpr) and Zig has just one. Zig treats types to first-class values at compile time, making Zig metaprogramming easy to pick up and straightforward to use.
Here's an example of what I mean. Lets say you wanted to generate the
header of a CSV file based on a struct, where eg.
struct Point { float x, float y} becomes
"x, y" entirely at compile time. Here's code in C++:
template < typename T >
consteval std::string generate_columns () {
std::string columns ;
bool first = true;
constexpr auto members = std::meta::nonstatic_data_members_of (^^T );
template for (constexpr std::meta::info member : members ) {
if (!first )
columns += ", " ;
first = false;
columns += std::meta::identifier_of (member );
}
return columns ;
}
Complaining about conceptual baggage in C++ is boring, but let me
labour the point a little. To write the above code we have to pretty
strongly understand: (1) templates, (2) consteval and (3)
reflection. Even more so, you have to identify the negative
space of language features you shouldn't use. C-style macros?
Runtime type information (RTTI)? What about vibe coding
consteval std::string get_column_names() on every class,
and a C++ 20 concept definition?
Anyway, here's what identical code looks like in Zig:
fn generateColumns ( comptime T : type ) [] const u8 {
var columns : [] const u8 = "" ;
const fields = @typeInfo ( T ). @ "struct" . fields ;
for ( fields , 0 ..) |field , i | {
if ( i > 0 ) {
columns = columns ++ ", " ;
}
columns = columns ++ field . name ;
}
return columns ;
}
You could dismiss some of Zig's improvements on C++ as "clearer syntax" or "better user experience", but most of this falls out of design decisions deeper within the language:
In Zig, types are first-class values at compile time. Instead of templates, you can pass a type as a parameter to a function. Functions can return new types, which is how Zig implements generics. Reflection is just the same as a regular function call.
Zig has no
constexprannotations, and all functions are elligible for execution at compile time. It is still a compile error to call runtime-only functions in acomptimecontext, but far fewer annotations are needed overall relative to C++.
Again, neither of these are showstoppers for C++. You can carry on
using templates, reflection syntax, and mark everything with
constexpr. When you inevitably run into an edge case,
you'll dig up a tutorial on the "constexpr two-step", hopefully fix it
or just give up. The language is undeniably harder, and more of its
capabilities are out of reach.
The example that made me write this post
I love C++'s boost::accumulators API, but I
could not reimplement it. Here's what it looks like:
// a new type for just the mean and variance
accumulator_set < double , stats < tag::mean , tag::variance (lazy )>> acc ;
// push in some data ...
acc (1.2 );
acc (2.3 );
// Display the results ...
std::println ("Mean: {}, Variance: {}" , mean (acc ), variance (acc ));
The whole point of this library is that hand-written statistical
accumulators can do less arithmetic than the sum of their individual
components. At compile time, Boost somehow determines that
mean and variance both depend on the sum, and
the count of datapoint, and it doesn't calculate them twice:
struct mean {
double sum_of_x ;
size_t count_of_x ;
};
struct variance {
double sum_of_x ;
double sum_of_x_squared ;
size_t count_of_x ;
};
// avoids redundant calculations
struct hand_written_acc {
double sum_of_x ;
double sum_of_x_squared ;
size_t count_of_x ;
};
// this is what Boost produces!
How would you write the Boost library in C++? If I were to guess, I would say that you need to create a distinction between raw quantities that you store and statistics you output.
// quantities: things you store
struct Sum { double value ; };
struct SumOfSquares { double value ; };
struct Count { size_t value ; };
// statistics: things you output
struct Mean ;
struct Count ;
struct Variance ;
You want to have some way of storing a variable number of quantities
in a single statistical object. C++ has variadic templates, which can
feed into a std::tuple:
// we instantiate eg. Accumulator<Sum, Count>
template < class ... Qs >
class Accumulator {
std::tuple < Qs ...> tuple ;
operator()(double v ) {
// insert `v` to every quantity in `tuple`
std::apply (
[v ](auto & ... q ) { (q .update (v ), ...); }, tuple
);
}
template <>
double get < Mean > () const {
return std::get < Sum > (tuple ).value / std::get < Count > (tuple ).value ;
}
}
Even if this doesn't match Boost's API, I think this is super satisfying. The user specifies exactly what intermediary results they want to calculate and C++ variadic templates let you compose exactly the object that you need.
Accumulator < Sum , Count > acc ;
auto m = acc .get < Mean > ();
Unfortunately, if a user wants the mean and the variance, they have
to determine that the variance needs an extra SumOfSquares
term, and update the accumulator object:
Accumulator < Sum , Count > acc ;
auto m = acc .get < Mean > ();
auto v = acc .get < Variance > (); // COMPILE ERROR
Boost does the resolving from <Mean, Variance> to
<Sum, SumOfSquares, Count> and I have no idea how to
implement this. C++ metaprogramming has an astonishingly sharp skill
curve, and perhaps I have a skill issue.
Rewrite in Zig?
Zig types are just expressions at compile time. The standard way to write generics in Zig is to write a function that takes a type, then construct and return a new type.
fn DoubleOrNothing ( comptime win : bool ) type {
return struct {
item : f64 if ( win ) else void ;
}
}
var x : DoubleOrNothing ( true ) = .{. item = 0.0 };
This is isomorphic to templates in C++, and you can achieve the same
thing as above with partial template specialization or
std::conditional_t in C++. But Zig is just cool.
Types as expressions and compile time duck typing makes Zig feel like
writing Python.
As a bonus, you are able to write something like Boost's compile time dependency resolution in Zig, which you'd really struggle to write in C++. Dependency resolution is conceptually just a mapping from one list of types to another:
fn resolve ( comptime types : [] const type ) [] const type {
A minimal and faithful re-implementation of
boost::accumulator is just less than 100 lines of Zig. The
public API does the aformentioned compile-time dependency
resolution:
pub fn main () ! void {
var acc : Accumulator (.{ Mean , Variance }) = .{};
acc . update ( 3.0 );
acc . update ( 3.0 );
acc . update ( 4.0 );
std . debug . print ( "mean= { d } \n " , .{ acc . result ( Mean )});
std . debug . print ( "variance= { d } \n " , .{ acc . result ( Variance )});
}
The core implementation of Accumulator is actually quite
elegant. We take in an anytype, coerce it to a slice of
types and perform dependency resolution. When we update, we update each
individual field in state. Getting a result is classic
dependency injection, where the input type (eg. Mean)
provides a .calculate() method to calculate the
statistic.
fn Accumulator ( comptime stats : anytype ) type {
// eg. resolve([Mean]) returns [Sum, Count]
const quantities = comptime resolve ( & stats );
return struct {
// store @Tuple([Sum, Count])
state : @Tuple ( quantities ), // default value todo
fn update ( self : * @This (), item : f32 ) void {
// update sum and count
inline for ( & self . state ) |* field | field . update ( item );
}
fn result ( self : * @This (), S : type ) f32 {
// calculate the statistic using state
return S . calculate ( self );
}
fn get ( self : * @This (), Q : type ) f32 {
// get any stored quantity in state
inline for ( self . state ) |field | {
if ( @TypeOf ( field ) == Q ) return field . val ;
}
}
};
}
Here's the rough implementation of the statistics and quantities respectively:
const Sum = struct {
val : f32 = 0 ,
fn update ( self : * @This (), item : f32 ) void {
self . val += item ;
}
};
const Count = struct {
val : f32 = 0 ,
fn update ( self : * @This (), _ : f32 ) void {
self . val += 1.0 ;
}
};
const Mean = struct {
const deps = [ _ ] type { Count , Sum };
fn calculate ( state : anytype ) f32 {
return state . get ( Sum ) / state . get ( Count );
}
};
const Variance = struct {
const deps = [ _ ] type { Count , Sum , SumOfSquares };
fn calculate ( state : anytype ) f32 {
const n = state . get ( Count );
const s = state . get ( Sum );
const ss = state . get ( SumOfSquares );
return ss / n - ( s / n ) * ( s / n );
}
};
The full implementation has a teeny weeny bit more boilerplate, but it's remarkably coherent.
Closing Notes
To be honest, I still like bits of C++. There's convenience in RAII, global allocators and in having pretty much every language feature of the last half century at your fingertips. C++ is de-facto standard and a powerful language once you've internalized its quirks.
Zig, by contrast, strikes tradeoff between complexity and clarity
that is rare for a systems programming language. The syntax is
refreshing, and comptime alongside a sprinkle of structural
typing makes metaprogramming feel like writing Python. The community is
small, but fanatically ambitious. They're currently working on writing
their own Zig
compiler backend to replace LLVM as well as porting libc to
Zig. I'm excited for the language.