C++23 leitet dies ab: Was es ist, warum es ist und wie man es verwendet

Ableiten this (P0847) ist eine C++23-Funktion, die eine neue Möglichkeit zur Angabe nicht statischer Memberfunktionen bietet. Wenn wir die Mitgliedsfunktion eines Objekts aufrufen, ist das Objekt normalerweise implizit an die Memberfunktion übergeben, obwohl er nicht in der Parameterliste vorhanden ist. Mit P0847 können wir diesen Parameter festlegen explizitihm einen Namen geben und const/reference-Qualifizierer. Zum Beispiel:

struct implicit_style {
    void do_something(); //object is implicit
};

struct explicit_style {
    void do_something(this explicit_style& self); //object is explicit
};

Der explizite Objektparameter wird durch das Schlüsselwort unterschieden this wird vor dem Typbezeichner platziert und ist nur für den ersten Parameter der Funktion gültig.

Die Gründe dafür, dies zuzulassen, scheinen vielleicht nicht sofort offensichtlich zu sein, aber eine Reihe zusätzlicher Funktionen ergeben sich dadurch fast wie von Zauberhand. Dazu gehören Dequadruplikation von Code, rekursive Lambdas und Übergabe this nach Wert und eine Version des CRTP, die nicht erfordert, dass die Basisklasse auf der abgeleiteten Klasse als Vorlage erstellt wird.

In diesem Beitrag erhalten Sie einen Überblick über das Design und anschließend viele Fälle, in denen Sie diese Funktion in Ihrem eigenen Code verwenden können.

Für den Rest dieses Blogbeitrags werde ich die Funktion als „explizite Objektparameter“ bezeichnen, da sie als Funktionsname sinnvoller ist als als „Ableitung“. this„. Explizite Objektparameter werden in MSVC ab Visual Studio 2022 Version 17.2 unterstützt. Ein guter Begleiter zu diesem Beitrag ist Ben Deanes Vortrag Deducing this Muster von CppCon.

Überblick

Der Artikel, in dem diese Funktion vorgeschlagen wurde, wurde von verfasst Gašper Ažman, Ben Deane, Barry RevzinUnd ich selbstund wurde von der Erfahrung vieler Experten auf diesem Gebiet geleitet. Barry und ich begannen, eine Version dieses Papiers zu schreiben, nachdem wir es jeweils umgesetzt hatten std::optional und bin auf das gleiche Problem gestoßen. Wir würden das schreiben value Die Funktion von optional und wie gute Bibliotheksentwickler würden wir versuchen, sie in so vielen Anwendungsfällen wie möglich nutzbar und leistungsfähig zu machen. Also würden wir wollen value a zurückgeben const Referenz, wenn das Objekt, für das es aufgerufen wurde, war constwir möchten, dass es einen R-Wert zurückgibt, wenn das Objekt, für das es aufgerufen wurde, ein R-Wert usw. war. Am Ende sah es so aus:

template 
class optional {
  // version of value for non-const lvalues
  constexpr T& value() & {
    if (has_value()) {
      return this->m_value;
    }
    throw bad_optional_access();
  }

  // version of value for const lvalues
  constexpr T const& value() const& {
    if (has_value()) {
      return this->m_value;
    }
    throw bad_optional_access();
  }

  // version of value for non-const rvalues... are you bored yet?
  constexpr T&& value() && {
    if (has_value()) {
      return std::move(this->m_value);
    }
    throw bad_optional_access();
  }

  // you sure are by this point
  constexpr T const&& value() const&& {
    if (has_value()) {
      return std::move(this->m_value);
    }
    throw bad_optional_access();
  }
  // ...
};

(Wenn Sie mit dem nicht vertraut sind member_function_name() & Diese Syntax wird als „Ref-Qualifizierer“ bezeichnet. Weitere Informationen finden Sie im Blog von Andrzej Krzemieński. Wenn Sie mit R-Wert-Referenzen nicht vertraut sind (T&&) Sie können sich in dieser Frage zum Stapelüberlauf über die Bewegungssemantik informieren.)

Beachten Sie die nahezu identischen Implementierungen von vier Versionen derselben Funktion, die sich nur dadurch unterscheiden, ob sie es sind const und ob sie den gespeicherten Wert verschieben, anstatt ihn zu kopieren.

