8.2. Direkter Zugriff auf Speicherbereiche: Zeigervariablen
Was sind Pointer ?
Wie schon öfters angedeutet, handelt es sich bei Zeigern (engl. pointer) um Variablen, die eine Speicheradresse beinhalten. Während also der Programmierer in einer Variablen vom Typ int oder float numerische Werte speichert, um damit beispielsweise arithmetische Operationen vorzunehmen, repräsentiert der Wert einer Pointer-Variablen immer die Adresse einer Speicherzelle. Wie groß eine solche Adresse ist oder in welchem Format sie abgelegt wird, hängt vom jeweiligen Betriebssystem ab.
Unter MS-DOS, wo der Speicher in Segmente zu 64 KByte zerfällt, existiert die Unterscheidung zwischen einem near pointer und einem far pointer . Ersterer enthält nur den Offset — einen Adreßwert, der sich auf eine Speicherzelle innerhalb des eigenen Segments bezieht. Im Gegensatz dazu beinhaltet ein far pointer zusätzlich die Segmentadresse. Deshalb benötigt dieser 4 Byte, der nearpointer nur 2 Byte. Den Speicherbedarf der beiden Pointer-Typen können Sie sich mit size_ptr.c anzeigen lassen.
/* size_ptr.c
* gibt den Speicherbedarf für die beiden unter
* MS-DOS gängigen Pointer-Typen aus
*/
#include <stdio.h>
int main(void){
/* Definition von Pointern siehe unten */
int *piToInt, far *lpiToInt;
printf("near pointer: %d, far pointer: %d\n",
sizeof(piToInt), sizeof(lpiToInt));
return 0;
}
Unter moderneren 32-Bit-Betriebssystemen ist diese Art der Speicheraufteilung hinfällig. Dort herrscht eine lineare Speicherverwaltung, wobei jeder Pointer auf jede x-beliebige Speicherzelle zeigen kann. Entsprechend entfällt auf solchen Systemen die Unterscheidung zwischen near und far pointer; die Verwendung dieser bei DOS-Compilern gültigen Schlüsselwörter führt dort i.a. zu Fehlermeldungen.
Pointer müssen, wie andere Variablen auch, vor ihrer Verwendung definiert werden. Dies geschieht in der Form:
Datentyp *Variablenname;
Der auffälligste und einzige Unterschied zur Definition einer "gewöhnlichen" Variablen ist der Asterisk (*) vor den Variablennamen. Beispiele für gültige Pointer-Definitionen:
float *pfSum;
long *plResult;
char *pszHello; /* Zeichenketten siehe unten */
Es bleibt noch zu klären, warum Sie bei einer Pointer-Definition einen Datentyp angeben müssen, wo doch ein Pointer immer nur eine Speicheradresse enthält und daher immer gleich viel Speicher beansprucht! Da es sich bei Pointern um Variablen handelt, können diese mit Hilfe verschiedener Operatoren manipuliert werden (in der Praxis fast ausschließlich durch Addition und Subtraktion).
Wird beispielsweise eine Pointer-Variable mit dem Inkrementoperator versehen, dann muß der Compiler wissen, um wieviel ihr Wert erhöht werden soll. Der sonst übliche Wert 1 wäre nämlich nicht angemessen. Dies läßt sich mit einem häufigen Anwendungsfall für Pointer leicht begründen: Stellen Sie sich vor, Sie wollen mit einem Zeiger der Reihe nach auf Elemente eines Arrays zugreifen. Es liegt auf der Hand, daß sich dessen Wert dann abhängig vom Platzbedarf des Datentyps ändern muß.
Handelt es sich um einen Pointer auf ein Datenobjekt vom Typ int, dann muß zur Adresse, die der Pointer enthält, selbstverständlich sizeof(int) addiert werden, damit er auf das nächste Datenobjekt vom Typ int zeigen kann. Enthält hingegen ein Pointer, der als long *plCount definiert wurde, z.B. den Adreßwert 2000, dann würde die Anwendung des Inkrementoperators den Wert auf 2004 erhöhen. Entsprechend zöge (für int *piCount) die Addition piCount += 3 nicht das Resultat 5003 nach sich, sondern piCount += 3 * sizeof(int).
Verwendung von Pointern
Wie bei anderen Variablen auch, ist der Inhalt von Pointern nach ihrer Definition zunächst unbestimmt, d.h. in der Praxis: Datenmüll. Während der Gebrauch von nicht initialisierten, gewöhnlichen Variablen in der Regel lediglich zu unsinnigen Ergebnissen führt, kann dies bei Pointern gefährlich werden. Nicht initialisierte Pointer, auch "wilde Pointer" genannt, zeigen auf irgendeine Stelle im Arbeitsspeicher. Die Manipulation der von solchen Pointern betroffenen Speicherbereiche, deren Inhalt und Besitzer gar nicht bekannt sind, hat im allgemeinen böse Folgen.
Freilich besteht auch bei Pointern die Möglichkeit, ihnen gleich bei der Defintion einen Anfangswert zuzuweisen. Es stellt sich bloß die Frage, welcher Wert für einen Pointer geeignet ist. Oder wissen Sie, ob die Speicheradresse mit dem Wert 0x3d44 gerade unbenutzt ist?
Eine Möglichkeit, wilde Pointer zu vermeiden, besteht darin, sie gleich als Null-Pointer zu initialisieren. Wie der Name schon nahelegt, geschieht dies, indem Sie einem Pointer den Wert 0 zuweisen. Daraus ist aber nicht zu schließen, daß damit die Adresse mit dem Wert 0 für die unbeschränkte Manipulation durch alle möglichen Pointer freigegeben ist. Die Definition eines Null-Pointer drückt sich laut Sprachbeschreibung vielmehr darin aus, daß er auf keine Adresse irgendeines Datenobjektes zeigt. Die Zuweisung des Werts 0 heißt also nicht, daß der Pointer exakt diesen Wert enthält; wie ein Null-Pointer intern tatsächlich repräsentiert wird, hängt vom jeweiligen System ab. In der Praxis ist es ratsam und besser, anstelle des numerischen Werts 0 die in stdio.h definierte Konstante NULL zu verwenden. Beispiel:
long *plResult = NULL;
Da ein Hochsprachen-Programmierer im allgemeinen nicht über die Speicheradressen seiner Datenobjekte informiert ist, stellt C den uns mittlerweile bekannten Adreßoperator zur Verfügung, um die Speicheradressen von Datenobjekten zu ermitteln. So können Zeigervariablen auf die verschiedenen Datenobjekte gerichtet und auf diese Weise auch initialisiert werden.
#include <stdio.h>
int main(void){
int iCount;
int *piCount;
piCount = &iCount;
/* piCount enthält nun die Adresse der Variablen iCount
*/
printf("Wert von piCount: %p, Adresse von iCount: %p\n",
piCount, &iCount);
return 0;
}
Bisher wurde eindringlich die Gefahr beschworen, die durch unbedachten Gebrauch von Pointern entstehen kann. Tatsächlich sind wir bis dato aber gar nicht in der Lage, Veränderungen dort vorzunehmen, wohin die Pointer-Variable zeigt. Dazu benötigen wir den (unären) Inhaltsoperator, der als * vor die Pointer-Variable geschrieben wird (und nicht mit dem binären Multiplikationsoperator zu verwechseln ist). Mit seiner Hilfe beziehen wir uns auf das bezeichnete Datenobjekt.
/* Verwendung des Inhaltsoperators
* Veränderungen an *piCount und iCount
* manipulieren den gleichen Speicherbereich
*/
#include <stdio.h>
int main(void){
int iCount = 99;
int *piCount;
/* piCount zeigt nun auf die Variable iCount */
piCount = &iCount;
printf("Wert von *piCount: %d, Wert von iCount: %d\n",
*piCount, iCount);
printf("++*piCount\n");
/*Welchen Einfluß hat dies auf iCount? */
++*piCount;
printf("Wert von iCount: %d\n", iCount);
printf("iCount *= *piCount\n");
iCount *= *piCount;
printf("Wert von *piCount: %d, Wert von iCount: %d\n",
*piCount, iCount);
return 0;
}
Die Ausgabe dieses Programms zeigt, daß die Anwendung des Inhaltsoperators auf den Pointer piCount Änderungen an der Speicheradresse zuläßt, die für die Variable iCount reserviert wurde:
Wert von *piCount: 99, Wert von iCount: 99
++*piCount
Wert von iCount: 100
iCount *= *piCount
Wert von *piCount: 10000, Wert von iCount: 10000
Aufgrund dieser Informationen läßt sich sagen, daß iCount und *piCount zwei verschiedene Namen für den gleichen Speicherbereich sind. Den Vorgang, mit Hilfe des Inhaltsoperators auf ein Datenobjekt zuzugreifen, auf das ein Pointer zeigt, nennt man übrigens "Dereferenzieren" eines Pointers.
In unserem Beispiel verwenden wir den Inkrementoperator, um den Wert von *piCount um 1 zu erhöhen. Bei der Kombination von Inhalts- und Inkrementoperator (bzw. Dekrementoperator) ist Vorsicht geboten, da es aufgrund ihres gleichen Vorranges zu Mißverständnissen kommen kann. Was wäre wohl passiert, wenn wir statt ++*piCount die Formulierung *piCount++ gewählt hätten? Aufgrund der rechts-nach-links Assoziativität beider Operatoren wird in unserer Variante der Operand rechts von ++, also *piCount inkrementiert. Bei *piCount++ hingegen wird zuerst piCount dereferenziert, mit dem Wert geschieht aber nichts, da wir keinerlei Zuweisungen vornehmen. Anschließend wird der Wert von piCount, also die Speicheradresse, inkrementiert! Bei der Verwendung der Postfixvariante von ++ hätten wir also Klammern setzen müssen, um das gewünschte Ergebnis zu erhalten. Weitere Varianten, wenn folgende Variablen definiert seien:
int iCount = 99, iTemp;
int *piCount = &iCount;
- iTemp den Wert von *piCount, also 99, zuweisen und anschließend piCount inkrementieren:
iTemp = *piCount++;
- Zuerst den Wert von piCount erhöhen, dann den Wert an dieser neuen Adresse an iTemp zuweisen. In unserem Fall ist dieser undefiniert, da sich dort kein bekanntes Datenobjekt befindet. *piCount ist also ein wilder Pointer geworden:
iTemp = *++piCount;
- Den Wert, auf den piCount zeigt, erhöhen und dann an iTemp zuweisen; in unserem Fall erhielte iTemp den Wert 100:
iTemp = ++*piCount;
- Zuerst den Wert von *piCount, also 99, an iTemp zuweisen und dann *piCount auf 100 erhöhen:
iTemp = (*piCount)++;
Dynamische Speicherverwaltung
Wenn wir uns nicht gleich mit wilden Pointern herumschlagen wollen, bleibt uns bisher nur die Möglichkeit, Pointer als Null-Pointer einzurichten (und nichts weiter mit ihnen anzufangen) oder sie auf bestehende Variablen zu richten. Letzteres scheint auch nicht besonders spannend zu sein, da man anstelle von (*piCount)++ ja gleich iCount++ verwenden könnte. Pointer erweisen sich später als wichtige Mittel zur Übergabe von Funktionsargumenten, wenn komplexe Datenobjekte betroffen sind. Zur vollen Entfaltung gelangt das Konzept der Pointer allerdings erst zusammen mit der dynamischen Speicherverwaltung.
Das Beispielprogramm array_3.c bewies durch die statische Zuteilung von Speicher — in Form eines umfangsreichen 3stelligen Arrays — enormen Speicherbedarf. Bei der Definition von Arrays muß die Anzahl der Elemente so gewählt werden, daß auch im Extremfall der Speicher noch ausreicht. Wird andererseits aber nur ein Bruchteil der Elemente benutzt, so ist der Speicher trotzdem für andere Zwecke blockiert.
Wie der Name schon verrät, bietet die dynamische Speicherverwaltung die Möglichkeit, Speicher je nach Bedarf zur Laufzeit des Programms anzufordern. Während also bei der Definition einzelner Variablen oder Arrays eine fester Speicherbereich reserviert wird, dessen Größe nachträglich nicht mehr veränderbar ist, kann der Programmierer unter Verwendung der entsprechenden Bibliotheksfunktionen den Speicherbedarf für seine Daten auf die wirklich benötigte Größe reduzieren.
Variablen sind im Prinzip nur Namen, mit deren Hilfe ein Programmierer auf bestimmte Speicherbereiche zugreifen kann. Bei der dynamischen Speicherverwaltung wird Speicher nicht über die Definition von Variablen angefordert, sondern über den Aufruf der Bibliotheksfunktionen malloc(), calloc() oder realloc(). Deshalb sind diese Speicherbereiche anonym, d.h., es besteht keine Möglichkeit, sie über Variablennamen anzusprechen. Abhilfe schaffen in diesem Fall Pointer: Bei der Zuteilung von Speicher kann dessen Startadresse in Pointern abgelegt werden. Die drei angeführten Funktionen zur dynamischen Speicherverwaltung stellen einen zusammenhängenden Speicherblock zur Verfügung; die Informationen über Anfangsadresse und Größe des reservierten Speichers reichen dann aus, um jeden Teilbereich ansprechen zu können.
Die gebräuchlichste Funktion für die dynamische Zuteilung von Speicher ist malloc() (steht für memory allocate). Dieser Funktion muß die gewünschte Anzahl an Byte als Argument übergeben werden. Die Funktion malloc() gibt ihrereseits bei erfolgreicher Ausführung einen Pointer auf den Beginn des reservierten Speicherbereichs zurück, bei Mißerfolg einen Null-Pointer (NULL). Wie auch bei der Verwendung der anderen Funktionen zur dynamischen Speicherverwaltung muß die Header-Datei stdlib.h eingebunden werden.
Wurde beispielsweise der Pointer
int *piToInt;
definiert, dann würde der Aufruf
piToInt = malloc(20);
einen Speicherblock in der Größe von 20 Byte reservieren und piToInt die Basisadresse dieses Speicherbereichs zuweisen.
Diese Form des Aufrufs von malloc() weist allerdings einige Schönheitsfehler auf. Mit der numerischen Konstanten 20 als Argument liefert uns malloc() einen Zeiger auf einen Speicherbereich mit einer Größe von 20 Byte. Da mit piToInt ein int-Zeiger die Basisadresse zugewiesen erhält, sollen Daten vom Typ int in diesem Speicherblock abgelegt werden. Nachdem aber der Speicherbedarf des Typs int je nach Plattform variiert, können wir nicht sicher wissen, wie viele Daten in 20 Byte Platz haben. Daher empfiehlt sich aus Gründen der Portabilität, beim Aufruf von malloc() den sizeof-Operator zu verwenden. Soll also Platz für 10 int-Werte geschaffen werden, dann ist malloc(10* sizeof(int)) dem Aufruf malloc(20) vorzuziehen.
Ein weiteres Manko der Anweisung piToInt = malloc(20) besteht darin, daß malloc() einen Zeiger vom Typ void * liefert, links vom Zuweisungsoperator aber ein Zeiger auf int steht. Zwar handelt es sich bei einem Zeiger auf void um einen typenlosen Pointer, zu dem jeder andere Pointer ohne Datenverlust konvertiert werden kann; trotzdem sollten Sie sich aber nicht auf die implizite Typumwandlung verlassen, sondern - auch aus Gründen der Lesbarkeit (und Konvention) - eine explizite Umwandlung mit dem type-cast-Operator vornehmen.
Schließlich fällt noch auf, daß für den Fall eines Mißerfolgs von malloc() keine Vorkehrungen getroffen sind. Schlägt die Zuordnung von Speicher fehl, dann liefert malloc() einen Null-Zeiger, der an piToInt zugewiesen wird. Falls Sie diesen dereferenzieren, um Daten in einem vermeintlich reservierten Speicherbereich abzulegen, wird das Programm bei seiner Ausführung jedoch ein irreguläres Ende erleben.
Folgendes Beispiel benutzt einen Aufruf von malloc(), der diese Mängel beseitigt und zudem den zur Verfügung gestellten Speicherbereich in Anspruch nimmt.
/* malloc.c
* demonstriert Verwendung von malloc()
*/
#include <stdio.h>
#include <stdlib.h> /* wg. malloc() und exit() */
int main (void){
int *piToInt, iCount;
/* malloc() liefert als Rückgabewert void *,
* der durch den cast (int *) umgewandelt
* wird
*/
piToInt = (int *) malloc( 10 * sizeof(int));
if( piToInt == NULL ){
printf("Fehler bei Zuordnung von Speicher!\n");
/* EXIT_FAILURE ist in stdlib.h definiert */
exit(EXIT_FAILURE);
/* Vorzeitiger Programmabbruch mit Rückgabe eines
* Fehlercodes an den Aufrufer (Betriebssystem),
* falls malloc() fehlschlägt
*/
}
/* in den von malloc() zur Verfügung gestellten
* Speicherbereich mit Hilfe von piToInt 10 int-
* Werte schreiben...
*/
for(iCount = 0; iCount <= 9; iCount++){
*piToInt = iCount;
piToInt++;
}
/* ... und anschließend auslesen. */
for(iCount = 0; iCount <= 9; iCount++){
piToInt--;
printf("%d\t", *piToInt);
}
return 0;
}
Bei der expliziten Typumwandlung mit dem type-cast-Operator geben wir als neuen Typ des Rückgabewerts von malloc() nicht einfach int, sondern int * an. Dies ist deshalb korrekt, weil schon bei der Definition von Pointern wie int *piToInt der Asterisk (*) nicht Teil des Variblennamens ist, sondern int * den Typ "Pointer auf int" bezeichnet.
Bei der Überprüfung, ob malloc() erfolgreich Speicher zur Verfügung gestellt hat, wird piToInt mit NULL verglichen. Liefert dieser Vergleich den Wert "wahr", ist also malloc() gescheitert, soll das Programm beendet werden. Dafür bietet sich die Funktion exit() an; sie sorgt für die sofortige Rückkehr zum Aufrufer (in der Regel zum Betriebssystem) und übergibt diesem den Wert ihres Arguments. Beispielsweise würde der Aufruf von exit(1) alle offenen Dateien schließen, das Programm beenden und den Wert 1 zurückgeben. Besser ist jedoch, exit() nicht mit einem x-beliebigen int-Wert aufzurufen, sondern wie im obigen Beispiel mit den symbolischen Konstanten EXIT_FAILURE oder EXIT_SUCCESS. Beide sind in stdlib.h definiert und repräsentieren unter den jeweiligen Systemen die dort üblichen Werte für fehlerhafte bzw. reguläre Beendigung eines Programmes. Diese Vorgangsweise leistet einen Beitrag zur Portabilität Ihrer Programme.
Bei der Benutzung von Speicherbereichen, die durch malloc() berreitgestellt werden, ist die gleiche Vorsicht geboten wie schon bei Arrays: Nichts hindert Sie daran, über den zugeteilten Speicherplatz hinauszuschreiben und damit Speicherinhalte anderer Programmteile oder des Betriebssystems zu zerstören. Im obigen Beispiel würde
for(iCount = 0; iCount <= 10; iCount++){
*piToInt = iCount;
/*--------->>>>> Vorsicht <<<<<---------
zuviele Schleifendurchläufe! */
.
.
um den Wert sizeof(int) über den zur Verfügung gestellten Speicher hinaus Zuweisungen vornehmen.
Wie schon bei der Definition von Arrays, ist der Inhalt von Speicherblöcken, die mit malloc() angefordert wurden, nicht initialisiert und enthält irgendwelche Datenrückstände. Allerdings besteht im Gegensatz zu Arrays keine Möglichkeit, einen solchen Speicherbereich durch eine Zuweisung der Form piToInt={0} zu initialisieren. Stattdessen muß jedem Element explizit ein Wert zugewiesen werden, was im allgemeinen unter Verwendung einer Schleife passieren wird. Wenn Sie den Inhalt eines angeforderten Speicherblocks mit 0 initialisieren wollen, bietet sich alternativ zu malloc() die Funktion calloc() an. Diese erfüllt ansonsten den gleichen Zweck wie malloc(), muß aber mit zwei Argumenten aufgerufen werden, wobei das erste die Anzahl der Elemente, das zweite deren Größe angeben muß. Die Funktion calloc() setzt die betroffenen Speicherinhalte byte-weise auf 0. Dies ist sehr komfortabel, sofern dort ganzzahlige Werte abgelegt werden sollen. Für Fließkommawerte sollten Sie aber alle Elemente explizit durch Zuweisungen eines Anfangswerts initialisieren.
/* calloc.c
* demonstriert die Verwendung von calloc()
*/
#include <stdio.h>
#include <stdlib.h> /* wg. calloc() und exit() */
int main (void){
int *piToInt, iCount;
/* Speicherplatz in der Größe 10 * sizeof(int)
* anfordern
*/
piToInt = (int *) calloc( 10, sizeof(int));
if( piToInt == NULL ){
printf("Fehler bei Zuordnung von Speicher!\n");
exit(EXIT_FAILURE);
/* Vorzeitiger Programmabbruch mit Rückgabe eines
* Fehlercodes an den Aufrufer
*/
}
/* Überprüfen, ob alle Elemente tatsächlich
* den Wert 0 enthalten
*/
for(iCount = 0; iCount <= 9; iCount++){
printf("%d\t", *piToInt++);
}
return 0;
}
Die dritte von ANSI definierte Funktion zur dynamischen Zuteilung von Speicher ist realloc() . Sie dient dazu, die Größe eines von malloc() oder calloc() bereitgestellten Speicherblocks zu verändern. Zu diesem Zweck müssen ihr ein Zeiger auf den Beginn dieses Speicherabschnitts und die neue Größe in Byte als Argumente übergeben werden. Bei erfolgreicher Ausführung gibt realloc() einen Zeiger auf den zur Verfügung gestellten Speicherblock zurück. Dies ist deshalb notwendig, weil bei Anforderung von zusätzlichem Speicher unter Umständen der ganze Block verlagert werden muß und daher eine andere Basisadresse aufweisen kann. Bei Mißerfolg liefert realloc() einen NULL-Zeiger, wobei aber der zuvor durch malloc() oder calloc() angeforderte Speicher noch unverändert zur Verfügung steht. Voraussetzung dafür ist jedoch, daß weiterhin ein gültiger Zeiger auf diesen Speicherbreich existiert; falls Sie ausgerechnet den einzigen Zeiger auf den ursprünglichen Speicherblock verwenden, um den Rückgabewert von realloc() zu speichern, dann haben Sie im Falle des Rückgabewerts NULL keine Gelegenheit mehr, auf den zuvor benutzten Speicher zuzugreifen.
/* realloc.c
* demonstriert die Verwendung von realloc()
*/
#include <stdio.h>
#include <stdlib.h>
/* wg. malloc(), calloc(), exit(), EXIT_FAILURE */
int main (void){
int *piToInt, *piBase, iCount;
piBase = (int *) malloc( 10 * sizeof(int));
if( piBase == NULL ){
printf("Fehler bei Zuordnung von Speicher!\n");
exit(EXIT_FAILURE);
}
/* piToInt auf Anfangsadresse von Speicherblock */
piToInt = piBase;
/* 10 int-Werte schreiben... */
for(iCount = 0; iCount <= 9; iCount++)
*piToInt++ = iCount;
/* Speicherblock auf 15 * sizeof(int) vergößern */
piBase = (int *) realloc( piBase, 15 * sizeof(int));
if( piBase == NULL ){
printf("Fehler bei realloc()!\n");
exit(EXIT_FAILURE);
}
/* möglicherweise wurde von malloc() angeforderter
* Speicher durch den Aufruf von realloc() verschoben.
* Deshalb vorsichtshalber piToInt an piBase ausrichten.
*/
piToInt = piBase + iCount;
while( iCount <= 14 )
/* Wert von iCount an *piToInt zuweisen,
* anschließend piToInt und iCount erhöhen
*/
*piToInt++ = iCount++;
/* zum Schluß auslesen. */
for(iCount = 0; iCount <= 14; iCount++){
piToInt--;
printf("%d\t", *piToInt);
}
return 0;
}
Gegenüber malloc.c stellt realloc.c eine Erweiterung dar, weil es nachträglich weitere 5 * sizeof(int) Byte anfordert und mit Werten belegt. Dieses Beispiel benutzt zudem zwei Zeiger, um auf den angeforderten Speicher zuzugreifen. Dies ist deswegen sinnvoll, weil beim Aufruf von realloc() die Basisadresse des durch malloc() zugeteilten Speichers zur Verfügung stehen muß. Da piToInt durch mehrmaliges Inkrementieren irgendwo mitten in diesen Speicherblock zeigt, müßten wir dessen ursprünglichen Wert restaurieren, wenn wir damit realloc() aufrufen wollen. So aber speichert piBase die Basisadresse und steht bei Bedarf zur Verfügung.
Beachtenswert ist die Tatsache, daß nach dem Aufruf von realloc() der Wert von piToInt an piBase neu ausgerichtet wird. Dies empfiehlt sich, weil realloc() den von malloc() zugewiesenen Speicher an eine andere Stelle verschieben könnte, und schon würde piToInt in einen unzulässigen Adreßbereich zeigen (und damit zum wilden Pointer werden). Offensichtlich gibt uns iCount Auskunft darüber, wie oft piToInt inkrementiert wurde; wie bekannt, wird bei der Pointer-Arithmetik nicht einfach der Wert von iCount zur Adresse in piBase hinzugezählt, sondern der Datentyp von piBase berücksichtigt. Mithin wird zum Wert von piBase das Ergebnis von iCount * sizeof(int) hinzuaddiert und an piToInt zugewiesen.
Die dynamische Speicherverwaltung in C reduziert sich nicht auf die flexible Zuteilung angeforderten Speichers. Da es sich bei Speicher oft um ein knappes Gut handelt, ist die Beschränkung auf den tatsächlich erforderlichen Speicherplatz ein angemessenen Vorgehen. Noch ökonomischer ist es freilich, angeforderten Speicher wieder an das System zurückzugeben, sobald er nicht mehr benötigt wird. Die bisher vorgestellten Beispielprogramme zur dynamischen Speicherverwaltung haben dies unterlassen. Da aber der von Programmen beanspruchte Speicher nach deren Beendigung ohnehin wieder an das Betriebssystem zurückfällt, hielt sich der Schaden bei diesen kurzen Programmen in Grenzen. Bei umfangreichen Programmen hingegen ist die Freigabe von nicht mehr benötigtem Speicher ein wichtiges Anliegen. In ANSI C steht dafür die Funktion free() zur Verfügung.
Als Argument verlangt sie einen Zeiger auf die Anfangsadresse eines Speicherblocks, der mit malloc(), calloc() oder realloc() angefordert wurde. Wie generell bei der dynamischen Speicherverwaltung ist auch bei der Verwendung von free() einige Vorsicht geboten. Folgende Speicherbereiche dürfen nicht durch free() freigegeben werden:
- Speicher, der nicht durch eine der drei Funktionen zur dynamischen Zuteilung von Speicher angefordert wurde.
- Speicher, der schon durch einen früheren Aufruf von free() freigegeben wurde.
- Speicher, der durch einen anderen Thread benutzt wird (nur bei entspechenden Betriebsystemen).
- Außerdem sollten Sie immer daran denken, daß ein Pointer, der vor dem Aufruf von free() noch auf einen gültigen Speicherbereich zeigte, durch die Rückgabe dieses Speicherbereichs an das Betriebssystem zum wilden Pointer wird!
/* free.c
* Freigabe von Speicher durch free()
*/
#include <stdio.h>
#include <stdlib.h>
int main(void){
int *piToInt, iCount;
/* Platz für 5 Elemente anfordern */
piToInt = (int *) malloc( 5 * sizeof(int));
if( piToInt == NULL ){
printf("Fehler bei Zuteilung von Speicher!\n");
exit(EXIT_FAILURE);
}
printf("Bitte 5 Zahlen eingeben!\n");
for( iCount = 0; iCount <= 4; iCount++){
fflush(stdin);
scanf("%d", piToInt++);
/* >>>>>>>>>>>> wichtig <<<<<<<<<<<<
* wenn Pointer als Argumente an scanf()
* übergeben werden, darf der Adreßoperator &
* nicht verwendet werden !!
*/
}
for( iCount = 0; iCount <= 4; iCount++){
/* zuerst piToInt dekremenieren, dann
* dereferenzieren
*/
printf("%d\t", *--piToInt);
}
/* mit malloc() angeforderten Speicher freigeben */
free( (void *) piToInt );
/* piToInt entschärfen */
piToInt = NULL;
return 0;
}
Größtenteils wiederholt free.c, was schon in den letzten Beispielen erläutert wurde. Erwähnenswert hingegen ist die Tatsache, daß beim Aufruf von scanf() kein Adreßoperator vor die Variable gesetzt werden darf. Das liegt daran, daß scanf() eine Speicheradresse erwartet, an der eingelesene Werte abgelegt werden sollen. Bei normalen Variablen muß diese mit Hilfe des Adreßoperators ermittelt werden, Pointer enthalten aber von Haus aus Adreßwerte. Die Verwendung des Adreßoperators hätte zur Folge, daß scanf() die Daten nicht an dem Ort speichern würde, auf den piToInt zeigt, sondern dort, wo sich die Zeigervariable selbst befindet. Das Ergebnis eines solchen Vorgehens ist unschwer abzuschätzen.
Beim Aufruf von free() ist es guter Stil, wenn das Argument in den Typ void * umgewandelt wird: free() erwartet nämlich einen Pointer auf void. Wichtig ist allemal, daß der Pointer wirklich auf den Anfang des freizugebenden Speichers zeigt. In unserem Fall muß piToInt in der letzten Schleife genauso oft dekrementiert werden, wie er zuvor inkrementiert wurde. Dabei spielt es eine entscheidende Rolle, wann und wo die Präfix- bzw. Postfixnotation des In- bzw. Dekrementoperators zum Einsatz kommt. Damit nach der Freigabe des Speichers piToInt nicht wild in die Gegend zeigt, erhält er den Wert NULL; besonders wichtig ist dies in längeren Programmen, wo betroffene Pointer oft noch weiterhin verwendet werden.
Abschließend sei zur dynamischen Speicherverwaltung noch erwähnt, daß viele Compiler eine Reihe zusätzlicher Funktionen zur Verfügung stellen, die nicht von ANSI definiert wurden. Gerade im MS-DOS-Bereich, wo aufgrund der Segmentierung des Speichers je nach Speichermodell zwischen einem near heap und einem far heap unterschieden wird, stehen Funktionen zur Verfügung, die den Zugriff auf diese beiden Speicherbereiche ermöglichen. Auch hier gilt: Verwenden Sie solche nicht ANSI-konforme Funktionen nur dann, wenn Sie anders nicht zum Ziel kommen!