C++ Core Guidelines: Type Erasure mit Templates

Modernes C++  –  6 Kommentare

Im letzten Artikel "C++ Core Guidelines: Type Erasure" habe ich zwei Wege vorgestellt, Type Erasure zu implementieren: void-Zeiger und Objektorientierung. In diesem verbinde ich dynamischen Polymorphismus (Objekt-Orientierung) mit statischem (Templates), um Type Erasure mit Templates umzusetzen.

Zur Auffrischung und als Startpunkt gehe ich nochmals kurz auf Type Erasure mit Objektorientierung ein.

Type Erasure mit Objektorientierung

Type Erasure mit Objektorientierung eingedampft besteht aus einer Ableitungshierarchie:

// typeErasureOO.cpp

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

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

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

struct Foo: BaseClass{
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;

}

Der entscheidende Punkt ist es, dass du Instanzen vom Typ Foo oder Bar anstelle von Instanzen vom Typ BaseClass verwenden kannst. Weitere Details hierzu gibt es im Artikel "C++ Core Guidelines: Type Erasure".

Was sind die Vor- und Nachteile dieser Umsetzung?

Vorteile:

  • Typsicher
  • einfach zu implementieren

Nachteile:

  • virtueller Dispatch
  • Intrusive, da die abgeleitete Klasse ihre Basisklasse kennen muss

Nun bin ich neugierig. Welche Nachteile löst Type Erasure mit Templates auf?

Type Erasure mit Templates

Hier ist das dem OO-Beispiel entsprechende Programm mit Templates.

// typeErasure.cpp

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

