Verspätete Implementierung des Durchlaufens eines untergeordneten Baums der QObject-Klasse

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>(); //  10   for (std::size_t i = 0; i < 10; ++i) { auto obj = new QObject(parent.get()); obj->setObjectName(QStringLiteral("Object %1").arg(i)); } const auto& children = parent->children(); qDebug() << children; // => (QObject(0x1f7ffa0, name = "Object 0"), ...) qDebug() << children.count(); // => 10 

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); }; //  [729,343,125,27,1] std::cout << (v::ints(1, 10) | v::filter(is_odd) | v::transform(pow3) | v::reverse); 

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:


 //  729 343 125 27 1 for (int i = 9; i > 0; --i) { if (i % 2 != 0) { std::cout << std::pow(i, 3) << " "; } } 

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>> { //   friend r::range_access; //   ,       T *obj; //    (  ) Qt::FindChildOptions opts; //  --    cursor begin_cursor() { return cursor(obj, opts); } public: //  }; } // namespace qt::detail 

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 { // ,      std::shared_ptr<ObjectVector> children; //    std::size_t current_index = 0; //       decltype(auto) read() const { return (*children)[current_index]; } //     void next() { ++current_index; } //     auto equal(ranges::default_sentinel_t) const { return current_index == children->size(); } //  }; 

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); }); } } // namespace qt 

Jetzt können Sie diesen Code schreiben:


 for (auto &&c : root | qt::children) { //     () } for (auto &&c : root | qt::find_children(Qt::FindDirectChildrenOnly)) { //     } 

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(); }); } } // namespace qt 

Betrachten Sie als Verwendungsbeispiel den folgenden Code:


 for (auto &&c : root | qt::children | qt::with_type<Foo*>) { //       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); } } } // namespace qt::detail 

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:


 //   children_view //   Data{obj, takeChildRecursively(obj, opts)} struct Data { T *obj; cppcoro::recursive_generator<QObject*> gen; }; std::shared_ptr<Data> m_data; // ... cursor begin_cursor() { return cursor(m_data->gen.begin()); } 

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.

Source: https://habr.com/ru/post/de481402/


All Articles