Sie sind hier: Buchauszüge Typwandlungen  
 BUCHAUSZÜGE
Vorwort
Inhaltsverzeichnis
Einführung
Typwandlungen

EINIGE HÄUFIGE TYPWANDLUNGEN
 
&&

&   Operator bool

In der Praxis kommt es vor, dass Objekte einen ungültigen Zustand haben. Beispiele sind: benötigter Speicher kann nicht allokiert werde, eine Datei kann nicht gefunden werden, etc. In einem guten Design notiert man eine solche Situation im Objekt, um beim Aufruf einer Mitgliedsfunktion entsprechend reagieren zu können[1].

Zusätzlich implementiert man eine Funktion, die den Gültigkeitszu­stand des Objekts einem Aufrufer zur Verfügung stellt. Diese Funk­tion heißt typischerweise isValid. Weiterhin implementiert man ei­nen Operator bool, der den Aufruf der isValid-Funktion verein­facht[2]:

 

class A

{

public:

  bool isValid() const;          // true wenn das Objekt gültig ist

  operator bool() const;

 

  /* ... weitere Mitglieder von A */

};

 

inline A::operator bool() const { return isValid(); }

 

Nun kann man Objekte z.B. in logischen Ausdrücken verwenden:

 

A a;

if ( a )

  cout << "a ist gültig" << endl;

 

 

Übung 24-11:

Implementieren Sie ein solches Gültigkeitskonzept für die Klassen FractInt und FixedArray.

 

&   Operator char*

Wir haben bereits öfter von der Tatsache Gebrauch gemacht, dass Zeichenketten über cout ausgegeben werden können:

 

cout << "Dies ist ein String" << endl;

 

Wenn es nun gelingt, eine implizite Typwandlung von einer Klasse wie z.B. FractInt nach char* zu definieren, können FractInt-Objekte direkt in Ausgabeanweisungen verwendet werden:

 

FractInt fr( 1, 2 );

cout << "Der Wert von fr ist " << fr << endl;

 

Dies wäre eine natürlichere Schreibweise als die bisher notwendige:

 

cout << "Der Wert von fr ist ";

fr.print();

cout  << endl;

 

Operator char* leistet im Prinzip das Gewünschte. Zur Implementie­rung benötigen wir einen Speicherbereich, in dem wir das Ergebnis bereitstellen und den wir durch operator char* an den Aufrufer lie­fern.

Folgendes Listing zeigt die Deklaration des Operators für FractInt:

 

//-- l 05

//  char* operator

 

class FractInt

{

public:

  operator char*() const;

 

  /* ... weitere Mitglieder FractInt */

};

 

Bei der Implementierung gibt es allerdings Schwierigkeiten. Wie soll der Speicherbereich allokiert werden? Es gibt prinzipiell mehrere An­sätze, die allerdings alle mangelhaft sind.

Ein trivialer Ansatz verwendet eine lokale Variable in der Operator­funktion:

 

FractInt::operator char*() const

{

  char buf[ 32 ];

  sprintf( buf, "(%d,%d)", zaehler, nenner );

  return buf;

}

 

Wir verwenden zur Wandlung der beiden Integerwerte zaehler und nenner in Strings wieder die Funktion printf, die die Werte hier an Stelle der beiden Platzhalter %d in das Ergebnis einsetzt.

Das Problem dieses Ansatzes liegt natürlich in der Rückgabe eines Zeigers auf einen lokalen Speicherbereich. Die Variable buf existiert nach Beendigung der Operatorfunktion nicht mehr, der Aufrufer hält aber weiterhin einen Zeiger darauf: undefiniertes Verhalten ist die Folge.

Als nächstes könnte man versuchen, den Speicherbereich global zu machen.  Eine statische Variable bietet sich an:

 

FractInt::operator char*() const

{

  static char buf[ 32 ];

  sprintf( buf, "(%d,%d)", zaehler, nenner );

  return buf;

}

 

Die Variable buf ist nun statisch, d.h. der ihr zugeordnete Speicher­platz bleibt während der gesamten Laufzeit des Programms allokiert – auch nach Beendigung der Operatorfunktion bleibt der zurückgege­bene Zeiger gültig.

