C++ Core Guidelines: Type Erasure

Modernes C++  –  15 Kommentare

Die Regel "T.5: Combine generic and OO techniques to amplify their strengths, not their costs" zur generischen Programmierung verwendet Type Erasure als Beispiel. Um Type Erasure zu erklären, muss ich ein wenig ausholen.

Zuerst was ist Type Erasure?

  • Type Erasure: Es erlaubt, verschiedene Datentypen mit einem generischen Interface zu verwenden.

Du hast vermutlich relativ häufig Type Erasure in deinem C- oder C++-Code verwendet. Der C-Weg zu Type Erasure führt über void-Zeiger; der bei C++ hingegen über Objektorientierung.

Ein genauerer Blick auf std::qsort ist sehr interessant:

void qsort(void *ptr, std::size_t count, std::size_t size, cmp);

Für die Vergleichsfunktion cmp gilt:

int cmp(const void *a, const void *b);

Die Vergleichsfunktion cmp sollte dabei die folgenden Werte zurückgeben:

  • kleiner Null, wenn das erste Argument kleiner ist als das zweite
  • Null, wenn beide Argumente identisch sind
  • größer Null, wenn das erste Argument größer ist als das zweite

Dank des void-Zeigers lässt sich std::qsort sehr generisch verwenden, ist aber auch sehr fehlerbehaftet.

Es kann relativ leicht passieren, dass du einen std::vector<int> verwenden willst, deine Vergleichsfunktion aber C-Strings vergleicht. Der Compiler kann diesen Fehler nicht detektieren, da die Typinformation entfernt wurden. Daher endest du mit "undefined behaviour".

Das geht aber besser in C++.

Los geht es mit einem einfachen Beispiel, das als Startpunkt für weitere Variationen dienen wird:

// typeErasureOO.cpp

#include <iostream>
#include <string>
#include <vector>

struct BaseClass{ // (2)
virtual std::string getName() const = 0;
};

struct Bar: BaseClass{ // (4)
std::string getName() const override {
return "Bar";
}
};

struct Foo: BaseClass{ // (4)
std::string getName() const override{
return "Foo";
}
};

void printName(std::vector<const BaseClass*> vec){ // (3)
for (auto v: vec) std::cout << v->getName() << std::endl;
}


int main(){

std::cout << std::endl;

Foo foo;
Bar bar;

std::vector<const BaseClass*> vec{&foo, &bar}; // (1)

printName(vec);

std::cout << std::endl;

}

std::vector<const Base*> (1) besitzt als Parameter einen Zeiger auf ein konstantes BaseClass. Das ist eine abstrakte Basisklasse, die in (3) verwendet wird. foo und bar sind konkrete Klassen (4).

Die Ausgabe ist nicht so aufregend:

Um es formaler auszudrücken: foo und bar setzen das Interface der Klasse BaseClass um und können daher anstelle von BaseClass verwendet werden. Dieses Prinzip wird Liskovsches Substitutionsprinzip genannt und stellt Type Erasure mit OO dar.

In objektorientierten Sprache implementierst du ein Interface. In dynamisch typisierten Sprachen wie Python interessieren dich Interfaces nicht. In diesen Sprachen interessiert dich Verhalten.

Hier möchte ich einen kleinen Ausflug machen. In Python programmierst du gegen Verhalten und nicht gegen formale Interfaces. Für diese Idee steht der bekannte Ausdruck Duck-Typing. Um mich kurz zu halten: Der Begriff geht auf ein Gedicht von James Whitcomb Rileys zurück:

“When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.”

Was soll das heißen? Stelle dir eine Funktion void acceptsOnlyDucks(Duck& duck) vor, die nur Ducks als Argument annimmt. In statisch typisierten Sprachen wie C++ kann diese Funktion von Datentypen verwendet werden, die öffentlich von Duck abgeleitet sind. In Python können hingegen alle Datentypen verwendet werden, die sich wie eine Duck verhalten. Um es deutlich auf den Punkt zu bringen: Wenn ein Vogel sich wie eine Ente verhält, dann ist es eine Ente. Ein Sprichwort in Python bringt das verschärft auf den Punkt: "Don't ask for permission, ask for forgiveness."

In unserem Fall bedeutet dies, dass du einfach die Funktion acceptsOnlyDucks mit einem Vogel aufrufst und auf das Beste hoffst. Falls dies nicht gut geht, fängst du die Ausnahme in einem Except Handler. Diese Strategie funktioniert ziemlich gut und schnell in Python.

Das ist aber nur das Ende meines Ausflugs. Vermutlich wunderst du dich, warum ich über Duck-Typing in einem C++-Artikel geschrieben habe. Meine Antwort ist ganz einfach. Dank Templates besitzen wir Duck-Typing in C++. Wenn du dazu noch Duck-Ttyping mit der Objektorientierung verknüpfst, wird es sogar noch typsicher.

std::function als polymorpher Funktions-Wrapper ist ein nettes und prominentes Beispiel für Type Erasure in C++.

std::function kann alles annehmen, was sich wie eine Funktion verhält. Genauer gesagt heißt dies, dass std::function zum Beispiel eine Funktion, ein Funktionsobjekt, ein von std::bind erzeugtes Funktionsobjekt oder einfach nur eine Lambda-Funktion annehmen kann:

// callable.cpp

#include <cmath>
#include <functional>
#include <iostream>
#include <map>

double add(double a, double b){
return a + b;
}

struct Sub{
double operator()(double a, double b){
return a - b;
}
};

double multThree(double a, double b, double c){
return a * b * c;
}

int main(){

using namespace std::placeholders; // (6)

std::cout << std::endl;

std::map<const char , std::function<double(double, double)>> dispTable{ // (1)
{'+', add }, // (2)
{'-', Sub() }, // (3)
{'*', std::bind(multThree, 1, _1, _2) }, // (4)
{'/',[](double a, double b){ return a / b; }}}; // (5)

std::cout << "3.5 + 4.5 = " << dispTable['+'](3.5, 4.5) << std::endl;
std::cout << "3.5 - 4.5 = " << dispTable['-'](3.5, 4.5) << std::endl;
std::cout << "3.5 * 4.5 = " << dispTable['*'](3.5, 4.5) << std::endl;
std::cout << "3.5 / 4.5 = " << dispTable['/'](3.5, 4.5) << std::endl;

std::cout << std::endl;

}

Ich verwendete in diesem Beispiel ein Dispatch Table (1), das in diesem Fall Buchstaben auf aufrufbare Einheiten (Callables) abbildet. Eine aufrufbare Einheit kann eine Funktion (2), ein Funktionsobjekt (3), ein durch std::bind erzeugtes Funktionsobjekt (4) oder eine Lambda-Funktion (5) sein. Die Ausdrücke _1, und _2 in (4) stehen für Platzhalter (6).

Der entscheidende Punkt von std::function ist es, dass diese verschiedenen Funktionstypen annimmt und ihre Typinformation reduziert (Type Erasure). std::function fordert von ihren aufrufbaren Einheiten, dass sie zwei [/i]double[/i]s annimmt und eine double zurückgibt: std::function<double(double, double)>. Der Vollständigkeit halber ist hier die Ausgabe:

Bevor ich in meinem nächsten Artikel tiefer auf Type Erasure eingehe, möchte ich die drei Techniken kurz vergleichen:

Du kannst Type Erasure mit void-Zeigern, Objektorientierung oder Templates umsetzen. Lediglich die Implementierung mit Templates ist typsicher und setzt keine Ableitungshierarchie voraus.

Wie Type Erasure mit Templates implementiert werden kann, darauf gehe ich in meinen nächsten Artikel ein.