std::exchange-Muster:schnell, sicher, ausdrucksstark und wahrscheinlich zu wenig genutzt

std::exchange-Muster:schnell, sicher, ausdrucksstark und wahrscheinlich zu wenig genutzt

Dies ist ein Gastbeitrag von Ben Deane . Ben ist ein lebenslanger Lerner und Fan von Algorithmen, der in der Finanzbranche arbeitet und es liebt, ausdrucksstarkes, leistungsstarkes C++ zu schreiben. Sie finden ihn auf Twitter unter @ben_deane.

An diesem Blog-Beitrag hat es lange gedauert. Ich habe am 01 einen Blitzvortrag gehalten auf der CppCon 2017; Jonathan bat mich zuerst, etwas über 14 zu schreiben im Januar 2019; jetzt befinden wir uns hier in den seltsamen Tagen der zweiten Hälfte des Jahres 2020. Aber obwohl sich in der Außenwelt viel geändert hat, würde ich vermuten, dass sich in den meisten C++-Codebasen und in den Köpfen vieler C++-Programmierer in Bezug auf die Verwendung von 27 . Es könnte immer noch mehr Werbung und mehr Anerkennung für potenzielle Anwendungsfälle vertragen.

Sie verwenden bereits so etwas wie 33

Ich beginne mit einer vielleicht überraschenden Behauptung:Sie verwenden mit ziemlicher Sicherheit bereits ein Konstrukt, das semantisch äquivalent zu 46 ist .

Sie müssen kein modernes C++ verwenden, da dieses Konstrukt seit dem ersten Tag in C++ enthalten ist. Sie müssen nicht einmal C++ verwenden, da dieses Konstrukt in C war und daher in vielen C-beeinflussten Sprachen vorhanden ist. Tatsächlich ist es seit vielleicht 50 Jahren oder länger bei uns, seit Ken Thompson die Programmiersprache B geschrieben hat.

Es ist der bescheidene Inkrementoperator. Genauer gesagt der Postfix-Inkrementoperator.

Wenn wir 54 schreiben , es ist genau dasselbe wie das Schreiben von 64 . Wir können dies sehen, indem wir zwei mögliche Implementierungen von 79 betrachten :

char *idiomatic_strcpy(char* dest, const char* src) {
    while ((*dest++ = *src++));
    return dest;
}

char *exchange_strcpy(char* dest, const char* src) {
    for (;;) {
        auto s = std::exchange(src, src+1); 
        auto d = std::exchange(dest, dest+1);
        *d = *s;
        if (*d == 0) break;
    }
    return dest;
}

(Code auf Godbolt hier)

Und sie optimieren auf genau dieselbe Montageleistung [1].

Es gibt sogar einen rudimentären Hinweis in C++, dass das Postfix-Inkrement dasselbe ist wie 87 :Postfix 97 nimmt einen Dummy 105 Streit. Das unterscheidet es von seinem Präfix-Gegenstück, aber ist es nur ein Zufall?

struct S {
    constexpr auto& operator++() { 
        ++i; 
        return *this;
    }
    constexpr auto operator++(int) { 
        auto ret = *this; 
        ++i; 
        return ret; 
    }
    int i{};
};

int main() {
    S s{};
    ++s;
    s++;
    return s.i;
}

Wir könnten dies sogar noch weiterführen, indem wir das „Dummy“-Argument verwenden und am Ende etwas haben, das fast genau wie 119 ist .

struct S {
    constexpr auto operator++(int incr) { 
        auto ret = *this; 
        i = incr;
        return ret; 
    }
    int i{};
};

int main() {
    S s{};
    s.operator++(17);
    return s.i;
}

Ich empfehle nicht ausdrücklich, die herkömmliche Verwendung von 128 zu missbrauchen so, aber es dient der Veranschaulichung des Punktes [2].

Obwohl das Postfix-Inkrement in einer typischen Codebasis möglicherweise nicht annähernd so weit verbreitet ist wie das Präfix-Inkrement, haben wir normalerweise keine Probleme damit, es zu verwenden oder über seine Verwendung nachzudenken, wenn es zu prägnantem, lesbarem Code führt [3]. Und so sollte es mit 133 sein .

