Aus Linux-Magazin 06/2020

C++ Core Guidelines – Folge 52

© Volodymyr Tverdokhlib, 123RF

Diesmal widmet sich der C++-Workshop der wohl wichtigsten Regel für gute Software: aussagekräftige Namen. Sie machen Code lesbar, verständlich und erweiterbar. Unglücklicherweise wird die Benennung viel zu häufig ignoriert.

Das Vergeben sinnvoller Namen zählt zu jenen Themen, die C++-Einsteiger beim Programmieren häufig nicht auf dem Radar haben. Das kann ihnen später schmerzhaft auf die Füße fallen. Daher widmen sich auch die C++ Core Guidelines diesem Thema.

Die ersten beiden Regeln besitzen einen recht allgemeinen Fokus, der auf den ersten Blick nicht auf Namen abzuzielen scheint. Die Regel ES.1 [1] lautet: “Bevorzuge die Standardbibliothek gegenüber anderen Bibliotheken und handgemachtem Code.” Sie hat aber einen Nebeneffekt für Namen: Anstelle einer anonymen Schleife kommt nun ein Algorithmus mit einem Namen zum Einsatz.

Es gibt beispielsweise keinen Grund, die Summe eines Vektors von Fließkommazahlen mit einer expliziten Schleife zu berechnen, wie in Listing 1. Wer das Rad nicht ständig neu erfinden will, der sollte den »std::accumulate«-Algorithmus der Standard Template Library (STL) nicht nur kennen, sondern auch wie in Listing 2 einsetzen.

Listing 1

Summe eines Vektors explizit berechnen

int max = v.size();
double sum = 0.0;
for (int i = 0; i < max; ++i)
  sum = sum + v[i];

Listing 2

std::accumulate

auto sum = std::accumulate(begin(a), end(a), 0.0);

Diese Regel weckt zugleich Erinnerungen an einen Satz von Sean Parent auf der CppCon 2013: “Wenn Du die Codequalität in deiner Organisation verbessern möchtest, dann ersetze alle deine Coding-Leitfäden durch ein Ziel: keine nackten Schleifen.” Oder, um es ein wenig verständlicher auszudrücken: Falls du eine nackte Schleife verwendest, kennst du vermutlich die Algorithmen der STL nicht gut genug.

Das nächste Déjà-vu stellt sich bei der Regel ES.2 [2] ein: “Ziehe passende Abstraktionen dem direkten Einsatz von Sprach-Features vor.” Ein Beispiel: In einem meiner letzten C++-Seminare gab es eine lange Diskussion, gefolgt von einer noch längeren Analyse einiger ziemlich cleverer Funktionen zum Lesen und Schreiben eines »strstream« [3].

Der Hintergrund: Die Teilnehmer mussten eine Funktion verstehen und erweitern, um ihren Legacy-Code zu pflegen. Ihr Kampf ging inzwischen in die zweite Woche. Das größte Hindernis für das Verständnis des vorhandenen Codes (Listing 3) bestand darin, dass die Funktion nicht die richtige Abstraktion verwendete. Hier handelte es sich um eine selbst gestrickte Funktion zum Lesen eines »std::istream«. Im Gegensatz zur schwer verdaulichen Funktion »read1()« lässt sich deren Pendant »read2()« aus Listing 4 deutlich einfacher verstehen.

Listing 3

Schlechte Abstraktion

char** read1(istream& is, int maxelem, int maxstring, int* nread) {
  auto res = new char*[maxelem];
  int elemcount = 0;
  while (is && elemcount < maxelem) {
    auto s = new char[maxstring];
    is.read(s, maxstring);
    res[elemcount++] = s;
  }
  nread = &elemcount;
  return res;
}

Listing 4

Bessere Abstraktion

vector<string> read2(istream& is) {
  vector<string> res;
  for (string s; is >> s;)
    res.push_back(s);
  return res;
}

