C++ Core Guidelines: Mehr Nichtregeln und Mythen

Modernes C++  –  166 Kommentare

Nichtregeln und Mythen zu entlarven ist ein mühsamer aber notwendiger Job. Das Ziel hingegen ist offensichtlich: Setze die mächtige Programmiersprache C++ richtig ein.

Ich kann es mir nicht verkneifen: Mein Familienname qualifiziert mich in besonderer Weise, über das Entlarven der Mythen zu schreiben. Hier sind die Regeln der C++ Core Guidelines, mit denen sich der Artikel heute beschäftigt.

NR.5: Don’t: Don’t do substantive work in a constructor; instead use two-phase initialization

Vollkommen klar, dies ist der Job des Konstruktors: Nachdem er fertig ist, soll das Objekt vollständig initialisiert sein. Genau aus diesem Grund steht das Beispiel aus den Guidelines für schlechten Code:

class Picture
{
int mx;
int my;
char * data;
public:
Picture(int x, int y)
{
mx = x,
my = y;
data = nullptr;
}

~Picture()
{
Cleanup();
}

bool Init()
{
// invariant checks
if (mx <= 0 || my <= 0) {
return false;
}
if (data) {
return false;
}
data = (char*) malloc(x*y*sizeof(int));
return data != nullptr;
}

void Cleanup() // (2)
{
if (data) free(data);
data = nullptr;
}
};

Picture picture(100, 0); // not ready-to-use picture here
// this will fail.. // (1)
if (!picture.Init()) {
puts("Error, invalid picture");
}
// now have a invalid picture object instance.

picture(100, 0) ist nicht vollständig initialisiert, sodass alle Aktionen auf picture in Zeile 1 auf einem ungültigen picture basieren. Die Lösung des Problems ist so einfach wie effektiv: Stecke die Initialisierung in den Konstruktor:

class Picture
{
size_t mx;
size_t my;
vector<char> data;

static size_t check_size(size_t s)
{
// invariant check
Expects(s > 0);
return s;
}

public:
// even more better would be a class for a 2D Size as one single parameter
Picture(size_t x, size_t y)
: mx(check_size(x))
, my(check_size(y))
// now we know x and y have a valid size
, data(mx * my * sizeof(int)) // will throw std::bad_alloc on error
{
// picture is ready-to-use
}
// compiler generated dtor does the job. (also see C.21)
};

Zusätzlich ist data in dem zweiten Beispiel ein std::vector und kein nackter Zeiger. Das heißt, dass die Cleanup-Funktion (Zeile 2) im ersten Beispiel nicht mehr notwendig ist, da der Compiler automatisch aufräumt. Dank der statischen Funktion check_size kann der Konstruktor seine Argumente prüfen. Dies sind aber noch nicht alle Vorteile, die uns modernes C++ beschert.

Oft wird ein Konstruktor nur dazu verwendet, um das Defaultverhalten der Objekte zu setzen. Tue es nicht. Setze das Defaultverhalten der Objekte im Klassenkörper. Die folgenden Klassen Widget und WidgetImpro bringen dieses praktische Feature auf den Punkt:

// classMemberInitialiserWidget.cpp

#include <iostream>

class Widget{
public:
Widget(): width(640), height(480), frame(false), visible(true) {}
explicit Widget(int w): width(w), height(getHeight(w)), frame(false), visible(true){}
Widget(int w, int h): width(w), height(h), frame(false), visible(true){}

void show(){ std::cout << std::boolalpha << width << "x" << height
<< ", frame: " << frame << ", visible: " << visible
<< std::endl;
}
private:
int getHeight(int w){ return w*3/4; }
int width;
int height;
bool frame;
bool visible;
};

class WidgetImpro{
public:
WidgetImpro(){}
explicit WidgetImpro(int w): width(w), height(getHeight(w)){}
WidgetImpro(int w, int h): width(w), height(h){}

void show(){ std::cout << std::boolalpha << width << "x" << height
<< ", frame: " << frame << ", visible: " << visible
<< std::endl;
}

private:
int getHeight(int w){ return w * 3 / 4; }
int width = 640;
int height = 480;
bool frame = false;
bool visible = true;
};


int main(){

std::cout << std::endl;

Widget wVGA;
Widget wSVGA(800);
Widget wHD(1280, 720);

wVGA.show();
wSVGA.show();
wHD.show();

std::cout << std::endl;

WidgetImpro wImproVGA;
WidgetImpro wImproSVGA(800);
WidgetImpro wImproHD(1280, 720);

wImproVGA.show();
wImproSVGA.show();
wImproHD.show();

std::cout << std::endl;

}

Beide Klassen verhalten sich identisch.

Der Unterschied der beiden Klassen ist es, dass die Konstruktoren der Klasse WidgetImpro viel einfacher zu verwenden und zu erweitern sind. Wenn du zum Beispiel eine neue Variable zu beiden Klassen hinzufügst, musst du im Falle der Klasse WidgetImpro nur eine Codestelle editieren, hingegen ist im Falle der Klasse Widget jeder Konstruktor betroffen. Dieses Bild habe ich im Kopf, wenn ich eine Klasse entwerfe: Definiere das Defaultverhalten jedes Objekts im Klassenkörper. Verwende explizite Konstruktoren nur, um dieses Defaultverhalten zu ändern.