Barry und ich gingen dann zu einer anderen Funktion über und mussten dasselbe tun. Und immer und immer wieder Code duplizieren, Fehler machen und Wartungsprobleme für zukünftige Versionen von uns selbst verursachen. „Was wäre, wenn“, dachten wir, „Sie das einfach schreiben könnten?“

template 
struct optional {
  // One version of value which works for everything
  template 
  constexpr auto&& value(this Self&& self) {
    if (self.has_value()) {
        return std::forward(self).m_value;
    }
    throw bad_optional_access();
  }

(Wenn Sie nicht damit vertraut sind std::forwardüber Perfect Forwarding können Sie auf Eli Benderskys Blog lesen)

Dies bewirkt dasselbe wie die oben genannten vier Überladungen, jedoch in einer einzigen Funktion. Anstatt verschiedene Versionen von zu schreiben value für const optional&, const optional&&, optional&Und optional&&wir schreiben eine Funktionsvorlage, die leitet ab Die const/volatile/reference (kurz cvref) Qualifizierer des Objekts, für das es aufgerufen wird. Diese Änderung für fast jede Funktion im Typ würde unseren Code enorm verkürzen.

Also schrieben wir eine Version dessen, was schließlich standardisiert wurde, stellten bald fest, dass Gašper und Ben an einem anderen Papier für genau dasselbe Feature arbeiteten, schlossen uns zusammen und hier sind wir alle einige Jahre später.

Design

Das wichtigste Designprinzip, dem wir folgten, war, dass es so sein sollte Mach, was du erwartest. Um dies zu erreichen, haben wir so wenige Stellen im Standard wie möglich berührt. Insbesondere haben wir die Überlastungsauflösungsregeln oder Vorlagenabzugsregeln nicht berührt und die Namensauflösung wurde nur geringfügig geändert (als Leckerbissen).

Angenommen, wir haben einen Typ wie diesen:

struct cat {
    template 
    void lick_paw(this Self&& self);
};

Der Vorlagenparameter Self wird basierend auf denselben Vorlagenabzugsregeln abgeleitet, mit denen Sie bereits vertraut sind. Es gibt keine zusätzliche Magie. Sie müssen die Namen nicht verwenden Self Und selfaber ich denke, das sind die klarsten Optionen, und dies folgt dem, was mehrere andere Programmiersprachen tun.

cat marshmallow;
marshmallow.lick_paw();                         //Self = cat&

const cat marshmallow_but_stubborn;
marshmallow_but_stubborn.lick_paw();            //Self = const cat&

std::move(marshmallow).lick_paw();              //Self = cat
std::move(marshmallow_but_stubborn).lick_paw(); //Self = const cat

Eine Änderung der Namensauflösung besteht darin, dass Sie innerhalb einer solchen Memberfunktion weder explizit noch implizit darauf verweisen dürfen this.

struct cat {
    std::string name;

    void print_name(this const cat& self) {
        std::cout << name;       //invalid
        std::cout << this->name; //also invalid
        std::cout << self.name;  //all good
    }
};

Anwendungsfälle

Für den Rest dieses Beitrags werden wir uns die verschiedenen Einsatzmöglichkeiten dieser Funktion ansehen (zumindest die bisher entdeckten, soweit ich weiß!). Viele dieser Beispiele stammen direkt aus der Arbeit.

Deduplizierung/Vervierfachung

Wir haben bereits gesehen, wie die Funktion auf einen Typ wie angewendet werden kann optional um zu vermeiden, dass vier Überladungen derselben Funktion geschrieben werden müssen.

Beachten Sie auch, dass dies den Aufwand für die anfängliche Implementierung und Wartung des Umgangs mit R-Wert-Memberfunktionen verringert. Sehr oft schreiben Entwickler nur const und nicht-const Überladungen für Mitgliedsfunktionen, da wir in vielen Fällen nicht wirklich zwei weitere ganze Funktionen schreiben möchten, nur um mit R-Werten umzugehen. Mit abgeleiteten Qualifikationsmerkmalen thiserhalten wir die Rvalue-Versionen kostenlos: Wir müssen nur schreiben std::forward an den richtigen Stellen, um die Laufzeitleistungssteigerungen zu erzielen, die mit der Vermeidung unnötiger Kopien einhergehen:

class cat {
    toy held_toy_;

public:
    //Before explicit object parameters
    toy& get_held_toy() { return held_toy_; }
    const toy& get_held_toy() const { return held_toy_; }

    //After
    template 
    auto&& get_held_toy(this Self&& self) {
        return self.held_toy_;
    }

