C++ Core Guidelines: Regeln zu Don'ts

Dieser Artikel geht auf einige Don'ts ein. Dies sind vor allem die zwei wichtigsten Regeln des Artikels: Setze std::move nicht unüberlegt ein und wende kein Slicing an. Und los geht's.

In Pocket speichern vorlesen Druckansicht 2 Kommentare lesen
Lesezeit: 8 Min.
Von
  • Rainer Grimm

Dieser Artikel geht auf einige Don'ts ein. Dies sind vor allem die zwei wichtigsten Regeln des Artikels: Setze std::move nicht unüberlegt ein und wende kein Slicing an. Und los geht's.

Hier sind alle Regeln für heute im Schnelldurchlauf.

Die erste Regel ist ein verkleidetes Don't.

ES.56: Write std::move() only when you need to explicitly move an object to another scope

Meist ist es nicht notwendig, explizit std::move aufzurufen. Der Compiler wendet automatisch Move-Semantik an, wenn der Ursprung einer Operation ein Rvalue ist. Ein Rvalue ist ein Objekt ohne Identität. Typischerweise besitzt es keinen Namen, und seine Adresse lässt sich nicht bestimmen. Die verbleibenden Werte sind Lvalues.

Wird eine std::move-Operation auf einen Lvalue angewandt, ist das Ergebnis meist ein leeres Objekt. Der Lvalue ist danach in dem sogenannten moved-from-Zustand. Das heißt, dass dieser gültig, aber in einem nicht genauer spezifizierten Zustand ist. Klingt seltsam? Genau! Daher gilt es nur eine Regel zu beachten: Nachdem du auf einen Lvalue wie in std::move(source) Move-Semantik angewandt hast, sind keine Annahmen über den Zustand des Objekts source mehr zulässig. Sein Wert muss neu gesetzt werden.

Stopp. Ich war zu schnell. Die Regel ES.56 lautet "you should only use std::move if you want to move an object to another scope". Ein klassischer Anwendungsfall für die Move-Semantik sind Objekte, die nicht kopiert, aber verschoben werden können. Zum Beispiel will ich in dem folgenden Beispiel einen std::promise in einen anderen Thread verschieben.

// moveExplicit.cpp

#include <future>
#include <iostream>
#include <thread>
#include <utility>

void product(std::promise<int>&& intPromise, int a, int b){ // (1)
intPromise.set_value(a * b);
}

int main(){

int a= 20;
int b= 10;

// define the promises
std::promise<int> prodPromise;

// get the futures
std::future<int> prodResult= prodPromise.get_future();

// calculate the result in a separat thread
std::thread prodThread(product,std::move(prodPromise), a, b); // (2)

// get the result
std::cout << "20 * 10 = " << prodResult.get() << std::endl; // 200

prodThread.join();

} // undefined behaviour

Die Funktion product (1) erhält ihren std::promise per Rvalue-Referenz. Ein Promise kann nicht kopiert, aber verschoben werden. Daher ist in diesem Fall std::move notwendig um den Promise in den neu erzeugten Kinder-Thread zu verschieben.

Jetzt kommt aber endlichen das dicke Don't: Wende keine std::move in return-Anweisungen an:

vector<int> make_vector() {
vector<int> result;
// ... load result with data
return std::move(result); // bad; just write "return result;"
}

Vertraue deinem Optimierer! Falls das Objekt per Kopie zurückgegeben wird, wird der Optimierer seinen Job tun. Dies war eine Best Practice bis C++14. Das ist verbindlich mit C++17 und wird guaranteed copy elision genannt. Auch wenn diese Technik den Name "copy" enthält, optimiert der Compiler mit C++11 auch Verschiebeoperationen weg.

RVO steht für Return Value Optimisation und bedeutet, dass der Compiler unnötige Copy-Operation entfernen kann. Was bisher ein Optimierungsschritt war, muss der Compiler in C++17 zusichern:

MyType func(){
return MyType{}; // (1) no copy with C++17
}
MyType myType = func(); // (2) no copy with C++17

Zwei unnötige Copy-Operationen können in den paar Zeilen stattfinden. Der erste in Ausdruck (1) und die zweite in Ausdruck (2). Mit C++17 ist das nicht mehr zulässig.

Falls der Rückgabewert einen Namen besitzt, heißt diese Technik NRVO. Das Akronym steht für Named Return Value Optimization.

MyType func(){
MyType myVal;
return myVal; // (1) one copy allowed
}
MyType myType = func(); // (2) no copy with C++17

Der feine Unterschied ist, das der Compiler mit C++17 den Wert myValue (1) kopieren darf. Hingegen findet in Ausdruck (2) definitiv kein Kopieren statt.

