Boris Schäling

30. April 2009


C++ Best Practices

Wer die Regeln der Programmiersprache C++ gelernt hat und weiß, wie man Variablen und Funktionen definiert, ist nicht automatisch ein guter C++-Entwickler. Während die Regeln in C++ eindeutig sind, wie Variablen und Funktionen zu definieren sind, besteht viel Spielraum bei der Strukturierung eines Programms. Wo soll eine Variable definiert werden - lokal in einer Funktion, als Eigenschaft in einer Klasse oder global im Programm? Soll eine Funktion als freistehende Funktion definiert werden oder als Methode zu einer Klasse gehören? Welche Klassen sollen überhaupt entwickelt werden und wie sollen ihre Schnittstellen aussehen? Dieser Artikel versucht, einige Best Practices in C++ vorzustellen, die sich bei vielen Entwicklern und in vielen Projekten bewährt haben.

Dieser Inhalt ist unter einer Creative Commons-Lizenz lizensiert.


Inhaltsverzeichnis


1. Allgemeines

Wer die Programmiersprache C++ lernt, lernt Regeln, die eingehalten werden müssen, um Quellcode zu erstellen, der von einem C++-Compiler übersetzt werden kann. Wer also zum Beispiel in C++ eine Variable anlegen möchte, muss einen Datentyp, ein Leerzeichen, einen Variablennamen und einen Strichpunkt eingeben. Diese Regeln sind verbindlich. Wer sie einmal gelernt und verinnerlicht hat, kann C++ programmieren. Nicht jeder, der die grundsätzlichen Regeln der Programmiersprache C++ kennt, ist aber automatisch ein guter C++-Programmierer.

C++ ist eine sehr flexible Sprache, die es Entwicklern freistellt, wie sie ein C++-Programm strukturieren möchten. So kann eine Variable zum Beispiel lokal in einer Funktion oder global außerhalb jeder Funktion definiert werden. Sie kann als Objekt-Eigenschaft Bestandteil einer Klasse sein oder - wenn das Schlüsselwort static verwendet wird - eine Klassen-Eigenschaft. Allein die Regeln zu kennen, nach denen Variablen definiert werden, reicht nicht aus. Es müssen Entscheidungen getroffen werden wie die, an welcher Stelle eine Variable denn nun eigentlich definiert werden soll.

Weil es unbegrenzt viele Möglichkeiten gibt, ein C++-Programm zu strukturieren, stellt sich zwangsläufig die Frage, was wann am besten ist. Die Frage lässt sich nicht abschließend beantworten, da die Antwort jeweils von den Anforderungen abhängt, die ein Programm zu erfüllen versucht. Dies ist schließlich eine der Stärken von C++: Egal, was kommt, Sie können es in C++ so entwickeln, wie Sie wollen und wie es für Sie und Ihre Anforderungen am besten ist.

In diesem Artikel lernen Sie grundsätzliche Richtlinien und Tipps und Tricks kennen, die sich in vielen C++-Projekten bewährt haben. Nichts von dem, was Sie in diesem Artikel kennenlernen, ist verbindlich - wenn es verbindlich wäre, wäre C++ entsprechend designt worden. Der Artikel soll Ihnen vielmehr helfen, von anderen C++-Entwicklern zu lernen, auch wenn Sie später in Ihren eigenen Programmen sicher von der einen oder anderen Vorgehensweise abweichen werden.


2. Objektmodellierung

Eines der wesentlichen Paradigmen, die die Programmiersprache C++ unterstützt, ist das der Objektorientierung. Für C++ gelten bei der Objektmodellierung daher grundsätzlich die gleichen Prinzipien, wie sie auch für andere objektorientierte Programmiersprachen gelten.


3. RAII

Die Abkürzung RAII steht für Resource Acquisition Is Initialization. Damit bezieht man sich auf eine Regel, die besagt, dass wann immer eine Ressource geöffnet wird, die auch wieder geschlossen werden muss, dies mit Hilfe eines Objekts passieren soll, das mit der Ressource initialisiert wird. Denn wenn das Objekt gelöscht wird, wird automatisch der Destruktur aufgerufen, in dem die Ressource geschlossen werden kann. Dies hat den Vorteil, dass kein Entwickler vergessen kann, eine Ressource zu schließen. Selbst dann, wenn man als Entwickler entsprechende Funktionen zum Schließen von Ressourcen nicht vergisst, sollte RAII beherzigt werden. Sollte nämlich eine Ausnahme geworfen werden, kann es sein, dass die Funktion zum Schließen einer Ressource nicht aufgerufen wird, weil die Ausnahme die aktuelle Funktion vorzeitig abbricht.