    //After + forwarding
    template 
    auto&& get_held_toy(this Self&& self) {
        return std::forward(self).held_toy_;
    }
};

Bei einem einfachen Getter wie diesem liegt es natürlich bei Ihnen, ob sich diese Änderung für Ihren speziellen Anwendungsfall lohnt oder nicht. Aber bei komplexeren Funktionen oder Fällen, in denen Sie es mit großen Objekten zu tun haben, die Sie nicht kopieren möchten, erleichtern explizite Objektparameter die Handhabung erheblich.

CRTP

Das Curiously Recurring Template Pattern (CRTP) ist eine Form des Polymorphismus zur Kompilierungszeit, der es Ihnen ermöglicht, Typen mit gemeinsamen Funktionalitätsteilen zu erweitern, ohne die Laufzeitkosten virtueller Funktionen zu bezahlen. Dies wird manchmal als bezeichnet Mixins (Das ist nicht der Fall alle das CRTP kann verwendet werden, ist aber die häufigste Verwendung). Wir könnten zum Beispiel einen Typ schreiben add_postfix_increment Dies kann in einen anderen Typ eingemischt werden, um das Postfix-Inkrement als Präfix-Inkrement zu definieren:

template 
struct add_postfix_increment {
    Derived operator++(int) {
        auto& self = static_cast(*this);

        Derived tmp(self);
        ++self;
        return tmp;
    }
};

struct some_type : add_postfix_increment {
    // Prefix increment, which the postfix one is implemented in terms of
    some_type& operator++();
};

Erstellen einer Vorlage für eine Basisklasse anhand ihrer abgeleiteten Umwandlung und static_casting this Das Innere der Funktion kann etwas geheimnisvoll sein, und das Problem wird schlimmer, wenn Sie über mehrere CRTP-Ebenen verfügen. Mit expliziten Objektparametern, da wir die Vorlagenabzugsregeln nicht geändert haben, Der Typ des expliziten Objektparameters kann auf einen abgeleiteten Typ abgeleitet werden. Konkreter:

struct base {
    template 
    void f(this Self&& self);
};

struct derived : base {};

int main() {
    derived my_derived;
    my_derived.f();
}

Im Anruf my_derived.f()die Art von Self innen f Ist derived&, nicht base&.

Das bedeutet, dass wir das obige CRTP-Beispiel wie folgt definieren können:

struct add_postfix_increment {
    template 
    auto operator++(this Self&& self, int) {
        auto tmp = self;
        ++self;
        return tmp;
    }
};

struct some_type : add_postfix_increment {
    // Prefix increment, which the postfix one is implemented in terms of
    some_type& operator++();
};

Beachten Sie das jetzt add_postfix_increment ist keine Vorlage. Stattdessen haben wir die Anpassung in den Postfix verschoben operator++. Das bedeutet, dass wir nicht passieren müssen some_type als Musterargument überall: „Alles funktioniert einfach“.

Weiterleitung aus Lambdas

Das Kopieren erfasster Werte aus einem Abschluss ist einfach: Wir können das Objekt einfach wie gewohnt weitergeben. Das Verschieben erfasster Werte aus einem Abschluss ist ebenfalls einfach: Wir können einfach aufrufen std::move darauf. Ein Problem tritt auf, wenn wir einen erfassten Wert perfekt weiterleiten müssen, basierend darauf, ob der Abschluss ein l-Wert oder ein r-Wert ist.

Ein Anwendungsfall, den ich aus P2445 geklaut habe, betrifft Lambdas, die sowohl im „Retry“- als auch im „Try or Fail“-Kontext verwendet werden können:

auto callback = [m=get_message(), &scheduler]() -> bool {
    return scheduler.submit(m);
};
callback(); // retry(callback)
std::move(callback)(); // try-or-fail(rvalue)

Die Frage hier ist: Wie gehen wir weiter? m basierend auf der Wertkategorie des Verschlusses? Explizite Objektparameter geben uns die Antwort. Da ein Lambda eine Klasse mit einem generiert operator() Mitgliedsfunktion der gegebenen Signatur, alle Maschinen, die ich gerade erklärt habe, funktionieren auch für Lambdas.

auto closure = [](this auto&& self) {
    //can use self inside the lambda
};

Dies bedeutet, dass wir basierend auf der Wertkategorie des Abschlusses innerhalb des Lambda eine Perfektion durchführen können. P2445 gibt eine std::forward_like Helfer, der einen Ausdruck basierend auf der Wertkategorie eines anderen weiterleitet:

auto callback = [m=get_message(), &scheduler](this auto &&self) -> bool {
    return scheduler.submit(std::forward_like(m));
};

Jetzt funktioniert unser ursprünglicher Anwendungsfall und das erfasste Objekt wird kopiert oder verschoben, je nachdem, wie wir den Verschluss verwenden.

Rekursive Lambdas

Da wir jetzt die Möglichkeit haben, das Abschlussobjekt in der Parameterliste eines Lambdas zu benennen, können wir rekursive Lambdas erstellen! Wie oben:

auto closure = [](this auto&& self) {
    self(); //just call ourself until the stack overflows
};

Dafür gibt es allerdings noch sinnvollere Einsatzmöglichkeiten als nur überlaufende Stapel. Denken Sie zum Beispiel an die Möglichkeit, rekursive Datenstrukturen zu besuchen, ohne zusätzliche Typen oder Funktionen definieren zu müssen? Gegeben sei die folgende Definition eines Binärbaums:

struct Leaf { };
struct Node;
using Tree = std::variant;
struct Node {
    Tree left;
    Tree right;
};

Wir können die Anzahl der Blätter folgendermaßen zählen:

int num_leaves(Tree const& tree) {
    return std::visit(overload( //see below
        [](Leaf const&) { return 1; },                       
        [](this auto const& self, Node* n) -> int {              
            return std::visit(self, n->left) + std::visit(self, n->right); 
        }
    ), tree);
}

overload Hier finden Sie eine Möglichkeit zum Erstellen eines Überladungssatzes aus mehreren Lambdas, die häufig für verwendet wird variant Heimsuchung. Siehe zum Beispiel cppreference.

Dabei wird die Anzahl der Blätter im Baum durch Rekursion gezählt. Für jeden Funktionsaufruf im Aufrufdiagramm, wenn der Strom a ist Leafes kehrt zurück 1. Andernfalls ruft sich der überladene Abschluss selbst auf self und führt eine Rekursion durch, wobei die Blattanzahlen für den linken und rechten Teilbaum addiert werden.

Passieren this nach Wert

Da wir die Qualifizierer des jetzt expliziten Objektparameters definieren können, können wir ihn nach Wert statt nach Referenz verwenden. Bei kleinen Objekten kann dies zu einer besseren Laufzeitleistung führen. Falls Sie nicht wissen, wie sich dies auf die Codegenerierung auswirkt, finden Sie hier ein Beispiel.

Angenommen, wir haben diesen Code, der normale alte implizite Objektparameter verwendet:

struct just_a_little_guy {
    int how_smol;
    int uwu();
};

int main() {
    just_a_little_guy tiny_tim{42};
    return tiny_tim.uwu();
}

MSVC generiert die folgende Assembly:

sub     rsp, 40                           
lea     rcx, QWORD PTR tiny_tim$[rsp]
mov     DWORD PTR tiny_tim$[rsp], 42     
call    int just_a_little_guy::uwu(void)  
add     rsp, 40                            
ret     0

Ich werde das Zeile für Zeile durchgehen.

