Statische und dynamische Polymorphie

Polymorphie ist die Eigenschaft, dass verschiedene Datentypen das gleiche Interface unterstützen. In C++ unterscheiden wir zwischen dynamischer und statischer Polymorphie.

Lesezeit: 8 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 3 Beiträge
Von
  • Rainer Grimm
Inhaltsverzeichnis

Polymorphie ist die Eigenschaft, dass verschiedene Datentypen das gleiche Interface unterstützen. In C++ unterscheiden wir zwischen dynamischer Polymorphie und statischer Polymorphie.

Nachdem wir nun die Grundlagen, Details und Techniken rund um Templates kennengelernt haben, möchte ich nun über das Design mit Templates schreiben. Es gibt viele Arten von Polymorphie. Auf einen Aspekt möchte ich mich besonders konzentrieren: Findet der polymorphe Dispatch zur Laufzeit oder zur Compilezeit statt? Die Laufzeit-Polymorphie basiert auf der Objektorientierung und den virtuellen Funktionen in C++, die Compilezeit-Polymorphie basiert auf Templates.

Beide Polymorphismen haben Vor- und Nachteile, die ich im folgenden Artikel darstelle.

Hier sind die wichtigsten Fakten: Dynamische Polymorphie findet zur Laufzeit statt, sie basiert auf der Objektorientierung und ermöglicht es uns, zwischen der Schnittstelle und der Implementierung einer Klassenhierarchie zu trennen. Um Late Binding, Dynamic Dispatch oder Dispatch zur Laufzeit zu erhalten, benötigt man zwei Zutaten: Virtualität und eine Indirektion wie einen Zeiger oder eine Referenz.

// dispatchDynamicPolymorphism.cpp

#include <chrono>
#include <iostream>

auto start = std::chrono::steady_clock::now();

void writeElapsedTime(){
auto now = std::chrono::steady_clock::now();
std::chrono::duration<double> diff = now - start;

std::cerr << diff.count() << " sec. elapsed: ";
}

struct MessageSeverity{
virtual void writeMessage() const {
std::cerr << "unexpected" << '\n';
}
};

struct MessageInformation: MessageSeverity{
void writeMessage() const override {
std::cerr << "information" << '\n';
}
};

struct MessageWarning: MessageSeverity{
void writeMessage() const override {
std::cerr << "warning" << '\n';
}
};

struct MessageFatal: MessageSeverity{};

void writeMessageReference(const MessageSeverity& messServer){ // (1)

writeElapsedTime();
messServer.writeMessage();

}

void writeMessagePointer(const MessageSeverity* messServer){ // (2)

writeElapsedTime();
messServer->writeMessage();

}

int main(){

std::cout << '\n';

MessageInformation messInfo;
MessageWarning messWarn;
MessageFatal messFatal;

MessageSeverity& messRef1 = messInfo; // (3)
MessageSeverity& messRef2 = messWarn; // (4)
MessageSeverity& messRef3 = messFatal; // (5)

writeMessageReference(messRef1);
writeMessageReference(messRef2);
writeMessageReference(messRef3);

std::cerr << '\n';

MessageSeverity* messPoin1 = new MessageInformation; // (6)
MessageSeverity* messPoin2 = new MessageWarning; // (7)
MessageSeverity* messPoin3 = new MessageFatal; // (8)

writeMessagePointer(messPoin1);
writeMessagePointer(messPoin2);
writeMessagePointer(messPoin3);

std::cout << '\n';

}

Die Funktionen writeMessageReference (1) oder writeMessagePointer (2) benötigen eine Referenz oder einen Zeiger auf ein Objekt vom Typ MessageSeverity. Von MessageSeverity öffentlich abgeleitete Klassen wie MessageInformation, MessageWarning oder MessageFatal unterstützen das sogenannte Liskovsches Substitutionsprinzip. Das bedeutet, dass eine MessageInformation, MessageWarning oder eine MessageFatal auch eine MessageSeverity ist.

Hier ist die Ausgabe des Programms:

Das wirft die Frage auf, warum die Memberfunktion writeMessage der abgeleiteten Klasse und nicht der Basisklasse aufgerufen wird. Hier kommt die späte Bindung ins Spiel. Die folgende Erklärung gilt für die (3) bis (8). Der Einfachheit halber schreibe ich nur über (6): MessageSeverity* messPoin1 = new MessageInformation. messPoint1 hat im Wesentlichen zwei Typen. Einen statischen Typ MessageSeverity und einen dynamischen Typ MessageInformation. Der statische Typ MessageSeverity steht für sein Interface und der dynamische Typ MessageInformation für seine Implementierung. Der statische Typ wird zur Compilezeit verwendet und der dynamische zur Laufzeit. Zur Laufzeit ist messPoint1 vom Typ MessageInformation; daher wird die virtuelle Funktion writeMessage von MessageInformation aufgerufen. Hier gilt natürlich, dass der dynamische Dispatch eine Indirektion wie einen Zeiger oder eine Referenz und Virtualität erfordert.

Ich betrachte diese Art von Polymorphismus als vertragsorientiertes Design. Eine Funktion wie writeMessagePointer erfordert, dass für ein Objekt gelten muss, dass es öffentlich von MessageSeverity abgeleitet ist. Wenn dieser Vertrag nicht erfüllt ist, beschwert sich der Compiler.

Im Gegensatz zum vertragsgesteuerten Design gibt es auch ein verhaltensgesteuertes Design mit statischem Polymorphismus.

