C++ Core Guidelines: To switch or not to switch, that is the Question

Modernes C++  –  96 Kommentare

Zuerst einmal muss ich mich entschuldigen. Heute wollte ich meine Reise durch die C++ Core Guidelines mit den arithmetischen Ausdrücken fortsetzen. In meiner Schulung in dieser Woche gab es aber eine lange Diskussion zu switch Anweisungen in C/C++ und wie diese immer mehr unwartbar werden. Ehrlich gesagt, bin ich kein Freund von switch-Anweisungen und ich verkünde gerne: Es gibt ein Leben nach switch-Anweisungen.

Bevor ich mich aber auf die Diskussion beziehen und insbesondere darüber schreibe, wie sich switch-Anweisungen überwinden lassen, möchte ich erst mal meinen Plan für heute vorstellen.

Und los geht es mit den switch Anweisungen.

Ich habe switch-Anweisungen in Sourcecode gesehen, die aus mehr als 100 case-Zweigen bestanden. Falls dann noch nicht-leere case-Zweige ohne eine break-Anweisung verwendet wurden, endeten die switch-Anweisung in einem Alptraum, der nicht mehr zu warten war. Hier ist das erste Beispiel der Guidelines.

switch (eventType) {
case Information:
update_status_bar();
break;
case Warning:
write_event_log();
// Bad - implicit fallthrough
case Error:
display_error_window();
break;
}

Der Zweig Warning besitzt keine break Anweisung. Daher wird der Error Zweig auch ausgeführt.

Seit C++17 kennt C++ ein Heilmittel in der Form des [[fallthrough]] Attributes. Jetzt kannst du dein Anliegen direkt ausdrücken. [[fallthrough]] muss unmittelbar vor eine case Bezeichner alleine in einer Zeile stehen. Es drückt aus, dass ein Durchrutschen beabsichtigt ist und daher keine Compilerwarnung erzeugen soll.

Das kleine Beispiel zeigt das C++17-Feature in der Anwendung.

void f(int n) {
void g(), h(), i();
switch (n) {
case 1:
case 2:
g();
[[fallthrough]];
case 3: // no warning on fallthrough (1)
h();
case 4: // compiler may warn on fallthrough (2)
i();
[[fallthrough]]; // ill­formed, not before a case label (3)
}
}

Das [[fallthrough]] Attribute in Zeile (1) unterdrückt die Compiler Warnung. Das gilt nicht für die Zeile 2. Der Compiler kann eine Warnung ausgeben. Zeile (3) hingegen stellt einen Fehler dar, da keine case Bezeichner folgt.

Hier ist mein konstruiertes Beispiel, um die Regel verständlich zu machen.

// switch.cpp

#include <iostream>

enum class Message{
information,
warning,
error,
fatal
};

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

void withDefault(Message mess){

switch(mess){
case Message::information:
writeMessage();
break;
case Message::warning:
writeWarning();
break;
default:
writeUnexpected();
break;
}

}

void withoutDefaultGood(Message mess){

switch(mess){
case Message::information:
writeMessage();
break;
case Message::warning:
writeWarning();
break;
default:
// nothing can be done // (1)
break;
}

}

void withoutDefaultBad(Message mess){

switch(mess){
case Message::information:
writeMessage();
break;
case Message::warning:
writeWarning();
break;
}

}

int main(){

std::cout << std::endl;

withDefault(Message::fatal);
withoutDefaultGood(Message::information);
withoutDefaultBad(Message::warning);

std::cout << std::endl;

}

Die Implementierung der Funktionen withDefault und withoutDefaultGood drücken deutlich ihre Absicht aus. Ein Programmier, der das Programm überarbeitet, weiß aufgrund des Kommentars (1), dass die switch-Anweisung keinen default-Zweig besitzt. Vergleiche doch die Funktion withDefaultGood und withDefaultBad aus der Wartungsperspektive. Weißt du, ob der Implementierer der Funktion withoutDefaultBad den default-Zweig schlicht vergessen hat oder ob die Aufzähler Message::error und Message::fatal erst später dazu kamen? Zumindest musst du den Sourcecode analysieren oder den Entwickler der Funktion fragen, falls das noch möglich ist.

Ich habe ja bereits erwähnt, dass ich in meiner letzten Schulung eine intensive Diskussion zur switch-Anweisungen in C/C++ hatte. Ich vergaß aber zu erwähnen, dass ich eine Python-Schulung gab. Python kennt keine switch-Anweisung. Es gibt daher ein Leben nach der switch-Anweisung in Python und vielleicht auch in C++. Die Datenstruktur, die in Python ein Dictionary genannt wird, wird in C++ als Hashtabelle oder ungeordnete assoziativer Container bezeichnet. Der offizielle Name ist std::unordered_map. Diese Datenstruktur sichert im Schnitt konstante Zugriffszeit (constant amortized time) zu. Das bedeutet, dass unabhängig von der seiner Größe, ein std::unordered_map immer die Antwort in der gleichen Zeit liefert.

Mein zentraler Punkt ist aber ein anderer. Ein std::unordered_map ist nicht nur eine Datenstruktur, sie ist auch eine Kontrollstruktur. Mit ihr kann eine switch-Anweisung umgesetzt werden. Diese Technik heißt offiziell dispatch table. Ich schrieb bereits einen Artikel darüber: Funktional in C++: Dispatch Table.

Um meinen Punkt zu beweisen, implementiere ich das Programm switch.cpp nochmals. In diesem Fall verwende ich aber eine std::unordered_map. Der Einfachheit halber kommt in dem Beispiel eine globale Hashtabelle zum Einsatz.

// switchDict.cpp

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

enum class Message{
information,
warning,
error,
fatal
};

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

std::unordered_map<Message, std::function<void()>> mess2Func{ // (1)
{Message::information, writeMessage},
{Message::warning, writeWarning}
};

void withDefault(Message mess){

auto pair = mess2Func.find(mess);
if (pair != mess2Func.end()){
pair->second();
}
else{
writeUnexpected();
}

}

void withoutDefaultGood(Message mess){

auto pair = mess2Func.find(mess);
if (pair != mess2Func.end()){
pair->second();
}
else{
// Nothing can be done
}

}

void withoutDefaultBad(Message mess){

auto pair = mess2Func.find(mess);
if (pair != mess2Func.end()){
pair->second();
}

}

int main(){

std::cout << std::endl;

withDefault(Message::fatal);
withoutDefaultGood(Message::information);
withoutDefaultBad(Message::warning);

std::cout << std::endl;

}

Zeile (1) enthält die std::unorderedMap. Ich verwende sie in den drei Funktionen withDefault, withoutDefaultGood und withoutDefaultBad. Die Ausgabe des Programms switchDict ist genau dieselbe wie die Ausgabe des Programms switch.

Natürlich gibt es ein paar Unterschiede zwischen der switch-Anweisung und der Hashtabelle. Zuerst einmal gilt, dass die Hashtabelle eine veränderliche Datenstruktur ist. Daher kann sie kopiert oder verändert werden. Darüber hinaus erlaubt sie es nicht, durch die Abfragen wie bei den case-Anweisungen durchzurutschen. Dies musst du implementieren, indem du zum Beispiel eine Funktion mehr wie einmal für einen Schlüssel verwendest: mess2Func[Message::error] = writeWarning;. Jetzt wird dieselbe Aktion für die Schlüssel Message::warning und Message::error ausgeführt.

Ich werde mich nicht auf die Performanz eingehen, denn abhängig von dem Anwendungsfall kann die Dispatch Tabelle auch zur Compilezeit ausgewertet werden. Dies ist zum Beispiel mit constexpr-Funktionen möglich.

Nochmals sorry für den Umweg, aber die Diskussion in meiner letzten Schulung war sehr intensiv. Im nächsten Artikel schließe ich die Regeln für Anweisungen ab und beginne mit den Regeln zu arithmetischen Ausdrücken.