C++-Programmierung/ Weitere Grundelemente
Aus Wikibooks
Zielgruppe:
Anfänger, diesmal zusammen
Lernziel:
Grundelemente in C++
Inhaltsverzeichnis |
Prozeduren und Funktionen
Unter einer Funktion (function, in anderen Programmiersprachen auch Prozedur oder Subroutine genannt) versteht man ein Unterprogramm, das eine bestimmte Aufgabe erfüllt. Funktionen sind unter anderem sinnvoll, um sich oft wiederholende Befehle zu kapseln, so dass diese nicht jedesmal neu geschrieben werden müssen. Zudem verbessert es die Übersichtlichkeit der Quellcode-Struktur erheblich, wenn der Programmtext logisch in Abschnitte unterteilt wird.
[Bearbeiten] Parameter und Rückgabewert
Die spezielle Funktion main() ist uns schon mehrfach begegnet. In C++ lassen sich Funktionen nach folgenden Kriterien unterscheiden:
- Eine Funktion kann Parameter besitzen, oder nicht.
- Eine Funktion kann einen Wert zurückgeben, oder nicht.
Dem Funktionsbegriff der Mathematik entsprechen diejenigen C++-Funktionen, die sowohl Parameter haben als auch einen Wert zurückgeben. Dieser Wert kann im Programm weiter genutzt werden, um ihn z.B. einer Variablen zuzuweisen.
Damit diese Anweisung fehlerfrei kompiliert wird, muss vorher die Funktion f() deklariert worden sein. Bei einer Funktion bedeutet Deklaration die Angabe des Funktionsprototyps. Das heißt, der Typ von Parametern und Rückgabewert muss angegeben werden. Das folgende Beispiel deklariert bspw. eine Funktion, die einen Parameter vom Typ int besitzt und einen int-Wert zurückgibt.
Soll eine Funktion keinen Wert zurückliefern, lautet der Rückgabetyp formal void.
Nach dem Compilieren ist das Linken der entstanden Objektdateien zu einem ausführbaren Programm nötig. Der Linker benötigt die Definition der aufzurufenden Funktion. Eine Funktionsdefinition umfasst auch die Implementation der Funktion, d.h. den Code, der beim Aufruf der Funktion ausgeführt werden soll. In unserem Fall wäre das:
int main(){
int a = f(3); // Funktionsaufruf
// a hat jetzt den Wert 9
}
int f(int x){ // Funktionsdefinition
return x * x;
}
Der Compiler muss die Deklaration kennen, um eventuelle Typ-Unverträglichkeiten abzufangen. Würden Sie die obige Funktion z.B. als int a = f(2.5); aufrufen, käme die Warnung, dass f() ein ganzzahliges Argument erwartet und keine Fließkommazahl. Eine Definition ist für den Compiler auch immer eine Deklaration, das heißt Sie müssen nicht explizit eine Deklaration einfügen um eine Funktion aufzurufen, die zuvor definiert wurde.
Die Trennung von Deklaration und Definition kann zu übersichtlicher Code-Strukturierung bei größeren Projekten genutzt werden. Insbesondere ist es sinnvoll, Deklarationen und Definitionen in verschiedene Dateien zu schreiben. Oft will man, wenn man fremden oder alten Code benutzt, nicht die Details der Implementierung einer Funktion sehen, sondern nur das Format der Parameter o.ä. und kann so in der Deklarationsdatei (header file, üblicherweise mit der Endung .hpp, z.T. auch .h oder .hh) nachsehen, ohne durch die Funktionsrümpfe abgelenkt zu werden. (Bei proprietärem Fremdcode bekommt man die Implementation in der Regel gar nicht zu Gesicht!)
Bei einer Deklaration ist es nicht nötig, die Parameternamen mit anzugeben, denn diese sind für den Aufruf der Funktion nicht relevant. Es ist allerdings üblich die Namen dennoch mit anzugeben um zu verdeutlichen was der Parameter darstellt. Der Compiler ignoriert die Namen in diesem Fall einfach, weshalb es auch möglich ist den Parametern in der Deklaration und der Definition unterschiedliche Namen zu geben. Allerdings wird davon aus Gründen der Übersichtlichkeit abgeraten.
Mit der Anweisung return gibt die Funktion einen Wert zurück, in unserem Beispiel x * x, wobei die Variable x als Parameter bezeichnet wird. Als Argument bezeichnet man eine Variable oder einen Wert, mit denen eine Funktion aufgerufen wird. Bei Funktionen mit dem Rückgabetyp void schreiben Sie einfach return; oder lassen die return-Anweisung ganz weg. Nach einem return wird die Funktion sofort verlassen, d.h. alle nachfolgenden Anweisungen des Funktionsrumpfs werden ignoriert.
Erwartet eine Funktion mehrere Argumente, so werden die Parameter durch Kommata getrennt. Eine mit
deklarierte Funktion könnte z.B. so aufgerufen werden:
Für eine leere Parameterliste schreiben Sie hinter dem Funktionsnamen einfach ().
[Bearbeiten] Übergabe der Argumente
C++ kennt zwei Varianten, wie einer Funktion die Argumente übergeben werden können: call-by-value und call-by-reference.
[Bearbeiten] call-by-value
Bei call-by-value (Wertübergabe) wird der Wert des Arguments in einen Speicherbereich kopiert, auf den die Funktion mittels Parametername zugreifen kann. Ein Werteparameter verhält sich wie eine lokale Variable, die „automatisch“ mit dem richtigen Wert initialisiert wird. Der Kopiervorgang kann bei Klassen (Thema eines späteren Kapitels) einen erheblichen Zeit- und Speicheraufwand bedeuten!
void f1(const int x) {
x = 3 * x; // ungültig, weil Konstanten nicht überschrieben werden dürfen
std::cout << x << std::endl;
}
void f2(int x) {
x = 3 * x;
std::cout << x << std::endl;
}
int main() {
int a = 7;
f2(a); // Ausgabe: 21
f2(5); // Ausgabe: 15
std::cout << x; // Fehler! x ist hier nicht definiert
std::cout << a; // a hat immer noch den Wert 7
}
Wird der Parameter als const deklariert, so darf ihn die Funktion nicht verändern (siehe erstes Beispiel). Im zweiten Beispiel kann die Variable x verändert werden. Die Änderungen betreffen aber nur die lokale Kopie und sind für die aufrufende Funktion nicht sichtbar.
[Bearbeiten] call-by-reference
Die Sprache C kennt nur call-by-value. Sollen die von einer Funktion vorgenommen Änderungen auch für das Hauptprogramm sichtbar sein, müssen sogenannte Zeiger verwendet werden. C++ stellt ebenfalls Zeiger zur Verfügung. C++ gibt Ihnen aber auch die Möglichkeit, diese Zeiger mittels Referenzen zu umgehen. Beide sind jedoch noch Thema eines späteren Kapitels.
Im Gegensatz zu call-by-value wird bei call-by-reference die Speicheradresse des Arguments übergeben, also der Wert nicht kopiert. Änderungen der (Referenz-)Variable betreffen zwangsläufig auch die übergebene Variable selbst und bleiben nach dem Funktionsaufruf erhalten. Um call-by-reference anzuzeigen, wird der Operator & verwendet, wie Sie gleich im Beispiel sehen werden. Wird keine Änderung des Inhalts gewünscht, sollten Sie den Referenzparameter als const deklarieren um so den Speicherbereich vor Änderungen zu schützen. Fehler, die sich aus der ungewollten Änderung des Inhaltes einer übergebenen Referenz ergeben, sind in der Regel schwer zu finden.
Die im folgenden Beispiel definierte Funktion swap() vertauscht ihre beiden Argumente. Weil diese als Referenzen übergeben werden, überträgt sich das auf die Variablen mit denen die Funktion aufrufen wurde:
void swap(int &a, int &b) {
int tmp = a; // "temporärer" Variable den Wert von Variable a zuweisen
a = b; // a mit dem Wert von Variable b überschreiben
b = tmp; // b den Wert der "temporären" Variable zuweisen (Anfangswert von Variable a)
}
int main() {
int x = 5, y = 10;
swap(x, y);
std::cout << "x=" << x << " y=" << y << std::endl;
return 0;
}
Nicht-konstante Referenzen können natürlich nur dann übergeben werden, wenn das Argument tatsächlich eine Speicheradresse hat, sprich eine Variable bezeichnet. Ein Literal z.B. 123 oder gar ein Ausdruck 1 + 2 wäre hier nicht erlaubt. Bei const-Referenzen wäre das möglich, der Compiler würde dann eine temporäre Variable anlegen.
void print(int const* array, int const arrayGroesse) {
std::cout << "Array:" << std::endl;
for (int i = 0; i < arrayGroesse; ++i) {
std::cout << array[i] << std::endl;
}
}
int main() {
int array[] = { 3, 13, 113 };
int n = sizeof(array) / sizeof(array[0]);
print(array, n);
}
3
13
113
[Bearbeiten] Default-Parameter
Default-Parameter dienen dazu, beim Aufruf einer Funktion nicht alle Parameter explizit angeben zu müssen. Die nicht angegebenen Parameter werden mit einer Voreinstellung (default) belegt. Parameter, die bei einem Aufruf einer Funktion nicht angegeben werden müssen, werden auch als „fakultative Parameter“ bezeichnet.
return a + b + c + d;
}
int main() {
int x = summe(2, 3, 4, 5); //x == 14
x = summe(2, 3, 4); //x == 9, es wird d=0 gesetzt
x = summe(2, 3); //x == 5, es wird c=0, d=0 gesetzt
}
Standardargumente werden in der Deklaration einer Funktion angegeben, da der Compiler sie beim Aufruf der Funktion kennen muss. Im obigen Beispiel wurde die Deklaration durch die Definition gemacht, daher sind die Parameter hier in der Definition angegeben. Bei einer getrennten Schreibung von Deklaration und Definition könnte das Beispiel so aussehen:
int main() {
int x = summe(2, 3, 4, 5); //x == 14
x = summe(2, 3, 4); //x == 9, es wird d=0 gesetzt
x = summe(2, 3); //x == 5, es wird c=0, d=0 gesetzt
}
int summe(int a, int b, int c, int d) { // Definition
return a + b + c + d;
}
[Bearbeiten] Funktionen überladen
Überladen (overloading) von Funktionen bedeutet, dass verschiedene Funktionen unter dem gleichen Namen angesprochen werden können. Damit der Compiler die Funktionen richtig zuordnen kann, müssen die Funktionen sich in ihrer Funktionssignatur unterscheiden. In C++ besteht die Signatur aus dem Funktionsnamen und ihren Parametern, der Typ des Rückgabewerts gehört nicht dazu. So ist es nicht zulässig, eine Funktion zu überladen, die den gleichen Namen und die gleiche Parameterliste wie eine bereits existierende Funktion besitzt und sich nur im Typ des Rückgabewerts unterscheidet. Das obige Beispiel lässt sich ohne Default-Parameter so formulieren:
return a + b + c + d;
}
int summe(int a, int b, int c) {
return a + b + c;
}
int summe(int a, int b) {
return a + b;
}
int main() {
// ...
}
[Bearbeiten] Funktionen mit beliebig vielen Argumenten
Wenn die Zahl der Argumente nicht von vornherein begrenzt ist, wird als Parameterliste die sog. Ellipse ... angegeben. Der Funktion werden die Argumente dann in Form einer Liste übergeben, auf die mit Hilfe der (in der Headerdatei cstdarg definierten) va-Makros zugegriffen werden kann.
int summe(int a, ...) {
int summe = 0;
int i = a;
va_list Parameter; // Zeiger auf Argumentliste
va_start(Parameter, a); // gehe zur ersten Position der Liste
while (i != 0){ // Schleife, solange Zahl nicht 0 (0 ist Abbruchbedingung)
summe += i; // Zahl zur Summe addieren
i = va_arg(Parameter, int); // nächstes Argument an i zuweisen, Typ int
}
va_end(Parameter); // Liste löschen
return summe;
}
int main() {
int x;
x = summe(2, 3, 4, 0); // x = 9
x = summe(2, 3, 0); // x = 5
x = summe(1,1,0,1,0); // x = 2 (da die erste 0 in der while-Schleife für Abbruch sorgt)
return x;
}
Obwohl unspezifizierte Argumente manchmal verlockend aussehen, sollten Sie sie nach Möglichkeit vermeiden. Das hat zwei Gründe:
- Die va-Befehle können nicht erkennen, wann die Argumentliste zu Ende ist. Es muss immer mit einer expliziten Abbruchbedingung gearbeitet werden. In unserem Beispiel muss als letztes Argument eine 0 stehen, ansonsten gibt es, je nach Compiler unterschiedliche, „interessante“ Ergebnisse.
- Die va-Befehle sind Makros, d.h. eine strenge Typüberprüfung findet nicht statt. Fehler werden - wenn überhaupt - erst zur Laufzeit bemerkt.
[Bearbeiten] Inline-Funktionen
Um den Aufruf einer Funktion zu beschleunigen, kann in die Funktionsdeklaration das Schlüsselwort inline eingefügt werden. Dies ist eine Empfehlung (keine Anweisung) an den Compiler, beim Aufruf dieser Funktion keine neue Schicht auf dem Stack anzulegen, sondern den Code direkt auszuführen - den Aufruf sozusagen durch den Funktionsrumpf zu ersetzen.
Da dies – wie eben schon erwähnt – nur eine Empfehlung an den Compiler ist, wird der Compiler eine Funktion nur dann tatsächlich inline einbauen, wenn es sich um eine kurze Funktion handelt. Ein typisches Beispiel:
Lebensdauer und Sichtbarkeit von Variablen
In diesem Kapitel werden Sie zuerst lernen, wie lange eine Variable im Speicher unseres Programmes existiert (Lebensdauer). Danach wird behandelt, an welchen Stellen wir auf eine bestimmte Variable zugreifen können (Sichtbarkeit).
[Bearbeiten] Lebensdauer
[Bearbeiten] Globale Variablen
Variablen, die auf der äußersten Ebene deklariert werden, nennt man global, weil an jeder Stelle des Programmes auf sie zugegriffen werden kann. Dabei darf die Variable nicht innerhalb einer Funktion ( main() eingeschlossen) oder einer Klasse deklariert werden.
Diese Variablen werden meist beim Starten initialisiert und beim Beenden wieder zerstört. Damit ist klar, dass der Speicher, den sie benötigen, für die gesamte Laufzeit des Programmes belegt ist und nicht anders verwendet werden kann.
[Bearbeiten] Lokale Variablen
Im Gegensatz zu globalen, werden lokale Variablen in einem bestimmten Anweisungsblock (z.B. Schleifen, if-Abfragen oder Funktionen) deklariert. Ihre Existenz endet wenn dieser Block wieder verlassen wird.
int lok1 = 0; // lok1 ist eine Variable im Anweisungsblock von void foo(int a)
if (a < 0) {
int lok2 = 1; // lok2 ist eine Variable im if-Block
} // hier wird lok2 aus dem Speicher gelöscht...
} // ...und hier lok1
[Bearbeiten] Statische Variablen
Statische Variablen (auch statische Klassenmember) werden wie globale zu Beginn des Programmes im Speicher angelegt und bei seinem Ende wieder daraus entfernt. Der Unterschied zu einer globalen Variable wird weiter unten auf dieser Seite im Teil über Sichtbarkeit geklärt.
[Bearbeiten] Dynamisch erzeugte Variablen
Eine Variable, die mittels dem new Operator angefordert wird, heißt dynamisch. Sie existiert so lange bis sie durch einen Aufruf von delete wieder gelöscht wird.
[Bearbeiten] Objekte und Membervariablen
Objekte werden wie normale Variablen gehandhabt, d. h. sie können global, lokal, statisch oder dynamisch erzeugt sein. Ihre Member haben die gleiche Lebensdauer wie sie selbst. Eine Ausnahme bilden statische Klassenvariablen, die von Anfang bis Ende des Programmablaufes im Speicher vorhanden sind.
[Bearbeiten] Sichtbarkeit
[Bearbeiten] Allgemein
Um Überhaupt die Chance zu haben mit einer Variablen zu arbeiten, muss diese im Quelltext bereits deklariert worden sein. Folgendes Codestück ist also falsch und würde zu einem Fehler führen.
[Bearbeiten] Gültigkeitsbereiche und deren Schachtelung
Jede Variable gehört zu einem bestimmten Gültigkeitsbereich (engl. scope). Diese legen fest wann eine Variable von uns „gesehen“ und damit benutzt werden kann. Vereinfacht gesagt bildet jedes Paar aus geschweiften Klammern ( {}) einen eigenen Definitionsbereich.Dazu gehören beispielsweise if, else, Schleifen und Funktionen. Diese unterschiedlichen Bereiche sind nun ineinander geschachtelt ähnlich wie die berühmten Matrjoschka-Puppen mit dem Unterschied, dass die Definitionsbereiche nicht „kleiner“ werden und dass es mehrere „nebeneinander“ geben kann.
int func() { // hier beginnt der Bereich der Funktion func...
return 0;
// ... und ist hier auch schon wieder beendet
}
int bar(int val) { // val gehört zum Definitionsbereich "bar"
if (val == 7) { // diese if-Anweisung hat auch ihren eigenen Gültigkeitsbereich...
int ich_gehoer_zum_if;
} //... der hier zu Ende ist
} // bar ende
[Bearbeiten] Welche Variablen sind sichtbar?
Jetzt ist es leicht zu bestimmen mit welchen Variablen wir an einer bestimmten Stelle im Programmcode arbeiten können: Es sind diejenigen, die entweder dem derzeitigen oder einem Gültigkeitsbereich auf einer höheren Ebene angehören. Wenn wir uns erneut das Beispiel der Puppen vor Augen halten, so wird klar was hiermit gemeint ist.
Beispiel:
{
int inner_1;
}
{
int inner_2;
//an dieser Stelle könnten wir sowohl auf out als auch auf inner_2 zugreifen, nicht jedoch auf inner_1;
}
Zusätzlich gilt noch, dass von zwei Variablen gleichen Namens nur auf die weiter inner liegende zugegriffen werden kann.
Schleifen mal anders – Rekursion
Jede Funktion kann sowohl andere Funktionen als auch sich selbst aufrufen. Ein solcher Selbstaufruf wird auch rekursiver Aufruf genannt. Das dahinter stehende Konzept bezeichnet man entsprechend als Rekursion.
Eine Ausnahme von dieser Regel bildet wiedereinmal die Funktion main(). Sie darf ausschließlich vom Betriebssystem aufgerufen werden, also weder von einer anderen Funktion, noch aus sich selbst heraus.
Eine rekursive Problemlösung ist etwas langsamer und speicheraufwendiger als eine iterative Variante (also mit Schleifen). Dafür ist der Code allerdings auch kompakter und ein „intelligenter“ Compiler ist meist in der Lage, eine Rekursion in eine Iteration umzuwandeln um somit die Nachteile aufzuheben. Sie sollten also keine Scheu haben ein Problem mit Rekursion zu lösen, wenn Sie so schneller ein richtiges Ergebnis erhalten als beim Schreiben einer iterativen Variante. Sollten dadurch im Laufe der Entwicklung eines Programms Geschwindigkeits- oder Speichernachteile auftreten, so können Sie die Funktion immer noch durch eine iterativ arbeitende ersetzen.
[Bearbeiten] Fakultät
Als erstes einfaches Beispiel einer rekursiven Problemlösung nehmen wir die Berechnung der Fakultät. Da die Fakultät für negative und nicht ganze Zahlen nicht definiert ist, benutzen wir als Datentyp unsigned int:
unsigned int fakultaet(unsigned int zahl) {
if (zahl <= 1) {
return 1; // Die Fakultät von 0 und 1 ist als 1 definiert.
}
return fakultaet(zahl - 1) * zahl;
}
int main() {
unsigned int zahl;
std::cout << "Bitte Zahl eingeben: ";
std::cin >> zahl; // Zahl einlesen
std::cout << "Die Fakultät von " << zahl << // Antwort ausgeben
" ist " << fakultaet(zahl) << "!" << endl;
}
Die Fakultät von 4 ist 24!
Genau wie bei einer Schleife, ist auch bei einer Rekursion eine Abbruchbedingung definiert (also erforderlich) und genau wie bei einer Schleife würde ohne Abbruchbedingung eine Endlosrekursion auftreten, analog zur Endlosschleife. So eine Endlosschleife bezeichnet man auch als infiniten Regress. Wenn der Wert der Variablen zahl kleiner, oder gleich eins ist, so wird eins zurückgegeben, andernfalls wird weiter rekursiv aufgerufen. Eine iterative Variante für das gleiche Problem könnte folgendermaßen aussehen:
unsigned int wert = 1;
for (unsigned int i = 2; i <= zahl; ++i) {
wert *= i;
}
return wert;
}
[Bearbeiten] Fibonacci-Zahlen
Als zweites Beispiel wollen wir Fibonacci-Zahlen ausrechnen.
unsigned int fibonacci(unsigned int zahl) {
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
}
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
}
// Ansonsten wird die Summe der zwei vorherigen Fibonacci-Zahlen zurückgegeben
return fibonacci(zahl - 1) + fibonacci(zahl - 2);
}
int main() {
unsigned int zahl;
std::cout << "Bitte Zahl eingeben: ";
std::cin >> zahl; // Zahl einlesen
std::cout << "Die Fibonacci-Zahl von " << zahl << // Antwort ausgeben
" ist " << fibonacci(zahl) << "!" << endl;
}
Die Fibonacci-Zahl von 12 ist 144!
Die iterative Entsprechung sieht folgendermaßen aus:
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
}
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
}
unsigned int ret;
unsigned int h1 = 0;
unsigned int h2 = 1;
for (unsigned int i = 1; i < zahl; ++i) {
ret = h1 + h2; // Ergebnis ist die Summe der zwei vorhergehenden Fibonacci-Zahlen
h1 = h2; // und den beiden Hilfsvariablen die
h2 = ret; // neuen vorhergehenden Fibonacci-Zahlen
}
return ret;
}
Bei vielen komplexen Problemen eignet sich Rekursion oft besser zur Beschreibung, als eine iterative Entsprechung. Aus diesem Grund trifft man das Konzept der Rekursion in der Programmierung recht häufig an.
Zeiger
[Bearbeiten] Grundlagen zu Zeigern
Zeiger (engl. pointer) sind Variablen, die als Wert die Speicheradresse einer anderen Variablen enthalten. Zeiger können auf eine Variable eines Typs zeigen, oder auf einen Speicherbereich ohne Typ, deklariert mit dem Schlüsselwort void.
Jede Variable wird in C++ an einer bestimmten Position im Hauptspeicher abgelegt. Diese Position nennt man Speicheradresse (memory address). C++ bietet die Möglichkeit, die Adresse jeder Variable zu ermitteln. Solange eine Variable gültig ist, bleibt sie an ein und derselben Stelle im Speicher.
Am einfachsten vergegenwärtigt man sich dieses Konzept anhand der globalen Variablen. Diese werden außerhalb aller Funktionen und Klassen deklariert und sind überall gültig. Auf sie kann man von jeder Klasse und jeder Funktion aus zugreifen. Über globale Variablen ist bereits zur Compilerzeit bekannt, wo sie sich innerhalb des Speichers befinden (also kennt das Programm ihre Adresse).
Zeiger sind nichts anderes als normale Variablen. Sie werden deklariert (und definiert), besitzen einen Gültigkeitsbereich, eine Adresse und einen Wert. Dieser Wert, der Inhalt der Zeigervariablen, ist aber nicht wie in unseren bisherigen Beispielen eine Zahl, sondern die Adresse einer anderen Variablen oder eines Speicherbereichs. Bei der Deklaration einer Zeigervariable wird der Typ der Variablen festgelegt, auf den sie verweisen soll. Dieser Typ ist fest und kann nicht verändert werden.
int main() {
int Wert; // eine int-Variable
int *pWert; // eine Zeigervariable, zeigt auf einen int
int *pZahl; // ein weiterer "Zeiger auf int"
Wert = 10; // Zuweisung eines Wertes an eine int-Variable
pWert = &Wert; // Adressoperator '&' liefert die Adresse einer Variablen
pZahl = pWert; // pZahl und pWert zeigen jetzt auf dieselbe Variable
Der Adressoperator & kann auf jede Variable angewandt werden und liefert deren Adresse, die man einer (dem Variablentyp entsprechenden) Zeigervariablen zuweisen kann. Wie im Beispiel gezeigt, können Zeiger gleichen Typs einander zugewiesen werden. Zeiger verschiedenen Typs bedürfen einer Typumwandlung. Die Zeigervariablen pWert und pZahl sind an verschiedenen Stellen im Speicher abgelegt, nur die Inhalte sind gleich.
Wollen Sie auf den Wert zugreifen, der sich hinter der im Zeiger gespeicherten Adresse verbirgt, so verwenden Sie den Dereferenzierungsoperator *.
Man nennt das den Zeiger dereferenzieren. Im Beispiel erhalten Sie die Ausgabe Wert = 23, denn pWert und pZahl verweisen ja beide auf die Variable Wert.
Um es noch einmal hervorzuheben: Zeiger auf Integer ( int) sind selbst keine Integer. Den Versuch, einer Zeigervariablen eine Zahl zuzuweisen, beantwortet der Compiler mit einer Fehlermeldung oder mindestens einer Warnung. Hier gibt es nur eine Ausnahme: die Zahl 0 darf jedem beliebigen Zeiger zugewiesen werden. Ein solcher Nullzeiger zeigt nirgendwohin. Der Versuch, ihn zu dereferenzieren, führt zu einem Laufzeitfehler.
[Bearbeiten] Zeiger und const
Das Schlüsselwort const kann auf zweierlei Arten in Verbindung mit Zeigern genutzt werden:
- Um den Wert, auf den der Zeiger zeigt, konstant zu machen
- Um den Zeiger selbst konstant zu machen
Im ersteren Fall kann der Zeiger im Laufe seines Lebens auf verschiedene Objekte zeigen, diese Werte können dann allerdings (über diesen Zeiger) nicht geändert werden. Im zweiten Fall kann der Zeiger nicht auf eine andere Adresse "umgebogen" werden. Der Wert an dieser Stelle kann allerdings verändert werden. Natürlich sind auch beide Varianten in Kombination möglich.
int Wert2; // noch eine int-Variable
int const * p1Wert = &Wert1; // Zeiger auf konstanten int
int * const p2Wert = &Wert1; // konstanter Zeiger auf int
int const * const p3Wert = &Wert1; // konstanter Zeiger auf konstanten int
p1Wert = &Wert2; // geht
*p1Wert = Wert2; // geht nicht, int konstant
p2Wert = &Wert2; // geht nicht, Zeiger konstant
*p2Wert = Wert2; // geht
p3Wert = &Wert2; // geht nicht, int konstant
*p3Wert = Wert2; // geht nicht, Zeiger konstant
Wie Sie sich sicher noch erinnern, gehört const immer zu dem was links von ihm steht. Es sei denn links steht nichts mehr, dann gehört es zu dem was rechts davon steht.
[Bearbeiten] Zeigerarithmetik
Zeiger sind keine Zahlen. Deshalb sind einige arithmetischen Operationen auf Zeiger nicht anwendbar und für die übrigen gelten andere Rechenregeln als in der Zahlenarithmetik. C++ kennt die Größe des Speicherbereichs, auf den ein Zeiger verweist. Inkrementieren (oder Dekrementieren) verändert die referenzierte Adresse unter Berücksichtigung dieser Speichergröße. Das folgende Beispiel soll den Unterschied zwischen Zahlen und Zeigerarithmetik verdeutlichen:
int main() {
std::cout << "Zahlenarithmetik" << std::endl;
int a = 1; // a wird 1
std::cout << "a: " << a << std::endl;
a++; // a wird 2
std::cout << "a: " << a << std::endl;
std::cout << "Zeigerarithmetik" << std::endl;
int *p = &a; // Adresse von a an p zuweisen
std::cout << "p verweist auf: " << p << std::endl;
std::cout << " Größe von int: " << sizeof(int) << std::endl;
p++;
std::cout << "p verweist auf: " << p << std::endl;
}
a: 1
a: 2
Zeigerarithmetik
p verweist auf: 0x7fff3aa60090
Größe von int: 4
p verweist auf: 0x7fff3aa60094
Wie Sie sehen, erhöht sich der Wert des Zeigers nicht um eins, sondern um vier, was genau der Größe des Typs entspricht auf den er zeigt: int. Auf einer Platform auf der int eine andere Größe hat würde natürlich entsprechend dieser Größe gezählt werden. Im nächsten Beispiel sehen Sie, wie ein Zeiger auf eine weitere Zeigervariable verweist, welche ihrerseits auf einen int-Wert zeigt.
int main() {
int a = 1;
int *p = &a; // Adresse von a an p zuweisen
int **pp = &p; // Adresse von p an pp zuweisen
std::cout << "pp verweist auf: " << pp << std::endl;
std::cout << " Größe von int*: " << sizeof(int*) << std::endl;
++pp;
std::cout << "pp verweist auf: " << pp << std::endl;
}
Größe von int*: 8
pp verweist auf: 0x7fff940cb6f8
Wie Sie sehen hat ein Zeiger auf int im Beispiel eine Größe von 8 Byte. Die Größe von Datentypen ist allerdings architektur-, compiler- und systembedingt. pp ist vom Typ „Zeiger auf Zeiger auf int“, was sich dann in C++ als int** schreibt. Um also auf die Variable "hinter" diesen beiden Zeigern zuzugreifen, muss man **pp schreiben.
Es spielt keine Rolle, ob man in Deklarationen int* p; oder int *p; schreibt. Einige Programmierer schreiben den Stern direkt hinter den Datentyp ( int* p), andere schreiben ihn direkt vor Variablennamen ( int *p) und wieder andere lassen zu beidem ein Leerzeichen ( int * p). In diesem Buch wird die Konvention verfolgt, den Stern direkt vor Variablennamen zu schreiben wenn einer vorhanden ist ( int *p), andernfalls wird er direkt nach dem Datentyp geschrieben ( int*).
[Bearbeiten] Negativbeispiele
Zur Verdeutlichung zwei Beispiele, die nicht funktionieren, weil sie vom Compiler nicht akzeptiert werden:
int Wert;
pWert = Wert; // dem Zeiger kann kein int zugewiesen werden
Wert = pWert; // umgekehrt natürlich auch nicht
Zeigervariablen erlauben als Wert nur Adressen auf Variablen. Daher kann einer Zeigervariable wie in diesem Beispiel kein Integer-Wert zugewiesen werden.
Im Folgenden wollen wir den Wert, auf den pWert zeigt, inkrementieren. Einige der Beispiele bearbeiten die Adresse, die pWert enthält. Erst das letzte Beispiel verändert tatsächlich den Wert, auf den pWert zeigt. Beachten Sie, dass jede Codezeile ein Einzelbeispiel ist.
int *pWert = &Wert; // pWert zeigt auf Wert
pWert += 5; // ohne Dereferenzierung (*pWert) verändert man die Adresse, auf die
// der Zeiger verweist, und nicht deren Inhalt
std::cout << pWert; // es wird nicht die Zahl ausgegeben, auf die pWert
// zeigt, sondern deren (veränderte) Adresse
printf("Wert enthält: %d", pWert); // gleiche Ausgabe
pWert++; // Diese Operation verändert wiederum die Adresse, da nicht dereferenziert wird.
*pWert++; // Auf diese Idee kommt man als nächstes. Doch auch das hat nicht den
// gewünschten Effekt. Da der (Post-)Inkrement-Operator vor dem Dereferenzierungs-
// operator ausgewertet wird, verändert sich wieder die Adresse.
(*pWert)++; // Da der Ausdruck in der Klammer zuerst ausgewertet wird, erreichen wir
// diesmal den gewünschten Effekt: Eine Änderung des Wertes.
[Bearbeiten] void-Zeiger (anonyme Zeiger)
Eine besondere Rolle spielen die „Zeiger auf void“, die so genannten generischen Zeiger. Einem Zeiger vom Typ void* kann jeder beliebige Zeiger zugewiesen werden. void-Zeiger werden in der Sprache C z.B. bei der dynamischen Speicherverwaltung verwendet. In C++ kommt man weitgehend ohne sie aus. Vermeiden Sie Zeiger auf void wenn Sie eine andere Möglichkeit haben.
Eine Variable kann nicht vom Typ void sein. Daher würde folgende Zeile zu einem Fehler führen:
Sie können einer Variable, die auf void zeigt, einen beliebigen Zeiger zuweisen. Deshalb werden solche Variablen meist für Zeiger verwendet, dessen Typ noch nicht feststeht und sich erst im Laufe des Programmes ergibt oder aber als temporärer Speicher mit wechselnden Zeigertypen.
int main() {
int intValue = 1;
int *intPointer = &intValue; // zeigt auf intValue
void *voidPointer;
voidPointer = intPointer; // voidPointer zeigt auf intPointer
Sie können jetzt nicht ohne Weiteres auf *voidPointer zugreifen, um an die Adresse von intValue zu bekommen. Da es sich um einen Zeiger, vom Typ void handelt, muss man diesen erst casten. In diesem Fall nach int*.
Ablauf im Detail:
- Zeiger *voidPointer ist vom Typ void und zeigt auf intPointer
- reinterpret_cast<int*>, Zeiger ist vom Typ int*
- Zeiger dereferenzieren ( *), um Wert zu erhalten
[Bearbeiten] Zeiger und Funktionen
Wenn Sie einen Zeiger als Parameter an eine Funktion übergeben, können Sie den Wert an der übergebenen Adresse ändern. Eine Funktion, welche die Werte zweier Variablen vertauscht, könnte folgendermaßen implementiert werden:
Diese Funktion hat natürlich einige Schwachstellen. Beispielsweise stürtzt sie ab, wenn ihr ein Nullzeiger übergeben wird. Aber sie zeigt, dass es mit Zeigern möglich ist, den Wert einer Variable außerhalb der Funktion zu verändern. In Kürze werden Sie sehen, dass sich dieses Beispiel besser mit Referenzen lösen lässt.
[Bearbeiten] Funktionszeiger
Zeiger können nicht nur auf Variablen, sondern auch auf Funktionen verweisen.
int (Klasse::*pFunc)(int); // Anlegen eines Funktionszeigers auf eine Memberfunktion
Um einen Zeiger auf eine Funktion zu deklarieren, benutzen Sie den Prototyp der Funktion und ersetzen dort den Funktionsnamen durch (*Variablenname). Das ganze wird gelesen als: pFunc ist ein Zeiger auf eine Funktion, die einen int übernimmt und einen int zurückgibt. Ohne die Klammern um den Variablennamen müsste man lesen: pFunc ist eine Funktion, die einen int übernimmt und einen Zeiger auf int zurückgibt.
Dem Zeiger kann die Adresse einer typentsprechenden Funktion zugewiesen werden. Typentsprechend heißt so viel wie: Übernimmt einen int-Wert als Argument und gibt einen int-Wert zurück. Die Funktion muss also einen ihrer Zeigervariablen entsprechenden Prototyp besitzen.
return x * x;
}
pFunc = & sqr; // Adresse der Funktion sqr() der Variable pFunc zuweisen
pFunc = & Klasse::sqr; // bei Verwendung für Memberfunktionen muss hier die Klasse angegeben werden
Um die Adresse der Funktion zu erhalten, müssen Sie den Adressoperator auf den Funktionsnamen anwenden. Beachten Sie, dass die Klammern, die Sie zum Aufruf einer Funktion immer setzen müssen, hier keinesfalls gesetzt werden dürfen. &sqr() würde Ihnen die Adresse, des von sqr() zurückgelieferten Objekts beschaffen. Es sei auch darauf hingewiesen, dass der Adressoperator nicht zwingend zum Ermitteln der Funktionsadresse notwendig ist. Sie sollten Ihn aus Gründen der Übersicht allerdings immer mitschreiben.
x = (*pFunc)(2); // ruft sqr() auf und weist x den Rückgabewert zu
x = pFunc(2); // alternative (nicht empfohlene) Syntax
x = (*this.*pFunc)(2) // auch hier die Variante für eine Memberfunktion
Auch für das Aufrufen einer Funktion über einen Funktionszeiger gibt es zwei Möglichkeiten. Sie können den Zeiger erst dereferenzieren, dann benötigen Sie Klammern um die Dereferenzierung, damit nicht das zurückgegebene Objekt dereferenziert wird, oder Sie rufen die Funktion mit der gleichen Syntax auf, über die Sie dies bei einem direkten Aufruf der Funktion tun würden.
Die Syntax der zweiten Variante ist einfacher, allerdings wird dabei nicht deutlich, dass eine Funktion über einen Zeiger aufgerufen wird. Hier gehen die Meinungen darüber, welche Variante besser ist, auseinander. Fakt ist jedoch, dass die erste Variante eindeutiger das Geschehen dokumentiert.
Typisches Beispiel für den Einsatz von Funktionszeigern stellt eine Sortierroutine dar, der man die Vergleichsfunktion als Argument übergibt. Ein ausführliches (englischsprachiges) Tutorial über Funktionszeiger finden Sie unter http://www.newty.de/fpt/index.html.
[Bearbeiten] Zeiger und Referenzen und Klassen
- Zeiger auf statische Datenelemente
- Zeiger auf Elementfunktionen.
[Bearbeiten] Löschen von Zeigern
Zeiger müssen prinzipiell nicht gelöscht werden, weil diese nur Verweise auf Adressbereiche sind. Es ist dennoch möglich, mit delete einen Zeiger zu löschen. Dabei sollte man beachten, dass der Zeiger seinen Gültigkeitsbereich verliert. Man gibt nur den Speicher frei, auf den der Zeiger verweist. Man kann dem Zeiger aber trotzdem noch Werte zuweisen. delete sollte nicht aufgerufen werden, wenn ein Zeiger auf einen nicht-vorhandenen Bereich zeigt, da dies das Programm zum Absturz bringen kann. Daher sollte nach dem Aufruf von delete, der Wert des Zeigers auf 0 gesetzt werden, weil delete bei Null-Zeigern keinen Fehler erzeugt.
Referenzen
[Bearbeiten] Grundlagen zu Referenzen
Referenzen sind interne Zeiger auf Variablen. Sie werden also genau so verwendet wie gewöhnliche Variablen, verweisen jedoch auf das Objekt, mit dem sie initialisiert wurden. Die Zeigerverwendung wird vor dem Programmierer verborgen.
Wie Sie im Beispiel sehen, sind a und r identisch. Gleiches können Sie natürlich auch mit einem Zeiger erreichen, auch wenn bei einem Zeiger die Syntax etwas anders ist als bei einer Referenz.
Im Beispiel wurde die Referenz auf int r, mit dem int a initialisiert. Beachten Sie, dass die Initialisierung einer Referenzvariablen nur beim Anlegen erfolgen kann, danach kann ihr Wert nur noch durch eine Zuweisung geändert werden. Daraus folgt, dass eine Referenz immer initialisiert werden muss und es nicht möglich ist, eine Referenzvariable auf ein neues Objekt verweisen zu lassen:
int b = 20; // noch eine Variable
int &r = a; // Referenz auf die Variable a
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
++a;
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
r = b; // r zeigt weiterhin auf a, r (und somit a) wird 20 zugewiesen
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
a: 11 b: 20 r: 11
a: 20 b: 20 r: 20
Wie Sie sehen, ist es nicht möglich, r als Alias für b zu definieren, nachdem es einmal mit a initialisiert wurde. Die Zuweisung bewirkt genau das, was auch eine Zuweisung von b an a bewirkt hätte. Dass eine Referenz wirklich nichts weiter ist als ein Aliasname wird umso deutlicher, wenn man sich die Adressen der Variablen aus dem ersten Beispiel ansieht:
Wie Sie sehen, sind die Adressen identisch.
[Bearbeiten] Anwendung von Referenzen
Vielleicht haben Sie sich bereits gefragt, wofür Referenzen nun eigentlich gut sind, schließlich könnte man ja auch einfach die Originalvariable benutzen.
[Bearbeiten] Selbstdefinierte Referenzen
Referenzen bieten in einigen Anwendungsfällen eine Beschleunigung und bessere Lesbarkeit der Quelltexte. Sie müssen sofort initialisiert werden.
{
const size_t x = 9, y = 15, z = 45;
unsigned int ar_Zahlen[x][y][z];
for (size_t a=0;a<x;++a)
{
for (size_t b=0;b<y;++b)
{
for (size_t c=1;c<z;++c)
{
unsigned int & s = ar_Zahlen[a][b][c];
// Mehrfache Verwendung der Referenz
}
}
}
return 0;
};
Bei umfangreicher, mehrfacher Verwendung, kann die Referenz viel Tipparbeit ersparen.
Außerdem erhöht diese Vorgehensweise die Performanz, da der Zugriff auf Daten in verschachtelten Klassen, großen Feldern, durch eine Referenzdefinition vereinfacht wird. Bei obigem Beispiel wird zur Laufzeit, im Arbeitsspeicher, bei jeder Verwendung von ar_Zahlen[a][b][c] zuerst der Speicherort der einzelnen Zahl berechnet, dabei müssen die Inhalts-,Feldgrößen und Offsets innerhalb des Felds berücksichtigt werden. An anderen Stellen mag dies alles mit STL-Kontainerklassen und verschachtelten Methoden- und Operatoraufrufen erfolgen. Dies alles können Sie dem Prozessor nicht ersparen. Sie können aber dafür sorgen, dass es bei obiger Iteration, pro Schritt, nur einmal vorkommt. Die Verwendung einer Referenz macht daher Sinn, sobald Sie ar_Zahlen[a][b][c] mehr als einmal verwenden. Eine Referenz ist intern mit einem Zeiger implementiert.
[Bearbeiten] Call-By-Reference
Möglicherweise erinnern Sie sich aber auch noch, dass im Kapitel „Prozeduren und Funktionen“ die Wertübergabe als Referenz (call-by-reference) vorgestellt wurde. Darauf wird nun genauer eingegangen.
Referenzen bieten genau wie Zeiger die Möglichkeit, den Wert einer Variable außerhalb der Funktion zu ändern. Im Folgenden sehen Sie die oben vorgestellte Funktion swap() mit Referenzen:
Diese Funktion bietet gegenüber der Zeigervariante einige Vorteile. Die Syntax ist einfacher und es ist nicht möglich, so etwas wie einen Nullzeiger zu übergeben. Um diese Funktion zum Absturz zu bewegen, ist schon einige Mühe nötig.
[Bearbeiten] const-Referenzen
Referenzen auf konstante Variablen spielen in C++ eine besondere Rolle. Eine Funktion die eine Variable übernimmt, kann genauso gut auch eine Referenzen auf eine konstante Variablen übernehmen. Folgendes Beispiel soll dies demonstrieren:
Die beiden Ausgabefunktionen sind an sich identisch, lediglich die Art der Parameterübergabe unterscheidet sich. ausgabe1() übernimmt einen int, ausgabe2() eine Referenz auf einen konstanten int. Beide Funktionen lassen sich auch vollkommen identisch aufrufen. Würde ausgabe2() eine Referenz auf einen nicht-konstanten int übernehmen, wäre ein Aufruf mit einer Konstanten, wie dem int-Literal 5 nicht möglich.
In Verbindung mit Klassenobjekten ist die Übergabe als Referenz auf ein konstantes Objekt sehr viel schneller, dazu erfahren Sie aber zu gegebener Zeit mehr. Für die Ihnen bereits bekannten Basisdatentypen ist tatsächlich die Übergabe als Wert effizienter.
[Bearbeiten] Referenzen als Rückgabetyp
Referenzen haben als Rückgabewert die gleichen Vorteile wie bei der Wertübergabe. Allerdings sind Sie in diesem Zusammenhang wesentlich gefährlicher. Es kann schnell passieren, dass Sie versehentlich eine Referenz auf eine lokale Variable zurückgeben. Diese Variable ist außerhalb der Funktion allerdings nicht mehr gültig, daher ist das Resultat, wenn Sie außerhalb der Funktion darauf zugreifen, undefiniert. Aus diesem Grund sollten Sie Referenzen als Rückgabewert nur verwenden wenn Sie wirklich wissen, was Sie tun.
// gibt die Referenz des Parameters x zurück
int &zahl(int &x) {
return x;
}
int main() {
int y = 3;
zahl(y) = 5;
std::cout << y; // Ausgabe: 5
return y;
}
Felder
In C++ lassen sich mehrere Variablen desselben Typs zu einem Array (im Deutschen bisweilen auch Feld oder Vektor genannt) zusammenfassen. Auf die Elemente des Arrays wird über einen Index zugegriffen. Bei der Definition sind der Typ der Elemente und die Größe des Arrays anzugeben. Folgende Möglichkeiten stehen zum Anlegen eines Array zur Verfügung:
int feld[] = {1,2,3,4,5,6,7,8,9,10}; // Mit Initialisierung (automatisch 10 Elemente)
int feld[10] = {1,2,3,4,5,6,7,8,9,10}; // 10 Elemente, mit Initialisierung
Soll das Array initialisiert werden, verwenden Sie eine Aufzählung in geschweiften Klammern, wobei der Compiler die Größe des Arrays selbst ermitteln kann. Es wird empfohlen, diese automatische Größenerkennung nicht zu nutzen. Wenn die Größenangabe explizit gemacht wurde, gibt der Compiler einen Fehler aus, falls die Anzahl der Intitiallisierungselemente nicht mit der Größenangabe übereinstimmt.
Wie bereits erwähnt, kann auf die einzelnen Elemente eines Arrays mit dem Indexoperator [] zugegriffen werden. Beim Zugriff auf Arrayelemente beginnt die Zählung bei 0. Das heißt, ein Array mit 10 Elementen enthält die Elemente 0 bis 9. Ein Arrayindex ist immer ganzzahlig.
Mit Arrayelementen können alle Operationen wie gewohnt ausgeführt werden.
feld[3] = 2;
feld[2] = feld[3] - 5 * feld[7 + feld[3]];
if(feld[0] < 1)
++feld[9];
for(int n = 0; n < 10; ++n)
std::cout << feld[n] << std::endl;
[Bearbeiten] Zeiger und Arrays
Der Name eines Arrays wird vom Compiler (ähnlich wie bei Funktionen) als Adresse des Arrays interpretiert. In Folge dessen haben Sie neben der Möglichkeit über den Indexoperator auch die Möglichkeit, mittels Zeigerarithmetik auf die einzelnen Arrayelemente zuzugreifen.
Im Normalfall werden Sie mit der ersten Syntax arbeiten, es ist jedoch nützlich zu wissen, wie Sie ein Array mittels Zeigern manipulieren können. Es ist übrigens nicht möglich, die Adresse von feld zu ändern. feld verhält sich also in vielerlei Hinsicht wie ein konstanter Zeiger auf das erste Element des Arrays. Einen deutlichen Unterschied werden Sie allerdings bemerken, wenn Sie den Operator sizeof() auf die Arrayvariable anwenden. Als Rückgabe bekommen Sie die Größe des gesamten Arrays. Teilen Sie diesen Wert durch die Größe eines beliebigen Arrayelements, erhalten Sie die Anzahl der Elemente im Array.
[Bearbeiten] Mehrere Dimensionen
Auch mehrdimensionale Arrays sind möglich. Hierfür werden einfach Größenangaben für die benötigte Anzahl von Dimensionen gemacht. Das folgende Beispiel legt ein zweidimensionales Array der Größe 4 × 8 an, das 32 Elemente enthält. Theoretisch ist die Anzahl der Dimensionen unbegrenzt.
int feld[4][8] = { // Mit Initialisierung
{ 1, 2, 3, 4, 5, 6, 7, 8},
{ 9, 10, 11, 12, 13, 14, 15, 16},
{ 17, 18, 19, 20, 21, 22, 23, 24},
{ 25, 26, 27, 28, 29, 30, 31, 32}
};
Wie Sie sehen, können auch mehrdimensionalen Arrays initialisiert werden. Die äußeren geschweiften Klammern beschreiben die erste Dimension mit 4 Elementen. Die inneren geschweiften Klammern beschreiben dementsprechend die zweite Dimension mit 8 Elementen. Beachten Sie, dass die inneren geschweiften Klammern lediglich der Übersicht dienen, sie sind nicht zwingend erforderlich. Dementsprechend ist es für mehrdimensionale Arrays immer nötig, die Größe aller Dimensionen anzugeben.
Genaugenommen wird eigentlich ein Array von Arrays erzeugt. In unserem Beispiel ist feld ein Array mit 4 Elementen vom Typ „Array mit 8 Elementen vom Typ int“. Dementsprechend sieht auch der Aufruf mittels Zeigerarithmetik auf einzelne Elemente aus.
Es sind ebenso viele Dereferenzierungen wie Dimensionen nötig. Um sich vor Augen zu führen, wie die Zeigerarithmetik für diese mehrdimensionalen Arrays funktioniert, ist es nützlich, sich einfach die Adressen bei Berechnungen anzusehen:
int main(){
int feld[4][8];
std::cout << "Größen\n";
std::cout << "int[4][8]: " << sizeof(feld) << "\n";
std::cout << " int[8]: " << sizeof(*feld) << "\n";
std::cout << " int: " << sizeof(**feld) << "\n";
std::cout << "Adressen\n";
std::cout << " feld: " << feld << "\n";
std::cout << " feld + 1: " << feld + 1 << "\n";
std::cout << "(*feld) + 1: " << (*feld) + 1 << "\n";
}
int[4][8]: 128
int[8]: 32
int: 4
Adressen
feld: 0x7fff2be5d400
feld + 1: 0x7fff2be5d420
(*feld) + 1: 0x7fff2be5d404
Wie Sie sehen, erhöht feld + 1 die Adresse um den Wert 32 (Hexadezimal 20), was sizeof(int[8]) entspricht. Also der Größe aller verbleibenden Dimensionen. Die erste Dereferenzierung liefert wiederum eine Adresse zurück. Wird diese um 1 erhöht, so steigt der Wert lediglich um 4 ( sizeof(int)).
[Bearbeiten] Arrays und Funktionen
Arrays und Funktionen arbeiten in C++ nicht besonders gut zusammen. Sie können keine Arrays als Parameter übergeben und auch keine zurückgeben lassen. Da ein Array allerdings eine Adresse hat (und der Arrayname diese zurückliefert), kann man einfach einen Zeiger übergeben. C++ bietet (ob nun zum besseren oder schlechteren) eine alternative Syntax für Zeiger bei Funktionsparametern an.
void funktion(int parameter[]);
void funktion(int parameter[5]);
void funktion(int parameter[76]);
Jeder dieser Prototypen ist gleichwertig. Die Größenangaben beim dritten und vierten den Beispiel werden vom Compiler ignoriert. Innerhalb der Funktion können Sie wie gewohnt mit dem Indexoperator auf die Elemente zugreifen. Beachten Sie, dass Sie die Arraygröße innerhalb der Funktion nicht mit sizeof(arrayname) feststellen können. Bei diesem Versuch würden Sie stattdessen die Größe eines Zeigers auf ein Array-Element erhalten.
Aufgrund dieses Verhaltens könnte man die Schreibweise des ersten Prototypen auswählen. Andere Programmierer argumentieren, dass bei der zweiten Schreibweise deutlich wird, dass der Parameter ein Array repräsentiert. Eine Größenangabe bei Arrayparametern ist manchmal anzutreffen, wenn die Funktion nur Arrays dieser Größe bearbeiten kann. Um es noch einmal zu betonen: Diese Größenangaben sind nur ein Hinweis für den Programmierer; Der Compiler wird ohne Fehler und Warnung Ihren Array mit allen Elementen übernehmen, auch wenn die beim Funktionsparameter angegebene Größe nicht mit Ihrem Array übereinstimmt.
Bei mehrdimensionalen Arrays sehen die Regeln ein wenig anders aus, da diese Arrays vom Typ Array sind. Wie Sie wissen ist es zulässig, Zeiger als Parameter zu übergeben. Entsprechend ist es natürlich auch möglich, einen Zeiger auf einen Array zulässig. Die folgenden Prototypen zeigen, wie die Syntax bei mehrdimensionalen Arrays aussieht.
void funktion(int parameter[][8]);
void funktion(int parameter[4][8]);
Alle diese Prototypen haben einen Parameter vom Typ „Zeiger auf Array mit acht Elementen vom Typ int“. Ab der zweiten Dimension geben Sie also tatsächlich Arrays an, somit müssen Sie natürlich auch die Anzahl der Elemente zwingend angeben. Daher können Sie sizeof() in der Funktion verwenden, um die Größe zu ermitteln. Dies ist allerdings nicht notwendig, da Sie bereits im Vorfeld wissen, wie groß der Array ist und vom welchem Typ er ist. Die Größe berechnet sich wie folgt: sizeof(Typ) * Anzahl der Elemente. In unserem Beispiel entspricht dies 4 * 8 = 32. Auch ein zweidimensionales Array können Sie innerhalb der Funktion mit dem normalen Indexoperator zugreifen.
Beachten Sie, dass beim ersten Prototypen die Klammern zwingend notwendig sind, andernfalls hätten die eckigen Klammern auf der rechten Seite des Parameternamen Vorrang. Somit würde der Compiler dies wie oben gezeigt als einen Zeiger behandeln, natürlich unabhängig von der Anzahl der angegebenen Elemente. Ohne diese Klammern würden Sie also einen Zeiger auf einen Zeiger auf int deklarieren.
[Bearbeiten] Lesen komplexer Datentypen
Sie kennen nun Zeiger, Referenzen und Arrays, sowie natürlich die grundlegenden Datentypen. Es kann Ihnen passieren, dass Sie auf Datentypen treffen, die all das in Kombination nutzen. Im Folgenden werden Sie lernen, solche komplexen Datentypen zu lesen und zu verstehen, wie man Sie schreibt.
- Es wird ausgehend vom Namen gelesen.
- Steht etwas rechts vom Namen, wird es ausgewertet.
- Steht rechts nichts mehr, wird der Teil auf der linken Seite ausgewertet.
- Mit Klammern kann die Reihenfolge geändert werden.
Die folgenden Beispiele werden zeigen, dass diese Regeln immer gelten:
int *j; // j ist ein Zeiger auf int
int k[6]; // k ist ein Array von sechs Elementen des Typs int
int *l[6]; // l ist ein Array von sechs Elementen des Typs Zeiger auf int
int (*m)[6]; // m ist ein Zeiger auf ein Array von sechs Elementen des Typs int
int *(*&n)[6]; // n ist eine Referenz auf einen Zeiger auf ein Array von
// sechs Elementen des Typs Zeiger auf int
int *(*o[6])[5]; // o ist ein Array von sechs Elementen des Typs Zeiger auf ein
// Array von fünf Elementen des Typs Zeiger auf int
int **(*p[6])[5]; // p ist Array von sechs Elementen des Typs Zeiger auf ein Array
// von fünf Elementen des Typs Zeiger auf Zeiger auf int
Nehmen Sie sich die Zeit, die Beispiele nachzuvollziehen. Wenn Sie keine Probleme damit haben, sehen Sie sich das nächste sehr komplexe Beispiel an. Es soll die allgemeine Gültigkeit dieser Regel noch einmal demonstrieren:
pFunc ist ein Array mit fünf Elementen, das Zeiger auf Zeiger auf Funktionen enthält, die einen Zeiger auf int und eine Referenz auf double übernehmen und einen Zeiger auf Arrays mit sechs Elementen vom Typ Zeiger auf Funktionen, ohne Parameter, mit einem int als Rückgabewert zurückgeben. Einem solchen Monstrum werden Sie beim Programmieren wahrscheinlich selten bis nie begegnen, aber falls doch, können Sie es mit den obengenannten Regeln ganz einfach entschlüsseln. Wenn Sie nicht in der Lage sind, dem Beispiel noch zu folgen, brauchen Sie sich keine Gedanken zu machen: Nur wenige Menschen sind in der Lage, sich ein solches Konstrukt überhaupt noch vorzustellen. Wenn Sie es nachvollziehen können, kommen Sie sehr wahrscheinlich mit jeder Datentypdeklaration klar.
Zeichenketten
[Bearbeiten] Einleitung
In C++ gibt es keinen eingebauten Datentyp für Zeichenketten, lediglich einen für einzelne Zeichen. Da es in C noch keine Klassen gab, bediente man sich dort der einfachsten Möglichkeit, aus Zeichen Zeichenketten zu bilden: Man legte einfach einen Array von Zeichen an. C++ bietet eine komfortablere Lösung an: Die C++-Standardbibliothekenthält eine Klasse namens string. Um diese Klasse nutzen zu können, müssen Sie die gleichnamige Headerdatei string einbinden.
Wir werden uns in diesem Kapitel mit der C++-Klasse string auseinandersetzen. Am Ende des Kapitels beleuchten wir den Umgang mit C-Strings (also char-Arrays) etwas genauer. Natürlich liegt auch string, wie alle Teile der Standardbibliothek, im Namensraum std.
[Bearbeiten] Wie entsteht ein string-Objekt?
Zunächst sind einige Worte zur Notation von Zeichenketten in doppelten Anführungszeichen nötig. Wie Ihnen bereits bekannt ist, werden einzelne Zeichen in einfachen Anführungszeichen geschrieben. Dieser Zeichenliteral ist dann vom Typ char. Die doppelten Anführungszeichen erzeugen hingegen eine Instanz eines char-Arrays. "Hallo Welt!" ist zum Beispiel vom Typ char[12].
Es handelt sich also um eine Kurzschreibweise, zum Erstellen von char-Arrays, damit Sie nicht {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'} schreiben müssen, um eine einfache Zeichenkette zu erstellen. Was ist das '\0' und warum ist das Array 12 chars lang ist, obwohl es nur 11 Zeichen enthält? Wie bereits erwähnt, ist ein C-String ein Array von Zeichen. Da ein solcher C-String natürlich im Programmablauf Zeichenketten unterschiedlicher Längen enthalten konnte, beendete man die Zeichenkette durch ein Endzeichen: '\0' (Zahlenwert 0). Somit musste ein Array von Zeichen in C immer ein Zeichen länger sein, als die längste Zeichenkette, die im Programmverlauf darin gespeichert wurde.
Diese Kurzschreibweise kann aber noch mehr, als man auf den ersten Blick vermuten würde. Die eben genannte lange Notation zur Initialisierung eines Arrays funktioniert im Quelltext nur, wenn der Compiler auch weiß, von welchem Datentyp die Elemente des Arrays sein sollen. Da Zeichenliterale jedoch implizit in größere integrale Typen umgewandelt werden können, kann er den Datentyp nicht vom Typ der Elemente, die für die Initialisierung genutzt wurden ableiten:
int main() {
// char-Array mit 12 Elementen
char a[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
// int-Array mit 12 Elementen
int b[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
// char-Array mit 12 Elementen
std::string z = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
}
Bei der Notation mit geschweiften Klammern ist dagegen immer bekannt, dass es sich um ein char-Array handelt. Entsprechend ist die Initialisierung eines int-Arrays damit nicht möglich. Folgendes dagegen schon:
int main() {
// char-Array mit 12 Elementen
char a[] = "Hallo Welt!";
// char-Array mit 12 Elementen
std::string z = "Hallo Welt!";
}
Bei der Erzeugung eines string-Objekts wird eine Funktion aufgerufen, die sich Konstruktor nennt. Was genau ein Konstruktor ist, erfahren Sie im Kapitel über Klassen. In unserem Fall wird also der Konstruktor für das string-Objekt z aufgerufen. Als Parameter erhält er das char-Array "Hallo Welt!". Wie Ihnen bereits bekannt ist, können an Funktionen keine Arrays übergeben werden. Stattdessen wird natürlich ein Zeiger vom Arrayelementtyp (also char) übergeben. Dabei geht aber die Information verloren, wie viele Elemente dieses Array enthält und an dieser Stelle kommt das '\0'-Zeichen (Nullzeichen) ins Spiel. Anhand dieses Zeichens kann auch innerhalb der Zeichenkette erkannt werden, wie lang die übergebene Zeichenkette ist.
Damit wissen Sie nun, wie aus dem einfachen char-Array das fertige string-Objekt wird. Jetzt ist es an der Zeit zu erfahren, was Sie mit diesem Objekt alles machen können
[Bearbeiten] string und andere Datentypen
Wie Sie bereits im Beispiel von eben gesehen haben, lässt sich die string-Klasse problemlos mit anderen Datentypen und Klassen kombinieren. Im ersten Beispiel dieses Kapitels wurde zunächst eine Zuweisung eines char-Arrays vorgenommen. Anschließend wurde das string-Objekt über cout ausgegeben. Auch die Eingabe einer Zeichenkette über cin ist mit einem string-Objekt problemlos möglich:
#include <string>
int main() {
std::string zeichenkette;
std::cin >> zeichenkette;
std::cout << zeichenkette;
}
Diese Art der Eingabe erlaubt es lediglich, bis zum nächsten Whitespace einzulesen. Es kommt jedoch häufig vor, dass man eine Zeichenkette bis zum Zeilenende oder einem bestimmten Endzeichen einlesen möchte. In diesem Fall ist die Funktion getline hilfreich. Sie erwartet als ersten Parameter einen Eingabestream und als zweiten ein string-Objekt.
#include <string>
int main() {
std::string zeichenkette;
// Liest bis zum Zeilenende
std::getline(std::cin, zeichenkette);
std::cout << zeichenkette;
}
Als optionalen dritten Parameter kann man das Zeichen angeben, bis zu dem man einlesen möchte. Im Fall von eben wurde als der Default-Parameter '\n' (Newline-Zeichen) benutzt. Im folgenden Beispiel wird stattdessen bis zum ersten kleinen y eingelesen.
#include <string>
int main() {
std::string zeichenkette;
// Liest bis zum nächsten y
std::getline(std::cin, zeichenkette, 'y');
std::cout << zeichenkette;
}
[Bearbeiten] Zuweisen und Verketten
Genau wie die Basisdatentypen, lassen sich auch strings einander zuweisen. Für die Verkettung von strings wird der +-Operator benutzt und das Anhängen einer Zeichenkette ist mit += möglich.
#include <string>
int main() {
std::string string1, string2, string3;
string1 = "ich bin ";
string2 = "doof";
string3 = string1 + string2;
std::cout << string3 << std::endl;
string3 += " - " + string1 + "schön";
std::cout << string3 << std::endl;
std::cout << string1 + "schön " + string2 << std::endl;
}
ich bin doof - ich bin schön
ich bin schön doof
Spielen Sie einfach ein wenig mit den Operatoren, um den Umgang mit ihnen zu lernen.
[Bearbeiten] Nützliche Methoden
Die string-Klasse stellt einige nützliche Methoden bereit. Etwa um den String mit etwas zu füllen, ihn zu leeren oder über verschiedene Eigenschaften Auskunft zu bekommen. Eine Methode wird mit folgender Syntax aufgerufen:
Die Methoden size() und length() erwarten keine Parameter und geben beide die aktuelle Länge der gespeicherten Zeichenkette zurück. Diese Doppelung in der Funktionalität existiert, da string in Analogie zu den anderen Containerklassen der C++-Standardbibliothek size() anbieten muss, der Name length() für die Bestimmung der Länge eines Strings aber natürlicher und auch allgemein üblich ist. empty() gibt true zurück falls der String leer ist, andernfalls false.
Mit clear() lässt sich der String leeren. Die resize()-Methode erwartet ein oder zwei Parameter. Der erste ist die neue Größe des Strings, der zweite das Zeichen, mit dem der String aufgefüllt wird, falls die angegebene Länge größer ist, als die aktuelle. Wird der zweite Parameter nicht angegeben, wird der String mit '\0' (Nullzeichen) aufgefüllt. In der Regel werden Sie dieses Verhalten nicht wollen, geben Sie also ein Füllzeichen an, falls Sie sich nicht sicher sind, was Sie tun. Ist die angegebene Länge geringer, als die des aktuellen Strings, wird am Ende abgeschnitten.
Um den Inhalt zweier Strings auszutauschen existiert die swap()-Methode. Sie erwartet als Parameter den String mit dem ihr Inhalt getauscht werden soll. Dies ist effizienter, als das Vertauschen über eine dritte, temporäre string-Variable.
#include <string>
int main() {
std::string zeichenkette1 = "Ich bin ganz lang!";
std::string zeichenkette2 = "Ich kurz!";
std::cout << zeichenkette1 << std::endl;
std::cout << zeichenkette2 << std::endl;
zeichenkette1.swap(zeichenkette2);
std::cout << zeichenkette1 << std::endl;
std::cout << zeichenkette2 << std::endl;
}
Ich kurz!
Ich kurz!
Ich bin ganz lang!
[Bearbeiten] Zeichenzugriff
Genau wie bei einem Array können Sie den []-Operator (Zugriffsoperator) verwenden, um auf einzelne Zeichen im String zuzugreifen. Allerdings wird, ebenfalls genau wie beim Array, nicht überprüft, ob der angegebene Wert noch innerhalb der enthaltenen Zeichenkette liegen.
Alternativ existiert die Methode at(), die den Index als Parameter erwartet und eine Grenzprüfung ausführt. Im Fehlerfall löst sie eine out_of_range-Exception aus. Da Sie den Umgang mit Exceptions wahrscheinlich noch nicht beherrschen, sollten Sie diese Methode vorerst nicht einsetzen und stattdessen genau darauf achten, dass Sie nicht versehentlich über die Stringlänge hinaus zugreifen.
#include <string>
int main() {
std::string zeichenkette = "Ich bin ganz lang!";
std::cout << zeichenkette[4] << std::endl;
std::cout << zeichenkette.at(4) << std::endl;
std::cout << zeichenkette[20] << std::endl; // Ausgabe von Datenmüll
std::cout << zeichenkette.at(20) << std::endl; // Laufzeitfehler
}
b
terminate called after throwing an instance of 'std::out_of_range'
what(): basic_string::at
Abgebrochen
Beachten Sie beim Zugriff, dass das erste Zeichen den Index 0 hat. Das letzte Zeichen hat demzufolge den Index zeichenkette.length() - 1.
[Bearbeiten] Manipulation
[Bearbeiten] Suchen
Die Methode find() sucht das erste Vorkommen eines Strings und gibt die Startposition (Index) zurück. Der zweite Parameter gibt an, ab welcher Position des Strings gesucht werden soll.
Wird ein Substring nicht gefunden, gibt find() den Wert std::string::npos zurück.
Das Gegenstück zu find() ist rfind(). Es ermittelt das letzte Vorkommen eines Strings. Die Parameter sind die gleichen wie bei find().
[Bearbeiten] Ersetzen
Sie können replace() verwenden, um Strings zu ersetzen. Dafür benötigen Sie die Anfangsposition und die Anzahl der Zeichen, die anschließend ersetzt werden sollen.
Wie Sie sehen, verwenden wir find(), um die Startposition zu ermitteln. Der zweite Parameter gibt die Länge an. Hier soll die alternative Schreibweise verdeutlicht werden; Sie müssen nicht eine zusätzliche Variable deklarieren, sondern können die std::string-Klasse wie eine Funktion verwenden und über Rückgabewert auf die Methode length() zugreifen. Im dritten Parameter spezifizieren Sie den String, welcher den ursprünglichen String zwischen der angegebenen Startposition und Startposition + Länge ersetzt.
[Bearbeiten] Einfügen
Die Methode insert() erlaubt es Ihnen, einen String an einer bestimmten Stelle einzufügen.
[Bearbeiten] Kopieren
Mit der Methode substr() kann man sich einen Substring zurückgeben lassen. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter noch die Endposition festlegen. Wird die Methode nur mit dem Startwert aufgerufen, gibt sie alle Zeichen ab dieser Position zurück.
[Bearbeiten] Vergleiche
C++-Strings können Sie, genau wie Zahlen, miteinander vergleichen. Was Gleichheit und Ungleichheit bei einem String bedeutet, wird Ihnen sofort klar sein. Sind alle Zeichen zweier Strings identisch, so sind beide gleich, andernfalls nicht. Die Operatoren <, >>, <= und >= geben da schon einige Rätsel mehr auf.
Im Grunde kennen Sie die Antwort bereits. Zeichen sind in C++ eigentlich Zahlen. Sie werden zu Zeichen, indem den Zahlen entsprechende Symbole zugeordnet werden. Der Vergleich erfolgt also einfach mit den Zahlen, welche die Zeichen kodieren. Das erste Zeichen der Strings, das sich unterscheidet, entscheidet darüber, welcher der Strings größer bzw. kleiner ist.
Die meisten Zeichenkodierungen beinhalten in den ersten 7 Bit den ASCII-Code, welchen die nachfolgende Tabelle zeigt.
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
int main(){
std::string gross = "Ich bin ganz groß!";
std::string klein = "Ich bin ganz klein!";
gross == klein; // ergibt false ('g' != 'k')
gross != klein; // ergibt true ('g' != 'k')
gross < klein; // ergibt true ('g' < 'k')
gross > klein; // ergibt false ('g' < 'k')
gross <= klein; // ergibt true ('g' < 'k')
gross >= klein; // ergibt false ('g' < 'k')
}
[Bearbeiten] Zahl zu string und umgekehrt
In C++ gibt es, im Gegensatz zu vielen anderen Programmiersprachen, keine Funktion, um direkt Zahlen in Strings oder umgekehrt umzuwandeln. Es ist allerdings nicht besonders schwierig, eine solche Funktion zu schreiben. Wir haben für die Umwandlung zwei Möglichkeiten:
- Die C-Funktionen atof(), atoi(), atol() und sprintf()
- C++-String-Streams
Die C-Variante wird in Kürze im Zusammenhang mit C-Strings besprochen. Für den Moment wollen wir uns der C++-Variante widmen. Stringstreams funktionieren im Grunde genau wie die Ihnen bereits bekannten Ein-/Ausgabestreams cin und cout mit dem Unterschied, dass sie ein string-Objekt als Ziel benutzen.
#include <sstream> // String-Ein-/Ausgabe
int main() {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
int var = 10; // Eine ganzzahlige Variable
strout << var; // ganzzahlige Variable auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
std::cout << str << std::endl; // String ausgeben
}
Der vorliegende Code wandelt eine Ganzzahl in einen String um, indem die Ganzzahl auf dem Ausgabe-Stringstream ausgegeben und dann der Inhalt des Streams an den String zugewiesen wird. Die umgekehrte Umwandlung funktioniert ähnlich. Natürlich verwenden wir hierfür einen Eingabe-Stringstream ( istringstream statt ostringstream) und übergeben den Inhalt des Strings an den Stream, bevor wir ihn von diesem auslesen.
#include <sstream> // String-Ein-/Ausgabe
int main() {
std::istringstream strin; // Unser Eingabe-Stream
std::string str = "17"; // Ein String-Objekt
int var; // Eine ganzzahlige Variable
strin.str(str); // Streaminhalt mit String-Variable füllen
strin >> var; // ganzzahlige Variable von Eingabe-Stream einlesen
std::cout << var << std::endl; // Zahl ausgeben
}
Statt istringstream und ostringstream können Sie übrigens auch ein stringstream-Objekt verwenden, welches sowohl Ein-, als auch Ausgabe erlaubt, allerdings sollte man immer so präzise wie möglich angeben, was der Code machen soll. Daher ist die Verwendung eines spezialisierten Streams zu empfehlen, wenn Sie nur die speziellen Fähigkeiten (Ein- oder Ausgabe) benötigen.
Sicher sind Sie jetzt bereits in der Lage, zwei Funktionen zu schreiben, welche diese Umwandlung durchführt. Allerdings stehen wir in dem Moment, wo wir andere Datentypen als int in Strings umwandeln wollen vor einem Problem. Wir können der folgenden Funktion zwar ohne weiteres eine double-Variable übergeben, allerdings wird dann der Nachkommateil einfach abgeschnitten. Als Lösung kommt Ihnen nur eventuell in den Sinn, einfach eine double-Variable von der Funktion übernehmen zu lassen.
#include <sstream> // String-Ein-/Ausgabe
std::string zahlZuString(double wert) {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
strout << wert; // Zahl auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
return str; // String zurückgeben
}
int main() {
std::string str;
int ganzzahl = 19;
double kommazahl = 5.55;
str = zahlZuString(ganzzahl);
std::cout << str << std::endl;
str = zahlZuString(kommazahl);
std::cout << str << std::endl;
}
5.55
Nun, so weit so gut. Das funktioniert. Leider gibt es da aber auch noch die umgekehrte Umwandlung und obgleich es möglich ist, sie auf ähnliche Weise zu lösen, wird Ihr Compiler sich dann ständig mit einer Warnung beschweren, wenn das Ergebnis ihrer Umwandlung an eine ganzzahlige Variable zugewiesen wird.
Besser wäre es, eine ganze Reihe von Funktionen zu erzeugen, von denen jede für einen Zahlentyp verantwortlich ist. Tatsächlich können Sie in C++ mehrere Funktionen gleichen Namens erzeugen, die unterschiedliche Parameter(typen) übernehmen. Diese Vorgang nennt sich Überladen von Funktionen. Der Compiler entscheidet dann beim Aufruf der Funktion anhand der übergeben Parameter, welche Version gemeint war während der Programmierer immer den gleichen Namen verwendet.
Im Moment haben wir obendrein einen Sonderfall der Überladung. Alle unsere Funktionen besitzen exakt den gleichen Code. Lediglich der Parametertyp ist unterschiedlich. Es wäre ziemlich zeitaufwendig und umständlich, den Code immer wieder zu kopieren, um dann nur den Datentyp in der Parameterliste zu ändern. Noch schlimmer wird es, wenn wir eines Tages eine Änderung am Funktionsinhalt vornehmen und diese dann auf alle Kopien übertragen müssen.
Glücklicherweise bietet C++ für solche Fälle so genannte Templates, die es uns erlauben, den Datentyp vom Compiler ermitteln zu lassen. Wir teilen dem Compiler also mit, was er tun soll, womit muss er dann selbst herausfinden. Die Funktion zahlZuString() (umbenannt in toString()) sieht als Template folgendermaßen aus:
#include <sstream> // String-Ein-/Ausgabe
template <typename Typ>
std::string toString(Typ wert) {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
strout << wert; // Zahl auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
return str; // String zurückgeben
}
int main() {
std::string str;
int ganzzahl = 19;
double kommazahl = 5.55;
std::string nochnString = "Blödsinn";
str = toString(ganzzahl);
std::cout << str << std::endl;
str = toString(kommazahl);
std::cout << str << std::endl;
str = toString(nochnString);
std::cout << str << std::endl;
}
5.55
Blödsinn
Die letzte Ausgabe zeigt deutlich warum die Funktion in toString umbenannt wurde, denn sie ist nun in der Lage, jeden Datentyp, der sich auf einem ostringstream ausgeben lässt, zu verarbeiten und dazu zählen eben auch string-Objekte und nicht nur Zahlen. Sie werden später noch lernen, welches enorme Potenzial diese Technik in Zusammenhang mit eigenen Datentypen hat. An dieser Stelle sei Ihnen noch die Funktion zur Umwandlung von Strings in Zahlen (oder besser: alles was sich von einem istringstream einlesen lässt) mit auf den Weg gegeben:
#include <sstream> // String-Ein-/Ausgabe
template <typename Typ>
void stringTo(std::string str, Typ &wert) {
std::istringstream strin; // Unser Eingabe-Stream
strin.str(str); // Streaminhalt mit String-Variable füllen
strin >> wert; // Variable von Eingabe-Stream einlesen
}
int main() {
std::string str = "7.65Blödsinn";
int ganzzahl;
double kommazahl;
std::string nochnString;
stringTo(str, ganzzahl);
std::cout << ganzzahl << std::endl;
stringTo(str, kommazahl);
std::cout << kommazahl << std::endl;
stringTo(str, nochnString);
std::cout << nochnString << std::endl;
}
7.65
7.65Blödsinn
Die Variable, die mit dem Wert des Strings belegt werden soll, wird als Referenz an die Funktion übergeben, damit der Compiler ihren Typ feststellen kann. Die Ausgabe zeigt, dass immer nur so viel eingelesen wird, wie der jeweilige Datentyp (zweiter Funktionsparameter) fassen kann. Für eine ganzzahlige Variable wird nur die Zahl Sieben eingelesen, die Gleitkommavariable erhält den Wert 7.65 und das string-Objekt kann die gesamte Zeichenkette übernehmen.
Sie werden Überladung im Kapitel „Überladen…“ (Abschnitt „Eigene Datentypen definieren“) genauer kennen lernen. Templates sind ein sehr umfangreiches Thema, Sie werden ihnen im Abschnitt Templates wiederbegegnen.
[Bearbeiten] C-Strings
Wie bereits erwähnt, handelt es sich bei einem C-String um ein Array von chars. Das Ende eines C-Strings wird durch ein Nullzeichen (Escape-Sequenz '\0') angegeben. Das Arbeiten mit C-Strings ist mühsam, denn es muss immer sichergestellt sein, dass das Array auch groß genug ist, um den String zu beinhalten. Da in C/C++ jedoch auch keine Bereichsüberprüfung durchgeführt wird, macht sich ein Pufferüberlauf (also eine Zeichenkette die größer ist als das Array, das sie beinhaltet) erst durch einen eventuellen Programmabsturz bemerkbar. Allein um dies zu vermeiden sollten Sie, wann immer es Ihnen möglich ist, die C++-string-Klasse verwenden.
Ein weiteres Problem beim Umgang mit C-Strings ist der geringe Komfort beim Arbeiten. Ob Sie einen String mit einem anderen vergleichen wollen, oder ihn an ein anderes Array „zuweisen“ möchten, in jedem Fall benötigen Sie unintuitive Zusatzfunktionen. Diese Funktionen finden Sie in der Standardheaderdatei „cstring“. Wie diese Funktionen heißen und wie man mit ihnen umgeht können Sie im C++-Referenz-Buch nachlesen, falls Sie sie einmal benötigen sollten.
Wenn Sie sich eingehender mit der Thematik auseinandersetzen möchten, sei Ihnen das Buch C-Programmierung ans Herz gelegt. Wenn Sie in C++ mit der C-Standard-Bibliotheken arbeiten möchten, müssen Sie den Headerdateien ein „c“ voranstellen und das „.h“ weglassen. So wird beispielsweise aus dem C-Header „string.h“ der C++-Header „cstring“.
Vorarbeiter des Compilers
Bevor der Compiler eine C++-Datei zu sehen kriegt, läuft noch der Präprozessor durch. Er überarbeitet den Quellcode, sodass der Compiler daraus eine Objektdatei erstellen kann. Diese werden dann wiederum vom Linker zu einem Programm gebunden. In diesem Kapitel soll es um den Präprozessor gehen, wenn Sie allgemeine Informationen über Präprozessor, Compiler und Linker brauchen, dann lesen Sie das Kapitel „Compiler“.
[Bearbeiten] #include
Die Präprozessordirektive #include haben Sie schon häufig benutzt. Sie fügt den Inhalt der angegebenen Datei ein. Dies ist nötig, da der Compiler immer nur eine Datei übersetzen kann. Viele Funktionen werden aber in verschiedenen Dateien benutzt. Daher definiert man die Prototypen der Funktionen (und einige andere Dinge, die Sie noch kennenlernen werden) in so genannten Headerdateien. Diese Headerdateien werden dann über #include eingebunden, weshalb Sie die Funktionen usw. Aufrufen können.
Ausführlich Informationen über Headerdateien erhalten Sie im gleichnamigen Kapitel.
#include bietet zwei Möglichkeiten Headerdateien einzubinden:
#include <name> // Sucht gleich in den Standardpfaden des Compilers
„Aktuelles Verzeichnis“ bezieht sich immer auf das Verzeichnis, in welchem die Datei liegt.
Die erste Syntax funktioniert immer, hat aber den Nachteil, dass dem Compiler nicht mitgeteilt wird, dass es sich um eine Standardheaderdatei handelt. Wenn sich im aktuellen Verzeichnis beispielsweise eine Datei namens iostream befände und Sie versuchten über die erste Syntax, die Standardheaderdatei iostream einzubinden, bänden Sie stattdessen die Datei im aktuellem Verzeichnis ein, was sehr unwahrscheinlich sein sollte, da Sie hoffentlich immer Dateiendungen wie .hpp oder .h für Ihre eigenen Header verwenden. Außerdem verlängert das Einbinden von Standardheadern in Anführungszeichen den Präprozessordurchlauf, je nach Anzahl der Dateien im aktuellen Quellpfad.
Aus diesem Grund ist es wichtig zu wissen, ob der eingebundene Header für eine Bibliotheksfunktionalität in den vordefinierten Pfaden des verwendeten Compilers steht, oder ob es eigener Inhalt ist, oder es sich um Zusatzbibliotheken handelt, deren Include-Verzeichnisse an anderen Stellen zu finden sind. Es sollte die Variante gewählt werden, bei der sich die Headerdatei vom Präprozessor am schnellsten finden lässt.
Verwenden Sie für Verweise auf Ihre eigenen Includes immer eine relative Pfadangabe mit normalen Slashes '/' als Verzeichnisseparator, damit Sie Ihre Quellcodeverzeichnisse auch an anderen Stellen und in anderen Entwicklungsumgebungen schneller kompiliert bekommen. Eine Verzeichnisangabe wie "/home/ichuser/code/cpp/projekt/zusatzlib/bla.h" oder "c:\users\manni\Eigene Dateien\code\cpp\projekt\zusatzlib\bla.h" ist sehr unangenehm und sorgt für große Verwirrung. Schreiben Sie es nun in "../zusatzlib/bla.h" um, können Sie später Ihr gesamtes Projekt leichter in anderen Pfaden kompilieren und sparen sich selbst und Ihrem Präprozessor einiges an Ärger und Verwirrung.
[Bearbeiten] #define und #undef
#define belegt eine Textsubstitution mit dem angegebenen Wert, z.B.:
Makros sind auch möglich, z.B.:
Diese sind allerdings mit Vorsicht zu genießen, da durch die strikte Textsubstitution des Präprozessors ohne jegliche Logikprüfungen des Compilers, fatale Fehler einfließen können und sollten eher durch ( inline)-Funktionen, Konstanten oder sonstige Konzepte realisiert werden. Manchmal lässt es sich allerdings nicht umgehen, ein Makro (weiter) zu verwenden. Beachten Sie bitte immer, dass es sich hierbei um Anweisungen an eine Rohquelltextvorbearbeitungsstufe handelt und nicht um Programmcode.
Da anstelle von einfachen Werten auch komplexere Terme als Parameter für das Makro verwendet werden können und das Makro selbst auch in einen Term eingebettet werden kann, sollten immer Klammern verwendet werden. Bsp.:
#define MUL_OK(x,y) ((x)*(y))
resultNok = a * MUL_NOK(b,c+d); // a * b*c+d = a*b*c+d <= falsch
resultOk = a * MUL_OK(b,c+d); // a * ((b)*(c+d)) = a*b*c + a*b*d <= richtig
#undef löscht die Belegung einer Textsubstitution/Makro, z.B.:
Bitte denken Sie bei allen Ideen, auf die Sie nun kommen, dass Sie immer eine Alternative zur Makroprogrammierung haben. Verwenden Sie diese Makros vor allem als Zustandsspeicher für den Präprozessordurchlauf an sich und nicht um Funktionalitäten Ihres Programms zu erweitern.
Einer statischen, konstanten Defintion, sowie Templatefunktionen ist immer der Vorzug zu geben. Wenn Sie dies nicht so sehen, lesen Sie bitte hier weiter: C-Programmierung.
[Bearbeiten] #
Der #-Ausdruck erlaubt es, den einem Makro übergebenen Parameter als Zeichenkette zu interpretieren:
Das obige Beispiel gibt also den Text "Test" auf die Standard-Ausgabe aus.
[Bearbeiten] ##
Der ##-Ausdruck erlaubt es, in Makros definierte Strings innerhalb des Präprozessorlaufs miteinander zu kombinieren, z.B.:
Als Resultat beinhaltet die Konstante A_UND_B die Zeichenkette "HalloWelt". Der ##-Ausdruck verbindet die Namen der symbolischen Konstanten, nicht deren Werte. Ein weiteres Anwendungsbeispiel:
class NAME \
{ \
public: \
static void Init##NAME() {}; \
};
...
MAKE_CLASS( MyClass )
...
MyClass::InitMyClass();
[Bearbeiten] #if, #ifdef, #ifndef, #else, #elif und #endif
Direktiven zur bedingten Übersetzung, d.h. Programmteile werden entweder übersetzt oder ignoriert.
// Programmteil 1
#elif ''Ausdruck2''
// Programmteil 2
/*
...
*/
#else
// Programmteil sonst
#endif
Die Ausdrücke hinter #if bzw. #elif werden der Reihe nach bewertet, bis einer von ihnen einen von 0 verschiedenen Wert liefert. Dann wird der zugehörige Programmteil wie üblich verarbeitet, die restlichen werden ignoriert. Ergeben alle Ausdrücke 0, wird der Programmteil nach #else verarbeitet, sofern vorhanden.
Als Bedingungen sind nur konstante Ausdrücke erlaubt, d.h. solche, die der Präprozessor tatsächlich auswerten kann. Definierte Makros werden dabei expandiert, verbleibende Namen durch 0L ersetzt. Insbesondere dürfen keine Zuweisungen, Funktionsaufrufe, Inkrement- und Dekrementoperatoren vorkommen. Das spezielle Konstrukt
wird durch 1L bzw. 0L ersetzt, je nachdem, ob das Makro Name definiert ist oder nicht.
#ifdef ist eine Abkürzung für #if defined.
#ifndef ist eine Abkürzung für #if ! defined.
Beispiel: Nehmen wir an, dass Sie Daten in zusammengesetzten Strukturen immer an 4-Byte Grenzen ausrichten wollen. Verschiedene Compiler bieten hierfür verschiedene compilerspezifische Pragma-Direktiven. Da diese Konfiguration nicht im C++-Standard definiert ist, kann nicht sichergestellt werden, dass es eine allgemeingültige Anweisung hierfür gibt. Sie definieren daher mit #define in einer Headerdatei, die überall eingebunden ist, welchen Compiler Sie verwenden. Z.B. mit #define FLAG_XY_COMPILER_SUITE in einer Datei namens "compiler-config-all.hpp". Das gibt Ihnen die Möglichkeit, an Stellen, an denen Sie compilerspezifisches Verhalten verwenden wollen, dieses auch auszuwählen.
#ifdef WIN32
#pragma pack(4)
#elif USING_GCC
#pragma align=4
#elif FLAG_XY_COMPILER_SUITE
#pragma ausrichtung(byte4)
#endif
[Bearbeiten] #error und #warning
#error gibt eine Fehlermeldung während des Compilerlaufs aus und bricht den Übersetzungsvorgang ab, z.B.:
#warning ist ähnlich wie #error, mit dem Unterschied, dass das Kompilieren nicht abgebrochen wird. Es ist allerdings nicht Teil von ISO-C++, auch wenn die meisten Compiler es unterstützen. Meistens wird es zum Debuggen eingesetzt:
[Bearbeiten] #line
Setzt den Compiler-internen Zeilenzähler auf den angegebenen Wert, z.B.:
[Bearbeiten] #pragma
Das #pragma-Kommando ist vorgesehen, um eine Reihe von Compiler-spezifischen Anweisungen zu implementieren, z.B.:
Kennt ein bestimmter Compiler eine Pragma-Anweisung nicht, so gibt er üblicherweise eine Warnung aus, ignoriert diese nicht für ihn vorgesehene Anweisung aber ansonsten.
Um z.B. bei MS VisualC++ eine "störende" Warnung zu unterdrücken, gibt man folgendes an:
[Bearbeiten] Vordefinierte Präprozessor-Variablen
- __LINE__: Zeilennummer
- __FILE__: Dateiname
- __DATE__: Datum des Präprozessoraufrufs im Format Monat/Tag/Jahr
- __TIME__: Zeit des Präprozessoraufrufs im Format Stunden:Minuten:Sekunden
- __cplusplus: Ist nur definiert, wenn ein C++-Programm verarbeitet wird
Headerdateien
Sie haben bereits mit 2 Headerdateien Bekanntschaft gemacht: iostream und string. Beide sind so genannte Standardheader, das heißt, sie sind in der Standardbibliothek jedes Compilers enthalten.
[Bearbeiten] Was ist eine Headerdatei?
Headerdateien sind gewöhnliche C++-Dateien, die im Normalfall Funktionsdeklarationen und ähnliches enthalten. Sicher werden Sie sich erinnern: Deklarationen machen dem Compiler bekannt, wie etwas benutzt wird. Wenn Sie also eine Headerdatei einbinden und darin eine Funktionsdeklaration steht, dann weiß der Compiler wie die Funktion aufgerufen wird. Der Compiler weiß zu diesem Zeitpunkt nicht, was die Funktion tut, aber das ist auch nicht nötig um sie aufrufen zu können.
Das einbinden einer Headerdatei erfolgt über die Präprozessordirektive #include. Der Code in der Datei die mit include referenziert wird, wird vom Präprozessor einfach an der Stelle eingefügt, an der das include stand.
In der nebenstehenden Darstellung wird die Headerdatei mal2.hpp von den Quelldateien main.cpp und mal2.cpp eingebunden. Um dieses Beispiel zu übersetzen, müssen die Dateien alle im gleichen Verzeichnis liegen. Sie übergeben die Quelldateien an einen Compiler und rufen anschließend den Linker auf, um die entstanden Objektdateien zu einem Programm zu binden.
Falls Sie mit der GCC arbeiten, beachten Sie bitte, dass g++ sowohl Compiler als auch Linker ist. Wenn Sie nur die beiden Quelldateien ohne weitere Parameter angeben, wird nach dem Kompilieren automatisch gelinkt. Der zweite Aufruf entfällt somit.
Einige Informationen zu Compilern und Linkern finden Sie übrigens im Kapitel Compiler
[Bearbeiten] Namenskonventionen
Übliche Dateiendungen für C++-Quelltexte sind „.cpp“ oder „.cc“. Headerdateien haben oft die Endungen „.hpp“, „.hh“ und „.h“. Letztere ist allerdings auch die gebräuchlichste Dateiendung für C-Header, weshalb zugunsten besserer Differenzierung empfohlen wird, diese nicht zu benutzen.
Die Standardheader von C++ haben überhaupt keine Dateiendung, wie Sie vielleicht schon anhand der beiden Headerdateien iostream und string erraten haben. In der Anfangszeit von C++ endeten Sie noch auf „.h“, inzwischen ist diese Notation aber nicht mehr gültig, obwohl sie immer noch von vielen Compilern unterstützt wird. Der Unterschied zwischen „iostream“ und „iostream.h“ besteht darin, dass in ersterer Headerdatei alle Deklarationen im Standardnamespace std vorgenommen werden.
Prinzipiell kommt es nicht darauf an, welche Dateiendungen Sie ihren Dateien geben, dennoch ist es sinnvoll, eine übliche Endung zu verwenden, wenn auch nur, um einer möglichen Verwechslungsgefahr vorzubeugen. Wenn Sie sich einmal für eine Dateiendung entschieden haben, ist es vorteilhaft, darin eine gewisse Konstanz zu bewahren, nicht nur vielleicht aus Gemütlichkeit und Zeiteinsparnis, sondern auch im Sinne des schnellen Wiederfindens. Einige Beispiele für Headerdateiendungen finden Sie, wenn Sie sich einfach mal einige weitverbreite C++-Bibliotheken ansehen. Boost verwendet „.hpp“, wxWidgets nutzt „.h“ und Qt orientiert sich an der Standardbibliothek, hat also gar keine Dateiendung.
[Bearbeiten] Schutz vor Mehrfacheinbindung
Da Headerdateien oft andere Headerdateien einbinden, kann es leicht passieren, dass eine Headerdatei mehrfach eingebunden wird. Da viele Header nicht nur Deklarationen, sondern auch Definitionen enthalten, führt dies zu Compiler-Fehlermeldungen, da innerhalb einer Übersetzungseinheit ein Name stets nur genau einmal definiert werden darf (mehrfache Deklarationen, die keine Definitionen sind, sind jedoch erlaubt). Um dies zu vermeiden, wird der Präprozessor verwendet. Am Anfang der Headerdatei wird ein Präprozessor-ifndef ausgeführt, das prüft, ob ein Symbol nicht definiert wurde, ist dies der Fall, wird das Symbol definiert. Am Ende der Headerdatei wird die Abfrage mit einem Präprozessor-endif wieder beendet.
Als Symbol wird üblicherweise aus dem Dateinamen des Headers abgeleitet. In diesem Fall wurde der Punkt durch einen Unterstrich ersetzt und ein weiter Unterstrich vor und nach dem Dateinamen eingefügt. Wenn der Präprozessor nun die Anweisung bekommt, diese Datei in eine andere einzubinden, wird er das anstandslos tun. Findet er eine zweite Anweisung, die Datei einzubinden, wird er alles von #ifndef bis #endif überspringen, da das Symbol _mal2_hpp_ ja nun bereits definiert wurde.
Mehr Informationen über Präprozessor-Anweisungen finden Sie im Kapitel Vorarbeiter des Compilers
[Bearbeiten] Inline-Funktionen
Im Kapitel „Prozeduren und Funktionen“ haben Sie bereits erfahren was eine Inline-Funktion ist. Das Schlüsselwort inline empfiehlt dem Compiler, beim Aufruf einer Funktion den Funktionsrumpf direkt durch den Funktionsaufruf zu ersetzen, wodurch bei kurzen Funktionen die Ausführungsgeschwindigkeit gesteigert werden kann. Es bewirkt aber noch mehr. Normalerweise darf eine Definition immer nur einmal gemacht werden. Da für das Inlinen einer Funktion aber die Definition bekannt sein muss, gibt es für Inline-Funktionen eine Ausnahme: Sie dürfen beliebig oft definiert werden, solange alle Definitionen identisch sind. Deshalb dürfen (und sollten) Inline-Funktionen in den Headerdateien definiert werden, ohne dass sich der Linker später über eine mehrfache Definition in verschiedenen Objektdateien beschweren wird.
Inline-Funktionen dürfen auch in cpp-Dateien definiert werden. Allerdings können sie dann auch nur innerhalb der Objektdatei, die aus der cpp-Datei erzeugt wird, geinlinet werden und das ist in aller Regel nicht beabsichtigt.
Zusammenfassung
|
Zu diesem Abschnitt existiert leider noch keine Zusammenfassung… |
|
|
Wenn Sie Lust haben können Sie die Zusammenfassung zum Abschnitt Weitere Grundelemente selbst schreiben oder einen Beitrag dazu leisten. |