Überladung von Operatoren in C++ (Teil 2) - Einführung in boost::operators
Operatorüberladung in C++ ist ein häufig benutztes und ebenso häufig unterschätztes Feature in C++. Im ersten Teil der Artikelreihe bin ich auf die grundsätzlichen Fragen "wann?, wie?, welche?" zur Operatorüberladung eingegangen und habe zu den einzelnen überladbaren Operatoren jeweils einen Überblick zur üblichen Semantik und Implementierung im Sinne von "Do as the ints do" geliefert.
Im vorliegenden zweiten Teil geht es um ein praktisches Hilfsmittel bei der Operatorüberladung: Die Bibliothek boost::operators unterstützt den Entwickler beim Implementieren vieler der in Teil 1 vorgestellten üblichen Praktiken. Auf den nächsten Seiten stelle ich die Gedanken und Konzepte hinter boost::operators vor und zeige anhand eines Beispiels wie man recht einfach die grundlegenden Bestandteile der Bibliothek benutzen kann. In Teil 3 der Artikelreihe gehe ich dann auf weitere Einzelheiten der Bibliothek ein und werfe einen kurzen Blick hinter die Kulissen der Implementierung.
Voraussetzungen
Dieser Artikel nimmt an einigen Stellen Bezug auf Aussagen im ersten Teil, dabei geht es um "best practices" bei der Operatorüberladung. Außerdem ist für die Verwendung von boost::operators ein rudimentäres Verständnis im Umgang mit Templates nötig.
Inhalt
Teil 2
1 Ein Operator kommt selten allein
1.1 Ein Beispiel: class Rational
1.2 Alles Routine
1.3 Arbeitserleichterung
2 "Do as the ints do": Die Konzepte von boost::operators
2.1 Die Operatorfamilien
2.2 Arithmetische Operatorgruppen
2.3 Iterator Operatoren und Iterator Helfer
3 boost::operators im Einsatz
3.1 Noch einmal class Rational
3.2 Rational trifft boost
1 Ein Operator kommt selten allein
1.1 Ein Beispiel: class Rational
Wer sich einmal eine Liste der überladbaren Operatoren anschaut, der sieht dass es sich um rund 50 Operatoren handelt, bei denen so ziemlich jeder beliebig viele mögliche Überladungen hat. Selbst wenn man sich auf die für eine gegebene Klasse sinnvollen Operatorüberladungen beschränkt bleibt noch eine beträchtliche zahl zu implementierender Funktionen. Nun kann man sich natürlich herausreden und sagen "Wieso? Ich möchte nur 5 Operationen implementieren, also brauche ich nur 5 Operatoren." Dem ist aber nicht so.
Nehmen wir einmal das Standardbeispiel für eine Klasse mathematischer Objekte, die Klasse Rational für Brüche. Die nötigen Operationen sind schnell aus dem Ärmel geschüttelt: vier Grundrechenarten, Vorzeichenwechsel, Test auf Gleichheit und Vergleichsrelation (kleiner als). Die Deklaration ist ebenso schnell hingeschrieben:
C/C++ Code:
1 2 3 4 5 6 7 8 9 10 11 12
1 2 3 4 5 6 7 8 9 10 11 12
class Rational
{
public:
Rational operator-() const;
};
Voilá. Sieben Operationen, sieben Operatoren. Aber damit nicht genug, es geht gerade erst los. In Teil 1 haben wir erfahren, dass sich Operatoren verhalten müssen wie man es erwartet. Dazu gehört auch, dass man statt a = a + b auch a += b schreiben kann, statt a < b auch b > a usw. Die Operatoren haben Verwandte und bilden Operatorfamilien. Hat man einen Operator aus einer Familie, dann erwartet man die anderen auch. Also erweitern wir unsere Deklarationen:
Damit sind wir schon bei 16 Operatoren. Das ist mehr Arbeit als es auf den ersten Blick ausgesehen hat.
1.2 Alles Routine
Wenn man sich jetzt arbeitswütig ins Getümmel stürzt und anfängt die Operatoren einen nach dem anderen zu implementieren, merkt man schnell, dass sich vieles wiederholt: die Addition in operator+ und operator+= ist die gleiche, ähnliches gilt für die anderen Grundrechenarten und die verschiedenen Vergleichsoperatoren ähneln sich auch sehr.
In den "üblichen Implementationen" in Teil 1 habe ich an mehreren Stellen darauf hingewiesen, dass man Operatoren mit Hilfe anderer Operatoren implementieren sollte. Wenn man einen Operator implementiert hat, kann man oft die anderen Operatoren der Familie implementieren, indem man den bereits vorhandenen aufruft. Damit reduziert sich der Aufwand für unsere Rational-Operatoren auf sechs Implementierungen und einen Haufen Einzeiler:
Also ist es doch nicht so wild. Die paar Einzeiler runden das Bild ab, sie sind schnell geschrieben und sehen sowieso immer gleich aus. Als Bonus kommt dazu dass sie automatisch konsistent mit den anderen Operatoren in ihrer Familie sind. Besser gehts doch garnicht, oder?
Doch, es geht besser.
1.3 Arbeitserleichterung
Entwickler sind von Natur aus faul. Die Devise lautet "Tue nichts was der Computer für dich tun kann" und die Königsdisziplin heißt Automatisierung. Die oben gezeigten Einzeiler sehen immer und überall gleich aus und wenn etwas überall gleich ist, dann schreit es danach automatisiert zu werden. Die Herren von den boost-Bibliotheken haben den Schrei gehört und antworten mit einer eigenen Bibliothek, die unserer Faulheit genüge tut und uns das lästige Tippen abnimmt. In unserem Beispiel sieht das dann so aus:
Alle zusätzlichen Operatoren, die wir oben mit nervigem Copy&Paste und einzelnen Änderungen an den Deklarationen hinzufügen mussten, werden durch die simple Ableitung von einem einzelnen Template hinzugeneriert. Welche Templates man braucht um bestimmte Operatoren zu generieren und welche Voraussetzungen man dafür liefern muss wird im folgenden Kapitel beschrieben.
2 "Do as the ints do": Die Konzepte von boost::operators
boost::operators ist dazu gedacht automatisch Operatoren zu generieren, deren manuelle Implementierung immer gleich aussehen würde, weil sich die entsprechenden Klassen und Operationen so verhalten sollen wie man es von Standarddatentypen her gewohnt ist. Unter dieses erwartete Verhalten fallen pauschal gesagt quasi sämtliche Punkte, die in Teil 1 zu den einzelnen Operatoren erwähnt werden. Dazu gehören sowohl semantische Eigenheiten einzelner Operatoren wie die Kommutativität von Multiplikation und Addition als auch das Vorkommen von verwandten Operatoren. boost::operators nimmt uns also für diese "langweiligen" Operatoren fast sämtliche Arbeit ab, der Aufwand für den Entwickler beschränkt sich auf wenige Zeilen. Auf der anderen Seite bedeutet das allerdings, dass wir uns keine unüblichen oder exotischen Operatoren einfallen lassen sollten, wenn wir sie nicht wieder komplett von Hand schreiben wollen. Aber das tun wir ja sowieso nur äußerst selten, schließlich wissen wir aus Teil 1, dass unübliche und exotische Operatoren den Anwender nur verwirren und deshalb nur mit guten Gründen und einer noch besseren Dokumentation serviert werden sollten.
2.1 Die Operatorfamilien
Boost definiert für die verschiedenen Operatorfamilien jeweils ein oder mehrere Templates. Pro Familie muss ein "Basisoperator" definiert sein, dessen Verhalten von den anderen Operatoren der Familie übernommen wird und der vom Entwickler der Klasse definiert werden muss. Damit die Operatorfamilien generiert werden können, müssen die Basisoperatoren bestimmte Bedingungen erfüllen, z.B. müssen die Ergebnistypen der Vergleichsoperatoren in einen bool konvertierbar sein. Die Operatorfamilien und die zugehörigen Basisoperatoren sowie eventuell nötige weitere Bedingungen werden in der folgenden Tabelle aufgelistet:
Die Familien der üblichen arithmetischen und bitweisen Operatoren sind selbsterklärend. Zwei weitere Operatorfamilien, dereferencable und indexable, generieren Pointer/Iterator-Operatoren: mit dereferencable wird mittels operator* ein operator-> erzeugt und indexable erzeugt einen operator[], so dass ptr[n] == *(ptr + n).
Die verschiedenen Operatorfamilien werden weiter zusammengefasst zu Gruppen. Dabei unterscheidet Boost zwischen den arithmetischen Operatorgruppen und den iteratorbezogenen Operatorgruppen. Es steht dem Benutzer frei, die angebotenen Operatorgruppen zu verwenden oder seine Klasse von mehreren Operatorfamilien abzuleiten; bei modernen Compilern mit den heutzutage üblichen Features wie Empty Base Class Optimization ist das Ergebnis identisch.
2.2 Arithmetische Operatorgruppen
Meistens ist für einen bestimmten Typ mehr als ein Operator (bzw. eine Operatorfamilie) sinnvoll. Die Addition geht meist Hand in Hand mit der Subtraktion, für Zahlentypen wie Rational werden gleich alle vier Grundrechenarten benötigt usw.
Die einzelnen Familien der arithmetischen Operatoren werden daher in Gruppen zusammengefasst, für die jeweils eigene Templates definiert sind. Die Gruppe ordered_field_operators enthält z.B. die Familien addable, subtractable, multiplicable, dividable, less_than_comparable und equality_comparable - die Namen sprechen für sich. Die verschiedenen arithmetischen Operatorgruppen sowie die Familien, die sie umfassen, werden im Folgenden kurz dargestellt.
Bei den mathematischen Operatoren gibt es manchmal zwei Gruppen mit den selben Familien, aber verschiedenen Namen. Dies beruht auf den verschiedenen möglichen Betrachtungsweisen der Operatorgruppen: eine einfache Zusammenfassung der Grundrechenarten auf der einen Seite, mathematisch- gruppentheoretische Überlegungen auf der anderen Seite. Die Operatorgruppen der Grundrechenarten sind additive und multiplicative, in denen jeweils die Familien addable und subtractable bzw. multipliable und dividable zusammengefasst sind. Diese beiden Gruppen ergeben zusammengefasst die Gruppe arithmetic (d.h. die 4 Grundrechenarten). Dazu gibt es noch die Gruppen integer_multipliable und integer_arithmetic, wo den entsprechenden Gruppen noch die Modulo-Operation hinzugefügt wurde (modable).
Die gruppentheoretische Seite sieht wie folgt aus: additive und multipliable, also die Familien um +,- und *, ergeben die Gruppe ring_operators. Zusammen mit der Division erhält man die field_operators, mit Division und Modulo die euclidian_ring_operators. Die Vergleichsfamilien less_than_comparable und equality_comparable ergeben zusammen die Gruppe totally_ordered. Fügt man diese wiederum den einzelnen gruppentheoretischen Operatorgruppen hinzu, so ergeben sich ordered_ring_operators, ordered_field_operators (siehe das Beispiel für Rational oben) und ordered_euclidian_ring_operators.
Zusätzlich zu all dem gibt es noch drei weitere kleinere arithmetische Operatorgruppen: bitwise setzt sich aus den drei Familien für bitweise Operationen zusammen (&, | und ^), unit_steppable umfasst die Inkrement- und Dekrement-Familien und shiftable - wie der Name schon sagt - die beiden Shifts.
2.3 Iterator Operatoren und Iterator Heler
Ähnlich wie bei den arithmetischen Gruppen gibt es Operatorgruppen, die die üblichen Operationen der verschiedenen Iteratorarten umfassen, wie sie auch im C++98 Standard, §24.1 definiert sind. Der Name ist jeweils Programm: input_iterable, output_iterable, forward_iterable, bidirectional_iterable und random_access_iterable. input_iterable und forward_iterable beinhalten dabei beide lediglich die Inkrementoperatoren, die Namen lassen aber darauf schließen in welchem Kontext die jeweiligen Iteratorklassen verwendet werden sollen.
Zusätzlich zu den Operatorgruppen für Iteratoren gibt es noch jeweils einen sogenannten Iterator Helper, der zusätzlich zu den geerbten Operatorgruppen noch die vom Standard verlangten typedefs für die jeweilige Iteratorart beinhaltet. Der Helper für Input-Iteratoren heißt input_iterator_helper, für die vier anderen Iteratorarten werden die Namen ähnlich gebildet.
3 boost::operators im Einsatz
Im Folgenden wird die grundlegende Verwendung von boost::operators anhand unserer Rational-Klasse genauer gezeigt.
3.1 Noch einmal class Rational
Gehen wir es also nochmal gründlich an mit unserer Klasse für rationale Zahlen:
Wir nehmen für die interne Darstellung das, was am Nächsten liegt, nämlich zwei ints für Zähler und Nenner.
Destruktor und Zuweisungsoperator interessieren uns nicht weiter, ebensowenig der Copy-Ctor, da hier die compilergenerierten ausreichend sind.
Weitere Konstruktoren die wir brauchen könnten sind ein Konstruktor fur die explizite Angabe von Zähler und Nenner, ein Defaultkonstruktor, der wie bei ints und anderen Standardtypen nullinitialisiert sowie einen Konvertierungskonstruktor von int nach Rational.
Eine Konvertierung von double nach Rational sehen wir wegen der unterschiedlichen Wertebereiche nicht vor, allerdings statten wir unsere Klasse mit einer Konvertirungsfunktion nach double aus (keinen Konvertierungsoperator, um später Mehrdeutigkeiten zu vermeiden).
Schließlich nehmen wir noch an, dass wir eine Kürzungsfunktion haben, die in fast jeder Operation aufgerufen wird und dafür sorgt, dass Zähler und Nenner so weit wie möglich gekürzt sind. Eine weitere Invariante soll sein, dass nur der Zähler vorzeichenbehaftet ist.
Die Behandlung von Division durch Null (sowohl bei der Rechenoperation als auch wenn der Zähler Null ist) und von Integerüberläufen werde ich hier vorerst nicht behandeln.
class Rational
{
//Invarianten:
//- zaehler und nenner sind immer vollstaendig gekuerzt
//- der nenner ist immer positiv (Vorzeichen befindet sich am zaehler) int zaehler;
int nenner;
void kuerzen();
public:
//Konstruktoren:
//Default- und Konvertierungs-Konstruktor für ints gleich mit eingebaut...
Rational(int z = 0, int n = 1)
: zaehler(n>0?z:-z),
nenner(n>0?n:-n)
{
kuerzen();
}
//Copy-Ctor compilergeneriert
//Destruktor compilergeneriert
//Zuweisung für Rational compilergeneriert
//Zuweisung für int implizit generiert durch Konvertierungs-Konstruktor
class Rational
{
//Invarianten:
//- zaehler und nenner sind immer vollstaendig gekuerzt
//- der nenner ist immer positiv (Vorzeichen befindet sich am zaehler) int zaehler;
int nenner;
void kuerzen();
public:
//Konstruktoren:
//Default- und Konvertierungs-Konstruktor für ints gleich mit eingebaut...
Rational(int z = 0, int n = 1)
: zaehler(n>0?z:-z),
nenner(n>0?n:-n)
{
kuerzen();
}
//Copy-Ctor compilergeneriert
//Destruktor compilergeneriert
//Zuweisung für Rational compilergeneriert
//Zuweisung für int implizit generiert durch Konvertierungs-Konstruktor
class Rational
{
//Invarianten:
//- zaehler und nenner sind immer vollstaendig gekuerzt
//- der nenner ist immer positiv (Vorzeichen befindet sich am zaehler) int zaehler;
int nenner;
void kuerzen();
public:
//Konstruktoren:
//Default- und Konvertierungs-Konstruktor für ints gleich mit eingebaut...
Rational(int z = 0, int n = 1)
: zaehler(n>0?z:-z),
nenner(n>0?n:-n)
{
kuerzen();
}
//Copy-Ctor compilergeneriert
//Destruktor compilergeneriert
//Zuweisung für Rational compilergeneriert
//Zuweisung für int implizit generiert durch Konvertierungs-Konstruktor
Als nächstes kommt die Implementierung der vier Grundrechenarten. Wie man der Tabelle aus Kapitel 2.1 entnehmen kann, braucht boost::operators dafür die Operatoren +=, -= usw. Außerdem kann man nach Vorlage der Standardtypen double und float die Inkrement- und Dekrementoperatoren so implementieren, dass sie jeweils eine Erhöhung/Erniedrigung um 1 bedeuten. Was noch bleibt sind die Vergleichsoperatoren:
Damit hätten wir das Grundgerüst schon so weit, dass wir den Rest von Boost erledigen lassen können.
3.2 Rational trifft boost
Schauen wir uns nochmal die Tabelle der Operatorfamilien an und vergleichen sie mit dem, was wir unserer Klasse schon mitgegeben haben. Folgende Familien können (und sollten) wir damit benutzen:
Die Grundrechenarten, also addable, subtractable, multipliable und dividable.
incrementable und decrementable
less_than_comparable, equivalent und dadurch equality_comparable
Um unsere Klasse jeder einzelnen dieser Familien bekannt zu machen haben wir zwei Möglichkeiten, nämlich einmal indem Rational direkt von jeder einzelnen erbt und einmal indem wir eine Vererbungskette aufbauen mit einer Technik, die boost base class chaining nennt. Die Vererbung darf je nach Laune public, protected oder private geschehen, das hat keinen Einfluss auf das Resultat.
Das sieht beides recht wüst aus. In der ersten Version haben wir eine neunfach-Vererbung, in der zweiten Version ein neunfach geschachteltes Template. All die Operatorfamilien-Templates haben einen optionalen zusätzlichen Parameter, der als Basisklasse dient. Die oberste Klasse in der erzeugten Hierarchie ist also die equality_comparable-Familie, die vorletzte die addable-Familie. Die Technik des base class chaining ist relativ neu und in älteren Versionen der Bibliothek nicht enthalten. Die Gründe warum sie eingeführt wurde werden in Teil 3 der Artikelreihe erläutert, es wird trotz der etwas schwierigeren Schreibweise empfohlen sie an Stelle der Mehrfachvererbung zu benutzen.
Wie schon erwähnt hat boost das Konzept der Operatorgruppen. Damit lässt sich der große Haufen Templates um einiges reduzieren:
C/C++ Code:
//base class chaining mit Operatorgruppen class Rational : boost::ordered_field_operators<Rational //Operatoren +, -, *, /, >, >=, <=, !=
, boost::unit_steppable<Rational //Postinkrement und -dekrement
, boost::equivalent<Rational> > > //operator==
{
/*...*/
};
C/C++ Code:
//base class chaining mit Operatorgruppen class Rational : boost::ordered_field_operators<Rational //Operatoren +, -, *, /, >, >=, <=, !=
, boost::unit_steppable<Rational //Postinkrement und -dekrement
, boost::equivalent<Rational> > > //operator==
{
/*...*/
};
C/C++ Code:
//base class chaining mit Operatorgruppen class Rational : boost::ordered_field_operators<Rational //Operatoren +, -, *, /, >, >=, <=, !=
, boost::unit_steppable<Rational //Postinkrement und -dekrement
, boost::equivalent<Rational> > > //operator==
{
/*...*/
};
Mit den drei Zeilen werden also mal eben 11 zusätzliche Operatoren generiert, besser geht es kaum! Damit haben wir alles, um rationale Zahlen mit anderen rationalen Zahlen zu verrechnen und zu vergleichen. Da wir den Konvertierungskonstruktor für int haben und boost freundlicherweise alle nötigen binären Operatoren als freie Funktionen liefert, haben wir frei Haus auch die Grundrechenarten und Vergleiche mit ints auf alle erdenkliche Arten mitgeliefert bekommen.
Fazit und Ausblick
Wie man sieht kann Boost uns hier wiedereinmal viel Arbeit abnehmen. Mit geringem Aufwand können die eigenen Klassen mit einem vollständigen Satz von Operatoren ausgestattet werden.
Im nächten Artikel dieser Reihe werde ich die Unterstützung von gemischten Operatoren durch boost::operators erläutern und die Klasse Rational um gemischte Rechenoperationen mit double erweitern. Außerdem zeige ich die Verwendung der Iterator Helfer am Beispiel eines simplen Array-Iterators und werfe anschließend einen Blick in die Implementation von boost::operators.
Der fertige Quellcode der Rational-Klasse inklusive Behandlung der Nulldivision (eine einfache Exception) kann hier heruntergeladen werden.
Der vorgestellte Code wurde auf MSVC 2008 kompiliert und getestet (Tippfehler vorbehalten)
Warum? Nun, "tmp+=rhs" gibt eine Referenz zurück. Im ersten Fall "return tmp+=rhs;" ist zur Compile-Zeit nicht klar, was für eine Referenz das ist -- es sei denn der Compiler ist superschlau und stellt dies beim Inlining von operator+= fest. Im zweiten Fall taucht in der Return-Anweisung das lokale Objekt "tmp" auf. Hier kann der Compiler die "[N]amed [R]eturn [V]alue [O]ptimization" (NRVO) durchführen.
Der GCC C++ Compiler führt die NRVO im zweiten Fall durch. Im ersten Fall nicht -- auch nicht mit höchster Optimierungsstufe. Man kann sich also mit der zweiten Version eine Kopie eines Rational-Objektes sparen.
Hallo Sebastian, danke für die Anmerkung
Das war allerdings nur ein Beispiel, später übernimmt boost ja die Implementierung der Operatoren. Die sieht dann im Falle eines NRVO-fähigen Compilers, oder wenn das Flag BOOST_FORCE_SYMMETRIC_OPERATORS gesetzt ist (wird im nächsten Artikel angesprochen), so aus, wie du vorgeschlagen hast, ansonsten sinngemäß
Nächstes Thema anzeigen Vorheriges Thema anzeigen
Sie können keine Beiträge in dieses Forum schreiben. Sie können auf Beiträge in diesem Forum antworten. Sie können Ihre Beiträge in diesem Forum nicht bearbeiten. Sie können Ihre Beiträge in diesem Forum nicht löschen. Sie können an Umfragen in diesem Forum nicht mitmachen.
c++.de ist Teilnehmer des Partnerprogramms von Amazon Europe S.à.r.l. und Partner des Werbeprogramms, das zur Bereitstellung eines Mediums
für Websites konzipiert wurde, mittels dessen durch die Platzierung von Werbeanzeigen und Links zu amazon.de
Werbekostenerstattung verdient werden kann.
Die Vervielfältigung der auf den Seiten www.c-plusplus.de, www.c-plusplus.info, www.c-sar.de, www.c-plusplus.net und www.baeckmann.de
enthaltenen Informationen ohne eine schriftliche Genehmigung des Seitenbetreibers ist untersagt
(vgl. §4 Urheberrechtsgesetz). Die Nutzung und Änderung der vorgestellten Strukturen und Verfahren in
privaten und kommerziellen Softwareanwendungen ist ausdrücklich erlaubt, soweit keine Rechte Dritter verletzt werden.
Der Seitenbetreiber übernimmt keine Gewähr für die Funktion einzelner Beiträge oder Programmfragmente, insbesondere
übernimmt er keine Haftung für eventuelle aus dem Gebrauch entstehenden Folgeschäden.