Comparing reflection capabilities of C++, Zig and C3 | nyr24-blog
nyr24-blog Blog Comparing reflection capabilities of C++, Zig and C3 Authors
Comparing reflection capabilities of C++, Zig and C3 Comparing reflection capabilities of C++, Zig and C3 What is C3? Enum to string conversion Struct introspection Validation with compile-time only attributes Conclusions
Comparing reflection capabilities of C++, Zig and C3
nyr24
Sep 20, 2026 Comparing reflection capabilities of C++, Zig and C3 Reflection lets a program inspect and manipulate its own structure at runtime or compile time. All C++ (with its upcoming reflection support), Zig and C3 rely on compile-time reflection, so you can reason about types, enumerators, and struct members without any runtime cost. In this post I will compare how these languages approach compile-time reflection. What is C3?
C3 is a relatively new programming language which mainly focuses on readability, performance, minimalism, and familiarity for C/C++ programmers. It doesn't have heavy runtime, garbage collection, exceptions or RAII. It also fully supports C ABI compatibility out of the box.
C3 uses special syntax for compile-time execution: all variables, control-flow constructs are prefixed with $. This was done on purpose to explicitly show the reader which code runs at compile time. It uses macros for compile-time evaluation and reflection.
C3 macros are designed to provide a replacement for C preprocessor macros. They extend such macros by providing compile-time evaluation using constant folding, which offers an IDE friendly, limited, compile-time execution.
Let’s see all languages in action! Enum to string conversion C++: 1enum class Color { Red, Green, Blue };2 3template <typename E>4constexpr std::string_view enum_to_string(E value) {5 template inline for (constexpr auto r : std::meta::enumerators_of(^^E)) {6 if (value == [:r:]) {7 return std::meta::identifier_of(r);8 }9 }10 return "Unknown";11}12 13int main()14{15 Color color = Color::Red;16 printf("%s", enum_to_string(color));17 18 return 0;19} Zig: 1const Color = enum {2 RED,3 GREEN,4 BLUE,5 6 pub fn to_string(color: Color) []const u8 {7 switch (color) {8 .RED => return "red",9 .GREEN => return "green",10 .BLUE => return "blue",11 }12 }13};14 15pub fn main() !void {16 const c: Color = .BLUE;17 std.debug.print("{s}", .{c.to_string()});18 // Outputs:19 // blue20} In Zig, the only solution I can think of is attaching a method to each enum you want to turn into a string, not a generic approach. I’m not a profound zig expert so you can correct me in the comments. C3: 1enum Color { RED, GREEN, BLUE }2 3macro String enum_to_string($enum_val)4{5 var $EnumType = $Typeof($enum_val);6 $foreach $val : $EnumType::values:7 $if $val == $enum_val:8 return $val.description;9 $endif10 $endforeach11}12 13fn void main()14{15 Color $color = RED;16 String $color_name = enum_to_string($color);17 io::printfn("%s", $color_name);18} In C3 enums have special properties. For example, if you want to print enum value, it will print it in a readable form, exactly as defined in the source code. For example, this code: io::printfn(“%s”, Color.RED) will output RED, not 0.
If you want to take the underlying value from an enum, you can either access .ordinal or cast it to the underlying type.
You can also associate values of any type with your enumerators: 1enum Color : uint (String str_repr, char amount_of_red)2{3 RED { "Red Color", 255 }4 BLUE { "Blue Color", 0 }5}6 7fn void log_color(Color c)8{9 io::printfn("%s %s", c.str_repr, c.amount_of_red); // Outputs: Red Color 25510} Let’s proceed with reflections! Struct introspection C++: 1struct Person {2 std::string_view name;3 int age;4 double height;5};6 7template <typename T>8void print_struct_fields(const T& obj) {9 std::cout << std::meta::identifier_of(^^T) << " details:\n";10 11 template inline for (constexpr auto member : std::meta::nonstatic_data_members_of(^^T)) {12 constexpr std::string_view member_name = std::meta::identifier_of(member);13 std::cout << " " << member_name << ": " << obj.[:member:] << "\n";14 }15}16 17int main() {18 Person alice{"Alice Smith", 30, 1.75};19 print_struct_fields(alice);20 /*21 Outputs:22 Person details:23 name: Alice Smith24 age: 3025 height: 1.7526 */27} Zig: 1const Person = struct {2 name: []const u8,3 age: i32,4 height: f64,5};6 7fn printStructFields(value: anytype) void {8 comptime {9 std.debug.assert(@typeInfo(@TypeOf(value)) == .@"struct");10 }11 inline for (@typeInfo(@TypeOf(value)).@"struct".fields) |field| {12 switch (field.type) {13 []const u8 => {14 std.debug.print("{s}: {s},\n", .{ field.name, @field(value, field.name) });15 },16 else => {17 std.debug.print("{s}: {any},\n", .{ field.name, @field(value, field.name) });18 },19 }20 }21}22 23pub fn main() !void {24 const alice = Person{25 .name = "Alice Smith",26 .age = 30,27 .height = 1.75,28 };29 30 std.debug.print("Person Details:\n", .{});31 printStructFields(alice);32 // Outputs:33 // Person details:34 // name: Alice Smith35 // age: 3036 // height: 1.75000037} C3: 1struct Person2{3 String name;4 int age;5 double height;6}7 8<*9 @require @kindof($val) == STRUCT : "Expected a struct" // (1)10*>11macro void print_struct_fields($val)12{13 var $Type = $Typeof($val);14 $foreach $field : $Type::members:15 io::printfn("\t%s: %s", $field.name, $val.$field);16 $endforeach17}18 19fn void main()20{21 Person $alice = {"Alice Smith", 30, 1.75};22 io::printfn("Person details: ");23 print_struct_fields($alice);24 /*25 Outputs:26 Person details:27 name: Alice Smith28 age: 3029 height: 1.75000030 */31}
Here, (1) C3 uses optional pre-conditions called 'contracts' which can help drastically with input validation. They will be executed at compile-time if it is possible, if not - at runtime.
Validation with compile-time only attributes C++: 1struct Range { int lo; int hi; }2 3struct Config4{5 [[=Range{ 1, 65535 }]] int port;6 [[=Range{ 1, 256 }]] int max_threads;7 [[=Range{ 100, 30000 }]] int timeout_ms;8}9 10template<typename T>11consexpr bool validate(const T& obj)12{13 constexpr auto context = std::meta::access_context::current();14 template for (constexpr auto member: define_static_array(15 nonstatic_data_members_of(^^T, context)) {16 template for (constexpr auto annotation : define_static_array(17 annotations_of_with_type(member, ^^Range))) {18 auto [lo, hi] = extract<Range>(annotation);19 if (obj.[:member:] < lo) return false;20 else if (obj.[:member:] > hi) return false;21 })22 return true;23}24 25static_assert(validate(Config{ 1000, 50, 20000 }));26static_assert(validate(Config{ 0, 0, 0 })); // Fails to compile. Zig:
Zig unfortunately doesn’t have ‘attributes’ or any substitute to attach compile-time data to struct members. C3: 1struct Range { int lo; int hi; }2 3attrdef @Range(r) = @tag("range", r);4 5struct Config6{7 int port @Range({1, 65535});8 int max_threads @Range({1, 256});9 int timeout_ms @Range({100, 30000});10}11 12enum ValidationResult { TO_LOW, TO_HIGH, SUCCESS }13 14// (1)15macro ValidationResult validate_comptime($obj) @const16{17 var $Type = $Typeof($obj);18 19 $foreach $field : $Type::members:20 $if $field.has_tag("range"):21 Range $r = $field.get_tag("range");22 $if $obj.$field < $r.lo:23 return TO_LOW;24 $endif25 $if $obj.$field > $r.hi:26 return TO_HIGH;27 $endif28 $endif29 $endforeach30 return SUCCESS;31}32 33// (2)34macro ValidationResult validate_runtime(obj)35{36 var $Type = $Typeof(obj);37 Range r @noinit;38 39 $foreach $field : $Type::members:40 $if $field.has_tag("range"):41 r = $field.get_tag("range");42 if (obj.$field < r.lo) return TO_LOW;43 if (obj.$field > r.hi) return TO_HIGH;44 $endif45 $endforeach46 return SUCCESS;47}48 49fn void main()50{51 Config $c1 = { .port = 1000, .max_threads = 50, .timeout_ms = 20000 };52 Config $c2 = { .port = 0, .max_threads = 0, .timeout_ms = 0 };53 Config c1 = { .port = 1000, .max_threads = 50, .timeout_ms = 20000 };54 Config c2 = { .port = 0, .max_threads = 0, .timeout_ms = 0 };55 56 io::printn(validate_comptime($c1));57 io::printn(validate_comptime($c2));58 io::printn(validate_runtime(c1));59 io::printn(validate_runtime(c2));60 /*61 Outputs:62 SUCCESS63 TO_LOW64 SUCCESS65 TO_LOW66 */67} For this example with C3 I want to show you 2 options. In the first (1) variant we validate everything at compile-time, we can verify this easily by putting @const attribute on the macro. In the second (2) variant we’re mixing compile-time attributes with validation at runtime. In this example you can see how syntax distinction between $if and if helps to understand which code gets expanded at compile-time and which will execute at runtime. Conclusions
All observed languages can do real compile-time reflection, which is great for serializers, debug printers, and generic helpers like the ones above.The tradeoff is ergonomics: C++ gets the power via verbose template machinery and splices, while C3 makes the same ideas more readable and expressive through its macro system and special syntax for compile-time execution, it's very easy to understand where code will execute at compile time and where it wouldn't.
Zig in turn doesn't have macros, instead it relies on comptime functions and blocks, inline for loops and type-introspection builtins, which is also a good, modern and mostly readable approach.
Personally, I've found C3 to be a very promising systems programming language that needs more attention; everybody knows about C++ and Zig is marketed very well, but C3 lacks that kind of marketing, though it can compete easily with Zig, Odin, or any other new systems programming language out there. Also it doesn't have tons of breaking changes with each minor version. It's a lot more stable than Zig (honestly, it's pretty embarrassing that Zig is still stuck on 0.1x versions after over 10 years of development), and since C3 is already on 0.8.x versions, 1.0 is very close, see the roadmap.
You can search for more info about C3 on the main website. Want to discuss the language or have a question? Join official C3 server on Discord. © 2026 nyr24-blog |
Reflection allows programs to inspect and manipulate their structure either during runtime or compile time, and this capability is explored across C++, Zig, and C3 through compile-time reflection. These languages collectively achieve compile-time reflection, enabling reasoning about types, enumerators, and struct members without incurring runtime costs.
In the context of enumerator to string conversion, the languages demonstrate different approaches. C++ utilizes template machinery and metaprogramming facilities to implement this functionality. Zig relies on attaching methods to each enum member, which is less generic. C3 employs macros to iterate over enum values and access associated descriptions, highlighting the special properties of C3 enumerators. C3 also supports associating arbitrary types with enumerator values, allowing for richer data representation.
Struct introspection showcases further divergence in methodology. C++ achieves introspection through verbose template structures to identify and access nonstatic data members. Zig adopts a more functional approach, leveraging comptime functions and builtins for type introspection. C3 uses macros to iterate over struct members, effectively exposing field names and values. Furthermore, C3 introduces optional pre-conditions, or contracts, attached to struct fields using attributes, which aids in input validation.
The implementation of compile-time validation also differs significantly. C++ validation relies on sophisticated template metaprogramming and static assertions. Zig lacks explicit attribute support for this purpose. C3 provides a mechanism using attributes to define constraints, and macros can be used to implement logic that checks these constraints either at compile time or at runtime. This runtime validation capability in C3 is demonstrated by distinguishing between code expanded at compile time and code executed at runtime within the corresponding macro constructs.
In summary, while all languages support compile-time reflection, the trade-off involves ergonomics. C++ offers powerful reflection via complex template machinery. C3 enhances expressiveness through its macro system and specialized syntax that clearly delineates compile-time versus runtime execution. Zig provides a modern, reasonably readable alternative through comptime functions and type introspection builtins. The author suggests that C3 is a promising systems programming language that merits further attention due to its stability and expressive capabilities relative to its competitors. |