Fertig? Nein

Oft kommt eine init-Funktion zum Einsatz, um gemeinsame Initialisierungsaufgaben und Prüfungen der Argumente in einer Funktion zu kapseln. Damit setzt du das wichtige DRY-Prinzip (Don't Repeat Yourself) richtig um. Damit brichst du aber auch automatisch ein weiteres wichtiges Prinzip, dass ein Objekt nach dem Aufruf des Konstruktors einsatzbereit sein soll. Wie lässt sich diese Zwickmühle lösen? Einfach, denn seit C++11 lassen sich Konstruktoraufrufe delegieren. Das heißt, stecke die gemeinsame Initialisierung und Prüfung der Argumente in einen besonders smarten Konstruktor und verwende die anderen Konstruktoren als Aufrufkonstruktoren, die ihre Aufgabe an den besonders smarten Konstruktor delegieren. Hier ist meine Idee in Code gegossen:

// constructorDelegation.cpp

#include <cmath>
#include <iostream>

class Degree{
public:
explicit Degree(int deg){ // (2)
degree = deg % 360;
if (degree < 0) degree += 360;
}

Degree() = default;
// (3)
explicit Degree(double deg):Degree(static_cast<int>(ceil(deg))) {}

int getDegree() const { return degree; }

private:
int degree{}; // (1)
};

int main(){

std::cout << std::endl;

Degree degree;
Degree degree10(10);
Degree degree45(45);
Degree degreeMinus315(-315);
Degree degree405(405);
Degree degree44(44.45);

std::cout << "Degree(): " << degree.getDegree() << std::endl;
std::cout << "Degree(10): " << degree10.getDegree() << std::endl;
std::cout << "Degree(45): " << degree45.getDegree() << std::endl;
std::cout << "Degree(-315): " << degreeMinus315.getDegree() << std::endl;
std::cout << "Degree(405): " << degree405.getDegree() << std::endl;
std::cout << "Degree(44.45): " << degree44.getDegree() << std::endl;

std::cout << std::endl;

}

Der Ausdruck int degree{} (Zeile 1) setzt degree auf 0. Der Konstruktor in Zeile 1 ist ziemlich smart. Er rechnet jedes Grad auf den Einheitskreis um. Der Konstruktor, der ein double annimmt, wendet diesen an. Der Vollständigkeit halber ist hier die Ausgabe des Programms.

NR.6: Don’t: Place all cleanup actions at the end of a function and goto exit

Klar, der folgende Code der Guidelines lässt sich einfach verbessern:

void do_something(int n)
{
if (n < 100) goto exit;
// ...
int* p = (int*) malloc(n);
// ...
exit:
free(p);
}

Nebenbei bemerkt, hast du den Fehler gefunden? Der Sprung goto exit überspringt die Definition des Zeigers p.

Häufig habe ich C-Code gesehen, der dieser typischen Struktur folgte:

// lifecycle.c

#include <stdio.h>

void initDevice(const char* mess){
printf("\n\nINIT: %s\n",mess);
}

void work(const char* mess){
printf("WORKING: %s",mess);
}

void shutDownDevice(const char* mess){
printf("\nSHUT DOWN: %s\n\n",mess);
}

int main(void){

initDevice("DEVICE 1");
work("DEVICE1");
{
initDevice("DEVICE 2");
work("DEVICE2");
shutDownDevice("DEVICE 2");
}
work("DEVICE 1");
shutDownDevice("DEVICE 1");

return 0;

}

Dies ist ein typischer, aber leider auch extrem fehleranfälliger Code. Jeder Einsatz des Devices besteht aus drei Schritten: Initialisierung, Verwendung und Freigabe des Devices. Natürlich ist dies ein Job für RAII:

// lifecycle.cpp

#include <iostream>
#include <string>

class Device{
private:
const std::string resource;
public:
Device(const std::string& res):resource(res){
std::cout << "\nINIT: " << resource << ".\n";
}
void work() const {
std::cout << "WORKING: " << resource << std::endl;
}
~Device(){
std::cout << "SHUT DOWN: "<< resource << ".\n\n";
}
};

int main(){


Device resGuard1{"DEVICE 1"};
resGuard1.work();

{
Device resGuard2{"DEVICE 2"};
resGuard2.work();
}
resGuard1.work();

}

Initialisiere die Ressource im Konstruktor und gib sie im Destruktor wieder frei. Einerseits ist es damit unmöglich, die Ressource nicht zu initialisieren, andererseits kümmert sich der Compiler darum, die Ressource wieder aufzuräumen. Beide Programme besitzen die gleiche Ausgabe.

Mein Artikel C++ Core Guidelines: Wenn RAII versagt enthält mehr Informationen zu RAII.

Weitere Mythen

Ich bin mir sicher, das ist noch nicht das Ende des Kampfes gegen Nichtregeln und Mythen in modernem C++. Sicher kennst du noch weitere Mythen. Schreibe mir daher eine E-Mail an rainer.grimm@modernescpp.de oder einen Kommentar. Beschreibe darin den Mythos und biete, wenn möglich, eine Lösung für ihn an. Ich werde versuchen die Kommentare in einen Artikel zu gießen und, wenn du es willst, dich namentlich zu nennen. Nun bin ich nur noch gespannt.