namespace cpp

C++ lernen, kennen, anwenden

Benutzer-Werkzeuge

Webseiten-Werkzeuge


lernen:checklist

Eine Checkliste für Klassen-Autoren

Knowledge can be acquired systematically; wisdom cannot.
— Andrew Koenig [Ruminations]

Zielstellung

Beim Klassen-Entwurf in C++ sind viele Aspekte gleichzeitig zu beachten. Und wenn vieles gleichzeitig geschieht, kann auch vieles zugleich schief gehen [Murphy]. Eine Checkliste kann helfen, nichts aus dem Auge zu verlieren.

Es gibt eine Menge Bücher über C++. Die meisten (guten) wurden in Englisch geschrieben. Und leider wurden einige der guten (bisher) nicht ins Deutsche übersetzt. Ist der deutsche Markt zu klein? Fehlen gute Übersetzer? Dieser Text gibt gekürzt ein Kapitel aus einem englisch geschriebenen Buch [Ruminations] wieder. Ich hoffe, dass diese Fassung trotzdem geeignet ist, Neulingen Anregung zu geben. Die genauer Interessierten ermuntere ich, zum Original zu greifen — und Englisch zu lernen!

Jeder gute Pilot benutzt eine Checkliste. Naive Passagiere ziehen verwundert die Augenbrauen hoch: Wer weiß, wie man ein Flugzeug fliegt, braucht so ein Ding sicher nicht! Dennoch: Sich um die Sicherheit kümmernde Piloten erklären, wie wichtig ein Schutzmechanismus ist, um nichts Wichtiges in einem Moment der Ablenkung zu vergessen. Auch erfahrene Berufspiloten haben schon vergessen, das Fahrwerk auszufahren, bis sie daran erinnert wurden, von einer Warnhupe oder den die Landebahn ritzenden Propellern.

Eine Checkliste ist keine "Du must … tun"-Liste. Ihr Zweck ist, an Dinge zu erinnern, die du vergessen haben könntest, statt dich einzuschränken. Wer einer Checkliste blind folgt, ohne darüber nachzudenken, wird wahrscheinlich auch Dinge vergessen.

Dies sind Fragen, die sich stellen, wenn du Klassen definierst. Es gibt keine "richtigen" Antworten auf diese Fragen. Es kommt darauf an, über sie nachzudenken und sicherzustellen, dass, was immer du tust, das Ergebnis einer bewussten Entscheidung ist und nicht Zufall.

Checkliste Klassen-Entwurf

  • Braucht deine Klasse einen Konstruktor?
  • Sind deine Daten-Attribute privat?
  • Hat deine Klasse einen Konstruktor ohne Parameter?
  • Initialisiert jeder Konstruktor jedes Daten-Attribut?
  • Braucht die Klasse einen Destruktor?
  • Braucht die Klasse einen virtuellen Destruktor?
  • Braucht die Klasse einen Kopierkonstruktor?
  • Braucht die Klasse einen Zuweisungsoperator?
  • Behandelt der Zuweisungsoperator die Selbstzuweisung richtig?
  • Braucht die Klasse Vergleichsoperatoren?
  • Hast du delete[ ] zum Löschen eines Feldes geschrieben?
  • Sind die Originale von Kopierkonstruktor und Zuweisungsoperator const?
  • Sollten Referenz-Argumente von Funktionen als const deklariert werden?
  • Hast du die Methoden richtig als const deklariert?

Braucht deine Klasse einen Konstruktor?

Falls die Antwort "Nein" ist, liest du das vielleicht zu unpassender Zeit. Einige Klassen brauchen keine Konstruktoren, weil sie so einfach sind, dass ihre Struktur ihre Schnittstelle ist. Wir haben jedoch meist mit Klassen zu tun, die kompliziert genug sind.

Sind deine Daten-Attribute privat?

Öffentliche Daten sind oft eine schlechte Idee, weil der Klassen-Schöpfer keinen Einfluss auf den Zugriff hat:

class Array
{
public:
  int length; // sehr zweifelhaft!
  // ...
};

Flexibler einsetzbar sind Abfragemethoden:

class Array
{
public:
  int length() const; // viel besser
  // ...
};

Auch denkbar, aber mühseliger:

class Array
{
public:
  const int& length; // in !jedem! Konstruktor an true_length binden
  // ...
private:
  int true_length;
};

Es gibt keinen "besten" Stil, wie Funktionen zu schreiben sind, die auf Teile der Klasse zugreifen oder diese ändern. Zum Beispiel zwei Funktionen für die Feldgröße: Sollen sie Namen wie set_length() und get_length() haben? Sollen beide length() heißen und sich durch die Anzahl der Argumente unterscheiden? Soll die ändernde Funktion einen Wert zurückliefern oder nicht? Wenn sie einen liefert, den neuen oder den vorherigen? Oder soll sie etwas ganz anderes liefern, etwa einen Hinweis, ob die Anforderung erfolgreich war? Nimm eine Stil-Konvention, nutze sie durchgängig und dokumentiere sie klar.

Hat deine Klasse einen Konstruktor ohne Parameter?

Wenn eine Klasse mindestens einen Konstruktor hat und du

Klasse objekt;

ermöglichen willst, musst du ausdrücklich noch einen Konstruktor ohne Parameter schreiben. Sonst wird das nicht möglich sein. Dies kann absichtlich Teil des Entwurfes sein, aber vergiss nicht, darüber nachzudenken. Auch wenn du forderst, das alle Objekte initialisiert werden müssen, ist abzuwägen, dass du mit diesem Beharren Felder von Objekten verbietest:

Klasse objekte[100]; // brauchen Konstruktor Klasse()

Initialisiert jeder Konstruktor jedes Daten-Attribut?

Die Aufgabe des Konstruktors ist, das Objekt in einen wohldefinierten Zustand zu versetzen. Der Zustand spiegelt sich in den Datenwerten des Objekts. Wenn irgendein Konstruktor dabei versagt, kann das künftigen Ärger andeuten.

Braucht die Klasse einen Destruktor?

Nicht alle Klassen brauchen einen. Es reicht gewöhnlich nachzudenken, ob die Klasse Ressourcen allokiert, die nicht automatisch freigegeben werden. Jeder new-Ausdruck in den Konstruktoren braucht gewöhnlich einen entsprechenden delete-Ausdruck im Destruktor.

C++11 / C++14: Nutze std::make_unique<T>(…) bzw. std::make_shared<T>(…) zum Erzeugen und std::unique-ptr<T> bzw. std::shared_ptr<T> zum Verwalten dynamischer Objekte, um das RAII-Prinzip durchzusetzen (resource akquisition is initialization). Dies erspart in vielen Fällen das Schreiben eines Destruktors.

Braucht die Klasse einen virtuellen Destruktor?

Virtuelle Funktionen sind nur bei Vererbung sinnvoll. Klassen, die nie als Basis dienen, brauchen keinen. Falls jedoch jemals jemand ein dynamisches abgeleitetes Objekt über einen Basiszeiger löscht, muss der richtige Destruktor aufgerufen werden:

class Base
{
public:
  std::string eins;
  virtual ~Base() {}  // virtueller Destruktor in Basisklasse
};
 
class Derived : public Base
{
public:
  std::string zwei;
};
 
int main()
{
  Base* pBase = new Derived;
  delete pBase;  // ohne virtual ~B() würde string zwei nicht freigegeben
  //...
}

Obwohl die Basisklasse keine überhaupt keine Funktionen hat, entsteht ein Speicherleck, wenn die Basisklasse keinen virtuellen Destruktor hat. [Anm.: Viele Bücher über C++ erwähnen dies nicht oder erst zu weit hinten.] Virtuelle Destruktoren sind meist leer.

Braucht die Klasse einen Kopierkonstruktor?

Oft ist die Antwort "Nein". Eine Klasse mit Destruktor (vom leeren virtuellen abgesehen) hat diesen gewöhnlich, weil der Ressourcen freizugeben hat, die seine Konstruktoren allokiert haben. Dann braucht sie vermutlich auch einen Kopierkonstruktor (und Zuweisungsoperator):

