C++20-Konzepte: Neue Wege mit Konzepten

Seite 4: Schnittstellen mit Konzepten statt Vererbungsdefinition

Inhaltsverzeichnis

Ein weiteres Beispiel soll zeigen, wie Konzepte den Code sicherer machen und die Einschränkung gut lesbar dokumentieren.

Vererbung und virtuelle Funktionen führen gelegentlich zu Diskussionen in Bezug auf Performance und Speicherbedarf. Die Tabelle virtueller Methoden, die durch virtuelle Funktionen entsteht, benötigt etwas Speicherplatz. Gleichzeitig kostet das Dereferenzieren des Zeigers etwas Laufzeit. Dank guter Compiler und vor allem deren Optimizer sind virtuelle Funktionen dennoch eigenen Varianten vorzuziehen. Nicht immer ist Vererbung das, was an dieser Stelle gewünscht ist, und erscheint dann wie ein Provisorium. Außerdem gibt es durch virtuelle Funktionen Fehlerquellen wie pure virtuelle Funktionen, die zur Laufzeit zu einer Dereferenzierung eines Null-Zeigers und damit einem Absturz des Programms führen, oder die Funktion in der Basisklasse kommt zum Einsatz, obwohl sie nicht gewünscht ist. All das lässt sich finden und beheben, es nicht suchen und beheben zu müssen, ist jedoch vorzuziehen.

Ziel ist es, mehrere Objekte zu haben, die eine gemeinsame Eigenschaft besitzen. Sie alle haben eine Funktion draw, die das Zeichnen des jeweiligen Objekts erlaubt. Der typische Weg vor C++20 war eine Basisklasse, die die Methode draw definiert, von der alle Objekte erben, und womöglich auch CRTP (Curiously Recurring Template Pattern). Das folgende Listing zeigt eine denkbare Implementierung am Beispiel der Klassen Circle und Rectangle, die beide über eine Funktion draw verfügen. Das Interface bestimmt sich durch die Basisklasse Drawable, und die Funktion Paint nimmt ein Drawable-Objekt entgegen.

struct Drawable {
  virtual ~Drawable()       = default;
  virtual void draw() const = 0;
};

struct Circle : public Drawable {
  void draw() const override;
};

struct Rectangle : public Drawable {
  void draw() const override;
};

void Paint(const Drawable& shape);

void Use()
{
  Circle c{};
  Paint(c);

  Rectangle r{};
  Paint(r);
}

Konzepte erlauben eine solche Schnittstellendefinition ohne Vererbung. Dadurch entfallen die Tabelle virtueller Methoden und die Dereferenzierung durch den virtuellen Funktionszeiger – also im Grunde genau das, was auch der neue Ranges-Bibliotheksteil in C++20 bewirkt. Die aufgerufene Funktion beschreibt dadurch die Schnittstelle und nicht mehr die Klasse. Im folgenden Listing ist die Implementierung dargestellt:

template<typename T>
concept drawable = requires(T obj) { obj.draw(); };

struct Circle {
  void draw() const;
};

struct Rectangle {
  void draw() const;
};

void Paint(const drawable auto& shape);

void Use()
{
  Circle c{};
  Paint(c);

  Rectangle r{};
  Paint(r);
}

Das Großartige an dieser Lösung ist, dass sie erweiterbar ist. Angenommen, es gibt eine weitere Klasse Point, die über keine dezidierte draw-Funktion verfügt. Allerdings gibt es dafür eine freie Funktion Paint, die in der Lage ist, einen Point zu zeichnen. Gleichzeitig gibt es noch mehr solcher Objekte, die über eine globale Funktion Paint darstellbar sind. Während das in der Vererbungswelt ein Problem darstellt, ist die Erweiterung bei Konzepten recht einfach.

template<typename T>
concept drawable = requires(T obj) { obj.draw(); }
                                    || requires(T obj) { Paint(obj); };

struct Circle {
  void draw() const;
};

struct Rectangle {
  void draw() const;
};

struct Point {
  int x;
  int y;
};

void Paint(const Point& p);

void Draw(const drawable auto& shape);

void Use()
{
  Circle c{};
  Draw(c);

  Rectangle r{};
  Draw(r);

  Point pt{2, 3};
  Draw(pt);
}

Wie Draw nun implementiert wird, ist Geschmackssache. Eine Variante ist die Umsetzung in nur einer Funktion, in der constexpr if entscheidet, ob draw oder Paint aufgerufen wird:

void Draw(const drawable auto& shape)
{
  if constexpr(requires() { shape.draw(); }) {
    shape.draw();
  } else {
    Paint(shape);
  }
}