cpp26-reflection · part 17

Replacing Qt's MOC with reflection

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Updated 30 September: the first version of this post showed a sketch that did not compile. The code below is taken from the example that runs on GCC 16.2 and clang-p2996.

Qt’s Meta-Object Compiler (MOC) is one of the oldest codegen tools in the C++ ecosystem. It parses headers looking for Q_OBJECT, Q_PROPERTY, Q_INVOKABLE, signals:, slots:, and emits glue code (.moc files) that adds runtime introspection, property binding, signal/slot dispatch, and QML integration.

It works. It also means Qt projects ship a second compiler, every IDE needs MOC awareness, and your headers have a parallel syntax that isn’t quite C++.

With C++26 reflection, the core of what MOC provides (property introspection for bindings and QML) becomes a library pattern. You write regular C++; reflection gives the framework everything it needs.

The MOC surface we care about

Three things MOC produces, which we’ll replace:

  1. Property metadata: for each Q_PROPERTY(Type name READ getter WRITE setter NOTIFY changed), a runtime registry entry with the name, type, getter, setter, and notify signal.
  2. Property binding: when QML says userNameLabel.text: user.name, QML subscribes to user’s nameChanged signal and re-reads name on fire.
  3. Signal/slot dispatch: metadata about which methods are signals, which are slots, their parameter types.

We’ll focus on (1) and (2). They’re the most common pain points.

Target syntax

class User : public rqt::Object {
public:
    [[=rqt::property{}]] std::string name = "Ada";
    [[=rqt::property{}]] int age = 36;
    [[=rqt::property{}, =rqt::read_only{}]] std::string id = "u-0001";
    int cache_hits = 0;  // not a property: no annotation
};

Three annotations, no macros. A function template, rqt::properties_of<User>(), uses reflection to fill a runtime property registry. The Object base carries only the change signal.

Reflection walks the properties

namespace rqt {

struct property  { bool operator==(property const&) const = default; };
struct read_only { bool operator==(read_only const&) const = default; };

template <typename Tag>
consteval bool has_annotation(std::meta::info r) {
    for (auto a : std::meta::annotations_of(r)) {
        if (std::meta::remove_cvref(std::meta::type_of(a)) == ^^Tag) return true;
    }
    return false;
}

class signal {
public:
    void connect(std::function<void(std::string_view)> slot) {
        slots_.push_back(std::move(slot));
    }
    void emit(std::string_view property_name) const {
        for (auto const& slot : slots_) slot(property_name);
    }
private:
    std::vector<std::function<void(std::string_view)>> slots_;
};

class Object {
public:
    signal changed;
};

struct property_info {
    std::string_view name;
    std::string_view type;
    bool             writable;
    std::function<std::any(Object const&)>        getter;
    std::function<void(Object&, std::any const&)> setter;  // no-op if read-only
};

template <typename T>
std::vector<property_info> properties_of() {
    std::vector<property_info> out;
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr auto m
                  : std::define_static_array(
                      std::meta::nonstatic_data_members_of(^^T, ctx))) {
        if constexpr (has_annotation<property>(m)) {
            using M = [:std::meta::type_of(m):];
            out.push_back({
                .name     = std::meta::identifier_of(m),
                .type     = std::meta::display_string_of(std::meta::type_of(m)),
                .writable = !has_annotation<read_only>(m),
                .getter   = [](Object const& o) -> std::any {
                    return std::any{static_cast<T const&>(o).[:m:]};
                },
                .setter   = [](Object& o, std::any const& v) {
                    if constexpr (!has_annotation<read_only>(m)) {
                        static_cast<T&>(o).[:m:] = std::any_cast<M>(v);
                        o.changed.emit(std::meta::identifier_of(m));
                    }
                },
            });
        }
    }
    return out;
}

}  // namespace rqt

has_annotation is a small shim. GCC 16.2 has P3394’s annotations_of_with_type(r, ^^Tag), and clang-p2996 has annotation_of_type<Tag>(r) instead, but both have annotations_of, so the shim walks the list and compares types.

The getter and setter lambdas splice m without capturing it. m is a constexpr variable and a splice is not an odr-use, so each iteration’s lambdas see their own member. Writing [m] would make a runtime copy, and GCC refuses to splice that copy because a splice needs a constant expression.

GCC 16.1 is stricter than it should be here: it treats these lambdas as consteval because their bodies name m, and rejects them. GCC 16.2 and clang accept them. On 16.1, pass &[:m:] to a helper function template as a template argument and build the lambda inside the helper; that version builds on all three.

The registry is filled at run time. Everything reflection does (listing the members, reading names, types and annotations) happens during compilation, but std::function cannot be used in a constant expression, so the vector of closures is built when properties_of runs.

