Aus Linux-Magazin 12/2019

Modernes C++ in der Praxis – Folge 49

© GlebTV, 123RF

Soll der Zugriff auf polymorphe Objekte gelingen, muss der Entwickler ein paar Regeln im Auge behalten. Andernfalls stiften Phänomene wie Slicing und frühe Bindung Verwirrung.

Adressiert ein C++-Programmierer polymorphe Objekte nicht über Zeiger oder Referenzen, kommen zwei dunkle Phänomene ans Tageslicht: Slicing und frühe Bindung. Genau davon handelt die Regel C.145 [1] in den C++ Core Guidelines: “Greife über Pointer und Referenzen auf polymorphe Objekte zu.”

Zuerst beschäftigt sich der Artikel mit dem Phänomen Slicing: Beim Kopieren eines Objekts gehen Teile verloren, weshalb eine Zuweisung oder Initialisierung nur einen Teil des Objekts betrifft. Listing 1 zeigt das überraschende Verhalten.

Listing 1

Slicing in Aktion

struct Base {
  int base{1998};
}
struct Derived : Base {
  int derived{2011};
}
void needB(Base b){
  // [...]
}
int main(){
  Derived d;
  Base b = d;
  Base b2(d);
  needB(d);
}

Die Zeilen 15, 16 und 17 erzeugen alle denselben Effekt: Sie ignorieren den »Derived«-Anteil von »d«. Das dürfte nicht im Sinne des Erfinders sein.

Slicing ist eine der dunkelsten Ecken in C++. In Listing 2 wird es noch düsterer. Hier geht es genau um die erwähnte Regel: Der Code soll mittels Zeiger oder Referenz auf polymorphe Objekte zugreifen. Das wirft zunächst die Frage auf: Was ist ein polymorphes Objekt? Dabei handelt es sich um ein Objekt, das mindestens eine virtuelle Methode besitzt.

Listing 2

Virtualität im Einsatz

#include <iostream>
#include <string>
struct Base {
  virtual std::string getName() const {
    return "Base";
  }
};
struct Derived : Base {
  std::string getName() const override {
    return "Derived";
  }
};
int main(){
  std::cout << std::endl;
  Base b;
  std::cout << "b.getName(): " \
            << b.getName() << std::endl;
  Derived d;
  std::cout << "d.getName(): " \
            << d.getName() << std::endl;
  Base b1 = d;
  std::cout << "b1.getName():  " \
            << b1.getName() << std::endl;
  Base& b2 = d;
  std::cout << "b2.getName():  " \
            << b2.getName() << std::endl;
  Base* b3 = new Derived;
  std::cout << "b3->getName(): " \
            << b3->getName() << std::endl;
  std::cout << std::endl;
}

Das Beispiel konstruiert eine kleine Klassenhierarchie, bestehend aus einer »Base«- und einer »Derived«-Klasse. Jedes Objekt der Klassenhierarchie soll seinen Namen zurückgeben. Dazu gilt es, die Methode »getName()« (Zeile 5) virtuell zu deklarieren und zu überschreiben (Zeile 11). Jetzt unterstützt die Klassenhierarchie Polymorphie. Das heißt, ein abgeleitetes Objekt lässt sich mittels Referenz (Zeile 32) oder Zeiger (Zeile 36) auf ein Objekt der Basisklasse verwenden.

Unter der Haube ist das Objekt vom Typ »Derived«. Dies gilt allerdings nicht, wenn das Listing nur »Derived d« nach »Base b1« kopiert (Zeile 28). In diesem Fall schlägt das Slicing erneut zu, denn »b1« ist unter der Decke ein »Base«-Objekt. Beim Kopieren kommt der deklarierte oder statische Typ zum Einsatz.

Verwendet der Entwickler jedoch eine Indirektion wie eine Referenz oder einen Zeiger, benutzt C++ den tatsächlichen oder dynamischen Typ. Hier spricht die C++-Community schlicht von der frühen versus der späten Bindung. Abbildung 1 stellt die Ausgabe des Programms dar.

Abbildung 1: Virtualit&auml;t im Einsatz zeigt die Ausgabe von <a href="#artRef-l2">Listing 2</a>.

Abbildung 1: Virtualität im Einsatz zeigt die Ausgabe von Listing 2.

Am besten behält man folgende Regel zur Polymorphie im Kopf: Sollen sich Instanzen einer Klasse polymorph verhalten, muss die Klasse zumindest eine virtuelle Methode deklarieren oder erben. Darüber hinaus muss der C++-Entwickler Instanzen dieser Klasse mit einer Indirektion wie einer Referenz oder einem Zeiger programmieren.

Rechne mit mir

Operator Overloading erlaubt es, benutzerdefinierte Datentypen festzulegen, die sich wie Built-in-Datentypen verhalten. Gerne überladen Programmierer zum Beispiel den »+«-Operator, um benutzerdefinierte Typen zu ergänzen.

