flagship

Replaying Qt Bridges' meta-object calls in C++ and diffing them against moc

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

These two lines come from a diff of one class’s meta-object. The first line is what moc generates. The second is what the builder calls of the Qt Bridges Rust layer produce:

moc:     property 3 total type=int flags=R--DST--------
builder: property 3 total type=int flags=RW--S---------

In short

  • Qt Bridges’ Rust layer builds each class’s meta-object at run time with Qt’s private QMetaObjectBuilder. I replayed its builder calls on one class in C++ and diffed the result against moc. The versions were Qt 6.10.3, 6.11.3 and 6.12.0.
  • The registration API has no way to say that a property is read-only, to name a signal’s parameters or to add an enum.
  • The visible effects in QML: a write to a getter-only property is accepted, and an implicit signal handler fails with a ReferenceError.
  • In the C++ analogue, emitting a signal by name costs about 22 to 46 ns more than emitting it by index.
  • No Rust was built or run, so every behaviour is the replay’s. The dumps are identical across the three Qt versions.

The difference in one property

The property total is declared with a getter and nothing else. The tool moc marks it read-only (R, no W). The builder path marks it writable. A QML assignment to it is accepted without an error. The moc class throws TypeError: Cannot assign to read-only property "total" instead. This post lists where the two meta-objects differ for one small class. It also lists the builder calls and what each difference does or does not change.

Limits of the comparison

Two limits first. Qt Bridges targets languages other than C++. Its Rust and C# layers are labelled beta on the Qt Bridges page, which I read again on 6 October 2026. The library reflect-moc, from the previous post, is a C++ analogue that generates the same tables moc does. This is a comparison of two ways to produce a QMetaObject, run on one class, and not a comparison of the products. And I did not build or run any Rust. The Rust layer’s C++ half is replayed in C++, so every behaviour below is the replay’s.

The snapshot

The sources are on code.qt.io. I recorded the head of the dev branch of each repository on 5 October and read them again on 6 October. All three heads were unchanged, so no builder call cited here has moved since the replay was written.

repository commit
qtbridges (the umbrella) 40bdaee
qtbridge-rust f7fe523 (12 July)
qtbridge-csharp 575651a

The projects move, so treat these as a dated snapshot. Every source link below is pinned to one of these commits.

What the Rust layer asks Qt to build

The builder is the file dynamicmetaobjectbuilder.cpp. It includes <private/qmetaobjectbuilder_p.h> (line 11) and owns a QMetaObjectBuilder. The calls it makes are these:

Qt call line
setSuperClass, setClassName, addClassInfo 68, 69, 77
addProperty(name, typeName), setReadable(true) 102, 103
setWritable(writable) with writable = !isConstant 99, 104
setConstant, setNotifySignal 105, 108
addSignal(signature) 123
addSlot(signature), setReturnType 139, 142
toMetaObject() 151

The proc-macro fixes the order: signals, slots, properties, class infos (generate_meta.rs, lines 35 to 38). The registration API that the macros call has three entry points (dynamicmetaobjectbuilder.rs, lines 43 to 49):

entry point signature
property registerProperty(name, prop_id, meta_type, is_constant, notify_signal)
signal registerSignal(name, arg_meta_types)
slot registerSlot(name, slot_id, arg_meta_types, return_meta_type, mutability)

Nothing in them carries a parameter name, a revision, a final flag or an enum.

Calls that appear nowhere in the repository

I grepped the whole qtbridge-rust tree at f7fe523 for the names in the table. The search covered every C++ source and header file, and every Rust file. The table also shows a grep of the whole qtbridge-csharp tree for six of the names.

name qtbridge-rust qtbridge-csharp
setFinal no match no match
setRequired no match no match
setBindable no match no match
setRevision no match no match
addEnumerator no match not searched
setEnumOrFlag no match not searched
setStdCppSet no match no match
setParameterNames no match no match
setResettable no match not searched
setDesignable no match not searched
setStored no match not searched
addMethod no match not searched
setStaticMetacallFunction no match not searched

Three Qt builder classes appear in only two files, dynamicmetaobjectbuilder.h and .cpp. The classes are:

  • QMetaObjectBuilder
  • QMetaPropertyBuilder
  • QMetaMethodBuilder

Dispatch goes through QDynamicMetaObjectData instead of a static metacall. The file qbaseproxy.h installs it at line 69 with QObjectPrivate::get(proxy)->metaObject = ....

One of those names needs a qualifier. QMetaPropertyBuilder::setRequired is not in the Qt 6.10.3 private header at all. It appears in the 6.11.3 header, which I diffed against 6.10.3. So it is a call the Rust layer does not make on versions that offer it.

The C# layer decides some of this differently. Its file metadata_loader.cpp calls setWritable(false) from a flag in its generated metadata (line 204). The flag is named hasSet. The function loadEnum calls addEnumerator("Values") (line 245). The grep of the whole qtbridge-csharp tree in the table returns no match for the six names it covers.

