Zum Inhalt springen

C++-Programmierung/ Eine Matrix-Bibliothek – mitrax/ Dimension einer Matrix

Aus Wikibooks


Die Inhalte dieses Kapitels werden in der Datei dimension.hpp stehen. Die Dimension einer Matrix besteht aus einer Anzahl von Zeilen und Spalten. Ein entsprechender Datentyp soll nichts weiter tun, als diese in einen Zusammenhang zu bringen. Der Lesezugriff soll auf die Anzahl der Zeilen und der Spalten getrennt möglich sein, während der Schreibzugriff immer beide betreffen sollte. Das Erstellen von Objekten muss natürlich entweder ohne Argumente oder mit einer Anzahl von Zeilen und eine Anzahl von Spalten möglich sein. Außerdem bietet es sich an, eine Erstellung aus Objekten zu ermöglichen, die ebenfalls über eine Höhe (Anzahl von Zeilen) und eine Breite (Anzahl von Spalten) verfügen. Ein Kopierkonstruktor ist selbstverständlich auch notwendig, wobei hier die vom Compiler erstellte Variante genutzt werden sollte. An dieser Stelle gibt es noch keine Notwendigkeit, sich auf einen bestimmten Typ für die internen Größen festzulegen. Lediglich dass dieser für Zeilen und Spalten identisch sein sollte, kann schon sicher gesagt werden. Alles in allem sieht unsere Klasse damit folgendermaßen aus:

template < typename T >
class dimension{
public:
    // Clientklassen müssen herausfinden können, welchen Typ die Daten haben
    typedef T value_type;

    // Konstruktoren
    dimension():
        rows_(value_type()),
        columns_(value_type())
        {}

    dimension(value_type const& rows, value_type const& columns):
        rows_(rows),
        columns_(columns)
        {}

    template < typename SizeType >
    explicit dimension(SizeType const& size):
        rows_(size.height()),
        columns_(size.width())
        {}

    // Schreibzugriff
   void resize(value_type const& rows, value_type const& columns){
        rows_ = rows;
        columns_ = columns;
    }

    // Lesezugriff
    value_type const rows()const{return rows_;}
    value_type const columns()const{return columns_;}

private:
    value_type rows_;
    value_type columns_;
};

Schauen wir uns die Bedingungen, unter denen die verschiedenen Methoden genutzt werden können, noch einmal genauer an. Für die Nutzung des Standardkonstruktors muss value_type (also der als Templateparameter übergebene Typ T) defaultkonstruierbar sein. In den meisten Fällen dürfte value_type für einen eingebauten Datentyp stehen. Diese sind alle defaultkonstruierbar und entsprechend kann der Standardkonstruktor mit Ihnen verwendet werden. Beachten Sie an dieser Stelle noch einmal, dass dies explizite Initialisierung für eingebauten Datentypen, nicht äquivalent zu einem Konstruktor ist, der keine explizite Initialisierung der Datenmember durchführt. Der Grund hierfür ist in einem früheren Kapitel dieses Buches beschrieben.

Der zweite Konstruktor setzt voraus, das value_type kopierkonstruierbar ist. Damit der Compiler einen Kopierkonstruktor für dimension-Objekte erzeugen kann, muss diese Bedingung ebenfalls erfüllt sein.

Der dritte Konstruktor ist ein Template. Er übernimmt eine Referenz auf ein konstantes Objekt vom Typ SizeTyp. Damit dieser Konstruktor genutzt werden kann, müssen SizeTyp-Objekte über die Methoden width() und height verfügen. Diese Methoden müssen konstant sein und sie müssen Objekte von einem Typ zurückgeben, der implizit in value_type gewandelt werden kann. Das bedeutet, dass value_type-Objekte entweder einen Konstruktor besitzen, der ein SizeTyp-Objekt übernimmt, oder kopierkonstruierbar sein müssen, wobei SizeTyp dann einen Castoperator nach value_type zur Verfügung stellen muss. Sind beides eingebaute Datentypen, so ist eine implizite Umwandlung natürlich immer möglich. Dass der Konstruktor explicit ist, ergibt sich aus der Tatsache, dass SizeTyp nicht zwingend auch einer Dimension oder allgemein einer 2D-Größe entspricht. Betrachten Sie folgendes Beispiel, in dem angenommen wird, der Konstruktor wäre nicht explicit:

dimension< std::size_t > dim1 = size();   // OK
dimension< std::size_t > dim2 = rect();   // Grenzwertig
dimension< std::size_t > dim3 = pixmap(); // Aussagemäßiger Blödsinn

size(), rect() und pixmap() stehen jeweils für Objekte der entsprechenden Typen. Was die Typen repräsentieren, ist für unsere Zwecke am Name ausreichend erkennbar. Sicher erinnern Sie sich noch, dass diese implizite Schreibweise für eine Initialisierung immer dann verwendet werden sollte, wenn man guten Gewissens sagen kann, dim1 entspricht dimension(). Für dieses erste Beispiel ist das ohne Bedenken möglich. dim2 entspricht einem rect() ist dagegen schon grenzwertig. Man kann es noch durchgehen lassen, aber aussagekräftiger wäre es, so etwas wie dim2 = rect().size() zu schreiben. dim3 entspricht einer pixmap() ist endgültig Blödsinn. Eine Pixmap ist etwas völlig anderes als eine 2D-Größenangabe.

