Mehr Mythen der Blog-Leser

Bei den weiteren Mythen geht es um Funktionsparameter, die Initialisierung von Klassenelementen und Zeiger versus Referenzen.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 8 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Heute schließe ich meine Geschichte zu den Mythen meiner Blog-Leser ab. Bei den aktuellen Mythen geht es um Funktionsparameter, die Initialisierung von Klassenelementen und Zeiger versus Referenzen.

Wenn eine Funktion ihre Parameter annimmt und diese nicht verändern soll, gibt es zwei Optionen:

  • Sie nimmt die Parameter by Value (kopieren) an.
  • Sie nimmt die Parameter als konstante Referenz an.

Dies war die Korrektheitsperspektive. Doch was lässt sich über die Performanz sagen? Die C++ Core Guidelines geben auf diese Frage eine Antwort. Das folgende Beispiel bringt die Schwierigkeit auf den Punkt:

void f1(const string& s);  // OK: pass by reference to const; always cheap

void f2(string s); // bad: potentially expensive

void f3(int x); // OK: Unbeatable

void f4(const int& x); // bad: overhead on access in f4()

Wohl basierend auf Erfahrung stellt die Guidelines die folgende Daumenregel auf.

  • Du sollst einen Parameter p per konstanter Referenz annehmen, wenn gilt: sizeof(p) > 4 * sizeof(int)
  • Du sollst einen Parameter p kopieren, wenn gilt: sizeof(p) < 3 * sizeof(int)

Damit stellt sich nur noch die Frage, wie groß die Datentypen sind. Das Programm sizeofArithmeticTypes.cpp gibt die Antwort für die arithmetischen Datentypen:

// sizeofArithmeticTypes.cpp

#include <iostream>

int main(){

std::cout << std::endl;

std::cout << "sizeof(void*): " << sizeof(void*) << std::endl;

std::cout << std::endl;

std::cout << "sizeof(5): " << sizeof(5) << std::endl;
std::cout << "sizeof(5l): " << sizeof(5l) << std::endl;
std::cout << "sizeof(5ll): " << sizeof(5ll) << std::endl;

std::cout << std::endl;

std::cout << "sizeof(5.5f): " << sizeof(5.5f) << std::endl;
std::cout << "sizeof(5.5): " << sizeof(5.5) << std::endl;
std::cout << "sizeof(5.5l): " << sizeof(5.5l) << std::endl;

std::cout << std::endl;

}

sizeof(void*) gibt zurück, ob es sich um ein 32-Bit- oder 64-Bit-System handelt. Dank des Online-Compilers rextester lässt sich das Programm auf GCC, Clang und cl.exe (Windows) ausführen. Hier sind die Zahlen für die 64-Bit-Systeme:

Die Zahlen von cl.exe unterscheiden sich deutlich von den von GCC und Clang. Ein long int besitzt 4 Bytes und ein long double 8 Bytes auf Windows. Im Gegensatz dazu sind beide Datentypen auf dem GCC und Clang doppelt so groß.

Zuerst muss ich klären, für was die Initialisierung und die Zuweisung in einem Konstruktor steht:

class Good{  
int i;
public:
Good(int i_): i{i_ }{}
};

class Bad{
int i;
public:
Bad(int i_): { i = i_ ; }
};

Die Klasse Good verwendet Initialisierung, aber die Klasse Bad Zuweisung. Dies sind die Konsequenzen:

  • Die Variable i wird in der Klasse Good direkt initialisiert.
  • Die Variable i wird in der Klasse Bad zuerst Default-konstruiert und danach nochmals überschrieben.

Die Konstruktorinitialisierung ist einerseits schneller, aber auch andererseits bei Konstanten, Referenzen, und Mitglieder einer Klasse, die sich nicht Default-konstruieren lassen, möglich:

// constructorAssignment.cpp

struct NoDefault{
NoDefault(int){};
};

class Bad{
const int constInt;
int& refToInt;
NoDefault noDefault;
public:
Bad(int i, int& iRef){
constInt = i;
refToInt = iRef;
}
// Bad(int i, int& iRef): constInt(i), refToInt(iRef), noDefault{i} {}
};

int main(){

int i = 10;
int& j = i;

Bad bad(i, j);

}

Wenn ich versuche, das Programm zu übersetzen, erhalte ich drei Fehlermeldungen:

  1. constInt ist nicht initialisiert und kann nicht im Konstruktor überschrieben werden.
  2. refToInt ist nicht initialisiert.
  3. Die Klasse NoDefault besitzt keinen Default-Konstruktor, da ich einen Konstruktor für int bereits implementiert habe. Wenn du einen Konstruktor für eine Klasse implementierst, erzeugt der Compiler nicht automatisch einen Default-Konstruktor.

In der zweiten, erfolgreichen Übersetzung verwende ich den auskommentierten Konstruktor, der die Initialisierung der Zuweisung vorzieht.

In dem Beispiel kamen aus gutem Grunde Referenz statt nackten Zeiger zum Einsatz.

