Einleitung
Der Artikel beschreibt die verzögerte Implementierung von Tree Traversal in C ++ mithilfe von Coroutinen und Bereichen am Beispiel der Verbesserung der Schnittstelle für die Arbeit mit QObject
der QObject
Klasse aus dem Qt-Framework. Die Erstellung einer benutzerdefinierten Ansicht für die Arbeit mit untergeordneten Elementen wird detailliert betrachtet, und es werden verzögerte und klassische Implementierungen angegeben. Am Ende des Artikels befindet sich ein Link zum Repository mit dem vollständigen Quellcode.
Über den Autor
Ich arbeite als Senior Developer im norwegischen Büro von The Qt Company. Ich habe Widgets und QtQuick-Elemente entwickelt, vor kurzem Qt Core. Ich benutze C ++ und bin ein bisschen an funktionaler Programmierung interessiert. Manchmal erstelle ich Berichte und schreibe Artikel.
Was ist Qt
Qt ist ein plattformübergreifendes Framework zum Erstellen grafischer Benutzeroberflächen (GUIs). Neben Modulen zum Erstellen einer GUI enthält Qt viele Module zum Entwickeln von Anwendungssoftware. Das Framework wurde hauptsächlich in der Programmiersprache C ++ entwickelt. Einige Komponenten verwenden QML und JavaScript .
QObject-Klasse
QObject ist die Klasse, um die das Qt-Objektmodell aufgebaut ist. Von QObject
geerbte QObject
können im Slot-Signalmodell und in der Ereignisschleife verwendet werden. Darüber hinaus können Sie mit QObject
auf Informationen zu QObject
zugreifen und Objekte in Baumstrukturen organisieren.
QObject Baumstruktur
Die Verwendung einer Baumstruktur bedeutet, dass jedes QObject
Objekt ein übergeordnetes Objekt und null oder mehr QObject
Objekte haben kann. Das übergeordnete Objekt steuert die Lebensdauer der untergeordneten Objekte. Im folgenden Beispiel werden zwei untergeordnete Elemente automatisch gelöscht:
auto parent = std::make_unique<QObject>(); auto onDestroyed = [](auto obj){ qDebug("Object %p destroyed.", obj); }; QObject::connect(new QObject(parent.get()), &QObject::destroyed, onDestroyed); QObject::connect(new QObject(parent.get()), &QObject::destroyed, onDestroyed);
Leider funktioniert der Großteil der Qt-API bisher nur mit rohen Zeigern. Wir arbeiten daran, und vielleicht wird sich die Situation bald zumindest teilweise zum Besseren wenden.
Über die QObject
Klassenschnittstelle können Sie eine Liste aller QObject
Objekte QObject
und nach bestimmten Kriterien suchen. Betrachten Sie das Beispiel zum Abrufen einer Liste aller untergeordneten Objekte:
auto parent = std::make_unique<QObject>();
Die QObject::children
Methode gibt eine Liste aller QObject::children
des angegebenen Objekts zurück. Oft ist jedoch eine Suche innerhalb des gesamten Teilbaums von Objekten nach einem bestimmten Kriterium erforderlich:
auto children = parent->findChildren<QObject>(QRegularExpression("0$")); qDebug() << children.count();
Das obige Beispiel zeigt, wie Sie eine Liste aller QObject
Typs QObject
deren Name auf 0 endet. Im Gegensatz zur Methode findChildren
durchläuft die Methode findChildren
den Baum rekursiv, findChildren
wird die gesamte Hierarchie der Objekte durchsucht. Dieses Verhalten kann durch Übergeben des Qt::FindDirectChildrenOnly
geändert werden.
Nachteile der Schnittstelle für die Arbeit mit untergeordneten Elementen
Auf den ersten Blick scheint die Oberfläche für die Arbeit mit Kindern gut durchdacht und flexibel zu sein. Er ist jedoch nicht ohne Mängel. Betrachten wir einige davon:
- Redundante Schnittstelle
Es gibt zwei verschiedene findChildren
Methoden (es gab drei vor nicht allzu langer Zeit): die findChild
Methode zum Suchen eines Elements und die findChild
Methode. Sie alle überlappen sich teilweise. - Die Schnittstelle ist schwer zu ändern
Qt garantiert Binärkompatibilität und Kompatibilität auf Quellcodeebene in einer einzigen Hauptversion. Daher können Sie nicht einfach die Signatur einer Methode ändern oder neue Methoden hinzufügen. - Die Schnittstelle ist schwer zu erweitern
Neben der Verletzung der Kompatibilität ist es beispielsweise unmöglich, eine Liste von untergeordneten Elementen nach einem bestimmten Kriterium zu erhalten. Um diese Funktionalität hinzuzufügen, müssen Sie auf die nächste Version warten oder eine andere Methode erstellen. - Kopieren Sie alle Elemente
Oft müssen Sie nur die Liste aller untergeordneten Elemente durchgehen, die nach einem bestimmten Kriterium gefiltert wurden. Zu diesem Zweck ist es nicht erforderlich, einen Container mit Zeigern auf alle diese Elemente zurückzugeben. - Mögliche SRP- Verletzung
Dies ist ein ziemlich kontroverses Thema, aber die Notwendigkeit, die Klassenschnittstelle zu ändern, um beispielsweise eine Methode zum Überqueren von Kindern zu ändern, sieht seltsam aus.
Verwenden Sie range-v3, um einige Fehler zu beheben
range-v3 ist eine Bibliothek, die Komponenten zum Arbeiten mit Elementbereichen bereitstellt. Tatsächlich ist dies eine zusätzliche Abstraktionsebene gegenüber klassischen Iteratoren, die es Ihnen ermöglicht, Operationen zu komponieren und von verzögerten Berechnungen zu profitieren.
Es wird eine Bibliothek eines Drittanbieters verwendet, da dem Autor zum Zeitpunkt des Schreibens keine Compiler bekannt sind, die diese Funktionalität unterstützen. Vielleicht ändert sich die Situation bald.
Bei QObject
mithilfe dieses Ansatzes die Operationen zum Durchlaufen des Baums der QObject
Elemente von der Klasse trennen und eine flexible Schnittstelle zum Suchen von Objekten nach einem bestimmten Kriterium erstellen, die leicht geändert werden kann.
Beispiel für Ranges-v3
Betrachten Sie zunächst ein einfaches Beispiel für die Verwendung der Bibliothek. Bevor wir mit dem Beispiel fortfahren, führen wir die Kurzschreibweise für Namespaces ein:
namespace r = ranges; namespace v = r::views; namespace a = r::actions;
Betrachten Sie nun ein Beispiel für ein Programm, das Würfel aller ungeraden Zahlen im Intervall [1, 10) in umgekehrter Reihenfolge ausgibt:
auto is_odd = [](int n) { return n % 2 != 0; }; auto pow3 = [](int n) { return std::pow(n, 3); };
Es ist zu beachten, dass alle Berechnungen träge ablaufen, d.h. Temporäre Datensätze werden nicht erstellt oder kopiert. Das obige Programm entspricht dem, mit der Ausnahme, dass die Ausgabe formatiert wird:
Wie Sie dem obigen Beispiel entnehmen können, können Sie mit der Bibliothek verschiedene Operationen elegant zusammenstellen. Weitere Verwendungsbeispiele finden Sie in den Verzeichnissen tests
und examples
des range-v3- Repository.
Klasse zur Darstellung einer Folge von Kindern
Die range-v3
Bibliothek bietet Hilfsklassen zum Erstellen verschiedener benutzerdefinierter Wrapperklassen. Darunter sind Klassen aus der Kategorie view
. Diese Klassen sollen eine Folge von Elementen auf eine bestimmte Weise darstellen, ohne die Folge selbst zu transformieren und zu kopieren. Im vorherigen Beispiel wurde die filter
verwendet, um nur die Elemente der Sequenz zu berücksichtigen, die den angegebenen Kriterien entsprechen.
Um eine solche Klasse für die Arbeit mit untergeordneten QObject-Elementen zu erstellen, muss sie von den Hilfsklassenbereichen ranges::view_facade
:
namespace qt::detail { template <class T = QObject> class children_view : public r::view_facade<children_view<T>> {
Es ist zu beachten, dass die Klasse automatisch die Methode end_cursor
definiert, die das Vorzeichen des Endes der Sequenz zurückgibt. Bei Bedarf kann diese Methode überschrieben werden.
Als nächstes definieren wir die Cursorklasse selbst. Dies kann sowohl in der Klasse children_view
als auch darüber hinaus erfolgen:
struct cursor {
Der oben definierte Cursor ist Single-Pass. Dies bedeutet, dass sich die Sequenz nur in eine Richtung und nur einmal bewegen darf. Für diese Implementierung ist dies nicht notwendig, weil Wir speichern eine Sequenz aller untergeordneten Objekte und können sie beliebig oft durchlaufen. Um anzuzeigen, dass Sie eine Sequenz mehrmals durchlaufen können, müssen Sie die folgende Methode in der Cursorklasse implementieren:
auto equal(const cursor &that) const { return current_index == that.current_index; }
Jetzt müssen Sie hinzufügen, um sicherzustellen, dass die erstellte Ansicht in die Komposition aufgenommen werden kann. Verwenden Sie dazu die Hilfsfunktionsbereiche ranges::make_pipeable
:
namespace qt { constexpr auto children = r::make_pipeable([](auto &&o) { return detail::children_view(o); }); constexpr auto find_children(Qt::FindChildOptions opts = Qt::FindChildrenRecursively) { return r::make_pipeable([opts](auto &&o) { return detail::children_view(o, opts); }); } }
Jetzt können Sie diesen Code schreiben:
for (auto &&c : root | qt::children) {
Implementieren der vorhandenen QObject-Klassenfunktionalität
Nach der Implementierung der Präsentationsklasse können Sie problemlos alle Funktionen für die Arbeit mit Kindern implementieren. Dazu müssen Sie drei Funktionen implementieren:
namespace qt { template <class T> const auto with_type = v::filter([](auto &&o) { using ObjType = std::remove_cv_t<std::remove_pointer_t<T>>; return ObjType::staticMetaObject.cast(o); }) | v::transform([](auto &&o){ return static_cast<T>(o); }); auto by_name(const QString &name) { return v::filter([name](auto &&obj) { return obj->objectName() == name; }); } auto by_re(const QRegularExpression &re) { return v::filter([re](auto &&obj) { return re.match(obj->objectName()).hasMatch(); }); } }
Betrachten Sie als Verwendungsbeispiel den folgenden Code:
for (auto &&c : root | qt::children | qt::with_type<Foo*>) {
Zwischenfazit
Wie der Code erkennen lässt, ist es jetzt ganz einfach, die Funktionalität zu erweitern, ohne die Klassenschnittstelle zu ändern. Darüber hinaus werden alle Operationen durch separate Funktionen dargestellt und können in der gewünschten Reihenfolge angeordnet werden. Dies verbessert unter anderem die Lesbarkeit des Codes und vermeidet die Verwendung von Funktionen mit mehreren Parametern in der Klassenschnittstelle. Erwähnenswert ist auch das Entladen der Klassenschnittstelle und die Reduzierung der Gründe für deren Änderung.
Tatsächlich beseitigt diese Implementierung bereits fast alle aufgeführten Nachteile der Schnittstelle, mit der Ausnahme, dass noch alle untergeordneten Elemente in den Container kopiert werden müssen. Eine Möglichkeit, dieses Problem zu lösen, ist die Verwendung von Coroutinen.
Verzögerte Implementierung der Objektbaumüberquerung mit Hilfe von Coroutinen
Mit Coroutinen (Coroutinen) können Sie die Funktion anhalten und später fortsetzen. Sie können diese Technologie als eine Art Zustandsmaschine betrachten.
Zum Zeitpunkt des Schreibens fehlen der Standardbibliothek viele wichtige Elemente, die für die komfortable Verwendung von Coroutinen erforderlich sind. Daher wird vorgeschlagen, eine cppcoro- Bibliothek eines Drittanbieters zu verwenden, die wahrscheinlich in der einen oder anderen Form in den Standard aufgenommen wird.
Zunächst werden wir Funktionen schreiben, die das nächste Kind auf Anfrage zurückgeben:
namespace qt::detail { cppcoro::recursive_generator<QObject*> takeChildRecursivelyImpl( const QObjectList &children, Qt::FindChildOptions opts) { for (QObject *c : children) { if (opts == Qt::FindChildrenRecursively) { co_yield takeChildRecursivelyImpl(c->children(), opts); } co_yield c; } } cppcoro::recursive_generator<QObject*> takeChildRecursively( QObject *root, Qt::FindChildOptions opts = Qt::FindChildrenRecursively) { if (root) { co_yield takeChildRecursivelyImpl(root->children(), opts); } } }
Die Anweisung co_yield
gibt den Wert an den aufrufenden Code zurück und hält die Coroutine an.
Integrieren Sie nun diesen Code in die Klasse children_view
. Der folgende Code zeigt nur die Elemente an, die geändert wurden:
Der Cursor muss auch geändert werden:
template <class T> struct children_view<T>::cursor { cppcoro::recursive_generator<QObject*>::iterator it; decltype(auto) read() const { return *it; } void next() { ++it; } auto equal(ranges::default_sentinel_t) const { return it == cppcoro::recursive_generator<QObject*>::iterator(nullptr); } explicit cursor(cppcoro::recursive_generator<QObject*>::iterator it): it(it) {} cursor() = default; };
Der Cursor fungiert hier einfach als Wrapper um einen regulären Iterator. Der Rest des Codes kann unverändert verwendet werden, ohne dass zusätzliche Änderungen erforderlich sind.
Die Gefahren fauler Baumspaziergänge
Es ist erwähnenswert, dass das träge Durchqueren des Kinderbaums nicht immer sicher ist. Dies bezieht sich hauptsächlich auf die Umgehung komplexer Hierarchien von grafischen Elementen, z. B. Widgets. Tatsache ist, dass beim Durchlaufen die Hierarchie neu aufgebaut werden kann und einige Elemente vollständig entfernt werden. Wenn Sie in diesem Fall eine verzögerte Problemumgehung verwenden, können Sie sehr interessante und unvorhersehbare Ergebnisse des Programms erhalten.
Dies bedeutet, dass es in einigen Fällen sinnvoll ist, alle Elemente in einen Container zu kopieren. Dazu können Sie die folgende Hilfsfunktion verwenden:
auto children = ranges::to<std::vector>(root | qt::children);
Genau genommen müssen in diesem Fall keine Coroutinen verwendet werden, und Sie können die Ansicht ab der ersten Iteration verwenden.
Wird es in Qt sein?
Vielleicht, aber nicht in der nächsten Version. Dafür gibt es mehrere Gründe:
- Die nächste Hauptversion, Qt 6, wird offiziell C ++ 17 erfordern und unterstützen, jedoch nicht höher.
- Ohne Bibliotheken von Drittanbietern ist eine Implementierung nicht möglich.
- Es wird relativ schwierig sein, die vorhandene Codebasis anzupassen.
Höchstwahrscheinlich werden sie im Rahmen der Qt 7-Version zu diesem Problem zurückkehren.
Fazit
Die vorgeschlagene Implementierung des Durchlaufens des Baums untergeordneter Elemente erleichtert das Hinzufügen neuer Funktionen. Durch die Trennung von Operationen wird das Schreiben von saubererem Code und das Entfernen unnötiger Elemente von der Klassenschnittstelle erreicht.
Es ist anzumerken, dass beide verwendeten Bibliotheken (range-v3 und cpp-coro) als Header-Dateien geliefert werden, was den Erstellungsprozess vereinfacht. Auf Fremdbibliotheken kann künftig überhaupt noch verzichtet werden.
Der beschriebene Ansatz weist jedoch einige Nachteile auf. Darunter ist die für viele Entwickler ungewöhnliche Syntax, die relative Komplexität der Implementierung und die Faulheit zu bemerken, die in einigen Fällen gefährlich sein können.
Optional
Quellcode
Besonderer Dank geht an Misha Svetkin ( Trilla ) für seinen Beitrag zur Umsetzung und Diskussion des Projekts.