Nutzt man nun stattdessen die Schreibweise der expliziten Initialisierung, dann wandelt sich die Aussage von „entspricht“ in „wird konstruiert aus/mit“. Für size() ist es jetzt zwar schade, dass man die implizite Schreibweise nicht verwenden kann, aber mit der jetzigen Aussage macht man auch nichts falsch. rect() kann mit dieser Schreibweise ebenfalls guten Gewissens zur Initialisierung verwendet werden, ohne dass sich der aufmerksame Leser fragen muss, was hier wohl passiert. Für pixmap() wäre es angemessen, stattdessen pixmap().size() zu schreiben, leider bieten nicht alle Typen auch eine Methode an, die Breite und Höhe in einem zurückgibt. Intuitiv wird man, ohne den Konstruktor zu kennen, vermuten, dass dim3 mit der Dimension bzw. Größe der Pixmap erstellt wird, daher ist es durchaus zumutbar, nur das Pixmapobjekt anzugeben, wenn die Pixmap keine size()-Methode oder etwas ähnlich passendes zur Verfügung stellt.

dimension< std::size_t > dim1(size());   // OK
dimension< std::size_t > dim2(rect());   // OK
dimension< std::size_t > dim3(pixmap()); // Grenzwertig

// OK, aber warum erst height, dann width?
dimension< std::size_t > dim4(pixmap().height(), pixmap().width());

Natürlich könnte man, wie bei dim4, auch explizit die beiden Parameter angeben, aber wie im Kommentar schon angedeutet, werden sich hier viele Programmierer einen Augenblick lang fragen, warum erst die Höhe und dann die Breite angegeben wird. In den allermeisten Fällen, in denen man von Breite und Höhe spricht, ist es nun mal genau umgekehrt. Bei Matrizen spricht man immer von Zeilen und Spalten und zwar in dieser Reihenfolge. Das äquivalent zu einer Anzahl von Zeilen ist aber nun mal die Höhe und eine Anzahl von Spalten entspricht einer Breite. Dieses kurze Nachdenken sollte man einem Leser von Nutzercode ebenfalls ersparen, denn es lenkt ihn nur unnötig vom verstehen des eigentlichen Codes ab. Daher ist der als grenzwertig markierten Variante bei dim3 gegenüber diesem Vorgehen doch der Vorzug zu gewähren. Durch die Verwendung eines im Kontext geeigneten Variablennamens, kann auch dieser Aufruf für spätere Leser intuitiv gestaltet werden. Auf die Wahl eines solchen Variablennamens haben wir jedoch leider keinen Einfluss. Das liegt einzig in der Hand des zuständigen Programmierers.

Es gibt noch ein weiteres, sehr starkes Argument dafür, den Konstruktor als explicit zu deklarieren. Nehmen Sie an, sie besäßen eine Funktion, die als Parameter eine Dimension erwartet. Wäre eine implizite Typumwandlung mit diesem Konstruktor möglich, könnten hier beliebige Objekte mit einer kompatiblen width()- und height()-Methode übergeben werden. Ist der Konstruktor explizit, muss immer eine Umwandlung mittels static_cast oder, falls das als intuitiv empfunden wird, durch explizite Erzeugung eines temporären Objekts erfolgen.

Wie Sie bereits bemerkt haben werden, machen wir uns viele Gedanken darüber, wie eine Klasse so gestaltet werden kann, dass ein Nutzer leicht Code schreiben kann, den später wiederum ein Leser seines Codes, leicht versteht. Unsere Entscheidungen haben also nicht nur wesentlichen Einfluss auf den Nutzer unserer Klasse, sondern auch noch auf den Nutzer von dessen Code. Wenn wir bei unserem Klassendesign gute Arbeit leisten, legen wir den Grundstein für Code, der leichter zu warten ist. Wenn wir dagegen schlechte Entscheidungen treffen, haben auch Nutzer unserer Klasse kaum eine Chance ihren Code intuitiv zu gestalten. Die Aufgabe, die uns hier zu Teil wird, ist also ein ausgesprochen verantwortungsvolle. Umsomehr, wenn Sie daran denken, dass die Schnittstellen unserer Klassen später nicht geändert werden können, ohne dass Benutzercode dadurch inkompatibel wird.

Elemente vertauschen

[Bearbeiten]

Das Funktionstemplate swap() vertauscht zwei Elemente gleichen Typs. Falls für einen Datentyp keine eigene swap()-Funktion deklariert ist, wird auf die Standardfunktion std::swap() zurückgegriffen. Diese wird Objekt Zwei kopieren, anschließend Objekt Eins an Objekt Zwei zuweisen und schließlich das kopierte Objekt, an Objekt Eins zuweisen. Objekte die std::swap() vertauschen kann, müssen also kopierkonstruierbar und kopierzuweisbar sein. Dieses Vorgehen ist für viele Objekte zu Zeitaufwendig und außerdem fängt man sich durch den potentiellen Aufruf eines Kopierkonstruktors und einer Kopierzuweisung die Gefahr ein, dass innerhalb der swap()-Funktion unnötig Ausnahmen geworfen werden könnten. Entsprechend bietet es sich an, für eigene Datentypen auch eine eigene swap()-Funktion anzubieten, welche die spezifischen Eigenschaften des eigenen Datentyps ausnutzt.

