C++/Tutorial: 9. Pointer
Eine Menge Leute behaupten C++ sei eine unsichere Sprache und sie haben zumindest in soweit Recht, dass es gewisse Teile in C++ gibt, bei denen man höllisch aufpassen muss, damit das Programm nicht Amok läuft und des Programmierers Hamster frisst. Meistens, wenn man von diesen unsicheren Teilen spricht sind irgendwo Pointer im Spiel, was diese sind und dass diese nicht so schlimm sind, wenn man weiß worauf man achten muss und wann man sie einsetzen sollte und wann es sicherere Alternativen gibt möchte ich in diesem Kapitel zeigen.
Inhaltsverzeichnis
Was sind Pointer?
Technisch gesprochen sind Pointer, oder zu Deutsch Zeiger nichts weiter als ein Datentyp für Variablen, die Speicheraddressen beinhalten. Das im Hinterkopf zu behalten mag eine Hilfe sein, wenn wir weiter von Pointern sprechen, aber wirklich erklären wozu Pointer nun gut sind und was wir mit ihnen machen tut es nicht.
Wenn uns die technische Erklärung nicht weiter bringt versuchen wir mal eine etwas abstraktere Definition: Pointer sind Verweise auf Daten. Das klingt zwar schon besser, aber wirklich was anfangen können wir damit auch noch nicht, also nehmen wir es einmal auseinander. Wenn wir von Daten sprechen fällt die Rede wohl irgendwann auf Variablen, denn Variablen sind schließlich Daten, die wir gespeichert haben, also schauen wir uns die Variablen noch einmal genauer an. Wenn wir eine Variable deklarieren, wird sie von C++ mit einer bestimmten Stelle im Speicher verbunden. Legen wir z.B. ein int a(0); an werden irgendwo im Speicher ein paar Bytes mit Daten belegt und wenn wir fortan irgendwo im Quellcode die Variable a verwenden liest oder schreibt C++ die Daten an dieser Stelle im Speicher und interpretiert sie dem Datentyp int gemäß.
Soweit ist uns noch alles bekannt, was haben also Pointer damit zu tun? Die Verbindung der Variable a und dem dahinter liegendem Speicher ist sehr eng, die Variable ist der Speicher, Pointer bieten hier eine lockerere Verbindung. Pointer können auf beliebige Stellen im Speicher verweisen, so wie Variablen es automatisch tun, aber auf welche Stellen im Speicher sie verweisen, das können wir selbst bestimmen und auch verändern.
Wenn wir das verinnerlicht haben, haben wir nun eventuell eine vage Idee, was ein Pointer sein könnte, nun können wir diese Idee zum richtigen Verständnis dieser seltsamen Konstrukte führen und lernen wie und wann man sie anwendet. Nun haben wir gesagt Pointer verweisen, "zeigen" auf eine Stelle im Speicher, also wollen wir das mal ausprobieren, dazu benötigen wir eine Stelle im Speicher. Dem aufmerksamen Leser mag nun schon aufgefallen sein, dass wir auch das bereits in einer Form hatten, wir haben gesagt Variablen tun nichts weiter als Daten an bestimmten Stellen im Speicher abzulegen nun brauchen wir also nur noch ein Mittel um an diese Stellen heran zu kommen und das ist der sogenannte Addressoperator &. Die `Stellen im Speicher` werden durch Adressen .. nunja adressiert, jedes Byte im Speicher hat eine Addresse, mit der wir es ansprechen können und damit schließt sich der Kreis zu unserer ersten, technischen Definition, denn ein Pointer verweist auf eine Stelle im Speicher und dazu muss er nichts weiter tun als die Addresse dieser Stelle zu speichern. Nun wollen wir das ganze erst einmal ausprobieren und die Adresse des Speichers einer Variablen a ausgeben:
int a(3); cout << &a << endl;
Die Ausgabe sieht nun so ähnlich aus wie:
0xbfbc03ac
Also eine Zahl(hexadezimal ausgegeben). Diese Zahl wird natürlich jedes Mal anders sein, wenn wir das Programm starten, da C++ die Variable nicht immer an der selben Stelle im Speicher ablegt, aber meistens interessiert uns die konkrete Adresse sowieso nicht, sondern wir wollen damit arbeiten.
Zum Beispiel wollen wir einen Pointer erstellen, der auf genau diese Adresse zeigt und somit auf den Speicher dieser Variable verweist. Wie bereits zu Beginn erwähnt handelt es sich bei Pointern um Datentypen. Nun eigentlich habe ich gesagt, dass es sich um einen Datentyp handelt, aber das war gelogen, denn der Datentyp eines Pointers enthält Informationen über die Daten an der Stelle im Speicher auf die der Pointer zeigt. Okay, das war kompliziert, also was heißt das? Variablen haben Datentypen, wie z.B. int damit C++ weiß wie die Daten im Speicher zu interpretieren sind. Ein Pointer speichert diese Informationen ebenfalls und zwar in seinem Typ, wir brauchen also einen Typ für Zeiger die auf ints zeigen, sowie wir einen anderen Typ brauchen für Zeiger, die auf chars zeigen. In dem Fall interessieren wir uns für die Adresse einer Integervariablen, die Daten an dieser Adresse sind also logischerweise als Integer zu interpretieren und deswegen brauchen wie einen Zeiger, der auf int zeigt und diese haben den Typ int*. So einfach ist das Prinzip schon, wir nehmen den Datentyp auf den wir zeigen wollen und hängen ein * hinten dran und schon haben wir einen Pointer darauf.
Gut, genug der Theorie, probieren wirs aus, wir wollen einen Zeiger, der auf den Speicher der Variable a zeigt und dann den Inhalt des Speichers ausgeben.
int a(3); int* a_ptr(&a); //weise a_ptr die Adresse von a zu. cout << a_ptr << endl;
ups, nun haben wir wieder eine Ausgabe wie 0xbfbc03ac. Wenn wir drüber nachdenken ist das auch logisch, a_ptr ist schließlich eine normale Variable, die eben diese Adresse beinhaltet, wir geben also nur die Adresse aus. Wollen wir den Inhalt des Speichers an dieser Adresse müssen wir das bei Pointern explizit angeben, das nennt man dereferenzieren und geschieht mit dem Dereferenzierungsoperator *, der das Gegenstück zum Adressoperator bildet.
Dereferenzieren wir a_ptr, dann bedeuted dies, dass wir auf den Inhalt des Speichers zugreifen, der an der Adresse liegt, die in a_ptr gespeichert ist. Oder einfach gesprochen: wir greifen auf den Wert von a zu:
int a(3); int* a_ptr(&a); cout << *a_ptr << endl;
und nun haben wir unsere gewünschte Ausgabe: 0.
Wir können natürlich auch schreibend auf den Speicher zugreifen:
int a(3); int* a_ptr(&a); *a_ptr = 5; cout << a << endl;
Da a_ptr auf den Speicher der Variable a zeigt(oder kürzer gesagt: auf die Variable a zeigt) und wir den Speicher auf den a_ptr zeigt verändern, verändern wir damit auch a. Wenn wir a dann ausgeben, kriegen wir als Ausgabe "5".
Das ganze erinnert bisher noch ein wenig an das, was wir bereits von Referenzen kennen, nur mit lästigen &s und *s dazu, aber wir können Pointer auch nachträglich auf andere Dinge zeigen lassen. Schaut mal ob ihr noch durchblickt:
int a(3); int b(6); int* ptr1(&a); //ptr1 zeigt auf a int* ptr2(ptr1); //ptr2 wird als Adresse der Wert von ptr1 zugewiesen(da ptr1 ja auch nur eine Variable ist), ptr2 zeigt also auch auf a *ptr2 = 5; //a ist nun 5 ptr2 = &b; //ptr2 wird nun eine neue Adresse zugewiesen, es zeigt nun auf b *ptr2 = 4; //b ist nun 4 cout << a << "," << b << endl; // 5,4
Ein weiterer Kernaspekt von Pointern ist, dass sie auf nichts zeigen müssen. Pointer können ungültig sein, das wird dadurch signalisiert, dass sie auf die Addresse 0 zeigen.
int* a_ptr(0); //zeigt auf "nichts"
Diese Möglichkeit birgt aber zugleich eine Unsicherheit, eines der genannten Sicherheitsrisiken, denn was passiert wenn wir a_ptr nun dereferenzieren? Vermutlich stürzt das Programm ab und wir kriegen eine kryptische Fehlermeldung, wie z.B. "Segmentation Fault", aber eigentlich sagt der Standard nichts dazu, sollte der Computer also zufällig mit dem Pentagon vernetzt sein passt besser auf welche Pointer ihr dereferenziert, denn ansonsten haben wir eine atomare Katastrophe am Hals.
Es existiert im übrigen eine Konstante für 0, die speziell für den Einsatz von Pointern gedacht ist und zwar ist das NULL. In C++ besteht kein Unterschied zwischen 0 und NULL, aber es kann das Programm lesbarer machen, wenn man NULL verwendet, da man dann sofort weiß, dass es sich hierbei um einen Wert für Pointer handelt.
Tücken der Pointerdeklaration
Schauen wir uns noch ein paar technische Details von Pointern an:
C++ weist ein seltsames Verhalten auf, wenn wir Pointer deklarieren, zunächst einmal muss das * nicht am Typen stehen, es kann überall zwischen Typ und Variable stehen.
int* ptr1; int *ptr2; int * ptr3;
Welche Variante man bevorzugt ist Geschmackssache, inkonsistent und unlogisch sind sie alle. Für die erste Variante spricht, dass int* der Typ des Pointers ist. Der Zeiger hat den Typ int*, es macht also Sinn den Stern zum Typen zu zählen. Problematisch wird das ganze aber, wenn wir mehrere Pointer anlegen wollen:
int* ptr1, ptr2; //ptr1 ist ein int*, ptr2 ist ein int
ptr2 ist in dem Fall eine ganz normale Integervariable, dieses Problem hat man nicht, wenn man den * zum Bezeichner dazu schreibt:
int *ptr1, *ptr2;
Pointer-Pointer
Nun, Pointer haben einen Datentyp und sind normale Variablen. Und Pointer können auf andere Datentypen zeigen, warum also nicht auch auf andere Pointer? Das geht in der Tat und zwar genau, wie wir es erwarten würden:
int a(0); int* a_ptr(&a); int** a_ptr_ptr(&a_ptr);
const bei Pointern
Interessant wird es noch, wenn wir const auf Pointer verwenden. Nehmen wir mal an wir wollen einen Pointer haben, den man nicht verändern kann, const int*? falsch. Denn bei const int* ist nicht der Pointer konstant, richtig wäre hier int* const.
Das ganze wird klarer wenn wir grundsätzlich die rechtsseitige Schreibweise verwenden:
int* pi; //Ein nicht konstanter Pointer, wir können ihn dereferenzieren und das worauf er zeigt dadurch verändern und wir können den Pointer selbst verwenden. int* const pi; //Ein konstanter Pointer auf ein nicht konstantes int. Wir können den Pointer dereferenzieren und dabei den Speicherbereich auf den er zeigt verändern. //Den Pointer selbst, also die Adresse auf die er zeigt können wir jedoch nicht verändern! int const* pi; //Ein nicht konstanter Pointer auf ein konstantes int. Wir können den Speicherbereich auf den pi zeigt nicht verändern, wohl aber den Zeiger selbst. //*pi = 3; wäre nicht erlaubt! int const* const pi; //Ein konstanter Pointer auf ein konstantes int. Wir können weder die Adresse des Zeigers ändern, //noch können wir den Speicherbereich auf den der Zeiger zeigt modifizieren
const_cast
Für Pointer ist const jedoch nicht so eine endgültige Sache, wie für Variablen. Ein Pointer auf ein const int, also ein int const* kann zu einem int* gecastet werden.
C-Casts machen das ohne zu fragen, static_cast macht dies jedoch nicht, denn dieser Cast ist gefährlich. Wenn wir ein const wegcasten wollen benötigen wir const_cast anstelle von static_cast, denn wenn wir const_cast auf eine wirkliche Konstante anwenden ist das Verhalten undefiniert, das heißt, folgendes ist legal:
int i(3); int const* pci(&i); int* pi(const_cast<int*>(pci)); *pi = 4; cout << i << endl;
denn i war ursprünglich keine Konstante, folgendes ist aber nicht legal(kann aber funktionieren, Verhalten ist undefiniert):
int const i(3); int const* pci(&i); int* pi(const_cast<int*>(pci)); *pi = 4; cout << i << endl;
Denn hier ist i eine Konstante und Konstanten dürfen wir nicht verändern!
Es gilt die Regel, const ist etwas aus gutem Grund, nur verwenden wenn man genau weiß was man tut!
reinterpret_cast
Ein weiterer Cast ist reinterpret_cast. reinterpret_cast ist in der Lage den Typ eines Pointers beliebig zu einem anderen Pointertyp zu verändern(schließlich beinhaltet ein Pointer immernoch nur eine Adresse). reinterpret_cast<double*> auf ein int* würde also einfach den Typ des Pointers ändern. Der Speicherbereich auf den der Pointer zeigt ändert sich nicht, es entsteht also kein double! Es ist übrigens ähnlich const_cast nicht erlaubt den double* zu dereferenzieren, ein int* bleibt ein int* und wenn das int* nicht ursprünglich ein double* war, dann können wir den double* den reinterpret_cast liefert nicht legal dereferenzieren.
void Pointer
Der void Pointer ist ein interessanter Sonderfall eines Pointers. Wie bereits erwähnt und durch reinterpret_cast bereits spürbar enthalten Pointer wirklich nur Adressen und der Typ gilt nur der Dereferenzierung. void* ist ein Pointertyp ohne Typ, es wird einfach eine Adresse dafür gespeichert, dass void* später wieder in einen anderen Pointer umgewandelt wird(oder ausgegeben wird). Es ist möglich jeden beliebigen Pointer in den Typ void* zu casten um den Typ des Pointers vorrübergehend zu "vergessen". Wir können mit void* nun Typlos arbeiten, wir können void* aber nicht dereferenzieren. Mit einem static_cast können wir void* wieder zu einem typisierten Pointer casten.
int i(2); int* pi(&i); void* pv(pi); //vergiss den Typ "int" //Hier können wir void* z.B. mit anderen void* in einem vector speichern und ihn erst später wieder in ein int* umwandeln //Zurück casten: int* pi(static_cast<int*>(pv));
Diese Verwendung von void* erlaubt eine Art von Polymorphie, wie sie in C sehr populär ist(in C ist es auch möglich jeden void* implizit in jeden anderen Pointertyp zu casten, in C++ geht dies nicht), in C++ hingegen ist diese Verwendung von void* eher unbeliebt, da durch das vergessen des Typs viele Fehler entstehen können und mit Templates und Virtuellen Methoden andere Arten der Polymorphie möglich sind, die wir später noch kennen lernen werden. Sollte es dennoch mal nötig sein eine Variable zu haben, die ihren Typ vorrübergehend vergisst bietet die Boostbibliothek mit Boost.variant und Boost.any sicherere Alternativen!
Der -> Operator
C++ hat noch einen weiteren Dereferenzierungsoperator, der nichts weiter als eine Kurzschreibweise für einen häufig auftretenden Fall ist. Nehmen wir an, wir haben einen Pointer auf eine struct und wollen auf ein Element zugreifen, das können wir mit unserem bisherigen Wissen bereits folgendermaßen machen:
(*some_struct).some_element
Nicht besonders schön, dafür, dass dies so häufig getan wird, deswegen gibt es den -> Operator, dieser dereferenziert den linken Operanden und wendet dann den Punktoperator auf ihn an, also genau das was wir in unserem obigen Beispiel getan haben können wir mit diesem Operator in kürzerer Form auch machen:
some_struct->some_element
Vorsicht mit Pointern!
Nun, da wir die technischen Aspekte von Pointern einigermaßen erläutert haben, kommen wir wieder zu der Frage wann Pointer verwendet werden sollten. Dazu schauen wir uns erstmal die Gefahren an, die unsere bisherigen Beispiele von Pointern mit sich bringen:
- NULL zu dereferenzieren, diese Gefahr haben wir bereits genannt. Dereferenzieren wir einen ungültigen Pointer machts Bumm.
- Generell ungültige Pointer zu dereferenzieren.
Schauen wir uns letzteren Punkt nochmal genauer an: Pointer sind absolut dumm, sie speichern nur eine Adresse und wissen nichts über das was am Ende dieser Adresse liegt. Nehmen wir einmal an, wir erstellen in einer Funktion eine Variable und einen Pointer auf diese Variable. Geben wir die Variable zurück ist ist alles in Ordnung. Der Aufrufer kriegt eine Kopie dieser Variable(auch wenn vl gar nichts kopiert wird, wenn der Compiler es wegoptimiert, bitte nicht aus Sorge um die Geschwindigkeit an die Decke gehen) und fertig. Geben wir aber den Pointer zurück wird nur die Adresse kopiert. Das ist ein Problem, denn die Variable auf die der Pointer zeigt existiert überhaupt nicht mehr, wollen wir nun den Pointer dereferenzieren kann alles mögliche passieren, aber bestimmt nicht das was wir wollen. Wenn man Pointer auf Variablen zeigen lässt sollte man sicher stellen, dass der Pointer nur so lange auf die Variable zeigt, wie die Variable auch existiert!
Analogie zu Referenzen
Bereits in Kapitel 5 haben wir über Referenzen gesprochen, die den Pointern auf andere Variablen erstaunlich ähnlich sehen. Das genaue Verständnis von Referenzen können wir nun mit Pointern auch bringen, denn es handelt sich bei Referenzen im Prinzip(technisch gesehen kann es als Pointer implementiert sein muss aber nicht, der Standard lässt dies offen. Für uns ist das aber unwichtig) um eine sichere Form von Pointern mit eingeschränkter Funktionalität! Alles was mit Referenzen getan werden kann, kann auch mit Pointern erledigt werden, aber nicht andersrum. Referenzen sind, wie Pointer, die
- Von Anfang an auf eine Adresse gesetzt werden und diese niemals ändern. Referenzen wird zwar keine Adresse zugewiesen, sie können dennoch auf alles zeigen, worauf auch Zeiger zeigen können, solange es gültig ist.(Nicht NULL).
- Referenzen sind daher immer gültig
- Da man die Adresse nicht ändern oder einsehen kann ist es auch nicht nötig Referenzen zu dereferenzieren, das geschieh automatisch.
Wann immer wir also einen Pointer haben, der immer auf das selbe zeigt, verwenden wir besser eine Referenz!
Dynamische Speicherreservierung
Pointer zeigen auf beliebige Speicheradressen, bisher haben wir immer nur die Adressen von Variablen genommen, aber das ist sehr einschränkend und auch nicht unbedingt die häufigste Verwendung. Wann immer wir Variablen haben, die wir bereits irgendwo sinnvoll verwenden und anderen einen Pointer darauf geben wollen mag es gut sein, aber Pointer können noch mehr, denn Speicheradressen finden auch anderswo Verwendung.
Eine Schlüsselrolle spielen Pointer in der äußerst wichtigen dynamischen Speicherreservierung(auch dynamische Allokation genannt). Wenn wir Variablen anlegen, wird der Speicher automatisch allokiert: C++ reserviert sofort Speicher und gibt ihn erst dann, aber dann sicher wieder frei, wenn die Variable den Scope verlässt. Das mag einfach sein birgt aber einige Probleme, denn oft wollen wir einer Variablen gar nicht sofort einen Wert zuweisen, Variablen erfordern dies aber(zwar können wir z.B. ints deklarieren ohne ihnen einen Wert zuzuweisen und sie für die Dauer einfach nicht verwenden, aber diese ints haben dennoch einen Wert und der Speicher für die ints wird sofort belegt), die Lebenszeit des Speichers ist an die Variable gebunden.
C++ bietet uns zusätzlich die Möglichkeit uns um die Lebenszeit des Speichers manuell zu kümmern, sprich Speicher anzufordern und ihn (notwendigerweise! nicht vergessen!) wieder freizugeben.
Das ist im übrigen nur ein Vorteil von dynamischer Speicherreservierung, darüber hinaus bietet sie noch weitere Vorteile
- Die Größe des Speichers, der allokiert werden muss, wird bei normalen Variablen zur Compilezeit festgelegt, bei dynamischer Allokation kann die Größe zur Laufzeit festgelegt werden, das ist wichtig für Arrays und ermöglicht somit Strukturen, wie std::vector nachzubauen.
- Zwar ist es Abhängig von der Implementierung, aber meistens ist der Speicherplatz für normale Variablen(auf dem Stack) begrenzt. Zudem werden gewöhnliche Variablen häufiger kopiert, sodass großer Speicher immer dynamisch allokiert werden sollte.
Neben diesen Vorteilen gibt es auch eine Reihe von Nachteilen, wie die kompliziertere Benutzung, die größere Fehleranfälligkeit und auch, dass es schlicht langsamer ist, deswegen sollte dynamische Speicherreservierung nur verwendet werden, wenn eine der oben genannten Vorteile greift!
Okay genug von den Vorzügen und wann man es anwenden soll, kommen wir nun zur Anwendung:
Wollen wir Speicher anfordern so tun wir dies mit dem Operator new. z.B.
new int(3);
Hierbei wird Speicher für einen int angefordert und in dieser Speicher mit dem Wert 3 initialisiert. Die Frage ist nun, wie wir auf den Speicher zugreifen können, aber wir haben ja bereits ein Mittel um auf Speicher zu verweisen nämlich Pointer. Und genau so einen gibt new auch zurück, new int(3); gibt einen int-Pointer auf den reservierten Speicher zurück:
int* a(new int(3)); //a zeigt nun auf einen manuell angeforderten Speicher.
Um die Vorteile noch ein wenig hervorzuheben:
int* a(0); //a zeigt auf nichts, der Speicher für das int existiert noch nicht einmal, demnach müssen wir uns für ihn auch noch keinen Wert erdenken. //irgendein Code a = new int(3); //nun erst (mitten innerhalb eines Blockes) wird der Speicher für unser int angefordert und erst jetzt müssen wir uns um einen Wert für diesen Speicher kümmern: 3 cout << *a << endl; //natürlich muss beim Arbeiten mit dem Speicher, der Zeiger darauf erst dereferenziert werden. Dafür können auch mehrere Zeiger darauf zeigen und wir können den Zeiger kopieren, ohne dass der Inhalt des Speichers kopiert wird. Es gibt keinen klaren "Besitzer" des Speichers
Belassen wir es nun dabei, haben wir aber einen "Memory Leak". Der Speicher bleibt allokiert, denn in C++ gibt es keinen Garbage Collector, der im Hintergrund unbenutzten Speicher wieder freiräumt und wenn wir Glück haben wird (erst) beim Beenden des Programmes der Speicher vom Betriebssystem wieder freigegeben. Wir müssen den Speicher also wieder freigeben, dies geschieht mit dem Operator delete.
delete a; a = NULL;
dies sollte nur einmal im Code geschehen, mehrfach sorgt für einen Fehler. Da es ebenfalls falsch ist einen Pointer zu dereferenzieren, der auf einen Speicher zeigt, der nicht mehr existent ist, aber wir nicht prüfen können ob ein Speicher noch existent ist weisen wir dem Pointer anschließend noch NULL zu, denn ob ein Pointer NULL ist können wir überprüfen.
Damit wir das Freigeben des Speichers nicht vergessen ist es Konvention, dass immer nur derjenige der den Speicher angefordert hat ihn auch wieder freigibt. Es wäre also schlechter und gefährlicher Stil in einer Funktion Speicher anzufordern und einen Pointer darauf zurückzugeben mit der Bedingung, dass der Aufrufer der Funktion den Speicher selbst noch freigeben muss. In C ist es daher üblich den Speicher vorher anzufordern und den Pointer dann der Funktion zu übergeben, die den Speicherbereich dann beschreibt, in C++ gibt es noch die Alternative der Smartpointer.
Smartpointer
Manuell freizugeben mag ja eine Sache der Erinnerung sein, aber problematisch wird es insbesondere dann, wenn mehrere verschiedene Pointer auf den Speicherbereich zeigen. Denn wer soll den Speicherbereich nun freigeben? Wer ist der Besitzer des Speichers und hatten wir vorher nicht gesagt, dass es eigentlich keinen Besitzer geben brauch?
Zwar hat C++ keinen Garbage Collector, dennoch ist es möglich das Freigeben des Speichers zu automatisieren und zwar mit sogenannten Smartpointer. Ein Beispiel wäre ein Referenzzählender Smartpointer, das bedeuted ein Smartpointer der den Speicherbereich auf den er zeigt freigibt, wenn kein anderer Smartpointer mehr auf dieses Objekt zeigt.
An diesen Smartpointern ist überhaupt nichts magisches, ebenso wie bei string und vector werden wir am Ende dieses (gesamten) Tutorials über die Mittel verfügen selbst welche zu schreiben, wenngleich es besser ist auf bestehende zurückzugreifen(da diese über längere Zeit entwickelt und getestet wurden).
Der C++98 Standard hat bereits einen Smartpointer, doch dieser ist nicht besonders geeignet und wird im nächsten Standard deprecated sein(das heißt nur noch aus Gründen der Abwärtskompatibelität vorhanden sein). Der TR1 verfügt über Smartpointer die aus Boost übernommen wurden und in erweiterter Form an den nächsten Standard weitergegeben werden, diese befinden sich im Header <tr1/memory>.
Der wichtigste Smartpointer ist shared_ptr, es handelt sich dabei um einen referenzzählenden Smartpointer, der seinen Speicher freigibt, wenn kein anderer shared_ptr mehr auf diesen Speicher zeigt. Im Prinzip also sehr simpel: wird ein Speicher nicht mehr gebraucht, wird er freigegeben, schöne Sache. Zurück zur Anwendung: Wie auch bei vector wird der Typ dahinter in eckigen Klammern angegeben, ein shared_ptr auf ein int(das Gegenstück zu int*) wäre also shared_ptr<int>. Wir initialisieren den shared_ptr mit dem Zeiger, den uns new zurückgibt und verwenden shared_ptr dann wie einen Zeiger. Wir können mit if(der_shared_ptr) überprüfen ob er gültig ist und ihn mit * dereferenzieren.
#include <iostream> #include <tr1/memory> #include "foo.hpp" //fiktiver Header using namespace std; using namespace std::tr1; void some_function() { shared_ptr<int> pi(new int(3)); //Tue irgendwas mit dem Pointer cout << *pi << endl; foo(pi); //fiktive Funktione cout << *pi << endl; //Gebe den Smartpointer nicht wieder frei, wenn pi der einzige shared_ptr ist, der auf //den Speicherbereich gezeigt hat, wird der Bereich freigegeben wenn pi gelöscht wird, //wenn foo den shared_ptr aber für sich gespeichert hat, wird der Bereich erst freigegeben //wenn auch dieser shared_ptr gelöscht wird(was nicht in unserem Aufgabenbereich liegt }
Weisen wir einem shared_ptr zu Beginn keine Adresse zu, ist er ungültig ähnlich, wie wenn ein normaler Pointer, ein "raw pointer" auf NULL zeigt. Rufen wir reset() auf den shared_ptr auf können wir diesen Zustand wieder herbeiführen
pi.reset();
macht pi also wieder ungültig. if(pi) würde nicht mehr ausgeführt werden.
Zirkuläre Referenzen
Die Verwendung von Referenzzählung verhält sich schon fast wie ein Garbage Collector, hat aber einige Unterschiede
- Referenzzählung verhält sich deterministisch, man kann vorhersagen, wann ein Speicher wieder freigegeben wird. Dies hat Vor-(man kann darauf optimieren) und Nachteile(ein GC kann entscheiden, zu warten, wenn der Prozessor zu sehr ausgelastet ist)
- Man muss bei Referenzzählung mit zirkulären Referenzen aufpassen.
Der letztere Punkt ist ein Problem, das selten auftritt, dessen man sich aber bewusst sein sollte und von dem man wissen sollte, wie man es umgehen kann. Nehmen wir folgenden Code
struct b; //Deklaration von struct reicht für Pointer, nicht aber für Variablen struct a { shared_ptr<b> pb; }; struct b { shared_ptr<a> pa; };
Wir können in a zwar keine Variable vom Typ b haben, da b noch nicht definiert wurde, aber die Deklaration von b reicht bereits um einen Pointer auf b zu besitzen(ist ja auch logisch). Schön und gut, aber was wenn wir auf einmal folgendes machen?
shared_ptr<a> some_a(new a); shared_ptr<b> some_b(new b); some_a->pb = some_b; some_b->pa = some_a;
die beiden shared_ptr halten sich Gegenseitig am Leben, so etwas ist also nicht möglich, wir müssen ein Objekt vom anderen abhängig machen, damit das funktioniert. Wir könnten also den shared_ptr in b zu einem ganz normalen Pointer machen, der auf den selben Speicherbereich wie der shared_ptr zeigt, solange der shared_ptr noch existiert können wir mit dem Pointer dann auf den Speicherbereich zugreifen, sobald der shared_ptr aber gelöscht wird, wird auch der Speicherbereich freigegeben. Mit normalen Pointern ist das gefährlich, denn 1. wissen wir nicht ob der shared_ptr noch existiert, denn der normale Pointer wird ja nicht automatisch 0 und 2. wenn wir, was wir noch nicht hatten, einen anderen Prozess parallel mit dem shared_ptr arbeiten lassen kann unser Pointer sogar mitten in der Verwendung ungültig werden, die Alternative ist weak_ptr. weak_ptr ist genau für diesen Fall der zirkulären Referenzen designed und kann aus einem shared_ptr einfach erstellt werden:
struct b; struct a { shared_ptr<b> pb; }; struct b { weak_ptr<a> pa; }; //[...] shared_ptr<a> some_a(new a); shared_ptr<b> some_b(new b); some_a->pb = some_b; some_b->pa = some_a;
some_b ist nun nicht mehr relevant dafür wann der Speicher freigegeben wird, wenn some_a nicht mehr existiert wird auch der Speicher von a freigegeben und pa zeigt auf einen ungültigen Speicherbereich, im Gegensatz zu normalen Pointern weiß weak_ptr aber, dass es ungültig ist, wir können damit also sicher auf folgende Weise arbeiten:
{ //Wir erstellen uns vorrübergehend ein shared_ptr aus pa, damit das Objekt nicht während wir damit arbeiten ungültig wird shared_ptr<a> locked_pa(some_b->pa.lock()); if(locked_pa) //wir überprüfen ob pa noch gültig ist { //hier können wir mit locked_pa als shared_ptr auf a arbeiten } }//der shared_ptr wird wieder gelöscht, keine zirkuläre Referenz, //wenn some_a währenddesen gelöscht wurde wird der Speicher hier freigegeben, wenn nicht, dann wenn some_a gelöscht wird
Arrays
Nun da wir mit Pointern und dynamischen Speicher umgehen können, können wir uns dem widmen, was sich hinter std::vector verbirgt: arrays.
Es gibt 2 Arten von Arrays, statische und dyamische
Statische Arrays
Statische Arrays haben eine zur Compilzeit konstante Größe. Arrays sind eine die Low-Levelvariante von Containern, wir können sie syntaktisch identisch mit [] verwenden, können die Größe aber z.B. nicht verändern. Ein statisches Array legen wir an, indem wir hinter dem Bezeichner einer Variable ein [KONSTANTE_ODER_LITERAL_MIT_GRÖßE] folgen lassen. Ein Array mit ints der Größe 3, wäre z.B.
int some_int_array[3]; for(int i(0); i < 3; i++) some_int_array[i] = i; //arbeite mit dem Array wie mit einem vector
Arras sind relativ einfache Konstrukte, wenn man std::vector bereits kennt, aber es gibt dennoch eine Besonderheit: Man kann Arrays nicht kopieren.
Selbst wenn wir denken, dass wir das tun, also zum Beispiel ein Array als Typ eines Parameters in einer Funktion spezifizieren tun wir es nicht, in Wirklichkeit wird das Array in einen Pointer konvertiert. Es gibt eine einfache Möglichkeit dies nachzuweisen: Wenn wir sizeof auf ein int [3] anwenden kriegen wir die Größe sizeof(int) * 3, also z.B. 12. Wenn wir das Array aber als Übergabetyp spezifiziert haben kriegen wir die Größe sizeof(size_t), denn es handelt sich nicht mehr um ein Array, sondern um ein Pointer.
Pointerarithmetik
Wir können auf den Pointer dennoch mit dem [] Operator zugreifen um dieses Phänomen zu erklären benötigen wir ein Verständnis über die sogenannte Pointerarithmetik.
Wenn wir ein int [3] haben, haben wir nichts anderes als 3 garantiert aufeinanderfolgendende ints im Speicher, das Array lässt sich mit einem Zeiger vergleichen(und zu einem casten), der auf das erste dieser ints zeigt. Wie wir bereits wissen sind Adressen einfach Zahlenwerte, also dürfte klar sein, dass es auch einfach ist auf die Elemente des Arrays zuzugreifen. Da die Zeiger aufeinanderfolgen müssten wir den Zeiger doch nur so modifizieren, dass er auf das nächste Element zeigt und da es sich um Zahlen handelt können wir der Adresse doch einfach sizeof(int) hinzufügen und schon haben wir den nächsten Speicherbereich. Interessanterweise reicht es sogar + 1 zu schreiben, denn wann immer wir einem Zeiger 1 dazu addieren wird zur Adresse die der Zeiger enthält die Größe des Typs hinzuaddiert, indem Fall also sizeof(int). Wenn sizeof(int) 4 ist und die Adresse im Pointer pi 0x12342 war, zeigt pi + 1 auf 0x12346.
Wir können also auf das erste Element zugreifen indem wir den Zeiger einfach dereferenzieren:
int some_array[3]; some_array[0] = 3; some_arary[1] = 7; int* casted_array(some_array); cout << *casted_array << cout; // gibt 3 aus
wir können 1 hinzuaddieren um auf den Speicherbereich der sizeof(int) weiterliegt zuzugreifen, also auf some_array[1]
cout << *(casted_array + 1) << endl; // gibt 7 aus
und so weiter.
In der Tat ist das wie [] auf Pointer definiert ist. casted_array[0] und casted_array[1] wäre ebensomöglich denn some_pointer[n] ist nur eine Kurzschreibweise für *(some_pointer + n). Eine interessante, aber merkwürdige Art das zu überprüfen ist 1[casted_array], was genau das selbe ist wie casted_array[1] und genau so 7 entspricht.
Initialisierungsliste
statische Arrays haben eine weitere Besonderheit, wir können sie wie structs initialisieren. Anstatt verschiedenen Variablen Werte zuzuweisen können wir damit den einzelnen Arrayelementen Werte zuweisen, das ganze sieht dann so aus
int array[3] = {2, 4, 6};
Wir können die 3 dabei auch weglassen, da die Information aufgrund der Initialisierung redundant werden
int array[] = {2, 4, 6};
Dynamische Arrays
Neben statischen Arrays gibt es auch noch die dynamischen Arrays, wie std::vector sie benutzt, der Unterschied ist, dass die Größe erst zur Laufzeit festgelegt werden muss, das Array also dynamisch allokiert wird. Im Gegensatz zu statischen Arrays haben dynamische Arrays keinen eigenen Datentyp, sondern man arbeitet vollständig mit Pointern. Ein dynamisches Array besteht also immer nur aus einem Pointer auf das erste Element, allerdings gibt es sehr wohl einen eigenen Operator um ein dynamisches Array zu allokieren, der new[] Operator. Er funktioniert wie der normale new Operator, nur dass darauf folgend in eckigen Klammern angegeben wird wie viele dieser Objekte angelegt werden soll, unser statisches Array int [3] würde als dynamisches Array also so aussehen:
int* dynamic_int_array(new int[3]);
Im Gegensatz zu statischen Arrays kann innerhalb der eckigen Klammern hier aber auch eine Variable stehen.
Ein weiterer Unterschied ist, dass dynamische Arrays, wie alles dynamisch allokierte wieder gelöscht werden müssen. delete funktioniert in C++ aber nur auf Pointer deren Speicherbereich mit new angefordert wird, mit new[n] angeforderter Speicher muss mit delete[] gelöscht werden(die geschweiften Klammern bleiben einfach leer):
int* dynamic_int_array(new int[3]); //arbeite mit dem dynamischen Array for(int i(0); i < 3; i++) dynamic_int_array[i] = i; for(int i(2); i >= 0; i--) cout << dynamic_int_array[i] << endl; //lösche das Array wieder! delete[] dynamic_int_array;
Strings
Ein weiteres Element, für das auf low-level Ebene Pointer bzw. Arrays verwendet werden sind Strings.
Wie bereits zu Beginn erwähnt ist der Typ eines Stringliterals nicht string, sondern ein Array von konstanten chars. "hi" hätte also den Typ char const[3].
char const[] some_text("hi");
was für einen Typ hat das ganze nun? char const[2]? falsch. char const[3], denn die 3 oben war kein Tippfehler, in der Tat ist "hi" vom Typ char const[3] denn es enthält noch das sogenannte Terminatorzeichen. Jeder String wird in C und C++ mit dem sogenanntem Terminatorzeichen '\0' abgeschlossen. some_text[0] wäre also 'h', some_text[1] wäre 'i' und some_text[2] wäre '\0'.
Die C Kompatibelitätsbibliothek hat einige Stringbearbeitungsfunktionen die mit <cstring> eingebunden werden, in C++ ist es aber zu bevorzugen den stringdatentyp zu verwenden.