Replacing moc with reflection: 35 of 48 matrix rows match, and a class needs no macro
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
qmltyperegistrarinput, 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.
lupdatescans 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.