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.
Zusätzlich implementiert man eine Funktion, die den Gültigkeitszustand
des Objekts einem Aufrufer zur Verfügung stellt. Diese Funktion heißt typischerweise
isValid.
Weiterhin implementiert man einen Operator bool, der den Aufruf der isValid-Funktion
vereinfacht:
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;
Implementieren Sie ein solches Gültigkeitskonzept für die Klassen FractInt und FixedArray.
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 Implementierung benötigen wir einen
Speicherbereich, in dem wir das Ergebnis bereitstellen und den wir durch
operator char*
an den Aufrufer liefern.
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 Ansätze,
die allerdings alle mangelhaft sind.
Ein trivialer Ansatz verwendet eine lokale Variable in der Operatorfunktion:
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 Speicherplatz bleibt während der gesamten
Laufzeit des Programms allokiert – auch nach Beendigung der Operatorfunktion
bleibt der zurückgegebene 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 Parameter aufgerufen, dann werden beide Ergebnisse als
Aktualparameter an f
übergeben. Das Problem ist nun, dass es nur einen Speicherbereich buf gibt: Der
zweite Aufruf der Operatorfunktion überschreibt das Ergebnis des ersten
Aufrufs, bevor dieses weiterverwendet 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 vorgeschrieben 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 Speicherbereich 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 dieser zusätzliche Speicher nur
bei der Konvertierung wirklich benötigt – alles in allem eine unbefriedigende
Angelegenheit.
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 Objekt erzeugt wird, das außerdem so lange bestehen bleibt,
wie Referenzen 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) temporäre
string-Objekte
erzeugt, auf die Referenzen an f
übergeben werden. Der Compiler ist dafür verantwortlich, dass die beiden temporären
Objekte wieder abgebaut werden – und zwar nach
Beendigung 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 wesentlich 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 Speicherverwaltung kapselt. Der Ressourcenverbrauch entsteht
also nicht durch die Verwendung der Klasse string, sondern durch die Forderung
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.
Die Rückgabe von string-Objekten
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 Ausgabeanweisungen,
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
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 ausgestattet.