Nun ist es so, dass Funktionstemplates ausschließlich total spezialisiert werden können. Das heißt, Sie müssen den konkreten Typ kennen, den die übergeben Objekte haben. Eine Spezialisierung für alle Objekte vom Typ einer dimension< T >, wobei T ein beliebiger Typ ist, lässt sich also nicht durchführen. C++ erlaubt für Funktionstemplates stattdessen die Möglichkeit der Überladung, was fast den gleichen Effekt hat. Das große Problem besteht darin, dass eine Überladung zum Erstellen einer neuen swap()-Funktion führt. Also einer, die nicht zur Menge, der durch das ursprüngliche Template generierten Funktionen gehört. Dass Hinzufügen von etwas neuem zum Namensraum std ist jedoch laut C++-Standard nicht gestatten. Sie handeln sich undefiniertes Verhalten ein, wenn Sie so etwas versuchen.

Die Lösung besteht darin, nicht explizit die std-Version von swap() aufzurufen. Machen Sie std::swap() mittels using-Deklaration verfügbar und rufen Sie anschließend swap() ohne explizite Bereichsangabe auf. Ihr Compiler wird dann zunächst in dem Namensraum, in dem die Typen der Parameter definiert sind, nach einer swap()-Funktion suchen. Nur wenn dort keine gefunden wird, greift der Compiler auf die std-Version zurück. Dieses Vorgehen empfiehlt sich ohnehin für die meisten Funktionen aus std. Stellen Sie sich vor, Sie möchten für ein Objekt, eines unbekannten Datentyps T, die Quadratwurzel ziehen. Der Aufruf von std::sqrt() funktioniert nur für eingebaute Datentypen und solche, die implizit in diese umgewandelt werden können. Auch hier wäre es möglich, dass im Namensraum von T eine sqrt()-Funktion deklariert ist, die ihr Compiler nicht berücksichtigt, wenn Sie explizit nach der std-Version verlangen. Das Schöne an dieser using-Technik ist, dass sich fast nie Nachteile, aber oft Vorteile aus ihrer Nutzung ergeben.

Nun steht für uns natürlich die Frage im Raum, ob sich für unser Klassentemplate dimension spezifischen Eigenschaften erkennen lassen, die innerhalb einer swap()-Funktion zu irgendwelchen Vorteilen führen könnten. Auf den ersten Blick lautet die Antwort Nein, doch wenn man genauer hinschaut erkennt man, dass die Antwort vom konkreten Typ von T abhängt. Falls für T eine eigene swap()-Funktion deklariert ist, ist davon auszugehen, dass deren Nutzung, gegenüber der Variante mit Kopieren und Zuweisen, Vorteile bringen würde. Diese potentiellen Vorteile könnten wir nutzen, indem wir in einer eigenen spezifischen swap()-Funktion, für beide Membervariablen swap() aufrufen. Dabei müssen wir selbstverständlich die oben genannt using-Technik verwenden, um auf std::swap() zurückzugreifen, falls es keine spezifische Version für T gibt. Um auf die privaten Member der Klasse zugreifen zu können, muss die swap()-Funktion natürlich ein Member oder Freund der Klasse sein. Üblicherweise wird eine Memberfunktion swap() deklariert, welche die eigentliche Arbeit ausführt und eine nicht befreundete Nichtmemberfunktion, welche die Memberfunktion aufruft und natürlich vom Compiler gefunden wird, wenn swap() in Zusammenhang mit Objekten der Klasse dimension aufgerufen wird. In der C++-Standardbibliothek wird dies übrigens genau so gemacht. Sie können sich ja mal beispielhaft den Container std::vector ansehen. Auch dort gibt es eine Memberfunktion swap(), welche die eigentliche Arbeit erledigt. Unserer Implementierung sieht folgendermaßen aus:

template < typename T >
class dimension{
public: // ...
    void swap(dimension& rhs){
        using std::swap; // Falls keine spezielle swap verfügbar
        swap(rows_, rhs.rows_);
        swap(columns_, rhs.columns_);
    }
// ...
};

template < typename T >
inline void swap(dimension< T >& lhs, dimension< T >& rhs){
    lhs.swap(rhs);
}

Realistisch betrachtet, wird T für unseren dimension-Typ wohl in den aller meisten Fällen ein eingebauter Datentyp sein. Für solche wäre die Optimierung unnötig. Die Frage ist nun, ob sich aus unserem Vorgehen, für alle Memberdaten swap() aufzurufen, irgendwelche Nachteile ergeben können. Offensichtlich ist natürlich, dass es Nachteile bei der Wartung des Codes mit sich bringt. Falls ein Datenmember zur Klasse hinzugefügt wird, muss auch unsere swap()-Funktion erweitert werden. Noch schlimmer Folgen dieser Art hätten wir zu befürchten, wenn die Klasse Teil einer Vererbungshierarchie wäre, denn dann müssten auch Datenmember der Basisklasse beachtet werden. Kompensieren ließe sich dies ein wenig, indem eine swap()-Methode neben dem Tauschen der eigenen Datenmember, immer auch ein swap() für den Basisklassenanteil aufruft. Das ist ohnehin sinnvoll, denn auf eventuelle private Datenmember der Basisklasse, hat die swap()-Methode ja gar keinen Zugriff. Dies ist hier zwar nicht gegeben, sollte aber immer mit bedacht werden, denn auch solche Hierarchien können sich im Laufe der Zeit ändern.