Das „Swap-and-Iterate“-Muster

Ich habe eine umfangreiche Verwendung für 147 gefunden überall dort, wo ich zuvor das Muster „Swap-and-Iterate“ verwendet hätte. Dieses Muster tritt häufig in ereignisgesteuerten Architekturen auf. Man könnte typischerweise einen Vektor von Ereignissen haben, die gesendet werden müssen, oder äquivalent Callbacks, die aufgerufen werden müssen. Aber wir möchten, dass Event-Handler in der Lage sind, eigene Events für verzögerten Versand zu erstellen.

class Dispatcher {
    // We hold some vector of callables that represents
    // events to dispatch or actions to take
    using Callback = /* some callable */;
    std::vector<Callback> callbacks_;

    // Anyone can register an event to be dispatched later
    void defer_event(const Callback& cb) {
        callbacks_.push_back(cb);
    }

    // All events are dispatched when we call process
    void process() {
        std::vector<Callback> tmp{};
        using std::swap; // the "std::swap" two-step
        swap(tmp, callbacks_);
        for (const auto& callback : tmp) {
            std::invoke(callback);
        }
    }
};

Dies ist das „Swap-and-Iterate“-Muster. Es ist sicher für die Rückrufe, 152 anzurufen und erzeugen daher eigene Ereignisse:Wir verwenden 163 damit ein Anruf an 172 macht den Iterator in unserer Schleife nicht ungültig.

Aber wir machen hier ein bisschen mehr Arbeit als nötig, und wir machen uns auch des „ITM-Antimusters“ [4] schuldig. Zuerst konstruieren wir einen leeren Vektor (184 ), dann — mit 195 – Wir haben drei Bewegungsaufgaben, bevor wir mit dem Iterieren beginnen.

Refactoring mit 208 löst diese Probleme:

class Dispatcher {
    // ...

    // All events are dispatched when we call process
    void process() {
        for (const auto& callback : std::exchange(callbacks_, {}) {
            std::invoke(callback);
        }
    }
};

Jetzt müssen wir kein temporäres mehr deklarieren. Innerhalb von 212 Wir haben eine Zugkonstruktion und eine Zugzuweisung, was im Vergleich zu 222 einen Zug spart . Wir müssen den ADL-Tanz in „234“ nicht verstehen zweistufig“ [5]. Wir brauchten 246 nicht – nur eine Möglichkeit, den leeren Vektor auszudrücken, der hier 255 ist . Und der Compiler ist wirklich gut darin, den Aufruf von 261 zu optimieren , also erhalten wir natürlich die Kopie, die wir normalerweise erwarten würden. Dadurch ist der Code insgesamt prägnanter, schneller und bietet die gleiche Sicherheit wie zuvor.

In einem anderen Thread posten

Ein ähnliches Muster tritt in jeder Multithread-Umgebung auf, in der wir ein Objekt in einem Lambda-Ausdruck erfassen und an einen anderen Thread senden möchten. 274 ermöglicht es uns, das Eigentum an den „Eingeweiden“ eines Objekts effizient zu übertragen.

class Dispatcher {
    // ...

    void post_event(Callback& cb) {
        Callback tmp{};
        using std::swap;
        swap(cb, tmp);
        PostToMainThread([this, cb_ = std::move(tmp)] {
            callbacks_.push_back(cb_);
        });
    }
};

Hier übernehmen wir den übergebenen Rückruf, indem wir ihn in ein temporäres tauschen und dieses temporäre in einem Lambda-Abschluss erfassen. Wir versuchen, die Leistung durch Capture-by-Move zu verbessern, aber letztendlich tun wir immer noch viel mehr als nötig.

class Dispatcher {
    // ...