ES.60: Avoid new and delete outside resource management functions

Jetzt kann ich mich kurz halten: Wende kein new und delete in Applikationscode an. Für die Regel gibt es eine einfache Erinnerungshilfe:"No naked new!"

ES.61: Delete arrays using delete[] and non-arrays using delete

Hier ist die Begründung für die letzte Regel. Ressourcenmanagement im Applikationscode ist fehleranfällig:

void f(int n)
{
auto p = new X[n]; // n default constructed Xs
// ...
delete p; // error: just delete the object p, rather than delete the array p[]
}

Die Guidelines schreiben im Kommentar: "just delete the object p". Das möchte ich gerne noch dramatischer formulieren. Der Code besitzt undefiniertes Verhalten.

ES.63: Don't slice

Zuerst einmal. Was ist Slicing? Slicing bedeutet: Du kopierst ein Objekt während einer Zuweisung oder Initialisierung und bekommst nur ein Teil des Objekts zurück. Los geht's mit einem einfachen Beispiel:

// slice.cpp

struct Base {
int base{1998};
}

struct Derived : Base {
int derived{2011};
}

void needB(Base b){
// ...
}

int main(){

Derived d;
Base b = d; // (1)
Base b2(d); // (2)
needB(d); // (3)

}

Die Zeilen (1), (2) und (3) besitzen alle den gleichen Effekt: Der Derived-Anteil von d wird entfernt. Ich nehme an, dass war nicht im Sinne des Autors.

Ich erwähnte es bereits in der Ankündigung zu diesem Artikel, dass Splicing einer der dunkelsten Ecken von C++ ist. Jetzt wird es dunkel:

// sliceVirtuality.cpp

#include <iostream>
#include <string>

struct Base {
virtual std::string getName() const { // (1)
return "Base";
}
};

struct Derived : Base {
std::string getName() const override { // (2)
return "Derived";
}
};

int main(){

std::cout << std::endl;

Base b;
std::cout << "b.getName(): " << b.getName() << std::endl; // (3)

Derived d;
std::cout << "d.getName(): " << d.getName() << std::endl; // (4)

Base b1 = d;
std::cout << "b1.getName(): " << b1.getName() << std::endl; // (5)

Base& b2 = d;
std::cout << "b2.getName(): " << b2.getName() << std::endl; // (6)

Base* b3 = new Derived;
std::cout << "b3->getName(): " << b3->getName() << std::endl; // (7)

std::cout << std::endl;

}

In dem Beispiel erzeuge ich eine kleine Klassenhierarchie, bestehend aus einer Base- und einer Derived-Klasse. Jedes Objekt der Klassenhierarchie soll seinen Namen zurückgeben. Dazu ist notwendig, die Methode getName (2) virtuell zu deklarieren und im Ausdruck (2) zu überschreiben. Jetzt unterstützt meine Klassenhierarchie Polymorphie. Das heißt, ich kann ein abgeleitetes Objekt mittels Referenz (6) oder Zeiger auf ein Objekt der Basisklasse annehmen. Unter der Decke ist das Objekt vom Typ Derived.

Dies gilt aber nicht, wenn ich nur Derived D nach Base b1 kopiere (5). In diesem Fall schlägt Slicing zu, und ich erhalte eine Base-Objekt unter der Decke. Im Falle des Kopierens wird der deklarierte oder statische Typ verwendet. Falls jedoch eine Indirektion wie eine Referenz oder ein Zeiger zum Einsatz kommt, wird der tatsächliche oder dynamische Typ verwendet. Oft spricht die C++-Community schlicht von der frühen versus der späten Bindung.

Es ist relativ einfach, die Regel im Kopf zu behalten: Falls sich Instanzen einer Klasse polymporph verhalten sollen, muss die Klasse zumindest eine virtuelle Methode deklarieren oder erben. Darüber hinaus müssen Instanzen dieser Klasse mit einer Indirektion wie einer Referenz oder einem Zeiger verwendet werden.

Natürlich gibt es auch ein Heilmittel gegen Slicing: Implementiere eine virtuelle clone-Funktion. Hier gibt es die Details dazu: C++ Core Guidelines: Regeln für das Kopieren und Verschieben.

In diesem Artikel ging es nur um Son'ts. Der nächste Artikel wird mit Do's beginnen. Verwende geschweifte Klammern für die Initialisierung von Daten.

Ich freue mich darauf, Ihnen von 13. bis 15. März modernes C++ in Theorie und Praxis genau vorstellen zu dürfen. Die Schulung findet definitiv statt und es sind noch wenige Plätze frei. ()