Symmetrische Operatoren wie den »+«-Operator soll man laut den C++ Core Guidelines jedoch außerhalb der Klasse definieren. Konkret sagt Regel C.161 [2]: “Benutze Nonmember-Funktionen für symmetrische Operatoren.” Warum? In der Regel ist das Implementieren eines symmetrischen Operators in einer Klasse nicht möglich.

Die Klasse »MyInt« soll die Addition mit dem fundamentalen Datentyp »int« unterstützen. Die erste naive Umsetzung der Anforderung in Listing 3 bringt noch nicht das gewünschte Ergebnis.

Listing 3

Naiver +-Operator

// Naive Implementierung des <C>+<C>-Operators
struct MyInt{
  MyInt(int v):val(v){};
  MyInt operator+(const MyInt& oth) const {
    return MyInt(val + oth.val);
  }
  int val;
};
int main(){
  MyInt myFive = MyInt(2) + MyInt(3);
  MyInt myFive2 = MyInt(3) + MyInt(2);
  MyInt myTen = myFive + 5;
  MyInt myTen2 = 5 + myFive;
}

Der implizite Konvertierungskonstruktor (Zeile 4) macht den Ausdruck in Zeile 15 gültig, jedoch nicht jenen in der Folgezeile: C++ konvertiert die »5« im Ausdruck »5 + myFive« nicht automatisch nach »MyInt«. Genauer gesagt scheitert das Kompilieren des Programms, da der fundamentale Datentyp »int« den Operator »+« nicht für »MyInt« überladen hat. Abbildung 2 zeigt den entsprechenden Compiler-Fehler.

Abbildung 2: Die fehlende &Uuml;berladung von &raquo;+&laquo; durch &raquo;int&laquo; erzeugt einen Compiler-Fehler.

Abbildung 2: Die fehlende Überladung von »+« durch »int« erzeugt einen Compiler-Fehler.

Das kleine Programm in Listing 3 besitzt jedoch noch drei weitere Probleme: Der »+«-Operator ist nicht symmetrisch, die »val«-Variable »public« und der Konvertierungskonstruktor implizit.

Ein Entwickler kann die ersten zwei Probleme einfach lösen, indem er einen freien »+«-Operator wie in Listing 4 einsetzt, den die Klasse als »friend()« deklariert. Die »friend()«-Methoden verhalten sich dabei so wie freie Funktionen und greifen auf die Interna der Klasse zu.

Listing 4

Ein freier +-Operator

class MyInt2{
public:
  MyInt2(int v):val(v){};
  friend MyInt2 operator+(const MyInt2& fir, const MyInt2& sec){
    return MyInt2(fir.val + sec.val);
  }
private:
  int val;
};
int main(){
  MyInt2 myFive = MyInt2(2) + MyInt2(3);
  MyInt2 myFive2 = MyInt2(3) + MyInt2(2);
  MyInt2 myTen = myFive + 5;
  MyInt2 myTen2 = 5 + myFive;
}

Nun springt die implizite Konvertierung von »int« nach »MyInt2« ein, und die Variable »val« ist privat. Dem Wortlaut der Regel C.164 (“Vermeide Konvertierungs-Operatoren” [3]) folgend gilt aber, dass der Programmierer keinen impliziten Konvertierungskonstruktor anwenden sollte. Dabei handelt es sich um einen Konstruktor, der ein Argument annimmt.

Im konkreten Fall empfängt er einen »int«-Wert und erzeugt daraus eine Instanz vom Datentyp »MyInt2«. Er konvertiert damit den »int«-Wert in eine Instanz vom Datentyp »MyInt2«. Setzt der Entwickler den Konvertierungskonstruktor auf explizit, lässt sich das Programm nicht mehr übersetzen (Listing 5).

Listing 5

Expliziter Konstruktor

// Expliziter Konvertierungskonstruktor
class MyInt3{
public:
  explicit MyInt3(int v):val(v){};
  friend MyInt3 operator+(const MyInt3& fir, const MyInt3& sec){
    return MyInt3(fir.val + sec.val);
  }
private:
  int val;
};
int main(){
  MyInt3 myFive = MyInt3(2) + MyInt3(3);
  MyInt3 myFive2 = MyInt3(3) + MyInt3(2);
  MyInt3 myTen = myFive + 5;
  MyInt3 myTen2 = 5 + myFive;
}

Wegen des expliziten Konvertierungskonstruktors in Zeile 4 ist die implizite Konvertierung von »int« nach »MyInt3« nicht mehr zulässig, und die Zeilen 17 und 18 führen zu einem Fehler (Abbildung 3).

Abbildung 3: Ein expliziter Konvertierungskonstruktor verhindert in <a href="#artRef-l5">Listing&nbsp;5</a> die implizite Konvertierung.

Abbildung 3: Ein expliziter Konvertierungskonstruktor verhindert in Listing 5 die implizite Konvertierung.

