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.

Lesezeit: 6 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 25 Beiträge
Von
  • Rainer Grimm

Type Erasure ermöglicht es, verschiedene konkrete Typen über eine einzige generische Schnittstelle zu verwenden.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

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.

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.

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.

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?

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?

  • 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.

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.

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)