C++ Core Guidelines: Regeln zu Konstanten und zur Unveränderlichkeit

Modernes C++  –  10 Kommentare

Das Deklarieren von Objekten und Methoden als const besitzt zwei große Vorteile. Zuerst einmal beschwert sich der Compiler, wenn der Vertrag bricht. Dann teilt man damit dem Anwender des Interfaces mit, dass die Funktion ihre Argumente nicht verändert.

Die C++ Core Guidelines bieten fünf Regeln zu konstanten Objekten oder Methoden und konstanten Ausdrücken an. Hier sind sie:

Bevor ich mich genau mit den Regeln beschäftige, möchte ich einen Ausdruck klären, der häufig im Zusammenhang mit Konstantheit und Unveränderlichkeit genannt wird: "const correctness". Entsprechend der C++-FAQ heißt dies:

  • What is const correctness? It means using the keyword const to prevent const objects from getting mutated.

Heute geht es also um "const correctness".

Diese Regel ist recht einfach. Du kannst einen Wert eines Built-in-Typs oder eine Instanz eines benutzerdefinierten Typs als konstant deklarieren. Das Ergebnis ist das gleiche. Wenn du versuchst, diesen zu ändern, erhältst du das, was du verdienst: einen Compile-Fehler:

struct Immutable{
int val{12};
};

int main(){
const int val{12};
val = 13; // assignment of read-only variable 'val'

const Immutable immu;
immu.val = 13; // assignment of member 'Immutable::val' in read-only object
}

Die Fehlermeldung des GCC, eingebettet im Listing, ist sehr überzeugend.

Methoden einer Klasse als "const" zu erklären, besitzt doppelten Mehrwert. In diesem Fall dürfen keine nicht-konstante Methoden eines konstantes Objekt aufgerufen werden und die konstante Methode kann das zugrunde liegende Objekt nicht modifizieren. Und nochmals. Hier ist ein einfaches Beispiel, das die Fehlermeldung des GCC eingebettet enthält:

struct Immutable{
int val{12};
void canNotModify() const {
val = 13; // assignment of member 'Immutable::val' in read-only object
}
void modifyVal() {
val = 13;
}
};

int main(){
const Immutable immu;
immu.modifyVal(); // passing 'const Immutable' as 'this' argument discards qualifiers
}

Dies war nur die halbe Wahrheit. Manchmal gilt es, zwischen der logischen und physikalischen Konstantheit eines Objekts zu unterscheiden. Klingt komisch. Oder?

  • Physikalische Konstantheit: Dein Objekt ist als konstant deklariert und kann daher nicht verändert werden.
  • Logische Konstantheit: Dein Objekt ist als konstant deklariert und kann trotzdem verändert werden.

Die physikalische Konstantheit ist einfach zu verdauen, die logische Konstantheit hingegen nicht. Lasse mich das vorherige Programm ein wenig verändern. Nimm dazu an, dass ich das Attribut val in einer konstanten Methode verändern will:

// mutable.cpp

#include <iostream>

struct Immutable{
mutable int val{12}; // (1)
void canNotModify() const {
val = 13;
}
};

int main(){

std::cout << std::endl;

const Immutable immu;
std::cout << "val: " << immu.val << std::endl;
immu.canNotModify(); // (2)
std::cout << "val: " << immu.val << std::endl;

std::cout << std::endl;

}

Dank des Bezeichners mutable (1) ist die Magie möglich. Das konstante Objekt kann daher die konstante Methode (2) aufrufen, die val verändet.

Wozu ist mutable nützlich? Hier kommt ein netter Anwendungsfall Stelle dir vor, deine Klasse hat eine "constant read"-Funktion. Da du Instanzen der Klasse concurrent verwendest, musst du die Funktion durch einen Mutex schützen. Daher erhält die Klasse einen Mutex und du lockst diesen in der read Methode. Jetzt hast du ein Problem. Deine read-Methode kann nicht konstant sein, den in ihr wird der Mutex gelockt. Die Lösung besteht nun darin, den Mutex als mutable zu erklären.

Die Klasse Immutable erhält eine einfache Umsetzung der Idee. Ohne mutable würde der Code nicht kompilieren:

struct Immutable{
mutable std::mutex m;
int read() const {
std::lock_guard<std::mutex> lck(m);
// critical section
...
}
};

Wenn du einen Zeiger oder eine Refererenz auf ein konstantes Datum übergibst, ist die Absicht der Funktion eindeutig. Das referenzierte Objekt kann nicht verändert werden:

void getCString(const char* cStr);
void getCppString(const std::string& cppStr);