Wir sollten uns noch einmal genauer anschauen, was die std::swap()-Funktion in unserem konkreten Beispiel bewirken würde und was genau unsere swap()-Methode im Unterschied dazu tut, wenn für T keine spezielle swap()-Funktion existiert. Für die folgende Betrachtung werden die Begriffe Kopierkonstruktor und Kopierzuweisung auch genutzt, wenn es sich um eingebaute Datentypen handelt. In diesem Fall wird statt einen Funktionsaufruf durchzuführen, natürlich einfach eine binäre Kopie erstellt.

Die std::swap()-Funktion ruft zunächst den Kopierkonstruktor von dimension< T > auf. Dieser ruft dann zweimal den Kopierkonstruktor von T auf, wobei natürlich von T abhängt was dieser tut. Als nächstes wird die Kopierzuweisung von dimension< T > aufgerufen und diese ruft wiederum zwei mal die Kopierzuweisung von T auf. Es folgt wiederum eine Kopierzuweisung von dimension< T >, mit den eben beschriebenen Folgen.

Unsere swap()-Methode ruft zweimal infolge std::swap() mit T-Objekten als Parameter auf. Beide male werden zunächst der Kopierkonstruktor von T und anschließend zweimal die Kopierzuweisung von T aufgerufen. Wie sie sehen, unterscheiden sich die beiden Varianten im Wesentlichen, lediglich in der Reihenfolge, in der die Funktionen aufgerufen werden.

Nehmen wir noch einmal an, T wäre ein eingebauter Datentyp, was wir ja als wahrscheinlich erachten. dimension< T > verwendet die compilergenerierten Versionen für Kopierkonstruktor und Kopierzuweisung, daher haben wir keine wirkliche Sicherheit, wie der Compiler diese Implementiert. Falls T ein eingebauter Datentyp ist, wäre es durchaus legitim, wenn der Compiler statt einer binären Kopie der einzelnen Memberdaten, einfach eine binäre Kopie des gesamten Objekts erstellt. Dies kann unter Umständen zu einer Verbesserung der Performance beitragen. Genau diese potentielle Optimierung erschweren wir dem Compiler, wenn wir eine eigene swap()-Funktion in der oben gezeigten Art implementieren.

Aufgrund dieser Abwägungen entscheiden wir uns dagegen, eine eigene swap()-Funktion für dimension< T > zu implementieren. Im Folgenden sind die entscheiden Gegenargumente noch einmal kurz zusammengefasst:

  • Die Wartbarkeit des Codes wird erschwert.
  • Vorteile der eigenen Implementierung sind nur zu erwarten, wenn es sich bei T nicht um einen eingebauten Datentyp handelt. Für T müsste eine spezielle swap()-Funktion existieren. Dies halten wir für sehr unwahrscheinlich.
  • Es sind eventuell Laufzeitnachteile zu erwarten, falls T ein eingebauter Datentyp ist. Dies halten wir für den häufigsten Fall.

Vergleichsoperatoren

[Bearbeiten]

Als Vergleichsoperatoren kommen natürlich nur Gleichheit und Ungleichheit in Frage, da sich die übrigen 4 Operatoren nicht sinnvoll definieren lassen. Wir werden nachfolgend sehr ausführlich betrachten, welche Möglichkeiten für die Implementierung in Frage kommen würden. Dabei gehen wir insbesondere auf eine Technik ein, die in Zusammenhang mit Templates oft nützlich ist. Wir werden jedoch auch zu dem Schluss kommen, dass es an dieser Stelle, ähnlich der swap()-Funktion, mehr Überlegungen gibt, die gegen die Verwendung dieser Technik, an dieser Stelle, sprechen. Die Argumentation für eine Verwendung mag entsprechend etwas konstruiert wirken. Dennoch gibt sie sehr gut die Anwendungsmöglichkeiten wieder und wir werden diese Technik später noch intensiv einsetzen.

Die Frage, vor der wir im Moment stehen, ist, auf welche Weise wir die Vergleichsoperatoren implementieren sollten. Wir könnten sie zu Membern machen oder sie als friend deklarieren. Wir können sie auch als gewöhnliche Funktion deklarieren, etwas komplizierter ausgedrückt: als Nicht-befreundete-Nichtelementfunktionen. Gewöhnliche Funktionen ist an dieser Stelle natürlich auch etwas untertrieben, denn natürlich müssten sie in jedem Fall als Funktionstemplates implementiert werden. Um den Text nicht unnötig in die Länge zu ziehen, verzichten wir jedoch darauf, im Folgenden jedes mal explizit darauf hinzuweisen, dass es sich um ein Funktionstemplate handelt, wenn dieses offensichtlich ist. Weiterhin werden wir nur den Gleichheitsoperator betrachten, für den Ungleichheitsoperator gilt aber alles äquivalent.

