C++ Core Guidelines: Mehr zu Kontrollstrukturen

Modernes C++  –  16 Kommentare

Mein letzter Artikel zu den C++ Core Guidelines "To Switch or not to Switch, that is the Question" wurde sehr intensiv diskutiert. Der Einsatz einer Hashtabelle anstelle einer switch-Anweisung scheint ein sehr emotionales Thema zu sein. Daher habe ich meinen ursprünglichen Plan geändert. Heute werde ich verschiedene Kontrollstrukturen vorstellen.

Los geht es mit der if- und switch-Anweisung, weiter mit der Hashtabelle und zum Abschluss werde ich auf dynamischen und statischen Polymorphismus eingehen. Zusätzlich werde ich die verschiedenen Kontrollstrukturen bzgl. ihrer Pflege und Performanz vergleichen.

Die klassische Kontrollstruktur ist die if-Anweisung. Daher ist sie auch mein Startpunkt in diesem Artikel.

Hier ist bereits das einfache Programm, das ich im Laufe des Artikels mit verschiedenen Kontrollstrukturen umsetzen werde.

// dispatchIf.cpp

#include <chrono>
#include <iostream>

enum class MessageSeverity{ // (2)
information,
warning,
fatal,
};

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

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

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

void writeInformation(){ std::cerr << "information" << std::endl; }
void writeWarning(){ std::cerr << "warning" << std::endl; }
void writeUnexpected(){ std::cerr << "unexpected" << std::endl; }

void writeMessage(MessageSeverity messServer){ // (1)

writeElapsedTime(); // (3)

if (MessageSeverity::information == messServer){
writeInformation();
}
else if (MessageSeverity::warning == messServer){
writeWarning();
}
else{
writeUnexpected();
}

}

int main(){

std::cout << std::endl;

writeMessage(MessageSeverity::information);
writeMessage(MessageSeverity::warning);
writeMessage(MessageSeverity::fatal);

std::cout << std::endl;

}

Die Funktion writeMessage in Zeile (1) stellt die verstrichene Zeit (3) seit dem Programmstart in Sekundenauflösung und eine Log-Nachricht dar. Sie verwendet eine Aufzählung (2) für die Ernsthaftigkeit der Nachricht. Ich wende den Startzeitpunkt (4) und die aktuelle Zeit (5) an, um die verstrichene Zeit zu berechnen. Wie es der Name des Zeitgebers std::steady_clock bereits andeutet, kann dieser nicht angepasst werden. Genau aus diesem Grund ist er der ideale Zeitgeber für Zeitmessungen. Der entscheidende Teil des Programms ist die Funktion writeMessage (2). In ihr treffe ich die Entscheidung mithilfe der if-else Anweisung, welche Nachricht dargestellt werden soll.

Damit ich die if-else-Anweisung umsetzen konnte, musste ich die genaue Syntax nachschlagen.

Hier ist die Ausgabe des Programms:

Für den Rest des Artikels werde ich auf die Ausgaben der Programme verzichten, denn diese unterscheiden sich nur bezüglich des Zeitstempels.

Das folgende Programm ist dem vorherigen sehr ähnlich. Lediglich die Implementierung der Funktion writeMessage hat sich geändert.

// dispatchSwitch.cpp

#include <chrono>
#include <iostream>

enum class MessageSeverity{
information,
warning,
fatal,
};

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: ";
}

void writeInformation(){ std::cerr << "information" << std::endl; }
void writeWarning(){ std::cerr << "warning" << std::endl; }
void writeUnexpected(){ std::cerr << "unexpected" << std::endl; }

void writeMessage(MessageSeverity messSever){

writeElapsedTime();

switch(messSever){
case MessageSeverity::information:
writeInformation();
break;
case MessageSeverity::warning:
writeWarning();
break;
default:
writeUnexpected();
break;
}

}

int main(){

std::cout << std::endl;

writeMessage(MessageSeverity::information);
writeMessage(MessageSeverity::warning);
writeMessage(MessageSeverity::fatal);

std::cout << std::endl;

}

Jetzt kann ich mich kurz fassen. Weiter geht es mit der Hashtabelle.

Für eine deutlich tiefere Diskussion der Hashtabelle und der switch-Anweisung, möchte ich gerne auf meinen vorherigen Artikel verweisen: "C++ Core Guidelines: To Switch or not to Switch, that is the Question."

