C++ Core Guidelines: Regeln für Anweisungen

Modernes C++  –  7 Kommentare

Bevor ich auf die gut 15 Regeln für Anweisungen eingehe, möchte ich gerne auf die zwei letzten Regeln für Ausdrücke eingehen. Beide Regeln helfen, das Programm vor undefiniertem Verhalten zu bewahren.

Hier sind die zwei verbleibenden Regeln.

Der Grund, den Ausdruck T{e} für die Erzeugung eines Wertes zu verwenden, ist sehr offensichtlich. Im Gegensatz zu den Ausdrücken T(e) oder (T)e, erlaubt T{e} keine verengende Konvertierung (narrowing conversion). Verengende Konvertierung ist eine Konvertierung unter Verlust der Datengenauigkeit. Ich nehme an, meist ist das nicht im Sinne des Autors. Hier ist das Beispiel der Guidelines.

void use(char ch, double d, char* p, long long lng){
int x1 = int{ch}; // OK, but redundant
int x2 = int{d}; // error: double->int narrowing;
// use a cast if you need to
int x3 = int{p}; // error: pointer to->int;
// use a reinterpret_cast if you really need to
int x4 = int{lng}; // error: long long->int narrowing;
// use a cast if you need to (1)

int y1 = int(ch); // OK, but redundant
int y2 = int(d); // bad: double->int narrowing; use a cast if you need to
int y3 = int(p); // bad: pointer to->int;
// use a reinterpret_cast if you really need to (2)
int y4 = int(lng); // bad: long->int narrowing;
// use a cast if you need to

int z1 = (int)ch; // OK, but redundant
int z2 = (int)d; // bad: double->int narrowing; use a cast if you need to
int z3 = (int)p; // bad: pointer to->int;
// use a reinterpret_cast if you really need to (3)
int z4 = (int)lng; // bad: long long->int narrowing;
// use a cast if you need to
}

Dies ist die Ausgabe der Codezeilen mit dem GCC ohne eine spezielles Flag.


Wenn du genau die Ausgabe der Compilerausgabe studierst, fallen ein paar interessante Punkte ins Auge.

  • Der Ausdruck (1) erzeugt im ersten Codeblock nur eine Warnung. Die zwei vorherigen Ausdrücke jedoch einen Fehler.
  • Nur die Ausdrücke (2) und (3) führen zu einem Fehler. Die weiteren Konvertierungen im zweiten und dritten Codeblock verursachen nicht einmal eine Warnung.

Es gibt eine spezielle Regel, die du beachten solltest, wenn du einen Wert mit dem Ausdruck T(e1, e2) oder T{e1, e2} erzeugst. Was passiert, wenn deine Klasse zwei konkurrierende Konstruktoren besitzt? Einen Konstruktor, der zwei int's annimmt (MyVector(int, int)) und einen anderen Konstruktor, der eine std::initializer_list<int> (MyVector(std::initializer_list<int>) erwartet. Die interessante Frage ist: Führt ein Aufruf MyVector(int, int) oder ein Aufruf MyVector{int, int} zu dem Aufruf des Konstruktors mit zwei int's oder dem mit der std::initializer_list<int>?

// constructionWithBraces.cpp

#include <iostream>

class MyVector{
public:
MyVector(int, int){
std::cout << "MyVector(int, int)" << std::endl;
}
MyVector(std::initializer_list<int>){
std::cout << "MyVector(std::initalizer_list<int>)" << std::endl;
}
};

class MyVector1{
public:
MyVector1(int, int){
std::cout << "MyVector1(int, int)" << std::endl;
}
};

class MyVector2{
public:
MyVector2(int, int){
std::cout << "MyVector2(int, int)" << std::endl;
}
};

int main(){

std::cout << std::endl;

MyVector(1, 2); // (1)
MyVector{1, 2}; // (2)

std::cout << std::endl;

MyVector1{1, 2}; // (3)

std::cout << std::endl;

MyVector2(1, 2); // (4)

std::cout << std::endl;

}