class Object { // (2)

public:
template <typename T> // (3)
Object(const T& obj): object(std::make_shared<Model<T>>(std::move(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() << std::endl;
}

struct Bar{
std::string getName() const { // (8)
return "Bar";
}
};

struct Foo{
std::string getName() const { // (8)
return "Foo";
}
};

int main(){

std::cout << std::endl;

std::vector<Object> vec{Object(Foo()), Object(Bar())}; // (1)

printName(vec);

std::cout << std::endl;

}

Was passiert hier? Lass dich nicht durch die Namen Object, Concept und Model verwirren. Diese werden typischerweise für Type Erasure in der Literatur verwendet. Daher werde ich sie einsetzen.

Zuerst einmal, mein std::vector (1) verwendet Instanzen des Typs Objekts (2) und nicht Zeiger, wie in dem vorherigen OO-Beispiel. Die Instanzen können mit beliebigen Typen erzeugt werden, denn der Konstruktor (3) ist generisch. object besitzt die getName-Methode (4), die den Aufruf direkt an die getName-Methode von object delegiert. object ist vom Typ std::shared_ptr<const Concept>. Die getName-Methode von Concept ist rein virtuell (5), daher kommt dank virtuellem Dispatch die getName-Methode von Model (6) zum Einsatz. Letztlich werden die getName-Methoden von Bar und Foo in der Funktion printName (7) angewandt.

Hier ist die Ausgabe des Programms:

Diese Implementierung ist typsicher.

Fehlermeldungen

Ich gebe gerade eine C++-Schulung. In dieser gibt es häufig Diskussionen zu Fehlermeldungen mit Templates. Nun bin ich neugierig. Welche Fehlermeldung erhalte ich, wenn ich die Klassen Bar und Foo ein wenig ändere? Dies 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 in get (1) und get_name (2) umbenannt.

Hier sind die Fehlermeldungen direkt aus dem Compiler Explorer.

Ich beginne mit der hässlichsten Fehlermeldung seitens des Clang 6.0.0 und ende mit der gut lesbaren Fehlermeldung des GCC 8.2. Die Lesbarkeit der Fehlermeldug von MSVC 19 befindet sich dazwischen. Ich war sehr überrascht, denn ich vermutete, dass die Fehlermeldung vom Clang am leichtesten zu verdauen sei.

Clang 6.0.0

Der Screenshot stellt nur die Hälfte der Fehlermeldung vor, da sie deutlich zu lange ist:

MSVC 19
GCC 8.2

Betrachte sorgfältige den Screenshot von GCC 8.2. In ihm steht "27:20: error: 'const struct Foo' has no member named 'getName'; did you mean 'get_name'?". Ist das nicht großartig? Die Fehlermeldungen von MSVC und insbesondere von Clang sind deutlich schwieriger zu lesen.

Das kann aber nicht das Ende meines Artikels sein.

Die Herausforderung

Nun möchte ich die folgende Herausforderung annehmen: Wie kann der Compiler prüfen, ob eine Klasse eine bestimmte Methode unterstützt. In unserm Fall sollten die Klassen Bar und Foo die Methode getName besitzen.

Ich habe mit SFINAE herumgespielt, mit der C++11-Variante std::enable_if experimentiert und bin letztlich beim Deduktions_Idiom hängen geblieben. Das Deduktions-Idiom ist Bestandteil der library fundamental TS v2. Um diese neue Feature zu verwenden, musst du den Header aus dem experimental-Namespace inkludieren. Hier ist das leicht modifizierte Beispiel:

// typeErasureDetection.cpp

#include <experimental/type_traits> // (1)

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

template<typename T>
using getName_t = decltype( std::declval<T&>().getName() ); // (2)

class Object {

public:
template <typename T>
Object(const T& obj): object(std::make_shared<Model<T>>(std::move(obj))){ // (3)

static_assert(std::experimental::is_detected<getName_t, decltype(obj)>::value,
"No method getName available!");

}

std::string getName() const {
return object->getName();
}

struct Concept {
virtual ~Concept() {}
virtual std::string getName() const = 0;
};

template< typename T >
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){
for (auto v: vec) std::cout << v.getName() << std::endl;
}

struct Bar{
std::string get() const {
return "Bar";
}
};

struct Foo{
std::string get_name() const {
return "Foo";
}
};

int main(){

std::cout << std::endl;

std::vector<Object> vec{Object(Foo()), Object(Bar())};

printName(vec);

std::cout << std::endl;

}

Ich habe die Zeilen (1), (2) und (3) in dem Beispiel ergänzt. Die Zeile (2) deduziert den Typ der Methode getName(). std::declval, das seit C++11 zu Verfügung steht, ist eine Funktion, die es erlaubt, Methoden in decltype-Ausdrücken zu verwenden, ohne dabei das Objekt zu erzeugen. Der entscheidende Bestandteil des Deduktions-Idiom ist die Funktion std::experimental::is_detected aus der Type-Traits-Bibliothek, die in dem static_assert zum Einsatz kommt (3).

Jetzt bin ich neugierig. Welche Fehlermeldung der Clang-6.0.0-Compiler erzeugt, wenn ich das Programm nochmals im Compiler Explorer ausführe?

Die Ausgabe ist mir immer noch viel zu wortreich, um ehrlich zu sein. Das Feature ist noch im experimentellen Status. Wenn du genau auf die Fehlermeldung schaust und nach dem Ausdruck static_assert suchst, findest du auch die Antwort, nach der zu suchst. Hier sind die drei ersten Zeilen der Fehlermeldung nochmals dargestellt:

Großartig! Zumindest kannst du jetzt nach dem Ausdruck "No method getName Available" in der Fehlermeldung suchen.

Bevor ich den Artikel abschließe, möchte in noch kurz auf die Vor- und Nachteile von Type Erasure mit Templates eingehen.

Vorteile:

  • Typsicher
  • nichtintrusive, da die abgeleitete Klasse ihre Basisklasse nicht kennen muss

Nachteile:

  • virtueller Dispatch
  • schwierig zu implementieren

Letztlich lässt sich der Unterschied von Type Erasure mit Objektorientierung oder Tempates insbesondere an zwei Punkten festmachen:

  • Intrusive versus nichtintrusive
  • schwierig versus einfach zu implementieren

Wie geht's weiter?

Das ist das Ende meines Ausflugs. Im nächsten Artikel setze ich meine Artikel zur generischen Programmierung fort. Um genauer zu sein, geht es um Concepts.