Dies behebt zwar den Fehler mit dem Zeiger auf einen nicht mehr existierenden Speicherbereich, nun gibt es jedoch andere Probleme. Diese werden deutlich, wenn wir einen Funktionsaufruf wie in

 

FractInt fr1( 1, 2 );

FractInt fr2( 3, 4 );

void f( const char*, const char* );

 

f( fr1, fr2 );

 

näher betrachten. Hier wird zuerst Operator char* für die beiden Pa­rameter aufgerufen, dann werden beide Ergebnisse als Aktualpara­meter an f übergeben. Das Problem ist nun, dass es nur einen Spei­cherbereich buf gibt: Der zweite Aufruf der Operatorfunktion über­schreibt das Ergebnis des ersten Aufrufs, bevor dieses weiterverwen­det werden konnte. Im Endeffekt wird also f nur einen konvertierten Wert erhalten: entweder den von fr1 oder den von fr2 – aber dafür identisch in beiden Parametern.

Ob der Wert von fr1 oder der von fr2 geliefert wird, hängt von der Auswertungsreihenfolge der Parameter ab, die ja in C++ nicht vorge­schrieben ist.

Beachten Sie bitte, dass allerdings die Ausgabeanweisung

 

cout << fr1 << fr2 << endl;

 

korrekt funktioniert. Hier wird zuerst fr1 konvertiert, das Ergebnis wird an cout übergeben und dort verarbeitet. Im nächsten Schritt wird fr2 konvertiert und das Ergebnis wird analog übergeben. Die Anweisung funktioniert, weil das Ergebnis der ersten Konvertierung verwendet wird, bevor die zweite Konvertierung stattfindet.

Eine Lösung, die auch im Falle der Parameterübergabe an Funktionen funktioniert, muss also für jedes Objekt einen eigenen Speicherbe­reich bereitstellen. Eine Möglichkeit hierzu ist die Speicherung des Puffers im Objekt selber.

 

class FractInt

{

public:

  operator char*() const;

 

private:

  char buf[ 32 ];   // Puffer zur Aufnahme des Konvertierungsergebnisses

 

  /* ... weitere Mitglieder FractInt */

};

 

Der Preis dafür ist allerdings hoch: jedes Objekt ist nun auf ein Mehrfaches der eigentlich erforderlichen Größe angewachsen – jedes Objekt benötigt nun 32 Byte mehr Speicher. Darüber hinaus wird die­ser zusätzliche Speicher nur bei der Konvertierung wirklich benötigt – alles in allem eine unbefriedigende Angelegenheit.

&   Operator string

Eine Möglichkeit, den zur Konvertierung benötigten Speicherplatz erst bei der Konvertierung (d.h. im Wandlungsoperator) allokieren und darüber hinaus automatisch deallokieren zu lassen bietet die Klasse string aus der Standardbibliothek. string-Objekte können genauso wie Zeichenketten über cout ausgegeben werden, so dass Operator string eine gangbare Alternative zu Operator char* darstellt.

Die Klasse FractInt nimmt nun folgende Form an:

 

class FractInt

{

public:

  operator string() const;

 

  /* ... weitere Mitglieder FractInt */

};

 

Folgendes Listing zeigt eine Implementierung

 

FractInt::operator string() const

{

  char buf[ 32 ];

  sprintf( buf, "(%d,%d)", zaehler, nenner );

  return buf;

}

 

Die Implementierung ist identisch zur ersten Implementierung bei Operator char*: der Unterschied liegt lediglich im Rückgabetyp. Auch hier spielen temporäre Objekte wieder die zentrale Rolle: Bei der Rückgabe an den Aufrufer wird ein temporäres string-Objekt erzeugt, das mit dem Wert von buf initialisiert wird. Der Punkt ist, dass für jeden Funktionsaufruf natürlich ein eigenes temporäres Ob­jekt erzeugt wird, das außerdem so lange bestehen bleibt, wie Refe­renzen daran gebunden sind. Die Situation

 

FractInt fr1( 1, 2 );

FractInt fr2( 3, 4 );

void f( const string&, const string& );

 

f( fr1, fr2 );

 