Ein prominentes Beispiel für RAII ist die Klasse std::auto_ptr aus dem C++-Standard. Es handelt sich hierbei um einen sogenannten smart pointer, dem ein Zeiger auf dynamisch reservierten Speicher übergeben wird. Der Vorteil für den Entwickler ist, dass er nicht mehr daran denken muss, an geeigneter Stelle den dynamisch reservierten Speicher mit delete freizugeben. Dies ist besonders dann hilfreich, wenn der Speicher in einer Funktion reserviert, in einer anderen Funktion aber verwendet wird und dort anschließend freigegeben werden muss. Da die Speicherfreigabe in solchen Fällen leicht vergessen wird, können smart pointer wie std::auto_ptr sehr hilfreich sein.

Beachten Sie, dass Sie RAII auch in anderen Fällen anwenden sollten - nicht nur bei der Reservierung und Freigabe von dynamischen Speicher. So gibt es zum Beispiel unter Windows viele Funktionen, durch die eine Ressource im Betriebssystem geöffnet werden muss, bevor dann auf sie zugegriffen werden kann. Damit das Schließen der Ressource nicht vergessen wird, bietet es sich an, eine entsprechende Klasse zu entwickeln, die das Öffnen der Ressource im Konstruktur und das Schließen im Destruktur vornimmt.


4. Initialisierungslisten

In Konstruktoren werden üblicherweise Eigenschaften eines Objekts initialisiert. Während wie in anderen Methoden auch innerhalb eines Konstruktors über den =-Operator auf Eigenschaften zugegriffen werden könnte, um sie zu initialisieren, sollten sogenannte Initialisierungslisten vorgezogen werden.

#include <string> 

class person 
{ 
  public: 
    person() 
      : name("Boris"), maennlich(true), schuhgroesse(43) 
    { 
    } 

  private: 
    std::string name; 
    bool maennlich; 
    int schuhgroesse; 
}; 

Initialisierungslisten werden hinter einem Doppelpunkt zwischen dem Kopf und dem Rumpf des Konstruktors angegeben. Dabei werden Eigenschaften durch Komma getrennt in der Reihenfolge initialisiert, in der sie in der Klasse definiert sind. Eigenschaften, die nicht in der Initialisierungsliste auftauchen, werden nicht explizit initialisiert. Für primitive Datentypen bedeutet dies, dass keine Initialisierung stattfindet. Für Eigenschaften, die auf Klassen basieren, wird der Standardkonstruktor aufgerufen. Für Eigenschaften in Initialisierungslisten kann jedoch ein geeigneter Konstruktor aufgerufen werden. Die Initialisierung von Eigenschaften über einen geeigneten Konstruktoraufruf ist nicht nur natürlicher als Eigenschaften per Standardkonstruktor zu initialisieren und ihnen dann im zweiten Schritt über den =-Operator einen Wert zuzuweisen. Initialisierungslisten sind auch aus Performance-Gründen vorzuziehen.


5. Überladen von Operatoren

Das Überladen von Operatoren kann den Umgang mit Klassen vereinfachen. So gewöhnt man sich zum Beispiel schnell daran, Variablen vom Typ std::string mit dem +-Operator zu verknüpfen. Da der Code leicht verständlich ist, ist dieser überladene Operator eines gutes Beispiel.

Wenn Sie Klassen definieren, überlegen Sie sich, ob es Sinn macht, den einen oder anderen Operator zu überladen. Ob es Sinn macht oder nicht hängt davon ab, ob die Anwendung eines Operators einleuchtend ist und den Code verständlicher macht. Es ist dabei von Vorteil, Operatoren ähnlich zu definieren, wie es verschiedene Klassen in der C++-Standardbibliothek tun. Da Entwickler im Allgemeinen mit dem C++-Standard vertraut sind, werden sie überladene Operatoren im Zusammenhang mit neuen Klassen einfacher einsetzen können, wenn sie eine ähnliche Bedeutung haben wie überladene Operatoren im C++-Standard.

Gute Beispiele für überladene Operatoren sind [], << oder >>. Der []-Operator wird von verschiedenen Containern überladen, um mit einem Index auf ein Element im Container zuzugreifen. Die <<- und >>-Operatoren werden im Zusammenhang mit Streams angewandt, um Daten auf einen Stream auszugeben oder von einem Stream zu lesen. Falls Sie Klassen erstellen, die ähnliche Operationen unterstützen - also zum Beispiel einen Zugriff per Index - würde sich das Überladen entsprechender Operatoren anbieten.

