Herb Sutter's "Writing Good C++14 …By Default" (PDF)



  • Herb Sutter's Vortragsfolien für "Writing Good C++14 …By Default", den er vor kurzem auf der CppCon gehalten hat, sind online (PDF).

    Bei der Gelegenheit möchte ich folgende Tweets eines Zuschauers (@jfbastien) zitieren:

    Bjarne discusses @rustlang during his @CppCon keynote.

    Now @herbsutter mentions @rustlang w.r.t. safety @CppCon. C++ trying to be memory safe[…]

    Das fand ich schon irgendwie cool. Aber jetzt habe ich mir auch Sutters Folien angeguckt und ich muss sagen: Ich bin überrascht. Ich hätte nicht gedacht, dass er die zweite Hälfte seines Vortrags sich nur dem Thema (potentiell) baumelnder Zeiger widmet und einen Satz von Regeln vorlegt, mit dem statische Analyse-Tools mehr Fehler genauso abfangen können, wie es der Rust Compiler schon tut.

    Außer "lifetimes" kommen vorher noch Typsicherheit und "Bounds" zur Sprache. Zum Beispiel empfiehlt er variant<int,string> statt union{} . Bzgl Buffer overflows wird array_view<T> statt Zeigerarithmetik mit T* empfohlen. Das ist ergonomischer und würde wenigstens im Debug-Modus Bounds-Checking erlauben. So etwas wie array_view<T> benutze ich in C++ schon länger selbst. Man hat eben doch recht viel linear im Speicher liegen und da lohnt sich so eine Abstraktion. Mit entsprechenden Konstruktoren (für std::array, std::vector, rohe Arrays) ist das schon praktisch.



  • Cui bono?



  • Ist doch schön, dass C++ versucht mitzuhalten. Mehr statische Überprüfungen zu machen ist jedoch schon lange überfällig. Dafür hätte man sich nicht von Rust wachrütteln lassen müssen.

    Ich habe keinen der Vorträge gesehen, aber die Sutter-Folien lassen eindeutig durchscheinen, dass man sich von Rust inspirieren lässt. array_view entspricht Slice, variant ist Rust- enum , ein sicheres Subset hat Rust auch zuerst eingeführt.

    Etwas seltsam sind da so Statements wie "don't deref * to a deleted object". Ach was, das ist ja etwas ganz neues?! Hat man dafür irgendwelche Lösungen (wie Rust) oder sollte hier nur das Thema Lifetimes irgendwie abgehakt werden?



  • TyRoXx schrieb:

    Ich habe keinen der Vorträge gesehen, aber die Sutter-Folien lassen eindeutig durchscheinen, dass man sich von Rust inspirieren lässt. array_view entspricht Slice, variant ist Rust- enum , ein sicheres Subset hat Rust auch zuerst eingeführt.

    Naja, ganz so fern liegen diese Dinge jetzt nicht. Das " arrayref "-Klassentemplate habe ich mir schon gebastelt, bevor ich Rust kennengelernt habe. Im LLVM Projekt findest Du z.B. seit 2009 StringRef und seit 2011 ArrayRef . Google hat so etwas auch in ihrer C++ Code Sammlung. Und boost::variant gibt es schon seit 2004. Auf so etwas kann man also auch ohne Rust kommen. Das muss nur noch bekannter werden, damit Leute das vermehrt einsetzen und dann auch davon profitieren können.

    TyRoXx schrieb:

    Etwas seltsam sind da so Statements wie "don't deref * to a deleted object". Ach was, das ist ja etwas ganz neues?!

    Meinst du die rechte Spalte auf Folie 11? Ja, das ist die Krönung. Aber es ist ja nicht ernst gemeint. Es ist zusammen mit Folie 12 ein Witz. Das wird deutlich, wenn man dann Folie 13 sieht. 🙂 Die restlichen 2/3 des Vortrags kümmern sich genau um das Problem.

    TyRoXx schrieb:

    Hat man dafür irgendwelche Lösungen (wie Rust) oder sollte hier nur das Thema Lifetimes irgendwie abgehakt werden?

    Siehe die letzten 2/3 des Vortrags. Das TL;DR ist: Ein statischer Analyzer könnte laut Sutter, wie es auch bei Rust üblich ist, "lifetime elision"-Regeln anwenden, also Standardmuster erkennen, die man nicht mehr annotieren muss. Beim Rest müsste man dann hier und da ein Attribut einbauen, z.B. [[lifetime(this)]] , um damit zu sagen, auf was sich etwas wie z.B. ein Iterator bezieht bzw beziehen muss.

    Es ist eine Ideensammlung und sie sind laut Folie 32 dabei, ein "design paper" fertigzustellen und einen Prototypen vorzubereiten, den man im Winter ausprobieren können soll. Die aktuelle Version des Papiers scheint das hier zu sein.

    Und Stroustrups Keynote ist jetzt auch schon auf YouTube:
    https://www.youtube.com/watch?v=1OEu9C51K2A
    (Laufzeit 1h:40m)



  • krümelkacker schrieb:

    Naja, ganz so fern liegen diese Dinge jetzt nicht. Das " arrayref "-Klassentemplate habe ich mir schon gebastelt, bevor ich Rust kennengelernt habe.

    Diese Ideen sind natürlich nicht nur Rust zu verdanken. Die sind viel älter und haben sich erst in den letzten Jahren weit verbreitet. Da gibt es auch noch Concepts, die ein ähnliches Problem lösen wie Rust-Traits.

    krümelkacker schrieb:

    TyRoXx schrieb:

    Etwas seltsam sind da so Statements wie "don't deref * to a deleted object". Ach was, das ist ja etwas ganz neues?!

    Meinst du die rechte Spalte auf Folie 11? Ja, das ist die Krönung. Aber es ist ja nicht ernst gemeint. Es ist zusammen mit Folie 12 ein Witz.

    Ok, jetzt habe ich das verstanden.
    Trotzdem sehe ich nicht, wie solche Annotationen von Besitzverhältnissen und non-null meine wirklichen Probleme lösen sollen. Ich gebe fast nie Referenzen auf lokale Variablen zurück oder greife nach push_back über alte Referenzen zu. Ich verliere aber oft den Überblick in einem komplexen Programm, in dem nicht-besitzende Referenzen herumgereicht werden. Womöglich noch geteilt zwischen mehreren Threads. Ich sehe bei Sutter nur lokale Hilfestellungen, aber keine Lösungen für größere Zusammenhänge.

    krümelkacker schrieb:

    Siehe die letzten 2/3 des Vortrags. Das TL;DR ist: Ein statischer Analyzer könnte laut Sutter, wie es auch bei Rust üblich ist, "lifetime elision"-Regeln anwenden, also Standardmuster erkennen, die man nicht mehr annotieren muss. Beim Rest müsste man dann hier und da ein Attribut einbauen, z.B. [[lifetime(this)]] , um damit zu sagen, auf was sich etwas wie z.B. ein Iterator bezieht bzw beziehen muss.

    Das ist wieder das alte Thema "a sufficiently smart static analyzer could..". Ich dachte mit so etwas würden sich Informatiker seit Jahrzehnten beschäftigen, ohne dass es auch nur ein Werkzeug in den Mainstream geschafft hätte. Bei altem Schrottcode würde so etwas vermutlich False Positives ohne Ende hageln und neuer Code kann gleich in einer geeigneten Sprache wie Rust oder in sauberem C++ mit überschaubaren Funktionen geschrieben werden.

    Es ist toll, dass man sich hier Gedanken über so ein wichtiges Thema macht. Ich habe allerdings keine riesigen Erwartungen. Der Static Analyzer von Visual Studio bekommt ein paar neue Warnungen rund um Container, unique_ptr und so. Rust ist da schon vielversprechender. Man muss berücksichtigen, dass beim Rust-Compiler noch Änderungen geplant sind, sodass er immer mehr Annotationen aus dem Kontext herleiten können wird.



  • Bzgl. "Sufficiently Smart Compiler": In Rust läuft diese Analyse funktionslokal. Jede Funktion ist separat prüfbar. Wenn in jeder Funktion die Regeln eingehalten werden, werden sie das auch global. Das funktioniert heute schon.

    Wenn man aber funktionslokal solche Dinge prüfen können will, muss auch genug Information lokal vorhanden sein. Einerseits bekommt der Static Analyzer Information über die "Typausdrücke", die Du verwendest, z.B. owner<int*> versus int* . Dem Compiler, der da Maschinencode draus macht, ist das egal, weil owner<int*> nur ein Alias für int* ist. Der Static Analyzer erzwingt aber je nachdem verschiedene Regeln. Andererseits müssen hier und da sehr wahrscheinlich Annotationen vom Programmierer eingeführt werden, damit der Static Analyzer weiß, worauf sich ein(e) Zeiger, eine Referenz, ein Iterator, ein array_view (...) bezieht.

    Ich befürchte aber, dass sich Stroustrup und Sutter hier ein bisschen zu weit aus dem Fenster gelehnt haben. Ich habe gerade nicht den Eindruck, als hätten sie das alles nicht vollständig durchdacht. Aber ich habe bis jetzt dieses Paper auch nur überflogen und nicht gründlich gelesen. Ich hoffe mal, dass das, was sie sich da ausgedacht haben, 100% der baumelnden Zeiger/Referenzen/Iteratoren/ array_view s/... zur Compile-Zeit ausschließen können. Und wenn das am Ende sogar einfacher als Rust handzuhaben ist, dann nur zu. Das wage ich aber zu bezweifeln. Ich tippe mal darauf, dass sie entweder die 100% nicht schaffen oder etwas abliefern, was nicht simpler als Rust in der Hinsicht ist.

    Bzgl Thread-Safety gibt es auch noch große Fragezeichen. In Rust sehe ich auch das als gelöst an.



  • Wo könnte Sutters sufficiently smart static Analyzer hier warnen? Was könnte man hier hinzufügen, um dem Analyzer zu helfen?

    struct S
    {
    	void f()
    	{
    		int &i = m.front();
    		g();
    		i = 23;
    	}
    
    private:
    
    	std::vector<int> m;
    
    	void g()
    	{
    		m.emplace_back(0);
    	}
    };
    

  • Mod

    Man könnte erkennen, dass g() m modifiziert. Man kann auch mit etwas Raffinesse erkennen, dass i eine Referenz auf ein Objekt ist, dessen Lebenszeit davon abhängt, dass m nicht modifiziert wird. Demnach schlussfolgert man, dass i nach dem Aufruf ungültig sein könnte. Das ist prinzipiell machbar, erfordert aber natürlich e.g. eine vollständige Auflistung der Charakteristik aller Memberfunktionen solcher Container, usw.

    Was könnte man hier hinzufügen, um dem Analyzer zu helfen?

    Man könnte dem Analyzer mittels Attributen praktisch alles in den Schoß werfen:

    struct S 
    { 
        void f() 
        { 
            [[depends_on(m)]] int &i = m.front(); 
            g(); 
            i = 23; // error: access to potentially destroyed object
        } 
    
    private: 
    
        std::vector<int> m; 
    
        [[modifies(m)]] void g() // Das sollte ein Analyzer selbst können.
        { 
            m.emplace_back(0); 
        } 
    };
    


  • TyRoXx schrieb:

    Wo könnte Sutters sufficiently smart static Analyzer hier warnen? Was könnte man hier hinzufügen, um dem Analyzer zu helfen?

    struct S
    {
    	void f()
    	{
    		int &i = m.front();
    		g();
    		i = 23;
    	}
    
    private:
    
    	std::vector<int> m;
    
    	void g()
    	{
    		m.emplace_back(0);
    	}
    };
    

    Schönes Beispiel. Wie gesagt, ich bin mir nicht sicher, ob Sutter und co das vollständig durchdacht haben. Aber wenn das hier so ähnlich wie bei Rust laufen soll, dann müsste hier nichts annotiert werden. Der Static Analyzer würde die Funktion f ankreiden, da sich i auf etwas bezieht, was m und damit auch *this gehört, aber f danach versucht, g(); aufzurufen, welches *this als non-const-Elementfunktion ja ändern könnte und damit i invalidieren könnte. Eine funktionslokale Regel, die den Static Analyzer diesen Fehler finden lässt, könnte also so aussehen:

    Hast Du Dir etwas von X "ausgeliehen" (ein int von *this ), darfst Du solange das, worüber Du das komplette X erreichen kannst ( this ), nicht an eine andere Funktion ( g ) weitergegen.

    Und natürlich ist das eine recht pessimistische Regel. Funktionslokale Analysen müssen wahrscheinlich pessimistisch sein, wenn man 100% Speichersicherheit haben will.



  • In Rust sieht das gleiche so aus:

    struct S {
    	m: Vec<i32>
    }
    
    impl S {
    	pub fn f(&mut self) {
    		let i = &mut self.m[0];
    		self.g();
    		*i = 23;
    	}
    
    	fn g(&mut self) {
    		self.m.push(0);
    	}
    }
    

    Wenn man C++ gewohnt ist, ist das ein wenig wortreich auf den ersten Blick. Explizite Mutability ist das aber auf jeden Fall wert. Auch das explizite self erhört IMO eher die Lesbarkeit, weil man nie raten muss, ob der Bezeichner womöglich ein Member ist.

    Am besten ist aber die Fehlermeldung des Compilers, die exakt erklärt, wo das Problem ist:

    src/main.rs:17:3: 17:7 error: cannot borrow `*self` as mutable more than once at a time
    src/main.rs:17 		self.g();
                   		^~~~
    src/main.rs:16:16: 16:22 note: previous borrow of `self.m` occurs here; the mutable borrow prevents subsequent moves, borrows, or modification of `self.m` until the borrow ends
    src/main.rs:16 		let i = &mut self.m[0];
                   		             ^~~~~~
    src/main.rs:19:3: 19:3 note: previous borrow ends here
    src/main.rs:15 	pub fn f(&mut self) {
    src/main.rs:16 		let i = &mut self.m[0];
    src/main.rs:17 		self.g();
    src/main.rs:18 		*i = 23;
    src/main.rs:19 	}
                   	^
    

    Das "warum" steht hier nicht drin, aber das gehört in ein Rust-Tutorial und nicht in Compiler-Output.

    krümelkacker schrieb:

    Hast Du Dir etwas von X "ausgeliehen" (ein int von *this ), darfst Du solange das, worüber Du das komplette X erreichen kannst ( this ), nicht an eine andere Funktion ( g ) weitergegen.

    Gute Idee, aber das ist keine hinreichende Bedingung. Die Regel würde viel zu viele False Positives erzeugen.

    struct S
    {
        void f()
        {
    		m.resize(1);
    		m.reserve(2);
            int &i = m.front();
            g();
            i = 23;
        }
    
    private:
    
        std::vector<int> m;
    
        void g()
        {
            m.emplace_back(0);
        }
    };
    


  • Arcoth schrieb:

    [[modifies(m)]] void g() // Das sollte ein Analyzer selbst können.
        { 
            m.emplace_back(0); 
        } 
    };
    

    Das wäre meiner Ansicht nach keine lokale Analyse mehr. Bei der Prüfung von f sollte der Analyzer nicht wissen müssen, was in g passiert. Das sollte allein von der Signatur her klar sein, was darin passieren kann. Der Benutzer darf natürlich was annotieren. Aber das soll ja nicht überhand nehmen. Wenn der Compiler das selbst heraus finden soll, dann ist die Gefahr, dass der Analyseaufwand explodiert, weil g wieder andere Funktionen aufrufen können, die wieder andere Funktionen aufrufen können und so weiter.

    TyRoXx schrieb:

    Die Regel würde viel zu viele False Positives erzeugen.

    Das meinte ich mit "pessimistisch".

    Mein Vertrauen, Speichersicherheit auf diesem Wege an C++ dranflanschen zu können, ist im Moment nicht so hoch. Die werden damit noch ihren Spaß haben…



  • krümelkacker schrieb:

    Mein Vertrauen, Speichersicherheit auf diesem Wege an C++ dranflanschen zu können, ist im Moment nicht so hoch. Die werden damit noch ihren Spaß haben…

    Bezweifel ich auch, denn bei Rust muss man ja mit ziemlichen Einschraenkungen leben, damit der Compiler den Code durchwinkt. Gerade was mehrfachen Zugriff auf unabhaengige Member von self betrifft, muss man haeufig mit type destructuring, mem::replace oder zusaetzlichen Scopes hantieren.

    Man koennte aber zumindest die Sicherheit erhoehen. Gerade array_view sollte mal in den Standard kommen, denn es nervt mich schon lange, dass es keine andere standadisierte Moeglichkeit zu pointer-Laenge-Paar und iterator-paar gibt. Natuerlich geht selbst schreiben immer, aber im Standard erhoeht es doch ziemlich die konsistenz bei mehreren libraries.



  • Das Video von Herb's Vortrag ist jetzt auch online:
    https://www.youtube.com/watch?v=hEx5DNLWGgA



  • TyRoXx schrieb:

    In Rust sieht das gleiche so aus:

    Kannst du bitte mal deinen Codeabschnitt (Compilerausgaben) auf ca. 80 Zeichen kürzen? Kein Mensch kann die erste Threadseite lesen. 😡

    Danke!


  • Mod

    Artchi schrieb:

    Kannst du bitte mal deinen Codeabschnitt (Compilerausgaben) auf ca. 80 Zeichen kürzen? Kein Mensch kann die erste Threadseite lesen. 😡

    Doch. Was für einen Browser und welche Auflösung hast du denn?



  • Artchi schrieb:

    TyRoXx schrieb:

    In Rust sieht das gleiche so aus:

    Kannst du bitte mal deinen Codeabschnitt (Compilerausgaben) auf ca. 80 Zeichen kürzen? Kein Mensch kann die erste Threadseite lesen. 😡

    Danke!

    Hab auch keine Probleme (Chrome).

    MfG SideWinder



  • krümelkacker schrieb:

    Das wäre meiner Ansicht nach keine lokale Analyse mehr. Bei der Prüfung von f sollte der Analyzer nicht wissen müssen, was in g passiert. Das sollte allein von der Signatur her klar sein, was darin passieren kann. Der Benutzer darf natürlich was annotieren. Aber das soll ja nicht überhand nehmen. Wenn der Compiler das selbst heraus finden soll, dann ist die Gefahr, dass der Analyseaufwand explodiert, weil g wieder andere Funktionen aufrufen können, die wieder andere Funktionen aufrufen können und so weiter.

    Das erscheint auf den 1. Blick sehr aufwendig.
    Ist es aber mMn. nach nicht.
    Der Compiler muss sowieso alle Funktionen (mindestens) 1x compilieren die irgendwo verwendet werden.
    Wenn er das "bottom up" macht, dann kann er alle nötigen "Annotationen" in seinen internen Datenstrukturen selbst vornehmen. Wenn man die nötigen Infos mit im .obj File abspeichert, dann kann man auch weiterhin inkrementell compilieren bzw. ist nichtmal vom Source abhängig.

    Ein kleines Problem dabei sind natürlich rekursive Funktionen (weil es da halt kein "bottom up" gibt). Das grösste Problem sehe ich aber bei Funktionszeiger/Funktoren. Denn spätestens da kommt man nicht mehr mit dem Speichern von Annotationen zu einzelnen Funktionen bzw. Typen aus. std::function<T& (U&)> kann halt alle möglichen Abhängigkeiten haben und alles mögliche modifizieren.

    Bzw. es reicht schon std::function<void()> oder ein ganz normaler Funktionszeiger, gibt ja schliesslich Lambdas bzw. bind .



  • Nachdem ich mir das Vortragsvideo angeschaut und das dazugehörige Arbeitspapier durchgelesen habe, kann ich das nur nochmal bestätigen, wie ich mir die Analyse vorgestellt habe:

    struct S
    {
    	void f()
    	{
    		int &i = m.front(); // pset(i) = { this, this' }
    		                    // bedeutet: i bezieht sich auf etwas von
    		                    // this oder was this über eine (')
    		                    // Indirektionen gehört.
    
    		g();                // g bekommt ein nicht-const this und
    		                    // kann damit alle Zeiger, die this'
    		                    // oder this'' im pset haben invalidieren.
    		                    // => pset(i) = { invalid }
    
    		i = 23;             // nicht zulässig, da pset(i) = { invalid }
    	}
    
    private:
    
    	std::vector<int> m;
    
    	void g()
    	{
    		m.emplace_back(0);
    	}
    };
    

    "pset" heißt "pointer-to set", gibt also an, wo der Zeiger hinzeigen kann und wer der Besitzer davon ist. So eine Menge kann { invalid } sein, wenn man weiß, dass der Zeiger ungültig ist oder sein kann. Wenn die Menge nicht { invalid } ist, dann enthält sie mindestens eines der folgenden: null, static, X, X', X'' (wobei X ein beliebiger Besitzer ist). Mit einem if (ptr) {...} wird für den then-Block ggf null aus dem pset rausgeschmissen. Beim Dereferenzieren wird das pset geprüft und ggf mit einer Warnung reagiert.

    So ganz ausgekocht ist das noch nicht. Der Regelsatz wird im Papier eher informell erklärt. Das Schlüsselwort mutable wird hier wohl auch noch ein bisschen Ärger machen.



  • Arcoth schrieb:

    Artchi schrieb:

    Kannst du bitte mal deinen Codeabschnitt (Compilerausgaben) auf ca. 80 Zeichen kürzen? Kein Mensch kann die erste Threadseite lesen. 😡

    Doch. Was für einen Browser und welche Auflösung hast du denn?

    Firefox und 1600x1200 Pixel.

    Aber ist klar... ist zuviel verlangt mal nen Return in der einen zu langen Zeile rein zu hauen... 🙄


Log in to reply