Die richtige Abstraktion zu wählen, bedeutet häufig, dass der Entwickler die Besitzverhältnisse wie in »read2()« explizit regeln muss. In »read1()« gilt das nicht: Der Aufrufende ist hier gleichzeitig der Besitzer von »res« und muss »res« daher konsequenterweise auch löschen.

Deklarationen

Wer über Namen spricht, darf über Deklarationen nicht schweigen. Doch was ist eine Deklaration? Dabei handelt es sich um eine Anweisung, die einen Namen in einen Bereich einführt.

Zu Namen sagen die C++ Core Guidelines einiges. Zunächst einmal gilt es, den Bereich klein zu halten, in dem ein Name gültig ist. Eine einfache Faustregel dafür liefert die Bildschirmgröße: Der Codebereich, in dem die Namen ihre Gültigkeit besitzen, sollte auf einen Bildschirm passen. Andernfalls geht beim Scrollen der Kontext verloren, und Fehler schleichen sich ein.

Diese Regel macht es einfach, den Überblick eines Bereichs wie einer Funktion zu behalten. Die Regel ES.5 [4] in den Guidelines formuliert es etwas allgemeiner: “Halte den Gültigkeitsbereich klein.”

Eine weitere Regel schließt unmittelbar an den Wortlaut an. Je größer der Bereich, in dem der Name zum Einsatz kommt, desto länger und damit aussagekräftiger sollte er ausfallen. ES.7 [5] besagt: “Halte unspezifische und lokale Namen kurz, und wähle besondere sowie nicht lokale Namen länger.”

Diese Regel haben viele Programmierer bereits seit Langem verinnerlicht. Beispiel gefällig? Trägt eine Variable den Name »i« oder »j«, ist ihr Einsatzzweck unmittelbar klar: Sie dient als Index in einer Schleife. For-Schleifen wie die in Listing 5 besitzen meist einen kleinen Gültigkeitsbereich.

Listing 5

Die Indexvariable i

template<typename T>
void print(ostream& os, const vector<T>& v) {
  for (int i = 0; i < v.size(); ++i)
    os << v[i] << '\n';
}

Es gibt freilich auch Namen, die geradezu zum falschen Lesen einladen und die Entwickler daher nicht verwenden sollten. Die Codezeilen aus Listing 6 demonstrieren den Sinn der Regel ES.8 [6] (“Vermeide ähnlich aussehende Namen”) und bergen zugleich ein großes Überraschungspotenzial.

Listing 6

Schlecht lesbare Namen

if (readable(i1 + l1 + ol + o1 + o0 + ol + o1 + I0 + l0)) surprise();

Bei der Verwendung von »All_CAPS« für Namen droht immer die Gefahr von Makrosubstitutionen, da C++ »ALL_CAPS« typischerweise Makros für umschreibt. Daher ist der Programmschnipsel aus Listing 7 immer für eine Überraschung gut. Die Regel ES.9 [7] (“Vermeide »ALL_CAPS«-Namen”) bringt das Problem direkt auf den Punkt; auch hier gibt es Überraschungspotenzial.

Listing 7

Überraschung mit All_CAPS-Namen

// Irgendwo in irgendeinem Header:
#define NE !=
// Irgendwo anders, in einem anderen Header:
enum Coord { N, NE, NW, S, SE, SW, E, W };
// An dritter Stelle im C++-Code eines armen Programmierers:
switch (direction) {
case N:
  // [... ]
case NE:
  // [... ]
// [... ]
}

Die Regel ES.12 [8] lautet “Recycle keine Namen in verschachtelten Gültigkeitsbereichen” und hat es in sich. Vernachlässigt man sie, wie in Listing 8, dann fällt es oft selbst erfahrenen C++-Entwicklern schwer, das Problem aufzuspüren.

Listing 8

Überdecken (Shadowing) einer Variablen

