---
title: "Comparing reflection capabilities of C++, Zig and C3"
slug: comparing-reflection-capabilities-of-c-zig-and-c3
url: https://listedarticles.com/articles/comparing-reflection-capabilities-of-c-zig-and-c3
canonical_url: https://nyr24.github.io/blog/reflection-comparison/
content_type: blog_post
language: en
published_at: 2026-09-20T00:00:00.000Z
updated_at: 2026-09-20T03:08:28.490Z
author: "nyr24"
author_url: https://nyr24.github.io/
authored_by: human
publisher: "nyr24"
publisher_url: https://nyr24.github.io/
topics: ["Programming", "C", "Programming Languages", "Systems Programming"]
license: all-rights-reserved
word_count: 1568
reading_minutes: 7
citation: "nyr24, nyr24. \"Comparing reflection capabilities of C++, Zig and C3.\" 20 Sept 2026. https://nyr24.github.io/blog/reflection-comparison/ (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# Comparing reflection capabilities of C++, Zig and C3

> A side-by-side look at compile-time reflection in upcoming C++, Zig, and C3—how each language inspects types, enumerators, and struct members without runtime cost.

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](https://c3-lang.org/getting-started/roadmap/).

You can search for more info about C3 on [the main website](https://c3-lang.org).

Want to discuss the language or have a question? Join official [C3 server on Discord.](https://discord.gg/qN76R87)