Nun, was müssen wir bei der Deklaration also beachten? Die beiden Parameter der Funktion sind gleichwertig. Es ist also egal, ob Sie nun a == b oder b == a schreiben und entsprechend sollte es auch immer möglich sein, die beiden Parameter gegeneinander zu vertauschen, ohne dass sich der Compiler darüber beschwert. Solange a und b vom gleichen Typ sind, ist das der Fall. Was passiert jedoch, wenn b von einem Typ ist, der implizit in ein dimension-Objekt gewandelt werden kann? In diesem Fall wäre eine implizite Typumwandlung nötig. Falls der Operator als Member deklariert ist, so ist diese implizite Typumwandlung ausschließlich für den zweiten Parameter möglich, der erste Parameter, also this, muss immer explizit vom Typ der Klasse sein, zu welcher der Member gehört. Folglich ist eine Deklaration als Member, für die Gleichheitsoperatoren generell ungeeignet. Wir nehmen nachfolgend an, der Operator wäre wie folgt als gewöhnliche Funktion deklariert:

template < typename T >
bool operator==(dimension< T > const& lhs, dimension< T > const& rhs){
    return lhs.rows() == rhs.rows() && lhs.columns() == rhs.columns();
}

Besäße dimension einen nicht-expliziten Konstruktor, welcher die Umwandlung verschiedener Objekte nach dimension ermöglicht, so wäre dies bereits ein starkes Argument dafür, eine implizite Umwandlung, für die Parameter unseres Vergleichsoperators, zu unterstützen. Da dies nicht der Fall ist, müssen wir uns mit einem Notkonstrukt behelfen, das kaum praktische Relevanz haben dürfte, um eine Technik zu betrachten, die genau diese implizite Umwandlung erlaubt. Nehmen Sie an, Sie besäßen einen Typ size, welcher, äquivalent zu dimension, eine Breite und Höhe speichert. Es kann sinnvoll sein, wenn Objekte dieser beiden Typen direkt gegeneinander verglichen werden können. Sofern Sie einen Castoperator (unser Notkonstrukt) für die Umwandlung nach dimension besitzen, wird der Compiler beim Aufruf des Gleichheitsoperator, eine implizite Umwandlung für Ihren Typ size nach dimension ausführen. Praktisch könnte das etwa so aussehen:

// Typ size (kurz und unschön um Platz zu sparen)
struct size{
    std::size_t width, height;

    // Castoperator in eine dimension< std::size_t >
    operator mitrax::dimension< std::size_t >()const{
        return mitrax::dimension< std::size_t >(width, height);
    }
};

int main(){
    size                             size_1 = {5, 5};
    mitrax::dimension< std::size_t > size_2(5, 5);

    std::cout << (size_1 == size_2) << std::endl;
}

Funktioniert mit der aktuellen Deklaration des Gleichheitsoperators nicht…

Da der Vergleichsoperator eine Nicht-befreundete-Nichtelementfunktionen ist, wird der Compiler die Funktion erst instantiieren, wenn sie zum Ersten mal aufgerufen wird. Da der Compiler, an dieser Stelle, nach einer Funktion mit den Parametertypen size und dimension< std::size_t > sucht, wird er ein Funktionstemplate, das mit 2 Parametern gleichen Typs aufgerufen wird, nicht in Betracht ziehen. Dies ist auch sinnvoll, denn eine Instantiierung von Templates, in der Hoffnung dabei eine Variante zu finden, die nach impliziten Typumwandlungen aufgerufen werden kann, ist nicht nur sehr aufwendig, sondern meist auch unmöglich. Sobald für beide Parametertypen ein Operator, mit 2 typgleichen Parametern existiert, was fast immer der Fall ist, entsteht eine Mehrdeutigkeit, die der Compiler nicht auflösen kann.

Wir müssen uns also die Frage stellen, wie wir den Compiler dazu bringen, den Gleichheitsoperator gemeinsam mit der Klasse dimension< std::size_t > zu instantiieren. Dies ist auf jeden Fall für Memberfunktionen der Fall. Wir haben jedoch schon festgestellt, dass dies nicht in Frage kommt. Es ist in der Tat möglich, eine Nicht-Elementfunktion innerhalb einer Klasse zu definieren. Um dem Compiler mitzuteilen, dass es sich nicht um eine Elementfunktion handeln soll, machen wir die Funktion zu einem friend der Klasse. Wenn dies geschieht, wird der Compiler die Funktion gemeinsam mit der Klasse instantiieren. Etwas exakter: Er wird die Funktion, welche eventuell von den Templateparametern der Klasse abhängt, überladen. Es handelt sich hier also nicht um ein Funktionstemplate, sondern um eine Reihe unabhängiger Funktionen gleichen Namens. Betrachten wir einige Beispiele, um uns klar zu machen, was dies bedeutet.

#include <iostream>
#include <iomanip>  // Für setw
#include <typeinfo> // Für typeid-struct

template < typename T > struct A;

struct B{}; // Deklaration von 2 Klassen
struct C{}; // zur Instantiierung von A

// Funktionstemplate test
template < typename T >
void test(T const&){
    std::cout << std::setw(15) << "test< T >(T): " << typeid(T()).name() << std::endl;
}

// Überladung von test() für ein Objekt des Typs A< C >
void test(C const&){
    std::cout << std::setw(15) << "test(C): " << typeid(C()).name() << std::endl;
}

// Funktionstemplate test() für alle Instantzen von A< T >
template < typename T >
void test(A< T > const&){
    std::cout << std::setw(15) << "test< A< T > >(A< T >): " << typeid(T()).name() << std::endl;
}