    void post_event(Callback& cb) {
        PostToMainThread([this, cb_ = std::exchange(cb, {})] {
            callbacks_.push_back(cb_);
        });
    }
};

Dadurch erhalten wir genau das, was wir wollen – wieder mit aussagekräftigerem Code – und wir verlangen vom Prozessor weniger. Noch einmal 287 verwendet einen Zug weniger als 291 , und copy elision, auch bekannt als Rückgabewertoptimierung, konstruiert den Rückgabewert direkt in den Abschluss des Lambda-Ausdrucks.

Warum nicht einfach umziehen?

Aber, höre ich Sie fragen, warum bewegen sich überhaupt mehr als eine? Warum nicht so etwas?

class Dispatcher {
    // ...

    void post_event(Callback& cb) {
        PostToMainThread([this, cb_ = std::move(cb)] {
            callbacks_.push_back(cb_);
        });
    }
};

Die Antwort ist die Sicherstellung zukünftiger Wartbarkeit und Flexibilität. Es kann durchaus sein, dass ein move-from 305 wird genauso leer gewertet, als hätten wir es explizit mit 314 geleert , aber ist das offensichtlich? Wird es immer wahr sein? Müssen wir diese Annahme – oder diesen Code – jemals aktualisieren, wenn wir den Typ von 322 ändern später?

In den großen STL-Implementierungen ist es derzeit so, dass ein Container, aus dem verschoben wurde, leer ist. Genauer gesagt, sequenzierte Container wie 338; assoziative Container wie 349; und andere „Container“ wie 350 oder 369 sind nach dem Verschieben leer, auch wenn sie für kleine Puffer optimiert sind [6].

Dies gilt jedoch nicht unbedingt für jeden einzelnen Containertyp, den wir möglicherweise verwenden. Es gibt keinen besonderen Grund, warum ein selbst erstellter, für kleine Puffer optimierter Vektor leer sein sollte, nachdem wir ihn verlassen haben. Wir finden ein bemerkenswertes Standard-Gegenbeispiel für das „normale“ Verhalten in 370 , die nach dem Verschieben immer noch aktiviert ist. Also ja, mit 387 – offensichtlich – erfordert nur einen Zug, während 396 verursacht zwei, aber auf Kosten von Abstraktionslecks. Verwenden Sie nur 407 , müssen wir die umzugsbezogenen Eigenschaften des von uns verwendeten Containers kennen und in der Lage sein, darüber nachzudenken; Zukünftige Betreuer (normalerweise wir selbst in 6 Monaten) müssen auch über diese Einschränkung „leer nach Umzug“ im Code Bescheid wissen, die nirgendwo ausdrücklich zum Ausdruck kommt und bei der Überprüfung nicht offensichtlich ist.

Aus diesem Grund empfehle ich ausdrücklich, Objekte zu löschen, die vermeintlich leer sind, und 413 kann genau das tun. Tatsächlich stellt cppreference.com einen primären Anwendungsfall für 426 fest beim Schreiben der Move-Special-Member-Funktionen, um das verschobene Objekt gelöscht zu lassen.

Können wir 436 verwenden mit Schlössern?

Ich möchte noch einmal über Multithread-Code nachdenken, da es auf den ersten Blick wie 449 aussehen mag ist keine gute Option, wenn wir unter Mutex-Schutz auf etwas zugreifen müssen:

class Dispatcher {
    // ...

    // All events are dispatched when we call process
    void process() {
        std::vector<Callback> tmp{};
        {
            using std::swap;
            std::scoped_lock lock{mutex_};
            swap(tmp, callbacks_);
        }
        for (const auto& callback : tmp) {
            std::invoke(callback);
        }
    }
};

Hier wird der Vektor der Rückrufe durch einen 459 geschützt . Wir können es uns nicht leisten, diese Sperre während der Iteration aufrechtzuerhalten, da jeder Event-Handler, der ein Ereignis generieren möchte, versuchen wird, 460 zu sperren um sein Ereignis in die Warteschlange zu stellen [7].

Daher können wir unseren 478 nicht verwenden Muster naiv:

class Dispatcher {
    // ...

