cpp26-reflection · part 41

Replacing moc with reflection: 35 of 48 matrix rows match, and a class needs no macro

· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

In short

  • The question was how much of moc reflection replaces. I wrote 48 rows, one per construct, each with a verdict and a test.
  • 35 rows are “same”. In 30 of them the meta-object equals a real moc’s, and the behaviour matches. The other 5 have no twin. 7 rows are partial, 4 are unsupported, 1 needs a build step and 1 is not needed.
  • A QObject class, a derived class, a widget class and a gadget need no macro. The base class names the class for you.
  • Every open row has a reason. The largest: private slots and properties, the qmltyperegistrar input, and namespaces and plugin metadata, which work with a stated catch.
  • The container from the spec series is one use case. A class template with signals is a row moc cannot have.

How a row is proved

A row names a moc construct, its spelling in moc and in reflect-moc, a verdict and a test. A script run by ctest fails on a blank cell, an unknown verdict or a missing test file. It also fails on a “same” row without a test.

In 30 of the 35 “same” rows there is a twin. One executable holds the class written for real moc in one header and for reflect-moc in another. The test compares the two meta-objects row for row: names, signatures, parameter names, access, flags, revisions, properties, enums and class info. Then it runs both. It calls methods by name, round-trips properties, connects signals and casts with qobject_cast.

The other 5 are class templates, the class-name override, qobject_cast and two compile-time errors. I broke a twin on purpose once, by adding a property to the moc side only. The comparison failed. Without that check a comparison that always passes would look the same as one that works.

verdict rows meaning
same 35 30 equal real moc’s meta-object and behaviour; 5 are tested without a twin
partial 7 works, with a difference the note names
unsupported 4 does not build, or is ignored with a message
impossible 1 needs a build step that reads moc’s JSON
not needed 1 there is no generated file to include

A class with no macro

This is a test, with its comments cut. It uses no Q_ or RQT_ macro and no moc input.

class Counter : public rqt::ObjectOf<> {
 public:
  using ObjectOf::ObjectOf;

  [[= rqt::property{.write = "setValue", .notify = "valueChanged"}]] int value() const { return value_; }
  [[= rqt::slot]] void setValue(int v) {
    if (v == value_) return;
    value_ = v;
    valueChanged(v);
  }
  [[= rqt::invokable]] int doubled() const { return 2 * value_; }

  rqt::signal<void(int)> valueChanged;
  [[= rqt::signal_function]] void finished() { rqt::activate{this}(); }

 private:
  int value_ = kStart;
};

The base class supplies what Q_OBJECT declares. That is the meta-object function, the cast and call functions, tr and the static meta-object that qobject_cast and pointer-to-member connect read. It also gives the data-member signals their owner. Stock Qt sees an ordinary class.

The base adds no data member. I measured it on aarch64 with GCC 16.2: an empty class is 16 bytes, the size of QObject. With RQT_OBJECT it is 24, because that macro adds a hidden member. With two signals the sizes are 32 and 40 bytes.

A derived class names its base the same way. The form rqt::ObjectOf<Counter> extends Counter. The form rqt::ObjectOf<QWidget> extends a widget. Both are tested, with a class in a namespace, a nested class and a class template.

How the class names itself

The base needs the derived class, because the meta-object, the casts and the owner of a signal belong to it. The usual way is to write the class twice: class Counter : public Base<Counter>. The second Counter is what I wanted to remove.

The trick is a default template argument. The base takes the class as a second parameter, and its default is std::meta::current_class(). A default argument is evaluated where the template is named. In a base clause that is the class being defined.

Four other ways, tried on GCC 16.2 in small files, except the first, which the language rules out.

idea result
deducing this for the overrides cannot work: a virtual function takes no explicit object parameter
current_class() in a default member initializer evaluated where the object is made, not in the class
current_class() in a static member initializer works, but needs a line in every class
the parent of a lambda type in the base clause works, and is longer to write

A class template needed one more step. The default must depend on a template parameter, or GCC evaluates it once, where the template is defined. With that, Box<int> gets its own meta-object, with the class name Box<int>. A plain alias template in front of the base fails for the same reason, as I checked in a small file. The alias has to repeat the default argument.