Operatoren, die nicht überladen werden sollten, sind ||, && und ,. Da Entwickler bei diesen Operatoren intuitiv von einer bestimmten Funktionsweise ausgehen und nicht daran denken, dass sich die Funktionsweise geändert haben könnte, wäre das Überladen dieser Operatoren kontraproduktiv und würde viele Entwickler verwirren.


6. Ausnahmen

Ausnahmen sollten dann geworfen werden, wenn eine im Allgemeinen fehlerfreie Funktion dennoch mal fehlschlägt. Ein gutes Beispiel ist der Operator new, mit dem dynamisch Speicher angefordert werden kann. new gibt einen Zeiger auf einen neuen Speicherbereich zurück. Im Allgemeinen kann man davon ausgehen, dass der Aufruf von new funktioniert. Da Computer nicht unbegrenzt Speicher haben, könnte es aber sein, dass eine dynamische Speicherallokation fehlschlägt und new keinen Zeiger auf neu reservierten Speicher zurückgegeben kann. Für diesen unwahrscheinlichen, aber dennoch möglichen Fall wirft new eine Ausnahme vom Typ std::bad_alloc.

So wie new sollten auch Ihre Funkionen und Klassen gegebenenfalls Ausnahmen werfen. Es hängt dabei von der Definition der entsprechenden Funktion oder Methode ab, ob ein Fehler normal ist und den Aufrufer nicht unbedingt überrascht - dann sollte keine Ausnahme geworfen werden, sondern ein Fehler zum Beispiel per Rückgabewert gemeldet werden. Tritt ein Fehler aber nur in Ausnahmefällen auf, so dass eine ständige Überprüfung eines Rückgabewerts dem Aufrufer nicht zugemutet werden soll, sollte besser eine Ausnahme geworfen werden.

Wenn eine Ausnahme geworfen wird, sollte ein geeigneter Datentyp gewählt werden, der die Art des Problems beschreibt. new wirft beispielsweise eine Ausnahme vom Typ std::bad_alloc, die verdeutlicht, dass eine Speicherallokation fehlgeschlagen ist. Gibt es für die Art des Problems, für das Sie eine Ausnahme werfen möchten, keinen passenden Datentyp aus dem C++-Standard, sollten Sie eine eigene Klasse definieren. Für den Fall, dass Sie keine eigene Klasse definieren wollen, sondern einfach nur einen allgemeinen Fehlertyp brauchen, empfiehlt sich die Klasse std::runtime_error.

Entscheiden Sie sich, eine neue Klasse zu definieren, sollten Sie sie in die Klassenhierarchie für Ausnahmen aus dem C++-Standard integrieren. Die Elternklasse, von der alle Ausnahme-Klassen im C++-Standard wie auch std::bad_alloc abgeleitet sind, ist std::exception. Wenn Sie Ihre Klasse von std::exception oder einer anderen Klasse der Ausnahme-Klassenhierarchie ableiten, kann mit folgender try-catch-Anweisung jede beliebige Ausnahme abgefangen werden.

try 
{ 
} 
catch (std::exception&) 
{ 
} 

Würde Ihre Klasse nicht in die Ausnahme-Klassenhierarchie des C++-Standards integriert werden, dürften Entwickler nicht vergessen, ihre try-catch-Anweisung anzupassen, die sämtliche Ausnahmen abfangen soll, bevor ein Programm ohne Meldung beendet wird. Das würde nicht nur einen Anwender verdutzt zurücklassen, sondern würde es auch für Entwickler schwierig machen nachzuvollziehen, aus welchem Grund ein Programm auf einmal beendet wurde.


7. Referenzen vs. Zeiger

Wenn ein Verweis auf ein anderes Objekt notwendig ist, kann in C++ sowohl eine Referenz als auch ein Zeiger verwendet werden. Der entscheidende Unterschied ist, dass eine Referenz einmal initialisiert nicht neu gesetzt werden kann. Eine Referenz verweist also immer auf das gleiche Objekt. Damit ist bereits klar, dass Sie dann einen Zeiger verwenden müssen, wenn auf unterschiedliche Objekte verwiesen werden muss. Haben Sie zum Beispiel verschiedene Fenster vom Typ fenster und wollen in einer Variablen speichern, welches Fenster momentan im Vordergrund liegt, müssen Sie eine Variable vom Typ fenster* anlegen. Denn diese kann jeweils neu gesetzt werden und die Adresse des fenster-Objekts speichern, das das momentan im Vordergrund liegende Fenster repräsentiert.