Using it:

int main() {
    User u;
    auto const props = rqt::properties_of<User>();

    for (auto const& p : props) {
        std::println("{} : {}{}", p.name, p.type,
                     p.writable ? "" : " (read-only)");
    }

    u.changed.connect([](std::string_view name) {
        std::println("changed: {}", name);
    });

    // Write through the registry. The id setter is a no-op: no signal fires.
    for (auto const& p : props) {
        if (p.name == "age") p.setter(u, std::any{37});
        if (p.name == "id")  p.setter(u, std::any{std::string{"u-9999"}});
    }
    std::println("age = {}, id = {}", u.age, u.id);
    std::println("age via getter = {}", std::any_cast<int>(props[1].getter(u)));
}

GCC 16.2 prints:

name : std::__cxx11::basic_string<char>
age : int
id : std::__cxx11::basic_string<char> (read-only)
changed: age
age = 37, id = u-0001
age via getter = 37

clang-p2996 prints the same, except that it spells the string type basic_string<char, char_traits<char>, allocator<char>>. The text of display_string_of is left to the implementation.

Data binding without MOC

QML-like bindings (when any dependency of label.text changes, the label re-reads it) can be built on top:

struct Binding {
    std::function<std::string()> compute;
    std::string value = compute();
    void recompute() { value = compute(); }
};

// Usage:
Binding greeting{ [&] { return "Hello, " + user.name; } };
user.changed.connect([&](std::string_view name) {
    if (name == "name") greeting.recompute();
});

The signal fires only when the write goes through the registry’s setter. A plain user.name = "Grace"; is invisible to it; in Qt that is the job of the property’s setter or of a QProperty wrapper around the member.

Could the framework find those dependencies by itself? Not through reflection. P2996 reflects declarations (types, members, functions and their annotations), not function bodies, so nothing can walk the getter lambda and list the properties it reads. Automatic tracking has to happen at run time: while the getter runs, each property read registers itself with the binding being evaluated. That is how Qt’s bindable properties work, and a reflection-based framework would do the same, with reflection generating the wrapper for each annotated member.

What we don’t replace

  • QML integration: QML needs a stable C-callable ABI for its engine. MOC produces this. Reflection-based generation could produce the same ABI, but that’s a bigger engineering project than a blog post.
  • Q_INVOKABLE + reflection-from-script-engine: also ABI concerns. Same direction.
  • Signals/slots in their full generality: a reflection-built signal/slot system is feasible (boost::signals2 already does much of it without MOC) but requires careful design around thread-affinity (Qt’s killer feature).

Update, 6 October: the first two bullets are answered in the series. QML bindings work against a reflected meta-object, and signals are covered in a later episode.

Update, 6 October: the follow-up is a series. It starts with a QMetaObject that stock Qt accepts, and the rqt::Object registry in this post is a sketch, not that library.

So: the reflection-based approach replaces the declarative part of MOC: properties, metadata. The runtime parts (QML engine, scripting bridge) still want compiled stubs, which reflection can produce via a codegen step that’s now a library, not an external tool.

Comparison

Qt MOC rqt (reflection)
Spec syntax Q_PROPERTY(int age READ age WRITE setAge NOTIFY ageChanged) [[=rqt::property{}]] int age
Generated file moc_user.cpp (separate file, in build graph) None (lives in templates)
External compiler Yes (moc) No
IDE support Requires MOC integration Standard C++
Debug-step navigates into framework code No (MOC output is opaque) Yes (library templates)
Change notification One NOTIFY signal per property, generated by MOC One changed signal, emitted by the reflection-built setter
Per-class cost at class-define time One MOC pass One template instantiation

The daily ergonomic difference: your headers are regular C++. Your IDE navigates them. Your debugger steps through the binding mechanism. A new hire doesn’t need to learn a Qt-specific parser quirk.

A concrete migration sketch

For an existing Qt codebase:

  1. Parallel run: keep MOC for QML/invokables; add rqt::Object for new property-heavy classes.
  2. Annotation-augment: for Qt classes with heavy Q_PROPERTY lists, add [[=rqt::property{}]] annotations alongside existing Q_PROPERTY macros; dual-run the registries; verify identical behaviour.
  3. Cut MOC for migrated classes: once behaviour matches, drop the Q_OBJECT + Q_PROPERTY declarations.

Net result over time: MOC stays for QML boundary classes; everything else is plain C++ with reflection.

A note on scope

The full MOC replacement would take months of engineering. This post is an architectural sketch showing the primitive (reflection walks properties) rather than a drop-in library. rqt::Object isn’t a library you can use today. It’s the direction you’d take if starting a Qt-adjacent framework in 2026.

The capstone: reflection across seven languages