template < typename T >
struct A{
    // Überladung von test() für jedes Instantiierte A< T >
    friend void test(A const&){
        std::cout << std::setw(15) << "test(A< T >): " << typeid(A()).name() << std::endl;
    }
};

int main(){
    std::cout << std::setw(25) << "test(A< B >()):"; test(A< B >());
    std::cout << std::setw(25) << "test(A< C >()):"; test(A< C >());
    std::cout << std::setw(25) << "test< A< B > >(A< B >()):"; test< A< B > >(A< B >());
    std::cout << std::setw(25) << "test< A< C > >(A< C >()):"; test< A< C > >(A< C >());
    std::cout << std::setw(25) << "test(B()):"; test(B());
    std::cout << std::setw(25) << "test(C()):"; test(C());
    std::cout << std::setw(25) << "test< B >(B()):"; test< B >(B());
    std::cout << std::setw(25) << "test< C >(C()):"; test< C >(C());
}
Ausgabe:
.         test(A< B >()): test(A< T >): F1AI1BEvE
          test(A< C >()): test(A< T >): F1AI1CEvE
test< A< B > >(A< B >()): test< T >(T): F1AI1BEvE
test< A< C > >(A< C >()): test< T >(T): F1AI1CEvE
               test(B()): test< T >(T): F1BvE
               test(C()):      test(C): F1CvE
          test< B >(B()): test< T >(T): F1BvE
          test< C >(C()): test< T >(T): F1CvE

Bemerkung: Die Ausgabe der Methode name() kann bei Ihnen anders sein.

Wenn der Compiler die richtige Überladung für einen Funktionsaufruf herausfinden soll, wählt er jene, die am besten zum übergebenem Parameter passt. Wird etwa ein Objekt vom Typ A< C > übergeben, so passt das Template, das ein Objekt vom Typ A< T > übernimmt besser zu dem Aufruf, als das Template das ein Objekt vom Typ T übernimmt. Noch besser passt allerdings die Überladung, die ein Objekt vom Typ A< C > übernimmt. Sie sehen keine solche Überladung? Nun, durch die Instantiierung von A mit C wurde die friend-Funktion innerhalb der Klasse zu genau dieser Überladung. Entsprechend stammt die Ausgabe in Zeile zwei aus dieser Funktion. Verlangt man hingegen explizit nach einer Instanz eines Funktionstemplates, wie in Zeile 4 der Ausgabe, so wird die passendste Variante unter die Funktionstemplats ausgewählt.

Ein weiterer interessanter Effekt ergibt sich, wenn Sie eine friend-Funktion innerhalb einer Templateklasse definieren, deren Parameter nicht von den Templateparametern des Klassentemplats abhängen. In diesem Fall wird der Compiler bei der zweiten Instantiierung des Klassentemplats als Fehler vermelden, dass diese Funktion zuvor bereits definiert wurde. Wie bereits gesagt, nimmt der Compiler eine Überladung der Funktion vor und diese ist nur möglich, wenn sich die Parameter auch unterscheiden.

Eine weitere, wesentlich wichtigere Folge ist jedoch, dass Sie die befreundete Funktion, sofern sie von den Templateparametern des Klassentemplats abhängt, nicht außerhalb der Klasse definieren können. Im Gegensatz zu Memberfunktion, hängt eine befreundete Funktion schließlich nicht direkt von dem Klassentemplate ab. Da es sich bei der Funktion nicht um ein Template, sondern um eine Reihe von gewöhnlichen Überladungen handelt, müssten Sie bei jeder neuen Instantiierung auch manuell eine neue Definition für den betreffenden Typ schreiben. Das funktioniert syntaktisch, ergibt aber überhaupt keinen Sinn. Ihr Ziel ist es ja, für jede beliebige Instantiierung automatisch eine passende Funktion zu haben. Dass die Funktion innerhalb der Klasse definiert werden muss, hat wiederum zur Folge, dass eine solche Funktion implizit inline ist. Falls dies für Sie aus irgendeinem Grund ein Problem sein sollte, dann lassen Sie die befreundete Funktion, welche innerhalb der Klasse definiert ist, einfach eine Instanz eines Funktionstemplates aufrufen, dass außerhalb definiert ist und die eigentliche Arbeit verrichtet.

Ihnen wird zweifellos aufgefallen sein, dass wir das Schlüsselwort friend hier auf eine Weise missbrauchen, die unseren Wunsch nach einer, von der Klassentemplate-Instanzen abhängigen, Funktionsinstantiierung erfüllt. friend ist eigentlich dafür gedacht, einer Nicht-Memberfunktion Zugriff auf geschützte und private Member der Klasse zu gewähren. Dies stellt sich bei unserer Technik als unerwünschter Seiteneffekt dar. Es gibt keinen Grund, warum der Vergleichsoperator Zugriff auf diese Member haben sollte, da er durch die Zugriffsmethoden auch ganz bequem lesend auf die Daten zugreifen kann. Wie also vermeiden wir, dass die Deklaration als friend eine derartigen Zugriffsmöglichkeit zur Folge hat? Die Antwort lautet, wir machen uns eine andere Eigenschaft von Freundschaft zu nutze. Freundschaft wird nicht vererbt, also legen wir die Operatoren in einer Basisklasse an.