Sind beide Deklarationen äquivalent? Nicht hundertprozentig. Im Falle der Funktion getCString kann der Zeiger ein Nullzeiger sein. Das heißt, du musst ihn immer vor seiner Verwendung prüfen: if (cStr ....

Aber das ist noch nicht alles. Sowohl der Zeiger als auch das Objekt, auf das der Zeiger verweist, kann konstant sein. Hier sind die Variationen:

  • const char* cStr: cStr verweist auf ein char, das konstant ist: Das referenzierte Objekte kann nicht verändert werden.
  • char* const cStr: cStr ist ein konstanter Zeiger; der Zeiger kann nicht verändert werden.
  • const char* const cStr: cStr ist ein konstanter Zeiger auf ein char, das selbst konstant ist; weder der Zeiger noch das referenziert Objekt können verändert werden.

Zu kompliziert? Lies die Ausdrücke von rechts nach links. Immer noch zu kompliziert. Verwende eine Referenz auf const.

Die nächsten zwei Regeln möchte ich aus dem Blickwinkel der Concurrency betrachten. Daher fasse ich beide Regeln zusammen.

Wenn du eine Variable immutable zwischen Threads teilen willst und diese Variable ist als konstant deklariert, bist du fertig. Du kannst immutable ohne Synchronisation verwenden und erhältst die maximale Performanz aus deiner Maschine. Der Grund ist naheliegend. Die notwendige Voraussetzung für ein Data Race ist ein geteilter, veränderlicher Zustand.

  • Data Race: Zumindest zwei Threads greifen gleichzeitig auf eine Variable zu. Mindestens ein Thread versucht, sie dabei zu verändern.

Jetzt gibt es nur noch ein Problem zu lösen. Du musst die Variable in einer thread-sicheren Weise initialisieren. Mir fallen dazu vier Möglichkeiten ein.

  1. Initialisieren den gemeinsamen Zustand, bevor du einen Thread gestartet hast.
  2. Verwende die Funktion std::call_once in Kombination mit dem Flag std::once_flag.
  3. Verwende eine statische Variable mit Blockgültigkeit.
  4. Verwende eine constexpr-Variable.

Viele Entwickler übersehen die Variante 1, die am einfachsten umzusetzen ist. Du kannst mehr zur thread-sicheren Initialisierung einer Variable in dem Artikel "Sichere Initialisierung einer Variable" nachlesen.

In der Regel Con.5 geht es um den Punkt 4. Wenn du eine Variable als konstanten Ausdruck constexpr double totallyConst = 5.5; erklärst, wird er zum Compilezeit initialisiert und ist damit thread-sicher.

Das war noch nicht alles zu constexpr. Die C++ Core Guidelines erwähnen einen wichtigen Aspekt von constexpr in Concurrent-Umgebungen nicht. constexpr-Funktionen sind reinen Funktionen sehr ähnlich. Hier kommt ein Beispiel für einen constexpr gcd:

constexpr int gcd(int a, int b){
while (b != 0){
auto t= b;
b= a % b;
a= t;
}
return a;
}

Zuerst einmal, was heißt "pure" und was heißt vor allem eine "Art pure".

Eine constexpr-Funktion kann potentiell zur Compilezeit ausgeführt werden. Zur Compilezeit gibt es keinen Zustand. Insbesondere bedeutet dies, dass zur Compilezeit ausgeführte constexpr-Funktionen reine Funktionen sein müssen. Reine Funktionen sind Funktionen, die immer den gleichen Wert zurückgeben, wenn sie mit den gleichen Argumenten aufgerufen werden. Reine Funktionen verhalten sich wie unendlich große Tabellen, in denen der Wert einfach nur nachgeschlagen wird. Diese Zusicherung, dass ein Ausdruck immer den gleichen Wert zurückgibt, wenn er mit den gleichen Argumenten bedient wird, nennt sich Referenzielle Transparenz. Reine Funktionen besitzen viele Vorteile:

  1. Der Funktionsaufruf kann durch sein Ergebnis ersetzt werden.
  2. Die Ausführung der Funktion kann automatisch auf andere Threads verteilt werden.
  3. Funktionsaufrufe können umsortiert werden.
  4. Sie können einfach refaktoriert oder in Isolation getestet werden.

Insbesondere der Punkt 2 macht reine Funktionen zu solch wertvollen Funktionen in Concurrent-Programmen. Die folgende Tabelle bringt die Charakteristiken von reinen Funktionen explizit auf den Punkt.

Ich will es gerne noch explizit betonen. constexpr-Funktionen sind nicht per se rein. Sie sind nur rein, wenn sie zur Compilezeit ausgeführt werden.

Das war es schon. Ich habe in diesem Artikel alle Regeln zur Konstantheit und Unveränderlichkeit der C++ Core Guidelines vorgestellt. In nächsten Artikel schreibe ich über die Zukunft von C++: Templates und generische Programmierung.

Ich freue mich darauf, weitere C++-Schulungen halten zu dürfen.

Die Details zu meinen C++- und Python-Schulungen gibt es auf www.ModernesCpp.de.