What the Rust macro knows about read-only

The property macro does distinguish the case. In the file qproperty_info.rs, the function get_write_code returns nothing for a property with neither a setter nor a member. It annotates that case // A read-only property (line 302). What reaches the registration call is is_const() (line 230). So a property that is read-only but not constant is registered with setWritable(true). That is what the replay does for total. What the generated Rust function writeProperty does when QML writes to such a property I did not read or run.

The replay and the diff

The replay is 197 lines of C++ (builder.hpp). It makes only the calls in the table above, in that order, with the same signature strings. It dispatches reads, writes and slot calls to a small Counter class. The oracle that dumps and compares meta-objects is the one in reflect-moc (differential_dump.hpp) with one more namespace prefix stripped. Neither the replay nor the harness around it is published, so you cannot rerun my numbers from a link. What a reader can check is the builder calls, which are the table above. This is the registration for the test class:

Builder b(&QObject::staticMetaObject, "viabuilder::Counter");
const auto I = QMetaType::fromType<int>();
b.registerSignal("valueChanged", {I});
b.registerSignal("moved", {I, I});
b.registerSlot("setValue", 0, {I}, QMetaType{});
b.registerSlot("setPinned", 1, {I}, QMetaType{});
b.registerSlot("add", 2, {I}, I);
b.registerProperty("value", 0, I, false, "valueChanged");
b.registerProperty("pinned", 1, I, false, "");
b.registerProperty("limit", 2, I, true, "");
b.registerProperty("total", 3, I, false, "");  // getter only in the source
b.addClassInfo("author", "me");

The twin written for moc declares these members:

member declaration in the moc twin
class info Q_CLASSINFO("author", "me")
value READ, WRITE and NOTIFY
pinned FINAL
limit CONSTANT
total READ only
enum enum class Mode under Q_ENUM
signals valueChanged(int value) and moved(int from, int to)
slots setValue(int v) and setPinned(int v)
invokable method Q_INVOKABLE int add(int n)

The reflect-moc version of the class and the moc version produce identical dumps. The differential test in reflect-moc checks that, and it passed in the CI run 37466622213 on main, in the step “Core and Qt tests”. The builder replay differs from moc as follows, for Qt 6.10.3. The dumps on 6.11.3 and 6.12.0 are byte-identical to these:

what moc’s dump has the builder replay which call is missing
enum 0 name=Mode ... keys=Linear=0,Log=1 no enum addEnumerator
total flags R--DST RW--S, isWritable() is true none; setWritable gets !isConstant
pinned flag F (final) off setFinal
designable D and stored T on every property both off setDesignable, setStored (the builder’s defaults are false)
names=from|to, names=v, names=value, names=n empty setParameterNames
add(int) has method type 0 method type 2 (slot) addMethod (not called; every callable is a slot)

Equal in both:

  • the class and base class names, and the class info
  • every signature, access, return type and parameter type
  • the NOTIFY index of value (0)
  • the index of every method

The row for total carries the visible consequence, and the next section shows it.

What differs when the object is used

These checks pass on the replay on all three Qt versions:

  • Connecting two objects by SIGNAL/SLOT strings.
  • The same connection with an un-normalised valueChanged( int ).
  • Property reads and writes through QMetaProperty, with the NOTIFY signal firing.
  • A constant property reporting as not writable.
  • A call to invokeMethod with a return value.
  • A queued call to invokeMethod delivering the call.

The same QML against both classes

A run of the same QML against both classes, using a QQmlEngine with no Qt Quick items, gave the same output for these cases:

  • a property read
  • a binding
  • a write to value
  • a call to add
  • a write to the constant limit, which threw a TypeError on both
  • a function onValueChanged(v) handler

Two lines differ:

moc class:      res.totalWrite = threw TypeError: Cannot assign to read-only property "total"
builder class:  res.totalWrite = assigned, total now 2
moc class:      implicit from=1 to=2
builder class:  ReferenceError: from is not defined

The first pair is the total row above. The second is an implicit handler, onMoved: log(from, to): it needs the parameter names, which the builder was never given. Qt prints a deprecation warning for that form on the moc class, and the explicit function onMoved(a, b) works on both. In the replay, the dispatch for total has nowhere to store a written value. So “assigned, total now 2” says the write was accepted and then dropped.

The remaining rows (final, designable, stored, the slot type, the missing enum) show up in the dump. I did not observe a behavioural difference from them in these checks. One caveat: no QML code in them used the missing Mode enum. A QML expression such as Counter.Mode.Log would need it.

Emitting by name and by index

The Rust path emits a signal from a name. The file dynamicmetaobjectdata.cpp does this in three steps:

  • It builds a QByteArray from the name (line 26).
  • It searches the signal list linearly for it (lines 36 to 45).
  • It calls QMetaObject::activate with the index it found.