  • sub rsp, 40 weist 40 Bytes auf dem Stapel zu. Dies sind 4 Bytes zum Speichern int Mitglied von tiny_tim32 Bytes von Schattenraum für uwu zu verwendenden Datei und 4 Bytes Auffüllung.
  • Der lea Die Anweisung lädt die Adresse des tiny_tim Variable in die rcx Registrieren Sie sich, wo uwu erwartet den impliziten Objektparameter (aufgrund der verwendeten Aufrufkonventionen).
  • Der mov Shops 42 in die int Mitglied von tiny_tim.
  • Wir rufen dann an uwu Funktion.
  • Schließlich geben wir den zuvor auf dem Stapel zugewiesenen Speicherplatz frei und kehren zurück.

Was passiert, wenn wir stattdessen angeben uwu seinen Objektparameter nach Wert nehmen, so?

struct just_a_little_guy {
    int how_smol;
    int uwu(this just_a_little_guy);
};

In diesem Fall wird der folgende Code generiert:

mov     ecx, 42                           
jmp     static int just_a_little_guy::uwu(this just_a_little_guy) 

Wir ziehen einfach um 42 in das entsprechende Register und springen (jmp) zum uwu Funktion. Da wir keine Referenz übergeben, müssen wir nichts auf dem Stapel zuweisen. Da wir keine Zuweisung auf dem Stapel vornehmen, müssen wir die Zuweisung am Ende der Funktion nicht aufheben. Da wir die Zuordnung am Ende der Funktion nicht aufheben müssen, können wir direkt zu ihr springen uwu anstatt dorthin zu springen und dann wieder in diese Funktion zurückzukehren, wenn sie zurückkehrt, mit call.

Dies sind die Arten von Optimierungen, die den „Tod durch tausend Schnitte“ verhindern können, bei dem Sie immer wieder kleine Leistungseinbußen erleiden, was zu langsameren Laufzeiten führt, deren Ursache schwer zu finden ist.

SFINAE-unfreundliche Callables

Dieses Problem ist etwas esoterischer, tritt aber tatsächlich in echtem Code auf (ich weiß es, weil ich einen Fehlerbericht zu meiner erweiterten Implementierung von erhalten habe std::optional was genau dieses Problem in der Produktion traf). Gegeben sei eine Memberfunktion von optional genannt transformdie die angegebene Funktion nur dann für den gespeicherten Wert aufruft, wenn es einen gibt, sieht das Problem folgendermaßen aus:

struct oh_no {
    void non_const();
};

tl::optional o;
o.transform([](auto&& x) { x.non_const(); }); //does not compile

Der Fehler, den MSVC hierfür ausgibt, sieht folgendermaßen aus:

Fehler C2662: „void oh_no::non_const(void)“: „dieser“ Zeiger kann nicht von „const oh_no“ in „oh_no &“ konvertiert werden.

Es wird also versucht, a zu bestehen const oh_no als impliziter Objektparameter an non_const, was nicht funktioniert. Aber woher kam das? const oh_no komme aus? Die Antwort liegt in der Implementierung von optional selbst. Hier ist eine bewusst reduzierte Version:

template 
struct optional {
    T t;