The closed rows

construct spelling in reflect-moc test
Q_OBJECT class ObjectOf<> parity_macro_free_class
Q_GADGET Gadget<>, Gadget<Point> for a derived one parity_gadget
Q_REVISION, Q_SCRIPTABLE on methods [[=rqt::revision{1, 2}]], [[=rqt::scriptable]] parity_method_flags
property REVISION .revision = {1, 2} parity_revision
QPrivateSignal the tag as the last parameter parity_private_signal
Q_ENUM, Q_FLAG [[=rqt::enum_]], [[=rqt::flag]] parity_enum_from_type
BINDABLE BINDABLE name in the property text, or .bindable in the annotation parity_bindable
Q_INVOKABLE on a constructor [[=rqt::invokable]] on a public constructor, for a class and a gadget parity_invokable_constructor
Q_NAMESPACE, Q_ENUM_NS, Q_FLAG_NS (partial) RQT_NAMESPACE, or three lines parity_namespace_meta
Q_PLUGIN_METADATA (partial) RQT_PLUGIN_METADATA after an #embed block, with the JSON up to 32 KB parity_plugin_loader
overloaded signals, slots, invokables one annotation per function parity_overloads_function_form
several signatures in one signal member rqt::signal<void(int), void(const QString&)> parity_overloads_multi_member
WRITE and RESET with overloads the same rules as moc, by overload resolution parity_overloaded_property

A gadget has no QObject, so Qt hands its address over as a pointer to a QObject. Its property code runs in the static meta-call, as moc’s gadgets do. Reading and writing properties on a gadget, invoking its methods and storing it in a QVariant work with stock Qt.

One more rule came out of this work. What the parser accepts, the table builder must not ignore. A property revision used to be read and dropped without a word. Now an annotation on the wrong kind of member stops the build, with a test for each case.

The container as one use case

The container that started this series needs a class template with typed signals. moc cannot read a class template. So that row has no twin. It is one of the 5 “same” rows tested without one, and it goes beyond moc.

The regenerated spec runs on it. Its author’s two corrections are fixed in both bindings. The last post has the numbers.

What is still open, and why

rows construct reason
namespaces Q_NAMESPACE, Q_ENUM_NS partial. The meta-object equals moc’s, but a namespace is never closed: the lines must come after the enums, an enum declared later is a build error, and an inline namespace is one too
keywords with a function DESIGNABLE isDesignable and its three siblings the build stops with a message. moc evaluates the function at run time, and a constant table cannot
virtual base class D : virtual public QObject moc rejects it too, so it is not a parity gap
private Q_PRIVATE_SLOT, Q_PRIVATE_PROPERTY the d-pointer class is usually incomplete in the header
plugin Q_PLUGIN_METADATA partial. The CBOR equals moc’s byte for byte, but the file is embedded in a block next to the class, because the compiler fails on an embed inside macro arguments, and the JSON is capped at 32 KB
QML QML_ELEMENT and its family registered by a call, not by the class: partial
JSON the input of qmltyperegistrar a build step. A program built from the same classes could print it
partial default-argument clones of a non-public function; a signal or slot outside its section the clones exist for public functions only; the compat header does not map the keywords
enums QMetaEnum::fromType in a macro-free class needs two friend functions that a base class cannot declare for a derived class

Two tests pin the last row. A compile-fail test expects Qt’s static assertion, and a runtime check shows that the meta-object still lists the enum.

What this does not show

  • Only GCC 16.2 builds this, because only it has reflection here. Binding A of the spec also builds with MSVC and Clang, but that code uses moc.
  • Qt 6.10.3 only. lupdate scans source text and was not tried against these classes.
  • AI agents wrote most of the tests and the twins, under my direction. I reviewed the matrix and every verdict, not every line.
  • 35 of 48 counts rows, and the rows are not equal in weight. A property row weighs more than a class-info row.

Where to read it

The matrix is docs/moc-parity.md, and the tests are in tests/qt/parity beside it. The macro audit, a table of each macro and its replacement, is at the end of the matrix.