Hier ist die Ausgabe des Programms. Der Aufruf (1) stößt den Konstruktor mit zwei int's an. Der Aufruf (2) stößt den Konstruktor mit den std::initializer_list<int> an. Wenn du MyVector1{1, 2} (3) aufrufst, dient der Konstruktor MyVector(int, int) als eine Art Fallback.

Das gleiche gilt nicht für den Ausdruck (4). In diesem Fall ist der Konstruktor mit der std::initializer_list<int> nicht der Fallback.

Ein Konstruktor, der eine std::initializer_list als Argument annimmt, wird gerne auch Sequenz-Konstruktor genannt.

Ahnst du bereits, warum ich die Klasse in dem Beispiel MyVector genannt habe? Der Grund ist, dass die zwei folgenden Ausdrücke sich vollkommen verschieden verhalten.

std::vector<int> vec(10, 1);  // ten elements with 1
std::vector<int> vec2{10, 1}; // two elements 10 and 1

Die erste Zeile erzeugt einen Vektor von 10 Elementen; die zweite Zeile hingegen einen Vektor mit den Werten 10 und 1.

Lass es mich so formulieren. Falls du einen ungültigen Zeiger wie einen nullptr dereferenzierst, besitzt dein Programm undefiniertes Verhalten. Das ist nicht schön. Der einzige Weg, dieses Verhalten zu verhindern, ist den Zeiger vor seiner Verwendung zu prüfen.

void func(int* p) {
if (p == nullptr) { // do something special
}
int x = *p;
...
}

Wie lässt sich das Problem prinzipiell lösen. Verwende keine nackten Zeiger! Verwende einen Smart Pointer wie std::unique_ptr oder std::shared_ptr oder eine Referenz. Ich habe bereits einen Artikel zu den verschieden Arten von Besitzverhältnissen in modernem C++ geschrieben. Hier sind die Details: C++ Core Guidelines: Regeln für die Ressourcenverwaltung.

Und nun zu etwas ganz anderem.

Die Regeln für Anweisungen sind recht offensichtlich. Daher kann ich mich sehr kurz fassen.

  • Du solltest eine switch-Anweisung einer if-Anweisung vorziehen, falls dies möglich ist (ES.70). Eine switch-Anweisung ist typischerweise lesbarer und kann besser optimiert werden.
  • Das gleiche gilt für eine Range-basierte for-Schleife (ES.71) im Gegensatz zu einer for-Schleife. Zuerst einmal ist eine Range-basierte for-Schleife einfacher zu lesen und zweitens immun gegen das Verzählen oder das Ändern des Schleifenindex während des Schleifendurchlaufs.
  • Wenn du eine offensichtliche Schleifenvariable verwendest, solltest du eine for-Schleife einer while-Anweisung vorziehen (ES.72); falls nicht, solltest du die while-Anweisung vorziehen (ES.73).

Der Ausdruck (1) zeigt ein Beispiel für den Fall, dass eine for-Schleife verwendet werden soll. Der Ausdruck (2) hingegen, falls eine while-Anweisung zum Einsatz kommen sollte.

for (gsl::index i = 0; i < vec.size(); i++) {  // (1)
// do work
}

int events = 0; // (2)
while (wait_for_event()) {
++events;
// ...
}
  • Die Schleifen-Variable sollte direkt in der for-Schleife deklariert werden (ES.74). Das gilt seit C++17 nicht nur für die for-Schleife, sondern auch für die if- oder switch-Anweisung. Hier gibt es die Details: C++17: Was gibts Neues in der Kernsprache?
  • Vermeide do-Anweisungen (ES.75), goto-Anweisungen (ES.76) und minimiere den Einsatz der Anweisungen break und continue in Schleifen (ES.77), denn diese sind schwer zu lesen. Falls etwas schwer zu lesen ist, ist es automatisch fehleranfällig.

Ein paar Regeln für Anweisungen sind noch übrig. Mit diesen wird mein nächster Artikel beginnen. Danach wird es mit dem Regeln zur Arithmetik deutlich spannender.