Der naheliegendste Weg, um die ursprüngliche Anforderung umzusetzen, ist der, zwei zusätzliche »+«-Operatoren für »MyInt4« anzubieten (Listing 6). Ein zusätzlicher »+«-Operator nimmt »int« als linkes Argument an (Zeile 10), der andere »int« als rechtes Argument (Zeile 13).

Listing 6

Zusätzliche +-Operatoren

// Die Klasse <C>MyInt4<C> mit zwei
// zusätzlichen <C>+<C>-Operatoren
class MyInt4{
public:
  explicit MyInt4(int v):val(v){};
  friend MyInt4 operator+(const MyInt4& fir, const MyInt4& sec){
    return MyInt4(fir.val + sec.val);
  }
  friend MyInt4 operator+(const MyInt4& fir, int sec){
    return MyInt4(fir.val + sec);
  }
  friend MyInt4 operator+(int fir, const MyInt4& sec){
    return MyInt4(fir + sec.val);
  }
private:
  int val;
};
int main(){
  MyInt4 myFive = MyInt4(2) + MyInt4(3);
  MyInt4 myFive2 = MyInt4(3) + MyInt4(2);
  MyInt4 myTen = myFive + 5;
  MyInt4 myTen2 = 5 + myFive;
}

Hin und her

Wichtig ist auch, dass der Entwickler implizite Konvertierungen nicht nur beim Konvertierungskonstruktor unterbindet, sondern auch beim Konvertierungsoperator. Der feine Unterschied zwischen beiden besteht darin, dass ein Konvertierungskonstruktor zur Klasse hin konvertiert, ein Konvertierungsoperator hingegen von der Klasse weg. Beide sollten laut der erwähnten Regel C.164 explizit sein.

Überlädt der Entwickler den Operator »bool« als implizit für eine Klasse »MyHouse«, sind Überraschungen wortwörtlich programmiert. Auf diese Weise lässt sich eine Instanz von »MyHouse« auch als arithmetischer Datentyp verwenden.

Listing 7 enthält die Klasse »MyHouse«, die Immobilien definiert. Steht ein Objekt vom Datentyp »MyHouse« leer, darf eine Familie es kaufen. Daher ist es sehr praktisch, den Operator »bool« zu überladen, um einfach zu testen, ob bereits eine Familie ein Haus gekauft hat.

Listing 7

Implizites Konvertieren

// Implizites Konvertieren nach <C>int<C>
#include <iostream>
#include <string>
struct MyHouse{
  MyHouse() = default;
  MyHouse(const std::string& fam): family(fam){}
  operator bool(){ return not family.empty(); }
  // explicit operator bool(){ return not family.empty(); }
  std::string family = "";
};
int main(){
  std::cout << std::boolalpha << std::endl;
  MyHouse firstHouse;
  if (not firstHouse){
    std::cout << "firstHouse is already sold." << std::endl;
  };
  MyHouse secondHouse("grimm");
  if (secondHouse){
    std::cout << "Grimm bought secondHouse." << std::endl;
  }
  std::cout << std::endl;
  int myNewHouse = firstHouse + secondHouse;
  auto myNewHouse2 = (20 * firstHouse - 10 * secondHouse) / secondHouse;
  std::cout << "myNewHouse: " << myNewHouse << std::endl;
  std::cout << "myNewHouse2: " << myNewHouse2 << std::endl;
  std::cout << std::endl;
}

Schnell lässt sich mit dem Operator »bool« prüfen (Zeile 9), ob eine Familie in dem Haus wohnt (Zeile 20) oder nicht (Zeile 24). Dank des impliziten Operators »bool« finden sich nun aber auch Häuser in arithmetischen Ausdrücken (Zeile 32). Das ist sicherlich nicht im Sinne des Erfinders (Abbildung 4).

Abbildung 4: Ein implizierter Konvertierungsoperator.

Abbildung 4: Ein implizierter Konvertierungsoperator.

»MyHouse« bietet ein viel zu mächtiges Interface an. Seit C++ 11 lässt sich ein Konvertierungsoperator als explizit deklarieren. Damit wendet der Compiler keine implizite Konvertierung nach »int« an.

Kommt hingegen der explizite Operator »bool« zum Einsatz (Zeile 10), darf man Häuser nicht mehr addieren (kann sie jedoch in logischen Ausdrücken verwenden). Abbildung 5 zeigt, dass nun die Übersetzung des Programms fehlschlägt.

Abbildung 5: Ein expliziter Konvertierungsoperator l&auml;sst die &Uuml;bersetzung des Programms in <a href="#artRef-l7">Listing 7</a> scheitern.

Abbildung 5: Ein expliziter Konvertierungsoperator lässt die Übersetzung des Programms in Listing 7 scheitern.

Wie geht es weiter?

Der nächste Artikel zu den C++ Core Guidelines dreht sich um ein zentrales Anliegen von C++: den sorgsamen Umgang mit Ressourcen. (kki)

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
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben