C++ Core Guidelines: Mehr Regeln zur Performanz

Modernes C++  –  11 Kommentare

In diesem Artikel geht meine Reise durch die Regeln zur Performanz in den C++ Core Guidelines weiter. Insbesondere beschäftige ich mich dem Softwareentwurf, der die Optimierung im Fokus hat.

Hier sind die zwei Stationen für heute.

Per.7: Design to enable optimization

Als ich diesen Titel zum ersten Mal las, kam mir sofort die Move-Semantik in den Sinn. Warum? Du solltest deine Algorithmen mit Move- und nicht mit Copy-Semantik implementieren. Damit erhältst du sofort ein paar Vorteile.

  1. Klar, anstelle einer teuren Copy- kommt eine billige Move-Operation zum Einsatz.
  2. Dein Algorithmus ist deutlicher stabiler, da er keinen Speicher benötigt und somit keine std::bad_alloc-Ausnahme geworfen werden kann.
  3. Du kannst deinen Algorithmus mit Datentypen wie std::unique_ptr verwenden, die nicht kopiert, sondern nur verschoben werden können.

Verstanden! Da implementiere ich doch gleich einen generischen swap-Algorithmus, der die Move-Semantik anwendet:

// swap.cpp

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <vector>

template <typename T> // (3)
void swap(T& a, T& b) noexcept {
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}

class BigArray{

public:
BigArray(std::size_t sz): size(sz), data(new int[size]){}

BigArray(const BigArray& other): size(other.size), data(new int[other.size]){
std::cout << "Copy constructor" << std::endl;
std::copy(other.data, other.data + size, data);
}

BigArray& operator=(const BigArray& other){ // (1)
std::cout << "Copy assignment" << std::endl;
if (this != &other){
delete [] data;
data = nullptr;

size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}

~BigArray(){
delete[] data;
}
private:
std::size_t size;
int* data;
};

int main(){

std::cout << std::endl;

BigArray bigArr1(2011);
BigArray bigArr2(2017);
swap(bigArr1, bigArr2); // (2)

std::cout << std::endl;

};

Das war einfach. Leider nicht. Mein Kollege gab mir seinen Datentyp BigArray. BigArray besitzt ein paar Probleme. Über den Copy-Zuweisungsoperator (1) werde ich später schreiben. Zuerst einmal gibt es ein ernsthaftes Problem. BigArray unterstützt nur Copy-, aber nicht die Move-Semantik. Was passiert, wenn ich zwei BigArrays in Zeile (2) austausche? Mein swap-Algorithmus verwendet unter der Decke die Move-Semantik. Das probiere ich gleich aus:

Nichts passiert. Es funktioniert einfach. Die traditionelle Copy-Semantik springt ein, und ich erhalte das klassische Verhalten. Die Copy-Semantik ist eine Art Fallback für die Move-Semantik. Du kannst es aber auch anders herum sehen. Verschieben ist ein optimiertes Kopieren.

Wie ist das möglich? Ich habe explizit die Move-Semantik in meinen swap-Algorithmus angefordert. Der Grund ist, dass std::move einen Rvalue zurückgibt. Eine konstante Lvalue-Referenz kann einen Rvalue binden und der Copy-Konstruktor und der Copy-Zuweisungsoperator besitzen eine konstante Lvalue-Referenz als Argument. Falls BigArray einen Move-Konstruktor und einen Move-Zuweisungsoperator besitzen würde, die jeweils eine Rvalue-Referenz erwarten, besitzen beide eine höhere Priorität als ihre Copy-Pendants.

Wenn in Algorithmen Move-Semantik verwendet wird, kommt sie automatisch zum Einsatz, wenn die Datentypen Move-Semantik unterstützen. Falls nicht, springt die Copy-Semantik als Fallback ein. Schlimmstenfalls erhältst du daher das klassische Verhalten.

Ich habe bereits angedeutet, dass der Copy-Zuweisungsoperator einige Probleme besitzt. Hier sind sie:

BigArray& operator=(const BigArray& other){ 
if (this != &other){ // (1)
delete [] data;
data = nullptr;

size = other.size;
data = new int[size]; // (2)
std::copy(other.data, other.data + size, data); // (3)
}
return *this;
}
  1. Ich muss auf Selbstzuweisung prüfen. Meist findet Selbstzuweisung nicht statt. Trotzdem prüfe ich den speziellen Fall.
  2. Falls die Speicherallokierung fehlschlägt, wurde this bereits modifiziert. Die size ist falsch und data bereits gelöscht. Das bedeutet, dass der Copy-Zuweisungsoperator nur die basic exception guarantee besitzt, aber nicht die strong exception guarantee. Die basic exception guarantee sichert zu, das keine Speicherleck nach einer Ausnahme auftritt. Die strong exception guarantee sichert hingegen bei einer Ausnahme zu, dass das Programm auf den Zustand vor der Ausnahme zurückgesetzt werden kann. Mehr Informationen zur Exception-Sicherheit gibt es in dem Wikipedia-Artikel: exception safety.
  3. Diese Zeile ist identisch zu der Zeile aus dem Copy-Konstruktor.

All die Probleme lassen sich meistern, wenn BigArray eine eigene swap-Funktion besitzt, wie es die C++ Core Guidelines empfehlen: C.83: For value-like types, consider providing a noexcept swap function. Hier ist der verbesserte Datentyp BigArray, der eine Funktion swap besitzt und diese in seinem Copy-Zuweisungsoperator verwendet:

class BigArray{

public:
BigArray(std::size_t sz): size(sz), data(new int[size]){}

BigArray(const BigArray& other): size(other.size), data(new int[other.size]){
std::cout << "Copy constructor" << std::endl;
std::copy(other.data, other.data + size, data);
}

BigArray& operator = (BigArray other){ // (2)
swap(*this, other);
return *this;
}

~BigArray(){
delete[] data;
}

friend void swap(BigArray& first, BigArray& second){ // (1)
std::swap(first.size, second.size);
std::swap(first.data, second.data);
}

private:
std::size_t size;
int* data;
};

Die swap-Funktion in Zeile (1) ist kein Klassenmitglied, daher verwendet sie den Aufruf swap(bigArray1, bigArray2). Vermutlich verwundert dich die Signatur des Copy-Zuweisungsoperators in Zeile (2). Dank der Copy ist kein Test auf Selbstzuweisung notwendig. Zusätzlich gilt die strong exception guarantee und der Sourcecode des Copy-Konstruktors wird nicht dupliziert. Diese Technik ist unter dem Namen copy-and-swap-Idiom bekannt.

Es gibt viele Überladungen der swap-Funktion. Der C++-Standard bietet bereits 50 an.

Per.10: Rely on the static type system

Diese Regel ist eine Art Meta-Regel in C++. Entdecke Fehler zur Compilezeit. Meine Erläuterung zu dieser Regel kann ich relativ kurz halten, da ich schon einige Artikel zu dieser sehr wichtigen Regel geschrieben habe.

