Als ich kürzlich an einer neuen Version von SObjectizer arbeitete , stand ich vor der Aufgabe, die Aktionen des Entwicklers während der Kompilierungszeit zu steuern. Unter dem Strich konnte ein Programmierer zuvor das Formular aufrufen:
receive(from(ch).empty_timeout(150ms), ...); receive(from(ch).handle_n(2).no_wait_on_empty(), ...); receive(from(ch).empty_timeout(2s).extract_n(20).stop_on(...), ...); receive(from(ch).no_wait_on_empty().stop_on(...), ...);
Die Operation receive () erforderte eine Reihe von Parametern, für die eine Reihe von Methoden verwendet wurde, wie die oben gezeigten from(ch).empty_timeout(150ms)
oder from(ch).handle_n(2).no_wait_on_empty()
. Gleichzeitig war der Aufruf der Methoden handle_n () / extract_n (), die die Anzahl der zu extrahierenden / zu verarbeitenden Nachrichten begrenzen, optional. Daher waren alle oben gezeigten Ketten korrekt.
In der neuen Version musste der Benutzer jedoch gezwungen werden, die Anzahl der zu extrahierenden und / oder zu verarbeitenden Nachrichten explizit anzugeben. Das heißt, Eine Kette des Formulars from(ch).empty_timeout(150ms)
jetzt falsch. Es sollte durch from(ch).handle_all().empty_timeout(150ms)
.
Und ich wollte es so machen, dass der Compiler den Programmierer von Hand schlägt, wenn der Programmierer vergisst, handle_all (), handle_n () oder extract_n () aufzurufen.
Kann C ++ dabei helfen?
Ja Und wenn sich jemand genau dafür interessiert, wie, dann sind Sie unter Katze willkommen.
Es gibt mehr als eine receive () - Funktion
Die Funktion receive () wurde oben gezeigt, deren Parameter mithilfe einer Aufrufkette (auch als Builder-Muster bezeichnet ) festgelegt wurden. Es gab aber auch eine select () -Funktion, die fast den gleichen Parametersatz erhielt:
select(from_all().empty_timeout(150ms), case_(...), case_(...), ...); select(from_all().handle_n(2).no_wait_on_empty(), case_(...), case_(...), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); select(from_all().no_wait_on_empty().stop_on(...), case_(...), case_(...), ...);
Dementsprechend wollte ich eine Lösung erhalten, die sowohl für select () als auch für receive () geeignet ist. Darüber hinaus wurden die Parameter für select () und receive () selbst bereits im Code dargestellt, um das Kopieren und Einfügen zu vermeiden. Dies wird jedoch weiter unten erörtert.
Mögliche Lösungen
Die Aufgabe besteht also darin, dass der Benutzer handle_all (), handle_n () oder extract_n () ohne Fehler aufruft.
Dies kann im Prinzip erreicht werden, ohne auf komplexe Entscheidungen zurückgreifen zu müssen. Sie können beispielsweise ein zusätzliches Argument für select () und receive () eingeben:
receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...);
Oder es wäre möglich, den Benutzer zu zwingen, den Aufruf von receive () / select () anders auszuführen:
receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...);
Das Problem hierbei ist jedoch, dass der Benutzer beim Wechsel zu einer neuen Version von SObjectizer seinen Code wiederholen muss. Auch wenn der Code im Prinzip keine Nacharbeit erforderte. Sagen Sie in dieser Situation:
receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);
Und das ist meiner Meinung nach ein sehr ernstes Problem. Was dich dazu bringt, nach einem anderen Weg zu suchen. Und diese Methode wird unten beschrieben.
Woher kommt CRTP?
Der Titel des Artikels erwähnte CRTP. Er ist auch ein seltsam wiederkehrendes Vorlagenmuster (diejenigen, die sich mit dieser interessanten, aber leicht hirntoleranten Technik vertraut machen möchten, können mit dieser Reihe von Beiträgen im Fluent C ++ - Blog beginnen).
CRTP wurde erwähnt, weil wir über CRTP die Arbeit mit den Funktionsparametern receive () und select () implementiert haben. Da der Löwenanteil der Parameter für receive () und select () gleich war, verwendete der Code ungefähr Folgendes:
template<typename Derived> class bulk_processing_params_t { ...;
Warum ist CRTP überhaupt hier?
Wir mussten hier CRTP verwenden, damit die in der Basisklasse definierten Setter-Methoden einen Verweis nicht auf den Basistyp, sondern auf den abgeleiteten zurückgeben konnten.
Das heißt, wenn nicht CRTP verwendet würde, sondern gewöhnliche Vererbung, könnten wir nur so schreiben:
class bulk_processing_params_t { public:
Ein solcher primitiver Mechanismus erlaubt es uns jedoch nicht, dasselbe Builder-Muster zu verwenden, weil:
receive_processing_params_t{}.handle_n(20).receive_payload(0)
nicht kompiliert. Die handle_n () -Methode gibt einen Verweis aufulk_processing_params_t zurück, und dort ist die receive_payload () -Methode noch nicht definiert.
Mit CRTP haben wir jedoch keine Probleme mit dem Builder-Muster.
Endgültige Entscheidung
Die endgültige Lösung besteht darin, dass die endgültigen Typen wie receive_processing_params_t und select_processing_params_t selbst zu Vorlagentypen werden. Damit werden sie mit einem Skalar folgender Form parametriert:
enum class msg_count_status_t { undefined, defined };
Und damit der endgültige Typ von T <msg_count_status_t :: undefined> in T <msg_count_status_t :: defined> konvertiert werden kann.
Dies ermöglicht es beispielsweise in der Funktion receive (), receive_processing_params_t zu empfangen und den Statuswert in comp-time zu überprüfen. So etwas wie:
template< msg_count_status_t Msg_Count_Status, typename... Handlers > inline mchain_receive_result_t receive( const mchain_receive_params_t<Msg_Count_Status> & params, Handlers &&... handlers ) { static_assert( Msg_Count_Status == msg_count_status_t::defined, "message count to be processed/extracted should be defined " "by using handle_all()/handle_n()/extract_n() methods" );
Im Allgemeinen ist wie immer alles einfach: nehmen und tun;)
Beschreibung der getroffenen Entscheidung
Schauen wir uns ein minimales Beispiel an, das von den Besonderheiten von SObjectizer getrennt ist, wie es aussieht.
Wir haben also bereits einen Typ, der bestimmt, ob das Limit für die Anzahl der Nachrichten festgelegt ist oder nicht:
enum class msg_count_status_t { undefined, defined };
Als nächstes brauchen wir eine Struktur, in der alle gemeinsamen Parameter gespeichert werden:
struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; };
Im Allgemeinen spielt es keine Rolle, wie der Inhalt von basic_data_t aussehen wird. Zum Beispiel ist der oben gezeigte minimale Satz von Feldern geeignet.
In Bezug auf basic_data_t ist es wichtig, dass für bestimmte Operationen (ob Receive (), Select () oder etwas anderes) ein eigener konkreter Typ erstellt wird, der basic_data_t erbt. Für receive () in unserem abstrahierten Beispiel wäre dies beispielsweise die folgende Struktur:
struct receive_specific_data_t final : public basic_data_t { int receive_payload_{}; receive_specific_data_t() = default; receive_specific_data_t(int v) : receive_payload_{v} {} };
Wir gehen davon aus, dass die Struktur basic_data_t und ihre Nachkommen keine Schwierigkeiten verursachen. Daher gehen wir zu den komplexeren Teilen der Lösung über.
Jetzt brauchen wir einen Wrapper um basic_data_t, der Getter-Methoden bereitstellt. Dies ist eine Vorlagenklasse der folgenden Form:
template<typename Basic_Data> class basic_data_holder_t { private : Basic_Data data_; protected : void set_to_extract(int v) { data_.to_extract_ = v; } void set_to_handle(int v) { data_.to_handle_ = v; } void set_common_payload(int v) { data_.common_payload_ = v; } const auto & data() const { return data_; } public : basic_data_holder_t() = default; basic_data_holder_t(Basic_Data data) : data_{std::move(data)} {} int to_extract() const { return data_.to_extract_; } int to_handle() const { return data_.to_handle_; } int common_payload() const { return data_.common_payload_; } };
Diese Klasse ist Boilerplate, sodass sie alle Nachkommen von basic_data_t enthalten kann, obwohl sie Getter-Methoden nur für die Felder implementiert, die sich in basic_data_t befinden.
Bevor wir zu den noch komplexeren Teilen der Lösung übergehen, sollten Sie die data () -Methode in basic_data_holder_t beachten. Dies ist eine wichtige Methode, auf die wir später noch eingehen werden.
Jetzt können wir zur Schlüsselvorlagenklasse übergehen, die für Leute, die sich nicht sehr für modernes C ++ interessieren, ziemlich beängstigend aussehen kann:
template<typename Data, typename Derived> class basic_params_t : public basic_data_holder_t<Data> { using base_type = basic_data_holder_t<Data>; public : using actual_type = Derived; using data_type = Data; protected : actual_type & self_reference() { return static_cast<actual_type &>(*this); } decltype(auto) clone_as_defined() { return self_reference().template clone_if_necessary< msg_count_status_t::defined >(); } public : basic_params_t() = default; basic_params_t(data_type data) : base_type{std::move(data)} {} decltype(auto) handle_all() { this->set_to_handle(0); return clone_as_defined(); } decltype(auto) handle_n(int v) { this->set_to_handle(v); return clone_as_defined(); } decltype(auto) extract_n(int v) { this->set_to_extract(v); return clone_as_defined(); } actual_type & common_payload(int v) { this->set_common_payload(v); return self_reference(); } using base_type::common_payload; };
Dieses basic_params_t ist die Haupt-CRTP-Vorlage. Erst jetzt wird es durch zwei Parameter parametriert.
Der erste Parameter ist der Datentyp, der darin enthalten sein muss. Beispiel: receive_specific_data_t oder select_specific_data_t.
Der zweite Parameter ist der dem CRTP vertraute Nachfolgertyp. Es wird in der Methode self_reference () verwendet, um einen Verweis auf einen abgeleiteten Typ abzurufen.
Der entscheidende Punkt bei der Implementierung der Vorlage basic_params_t ist die Methode clone_as_defined (). Diese Methode erwartet, dass der Erbe die Methode clone_if_necessary () implementiert. Und dieses clone_if_necessary () dient nur dazu, das Objekt T <msg_count_status_t :: undefined> in das Objekt T <msg_count_status_t :: defined> umzuwandeln. Und eine solche Transformation wird in den Setter-Methoden handle_all (), handle_n () und extract_n () initiiert.
Darüber hinaus können Sie darauf achten, dass clone_as_defined (), handle_all (), handle_n () und extract_n () den Typ ihres Rückgabewerts als decltype (auto) bestimmen. Dies ist ein weiterer Trick, über den wir bald sprechen werden.
Jetzt können wir uns bereits einen der endgültigen Typen ansehen, für die all dies konzipiert wurde:
template< msg_count_status_t Msg_Count_Status > class receive_specific_params_t final : public basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> > { using base_type = basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> >; public : template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } receive_specific_params_t(int receive_payload) : base_type{ typename base_type::data_type{receive_payload} } {} receive_specific_params_t(typename base_type::data_type data) : base_type{ std::move(data) } {} int receive_payload() const { return this->data().receive_payload_; } };
Das erste, worauf Sie hier achten sollten, ist der Konstruktor, der base_type :: data_type verwendet. Mit diesem Konstruktor werden die aktuellen Parameterwerte während der Transformation von T <msg_count_status_t :: undefined> nach T <msg_count_status_t :: defined> übertragen.
Im Großen und Ganzen ist dieses receive_specific_params_t ungefähr so:
template<typename V, int K> class holder_t { V v_; public: holder_t() = default; holder_t(V v) : v_{std::move(v)} {} const V & value() const { return v_; } }; holder_t<std::string, 0> v1{"Hello!"}; holder_t<std::string, 1> v2; v2 = v1;
Mit dem obigen Konstruktor receive_specific_params_t können Sie receive_specific_params_t <msg_count_status_t :: defined> mit Werten aus receive_specific_params_t <msg_count_status_t :: undefined> initialisieren.
Das zweite wichtige Element in receive_specific_params_t sind die beiden Methoden clone_if_necessary ().
Warum gibt es zwei? Und was bedeutet all diese SFINAE-vskaya-Magie in ihrer Definition?
Es wurden zwei clone_if_necessary () -Methoden erstellt, um unnötige Transformationen zu vermeiden. Angenommen, ein Programmierer hat die Methode handle_n () aufgerufen und bereits receive_specific_params_t <msg_count_status_t :: defined> empfangen. Und dann hieß es extract_n (). Dies ist zulässig, handle_n () und extract_n () setzen leicht unterschiedliche Einschränkungen. Der Aufruf von extract_n () sollte uns auch receive_specific_params_t <msg_count_status_t :: defined> geben. Aber wir haben schon einen. Warum also nicht ein vorhandenes wiederverwenden?
Aus diesem Grund gibt es hier zwei clone_if_necessary () -Methoden. Die erste funktioniert, wenn die Transformation wirklich benötigt wird:
template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; }
Der Compiler wählt es beispielsweise aus, wenn sich der Status von undefiniert zu definiert ändert. Und diese Methode gibt ein neues Objekt zurück. Und ja, bei der Implementierung dieser Methode achten wir auf den Aufruf data (), der bereits in basic_data_holder_t definiert wurde.
Die zweite Methode:
template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; }
wird aufgerufen, wenn der Status nicht geändert werden muss. Und diese Methode gibt einen Verweis auf ein vorhandenes Objekt zurück.
Nun sollte klar werden, warum in basic_params_t für eine Reihe von Methoden der Rückgabetyp als decltype (auto) definiert wurde. Schließlich hängen diese Methoden davon ab, welche bestimmte Version von clone_if_necessary () im abgeleiteten Typ aufgerufen wird, und entweder ein Objekt oder ein Link kann dort zurückgegeben werden ... Sie werden dies nicht im Voraus vorhersagen. Und hier kommt decltype (auto) zur Rettung.
Kleiner Haftungsausschluss
Das beschriebene minimalistische Beispiel zielte auf die einfachste und verständlichste Demonstration der gewählten Lösung ab. Daher gibt es keine ganz offensichtlichen Dinge, die in den Code aufgenommen werden müssen.
Beispielsweise gibt die Methode basic_data_holder_t :: data () einen konstanten Verweis auf die Daten zurück. Dies führt zum Kopieren von Parameterwerten während der Transformation von T <msg_count_status_t :: undefined> nach T <msg_count_status_t :: defined>. Wenn das Kopieren von Parametern eine teure Operation ist, sollten Sie durch die Verschiebungssemantik verwirrt sein und die data () -Methode könnte die folgende Form haben:
auto data() { return std::move(data_); }
Außerdem müssen Sie jetzt in jeden endgültigen Typ (wie receive_specific_params_t und select_specific_params_t) Implementierungen der clone_if_necessary-Methoden einbeziehen. Das heißt, An dieser Stelle verwenden wir immer noch Copy Paste. Vielleicht sollte auch etwas einfallen, um zu vermeiden, dass derselbe Codetyp dupliziert wird.
Nun ja, noexcept wird nicht in den Code eingetragen, um den "Syntax-Overhead" zu reduzieren.
Das ist alles
Den Quellcode für das hier diskutierte minimalistische Beispiel finden Sie hier . Und Sie können zum Beispiel hier im Online-Compiler spielen (Sie können den Aufruf von handle_all () in Zeile 163 auskommentieren und sehen, was passiert).
Ich möchte nicht sagen, dass der von mir implementierte Ansatz der einzig richtige ist. Aber zuerst sah ich eine Alternative, außer beim Kopieren und Einfügen. Und zweitens war es überhaupt nicht schwierig, dies zu tun, und glücklicherweise dauerte es nicht viel Zeit. Die Schläge des Compilers haben jedoch sofort sehr geholfen, da die alten Tests und Beispiele an die neuen Funktionen der neuesten Version von SObjectizer angepasst wurden.
Für mich hat C ++ also erneut bestätigt, dass es komplex ist. Aber nicht nur so, sondern um dem Entwickler mehr Möglichkeiten zu geben. Nun, ich bin nicht überrascht, wenn all dies in modernem C ++ noch einfacher als ich erreicht werden kann.
PS. Wenn einer der Leser dem SObjectizer folgt, kann ich sagen, dass die neue Version 5.6, in der die Kompatibilität mit dem 5.5-Zweig erheblich verletzt wurde, bereits einiges atmet. Sie finden es auf BitBucket . Die Veröffentlichung ist noch weit entfernt, aber SObjectizer-5.6 ist bereits das, was es sein sollte. Sie können Ihre Eindrücke aufnehmen, versuchen und teilen.