In seiner Rede auf der CppCon 2018 präsentierte Herb Sutter der Öffentlichkeit seine Leistungen in zwei Richtungen. Zuallererst ist es die Steuerung der Lebensdauer von Variablen (Lifetime), die es ermöglicht, ganze Fehlerklassen in der Kompilierungsphase zu erkennen. Zweitens handelt es sich um einen aktualisierten Vorschlag für Metaklassen , mit dem Code-Duplikationen vermieden werden können, sobald das Verhalten einer Klassenkategorie beschrieben und dann mit einer Zeile mit bestimmten Klassen verbunden wird.
Vorwort: mehr = einfacher ?!
C ++ Vorwürfe sind zu hören, dass der Standard sinnlos und gnadenlos wächst. Aber selbst die leidenschaftlichsten Konservativen werden nicht argumentieren, dass neue Konstruktionen wie Range-for (Erfassungszyklus) und Auto (zumindest für Iteratoren) den Code einfacher machen. Sie können ungefähre Kriterien entwickeln, die (mindestens eine, im Idealfall alle) neuen Spracherweiterungen erfüllen müssen, um den Code in der Praxis zu vereinfachen:
- Code reduzieren, vereinfachen, doppelten Code entfernen (range-for, auto, lambda, Metaclasses)
- Erleichtern Sie das Schreiben von sicherem Code, vermeiden Sie Fehler und Sonderfälle (intelligente Zeiger, Lebensdauern).
- Ersetzen Sie alte, weniger funktionale Funktionen vollständig (typedef → using)
Herb Sutter identifiziert "modernes C ++" - eine Teilmenge von Funktionen, die modernen Codierungsstandards (wie C ++ Core Guidelines ) entsprechen, und betrachtet den vollständigen Standard als "Kompatibilitätsmodus", den nicht jeder kennen muss. Dementsprechend ist alles in Ordnung, wenn "modernes C ++" nicht wächst.
Überprüfen der Lebensdauer von Variablen (Lebensdauer)
Die neue Lifetime Verification Group ist jetzt als Teil des Core Guidelines Checker für Clang und Visual C ++ verfügbar. Ziel ist es nicht, wie bei Rust absolute Genauigkeit und Genauigkeit zu erreichen, sondern einfache und schnelle Überprüfungen innerhalb einzelner Funktionen durchzuführen.
Grundprinzipien der Verifikation
Unter dem Gesichtspunkt der Lebenszeitanalyse werden Typen in drei Kategorien unterteilt:
- Der Wert ist das, worauf ein Zeiger zeigen kann.
- Zeiger - bezieht sich auf den Wert, steuert jedoch nicht dessen Lebensdauer. Kann hängen (baumelnder Zeiger). Beispiele:
T*
, T&
, Iteratoren, std::observer_ptr<T>
, std::string_view
, gsl::span<T>
- Eigentümer - steuert die Lebensdauer des Werts. Normalerweise kann der Wert vorzeitig gelöscht werden. Beispiele:
std::unique_ptr<T>
, std::shared_ptr<T>
, std::vector<T>
, std::string
, gsl::owner<T*>
Ein Zeiger kann sich in einem der folgenden Zustände befinden:
- Zeigen Sie auf einen Wert, der auf dem Stapel gespeichert ist
- Zeigen Sie auf einen Wert, der von einem Eigentümer "innen" enthalten ist
- Sei leer (null)
- Hang (ungültig)
Zeiger und Werte
Für jeden Zeiger p wird verfolgt p s e t ( p ) - die Menge von Werten, auf die es hinweisen kann. Wenn ein Wert gelöscht wird, tritt er insgesamt auf p s e t ersetzt durch ungültig . Beim Zugriff auf einen Zeigerwert p so dass ungültig∈pset(p) einen Fehler ausgeben.
string_view s;
Mithilfe von Anmerkungen können Sie konfigurieren, welche Vorgänge als Vorgänge für den Zugriff auf den Wert betrachtet werden. Standardmäßig: *
, ->
, []
, begin()
, end()
.
Bitte beachten Sie, dass die Warnung nur zum Zeitpunkt des Zugriffs auf den ungültigen Index ausgegeben wird. Wenn der Wert gelöscht wird, aber niemand jemals auf diesen Zeiger zugreift, ist alles in Ordnung.
Wegweiser und Eigentümer
Wenn Zeiger p Gibt einen Wert an, der im Eigentümer enthalten ist o dann das hier pset(p)=o′ .
Methoden und Funktionen, die Eigentümer übernehmen, sind unterteilt in:
- Owner Value Access-Vorgänge. Standard:
*
, ->
, []
, begin()
, end()
- Zugriff auf Vorgänge auf den Eigentümer selbst,
v.clear()
Zeiger wie v.clear()
. Standardmäßig sind dies alle anderen nicht konstanten Operationen - Zugriff auf Vorgänge auf den Eigentümer selbst, nicht ungültig
v.empty()
Zeiger wie v.empty()
. Standardmäßig sind dies alles const-Operationen.
Alter Inhaltseigentümer angekündigt ungültig bei Entfernung des Eigentümers oder bei Anwendung ungültiger Vorgänge.
Diese Regeln reichen aus, um viele typische Fehler im C ++ - Code zu erkennen:
string_view s;
vector<int> v = get_ints(); int* p = &v[5];
std::string_view s = "foo"s; cout << s[0];
vector<int> v = get_ints(); for (auto i = v.begin(); i != v.end(); ++i) {
std::optional<std::vector<int>> get_data();
Verfolgung der Lebensdauer von Funktionsparametern
Wenn wir uns mit Funktionen in C ++ befassen, die Zeiger zurückgeben, können wir nur die Beziehung zwischen der Lebensdauer der Parameter und dem Rückgabewert erraten. Wenn eine Funktion Zeiger desselben Typs akzeptiert und zurückgibt, wird angenommen, dass die Funktion den Rückgabewert von einem der Eingabeparameter "erhält":
auto f(int* p, int* q) -> int*;
Verdächtige Funktionen, die das Ergebnis aus dem Nichts erhalten, sind leicht zu erkennen:
std::reference_wrapper<int> get_data() {
Da es möglich ist, einen temporären Wert an die Parameter const T&
, werden diese nicht berücksichtigt, es sei denn, das Ergebnis ist nirgendwo anders zu berücksichtigen:
template <typename T> const T& min(const T& x, const T& y);
using K = std::string; using V = std::string; const V& find_or_default(const std::map<K, V>& m, const K& key, const V& def);
Es wird auch angenommen, dass eine Funktion, die einen Zeiger (anstelle einer Referenz) akzeptiert, nullptr sein kann und dieser Zeiger nicht vor dem Vergleich mit nullptr verwendet werden kann.
Schlussfolgerung zur Lebenszeitkontrolle
Ich wiederhole, dass Lifetime noch kein Vorschlag für den C ++ - Standard ist, sondern ein mutiger Versuch, Lebensdauerkontrollen in C ++ zu implementieren, wo es im Gegensatz zu Rust beispielsweise nie entsprechende Anmerkungen gegeben hat. Zuerst wird es viele Fehlalarme geben, aber im Laufe der Zeit werden sich die Heuristiken verbessern.
Fragen aus dem Publikum
Bieten lebenslange Gruppenprüfungen eine mathematisch genaue Garantie für das Fehlen baumelnder Zeiger?
Theoretisch wäre es möglich (im neuen Code), eine Reihe von Anmerkungen an Klassen und Funktionen zu hängen, und im Gegenzug würde der Compiler solche Garantien geben. Diese Überprüfungen wurden jedoch nach dem 80: 20-Prinzip entwickelt. Das heißt, Sie können die meisten Fehler mit einer kleinen Anzahl von Regeln und einem Minimum an Anmerkungen abfangen.
Die Metaklasse ergänzt in gewisser Weise den Code der Klasse, auf die sie angewendet wird, und dient auch als Name für eine Gruppe von Klassen, die bestimmte Bedingungen erfüllen. Wie unten gezeigt, macht die interface
Metaklasse beispielsweise alle Funktionen für Sie öffentlich und rein virtuell.
Letztes Jahr hat Herb Sutter sein erstes Metaklassenprojekt gemacht ( siehe hier ). Seitdem hat sich die derzeit vorgeschlagene Syntax geändert.
Für den Anfang hat sich die Syntax für die Verwendung von Metaklassen geändert:
Es ist länger geworden, aber jetzt gibt es eine natürliche Syntax zum gleichzeitigen Anwenden mehrerer Metaklassen: class(meta1, meta2)
.
Bisher war eine Metaklasse ein Regelwerk zum Ändern einer Klasse. Jetzt ist eine Metaklasse eine constexpr-Funktion, die eine alte Klasse (im Code deklariert) verwendet und eine neue erstellt.
Die Funktion verwendet nämlich einen Parameter - Metainformationen über die alte Klasse (der Parametertyp hängt von der Implementierung ab), erstellt Klassenelemente (Fragmente) und fügt sie dann mit der Anweisung __generate
dem Hauptteil der neuen Klasse __generate
.
Fragmente können mit den __fragment
, __inject
, idexpr(…)
werden. Der Redner zog es vor, sich nicht auf seinen Zweck zu konzentrieren, da sich dieser Teil noch ändern wird, bevor er dem Normungsausschuss vorgelegt wird. Die Namen selbst werden garantiert geändert, um dies zu verdeutlichen, wurde eine doppelte Unterstreichung hinzugefügt. Der Schwerpunkt des Berichts lag auf Beispielen, die weiter gehen.
Schnittstelle
template <typename T> constexpr void interface(T source) {
Sie könnten denken, dass wir in den Zeilen (1) und (2) die ursprüngliche Klasse ändern, aber nein. Bitte beachten Sie, dass wir beim Kopieren die Funktionen der ursprünglichen Klasse durchlaufen, diese Funktionen ändern und sie dann in eine neue Klasse einfügen.
Metaklassenanwendung:
class(interface) Shape { int area() const; void scale_by(double factor); };
Mutex-Debugging
Angenommen, wir haben nicht threadsichere Daten, die durch einen Mutex geschützt sind. Das Debuggen kann erleichtert werden, wenn in einer Debug-Assembly bei jedem Aufruf überprüft wird, ob der aktuelle Prozess diesen Mutex gesperrt hat. Zu diesem Zweck wurde eine einfache TestableMutex-Klasse geschrieben:
class TestableMutex { public: void lock() { m.lock(); id = std::this_thread::get_id(); } void unlock() { id = std::thread::id{}; m.unlock(); } bool is_held() { return id == std::this_thread::get_id(); } private: std::mutex m; std::atomic<std::thread::id> id; };
Außerdem möchten wir in unserer MyData-Klasse jedes öffentliche Feld mögen
vector<int> v;
Durch + Getter ersetzen:
private: vector<int> v_; public: vector<int>& v() { assert(m_.is_held()); return v_; }
Für Funktionen kann man auch ähnliche Transformationen durchführen.
Solche Aufgaben werden mithilfe von Makros und Codegenerierung gelöst. Herb Sutter erklärte Makros den Krieg: Sie sind unsicher, ignorieren Semantik, Namespaces usw. Wie sieht die Lösung bei Metaklassen aus:
constexpr void guarded_with_mutex() { __generate __fragment class { TestableMutex m_;
Wie man es benutzt:
class(guarded) MyData { vector<int> v; Widget* w; }; MyData& x = findData("foo"); xv().clear();
Schauspieler
Nun, selbst wenn wir ein Objekt mit einem Mutex geschützt haben, ist jetzt alles threadsicher, es gibt keinen Anspruch auf Richtigkeit. Wenn jedoch häufig von vielen Threads gleichzeitig auf ein Objekt zugegriffen werden kann, wird der Mutex überlastet, und es ist ein großer Aufwand erforderlich, ihn zu übernehmen.
Die grundlegende Lösung für das Problem der fehlerhaften Mutexe ist das Konzept der Akteure. Wenn ein Objekt eine Anforderungswarteschlange hat, werden alle Aufrufe des Objekts nacheinander in einem speziellen Thread in die Warteschlange gestellt und ausgeführt.
Lassen Sie die Active-Klasse eine Implementierung von all dem enthalten - tatsächlich einen Thread-Pool / Executor mit einem einzelnen Thread. Nun, Metaklassen helfen dabei, doppelten Code zu entfernen und alle Vorgänge in die Warteschlange zu stellen:
class(active) ImageFilter { public: ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {} void apply(Buffer* b) { work(b); } private: std::function<void(Buffer*)> work; }
class(active) log { std::fstream f; public: void info(…) { f << …; } };
Eigentum
Es gibt Eigenschaften in fast allen modernen Programmiersprachen, und wer sie einfach nicht auf der Basis von C ++ implementiert hat: Qt, C ++ / CLI, alle möglichen hässlichen Makros. Sie werden jedoch niemals zum C ++ - Standard hinzugefügt, da sie selbst als zu enge Funktionen angesehen werden und immer die Hoffnung bestand, dass ein Vorschlag sie als Sonderfall implementieren würde. Nun, sie können auf Metaklassen implementiert werden!
Sie können Ihren eigenen Getter und Setter einstellen:
class Date { public: class(property<int>) MonthClass { int month; auto get() { return month; } void set(int m) { assert(m > 0 && m < 13); month = m; } } month; }; Date date; date.month = 15;
Idealerweise möchte ich die property int month { … }
schreiben, aber selbst eine solche Implementierung ersetzt den Zoo von C ++ - Erweiterungen, die Eigenschaften erfinden.
Metaklassen sind eine große Neuerung für eine bereits komplexe Sprache. Lohnt es sich? Hier sind einige ihrer Vorteile:
- Lassen Sie Programmierer ihre Absichten klarer ausdrücken (ich möchte Schauspieler schreiben)
- Reduzieren Sie die Codeduplizierung und vereinfachen Sie die Entwicklung und Wartung von Code, der bestimmten Mustern folgt
- Beseitigen Sie einige Gruppen häufiger Fehler (es reicht aus, alle Feinheiten einmal zu erledigen).
- Makros entfernen lassen? (Herb Sutter ist sehr kriegerisch)
Fragen aus dem Publikum
Wie kann man Metaklassen debuggen?
Zumindest für Clang gibt es eine intrinsische Funktion, die, wenn sie aufgerufen wird, den tatsächlichen Inhalt der Klasse während der Kompilierung druckt, dh was nach dem Anwenden aller Metaklassen erhalten wird.
Früher hieß es, Nichtmitglieder wie Swap und Hash in Metaklassen deklarieren zu können. Wo ist sie hingegangen?
Die Syntax wird weiterentwickelt.
Warum brauchen wir Metaklassen, wenn bereits Konzepte zur Standardisierung übernommen wurden?
Das sind verschiedene Dinge. Metaklassen werden benötigt, um Teile einer Klasse zu definieren, und Konzepte überprüfen anhand von Klassenbeispielen, ob eine Klasse einem bestimmten Muster entspricht. In der Tat arbeiten Metaklassen und Konzepte gut zusammen. Beispielsweise können Sie das Konzept eines Iterators und die Metaklasse eines „typischen Iterators“ definieren, der im Übrigen einige redundante Operationen definiert.