class Array
{
public:
  Array(int anzahl, float startwert);     // anzahl floats mit startwert
  ~Array();
  Array(const Array& original);            // Kopierkonstruktor
  Array& operator=(const Array& original); // Zuweisungsoperator
  //...
private:
  float *data;
};
 
Array a(10, 3.14f);
Array b = a;        // Kopie
b = a;              // Zuweisung

Ohne Kopierkonstruktor erhält eine Kopie einfach einen Zeiger auf denselben dynamischen Speicher. Werden Original und Kopie vernichtet, wird dieser Speicher unzulässig zweimal freigegeben!

Sollen keine Kopien erzeugt oder keine Zuweisungen ausgeführt werden dürfen, erkläre Kopierkonstruktor bzw. Zuweisungsoperator als privat oder = delete. Diese brauchen auch keinen Rumpf, weil sie dadurch nie aufgerufen werden können.

Braucht die Klasse einen Zuweisungsoperator?

Braucht sie einen Kopierkonstruktor, dann braucht sie auch einen Zuweisungsoperator, aus denselben Gründen (Rule of Three). Der Zuweisungsoperator sollte gewöhnlich eine Referenz auf den linken Operanden der Zuweisung liefern und enden mit der Anweisung:

  return *this;

Behandelt der Zuweisungsoperator die Selbstzuweisung richtig?

Selbstzuweisung wird so oft falsch gemacht, dass mehr als ein Buch über C++ es vergeigt. Zuweisung soll einen alten Wert durch einen neuen ersetzen. Wenn wir den alten Zielwert freigeben, bevor wir den Quellwert kopieren und Quelle und Ziel dasselbe Objekt sind, zerstören wir auch die Quelldaten vor dem Kopieren! Der einfachste Weg das Problem zu vermeiden ist, sich ausdrücklich davor zu schützen:

Array& Array::operator=(const Array& quelle)
{
  if (this != &quelle)  // Schutz vor a = a;
  {
    // alte Daten freigeben
    // neuen Speicher holen
    // Daten kopieren
  }
  return *this;
}

Allerdings versagt auch dieses Verfahren, wenn die Beschaffung des neuen Speichers fehlschlägt: Nach der Speicherfreigabe kann das Objekt nicht mehr benutzt werden!

Eleganter und ausnahmesicher ist das von [Sutter, p. 47, 172] vorgeschlagene Verfahren (copy assignment oder copy & swap):

#include <algorithm>
 
Array& Array::operator=(const Array& quelle)
{
  Array temp(quelle);         // das kann scheitern,
  std::swap(data, temp.data); // zerstört aber *this nicht
  return *this;              
}                             // Freigabe alter Ressourcen im temp-Destruktor     

Es nützt, den schon existierenden Kopierkonstruktor statt dessen Anweisungsfolge zu wiederholen. Zudem ist eine (eher selten auftretende) Selbstzuweisung unkritisch. Eine Variante mit Wertparameter erzeugt die temporäre Kopie schon beim Aufruf:

Array& Array::operator=(Array quelle)
{
  swap(quelle);
  return *this;
}

Der Attribut-Tausch

void Array::swap(Array& quelle)
{
  std::swap(data, tmp.data); // für alle (!) Attribute
}

kann bei Klassen mit umfangreichen dynamischen Daten als Zeigertausch effizient implementiert werden und ist damit auch als öffentliche Methode interessant.

Braucht die Klasse Vergleichsoperatoren?

C++ hat allgemein verwendbare Bibliotheken und Containerklassen für Datenstrukturen wie Listen, Mengen, Vektoren. Diese Container arbeiten mit den Operationen der Objekte, welche sie enthalten. Eine häufige Anforderung ist festzustellen, ob zwei Werte äquivalent sind oder einer größer oder kleiner als der andere ist. Wenn die Klasse das Konzept der Gleichheit unterstützt, ist es sinnvoll, operator== und operator!= anzugeben. Gibt es irgendeine Ordnungsrelation für die Werte der Klasse, sind die anderen Operatoren (<, > etc.) auch zu definieren, auch wenn sie nicht direkt benutzt werden. Geordnete Mengen von Objekten (std::set<T>, std::map<X,U>) brauchen sie.