// dispatchHashtable.cpp

#include <chrono>
#include <functional>
#include <iostream>
#include <unordered_map>

enum class MessageSeverity{
information,
warning,
fatal,
};

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: ";
}

void writeInformation(){ std::cerr << "information" << std::endl; }
void writeWarning(){ std::cerr << "warning" << std::endl; }
void writeUnexpected(){ std::cerr << "unexpected" << std::endl; }

std::unordered_map<MessageSeverity, std::function<void()>> mess2Func{
{MessageSeverity::information, writeInformation},
{MessageSeverity::warning, writeWarning},
{MessageSeverity::fatal, writeUnexpected}
};

void writeMessage(MessageSeverity messServer){

writeElapsedTime();

mess2Func[messServer]();

}

int main(){

std::cout << std::endl;

writeMessage(MessageSeverity::information);
writeMessage(MessageSeverity::warning);
writeMessage(MessageSeverity::fatal);

std::cout << std::endl;

}

Ist dies das Ende? Nein! In C++ besitzen wir dynamischen und statischen Polymorphismus. Einige meiner Leser erwähnten sie bereits in der Diskussion zu meinem vorherigen Artikel. Im Falle der if-else- oder der switch-Anweisung, wendete ich Aufzähler an, um den Kontrollfluss richtig zu steuern. Der Schlüssel in der Hashtabelle verhält sich sehr ähnlich wie die Aufzähler.

Dynamischer oder statischer Polymorphismus wendet eine vollkommen andere Logik an. Anstelle eines Aufzählers oder eines Schlüssels, um den Programmfluss zu steuern, wissen die Objekte selbst, welche Entscheidung zur Laufzeit (dynamischer Polymorphismus) oder zur Compilezeit (statischer Polymorphismus) gefällt werden muss.

Weiter geht es mit dem dynamischen Polymorphismus.

Nun ist die Entscheidungslogik direkt in der Klassenhierarchie implementiert.

// 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{ // (1)
virtual void writeMessage() const { // (5)
std::cerr << "unexpected" << std::endl;
}
};

struct MessageInformation: MessageSeverity{ // (2)
void writeMessage() const override { // (6)
std::cerr << "information" << std::endl;
}
};

struct MessageWarning: MessageSeverity{ // (3)
void writeMessage() const override { // (7)
std::cerr << "warning" << std::endl;
}
};

struct MessageFatal: MessageSeverity{};

void writeMessageReference(const MessageSeverity& messServer){

writeElapsedTime();
messServer.writeMessage();

}

void writeMessagePointer(const MessageSeverity* messServer){

writeElapsedTime();
messServer->writeMessage();

}

int main(){

std::cout << std::endl;

MessageInformation messInfo;
MessageWarning messWarn;
MessageFatal messFatal;

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

writeMessageReference(messRef1); // (8)
writeMessageReference(messRef2);
writeMessageReference(messRef3);

std::cerr << std::endl;

MessageSeverity* messPoin1 = new MessageInformation;
MessageSeverity* messPoin2 = new MessageWarning;
MessageSeverity* messPoin3 = new MessageFatal;

writeMessagePointer(messPoin1); // (9)
writeMessagePointer(messPoin2);
writeMessagePointer(messPoin3);

std::cout << std::endl;

}

Die Klassen (1), (2) und (3) wissen, wie sie sich zu verhalten haben. Die zentrale Idee ist es, dass sich der statische Typ MessageSeverity vom dynamischen Typ wie zum Beispiel in MessageInformation (4) unterscheidet. Daher wendet die C++-Laufzeit späte Bindung an und die writeMessage Methoden (5), (6), und (7) der dynamischen Typen werden verwendet. Dynamischer Polymorphismus benötigt eine Art Indirektion. Das bedeutet, dass diese erst mit Referenzen (8) oder Zeigern (9) zur Verfügung steht. Mit der Performanzbrille betrachtet, geht dies schneller in C++. Mit statischen Polymorphismus lässt sich die Entscheidungslogik von der Laufzeit auf die Compilezeit vorziehen.

Statischer Polymorphismus wird auch gerne CRTP genannt. CRTP steht für das beliebte C++-Idiom Curiously Recurring Template Pattern. Coriously, da bei diesem Idiom eine Klasse von einem Klassen-Template abgeleitet wird, das die Klasse selbst als Template-Argument verwendet.

