Type Erasure
Type Erasure auf der Basis von Templates ist eine ziemlich ausgeklügelte Technik. Sie ermöglicht es, dynamische mit statischer Polymorphie zu verbinden.

- Rainer Grimm
Type Erasure ermöglicht es, verschiedene konkrete Typen über eine einzige generische Schnittstelle zu verwenden.
Die meisten haben Type Erasure schon oft in C++ oder C verwendet. Die C-artige Art von Type Erasure ist ein void
pointer; die klassische C++-artige Art von Type Erasure ist die Objektorientierung. Beginnen möchte ich mit einem void
-Zeiger.
void
-Zeiger
Schauen wir uns die Deklaration von std::qsort
genauer an:
void qsort(void *ptr, std::size_t count, std::size_t size, cmp);
mit:
int cmp(const void *a, const void *b);
Die Vergleichsfunktion cmp
sollte eine
- negative Ganzzahl: das erste Argument ist kleiner als das zweite
- Null: beide Argumente sind gleich
- positive ganze Zahl: das erste Argument ist größer als das zweite
zurĂĽckgeben.
Dank des void
-Zeigers ist std::qsort
zwar allgemein anwendbar, aber auch sehr fehleranfällig.
Vielleicht will man einen std::vector<int>
sortieren, hat aber eine Vergleichsfunktion fĂĽr C-Strings verwendet. Der Compiler kann diesen Fehler nicht abfangen, weil die notwendigen Typinformationen fehlen. Das hat zur Folge, dass dein Programm undefiniertes Verhalten besitzt.
In C++ können wir es besser umsetzen.
Objektorientierung
Hier ist ein einfaches Beispiel, das als Ausgangspunkt fĂĽr eine weitere Variante dient
// 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() << '\n';
}
int main(){
std::cout << '\n';
Foo foo;
Bar bar;
std::vector<const BaseClass*> vec{&foo, &bar}; // (1)
printName(vec);
std::cout << '\n';
}
std::vector<const Base*>
(1) hat einen Zeiger auf eine konstante BaseClass
. BaseClass
ist eine abstrakte Basisklasse, die in Zeile (3) verwendet wird. Foo
und Bar
(4) sind die konkreten Klassen.
Die Ausgabe des Programms verhält sich erwartungsgemäß.
Um es formaler zu formulieren: Foo
und Bar
implementieren die Schnittstelle der BaseClass
und können daher anstelle von BaseClass
verwendet werden. Dieses Prinzip wird Liskov-Substitutionsprinzip genannt und ist Type Erasure in OO.
In der objektorientierten Programmierung implementiert man eine Schnittstelle. Bei der generischen Programmierung beispielsweise mit Templates geht es nicht um Schnittstellen, sondern um das Verhalten. Mehr ĂĽber den Unterschied zwischen schnittstellenorientiertem und verhaltensorientiertem Design findet sich in meinem vorherigen Beitrag "Dynamischer und statischer Polymorphismus".
Type Erasure mit Templates schlieĂźt die LĂĽcke zwischen dynamischem Polymorphismus und statischem Polymorphismus.
Type Erasure
Beginnen möchte ich mit einem prominenten Beispiel für Type Erasure: std::function
, einem polymorphen Funktionswrapper. Sie kann alles akzeptieren, was sich wie eine Funktion verhält. Um genau zu sein. Dieses alles kann eine beliebige aufrufbare Funktion, ein Funktionsobjekt, ein von std::bind
erzeugtes Funktionsobjekt oder einfach ein Lambda-Ausdruck sein.
// 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;
std::cout << '\n';
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) << '\n';
std::cout << "3.5 - 4.5 = " << dispTable['-'](3.5, 4.5) << '\n';
std::cout << "3.5 * 4.5 = " << dispTable['*'](3.5, 4.5) << '\n';
std::cout << "3.5 / 4.5 = " << dispTable['/'](3.5, 4.5) << '\n';
std::cout << '\n';
}
In diesem Beispiel verwende ich eine Dispatch-Tabelle (1), die Zeichen auf Callables abbildet. Ein Callable kann eine Funktion (Zeile 1), ein Funktionsobjekt (2 und 3), ein von std::bind
erstelltes Funktionsobjekt (4) oder ein Lambda-Ausdruck (5) sein. Das Wichtigste an std::function
ist, dass es alle verschiedenen funktionsähnlichen Typen akzeptiert und ihre Typen löscht. std::function
verlangt von seinem Aufrufer, dass er zwei double
annimmt und ein double
zurĂĽckgibt: std::function<double(double, double)>
.
Hier ist die Ausgabe:
Nach dieser ersten Einführung in die Type Erasure möchte ich das Programm typeErasureOO.cpp
mithilfe der Type Erasure auf der Grundlage von Templates implementieren.
// typeErasure.cpp
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Object { // (2)
public:
template <typename T> // (3)
Object( T&& obj):
object(std::make_shared<Model<T>>(std::forward<T>(obj))){}
std::string getName() const { // (4)
return object->getName();
}
struct Concept { // (5)
virtual ~Concept() {}
virtual std::string getName() const = 0;
};
template< typename T > // (6)
struct Model : Concept {
Model(const T& t) : object(t) {}
std::string getName() const override {
return object.getName();
}
private:
T object;
};
std::shared_ptr<const Concept> object;
};
void printName(std::vector<Object> vec){ // (7)
for (auto v: vec) std::cout << v.getName() << '\n';
}
struct Bar{
std::string getName() const { // (8)
return "Bar";
}
};
struct Foo{
std::string getName() const { // (8)
return "Foo";
}
};
int main(){
std::cout << '\n';
std::vector<Object> vec{Object(Foo()), Object(Bar())}; // (1)
printName(vec);
std::cout << '\n';
}
Was passiert hier eigentlich? Die Namen Object, Concept und Model sollen nicht irritieren. Sie werden in der Literatur typischerweise fĂĽr Type Erasure verwendet. Deshalb verwende ich sie.
std::vector
verwendet Instanzen (1) vom Typ Object
(2) und keine Zeiger, wie im ersten OO-Beispiel. Diese Instanzen können mit beliebigen Typen erstellt werden, weil sie einen generischen Konstruktor besitzen (3). Object
hat die Memberfunktion getName
(4), die direkt auf den getName
von object
weiterleitet. object
ist vom Typ std::shared_ptr<const Concept>
. Die Mitgliedsfunktion getName
von Concept
ist rein virtuell (5). Daher wird die getName
-Methode von Model
(6) aufgrund des virtuellen Dispatch verwendet. Am Ende werden die getName
-Mitgliedsfunktionen von Bar
und Foo
(8) in der printName
-Funktion (7) angewendet.
NatĂĽrlich ist diese Implementierung typsicher. Was passiert also im Falle eines Fehlers?
Fehlermeldungen
Hier ist die fehlerhafte Implementierung:
struct Bar{
std::string get() const { // (1)
return "Bar";
}
};
struct Foo{
std::string get_name() const { // (2)
return "Foo";
}
};
Ich habe die Methode getName
von Bar
und Foo
in get
(1) und in get_name
(2) umbenannt. Hier sind die Fehlermeldungen, die sich mit dem Compiler Explorer nachvollziehen lassen. Alle drei Compiler, g++, clang++ und der MS-Compiler cl.exe kommen direkt auf den Punkt.
Clang 14.0.0
GCC 11.2
MSVC 19.31
Was sind die Vor- und Nachteile dieser drei Techniken gegenĂĽber Type Erasure?
Pro und Contra
void
Pointer sind die C-artige Art, eine Schnittstelle für verschiedene Typen bereitzustellen. Sie geben völlige Flexibilität. Sie erwarten keine gemeinsame Basisklasse und sind einfach zu implementieren. Allerdings entfallen alle Typinformationen und damit die Typsicherheit.
- Die Objektorientierung ist der Weg von C++, eine Schnittstelle für verschiedene Typen bereitzustellen. Wer mit objektorientierter Programmierung vertraut ist, findet hier die typische Art, Softwaresysteme zu entwerfen. OO ist anspruchsvoll zu implementieren, aber typsicher. Sie erfordert eine Schnittstelle und öffentlich abgeleitete Implementierungen.
- Type Erasure ist eine typsichere, generische Methode, um eine Schnittstelle für verschiedene Typen bereitzustellen. Die verschiedenen Typen benötigen keine gemeinsame Basisklasse und stehen in keiner Beziehung zueinander. Die Implementierung von Type Erasure ist ziemlich anspruchsvoll.
Performance
Einen Punkt habe ich bei meinem Vergleich ignoriert: die Performance. Objektorientierung und Type Erasure beruhen auf virtueller Vererbung. Das hat zur Folge, dass zur Laufzeit eine Zeigerumlenkung stattfindet. Bedeutet das, dass Objektorientierung und Type Erasure langsamer sind als ein void
-Zeiger? Ich bin mir nicht sicher. Das gilt es im konkreten Anwendungsfall zu messen. Wer einen void
-Zeiger verwendet, verliert alle Typinformationen. Daher kann der Compiler keine Annahmen über die verwendeten Typen treffen und optimierten Code für sie erzeugen. Wie immer lässt sich die Frage nach der Performance nur mit einem Performancetest beantworten.
Wie geht's weiter?
Im letzten Jahr habe ich fast 50 Beiträge über Templates geschrieben. In dieser Zeit habe ich einiges mehr über C++20 gelernt. Deshalb schreibe ich nun wieder über C++20 und werfe einen Blick auf den nächsten C++ Standard: C++23. (rme)