#include <iostream>
#include <string>
struct Base{
  void shadow(std::string){
    std::cout << "Base::shadow" << std::endl;
  }
};
struct Derived: Base{
  void shadow(int){
    std::cout << "Derived::shadow" << std::endl;
  }
};
int main(){
  std::cout << std::endl;
  Derived derived;
  derived.shadow(std::string{});
  derived.shadow(int{});
  std::cout << std::endl;
}

Das Programm lässt sich nicht übersetzen. Die Strukturen »Base« und »Derived« besitzen beide eine Methode »shadow()«. Während die Methode der Basisklasse einen »std::string« entgegennimmt (Zeile 5), erwartet die andere einen Typ »int« (Zeile 11).

Kommt nun das Objekt »derived« mit einem »std::string« (Zeile 22) zum Einsatz, liegt die Erwartung nahe, dass das Programm die »Base«-Variante aufruft. Das ist jedoch falsch: Auch in der abgeleiteten Klasse »Derived« gibt es die Methode »shadow()«, weswegen das Programm beim Auflösen der Namen die Methode in der Klasse »Base« nicht beachtet. Abbildung 1 zeigt die Ausgabe des Compilers GCC.

<a href="#artRef-f1">Abbildung 1</a>: In verschachteltem Code gleichnamige Methoden zu verwenden, ist meist keine gute Idee.

Abbildung 1: In verschachteltem Code gleichnamige Methoden zu verwenden, ist meist keine gute Idee.

Macht der Entwickler hingegen die Methode »shadow()« in »Derived« bekannt, verhält sich das Programm erwartungsgemäß (Listing 9). Dank der Deklaration »using Base::shadow« berücksichtigt der Compiler die Methode »shadow()« der Basisklasse »Base«.

Listing 9

Die Methode shadow() bekannt machen

struct Derived: Base{
  using Base::shadow;
  void shadow(int){
    std::cout << "Derived::shadow" << std::endl;
  }
};

Initialisierung

Eine Deklaration kann auch ein Objekt mit Namen erzeugen. Natürlich gilt es, dieses Objekt zu initialisieren. Als wichtigste Regel beim Initialisieren einer Variablen greift Regel ES.23 [9]: “Bevorzuge beim Initialisieren die Syntax mit geschweiften Klammern.” Dafür gibt es gleich drei gute Gründe: Die Regel lässt sich immer anwenden, löst den Most Vexing Parse [10] und verhindert eine Narrowing Conversion. Wem diese Begriffe nichts sagen: Keine Sorge, der Artikel greift sie im Folgenden noch auf.

Anstelle der klassischen Initialisierung lässt sich also die Variante mit geschweiften Klammern anwenden. Das trifft für die direkte und die Copy-Initialisierung zu (Listing 10). Darüber hinaus unterstützt die Initialisierung mit »{}« neue Anwendungsfälle: So lassen sich ein C-Array, ein »std::vector« oder eine »std::unordered_map« in einem Rutsch initialisieren (Listing 11). Wendet der Entwickler leere geschweifte Klammern auf einen Datentyp an, so stößt das den Aufruf des Default-Konstruktors an (Listing 12).

Listing 10

Direkte und Copy-Initialisierung

int number{2011};
int number2 = {2011};

Listing 11

{}-Initialisierung

struct MyArray {
  public:
    MyArray(): data {1, 2, 3,
 4, 5} {}
  private:
    const int data[5];
};
std::vector<int> oneElement{1, 2, 3, 4, 5};
std::unordered_map<std::string, int> um { {"Dijkstra", 1972}, {"Scott", 1976} };

Listing 12

Default-Konstruktor

int number{};
std::string str{};
std::vector<int> vec{};

Most Vexing Parse

Nun aber zu den Begriffserklärungen. Most Vexing Parse hört sich gruselig an – und ist es auch. Listing 13 bringt das Problem auf den Punkt: Das vollkommen unschuldig wirkende Programm lässt sich nicht übersetzen (Abbildung 2).

Listing 13

Most Vexing Parse