    template 
    auto transform(F&& f) -> std::invoke_result_t;

    template 
    auto transform(F&& f) const -> std::invoke_result_t;
};

Diese std::invoke_result_ts gibt es zu machen transform SFINAE-freundlich. Dies bedeutet im Grunde, dass Sie überprüfen können, ob ein Anruf erfolgt transform würde kompilieren und, wenn nicht, etwas anderes tun, anstatt einfach die gesamte Kompilierung abzubrechen. Allerdings gibt es hier eine kleine Lücke in der Sprache.

Wenn Sie die Überlastungsauflösung aktivieren transform, muss der Compiler herausfinden, welche dieser beiden Überladungen angesichts der Argumenttypen am besten übereinstimmt. Dazu müssen die Deklarationen beider instanziiert werden const und nicht-const Überlastungen. Wenn Sie eine aufrufbare Datei an übergeben transform was nicht der Fall ist selbst SFINAE-freundlich und nicht gültig für a const qualifiziertes implizites Objekt (was in meinem Beispiel der Fall ist) und dann die Deklaration des instanziiert const Die Memberfunktion führt zu einem schwerwiegenden Compilerfehler. Uff.

Mit expliziten Objektparametern können Sie dieses Problem lösen, da die CVref-Qualifizierer dies tun abgeleitet aus dem Ausdruck, auf dem Sie die Mitgliedsfunktion aufrufen: Wenn Sie die Funktion nie auf a aufrufen const optional dann muss der Compiler nie versuchen, diese Deklaration zu instanziieren. Gegeben std::copy_cvref_t ab P1450:

template 
struct optional {
    T t;

    template 
    auto transform(this Self&& self, F&& f) 
    -> std::invoke_result_t>;
};

Dadurch kann das obige Beispiel kompiliert werden, während dies weiterhin möglich ist transform um SFINAE-freundlich zu sein.

Abschluss

Ich hoffe, dass dies dazu beigetragen hat, die Funktion und den Nutzen expliziter Objektparameter zu verdeutlichen. Sie können die Funktion in Visual Studio Version 17.2 ausprobieren. Wenn Sie Fragen, Kommentare oder Probleme mit der Funktion haben, können Sie unten einen Kommentar abgeben oder uns per E-Mail unter [email protected] oder über Twitter erreichen @VisualC.

Lesen Sie auch  Kann die Ausbreitung des Virus in Indien eine Epidemie auslösen? Wissen Sie, wie gefährlich Nipah ist

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.