Diese Basisklasse stellt keine „ist ein“-Beziehung dar, sondern eine „ist Implementiert in Form von“-Beziehung. Solche Beziehungen sind privat, der Nutzer unserer dimension-Klasse, braucht nichts über diese Basisklasse zu wissen. Das nächste Problem das sich ergibt, besteht darin, dass in der Basisklasse natürlich noch nicht bekannt sein kann, wie die abgeleitete Klasse heißen wird. Außerdem müssen wir sicherstellen, dass die Basisklasse für jede abgeleitete konkrete Klasseninstanz eine andere ist. Hier schlagen wir zwei Fliegen mit einer Klappe. Wir machen die Basisklasse ebenfalls zu einem Template und übergeben als Templateparameter die abgeleitete Klasse. Solange wir innerhalb der Basisklassendefinition nicht ein konkretes Objekt der abgeleiteten Klasse benötigen, ist dies ohne eine vollständige Definition der abgeleiteten Klasse möglich.

Solange wir nicht versuchen, auf etwas aus der Definition der abgeleiteten Klasse, innerhalb der Basisklasse, zuzugreifen, genügt deren Deklaration. Sie können ja beispielsweise mal scherzeshalber versuchen, auf einen typedef aus der abgeleiteten Klasse zuzugreifen. Der Compiler wird dies, mit einem Verweis auf die noch nicht definierte Klasse, quittieren. Die als friend deklarierte Funktion benötigt zwar intern eine vollständige Definition der abgeleiteten Klasse, aber zu dem Zeitpunkt, da deren Funktionsrumpf instantiiert wird, ist die komplette Definition der abgeleiteten Klasse bereits bekannt. Da die Basisklasse den Nutzer nicht interessieren muss, packen wir sie außerdem in den Namensraum detail. Das Ganze sieht folgendermaßen aus:

namespace detail{ // Forward-Deklaration der Basisklasse
    template < typename Derived > struct instantiate_with_dimension;
}

// Unsere dimension-Klasse mit privater Vererbung
template < typename T >
class dimension: private detail::instantiate_with_dimension< dimension< T > >{
public:
    // ...
};

namespace detail{
    // Definition der Basisklasse
    template < typename Derived > struct instantiate_with_dimension{
        // Definition (gleichzeitig auch Deklaration) der beiden Operatoren
        friend bool operator==(Derived const& lhs, Derived const& rhs){
            return lhs.rows() == rhs.rows() && lhs.columns() == rhs.columns();
        }
        friend bool operator!=(Derived const& lhs, Derived const& rhs){
            return !(lhs == rhs);
        }
    };
}

Von dieser Konstruktion ausgehend, wollen wir betrachten, wie sich C++ aufgrund der neu gewonnenen Möglichkeiten zu impliziten Typumwandlung verhält. Würde unser Beispiel so aussehen, dass auch für die weiter oben eingeführte size-Klasse ein Gleichheitsoperator deklariert ist, wird der Compiler sich beide Versionen ansehen und feststellen, das nur die Variante mit dimension< std::size_t >-Parametern, mittels impliziter Typumwandlung, aufgerufen werden kann. Falls nun jedoch size auch über einen Konstruktor verfügt, der ein dimension< std::size_t >-Objekt übernimmt, haben wir schließlich doch ein Mehrdeutigkeit erreicht.

struct size{
    size(std::size_t w, std::size_t h):width(w),height(h){}
    size(mitrax::dimension< std::size_t > const& d):width(d.rows()),height(d.columns()){}

    std::size_t width;
    std::size_t height;

    // Castoperator in eine dimension< std::size_t >
    operator mitrax::dimension< std::size_t >()const{
        return mitrax::dimension< std::size_t >(width, height);
    }
};

bool operator==(size const& lhs, size const& rhs){
    return lhs.width == rhs.width && lhs.height == rhs.height;
}

int main(){
    size                             size_1(5, 5);
    mitrax::dimension< std::size_t > size_2(5, 5);

    // Fehler: Mehrdeutige Aufruf von operator==()
    std::cout << (size_1 == size_2) << std::endl;
}

Der Compiler könnte hier, mittels Castoperator, eine Umwandlung von size_1 nach mitrax::dimension< std::size_t > vornehmen und den Gleichheitsoperator für zwei dimension< std::size_t >-Objekte wählen oder size_2 mit Hilfe des Konstruktors in ein size-Objekt umwandeln und den Operator der zwei size-Objekte übernimmt wählen. Die Lösung des Problems ist einfach: Entfernen Sie den Castoperator. Anschließend wird der Compiler eine implizite Typumwandlung des dimension< std::size_t > nach size durchführen und den Vergleichsoperator mit den size-Parametern verwendet.