I replayed those steps in C++ and compared them with calling activate with a known index. This is an analogue of the C++ half. The Rust-side cost of getting the name into C++ is not in it.

Benchmark setup and results

The setup was:

  • GCC 16.2.0 with -O2, and Qt 6.10.3
  • arm64 Linux in Docker on a Mac
  • one class with 16 signals
  • 10,000,000 emits per run
  • 3 runs in each of two sessions (5 and 6 October)

The machine was busy both times. On 6 October the host’s load average was about 30, and the container sees 12 CPUs. So a difference under about 10 ns is noise. The numbers are nanoseconds per emit with no connection on the signal.

signal by index by name
first of 16 6.2 to 7.3 (5 October), 4.3 (6 October) 33.8 to 51.9 (5 October), 28.9 to 30.5 (6 October)
last of 16 4.7 to 5.3 (5 October), 4.3 (6 October) 43.9 to 48.9 (5 October), 46.6 to 47.3 (6 October)

Take the first signal with one connection to a trivial slot. On 5 October it took 41 ns by index and 71 to 124 ns by name. On 6 October it took 40 ns by index and 73 to 77 ns by name. With no connection, by name costs about 22 to 46 ns more per emit than by index in this analogue, over both sessions. I did not separate the allocation of the QByteArray from the search, so I do not say which part is the cost.

Both calls follow from how the Rust macro lays things out. Signals are registered first and in order. So a signal’s position in m_signals is the index that activate wants. The file dynamicmetaobjectdata.cpp already relies on that for notify signals. Whether any Rust program emits often enough for 22 to 46 ns to show, I did not measure.

Qt 6.10.3, 6.11.3 and 6.12.0

I installed 6.11.3 and 6.12.0 with aqtinstall next to the 6.10.3 image. I ran the same dump, behaviour and QML checks on each. The builder, moc and reflect-moc dumps are byte-identical across the three versions (I compared the files), and the behaviour output is the same.

The private header changed in between:

step change
6.10.3 to 6.11.3 QMetaMethodBuilder::parameterTypes() returns QList<QByteArrayView> instead of QList<QByteArray>
6.10.3 to 6.11.3 the method builder stores its method type
6.10.3 to 6.11.3 the property builder gains setRequired (and setVirtual and setOverride)
6.11.3 to 6.12.0 QMetaObjectBuilder::signal(int) and removeSignal(int) appear

The Rust builder has a branch on whether signal() exists. A comment on line 33 says: “There are behavior changes in QMetaObjectBuilder between Qt versions 6.11 and 6.12.” On 6.12.0 I ran the replay twice, once with the signal(i) branch and once with method(i) forced. The dump was identical, with the notify signal at index 0 in both. For a class whose signals come first, which is the Rust layout, I could not make the change visible. Which class shape does show it, I did not find.

What this has not been tested on

  • No Rust was built or run. The checks run a C++ replay of the C++ half of the Rust layer. The Rust-generated dispatch, including the write path for a read-only property, was not exercised.
  • Only int members. Strings, lists, custom registered types and QObject* properties were not tried. Bases beyond QObject, including QAbstractItemModel, were not tried. The type QQmlListProperty was not tried either.
  • The C# layer was read for its C++ builder calls only. Which flags its managed side sets for user-defined members was not read.
  • Qt 6.10.3, 6.11.3 and 6.12.0, not 6.12.1. GCC only. QML was run with QtQml and no Qt Quick items or QML type registration.
  • Linux on aarch64 in Docker. The reflect-moc side of the comparison also ran on the project’s x86_64 CI runner. The run 37466622213 on main passed its core and Qt tests, which include the differential test against moc. I read the step results from that run. The replay itself was not run on x86_64.
  • The timings are from a loaded virtual machine, the analogue is C++ and not Rust, and no run was pinned to a core.
  • I re-ran the Qt 6.10.3 dump, behaviour and QML checks and the benchmark on 6 October against reflect-moc at 6145d94. The 6.11.3 and 6.12.0 results are from the run of 5 October.

Sources (read 6 October 2026):

  • qtbridges 40bdaee on code.qt.io, with the files linked above.
  • qtbridge-rust f7fe523 on code.qt.io.
  • qtbridge-csharp 575651a on code.qt.io.
  • The Qt Bridges page (Rust and C# marked beta).
  • reflect-moc at commit 6145d94.
  • The reflect-moc file tests/qt/differential_dump.hpp.
  • The reflect-moc file tests/qt/capability_differential_against_moc.cpp.
  • The reflect-moc file .github/workflows/ci.yml, and CI run 37466622213.
  • Qt 6.10.3, 6.11.3 and 6.12.0 private header qmetaobjectbuilder_p.h.