#include <iostream>
struct MyInt {
  MyInt(int arg = 0): i(arg) {}
  int i;
};
int main() {
  MyInt myInt(2011);
  MyInt myInt2{};
  std::cout << myInt.i;
  std::cout << myInt2.i;
}

<a href="#artRef-f2">Abbildung 2</a>: Beim Most Vexing Parse geht es um Entscheidungen des Compilers.

Abbildung 2: Beim Most Vexing Parse geht es um Entscheidungen des Compilers.

Die dazugehörige Fehlermeldung ist nicht besonders hilfreich. Der Compiler kann den Ausdruck »MyInt myInt2()« als Konstruktoraufruf oder als Funktionsdeklaration interpretieren. Im Zweifelsfall entscheidet er sich für eine Funktionsdeklaration. Damit ist der Ausdruck »myInt2.i« nicht gültig. Ersetzt der Programmierer aber die runden Klammern in dem Aufruf durch geschweifte (»MyInt myInt2{}«), löst dies die Zweideutigkeit auf, und das Programm lässt sich übersetzen.

Narrowing Conversion

Bei der Narrowing Conversion handelt es sich um eine Konvertierung mit Verlust der Datengenauigkeit. Das klingt zu Recht gefährlich; das Ergebnis ist undefiniertes Verhalten. Listing 14 stellt exemplarisch eine Narrowing Conversion für die fundamentalen Datentypen »char« und »int« vor. Dabei spielt es keine Rolle, ob die direkte Initialisierung oder die Copy-Initialisierung zum Einsatz kommt.

Listing 14

Narrowing Conversion

#include <iostream>
int main() {
  char c1(999);
  char c2 = (999);
  std::cout << "c1: " << c1 << std::endl;
  std::cout << "c2: " << c2 << std::endl;
  int i1(3.14);
  int i2 = (3.14);
  std::cout << "i1: " << i1 << std::endl;
  std::cout << "i2: " << i2 << std::endl;
}

Die Ausgabe des Programms in Abbildung 3 veranschaulicht das Dilemma. Weder passt der »int«-Wert in den Datentyp »char«, noch ist der »int«-Datentyp groß genug, um den »double«-Wert anzunehmen. Dank der Initialisierung mit geschweiften Klammern, die Listing 15 zeigt, ist eine Narrowing Conversion nicht mehr möglich. Dieses Programm ist ungültig, da die Initialisierung mit geschweiften Klammern dazu führt, dass der Compiler die Narrowing Conversion entdeckt. Er muss in diesem Fall zumindest eine Warnung ausgeben. Die meisten Compiler, wie der GCC in Abbildung 4, behandeln diese Warnung als Fehler.

<a href="#artRef-f3">Abbildung 3</a>: Narrowing Conversion: Die Datentypen passen nicht zueinander.

Abbildung 3: Narrowing Conversion: Die Datentypen passen nicht zueinander.

Listing 15

Narrowing Conversion umgehen

#include <iostream>
int main() {
  char c1{999};
  char c2 = {999};
  std::cout << "c1: " << c1 << std::endl;
  std::cout << "c2: " << c2 << std::endl;
  int i1{3.14};
  int i2 = {3.14};
  std::cout << "i1: " << i1 << std::endl;
  std::cout << "i2: " << i2 << std::endl;
}

<a href="#artRef-f4">Abbildung 4</a>: Wird die Narrowing Conversion entdeckt, schl&auml;gt der Compiler Alarm.

Abbildung 4: Wird die Narrowing Conversion entdeckt, schlägt der Compiler Alarm.

Ausblick

Besonders lehrreich in den C++ Core Guidelines sind die arithmetischen Regeln im Kapitel “Expressions and Statements”. Eben diesen widmet sich die nächste Folge dieser Reihe. (kki/jlu)

Der Autor

Der C++- und Python-Trainer Rainer Grimm hat bei O’Reilly und Leanpub zahlreiche Bücher zu C++ veröffentlicht, zuletzt “The C++ Standard Library” und “Concurrency with modern C++”.

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