Motiviert durch den Kommentar von Thargon110 bin ich jetzt dogmatisch: NNN. Was? Natürlich meinte ich No Naked New. Aus der Perspektive des Applikationsentwicklers betrachtet gibt es keinen Grund, nackte Zeiger einzusetzen. Wenn du zeigerartige Semantik benötigst, stecke deinen nackten Zeiger in einen Smart Pointer – dafür genau steht NNN – und das Problem ist gelöst.

Im Wesentlichen hat C++11 std::unique_ptr für die exklusiven und std::shared_ptr für die geteilten Besitzverhältnisse. Das heißt, wenn ein std::shared_ptr kopierst wird, wird ein interner Referenzzähler inkrementiert und wenn ein std::shared_ptr gelöscht wird, wird ein interner Referenzzähler dekrementiert. Besitzverhältnisse stehen dafür, dass Smart Pointer auf den zugrundeliegenden Speicher aufpassen und ihn freigeben, wenn er nicht mehr benötigt wird. Der Speicher wird im Falle des std::shared_ptr nicht mehr benötigt, wenn der Referenzzähler den Wert 0 besitzt.

Damit gibt es mit modernem C++ keine Speicherleaks mehr. Ich höre schon deine Klagen und freue mich darauf, sie zu widerlegen:

  • Zyklen von std::shared_ptr können Speicherleaks verursachen, da der Referenzzähler nie den Wert 0 erreicht. Stimmt, aber ein std:.weak_ptr hilft, zyklische Referenzen von std::shared_ptr zu brechen: std::weak_ptr.
  • Der std::shared_ptr besitzt Verwaltungsaufwand und ist damit teurer als ein nackter Zeiger. Stimmt, darum sollte std::unique_ptr die erste Wahl sein: std::unique_ptr.
  • Ein std::unique_ptr ist nicht sehr praktisch, denn er kann nicht kopiert werden. Stimmt, ein std::unique_ptr kann aber verschoben werden.

Gerade die letzte Klage ist sehr hartnäckig. Daher wird mir das kleine Beispiel wertvolle Dienste leisten:

// moveUniquePtr.cpp

#include <algorithm>
#include <iostream>
#include <memory>
#include <utility>
#include <vector>

void takeUniquePtr(std::unique_ptr<int> uniqPtr){ // (1)
std::cout << "*uniqPtr: " << *uniqPtr << std::endl;
}

int main(){

std::cout << std::endl;

auto uniqPtr1 = std::make_unique<int>(2014);

takeUniquePtr(std::move(uniqPtr1)); // (1)

auto uniqPtr2 = std::make_unique<int>(2017);
auto uniqPtr3 = std::make_unique<int>(2020);
auto uniqPtr4 = std::make_unique<int>(2023);

std::vector<std::unique_ptr<int>> vecUniqPtr;
vecUniqPtr.push_back(std::move(uniqPtr2)); // (2)
vecUniqPtr.push_back(std::move(uniqPtr3)); // (2)
vecUniqPtr.push_back(std::move(uniqPtr4)); // (2)

std::cout << std::endl;

std::for_each(vecUniqPtr.begin(), vecUniqPtr.end(), // (3)
[](std::unique_ptr<int>& uniqPtr){ std::cout << *uniqPtr << std::endl; } );

std::cout << std::endl;

}

Die Funktion takeUniquePtr (Zeile 1) nimmt einen std::unique_ptr per Value an. Die zentrale Beobachtung ist es, dass dazu der std::unique_ptr verschoben werden muss. Diese zentrale Beobachtung gilt auch für die Argumente des std::vector<std::unique_ptr<int>> (Zeile 2). std::vector wie alle Container der Standard Template Library (STL) will seine Elemente besitzen. Ein Kopieren eines std::unique_ptr ist aber nicht möglich. std::move löst dieses Problem. Du kannst selbst einen Algorithmus der STL wie std::for_each auf einen std::vector<std::unique_ptr<int>> (Zeile 3) anwenden, wenn dieser keine Copy-Semantik verwendet.

Am Ende will ich noch auf den initialen Punkt von Thargon110 reagieren. Ich muss zugeben, dass diese Regel deutlich höhere Relevanz in klassischem C++ ohne Smart Pointer besitzt, denn Smart Pointer sind im Gegensatz zu nackten Zeigern Besitzer.

Verwende eine Referenz anstelle eines Zeigers, denn eine Referenz besitzt immer einen Wert. Ermüdende Prüfungen wie die folgende sind daher nicht mehr notwendig mit Referenzen:

if(!ptr){
std::cout << "Something went terrible wrong" << std::endl;
return;
}
std::cout << "All fine" << std::endl;

Zusätzlich kannst du die Prüfung nicht vergessen. Referenzen verhalten sich wie konstante Zeiger.

Die C++ Core Guidelines definieren Profile. Profile sind Teilmengen der Regeln. Profile gibt es für Typ-Sicherheit, Bounds-Sicherheit (Prüfung der Containergrenzen) und Lebenszeit-Sicherheit.

Auf meinem deutschen und meinem englischen Blog findet gerade die Wahl zum nächsten PDF-Päckchen statt. Hier sind die Links zur Wahl:



()