Hast du delete[] zum Löschen eines Feldes geschrieben?

Zu new T gehört delete, zu new T[] gehört delete []. Die seltsame [ ]-Syntax kommt aus dem Streben, Kompatibilität zu C und Effizienz zu verbinden. C++ muss new mittels der C-Funktion malloc() implementieren. Die C++-Implementierung kann jedoch nicht unbedingt herausfinden, wie groß der freizugebende Speicher ist. Obwohl malloc() diese Größe speichern muss, kann dies an einer für C++ nicht sichtbaren Stelle geschehen. Als Kompromiss fordert C++ von seinen Nutzern, anzugeben, ob das freizugebende Speicher ein Feld ist.

Sind die Originale von Kopierkonstruktor und Zuweisungsoperator const?

Kopieren ändert die Originale nicht. Fast immer.

Sollten Referenz-Argumente von Funktionen als const deklariert werden?

Funktionen sollten nicht konstante Referenzen nur dann annehmen, wenn sie sie zu verändern und dem Aufrufer geändert zu hinterlassen beabsichtigen.

Hast du die Methoden richtig als const deklariert?

Ändert eine Methode garantiert keine Werte des Objekts, erkläre sie als const, damit sie bei konstanten Objekten genutzt werden kann const correctness:

class Array
{
public:
  int  length() const;         // abfragen
  void length(int newlength);  // neu setzen
  // ...
};

Nachbetrachtung

Diese Liste provoziert natürlich die Frage, warum C++ diese Dinge nicht irgendwie automatisch regelt. Der Hauptgrund ist, dass es nicht möglich ist, dies zu tun und dabei sicher zu sein, es richtig getan zu haben. Dies automatisch machen zu wollen, ist so ähnlich wie der Versuch, Flugzeuge zu entwerfen, die ihr Fahrwerk automatisch unter den richtigen Umständen ausfahren. Konstrukteure haben das versucht, mit begrenztem Erfolg. Die "richtigen Umstände" sind so schwer auf eine zuverlässige Weise definierbar, die mechanisch umsetzbar ist. Daher muss in der Praxis der Pilot die "Fahrwerk ausgefahren"-Lampe immer noch prüfen. Also bringt Automation nicht viel. Das Fahrwerk die ganze Zeit draußen zu lassen, würde das Problem zwar auch lösen, erzeugt aber nicht akzeptable Kosten. Das gleiche Argument gilt für den Entwurf einer Programmiersprache. Einer Klasse einen virtuellen Destruktor mitzugeben, erzeugt Overhead. Ist die Klasse winzig, kann dieser Zusatz merklich sein. Allen Klassen virtuelle Destruktoren mitzugeben widerspricht der Philosophie von C++, die Leute nur für das bezahlen zu lassen, was sie benutzen. Es wäre so, wie das Fahrwerk immer ausgefahren zu lassen. Die Alternative wäre, den Compiler irgendwie herausfinden zu lassen, wann eine Klasse einen virtuellen Destruktor braucht und ihn dann automatisch anzulegen. Wiederum liegt das Problem in der exakten Definition, wann das zu geschehen hat. Wäre die Definition nicht perfekt, müssten die Programmierer es nachprüfen. Das wäre mindestens genauso viel Arbeit wie den virtuellen Destruktor selbst am richtigen Ort festzulegen.

Mit anderen Worten: C++ ist bevorzugt etwas für Programmierer, die selbst denken. C++ zu nutzen erfordert Denken, in derselben Weise wie jedes professionelle Werkzeug.

Quellen

  • [Murphy] Joachim Graf: Murphys Computergesetze. Markt&Technik (1990). ISBN 3-89090-949-3.
  • [Ruminations] Andrew Koenig, Barbara Moo: Ruminations on C++. A decade of Programming Insight and Experience. Addison-Wesley (1997). ISBN 0-201-42339-1.
  • [Sutter] Herb Sutter: Exceptional C++. Addsion-Wesley (2000). ISBN 0-201-61562-2.
lernen/checklist.txt · Zuletzt geändert: 2014-07-13 16:14 (Externe Bearbeitung)