Aus Linux-Magazin 08/2021

C++-Core-Guidelines – Folge 59

© besjunior / 123RF.com

Die Keywords const, constexpr und das Konzept der Unveränderlichkeit umreißen fundamentale Paradigmen für den guten Softwareentwurf in C++.

Wo es um Konstantheit und Unveränderlichkeit in C++ geht, hört man oft den Begriff Const Correctness – doch was ist das? Laut C++-FAQ [1] bedeutet es “… using the keyword »const« to prevent const objects from getting mutated”, also den Einsatz des Schlüsselworts »const«, um die Veränderung von Objekten zu unterbinden.

Die C++ Core Guidelines enthalten lediglich fünf Regeln zu Konstanten und zur Unveränderlichkeit. Das verwundert ein wenig, löst doch der Einsatz von Konstanten viele Probleme per Design. Dank der geringen Anzahl bietet es sich an, auf jede der fünf Regeln explizit einzugehen.

Regel 1

Die sehr eingängliche erste Regel lautet: “By default, make objects immutable” [2]. Der Wert eines Datentyps oder einer Instanz eines benutzerdefinierten Datentyps soll konstant sein. Ändern Sie ihn, quittiert dies der Compiler mit einer Fehlermeldung, die Listing 1 in Kommentarform darstellt. Modifizieren Sie ein Objekt, das als »const« definiert und dessen Konstantheit nachträglich entfernt wurde, resultiert daraus ein undefiniertes Verhalten.

Listing 1

Ändern eines unveränderlichen Werts

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 */
}

Regel 2

Die zweite Regel besagt: “By default, make member functions const” [3]. Member-Funktionen als »const« zu deklarieren, bietet zwei offensichtliche Vorteile: Ein unveränderliches Objekt kann nur konstante Methoden aufrufen, und konstante Methoden können das zugrunde liegende Objekt nicht verändern. Listing 2 zeigt ein einfaches Beispiel, das wieder die Fehlermeldungen des GCC in Kommentarform enthält.

Listing 2

Nicht konstante Member-Funktion

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 */
}

Doch das war erst die halbe Miete: Manchmal muss man zwischen der logischen und der physischen Konstantheit eines Objekts unterscheiden. Physische Konstantheit bedeutet, dass Sie ein Objekt als »const« deklarieren und es sich daher nicht ändern lässt. Weder sein Wert noch seine Darstellung im Speicher können sich ändern. Bei der logische Konstantheit deklarieren Sie ein Objekt zwar als konstant, es lässt sich aber ändern: Sein logischer Wert bleibt zwar unveränderlich, aber seine Darstellung im Speicher kann sich zur Laufzeit des Programms ändern.

Physische Konstantheit lässt sich recht einfach verstehen, die logische Konstantheit ist etwas subtiler. Listing 3, eine leichte Modifikation von Listing 2, deklariert die Variable »val« als »mutable«. Der Spezifizierer »mutable« (Zeile 4) machte die Magie möglich. Das »const«-Objekt kann die »const«-Methode (Zeile 15) aufrufen, die »val« modifiziert. Genau das zeigt Abbildung 1.

Listing 3

Eine mutable Variable

#include <iostream>
struct Immutable {
  mutable int val{12};
  void canNotModify() const {
    val = 13;
  }
};
int main() {
  std::cout << '\n';
  const Immutable immu;
  std::cout << "val: " << immu.val << '\n';
  immu.canNotModify();
  std::cout << "val: " << immu.val << '\n';
  std::cout << '\n';
}
Abbildung 1: Modifikation einer mutablen Variable.

Abbildung 1: Modifikation einer mutablen Variable.

Ein typisches Beispiel für eine mutable Variable ist ein Mutex, den Sie in einer Klasse verwenden. Diese Klasse soll eine konstante Leseoperation ausführen. Um die Daten der Klasse gleichzeitig verwenden zu können, müssen Sie den Zugriff darauf mit dem Mutex schützen.

Zur Synchronisation erhält die Klasse einen Mutex für das exklusive Verwenden der Leseoperation. In diesem Konstrukt lässt sich die Leseoperation nicht als konstant deklarieren, da der Mutex in ihr verwendet und modifiziert (gelockt) wird. Um den Knoten aufzulösen, deklarieren Sie in diesem Fall den Mutex als »mutable« (Listing 4).

Listing 4

Beispiel mit Mutex

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

Regel 3