Beim Definieren von Funktionen, denen keine Kopie eines Objekts übergeben werden soll, stellt sich die Frage, ob der Parameter eine Referenz oder ein Zeiger sein soll. Grundsätzlich ist die Referenz vorzuziehen, da der Zugriff auf das Objekt dann etwas einfacher mit dem .-Operator erfolgen kann. Ist der Parameter für die Funktion aber optional, bietet sich ein Zeiger an, da ein Zeiger auf 0 gesetzt werden kann. Ist der Zeiger auf 0 gesetzt, bedeutet das für die Funktion, dass kein Parameter angegeben wurde. Ein Parameter, der eine Referenz oder ein Zeiger ist, kann also dem Aufrufer die zusätzliche Information bieten, dass in einem Fall ein Objekt angegeben werden muss, im anderen Fall die Angabe optional ist.


8. Const correctness

Das Schlüsselwort const kann in C++ verwendet werden, um Variablen als konstant zu definieren. Es wird typischerweise im Zusammenhang mit Funktions- und Methodendefinitionen verwendet, um dem Aufrufer mitzuteilen, ob ein Parameter von einer Funktion verändert wird oder nicht.

void f(int i, const char *c, bool &b); 

Obige Funktion f() erwartet drei Parameter: Der erste Parameter vom Typ int wird als Kopie übergeben. Der zweite Parameter wird ebenfalls als Kopie übergeben. Da es sich um einen Zeiger handelt, der auf einen Speicherbereich verweist, kann aber angegeben werden, ob der Speicherbereich verändert wird oder nicht. Die Angabe const char* bedeutet, dass der Zeiger c, der selbst eine Kopie ist, den Speicher, auf den er zeigt, nicht verändern wird. Der dritte Parameter ist eine Referenz auf eine Variable vom Typ bool. Da das Schlüsselwort const nicht verwendet wird, kann die referenzierte Variable von der Funktion geändert werden.

Es ist möglich, das Schlüsselwort const hinter Methodenköpfen anzugeben.

#include <string> 

class person 
{ 
  public: 
    person(std::string n) 
      : Name(n) 
    { 
    } 

    std::string name() const 
    { 
      return Name; 
    }; 

  private: 
    std::string Name; 
}; 

Die Klasse person bietet eine Methode name() an, die den Namen eines Objekts zurückgibt. Da name() ein Objekt nicht ändert, kann sie als const deklariert werden.

Eine Methode, die als const deklariert ist, kann ausschließlich Methoden der eigenen Klasse aufrufen, die ebenfalls als const deklariert sind. Für ein Objekt, das als const definiert ist, können ausschließlich Methoden aufgerufen werden, die ihrerseits mit const deklariert sind. Diese Regeln stellen sicher, dass zum Beispiel ein konstantes Objekt nicht versehentlich geändert wird, indem eine Methode aufgerufen wird, die nicht konstant ist.

Beachten Sie, dass const nicht bedeutet, dass eine Variable oder ein Objekt nicht geändert werden kann. Es handelt sich bei const vielmehr um ein Versprechen, dass eine Methode einen Parameter oder eine Eigenschaft nicht ändert. Eine Methode sollte sich an das Versprechen halten - sie muss es aber nicht. Es ist in C++ also durchaus möglich, eine Variable, die mit const definiert wurde, zu ändern. Da ein Aufrufer erwartet, dass sich eine Methode an das Versprechen hält, das sie mit const gegeben hat, sollte eine Methode idealerweise keine Änderungen vornehmen, die für den Aufrufer unerwartet und überraschend wären.

#include <iostream> 

void f(const int &a, int &b) 
{ 
  b = 99; 
} 

int main() 
{ 
  int i = 0; 
  f(i, i); 
  std::cout << i << std::endl; 
} 

Im obigen Beispiel wird eine Variable i im ersten Parameter als konstante Referenz und im zweiten Parameter als nicht-konstante Referenz an die Funktion f() weitergereicht. Die Funktion f() kann nun über den ersten Parameter a ausschließlich lesend auf die referenzierte Variable zugreifen, weil es sich um eine konstante Referenz handelt. Über den zweiten Parameter b kann jedoch der Wert in der referenzierten Variable geändert werden. Dass es sich um die jeweils gleiche Variable i handelt, spielt keine Rolle.

const bedeutet nicht, dass eine Variable nicht geändert werden kann. const bedeutet, dass eine Variable über eine mit const-definierte Referenz und einen mit const-definierten Zeiger nicht geändert werden kann. Über andere Referenzen und über andere Zeiger, die nicht konstant sind, darf die Variable sehr wohl geändert werden.