Wenn Sie es nun für wahrscheinlich halten, dass eine size-Klasse, die in Zusammenhang mit dimension< std::size_t > verwendet wird, auch einen Konstruktor besitzt, der dimension< std::size_t >-Objekt übernimmt, dann habe Sie damit vermutlich recht. Warum also die Mühe, wenn am Ende im Zweifelsfall doch der Vergleichsoperator der size-Klasse verwendet wird? Nun, möglicherweise enthält Ihre Klasse neben den Angaben über eine Höhe und eine Breite noch andere Informationen. Dann ist es viel unwahrscheinlicher, dass es einen Konstruktor gibt, der nur ein dimension< std::size_t >-Objekt übernimmt. Für eine solche Klasse kann der Castoperator nach dimension< std::size_t > durchaus sinnvoll sein. Möglicherweise ist hier jedoch auch eine explizite Typumwandlung nach dimension< std::size_t > die bessere Wahl. Das hängt davon ab, ob sich die implizite Umwandlung intuitiv nutzen lässt oder nicht. Beispielsweise ergäbe es keinen Sinn eine Matrix implizit nach dimension zu wandeln. Eine Matrix und eine 2D-Größenangabe sind nicht vergleichbar. Eine explizite Umwandlung könnte beispielsweise durch den Aufruf einer (Member-)Funktion geschehen, die ein entsprechendes dimension< std::size_t >-Objekt zurückgibt.

Ein viel wesentlicher Vorteil besteht jedoch, wenn size ebenfalls ein Template ist und seinen Vergleichsoperator als Nicht-befreundete-Nichtelementfunktion deklariert. Dann stellt der Compiler keine Mehrdeutigkeit fest, weil er sich die Template-Instantiierungen mit gleichen Parametern, aus den oben genannten Gründen, nicht ansieht.

template < typename T >
struct size{
    T width, height;

    size(T w, T h):width(w),height(h){}
    // Konstruktor mit mitrax::dimension< std::size_t >
    size(mitrax::dimension< std::size_t > const& d):width(d.rows()),height(d.columns()){}

    // Castoperator in eine dimension< std::size_t >
    operator mitrax::dimension< T >()const{
        return mitrax::dimension< T >(width, height);
    }
};

template < typename T >
bool operator==(size< T > const& lhs, size< T > const& rhs){
    return lhs.width == rhs.width && lhs.height == rhs.height;
}

int main(){
    size< std::size_t >              size_1(5, 5);
    mitrax::dimension< std::size_t > size_2(5, 5);

    std::cout << (size_1 == size_2) << std::endl; // OK
}

Wird operator==() als Member oder befreundete-Nichtelementfunktion deklariert, ist der Aufruf natürlich wieder mehrdeutig. Anzumerken ist, dass eine Implementierung als Methode, wie oben schon angemerkt, sehr schlechter Stiel wäre, da hierbei nur für den zweiten Parameter implizite Umwandlungen möglich wären. Ein solches grundlos unterschiedliches Verhalten, ist ein deutliches Zeichen für Unwissenheit, um die Feinheiten von C++. Wenn Sie so etwas sehen, dann weisen Sie den Ersteller nach Möglichkeit darauf hin und erklären Sie ihm, warum das Quatsch ist. Ein potentiell guter Programmierer wird erfreut sein, etwas dazulernen zu können.

Real bleibt also nur ein Problem, wenn size kein Template ist oder wenn size ein Template ist und der Operator mit einer ähnlichen Technik deklariert wird, wie wir es für die dimension-Operatorüberladung getan haben. In diesem Fall müssen Sie tatsächlich abwägen, welche implizite Typumwandlung sinnvoller ist. In den meisten Fällen wird die Wahl wohl auf den Konstruktor fallen, da andernfalls keine intuitive Erstellung eines size-Objekts aus einem dimension-Objekt möglich ist. Die Umwandlung nach dimension würde dann, wie oben angedeutet, als Memberfunktion, die ein dimension-Objekt zurückgibt, explizit ausgeführt werden müssen.

Wirkliche große Vorteile würde diese Technik nur bringen, wenn dimension selbst eine Reihe von Konstruktoren besäße, welche eine implizite Umwandlung nach dimension erlauben. Dies könnte zum Beispiel in Form eines nicht-expliziten Konstruktortemplats der Fall sein. Für unsere Implementierung werden wir auf die Verwendung dieser Technik verzichten, da die oben verwendete Krücke, mit dem Castoperator in size, praktisch kaum vorkommen wird. Typischerweise wollen Sie eine implizite Umwandlung für bereits existierende Klassen und denen können Sie nicht nachträglich einen Castoperator verpassen. Dennoch ist es wichtig, dass Sie diese Technik verstanden haben, denn wir werden sie in den folgenden Kapiteln in verschiedener Weise einsetzen.

Helferlein

[Bearbeiten]

Da es oft nötig ist, die Anzahl der Elemente bezüglich einer Dimension zu ermitteln, werden wir noch eine kleine Helferfunktion bereitstellen. Diese berechnet einfach das Produkt der Anzahl von Zeilen und Spalten einer übergebenen Dimension. Das ganze sieht folgendermaßen aus:

template < typename T >
T elements(dimension< T > const& value){
    return value.rows() * value.columns();
}

Auf die gleiche einfache Weise kann sich der Nutzer selbst Komfortfunktionen für unsere Datentypen schreiben. Aufgrund der argumentabhängigen Namenssuche muss beim Aufruf kein Namensraum angegeben werden. Wenn der Nutzer in einem eigenen Namensraum eine Komfortfunktionen verfasst, dann muss er eine using-Deklaration verwenden, um den Namen verfügbar zu machen. Das werden Sie im Kapitel über die Rechenoperatoren aber noch genauer nachlesen können.