“By default, pass pointers and references to consts” [4], fordert die dritte Regel. Wenn Sie wie in Listing 5 Zeiger oder Referenzen auf »const« an eine Funktion übergeben, ist die Absicht der Funktion offensichtlich: Das Objekt, auf das gezeigt oder das referenziert wird, lässt sich nicht verändern. Sind beide Deklarationen gleichwertig? Nein!

Listing 5

Konstante Zeiger oder Referenzen

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

Im Fall der Funktion »getCString()« könnte es sich beim Zeiger um einen Nullzeiger handeln. Das bedeutet, dass Sie ihn vor jeder Verwendung prüfen müssen (»if (cStr) […]«). Doch es geht noch weiter: Sowohl der Zeiger als auch das Objekt, auf das er verweist, können konstant sein. Daraus ergeben sich folgende Variationen:

  • »const char* cStr«: »cStr« verweist auf ein konstantes »char«, das referenzierte Objekt lässt sich nicht verändern.
  • »char* const cStr«: Bei »cStr« handelt es sich um einen konstanten Zeiger, der sich nicht verändern lässt.
  • »const char* const cStr«: »cStr« ist ein konstanter Zeiger auf ein ebenfalls konstantes »char«. Weder der Zeiger noch das referenzierte Objekt lassen sich verändern.

Erscheint Ihnen das zu kompliziert? Sie müssen die Ausdrücke von rechts nach links lesen. Immer noch zu kompliziert? Verwenden sie eine Referenz auf »const«.

Regel 4

Die vierte Regel lautet: “Use const to define objects with values that do not change after construction” [5]. Teilen Sie eine Variable zwischen Threads und deklarieren sie als »const«, passt alles. Konstante Variable lassen sich ohne Synchronisation verwenden und erzielen ein Maximum an Leistung. Der einfache Grund dafür: Ein Data Race setzt einen veränderbaren, gemeinsam genutzten Zustand voraus. Abbildung 2 bringt das auf den Punkt.

Abbildung 2: Geteilter und ver&auml;nderlicher Zustand.

Abbildung 2: Geteilter und veränderlicher Zustand.

Als Data Race bezeichnet man einen Zustand, in dem zwei oder mehr Threads gleichzeitig auf eine Variable zugreifen und mindestens einer davon versucht, sie zu verändern. Enthält ein Programm ein Data Race, verhält es sich automatisch undefiniert. Dann gibt es nur eine Handlungsoption: Sie müssen das Programm neu schreiben und das Data Race beseitigen.

Verwenden Sie unveränderliche und gemeinsam genutzte Daten in einem Programm mit mehreren Threads, gilt es noch ein zusätzliches Problem zu lösen: Sie müssen die gemeinsam genutzte(n) Variable(n) auf eine Thread-sichere Weise initialisieren.

Dazu bieten sich mehrere Optionen an. Initialisieren Sie die gemeinsam genutzte Variable, bevor Sie einen Thread starten, oder verwenden Sie die Funktion »std::call_once« [6] in Kombination mit dem Flag »std::once_flag«. Alternativ nutzen Sie eine statische Variable mit eigenem Gültigkeitsbereich oder eine »constexpr«-Variable.

Regel 5

Die letzte Regel heißt: “Use constexpr for values that can be computed at compile time” [7]. »constexpr«-Variablen bieten hohe Leistung, werden zur Compile-Zeit ausgewertet und können keinem Data Race zum Opfer fallen. Sie müssen einen »constexpr«-Wert »constexprValue« zur Compile-Zeit initialisieren (Listing 6).

Listing 6

constexpr initialisieren

constexpr double constexprValue = constexprFunction(2);

Eine »constexpr«-Funktion lässt sich zur Compile-Zeit ausführen, wenn es keinen Zustand und somit auch keine Veränderung gibt. Eine entsprechende, zur Compile-Zeit ausgeführte Funktion gilt deshalb als rein.

Solche reinen Funktionen haben viele Vorteile: Der Funktionsaufruf lässt sich durch sein Ergebnis ersetzen, und man kann die Funktion auf einem anderen Thread ausführen. Zudem lassen sich die Funktionsaufrufe umordnen, und Sie können »constexpr«-Funktionen leicht refaktorisieren und isoliert testen.

Wie geht es weiter?

Die Zukunft von C++ liegt in Templates. Daher verwundert es nicht, dass die C++ Core Guidelines viele Regeln zum korrekten Einsatz von Templates formulieren. Mit eben diesen Regeln beschäftigt sich der nächste Artikel dieser Serie genauer. (jcb/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben