04.01.2014
Version: 1
Inhaltsverzeichnis
1. Einleitung
Im Artikel "Verwenden Sie keine Bool-Typen in Schnittstellen"[1] haben wir gelernt in Schnittstellen besser Enums als Bool-Typen einzusetzen. Neben der besseren Lesbarkeit sind Enums typsicherer und änderungsfreundlicher als Bool-Typen.Während sich Bool-Status aber einfach mit tpyischen "set_state(state)" und "is_state()" Funktionen setzen und abfragen lassen, wird dies bei Enums schnell unschön. Dieser Artikel soll sich dieser Frage widmen: Wie fragt man einen Status sinnvoll ab? Wie sollte eine Schnittstelle für Enum-Werte aussehen?
Hinweis - wenn Sie den anderen Artikel nicht kennen, oder seinen Inhalt vergessen haben - lesen Sie ihn vorher noch einmal. Ansonsten ist dieser Artikel manchmal schwer nachzuvollziehen, da er sich immer wieder auf den Argumentationen und Beispielen des anderen Artikels abstützt.
2. Abfrage-Funktionen
Im vorherigen Artikel haben wir Abfrage-Funktionen auf Objekt-Zustände komplett ignoriert. Bei Bool-Parametern ist dies ja auch kein Problem, wie das folgende Beispiel zur Abfrage der Sichtbarkeit eines Check-Buttons zeigt.
class check_button
{
public:
...
bool is_visible() const;
...
};
check_button cb;
...
if (cb.is_visible()) // Wenn der Check-Button sichtbar ist, dann...
...
...
if (!cb.is_visible()) // Wenn der Check-Button unsichtbar ist, dann...
...
- Rückgabe des Enum-Werts
- Spezielle Abfrage-Funktionen
- Angabe des abzufragenden Werts
3 Rückgabe des Enum-Werts
Das ist sicher die offensichtlichste Lösung, die man auch in der Praxis an vielen Stellen wiederfindet. Die Abfrage-Funktion gibt den Enum-Wert zurück, und man muss ihn selber gegen die Enum-Werte vergleichen.
enum visible_state { visible, invisible };
class check_button
{
public:
...
visible_state get_visible() const;
...
};
check_button cb;
...
if (cb.get_visible()==visible) // Wenn der Check-Button sichtbar ist, dann...
...
...
if (cb.get_visible()==invisible) // Wenn der Check-Button unsichtbar ist, dann...
...
- Sie ist ohne Dokumentation lesbar und verstehbar.
- Sie kann mit beliebig vielen Enum-Konstanten umgehen, d.h. z.B. auch mit der Erweiterung um die Enum-Konstante "transparent".
- Mir persönlich gefällt auch, dass man auf den Not-Operator "!" bei der Abfrage auf Unsichtbarkeit verzichten kann. Meine persönliche Erfahrung mit dem Not-Operator ist, dass er schlecht lesbar ist und immer wieder zu Fehlern führt - vielleicht da Menschen nicht so gut mit Negationen umgehen können. Aber vielleicht ist das nur mein persönliches Problem - andere Programmierer sehen dies nicht so.
- Die Abfragen sind relativ lang - z.B. "get_visible()==visible" gegenüber "is_visible()".
- Eine Abfrage auf eine Teilmenge der möglichen Werte ist mit relativ hohem Aufwand verbunden - siehe folgendes Beispiel und die folgende Diskussion. Wobei dies kein ernsthafter Nachteil gegenüber einem Bool-Parameter ist, denn mit dem ist eine solche Anforderung gar nicht umsetzbar. Es ist mehr ein Nachteil der Lösung "an-sich".
enum alignment { left, right, center, block };
class paragraph
{
public:
...
alignment get_alignment() const;
...
};
paragraph para;
...
if (para.get_alignment()!=center && para.get_alignment()!=block)
...
paragraph para;
...
alignment a = para.get_alignment();
if (a!=center && a!= block)
...
4 Spezielle Abfrage-Funktionen
Ab und zu findet man auch einen anderen Stil für Abfrage-Funktionen, bei dem für jeden Zustand spezielle Abfrage-Funktionen zur Verfügung gestellt werden. Schauen wir uns das am Beispiel des sichtbaren bzw. unsichtbaren Check-Buttons mal an.
class check_button
{
public:
...
bool is_visible() const;
...
};
check_button cb;
...
if (cb.is_visible()) // Wenn der Check-Button sichtbar ist, dann...
...
...
if (!cb.is_visible()) // Wenn der Check-Button unsichtbar ist, dann...
...
class check_button
{
public:
...
bool is_visible() const;
bool is_invisible() const;
bool is_transparent() const;
...
};
check_button cb;
...
if (cb.is_visible())
...
if (cb.is_invisible()) // Das ist doch besser lesbar als !is_visible(), oder?
...
if (cb.is_transparent())
...
paragraph para;
...
if (!para.is_center() && !para.is_block())
...
Vorteile
- Die Abfragen sind relativ kurz.
- Die Abfragen sind ohne Dokumentation lesbar und verstehbar.
- Die Abfragen können mit beliebig vielen Enum-Konstanten umgehen. Hinzufügen von neuen Werten führt nicht zu einem Brechen von altem Code. Entfernen von alten Werten (inkl. der Funktionen) führt zu Compiler-Fehlern, aber nicht zu undefinertem Verhalten.
- Die Abfrage auf Teilmengen ist akzeptabel, aber nicht optimal - wir diskutieren das direkt am Anschluß dieser Auflistung.
- Man kann häufig - leider nicht immer - auf den Not-Operator "!" verzichten.
- Hoher Wartungs-Aufwand, da bei jedem neuen Enum-Wert auch eine neue Abfrage-Funktion geschrieben werden muss.
- Dieser Stil ergibt relativ große Schnittstellen, daher viele Funktionen im Public-Bereich von Klassen. Das ist nicht optimal.
- Die Abfrage auf Teilmengen ist akzeptabel, aber nicht optimal - wir diskutieren das direkt am Anschluß dieser Auflistung.
- Man kann leider nicht immer auf den Not-Operator "!" verzichten.
- Parametrisierbare Abfragen ergeben unschönen und schlecht wartbaren Code - eine entsprechende Diskussion folgt gleich im nächsten Kapitel.
4.1 Parametrisierbare Abfragen
Das letzte Argument mit "parametrisierbaren Abfragen" ist neu. Was ist damit gemeint? Nicht immer ist der Quelltext so einfach, dass man auf z.B. Sichtbarkeit abfragt und dann eben das oder das macht. Manchmal kommt der erwartet Status von außen, und nur bei Gleichheit oder Ungleichheit muss etwas gemacht werden. Schauen wir uns folgende Funktion an, die auf dem Enum-Rückgabe-Stil beruht, und bei Ungleichheit eine Warnung ausgibt.
void check_visible_state(const check_button& cb, visible_state vs)
{
if (vs != cb.get_visible())
{
cout << "Check-Button Visible-Status ist nicht okay." << endl;
}
}
void check_visible_state(const check_button& cb, visible_state vs)
{
bool equal;
switch (vs)
{
case visible:
equal = cb.is_visible();
break;
case invisible:
equal = cb.is_invisible();
break;
case transparent:
equal = cb.is_transparent();
break;
}
if (!equal)
{
cout << "Check-Button Visible-Status ist nicht okay." << endl;
}
}
void check_visible_state(const check_button& cb, visible_state vs)
{
bool equal;
switch (vs)
{
case visible:
equal = cb.is_visible();
break;
case invisible:
equal = cb.is_invisible();
break;
case transparent:
equal = cb.is_transparent();
break;
default:
assert(false);
}
if (!equal)
{
cout << "Check-Button Visible-Status ist nicht okay." << endl;
}
}
Hinzu kommt noch, dass manche Compiler den Ausführungs-Pfad ohne Initialisierung von "equal" erkennen und eine Warnung ausgeben. Wirklich gute Compiler - gibt es die? - würden hier sogar nur dann warnen, wenn es den Default-Fall wirklich gibt, daher ein Enum-Wert vorhanden ist, der durch keinen Case-Zweig abgedeckt wird. (Anmerkung: ich werde das vielleicht mal mit verschiedenen Compilern testen und den Artikel dann updaten). Das könnte man dann als Wartungs-Hinweis nutzen - man verläßt sich dann aber sehr auf spezielles Compiler-Verhalten, und wehe man wechselt den Compiler, man ändert die Warnungs-Einstellungen, jemand ignoriert die Warnungen, oder oder oder.
Sinnvoll ist also wohl eine Initialisierung von "equal" in jedem Fall - auch wenn dann der mögliche Vorteil der Compiler-Warnungen verloren geht. Eine Vorinitialisierung direkt bei der Definition von "equal" wird dem echten C-Programmierer aber nicht gefallen, denn das hieße in den Normal-Fällen ja eine überflüssige Initialisierung. Also muss "equal" auch im Default-Zweig gesetzt werden. Bleibt nur noch die Frage, was denn ein sinnvoller Default-Wert ist? Dies muss man wohl Kontext-Abhängig entscheiden - aber ein ungutes Gefühl bleibt.
void check_visible_state(const check_button& cb, visible_state vs)
{
bool equal;
switch (vs)
{
case visible:
equal = cb.is_visible();
break;
case invisible:
equal = cb.is_invisible();
break;
case transparent:
equal = cb.is_transparent();
break;
default:
assert(false);
equal = false; // Fuer ein definiertes Fehler-Verhalten im Release-Modus
}
if (!equal)
{
cout << "Check-Button Visible-Status ist nicht okay." << endl;
}
}
Wir sehen, dass obwohl spezielle Abfrage-Funktionen einige Vorteile gegenüber der Rückgabe des Enum-Werts haben, sie auch ihre Nachteile haben. Eine richtig gute Lösung scheint es nicht zu geben. Vielleicht sollte man beide Stile parallel anbieten, und dann je nach Situation mal die einen Funktionen und mal die anderen nutzen. Aber dann hätten wir ja wieder den Nachteil von viel zu großen Schnittstellen und viel zu viel Wartungs-Aufwand bei Änderungen. Was machen wir bloß?
5 Zwischenfazit & Kriterien-Katalog
Bevor wir uns eine weitere Lösungs-Möglichkeit anschauen, wollen wir nochmal alle Kriterien zusammenfassen, die wir im Laufe des alten und des bisherigen Artikels genutzt haben, um die möglichen Implementierungen zu bewerten:- Lesbarer Quelltext bei allen Arten von Funktionen, d.h. z.B. Settern und Konstruktoren.
- Lesbarer Quelltext bei Abfrage-Funktionen, sowohl wenn ein einzelner Wert, eine Negation, eine Kombination oder eine negierte Kombination abgefragt wird.
- Hart bzgl. Schnittstellen-Änderungen, d.h. am Besten funktioniert alles weiter, aber wenn nicht so muss dies durch einen Compiler-Fehler gemeldet werden.
- Unproblematisch wenn neue Enum-Werte hinzukommen bzw. welche wegfallen - d.h. am Besten funktioniert alles weiter, aber wenn nicht so muss dies durch einen Compiler-Fehler gemeldet werden.
- Kleine Schnittstellen, d.h. wenige Funktionen.
- Parametrisierbare Abfragen sollen einfach möglich sein.
6 Angabe des abzufragenden Werts
Die beiden bisherigen Lösungen sind zwar sehr verbreitet, kranken letztlich aber an der nicht konsequenten Umsetzung der objektorientierten Ideen der "Kapselung" und "Lass-das-Objekt-für-dich-arbeiten". Stroustrup und viele andere haben immer wieder die Ansicht vertreten, dass Getter-Funktionen schlecht sind und kein OO-Design darstellen - und bislang sind unsere Abfrage-Funktionen nichts anderes als einfachste Getter-Funktionen.Wenn wir die eigentliche Arbeit in die Abfrage-Funktionen integrieren würden (daher in die betroffenen Objekte), dann müßte der nutzende Quelltext doch einfach und wartungsfreundlich werden, oder? Probieren wir es mal aus.
enum alignment { left, right, center, block };
class paragraph
{
public:
paragraph(alignment); // (*)
...
bool is_alignment(alignment) const; // (**)
void set_alignment(alignment); // (***)
...
void print(output_device&, alignment) const; // (****)
};
paragraph pa(left); // (*)
if (pa.is_alignment(left)) // (**)
...
pa.set_alignment(right); // (***)
pa.print(printer, block); // (****)
6.1 Kriterium "Lesbarer Quelltext"
Checken wir am Beispiel, ob normale Funktionen gut lesbar sind:- Sowohl die Deklaration als auch die Nutzung des Konstruktors (*) läßt sich semantisch gut lesen, auch ohne dass in der Deklaration ein Parameter-Name angegeben sein muss.
- Auch die normle Abfrage (**) ist in beiden Fällen gut lesbar.
- Das gleiche gilt für den Setter (***)
- Und auch weitere Funktionen, die ein solches Argument erwarten wie z.B. (****) sind lesbar.
6.2 Kriterium "Abfragen jeglicher Art"
Für diese Thema müssen wir etwas weiter gehen. Hierzu sollte Ihnen bekannt sein, dass der von C++ erlaubte Enum Werte-Bereich nicht nur die angegebenen Enum-Werte umfaßt, sondern auch ihre Kombinationen - näheres hierzu finden Sie z.B. in meinem Artikel über Enums[2].Damit sollte klar sein - spätestens nach dem Lesen meines Enums-Artikels - das wir hier die Enum-Werte anders definieren müssen und auch den Oder-Operator "|" für die Enums überladen sollten - das Operator-Überladung für Enums funktioniert, und wie man das macht, das wird in meinem Artikel über Operator-Überladung[3] beschrieben. Damit können wir ganz einfach abfragen können, ob z.B. ein Kapitel zentriert oder mit Blocksatz formatiert ist.
enum alignment { left=1, right=2, center=4, block=8 };
inline alignment operator|(alignment lhs, alignment rhs)
{
return static_cast<alignment>(lhs | rhs);
}
class paragraph
{
public:
...
bool is_alignment(alignment) const;
...
};
paragraph pa;
if (pa.is_alignment(block | center))
{
...
}
inline alignment operator!(alignment a)
{
// Hier sollte besser eine Konstante stehen
return static_cast<alignment>(15-a);
}
inline alignment operator&(alignment lhs, alignment rhs)
{
return static_cast<alignment>(lhs & rhs);
}
paragraph pa;
if (pa.is_alignment(!block & !center)) // Nun ist die Abfrage so
{
...
}
if (pa.is_alignment(!(block | center))) // oder so moeglich
{
...
}
paragraph pa(left | right); // Was soll das denn meinen?
enum alignment { left=1, right=2, center=4, block=8 };
enum alignment_union { alignment_values=15 };
6.3 Kriterium "Hart bzgl. Schnittstellen-Änderungen"
Wie wir oben schon gelernt haben, sind alle Schnittstellen durch die Verwendung von Enums hart gegen Änderungen - also z.B. wenn sich die Reihenfolge der Parameter ändert oder mitten in der Parameterliste Parameter wegfallen oder hinzukommen. Da Enums echte C++ Typen darstellen, die nicht implizit ineinander konvertiert werden, führt jede Schnittstellen Änderung zu einem Compiler-Fehler.6.4 Kriterium "Änderung der Enum-Werte"
Entfernen, Hinzufügen oder Ändern von Enum-Werten ist überhaupt kein Problem - solange ich die Implementierungen der Operatoren mit anpasse. Verwendung nun nicht mehr vorhandener Enum-Konstanten führt zu Compiler-Fehlern. Und die Verwendung aller noch vorhandenen Enum-Konstanten führt zu keiner Änderung der Semantik - auch nicht bei z.B. negierten Gruppen-Abfragen.6.5 Kriterium "Wenige Funktionen"
Da es nicht für jeden Enum-Wert eine Funktion gibt, sondern nur einen Setter und Getter für den Enum-Typ, ist die Schnittstelle schön klein und wartungsfreundlich. Selbst bei der Verwendung eines extra Union-Enum-Typs wächst die Schnittstelle nur um eine Funktion - und bedenken Sie bitte: diese Funktion bietet eine Funktionalität an, die die anderen Lösungen gar nicht unterstützt haben.6.6 Kriterium "Parametrisierbare Abfragen"
Bleiben zu guter Letzt die parametriesierbaren Abfragen, und auch hier geht die Geschichte natürlich gut aus. Parametrisierbare Anfragen sind ganz primitiv und wartungsfreundlich möglich.
void check_visible_state(const check_button& cb, visible_state vs)
{
if (cb.is_visible(!vs))
{
cout << "Check-Button Visible-Status ist nicht okay." << endl;
}
}
7. Bewertung der Lösungs-Möglichkeiten
Bevor wir zu einem letzten Schmankerl kommen, lassen Sie uns die vorgestellten Lösungen noch mal bewerten.Kriterium | Bool + Setter/Getter | Enums + Setter/Getter | Enums + Abfrage-Fkt. | Enums + Operatoren + Angabe-Fkt. |
---|---|---|---|---|
Konstruktor Deklaration lesbar | - | + | + | + |
Konstruktor Aufruf lesbar | - | + | + | + |
Setter Deklaration lesbar | + | + | + | + |
Setter Aufruf lesbar | + | + | + | + |
Getter Deklaration lesbar | + | + | + | + |
Getter Aufruf lesbar | + | + | + | + |
Funktions Deklaration lesbar | - | + | + | + |
Funktions Aufruf lesbar | - | + | + | + |
Negierte Abfragen | + | o | o | + |
Gruppen Abfragen | o | o | - | + |
Schnittstellen problemlos änderbar | - | + | + | + |
Enum-Werte problemlos änderbar | - | + | + | + |
Minimales Funktions-Set | o | + | - | + |
Parametrisierbare Abfragen | + | - | - | + |
Klar sollte sein, dass ein Bool-Parameter, trotz seiner Einfachheit, hier wohl die schlechteste Wahl ist. Schon ein einfacher Enum bringt eine Menge an Lesbarkeit und Sicherheit. Wird dann noch geschickt mit den Enum-Werten und Operatoren gearbeitet, kann man wirklich schönen Quelltext bekommen.
8. Schmankerl
So richtig perfekt ist der Quelltext aber immer noch nicht, denn im Prinzip enthält er doppelten Code. Zur Zeit tragen die Funktions-Namen und die Enum-Konstanten nämlich überlappene Informations-Anteile - im folgenden Beispiel die Visible-Information.
button b;
b.set_visible(visible);
if (b.is_visible(visible)) ...
class button
{
public:
...
void set(visible_state);
bool is(visible_state);
...
};
button b;
b.set(visible);
if (b.is(visible)) ...
enum alignment
{
left_alignment = 1,
right_alignment = 2,
center_alignment = 4,
block alignment = 8
};
...
if(para.is(block_alignment)) ...
Wer es jetzt ganz genau nimmt, könnte sogar auf die Idee kommen, dass "make(button, visible) bzw. "has(paragraph, block_alignment)" noch besser sein könnten.
Egal, was einem besser gefällt - all das ist in C++ doch gar kein Problem...
8.1 Gefälligere Element-Funktionen
Für die erste Idee implementieren Sie einfach zwei Member-Templates "make" und "has" die inline auf "set" und "is" abgebildet werden.
class button
{
public:
...
template<class T> inline void make(T t) { set(t); }
template<class T> inline bool has(T t) { is(t); }
...
};
button b;
b.make(visible);
if (b.has(focus)) ...
8.2 Freie Funktionen
Item 44 des empfehlenswerten Buchs "C++ Coding Standards"[5] von Herb Sutter und Andrei Alexandresu beschreibt, dass freie Funktionen (d.h. Non-Member-Functions) den Element-Funktionen vorzuziehen sind, um die Schnittstelle von Klassen nicht unnötig zu überfluten. In diesem Sinne wären vielleicht auch hier die freien Funktionen vorzuziehen - ganz unabhängig von der vielleicht noch schöneren Lesbarkeit. Die Umsetzung ist auch hier wieder ganz einfach - einfach fünf Template-Funktionen implementieren.
template<class T, class E> inline void set(E& e, T t)
{
e.set(t);
}
template<class T, class E> inline void make(E& e, T t)
{
e.set(t);
}
template<class T, class E> inline bool is(const E& e, T t)
{
return e.is(t);
}
template<class T, class E> inline bool has(const E& e, T t)
{
return e.is(t);
}
button b;
make(b, visible);
if (is(b, visible)) ...
paragraph para;
make(para, left_alignment);
if (has(para, left_alignment | right_alignment)) ...
8.3 Man kann's auch übertreiben
Dem ein oder anderen mag das immer noch nicht lesbar genug sein, denn die Klammern und Kommas stören ja wirklich... Man könnte natürlich noch mehr Operator-Überladung und Templates nutzen, um folgende Dinge zu ermöglichen - aber mir geht das ehrlich gesagt zu weit.
make-button-visible; // Operator - ueberladen fuer "make", "button" und "visible"
make-button-visible+enabled; // Und noch zusaetzlich Operator + ueberladen
make(button, +visible);
make(button, -visible); // statt make(button, invisible)
make(button, visible+enabled-checked);
Man kann viel machen in C++ - man muss es aber nicht, und manches sollte man nicht.
9. Fazit
Nachdem wir gelernt hatten, keine Bool-Typen in Schnittstellen einzusetzen, haben wir heute gesehen, wie man sinnvoll den Status von Objekten abfragt. Einfache Bool-Funktionen kommen natürlich gar nicht in Frage - das wären ja ein Bool-Typ in der Schnittstelle. Aber auch einfche Enum-Funktionen sind erstmal nicht viel besser. Spezielle Abfrage Funktionen können den Quelltext lesbarer machen, blähen die Klassen aber extrem auf. Die Lösung sind die Nutzung von Enums mit Operator-Überladung und einfachen Funktionen - typischerweise dann als freie Funktionen ausgelegt. Damit erreicht man lesbaren, typsicheren, änderungs-freundlichen und kurzen Quelltext. Was will man mehr?10. Links & Literatur
Hier die Links zu den speziellen Themen aus diesem Artikel:- Mein Artikel zum Thema "Verwenden Sie keine Bool-Typen in Schnittstellen":
-
Mein Artikel über Enums in C++:
- Noch nicht veröffentlicht
- Mein Artikel zum Thema "C++ Operator-Überladung von A bis (fast) Z":
-
Barton-Nackman Trick
- Wird auch "Restricted Template Expansion" genannt.
- http://en.wikipedia.org/wiki/Barton%E2%80%93Nackman_trick
- http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Barton-Nackman_trick
-
Buch:
- C++ Coding Standards
- Von Herb Sutter & Andrei Alexandresu
- 101 Rules, Guidelines, and Best Practices
- Sprache: English
- Verlag: Pearson
- ISBN: 978-8131706138
11. Danksagung
Bedanken möchte ich mich bei folgenden Personen (in alphabetischer Reihenfolge), die diesen Artikel gegengelesen und mit vielen kleinen und großen Hinweisen dafür gesorgt haben, dass er besser ist als am Anfang:- Alexander Heckner
- Klaus Wittlich
- Ralph Habermann
- Robert Wittek
- Sven Johannsen
12. Versions-Historie
Die Versions-Historie dieses Artikels:-
Version 1
- 04.01.2014
- Initiale Version