One Qt class template can declare typed signals without moc
In short
- A reader read the README of reflect-moc and named its remaining limit, a class template with signals, as exactly his case.
- The cause was one line: a template specialization has no identifier, and the code that names a class asked for one.
- One container template now declares typed signals, and one generic model serves seven Qt containers, from
QListtoQSet. - 224,000 random steps are checked against a plain reference, and six deliberately wrong adapters are all caught.
- Against a rebuilt
QVariantversion with the same row index, the two designs are within about 10 percent end to end. The typed one is faster on x86_64 and for most changes on a Mac, and slower on a Mac forreplace. A bare emit with a receiver is 25 to 35 percent faster, and a container is 112 bytes against 24. - A row index, added after the first measurements, makes a change 42 to 125 times cheaper in a large hash or map model.
- I have not seen his code. The tests come from the Qt model contract.
What he asked for
Leonardo Rossi described the problem in a LinkedIn post after the post on four Qt examples. His team needed observable containers (lists, maps, tables) for many data types, so they had to be templates. moc rejects Q_OBJECT in a class template. So they split each one:
- a small non-template
QObjectdeclares the signals, with oneQVariantkey type for every container - a typed template inherits it and does the work, without
Q_OBJECT - a plain class built on the template adds
Q_OBJECTagain and signals of its own
He listed what that cost. The key travels as a QVariant. The wiring between data and views had been copied by hand into 19 files, with ghost rows and ghost selections. He also wrote that a class template with signals is still on reflect-moc’s list of limits, which is his case.
Why the template failed
On the commit before the fix, a template with RQT_OBJECT and signals did not build:
uncaught exception of type 'std::meta::exception';
'what()': 'reflection with has_identifier false'
The function that builds the class name called identifier_of on the class. A specialization such as ObservableContainer<QHash<unsigned, Sample>> has no identifier, so reflection threw. Nothing else needed a change: the anchor member, the signal offsets and the meta-object all work on a specialization. The fix names a type without an identifier by the compiler’s own spelling (tables.hpp).
Typed signals in one class
The key type comes from the container: the hash key for a QHash, the index for a QList. The signals carry it as it is:
template <class C>
class ObservableContainer : public QObject {
RQT_OBJECT
public:
using Key = typename ContainerTraits<C>::Key;
using Value = typename ContainerTraits<C>::Value;
bool insert(const Key& key, Value value = Value{});
bool remove(const Key& key);
bool replace(const Key& key, Value value);
bool move(const Key& from, const Key& to); // a list only
bool clear();
signals:
rqt::signal<void(const Key&)> entryAboutToBeAdded;
rqt::signal<void(const Key&)> entryAdded;
// the same pair for removed and changed; a pair for moved and for cleared
};
A mutator that is asked for something invalid returns false, changes nothing and emits nothing. That covers an index out of range, a key already present and a replace of a missing key. So a bad call cannot leave a view out of step with the container. The QVariant is gone from the signals, and so is the signals-only base class.
One model for every container
A model has to pair every begin call with its end call and find the right row. ContainerModel<C> does that once, from the container’s signals. A new data type needs a formatter and nothing else. The row comes from a different place for each kind of container:
| container | row order | where the row comes from |
|---|---|---|
QList, QQueue, QStack, QStringList |
the index | the key is the row |
QMap |
the key order | counted from the key at announcement time |
QHash, QSet |
the order of insertion | a key list the model keeps |
The map row is counted at announcement time because a removed key is already gone when the removal is confirmed. The code is in examples/qt/observable_models, with a smaller observable_container that keeps his split next to the one-class version. Both builds print the same 9 lines under QAbstractItemModelTester, and the one-class build has no warnings and runs no moc.
How ghost rows are caught
A ghost row is a row that a view shows and the model no longer has. A view learns about changes only from signals. So the test keeps a mirror of what a view would believe, built from signals alone. After every step it compares the mirror with the live model.
Each step applies one random change to both the container and a plain reference. It picks from insert, remove, replace, move, clear and select. Afterwards the container, model, mirror, selection and a persistent index must all agree with the reference. QAbstractItemModelTester runs in fatal mode. There are 8 seeds of 4,000 steps for each of 7 containers. That is 224,000 steps, about 192,000 of them change requests and the rest row selections. The run takes 1.5 seconds.
To check that the test can fail, I wrote six adapters with one mistake each. All six are reported, and the right adapter is not:
| mistake | what reports it |
|---|---|
| no end call after an insert, a move or a reset | the change was announced and never confirmed |
| the insert announced after the container changed | the row count is not what the change implies |
| the removal announced for the wrong row | the mirror differs from the model |
a value replaced with no dataChanged |
the mirror differs from the model |
The last two are the instructive ones. Only the mirror caught them. The model reads the container live, so a check built from the model’s own data agrees with it and sees nothing wrong.
I also ran 31 hand-listed mutants on the headers, because Mull needs clang and the image has only GCC. The first run killed 24 and exposed two gaps. Nothing tested the container’s own announcements, and nothing checked that a list model has no rows under a row. Both are closed. The final count is 30 killed and 1 survivor, which is equivalent: upperBound for lowerBound on a key that is not in the map yet.
Naming a specialization
Qt reports a class name for every meta-object. For a specialization, reflect-moc uses the compiler’s spelling. On GCC 16.2 that is ObservableContainer<QHash<unsigned int, Sample> >, with the space before the last bracket. The text is implementation-defined, so a test that pins it is pinned to one compiler.
The demo below has no Qt. It prints has_identifier for two specializations of a template and lists the typed signal members of each.
For a stable name, set one with RQT_CLASS_NAME(NameOf<T>::value) in the class, or [[=rqt::class_name{"SampleContainer"}]] on a plain class. One difference from moc shows in the signatures: a qsizetype argument is registered as qlonglong, in a template and in a plain class.
What it costs against the QVariant version
His post says the QVariant design is not the fastest, so I measured it. The twin is a reconstruction from his description, and it rebuilds only the per-entry part of his design. It has no batch events, no atomic resetWith, no roles in the data-changed pair, no 2D matrix and no selection. He listed those in his reply.
The per-entry part has a non-template base with Q_OBJECT and ten signals keyed by QVariant. A template container boxes the key and a model unboxes it. Its row logic is the same code as the typed model’s, so only the way the key travels differs. It passes the same random test as the typed version before anything is timed, and a copy with one wrong row fails that test.
| ns per change, 1,000 entries | Mac QVariant | Mac typed | x86_64 QVariant | x86_64 typed |
|---|---|---|---|---|
QList fill |
120.3 | 110.3 | 142.6 | 116.3 |
QList replace |
70.1 | 80.9 | 80.9 | 75.2 |
QHash drain |
181.4 | 176.4 | 246.6 | 227.6 |
QHash replace |
88.3 | 91.7 | 119.4 | 116.1 |
QMap fill |
249.2 | 242.3 | 347.5 | 319.4 |
one emit with a receiver, quint32 key |
27.5 | 20.4 | 39.8 | 25.6 |
| one emit with a receiver, 32-byte key | 35.3 | 20.6 | 51.2 | 25.9 |
bytes per container (ten signals, a QHash) |
24 | 112 | 24 | 112 |
End to end the two designs are within about 10 percent of each other. On x86_64 the typed one is faster in every row of the full table, by 3 to 18 percent. On the Mac, which was quiet for this run, it is faster for fill and drain and equal for a map. It is slower for replace, by 4 to 15 percent. A replace emits one signal that no model listens to. An emit with no receiver costs the typed signal about 10 ns, and that fits the gap, but I did not isolate it.
A change costs 70 to 320 ns in either design, so the speed is not the reason to choose. A bare emit with a receiver is where typed signals win clearly. Boxing a key in a QVariant is work that the typed signal never does. The reason to choose is the typed key.
A hash slowdown that was not one
The first x86_64 measurement showed removal from a hash model 1.7 times slower with typed signals. The scan that finds the row is the same four instructions in both builds. In the typed build the compiler put its compare and jump across a 32-byte boundary. Building both with that boundary respected made them equal, and a std::vector key list removed the gap as well, so the model uses one. The table above is after that change.
What it costs in memory and compile time
Each data-member signal takes 8 bytes in every instance, so a container with ten signals is 112 bytes against 24. RQT_OBJECT_STATIC with rqt::static_signal brings it back to 24 bytes. The binary is 186,568 bytes against 171,856 on the Mac, and 189,760 against 149,088 on x86_64.
One translation unit compiles in 4.5 seconds with reflection against 3.1 plus 1.3 for moc and its generated file on the Mac. On x86_64 it is 9.8 seconds against 6.6 plus 3.0. The totals are within 4 percent either way. The reflection time falls on every file that includes the header, and the moc time on one.
A row index
The first version of the model found a row by walking the container. A map counted from the start of its tree, and a hash model scanned its key list. Both are linear in the size, and at 100,000 entries a change in a hash model took 17 microseconds.
The model now owns a row index. A map keeps a sorted copy of its keys and searches it. A hash or set keeps a Fenwick tree over insertion slots. It counts the live slots before a key and finds the k-th live slot in logarithmic time. The QVariant twin uses the same index, so the comparison above is not affected.
| typed, x86_64, ns per change | linear scan | row index |
|---|---|---|
QHash, 100,000 entries, replace |
17,083 | 167 |
QHash, 100,000 entries, remove |
16,112 | 294 |
QMap, 10,000 entries, replace |
39,488 | 315 |
QMap, 10,000 entries, remove |
19,673 | 473 |
That is 42 to 125 times cheaper. A list needed no index, because its key is its row.
How far to trust it
These numbers come from one script (run.sh), run seven times with the two designs alternating. The x86_64 run is the manual bench workflow on a GitHub runner, with a spread under 2 percent. The Mac spread is larger, so treat differences under about 10 percent there as noise. The full tables, how to repeat them and the profile of the hash slowdown are in docs/measurements.md.
What this does not show
- The library is GCC only, tested on 16.2 and Qt 6.10 (Docker on aarch64, and CI on x86_64). Nothing here ran on clang or MSVC. A reading of the sources lists what blocks clang, and it is not a build.
- I have only the description in his post. The tests come from the
QAbstractItemModelcontract, so they show that these classes keep it. They do not show that his application has no ghost rows. - The speed numbers come from GCC 16.2 on two machines with no view attached. They cover only the per-entry operations of my reconstruction, and say nothing about batches, reset or the matrix. A real application with a view would see smaller differences, and his code could differ from my twin.
- A container in a worker thread with a model in the GUI thread is not supported. Both must live in one thread, which a debug build asserts.
- A throw from the allocator between the “about” and the “done” signal is not handled, as in Qt itself. A value type whose move can throw is refused at compile time.
Closed after this post was written
The first version of this post listed more limits. Since then the same repository has closed them, and each has a test:
QMultiMapandQMultiHash, with an entry named by its key and its occurrence.- Proxy models, QML registration of a specialization, and the same specialization in two translation units.
- Range inserts and removes, editing through
setData, and a table model. - The linear row lookup, with the row index above.
rqt-migratefolding a signals-onlyQVariantbase into the template.
Tests for these found three defects, and the next post in this series describes them.