  • Verwende automatische Typableitung mit auto (automatisch Initialisiert) in Kombination mit {}-Initialisierung und du wirst viele Vorteile besitzen.
    1. Der Compiler kennt immer den Typ: auto f = 5.0f.
    2. Du kannst das Initialisieren einer Variable nicht vergessen, da ein Ausdruck auto a; nicht gültig ist.
    3. Du kannst mit der {}-Initialisierung prüfen, ob eine verengende Konvertierung stattfindet. Damit kannst du sicherstellen, dass der erwartetet Typ der Typ ist, den der Compiler ableitet: int i = {f}. Der Compiler prüft in dem Ausdruck, dass f vom Typ int ist. Falls nicht, erhälst du eine Warnung. Das ist aber nicht der Fall, wenn du keine geschweiften Klammern anwendest: int i = f;.
  • Prüfe mit static_assert und der Typ-Traits-Bibliothek Eigenschaften von Datentypen zur Compilezeit. Falls der Check fehlschlägt, erhältst du einen Fehler zur Compilezeit: static_assert<std::is_integral<T>::value, "T should be an integral type!").
  • Wende typsichere Arithmetik mit den benutzerdefinierten und den neuen Built-in-Literalen (benutzerdefinierte Literale) an: auto distancePerWeek= (5 * 120_km + 2 * 1500m - 5 * 400m) / 5;.
  • override und final bieten Garantien zu virtuellen Methoden. Der Compiler prüft mit override, ob diese Methode tatsächlich eine Methode überschreibt. Der Compiler garantiert darüber hinaus, dass eine als final deklarierte Methode nicht überschrieben werden kann.
  • Die Null-Zeiger-Konstante nullptr räumt in C++11 mit der Mehrdeutigkeit der Zahl 0 und dem Makro NULL auf.

Wie geht's weiter?

Meine Reise durch die Regeln zur Performanz in den C++ Core Guidelines ist noch nicht abgeschlossen. Im nächsten Artikel werde ich mich insbesondere damit beschäftigen, wie sich Berechnung von der Laufzeit auf die Compilezeit vorziehen lassen.