// dispatchStaticPolymorphism.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: ";
}

template <typename ConcreteMessage> // (1)
struct MessageSeverity{
void writeMessage(){ // (2)
static_cast<ConcreteMessage*>(this)->writeMessageImplementation();
}
void writeMessageImplementation() const {
std::cerr << "unexpected" << std::endl;
}
};

struct MessageInformation: MessageSeverity<MessageInformation>{
void writeMessageImplementation() const { // (3)
std::cerr << "information" << std::endl;
}
};

struct MessageWarning: MessageSeverity<MessageWarning>{
void writeMessageImplementation() const { // (4)
std::cerr << "warning" << std::endl;
}
};

struct MessageFatal: MessageSeverity<MessageFatal>{}; // (5)

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

writeElapsedTime();
messServer.writeMessage(); // (6)

}

int main(){

std::cout << std::endl;

MessageInformation messInfo;
writeMessage(messInfo);

MessageWarning messWarn;
writeMessage(messWarn);

MessageFatal messFatal;
writeMessage(messFatal);

std::cout << std::endl;

}

In diesem Fall leite ich alle konkreten Klassen (3), (4) und (5) von der Basisklasse MessageSeverity ab. Die Methode writeMessage stellt das Interface dar, das die Aufrufe auf die konkreten Implementierung writeMessageImplementation abbildet. Damit das stattfindet, konvertiere ich die Objekte auf den Typ ConcreteMessage: static_cast<ConcreteMessage*>(this)->writeMessageImplementation();. Genau dies ist der statische Dispatch und gibt diesem C++ Idiom seinen Namen: Statischer Polymorphismus.

Um ehrlich zu sein, habe ich einige Zeit benötigt, um mit dieser besonderen Technik vertraut zu werden. Dies gilt nicht für ihren Einsatz in der Zeile (6). Falls das curiously recurring template pattern noch curiously für dich ist, verweise ich gerne auf den Artikel, denn ich darüber geschrieben habe: C++ ist doch lazy!

Zum Abschluss möchte ich gerne die vorgestellten Techniken kurz vergleichen.

Zuerst möchte ich auf den Aspekt eingehen, diese Kontrollstrukturen zu implementieren und zu pflegen. Es hängt natürlich von deinem Hintergrund ab, welche Konstrollstrukturen dir am vertrautesten ist. Als C-Programmierer ist es vermutlich die if- und switch-Anweisung. Falls du häufig einen Interpreter wie Python einsetzt, wohl eher die Hashtabelle. Mit einem objektorientierten Hintergrund, wirst du vermutlich den dynamische Polymorphismus vorziehen. Hingegen ist der statische Polymorphismus relativ ungewohnt. Es benötigt eine Zeit, um mit ihm vertraut zu werden. Danach lässt es sich einfach wie ein Kochrezept einsetzen.

Aus dem Sicherheitsblickwinkel betrachtet, möchte ich kurz auf den Bezeichner override eingehen. Dieser bringt explizit auf den Punkt, dass eine Methode wie zum Beispiel writeMessage eine virtuelle Methode einer Basisklasse überschreiben soll. Falls dies nicht zutrifft, moniert der Compiler das sofort mit einer eindeutigen Fehlermeldung.

Nun zu der deutlich interessanteren Frage: Was sind die Performanzunterschiede der vorgestellten Techniken? Ich werde nur ein grobe Idee ohne konkrete Zahlen liefern. Falls du eine lange Folge von if-Anweisungen hast, wird dies dank der vielen Vergleiche ziemlich teuer. Der dynamische Polymorphismus und die Hashtabelle sind daher schneller und befinden sich in derselben Performanzliga, da in beiden Fällen eine Zeigerindirektion involviert ist. Die switch-Anweisung und der statische Polymorphismus fällen ihre Entscheidung zur Compilezeit. Damit sind sie die schnellsten Kontrollstrukturen.

Nun hoffe ich, dass ich mit diesem Artikel die Diskussion zu den Kontrollstrukturen abschließen kann. Somit werde ich im nächsten Artikel auf die verbleibenden Regeln zu Anweisungen eingehen und mit den Regeln zu arithmetischen Ausdrücken beginnen.