funktioniert nun korrekt: es werden zwei (unterschiedliche) tempo­räre string-Objekte erzeugt, auf die Referenzen an f übergeben werden. Der Compiler ist dafür verantwortlich, dass die beiden tem­porären Objekte wieder abgebaut werden – und zwar nach Beendi­gung der Funktion f. Dieses Szenario zeigt also eine Situation, in der temporäre Objekte besonders nützlich sein können.

Leider funktioniert nun die Ausgabe nicht mehr. Die Anweisung

 

cout << fr1 << fr2 << endl;                    // Fehler

 

liefert einen Syntaxfehler, da in diesem Fall zwei benutzerdefinierte Wandlungen notwendig wären. Man kann allerdings

 

cout << fr1.c_str() << fr2.c_str() << endl;    // OK

 

schreiben. Hier ist nur noch die Wandlung von FractInt nach string implizit, die Funktion c_str() liefert ein const char*, das dann ausgegeben wird.

Eine Frage, die in diesem Zusammenhang oft gestellt wird, ist die Frage nach der Performanz solcher Lösungen. Schließlich ist es we­sentlich aufwändiger, ganze Objekte zu erzeugen und zu kopieren als einfache Zeiger. Darauf gibt es zwei Antworten:

qfür jedes Objekt wird ein eigener Speicherbereich benötigt, in dem das Ergebnis zur weiteren Verarbeitung abgelegt werden muss. Wenn man also eine bequeme Ausgabe wünscht, muss man den Aufwand zur Verwaltung dieses Speicherbereiches investieren. Die Klasse string erleichtert die Arbeit, in dem sie die Speicherver­waltung kapselt. Der Ressourcenverbrauch entsteht also nicht durch die Verwendung der Klasse string, sondern durch die For­derung nach Komfort.

q  string ist in der Regel so implementiert, dass Kopieroperationen des Objekts selber nur unwesentlich mehr Resourcen benötigen als die Kopie einer Referenz[3]. Die Rückgabe von string-Objek­ten aus Funktionen ist daher relativ „billig“. Hinzu kommt die Möglichkeit der return value optimization, die unnötige temporäre Objekte weitestgehend eliminieren kann.

     Der Standardfall hierzu ist die Initialisierung eines Objekts:

 

FractInt fr( 1, 2 );

string s = fr;  // OK. kein temporäres Objekt erforderlich

 

     Hier ist kein temporäres Objekt erforderlich.

Die syntaktische Erleichterung bei der Notation von Ausgabeanwei­sungen, die z.B. ein Operator string bringen kann, kann allerdings einfacher und besser auch über einen anderen Mechanismus erreicht werden. Bei der Besprechung der Streams[4] werden wir sog. Inserter vorstellen, deren Aufgabe es ist, ein Objekt in einen Stream einzufü­gen. Die Einfügeoperation selber ist natürlich wieder in einer C++-Funktion realisiert, in der der Programmierer bestimmen kann, wie die Repräsentation des Objekts aussehen soll. Zur korrekten Lösung des Problems wird also FractInt mit einem eigenen Inserter aus­gestattet.

 



[1]      Die Definition von gültigen bzw. ungültigen Zuständen bezeichnet man auch als Gültigkeitskonzept. Wir werden diese Idee später noch ausbauen.

[2]      Im angloamerikanischen Sprachgebrauch bezeichnet man eine Funktion, die dem Aufrufer eine einfachere Syntax gestattet, aber sonst keine Aktionen durchführt, auch als syntactic sugar (etwa: Zugabe zur Vereinfachung der Syn­tax). Der Operator bool wäre ein typisches Beispiel dafür.

[3]      Der Standard schreibt nicht vor, wie die Klasse string zu implementieren ist. Alle gängigen Implementierungen verwenden jedoch eine Optimierung, bei der das eigentliche string-Objekt im Wesentlichen nur einen Zeiger auf ein Implemen­tierungsobjekt definiert, das den eigentlichen String repräsentiert. Beim Kopie­ren wird nur das äußere Objekt kopiert. Diese Technik ist z.B. in [Aupperle2003]  genauer erläutert

[4]      In [Aupperle2003], als Teil der Besprechung der Standardbibliothek.




Typwandlung und Symmetrische Operatoren