    // All events are dispatched when we call process
    void process() {
        std::scoped_lock lock{mutex_};
        for (const auto& callback : std::exchange(callbacks_, {})) {
            std::invoke(callback);
        }
    }
};

da dies unsere Fähigkeit beeinträchtigen würde, Ereignisse von Rückrufen in die Warteschlange einzureihen. Die Lösung ist, wie so oft, die Verwendung einer Funktion. In diesem Fall passt ein sofort aufgerufener Lambda-Ausdruck gut zur Rechnung.

class Dispatcher {
    // ...

    // All events are dispatched when we call process
    void process() {
        const auto tmp = [&] {
            std::scoped_lock lock{mutex_};
            return std::exchange(callbacks_, {});
        }();
        for (const auto& callback : tmp) {
            std::invoke(callback);
        }
    }
};

Wir profitieren davon, die Sperre so kurz wie möglich zu halten. Nutzung der Renditeoptimierung; einen Zug speichern; und Prägnanz des Ausdrucks.

Wäre ich absichtlich provokativ – etwa in einem Lightning Talk – könnte ich auch Folgendes vorschlagen:

class Dispatcher {
    // ...

    // All events are dispatched when we call process
    void process() {
        const auto tmp = (std::scoped_lock{mutex_}, std::exchange(callbacks_, {}));
        for (const auto& callback : tmp) {
            std::invoke(callback);
        }
    }
};

Hier der 483 lebt bis zum Semikolon und das Ergebnis des Komma-Operators ist das Ergebnis von 496 , verwendet, um 505 zu erstellen . Ich gebe zu, dass viele Leute vor dieser Verwendung des Kommaoperators entsetzt zurückschrecken würden, aber das ist ein Thema für einen anderen Artikel [8].

Betrachten Sie 512 über 528

Zusammenfassend glaube ich, dass 538 wird immer noch zu wenig genutzt, und Situationen, in denen es sinnvoll eingesetzt werden kann, werden wahrscheinlich zu wenig erkannt. Immer wenn Sie 542 schreiben , überlegen Sie:Brauchen Sie das wirklich vorübergehend?

Fußnoten

[1]:Ja, ich kenne das im wirklichen Leben, 558 gibt leider eine Kopie von 569 zurück übergeben. Es wäre sinnvoller – wie ich hier geschrieben habe – dorthin zurückzukehren, wo 571 endet. Ich kenne auch diesen 583 ist unsicher, aber ich verwende es als Beispiel.

[2]:Ich empfehle jedoch, den Postfix-Inkrementoperator 597 zu markieren . Meines Wissens gibt es keine Möglichkeit, bei einem Compiler eine Warnung zu erhalten, weil er das Ergebnis eines eingebauten 600 verwirft .

[3]:Die meisten Ratgeber im modernen Stil bevorzugen Präfix-Inkremente und verwenden Postfix-Inkremente nur dort, wo es nötig ist – das heißt, genau dort, wo wir ihren „Rückgabewert“ brauchen, wie wir es manchmal tun.

[4]:Conor Hoekstra erläutert das Antimuster „ITM“ (initialize-then-modify) in seinem kürzlich erschienenen Vortrag über MUC++.

[5]:Die Datei „618 Two-Step“ wird hier von Arthur O’Dwyer erklärt.

[6]:Dafür gibt es wohlüberlegte Gründe. Es ist nicht so einfach wie „einen Small-Buffer-optimierten 625 nicht zu löschen muss billiger sein, als es zu löschen“. Fragen Sie Ihren lokalen Standardbibliotheksimplementierer nach Details.

[7]:Wir könnten einen 630 verwenden um das Sperren des Wiedereintritts zu handhaben, aber ich versuche, solche faulen Lösungen zu vermeiden. Sie führen normalerweise zu einer Erosion der Vernünftigkeit des Codes.

[8]:Diese Konstruktion kann auch mit 645 in Konflikt geraten Attribut, das sinnvollerweise auf Sperrobjekte angewendet werden kann, um genau das sofortige Entsperren von versehentlich unbenannten Sperren zu verhindern.