Für das Thema schlage ich einen kurzen Umweg ein.

Python interessiert sich für das Verhalten und nicht für formale Interfaces. Diese Idee ist als Duck-Typing bekannt. Um es kurz zu machen, der Ausdruck geht auf das Gedicht von James Whitcomb Rileys zurück: Hier ist es:

“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 bedeuten? Gegeben sei eine Funktion acceptOnlyDucks, die nur Enten als Argument akzeptiert. In statisch typisierten Sprachen wie C++ können alle Typen, die von Duck abgeleitet sind, verwendet werden, um die Funktion aufzurufen. In Python können alle Typen, die sich wie eine Ente verhalten, zum Aufrufen der Funktion verwendet werden. Um es noch konkreter zu machen. Wenn sich ein Vogel wie Ente verhält, ist er eine Ente. In Python wird oft ein Sprichwort verwendet, das dieses Verhalten ganz gut beschreibt.

"Don't ask for permission, ask for forgiveness."

Im Fall unserer Ente bedeutet das, dass man die Funktion acceptsOnlyDucks mit einem Vogel aufruft und auf das Beste hofft. Wenn etwas Schlimmes passiert, fängt man die Ausnahme mit einer Ausnahmebehandlung. Oft funktioniert diese Strategie in Python sehr gut und sehr schnell.

Soweit mein kleiner Ausflug, und dank der Templates haben wir in C++ auch Duck-Typing.

Das bedeutet, dass sich das vorherige Programm disptachStaticPolymorphism.cpp mit Duck Typing refaktorisieren lässt.

// duckTyping.cpp

#include <chrono>
#include <iostream>

auto start = std::chrono::steady_clock::now();

void writeElapsedTime(){
auto now = std::chrono::steady_clock::now();
std::chrono::duration<double> diff = now - start;

std::cerr << diff.count() << " sec. elapsed: ";
}

struct MessageSeverity{
void writeMessage() const {
std::cerr << "unexpected" << '\n';
}
};

struct MessageInformation {
void writeMessage() const {
std::cerr << "information" << '\n';
}
};

struct MessageWarning {
void writeMessage() const {
std::cerr << "warning" << '\n';
}
};

struct MessageFatal: MessageSeverity{};

template <typename T>
void writeMessage(T& messServer){ // (1)

writeElapsedTime();
messServer.writeMessage();

}

int main(){

std::cout << '\n';

MessageInformation messInfo;
writeMessage(messInfo);

MessageWarning messWarn;
writeMessage(messWarn);

MessageFatal messFatal;
writeMessage(messFatal);

std::cout << '\n';

}

Das Funktions-Template writeMessage (1) wendet Duck-Typing an. writeMessage geht davon aus, dass alle Objekte messServer die Mitgliedsfunktion writeMessage unterstützen. Wenn nicht, würde die Kompilierung fehlschlagen. Der Hauptunterschied zu Python besteht darin, dass der Fehler in C++ zur Compilezeit auftritt, in Python aber zur Laufzeit. Hier ist die Ausgabe des Programms.

Die Funktion writeMessage verhält sich polymorph, ist aber weder typsicher noch schreibt sie im Falle eines Fehlers eine lesbare Fehlermeldung. Zumindest das letzte Problem kann ich mit Concepts in C++20 elegant beheben. In meinen früheren Beiträgen zu Concepts lassen sich die Details schön nachlesen: Concepts Artikel. Im folgenden Beispiel definiere und verwende ich das Concept MessageServer (1).

// duckTypingWithConcept.cpp

#include <chrono>
#include <iostream>

template <typename T> // (1)
concept MessageServer = requires(T t) {
t.writeMessage();
};

auto start = std::chrono::steady_clock::now();

void writeElapsedTime(){
auto now = std::chrono::steady_clock::now();
std::chrono::duration<double> diff = now - start;

std::cerr << diff.count() << " sec. elapsed: ";
}

struct MessageSeverity{
void writeMessage() const {
std::cerr << "unexpected" << '\n';
}
};

struct MessageInformation {
void writeMessage() const {
std::cerr << "information" << '\n';
}
};

struct MessageWarning {
void writeMessage() const {
std::cerr << "warning" << '\n';
}
};

struct MessageFatal: MessageSeverity{};

template <MessageServer T> // (2)
void writeMessage(T& messServer){

writeElapsedTime();
messServer.writeMessage();

}

int main(){

std::cout << '\n';

MessageInformation messInfo;
writeMessage(messInfo);

MessageWarning messWarn;
writeMessage(messWarn);

MessageFatal messFatal;
writeMessage(messFatal);

std::cout << '\n';

}

Das Concept MessageServer (1) setzt voraus, dass ein Objekt t vom Datentyp T den Aufruf t.writeMessage unterstützt. (2) wendet das Concept in dem Funktions-Template writeMessage an.

Bisher habe ich nur über das polymorphe Verhalten von Templates geschrieben, aber nicht über statische Polymorphismie. Das ändert sich in meinem nächsten Beitrag. Ich stelle das sogenannte CRTP-Idiom vor. CRTP steht für Curiously Recurring Template Pattern und bezeichnet eine Technik in C++, bei der eine Klasse Derived von einer Template-Klasse Base abgeleitet wird und Base Derived als Template-Parameter besitzt:

template <typename T>
class Base
{
...
};

class Derived : public Base<Derived>
{
...
}; ()