C++-Programmierung/ Weitere Grundelemente
Zielgruppe:
Anfänger
Lernziel:
Grundelemente in C++
Prozeduren und Funktionen [Bearbeiten]
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 wiederholende Aufgaben zu kapseln, so dass die Befehle nicht jedesmal neu geschrieben werden müssen. Zudem verbessert es die Übersichtlichkeit der Quellcode-Struktur erheblich, wenn der Programmtext logisch in Abschnitte unterteilt wird.
Parameter und Rückgabewert
[Bearbeiten]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 char
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 entstandenen 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 f(int x); // Funktionsdeklaration
int main(){
int a = f(3); // Funktionsaufruf
// a hat jetzt den Wert 9
}
int f(int x){ // Funktionsdefinition
return x * x;
}
Innerhalb eines Programms dürfen Sie eine Funktion beliebig oft (übereinstimmend!) deklarieren, aber nur einmal definieren.
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 Kommas getrennt. Eine mit
deklarierte Funktion könnte z.B. so aufgerufen werden:
Für eine leere Parameterliste schreiben Sie hinter den Funktionsnamen einfach ()
.
Vergessen Sie beim Aufruf einer Funktion ohne Parameter nicht die leeren Klammern. Andernfalls erhalten Sie nicht den von der Funktion zurückgelieferten Wert, sondern die Adresse der Funktion. Dies ist ein „beliebter“ Anfängerfehler, daher sollten Sie im Falle eines Fehlers erst einmal überprüfen, ob Sie nicht vielleicht irgendwo eine Funktion ohne Parameter falsch aufgerufen haben.
Übergabe der Argumente
[Bearbeiten]C++ kennt zwei Varianten, wie einer Funktion die Argumente übergeben werden können: call-by-value und call-by-reference.
call-by-value
[Bearbeiten]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!
#include <iostream>
void f1(int const 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;
f1(a); // Kompiler-Fehler, da Übergabeparameter der Funktion const ist.
f2(5); // Ausgabe: 15
std::cout << x; // Fehler! x ist hier nicht definiert
std::cout << a; // a hat immer noch den Wert 7
return 0;
}
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.
call-by-reference
[Bearbeiten]Sollen die von einer Funktion vorgenommen Änderungen auch für das Hauptprogramm sichtbar sein, müssen in C 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, was im alten C nicht möglich war. 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 aufgerufen wurde:
#include <iostream>
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;
}
x=10 y=5
Ob das kaufmännische Und (&) genau nach int
(int& a, ...)
, oder genau vor der Variablen a
(int &a, ...)
, oder dazwischen (int & a, ...)
steht, ist für den Compiler nicht von Bedeutung. Auch möglich wäre (int&a, ...)
.
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.
Array
[Bearbeiten]Wir werden die oben angesprochenen Zeiger in einem späteren Kapitel ausführlich kennen lernen. Sie ermöglichen z.B. das Arbeiten mit Arrays. Soll eine Funktion mit einem Array umgehen, werden ihr die Startadresse des Arrays (ein Zeiger) und seine Größe als Parameter übergeben. Wenn das Array nur gelesen werden soll, deklariert man die Werte, auf die der Zeiger zeigt, als const
. Das Zeichen *
signalisiert, dass es sich um einen Zeiger handelt.
#include <iostream>
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);
}
Array:
3
13
113
Default-Parameter
[Bearbeiten]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.
int summe(int a, int b, int c = 0, int d = 0) {
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 summe(int a, int b, int c = 0, int d = 0); // Deklaration
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;
}
Funktionen überladen
[Bearbeiten]Ü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:
int summe(int a, int b, int c, int d) {
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() {
// ...
}
Funktionen mit beliebig vielen Argumenten
[Bearbeiten]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.
#include <cstdarg>
#include <iostream>
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() {
std::cout << summe(2, 3, 4, 0) << std::endl; // 9
std::cout << summe(2, 3, 0) << std::endl; // 5
std::cout << summe(1,1,0,1,0) << std::endl; // 2 (da die erste 0 in der while-Schleife für Abbruch sorgt)
}
9
5
2
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.
Mit dem kommenden C++ Standard C++11 wird mit den „variadic templates“ eine Technik eingeführt, welche diese Art von variabler Parameterliste überflüssig macht. Über Templates wie Sie im aktuellen Standard stehen, werden Sie später mehr erfahren. Sobald der neue Standard endgültig verabschiedet ist, wird es auch zu den „variadic templates“ ein Kapitel geben. Die meisten Compiler beherrschen diese Technik bereits in weiten Teilen, sofern man den neuen Standard explizit aktiviert:
#include <iostream>
template < typename First >
First summe(First first){
return first;
}
template < typename First, typename ... Liste >
First summe(First first, Liste ... rest){
return first + summe(rest ...);
}
int main() {
std::cout << summe(2, 3, 4) << std::endl;
std::cout << summe(2, 3) << std::endl;
std::cout << summe(1, 1, 0, 1) << std::endl;
}
9
5
3
Diese Implementierung ist typsicher und benötigt keine Tricks um den letzten Parameter zu kennzeichnen. Hierbei entspricht der Rückgabetyp immer dem Typ des ersten Parameters, was nicht sonderlich sinnvoll ist, aber eine Implementierung, die sich auch bezüglich des Rückgabetyps sinnvoll verhält, würde an dieser Stelle endgültig zu weit führen. „Variadic templates“ sind eine extrem nützliche Technik für Fortgeschrittene. Für Sie genügt es zum gegenwärtigen Zeitpunkt zu wissen, dass sie existieren und dass man derartige Funktionen wie jede andere Funktion aufrufen kann.
Inline-Funktionen
[Bearbeiten]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:
Das Schlüsselwort inline
wird bei der Deklaration angegeben. Allerdings muss der Compiler beim Aufruf den Funktionsrumpf kennen, wenn er den Code direkt einfügen soll. Für den Aufruf einer inline
-Funktion genügt also wie immer die Deklaration, um dem Compiler jedoch tatsächlich das Ersetzen des Funktionsaufrufs zu ermöglichen, muss auch die Definition bekannt sein. Über diese Eigenheit von inline
-Funktionen erfahren Sie im Kapitel „Headerdateien“ mehr. Auch die Bedeutung von Deklaration und Definition wird Ihnen nach diesem Kapitel klarer sein.
Rekursion [Bearbeiten]
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, insbesondere wenn die Lösung leichter zu verstehen ist als eine iterative 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.
Beispiele
[Bearbeiten]Fakultät
[Bearbeiten]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
:
#include <iostream> // Für std::cin und std::cout
unsigned int fakultaet(unsigned int zahl) {
if (zahl <= 1) {
return 1; // Die Fakultät von 0 und 1 ist als 1 definiert.
} else {
return zahl * fakultaet(zahl - 1);
}
}
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) << "." << std::endl;
}
Bitte Zahl eingeben: <eingabe>4</eingabe>
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 fakultaet(unsigned int zahl) {
unsigned int wert = 1;
for (unsigned int i = 2; i <= zahl; ++i) {
wert *= i;
}
return wert;
}
Fibonacci-Zahlen
[Bearbeiten]Als zweites Beispiel wollen wir Fibonacci-Zahlen ausrechnen.
#include <iostream>
unsigned int fibonacci(unsigned int zahl) {
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
} // else
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
} // else
// 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) << "." << std::endl;
}
Bitte Zahl eingeben: <eingabe>12</eingabe>
Die Fibonacci-Zahl von 12 ist 144.
Die iterative Entsprechung sieht folgendermaßen aus:
unsigned int fibonacci(unsigned int zahl) {
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
} // else
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
} // else
unsigned int ret;
unsigned int h1 = 0;
unsigned int h2 = 1;
for (unsigned int i = 1; i < zahl; ++i) {
// (Zwischen-)Ergebnis ist die Summe der zwei vorhergehenden Fibonacci-Zahlen.
ret = h1 + h2;
// "vorherige zwei F.-Zahlen" um 1 "Stelle" der Reihe "weiter ruecken":
h1 = h2;
h2 = ret;
}
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. Bei der Fibonacci-Funktion ist allerdings die iterative Lösung wesentlich effizienter, da ansonsten bei jedem Aufruf dieselbe Methode wieder zweimal neu aufgerufen wird. So ergeben sich bei fibonacci(40) schon 240-1 Aufrufe.
Merge sort
[Bearbeiten]Merge sort ist ein Beispiel für eine Funktion, bei der Rekursion sinnvoll eingesetzt wird. Die Idee ist: Um ein Array zu sortieren, sortiere erst die erste Hälfte, dann die zweite Hälfte, und dann füge die beiden Teile zusammen (merge). Der folgende Code implementiert Merge sort für int
-Arrays. Sie erwartet ein Array, den ersten Index des zu sortierenden Bereichs, und den Index auf das erste Element nach dem zu sortierenden Bereich. Da die genaue Implementierung des Merge-Schritts hier nicht von Interesse ist, wird einfach angenommen, dass dafür bereits eine Funktion merge
existiert.
void mergesort(int array[], int begin, int end)
{
int mid = begin + (end-begin)/2; // Mitte des Feldes bestimmen
mergesort(array, begin, mid); // Linke Hälfte
mergesort(array, mid, end); // Rechte Hälfte
merge(array, begin, mid, end);
}
Lösung: Es fehlt eine Abbruchbedingung. Eine mögliche Abbruchbedingung wäre: Weil eine Liste mit nur einem oder gar keinem Element darin nicht sortiert werden braucht, kann die Funktion 'nichts tun', wenn der Unterschied von begin
und end
kleinergleich 1 ist.
Bei komplexeren Problemen, die rekursiv gelöst werden sollen, ist es wichtig darauf zu achten, dass das „jeweils zu lösende Problem“ bei jedem tieferen Rekursionsschritt kleiner wird, einfacher wird, näher an die Abbruchbedingung herankommt. Damit ist recht gut sichergestellt, dass die Rekursion nicht (in ungünstigen Fällen) „unendlich tief“ verzweigt.
Jeder (rekursive) Aufruf der Funktion sollte das ihr übergebene (Teil-)Problem zumindest ein wenig vereinfachen, aufteilen oder anderweitig an eine Lösung heranbringen, bevor sich die Funktion für (Unter-Teil-)Probleme rekursiv erneut aufruft - und das Vereinfachen sollte in jedem möglichen Fall (if
-Zweig) geschehen.
Aufzählungen [Bearbeiten]
Eine Aufzählung oder Enumeration ist in C++ ein Typ, der eine Menge durch den Benutzer definierte Werte abbilden kann. Diese Konstanten werden als Enumeratoren bezeichnet. Im folgenden Beispiel wird eine Enumeration color
definiert, welche mehrere Farbnamen als Enumeratoren enthält.
Diese Typdefinition kann nun zum Anlegen einer Variablen verwendet werden:
Die Enumeration ist eng mit den ganzzahligen Datentypen verwandt, da die definierten Werte Ganzzahlkonstanten entsprechen:
enum color{
red, // Index 0
green, // Index 1
blue, // Index 2
yellow = 9, // Index 9
black, // Index 10
gray = -1, // Index -1
white, // Index 0
};
Die Werte hinter den Konstanten werden als Indizes der Enumeration bezeichnet. Der Index kann explizit angegeben, oder vom Compiler implizit automatisch vergeben werden. Bei einer expliziten Angabe kann ein beliebiger positiver oder negativer konstanter Ganzzahlwert vergeben werden. Ist kein Index angegeben, wird der Compiler von der letzten expliziten Indexangabe aus die Werte hochzählen, beziehungsweise im Falle des ersten Enumerators den Wert 0 vergeben. Im Beispiel wurden die Indizes teils explizit, teils implizit vergeben, wobei der Indexwert in jedem Fall als Kommentar angegeben ist.
Wie Sie leicht erkennen werden, haben red
und white
den gleichen Index, was bedeutet, dass die beiden Enumeratoren gleichwertig sind. Auch ist es offensichtlich möglich die Indizes nicht durchgehend zu vergeben, beispielsweise sind die Werte zwischen 3 und 8 nicht verwendet. Das letzte Komma hinter white
ist optional und hat den Vorteil, dass die Liste leicht erweitert werden kann. In größeren Bibliotheken kann es vorkommen, dass die Liste mittels Makros manipuliert wird, wofür das optionale Komma nützlich ist. Im Normalfall können Sie es auch weglassen.
Mit Enumeratoren als Zahlenwerten zu hantieren, führt leicht zu Problemen, davon ist abzuraten. Im Beispiel sind die Werte 3..8 nicht definiert; werden sie dennoch verwendet, so entsprechen sie keinem enum-Enumerator, und eine Abfrage
switch( ampelFarbe ) {
case red:
case green:
case yellow:
std::cout << "normale Ampelfarbe" << std::endl ;
break;
case black:
std::cout << "Ampel defekt?" << std::endl ;
break;
case blue:
case gray:
case white:
std::cout << "Sonderfarb-Ampel" << std::endl ;
break;
};
würde „ins Leere“ laufen.
Enumerationsobjekte lassen sich implizit in Ganzzahldatentypen umwandeln. Umgekehrt kann auch eine Ganzzahl in ein Objekt einer Enumeration umgewandelt werden. Hierfür ist allerdings eine explizite Angabe nötig und außerdem ist die Umwandlung nur standardkonform, wenn der Ganzzahlwert im Wertebereich der Enumeration liegt. Andernfalls ist das Verhalten undefiniert. Der Wertebereich einer Enumeration lässt sich aus ihren Indizes ableiten. Für Enumerationen, die nur positive Indizes besitzen liegt der Wertebereich zwischen 0 und der kleinsten Zweierpotenz, die größer als der größte Index ist. Für Enumerationen mit negativen Indizes muss auch der kleinste Wert größer oder gleich der größten negativen Zweierpotenz sein.
enum A{a, b, c = 15}; // 0 .. 15 ( 0 .. 2⁴ - 1)
enum B{v1 = 1, v2 = 2, v3 = 4}; // 0 .. 7 ( 0 .. 2³ - 1)
enum C{c1 = -10, c2 = 0}; // -16 .. 15 (-2⁴ .. 2⁴ - 1)
enum D{d1 = -10, d2 = 16}; // -32 .. 31 (-2⁵ .. 2⁵ - 1)
Die -1
im positiven Teil des Wertebereichs kommt zu Stande, da der positive Wertebereich die 0 beinhaltet, während bei Ganzzahlen keine negative 0 existiert. Die Enumeratoren können in der Umgebung ihrer Enumeration ohne Zugriffsoperator verwendet werden. Das folgende Beispiel wird dies demonstrieren.
#include <iostream>
enum color{
red,
green,
blue
};
int main(){
color actual_color = red;
int input;
// Eingabe bis input einen Wert hat, der einem der Indizes entspricht
do{
std::cin >> input;
}while(input < red || input > blue);
actual_color = color(input); // explizite Umwandlung nach color
switch(actual_color){
case red: std::cout << "red" << std::endl; break;
case green: std::cout << "green" << std::endl; break;
case blue: std::cout << "blue" << std::endl; break;
}
}
In diesem Beispiel kann der Compiler eine Warnung ausgeben, falls nicht alle Enumeratoren in der switch
-Anweisung angefragt wurden. Dies ist, neben der besseren Lesbarkeit des Codes, eine hilfreiche Ausgabe bei der Fehlersuche. Im folgenden Beispiel wird noch gezeigt, wie Enumeratoren genutzt werden können, um mehrere Boolesche Werte in einer Variablen unterzubringen.
#include <iostream>
enum font{
italic = 0x01,
bold = 0x02,
underline = 0x04
}; // Wertebereich 0 .. 7
int main(){
font flags = font(0); // Kein flag gesetzt
char input;
std::cout << "Kursivdruck? ";
std::cin >> input;
// Binäres Oder zum Setzen eines Flags (flags | italic => int)
if(input == 'j') flags = font(flags | italic);
std::cout << "Fettdruck? ";
std::cin >> input;
if(input == 'j') flags = font(flags | bold);
std::cout << "Unterstreichen? ";
std::cin >> input;
if(input == 'j') flags = font(flags | underline);
// Binäres Und zum Abfragen des Flags
if(flags & italic){
std::cout << "Der Text ist kursiv." << std::endl;
}
if(flags & bold){
std::cout << "Der Text ist fettgedruckt." << std::endl;
}
if(flags & underline){
std::cout << "Der Text ist unterstrichen." << std::endl;
}
if(flags == 0){
std::cout << "Der Text wird normal ausgegeben." << std::endl;
}
}
Zeiger [Bearbeiten]
Grundlagen zu Zeigern
[Bearbeiten]Zeiger (engl. pointers) sind Variablen, die als Wert die Speicheradresse einer anderen Variable (oder eines anderen Speicherobjekts) enthalten.
Jede Variable wird in C++ an einer bestimmten Position im Hauptspeicher abgelegt. Diese Position nennt man Speicheradresse (engl. 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 Kompilierzeit bekannt, wo sie sich innerhalb des Speichers befinden (also kennt das Programm ihre Adresse).
Eine Adresse ist nichts anderes als eine Ganzzahl, die den Ort, die Nummer des ersten Bytes eines Objekts, angibt. Um eine solche Adressen-Ganzzahl zu speichern, ist ein Zeiger im Wesentlichen eine normale (Ganzzahl-)Variable. Zeiger(variablen) werden deklariert (und definiert), besitzen einen Gültigkeitsbereich, selbst wiederum eine Adresse und einen Wert. Dieser Wert, der Inhalt der Zeigervariable, ist zwar zunächst einmal eine Zahl, aber auch die Adresse einer anderen Variable oder eines Speicherbereichs. Bei der Deklaration einer Zeigervariable wird auch der Typ der Variable angegeben, auf die der Zeiger verweisen soll - also der Typ, den das Objekt besitzt, auf das der Zeiger zeigt.
#include <iostream>
int main() {
int Wert; // eine int-Variable
int *pWert; // eine Zeigervariable für eine Variable vom Typ int
int *pZahl; // ein weiterer "Zeiger auf int"
Wert = 10; // Zuweisung eines Wertes an eine int-Variable
pWert = &Wert; // Adressoperator '&' liefert die Adresse der Variable "Wert"
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.
Der Sinn von Zeigern erschließt sich vor allem Anfängern nicht unmittelbar. Das ändert sich allerdings schnell, sobald der dynamische Speicher besprochen wird.
Zeiger und const
[Bearbeiten]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 ersten 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 jener Stelle kann allerdings verändert werden. Natürlich sind auch beide Varianten in Kombination möglich.
int Wert1; // eine int-Variable
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, Zeiger konstant
*p3Wert = Wert2; // geht nicht, int 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.
Zeiger und Funktionen
[Bearbeiten]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ürzt 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.
Funktionen, die einen Zeiger auf einen konstanten Datentyp erwarten, können auch mit einem Zeiger auf einen nicht-konstanten Datentyp aufgerufen werden. Das folgende Minimalbeispiel soll dies zeigen:
// Funktion, die einen Zeiger auf einen konstanten int erwartet
void function(int const* parameter){}
int main() {
int* zeiger; // Zeiger auf nicht-konstanten int
function(zeiger); // Funktioniert
}
Probleme mit Zeigern auf Zeiger auf konstante Daten
[Bearbeiten]Ein Problem, über das die meisten Programmierer irgendwann stolpern, ist die Übergabe eines Zeigers auf einen Zeiger auf nicht-konstante Daten an eine Funktion, die einen Zeiger auf einen Zeiger auf konstante Daten erwartet. Da sich das const
hier nicht direkt auf die Daten des Zeigers bezieht, sondern erst auf die Daten des Zeigers, auf den der Zeiger zeigt, erlaubt der Compiler die Übergabe nicht. Die Lösung des Problems ist relativ einfach: teilen Sie dem Compiler mittels const_cast
explizit mit, dass Sie die Umwandlung vornehmen möchten.
// Funktion, die einen Zeiger auf einen Zeiger einen konstanten int erwartet
void function(int const** parameter){}
int main() {
int** zeiger; // Zeiger auf Zeiger auf nicht-konstanten int
function(zeiger); // Fehler: Typen sind inkompatibel
// Lösung: explizites hinzucasten der Konstantheit
function(const_cast< int const** >(zeiger));
}
Wenn Sie das Beispiel ausprobieren möchten, kommentieren Sie die fehlerhafte Zeile aus
In den meisten Fällen werden Sie einen Parametertyp haben, der etwa die Form Typ const*const*
hat und Daten übergeben will, deren Typ Typ**
lautet. Die Umwandlung der Konstantheit der Daten unmittelbar auf die der Zeiger zeigt, dass der Compiler dies automatisch vornehmen kann. Alle anderen Umwandlungen müssen Sie explizit mittels const_cast
erledigen. Es ist somit egal, ob Sie const_cast< int const*const* >
oder nur const_cast< int const** >
für die explizite Umwandlung angeben.
Zeigerarithmetik
[Bearbeiten]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:
#include <iostream>
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;
}
Zahlenarithmetik
a: 1
a: 2
Zeigerarithmetik
p verweist auf: 0x7fff3aa60090
Größe von int: 4
p verweist auf: 0x7fff3aa60094
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.
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.
#include <iostream>
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;
}
pp verweist auf: 0x7fff940cb6f0
Größe von int*: 8
pp verweist auf: 0x7fff940cb6f8
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.
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*
).
Negativbeispiele
[Bearbeiten]Zur Verdeutlichung zwei Beispiele, die nicht funktionieren, weil sie vom Compiler nicht akzeptiert werden:
int *pWert;
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 Wert = 0;
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
std::cout << "Wert enthält: " << 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.
void
-Zeiger (anonyme Zeiger)
[Bearbeiten]Eine besondere Rolle spielen die „Zeiger auf void
“, die so genannten generischen Zeiger. Einer Zeigervariable vom Typ void*
kann jeder beliebige Zeiger zugewiesen werden. void
-Zeiger werden in der Sprache C beispielsweise 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. Ein Objekt kann nicht vom Typ void
sein. Entsprechend ist es auch nicht möglich, einen void
-Zeiger zu dereferenzieren. Das folgende kleine Codebeispiel ist praxisfern. Aber wie schon erwähnt gibt es in C++ nur noch sehr selten eine Notwendigkeit für void
-Zeiger, und keine dieser seltenen Situationen könnte an dieser Stelle im Buch bereits erklärt werden. Die meisten C++-Programmierer werden in Ihrem gesamten Leben nicht in die Verlegenheit kommen, einen void
-Zeiger wirklich verwenden zu müssen.
#include <iostream>
int main() {
int value = 1;
int* pointer = &value; // zeigt auf value
void* void_pointer;
void_pointer = pointer; // void_pointer zeigt wie pointer auf value
…
Sie können jetzt nicht ohne Weiteres auf *void_pointer
zugreifen, um an den Wert von value
zu kommen. Da es sich um einen Zeiger vom Typ void
handelt, muss man diesen erst umwandeln (engl. to cast). In diesem Fall nach int*
.
Der Ablauf dieser letzten Zeile noch einmal im Detail:
- Zeiger
void_pointer
ist vom Typvoid*
und zeigt aufvalue
reinterpret_cast< int* >
, Zeiger ist vom Typint*
- Zeiger dereferenzieren (
*
), um Wert (vom Typint
) zu erhalten - Wert ausgeben
Funktionszeiger
[Bearbeiten]Zeiger können nicht nur auf Variablen, sondern auch auf Funktionen verweisen. Die Deklaration eines solchen Funktionszeigers sieht im ersten Augenblick etwas schwierig aus, aber sie ist dennoch leicht zu merken. Sie schreiben einfach den Prototypen der Funktion, auf die ihr Zeiger verweisen soll und geben statt des Funktionsnamens den Variablennamen an. Selbigen stellen Sie einen Stern voran, um klar zu machen, dass es sich um einen Zeiger handelt, und um dem Compiler zu vermitteln, dass der Stern nicht zum Rückgabetyp gehört, fassen Sie ihn und den Variablennamen in Klammern ein. Das folgende kleine Beispiel zeigt die Verwendung:
#include <iostream>
int multiplication(int a, int b){
return a*b;
}
int division(int a, int b){
return a/b;
}
int main(){
int (*rechenoperation)(int, int) = 0; // Anlegen eines Funktionszeigers, Initialisierung mit 0
rechenoperation = &multiplication;
std::cout << (*rechenoperation)(40, 8) << std::endl;
rechenoperation = &division;
std::cout << (*rechenoperation)(40, 8) << std::endl;
}
320
5
Man liest: rechenoperation
ist ein Zeiger auf eine Funktion, die zwei int
s übernimmt und einen int
zurückgibt. Im Kapitel über Felder wird eine allgemein gültige Regel für das Lesen komplexer Datentypen vorgestellt. Wie Sie sehen wird der Zeigervariable nacheinander die Adresse zweier Funktionen zugewiesen, die dem Typ der Zeigervariable entsprechen. Eine für den Zeiger gültige Funktion muss also zwei int
s übernehmen und einen int
zurückgeben.
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. &multiplication()
würde Ihnen die Adresse des von multiplication()
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.
Gleiches gilt bei der Dereferenzierung: ein expliziter Stern vor dem Funktionszeiger macht deutlich, dass es sich um eine Zeigervariable handelt. Während der Fehlersuche kann dies beim Lesen des Codes erheblich helfen. Die Klammern sind, wie schon bei der Deklaration, nötig, um dem Compiler mitzuteilen, dass sich der Stern nicht auf den Rückgabewert, sondern auf die Funktion bezieht.
int x;
x = (*rechenoperation)(40, 8); // ruft multiplication() bzw. division() auf und weist x den Rückgabewert zu
x = rechenoperation(40, 8); // alternative (nicht empfohlene) Syntax
In C werden Funktionszeiger oft für generische Funktionen verwendet, wofür es in C++ mit den Templates (auf Deutsch etwa „Vorlagen“) eine bessere Lösung gibt. Insbesondere werden in C++ statt Funktionszeigern auch oft Funktoren verwendet, welche aber erst später vorgestellt werden. Vorweggenommen sei an dieser Stelle bereits, dass ein Funktor etwas mehr Schreibaufwand benötigt als ein Funktionszeiger, dafür aber auch einiges kann, was mit einfachen Funktionszeigern nicht möglich ist.
Im Zusammenhang mit Klassen werden uns weitere Arten von Zeigern begegnen:
- Zeiger auf statische Datenelemente
- Zeiger auf Elementfunktionen.
Beide werden Sie zu einem späteren Zeitpunkt noch kennen lernen. Auch im Abschnitt „Speicherverwaltung“ werden Ihnen Zeiger noch einmal begegnen.
Referenzen [Bearbeiten]
Grundlagen zu Referenzen
[Bearbeiten]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 a = 10; // eine Variable
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: 10 b: 20 r: 10
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.
Anwendung von Referenzen
[Bearbeiten]Vielleicht haben Sie sich bereits gefragt, wofür Referenzen nun eigentlich gut sind, schließlich könnte man ja auch einfach die Originalvariable benutzen.
Selbstdefinierte Referenzen
[Bearbeiten]Referenzen bieten in einigen Anwendungsfällen eine Beschleunigung und bessere Lesbarkeit der Quelltexte. Sie müssen initialisiert werden.
#include <iostream>
int main(){
unsigned int const x = 2, y = 3, z = 4;
unsigned int zahlen_array[x][y][z] = {
{ { 0, 1, 2, 3}, { 4, 5, 6, 7}, { 8, 9, 10, 11} },
{ {12, 13, 14, 15}, {16, 17, 18, 19}, {20, 21, 22, 23} }
};
for(unsigned int a = 0; a < x; ++a){
for(unsigned int b = 0; b < y; ++b){
for(unsigned int c = 0; c < z; ++c){
// ref wird als Referenz auf zahlen_array[a][b][c] initialisiert
unsigned int& ref = zahlen_array[a][b][c];
// entspricht 'zahlen_array[a][b][c] *= 2;'
ref *= 2;
// entspricht 'zahlen_array[a][b][c] += a * b * c;'
ref += a * b * c;
// entspricht 'std::cout << zahlen_array[a][b][c] << std::endl;'
std::cout << ref << ", ";
}
std::cout << std::endl;
}
std::cout << std::endl;
}
}
0, 2, 4, 6,
8, 10, 12, 14,
16, 18, 20, 22,
24, 26, 28, 30,
32, 35, 38, 41,
40, 44, 48, 52,
Bei mehrfacher Verwendung, kann eine Referenz Ihnen viel Tipparbeit ersparen und vor allem erhöht sie die Übersichtlichkeit des Quellcodes. Außerdem kann diese Vorgehensweise die Performance verbessern, da der Zugriff auf Daten in Klassen oder Feldern durch eine Referenzdefinition vereinfacht wird. Bei obigem Beispiel wird zur Laufzeit, im Arbeitsspeicher, bei jeder Verwendung von zahlen_array[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-Containerklassen und verschachtelten Methoden- und Operatoraufrufen erfolgen.
Dies alles können Sie dem Prozessor nicht ersparen. Sie können aber dafür sorgen, dass es, pro Schritt, nur einmal vorkommt. Die Verwendung einer Referenz ergibt daher Sinn, sobald Sie zahlen_array[a][b][c]
mehr als einmal verwenden. Eine Referenz ist intern meist mit einem Zeiger implementiert. Es sei allerdings darauf hingewiesen, dass der Compiler in vielen Fällen diese Optimierung auch selbst vornehmen kann, daher ist ein Performancegewinn nicht zwingend vorhanden.
Call-By-Reference
[Bearbeiten]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 im Kapitel über Zeiger vorgestellte Funktion swap()
mit Referenzen:
Diese Funktion bietet gegenüber der Zeigervariante zwei 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.
const
-Referenzen
[Bearbeiten]Referenzen auf konstante Variablen spielen in C++ eine besondere Rolle. Eine Funktion, die eine Variable übernimmt, kann genauso gut auch eine Referenz auf eine konstante Variable ü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.
Referenzen als Rückgabetyp
[Bearbeiten]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.
Felder [Bearbeiten]
In C++ lassen sich mehrere Variablen desselben Typs zu einem Array (im Deutschen bisweilen auch Datenfeld oder Vektor genannt, selten auch Matrix, Tabelle, Liste) 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 (=Anzahl der Elemente) anzugeben. Folgende Möglichkeiten stehen zum Anlegen eines Array zur Verfügung:
// Array mit 10 Elementen vom Typ 'int'; Array-Name ist 'feld'.
int feld[10]; // Anlegen ohne Initialisierung
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[4] = 88;
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;
}
Beachten Sie, dass der Compiler keine Indexprüfung durchführt. Wenn Sie ein nicht vorhandenes Element, z.B. feld[297]
im Programm verwenden, kann Ihr Programm bei einigen Durchläufen unerwartet abstürzen. Zugriffe über Arraygrenzen erzeugen undefiniertes Verhalten. In modernen Desktop-Betriebssystemen kann das Betriebssystem einige dieser Bereichsüberschreitungen abfangen und das Programm abbrechen („segmentation fault“). Manchmal überschreibt man auch einfach nur den eigenen Speicherbereich in anderen Variablen, was zu schwer zu findenden Bugs führt.
Zeiger und Arrays
[Bearbeiten]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.
Mehrere Dimensionen
[Bearbeiten]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]; // Ohne Initialisierung
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 mehrdimensionale 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 bei der Initialisierung nötig, die Größe aller Dimensionen anzugeben.
Genaugenommen wird eigentlich ein (1-dimensionales) Array von (1-dimensionalen) 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:
#include <iostream>
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";
}
Größen
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)
).
Beachten Sie, dass auch für mehrdimensionale Arrays keine Indexprüfung erfolgt. Greifen Sie nicht auf ein Element zu, dessen Grenzen außerhalb des Arrays liegen. Beim Array int[12][7][9]
können Sie auf die Elemente [0..11][0..6][0..8] zugreifen. Die Zählung beginnt also auch hier immer bei 0 und endet dementsprechend 1 unterhalb der Dimensionsgröße.
Arrays und Funktionen
[Bearbeiten]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[]);
void funktion(int parameter[5]);
void funktion(int parameter[76]);
Jeder dieser Prototypen ist gleichwertig. Die Größenangaben beim dritten und vierten 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 Ihr Array als einfachen Zeiger übernehmen. Eine eventuelle Angabe der Elementanzahl beim Funktionsparameter wird vom Compiler komplett ignoriert.
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 natürlich auch ein Zeiger auf ein Array zulässig. Die folgenden Prototypen zeigen, wie die Syntax bei mehrdimensionalen Arrays aussieht.
void funktion(int (*parameter)[8]);
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
. Auf 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.
Seit C++11 gibt es den Header <array>
in welchem eine gleichnamige Datenstruktur definiert ist. Man könnte in diesem Zusammenhang vielleicht von C++-Arrays sprechen. Sie werden verwendet wie gewöhnliche C-Arrays, haben aber eine andere Deklaration. Ein Array mit 8 Elemente vom Typ int
wird wie folgt definiert:
Für mehrdimensionale Arrays kann man statt int
als Datentyp wieder ein Array angeben. Die Deklaration ist also etwas aufwendiger als bei C-Arrays, dafür kann ein solches C++-Array aber ganz normal an Funktionen übergeben werden.
void funktion(std::array< int, 8 > parameter);
void funktion(std::array< int, 8 > const& parameter);
void funktion(std::array< std::array< int, 8 >, 4 > const& parameter);
Im ersten Fall wird eine Kopie übergeben, im zweiten eine Referenz auf eine konstantes Array. In beiden Fällen können nur C++-Arrays mit genau 8 Elementen übergeben werden. Die dritte Zeile zeigt die Übergabe eines zweidimensionalen Arrays, auch hier müssen die beiden Dimensionen (4 und 8) natürlich exakt übereinstimmen.
Ein weiterer Vorteil ist, dass C++-Array-Objekte eine Funktion names size()
haben, welche die Anzahl der Elemente zurückgibt.
Lesen komplexer Datentypen
[Bearbeiten]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.
Als einfache Regel zum Lesen von solchen komplexeren Datentypen können Sie sich merken:
- 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 i; // i ist ein int
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 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!
Wenn Sie in Ihren Programmen solche Strukturen deklarieren/anlegen, denken Sie daran, dass ein kurzes Kommentar es jedermann viel einfacher macht, die Struktur zu verstehen!
Zeichenketten [Bearbeiten]
Einleitung
[Bearbeiten]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++-Standardbibliothek enthä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
.
Wie entsteht ein string
-Objekt?
[Bearbeiten]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 char
s lang, 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:
#include <string>
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 Anführungszeichen 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:
#include <string>
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 des Konstruktors 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.
string
und andere Datentypen
[Bearbeiten]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 <iostream>
#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 <iostream>
#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 <iostream>
#include <string>
int main() {
std::string zeichenkette;
// Liest bis zum nächsten y
std::getline(std::cin, zeichenkette, 'y');
std::cout << zeichenkette;
}
Zuweisen und Verketten
[Bearbeiten]Genau wie die Basisdatentypen, lassen sich auch string
s einander zuweisen. Für die Verkettung von string
s wird der +
-Operator benutzt und das Anhängen einer Zeichenkette ist mit +=
möglich.
#include <iostream>
#include <string>
int main() {
std::string string1, string2, string3;
string1 = "ich bin ";
string2 = "klug";
string3 = string1 + string2;
std::cout << string3 << std::endl;
string3 += " - " + string1 + "schön";
std::cout << string3 << std::endl;
std::cout << string1 + "schön und" + string2 << std::endl;
}
ich bin klug
ich bin klug - ich bin schön
ich bin schön und klug
Spielen Sie einfach ein wenig mit den Operatoren, um den Umgang mit ihnen zu lernen.
Nützliche Methoden
[Bearbeiten]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 <iostream>
#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 bin ganz lang!
Ich kurz!
Ich kurz!
Ich bin ganz lang!
Zeichenzugriff
[Bearbeiten]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 liegt.
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 <iostream>
#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
b
terminate called after throwing an instance of 'std::out_of_range'
what(): basic_string::at
Abgebrochen
Die Fehlerausgabe kann bei Ihrem Compiler anders aussehen.
Beachten Sie beim Zugriff, dass das erste Zeichen den Index 0 hat. Das letzte Zeichen hat demzufolge den Index zeichenkette.length() - 1
.
Manipulation
[Bearbeiten]Suchen
[Bearbeiten]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()
.
Löschen
[Bearbeiten]Mit der Methode erase()
können Zeichen im String gelöscht werden. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter die Anzahl der Zeichen festlegen. Wird die Methode nur mit dem Startwert aufgerufen, löscht sie alle Zeichen ab dieser Position.
Ersetzen
[Bearbeiten]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 + Laenge
ersetzt.
Einfügen
[Bearbeiten]Die Methode insert()
erlaubt es Ihnen, einen String an einer bestimmten Stelle einzufügen.
Kopieren
[Bearbeiten]Mit der Methode substr()
kann man sich einen Zeichenketten-Teil zurückgeben lassen. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter noch die Anzahl der Zeichen festlegen. Wird die Methode nur mit dem Startwert aufgerufen, gibt sie alle Zeichen ab dieser Position zurück.
Das -0
soll verdeutlichen, dass der Startwert abgezogen werden muss, an dieser Stelle ist es natürlich überflüssig.
Vergleiche
[Bearbeiten]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.
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
#include <string>
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')
}
Zahl zu string
und umgekehrt
[Bearbeiten]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()
undsprintf()
- 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 <iostream> // Standard-Ein-/Ausgabe
#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 <iostream> // Standard-Ein-/Ausgabe
#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 nun eventuell in den Sinn, einfach eine double
-Variable von der Funktion übernehmen zu lassen.
#include <iostream> // Standard-Ein-/Ausgabe
#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;
}
19
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 übergebenen 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 <iostream> // Standard-Ein-/Ausgabe
#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;
}
19
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 <iostream> // Standard-Ein-/Ausgabe
#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
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.
C-Strings
[Bearbeiten]Wie bereits erwähnt, handelt es sich bei einem C-String um ein Array von char
s. 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-Bibliothek 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 [Bearbeiten]
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“.
#include
[Bearbeiten]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, wodurch 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 im aktuellen Verzeichnis und dann in den Standardpfaden des Compilers
#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 aktuellen 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.
#define
und #undef
[Bearbeiten]#define
belegt eine Textsubstitution mit dem angegebenen Wert, z.B.:
Makros sind auch möglich, z.B.:
#define ADD_DREI(x, y, z) x + y + z + 3
int d(1), e(20), f(4), p(0);
p = ADD_DREI(d, e, f); // p = d + e + f + 3;
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_NOK(x, y) x*y
#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 fast 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ät Ihres Programms zu erweitern.
Einer statischen, konstanten Definition oder Templatefunktionen ist immer der Vorzug zu geben.
#
[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:
#define MAKE_CLASS( NAME ) \
class NAME \
{ \
public: \
static void Init##NAME() {}; \
};
...
MAKE_CLASS( MyClass )
...
MyClass::InitMyClass();
#if
, #ifdef
, #ifndef
, #else
, #elif
und #endif
[Bearbeiten]Direktiven zur bedingten Übersetzung, d.h. Programmteile werden entweder übersetzt oder ignoriert.
#if ''Ausdruck1''
// 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.
#include "compiler-config-all.hpp"
#ifdef WIN32
#pragma pack(4)
#elif USING_GCC
#pragma align=4
#elif FLAG_XY_COMPILER_SUITE
#pragma ausrichtung(byte4)
#endif
#error
und #warning
[Bearbeiten]#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:
#line
[Bearbeiten]Setzt den Compiler-internen Zeilenzähler auf den angegebenen Wert, z.B.:
#pragma
[Bearbeiten]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:
Vordefinierte Präprozessor-Variablen
[Bearbeiten]__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 [Bearbeiten]
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.
Was ist eine Headerdatei?
[Bearbeiten]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 entstandenen 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.
Namenskonventionen
[Bearbeiten]Ü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.
Schutz vor Mehrfacheinbindung
[Bearbeiten]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.
Das Symbol wird üblicherweise aus dem Dateinamen des Headers abgeleitet. In diesem Fall wurde der Punkt durch einen Unterstrich ersetzt und ein weiterer 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.
Sie können sich eine beliebige Methode zum Generieren von Symbolen ausdenken. Da es schnell passieren kann, dass Sie eine Bibliothek verwenden, in der Headerdateien enthalten sind, welche Sie auch in ihrem eigenen Projekt bereits verwenden, ist es jedoch empfehlenswert an dieser Stelle nicht mit Zeichen zu geizen. Eine mögliche Variante ist beispielsweise neben dem Dateinamen auch noch den Projektnamen zu verwenden und eventuell auch noch eine feste Zeichenfolge, um das Symbol unmissverständlich als Mehrfacheinbindungsschutz zu kennzeichnen. Das könnte dann etwa so aussehen:
_Projektname_Dateiname_Dateiendung_INCLUDED_
Mehr Informationen über Präprozessor-Anweisungen finden Sie im Kapitel Vorarbeiter des Compilers.
Inline-Funktionen
[Bearbeiten]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 Funktionsaufruf direkt durch den Funktionsrumpf 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.
Werden Klassenmethoden bereits bei der Deklaration definiert (also mit einem Körper versehen), so werden sie automatisch als inline
verwendet, auch ohne daß der Bezeichner angegeben wird. Dies verbessert die Ausführung von einfachen Datenzugriffen erheblich. Alternativ zur Definition bei der Deklaration können die Methoden auch explizit als "inline" deklariert und an einem anderen Ort (z.B. weiter unten in der Deklarationsdatei) ausgeführt werden. Das kann die Übersichtlichkeit verbessern.
Inline-Funktionen dürfen auch in cpp-Dateien deklariert und definiert werden. Allerdings können sie dann auch nur innerhalb der Objektdatei, die aus der cpp-Datei erzeugt wird, ge-inlinet werden und das ist in aller Regel nicht beabsichtigt.
Zusammenfassung [Bearbeiten]
Funktionen
[Bearbeiten]Deklaration
[Bearbeiten]Eine Funktion kann in C++ beliebig oft (übereinstimmend) deklariert werden. Zur Deklaration gehören der Funktionsname, der Rückgabetyp und die Parametertypen. Es ist gestattet den Parametern in der Deklaration Namen zu geben, diese werden vom Compiler jedoch vollständig ignoriert, sie dienen ausschließlich als Information für den Programmierer.
«Rückgabetyp» «Funktionsname»(«Parametertypenliste»);
Parametertypenliste: «Parametertyp» »Parametername«, «Parametertypenliste»
Parametertypenliste: «Leere Liste»
Soll eine Funktion keinen Wert zurückgeben, lautet der Rückgabetyp formal void
.
Definition
[Bearbeiten]Eine Definition ist immer auch eine Deklaration. Eine Definition darf nur einmal im gesamten Programm gemacht werden. Später wird mit dem Schlüsselwort inline
die einzige Ausnahme von dieser Regel eingeführt, solche Funktionen dürfen mehrfach übereinstimmend definiert werden.
«Rückgabetyp» «Funktionsname»(«Parametertypenliste»){
«Anweisungen»
}
Aufruf
[Bearbeiten]«Funktionsname»(«Parameterliste»)
Parameterliste: «Ausdruck», «Parameterliste»
Parameterliste: «Leere Liste»
Beachten Sie für parameterlose Funktionen unbedingt, dass ein Weglassen der Klammern die Funktionsadresse liefert anstatt die Funktion aufruft.
Funktionsname() // Ruft eine Funktion auf
Funktionsname // Liefert eine Funktionsadresse
&Funktionsname // Liefert eine Funktionsadresse (wie bei Variable)
C++ erlaubt Rekursion, wobei die Rekursionstiefe typischerweise vom Compiler beschränkt wird.
Überladen
[Bearbeiten]Es ist möglich mehrere Funktionen mit gleichem Namen aber unterschiedlicher Parameterliste (hinsichtlich der Länge und/oder der Typen) zu definieren. Der Compiler ermittelt dann beim Aufruf der Funktion aus dem Namen und den übergebenen Parametern welche Funktion konkret gemeint ist. Dies bezeichnet man als Überladung einer Funktion.
Standardparameter
[Bearbeiten]Für jeden Parameter einer Funktion kann in der Funktionsdeklaration ein Standardwert angegeben werden. Jeder Parameter rechts von einem Parameter mit Standardwert muss ebenfalls einen Standardwert besitzen. Standardparameter dürfen nur in genau einer Deklaration angegeben werden.
Beliebige Parameteranzahl
[Bearbeiten]In C verwendete man gelegentlich Funktionen für die als letzter Parameter ...
angegeben war. Solchen Funktionen kann man beliebig viele Parameter übergeben. Nach dem Einbinden der Headerdatei cstdarg
kann innerhalb der Funktion mit Hilfe der va_...
-Makros auf die übergebenen Parameter zugegriffen werden. Diese Technik ist typunsicher und veraltet, sie wird mit dem kommenden C++ Standard (derzeit als C++11 bekannt) überflüssig, da dieser variable Parameterlisten mittels „variadic templates“ erlaubt.
Aufzählungen
[Bearbeiten]Enumerationen werden mit enum
eingeleitet und definieren einen Typ, der mehrere ganzzahlige Konstanten (Enumeratoren) enthält. Die Werte der Konstanten können explizit vergeben werden. Ist kein Wert angegeben zählt der Compiler von der letzten vergebenen Konstante aus hoch, im Falle der ersten Konstante nimmt der Compiler 0 als Wert. Negative Werte sind zulässig. Der Wertebereich einer Enumeration ist so festgelegt, dass die kleinste mögliche Zweierpotenz größer dem größten vergebene Wert ist. Falls negative Indizes vergeben wurden, reichen die Werte von , andernfalls ist er . Enumeratoren können implizit in Ganzzahlen umgewandelt werden. Ganzzahlen können explizit in einen Wert vom Typ einer Enumeration umgewandelt werden. Liegt der Ganzzahlwert hierbei außerhalb des Wertebereichs der Enumeration ist das Verhalten undefiniert!
enum «Name»{
«Enumerator» »= Compile-Time-konstante-Ganzzahl«,
...
};
// explizite Umwandlung aus einer Ganzzahl
«Name»(«Ganzzahl»)
Zeiger
[Bearbeiten]Zeiger werden mit einem Stern *
im Typ angegeben, wobei der Typ, auf den der Zeiger zeigt, immer links vom Stern steht. Bei der gleichzeitigen Deklaration mehrerer Variablen muss der Stern vor jedem Variablennamen angegeben werden, der ein Zeiger sein soll. Gleichzeitige Deklaration mehrerer Variablen sollte jedoch aus Gründen der Übersicht allgemein vermieden werden.
«Typ»* «Variablenname»;
«Typ» *«Variablenname», *«Variablenname»; // Möglich aber unübersichtlich
Auf die Daten, auf die der Zeiger zeigt, wird mit Hilfe des Dereferenzierungsoperators *
zugegriffen. Die Adresse einer Funktion wird über den Adressoperator &
ermittelt. Beide Operatoren müssen links von ihrem Operanden stehen.
*«Zeiger» // Wert zugreifen
&«Variable/Funktion» // Adresse zugreifen
Schlüsselwort const
[Bearbeiten]Das Schlüsselwort const
kann für Zeiger sowohl auf den dereferenzierten Wert angewendet werden, als auch auf die Zeigervariable selbst. const
bezieht sich wie immer auf das, was direkt links davon steht.
Zeigerarithmetik
[Bearbeiten]Für Zeiger sind die Addition und die Subtraktion definiert. Die Größe des Datentyps auf den gezeigt wird entscheidet über die Schrittweite. Wird also zu einem Zeiger auf long
5 addiert, so erhöht sich die Adresse um 5*sizeof(long)
Byte.
Zeiger auf void
[Bearbeiten]Zeiger auf void
gelten allgemein als veraltet (C), werden aber in begründeten Einzelfällen noch eingesetzt. Ihre Schrittweite in der Zeigerarithmetik beträgt 1 Byte und sie lassen sich implizit aus allen Zeigertypen und in alle Zeigertypen umwandeln. Es ist nicht möglich einen void
-Zeiger zu dereferenzieren.
Funktionszeiger
[Bearbeiten]Die Adresse einer Funktion lässt sich über den Funktionsnamen ermitteln, optional kann diesem auch noch der Adressoperator (&
) vorangestellt werden (empfohlen). Ein Funktionszeiger wird äquivalent zum Prototypen des gewünschten Funktionstypes definiert, wobei an Stelle des Funktionsnamens der Variablenname in runden Klammern und hinter einem Stern angegeben wird.
Referenzen
[Bearbeiten]Referenzen sind Aliasnamen für andere Variablen. Sie müssen als solche immer mit einer anderen Variable initialisiert werden. Ihre Deklaration erfolgt durch ein &
rechts vom Typ, äquivalent zum *
bei Zeigern. Es ist nicht möglich und auch nicht sinnvoll Referenzen auf Referenzen zu deklarieren. Intern sind Referenzen meist über Zeiger realisiert.
Felder
[Bearbeiten]Felder oder Arrays sind mehrere Variablen des gleichen Typs, die direkt hintereinander im Speicher stehen. Folglich lassen sich die einzelnen Elemente mithilfe der Zeigerarithmetik ansprechen, es gibt jedoch auch einen Indexoperator, welcher einen intuitiven Zugriff erlaubt. Die Syntax für Felder lautet:
«Typ» «Variablenname»[«positiver ganzzahliger compilezeitkonstanter Wert»]; // Deklaration
*(«Variablenname» + «ganzzahliger Wert») // Elementzugriff mittels Zeigerarithmetik
«Variablenname»[«ganzzahliger Wert»] // Elementzugriff mittels Indexoperator
Zeiger und Felder
[Bearbeiten]Analog zu Funktionen ist der Zugriff auf die Adresse des Arrays über den Variablennamen möglich, wobei die Adresse der Adresse des ersten Array-Elements entspricht. Entsprechend ist es nicht möglich, Arrays an Funktionen zu übergeben, stattdessen kann nur die Array-Adresse übergeben werden. Der Compiler hat innerhalb der Funktion aber nicht mehr die Möglichkeit, zwischen einem Zeiger auf eine gewöhnliche Variable und einer Array-Variable zu unterscheiden. Der Indexoperator ist eine alternative Schreibweise für die Zeiger-Arithmetik, er funktioniert somit für alle Adressen.
Mehrdimensionale Felder
[Bearbeiten]Mehrdimensionale Arrays werden als Arrays von Arrays deklariert und benutzt.
Zeichenketten
[Bearbeiten]Strings sind in C++ kein Basisdatentyp sondern eine Klasse aus der Standardbibliothek. Die Klasse kapselt die aus C stammenden nullterminierten Strings, welche kaum intuitiv zu benutzen waren. Klassen bestehen aus Variablen und Methoden, wobei Methoden nur ein Wort für Funktionen in Klassen ist. Methoden werden aufgerufen, indem der Variablenname ihrer Stringvariable gefolgt von einem Punkt und dem Methodenaufruf (= Funktionsaufruf) geschrieben wird. Außerdem sind einige nützliche Operatoren für die Stringklasse überladen. Im folgenden werden die wichtigsten Methoden und Operatoren kurz vorgestellt. Um Strings benutzen zu können müssen Sie die Headerdatei string
einbinden. Der Datentyp (= Klassenname) heißt std::string
, wobei das std
natürlich der Standardnamensraum ist.
C-Strings sind übrigens char
-Arrays, wobei das Stringende durch ein Nullzeichen ('\0') definiert wird. Ein Array aus N char
-Elementen kann also N-1 Zeichen speichern. Um Strings auf einfache Weise im Programmcode schreiben zu können, wurde in C ein Literal für derartige char
-Arrays eingeführt. Der eigentliche String wird dabei in doppelte Anführungszeichen gesetzt. Diese Literale werden in C++ natürlich auch noch verwendet, jedoch werden sie hier eben an std::string
-Variablen übergeben, daher ist es nicht mehr nötig direkt mit den Arrays zu arbeiten.
Ein-/Ausgabe
[Bearbeiten]Die Ein-/Ausgabe von Strings funktioniert genau wie bei den Basisdatentypen. Bei der Eingabe ist zu beachten, dass der Eingabeoperator (wie bei den Basisdatentypen) Leerraumzeichen überliest und anschließend alle Zeichen bis zum nächsten Leerraumzeichen einliest. Entsprechend ist es auf diese Weise nicht möglich einen String einzulesen, der Leerzeichen enthält. Hierfür gibt es die Funktion getline
, die bis zum nächsten Newline-Zeichen einliest.
#include <string>
std::string var;
std::cin >> var; // Eingabe eines Strings ohne Leerzeichen
std::getline(std::cin, var); // Eingabe einer kompletten Zeile
std::cout << var; // Ausgabe
Zuweisen
[Bearbeiten]Es ist möglich Stringliterale und andere Stringvariablen per Zuweisungsoperator (=
) in eine Stringvariable zu schreiben.
Verketten
[Bearbeiten]Strings lassen sich mithilfe des Plusoperator +
verketten, jedoch beachten Sie, dass mindestens ein Operand vom Typ std::string
sein muss. Die Verkettung zweier Stringliterale ist somit nicht möglich, für gewöhnlich aber auch nicht nötig. Natürlich ist auch der +=
-Operator für das Hinzufügen eines Strings zu einem anderen definiert.
Zeichenzugriff
[Bearbeiten]Mithilfe des Zugriffsoperators ([]
) ist, analog zu Arrays, der Zugriff auf die einzelnen Zeichen des Strings möglich. Die Anzahl der aktuell enthaltenen Zeichen liefern die Methoden length()
und size()
. Welche Funktion Sie verwenden ist reine Geschmackssache. length()
drückt gut aus, was abgefragt wird, size()
ist dafür in allen Containerklassen definiert.
Vergleiche
[Bearbeiten]Die sechs Vergleichsoperatoren sind für Strings definiert, wobei auch hier mindestens ein Operand vom Typ std::string
sein muss. Der Vergleich wird elementweise auf Basis der Zeichen ausgeführt.
Suchen, Ersetzen, Einfügen, Kopieren, Löschen
[Bearbeiten]Die folgende nützliche Methoden sind in der Stringklasse enthalten:
find(std::string search, std::size_t pos = 0)
sucht nachsearch
ab der Positionpos
.replace(std::size_t from, std::size_t to, std::string replacement)
ersetzt den Text zwischen (inklusive)from
bis (exklusive)to
durchreplacement
.insert(std::size_t before, std::string insertion)
fügt den Stringinsertion
vor der Positionbefore
ein.substr(std::size_t pos = 0, std::size_t n = std::string::npos)
extrahiert ab der Positionpos
n
Zeichen und gibt diese als neuen String zurück.erase(std::size_t pos = 0, std::size_t n = std::string::npos)
löscht ab der Positionpos
n
Zeichen.
Umwandlung: Zahl – String
[Bearbeiten]Definieren Sie zuerst zwei Templates. Wie Templates funktionieren erfahren Sie in einem späteren Kapitel.
#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
}
template <typename Typ>
Typ stringTo(std::string str) {
std::istringstream strin; // Unser Eingabe-Stream
Typ wert;
strin.str(str); // Streaminhalt mit String-Variable füllen
strin >> wert; // Variable von Eingabe-Stream einlesen
return wert;
}
// Anwendung:
std::string var = toString(5.4);
double var = stringTo<double>("6.6666");
Präprozessor
[Bearbeiten]Der Präprozessor läuft vor dem Compiler und verarbeitet alle Zeilen die mit einem Doppelkreuz (#
) beginnen. Ein solcher Befehl kann auf mehrere Zeilen ausgedehnt werden, indem das Zeilenende mit einem vorangestellten Backslash (\
) maskiert wird.
#include
fügt die angegebene Datei an dieser Stelle ein. Wird die Datei in spitzen Klammern angegeben, so wird in den Standardpfaden der Umgebung nach ihr gesucht. Wird die Datei in doppelten Anführungszeichen angegeben, so wird zusätzlich zunächst im aktuellen Verzeichnis gesucht.#define
definiert eine Präprozessorvariable bzw. ein Makro.#undef
löscht eine definierte Präprozessorvariable bzw. ein Makro.#pragma
führt eine Compilerspezifische Anweisung aus, kennt der Compiler die Anweisung nicht werden solche Zeilen ignoriert.#if
,#ifdef
,#ifndef
,#else
,#elif
und#endif
erlauben bedingten Code. Dies wird insbesondere zum Schutz vor Mehrfachinkludierung von Headerdateien verwendet.#error
bzw.#warning
lassen den Compiler einen Fehler bzw. eine Warnung ausgeben.
Vordefinierte Präprozessor-Variablen
[Bearbeiten]__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
[Bearbeiten]Deklarationen werden üblicherweise in Headerdateien ausgelagert, welche dann mittels #include
eingebunden werden. Auf diese Weise können mehrere Quelldateien die gleichen Deklarationen nutzen, während die Definition vom Compiler nur in einer Objektdatei erzeugt wird.