Kort voorwoord

Vaak krijg ik vragen over geheugen management in C++. Eerlijk gezegd ben ik zelf ook geen god in C++. Maar ik zal wel proberen mijn kennis over te dragen. Ik zal proberen aan de hand van veel voorbeelden duidelijk te maken, hoe C++ omspringt met pointers, references en normale variabelen. Ik ga er wel vanuit dat de lezer een zekere basiskennis beschikt over C++.

Geheugen adressen

Een variabele is niks meer dan een stukje gereserveerd geheugen waarin een waarde is opgeslagen. Voor elke variabele die u aanmaakt zal er geheugen worden gereserveerd, een klein voorbeeld:

#include <iostream>
using namespace std;
			
int main(int argc, char *argv[]) 
{
  int i = 1;
  cout << i << endl;
  return 0;
}

Voor deze variabelen gebeurt het reserveren van het geheugen automatisch. Om het geheugen adres van een dergelijke variabele op te vragen, kent C++ de operator &, ook wel referentieoperator genoemd.

#include <iostream>
using namespace std;
			
int main(int argc, char *argv[]) 
{
  int i = 1;
  int ii = 2;
  cout << &i << endl << &ii << endl;
  return 0;
}

De uitvoer van dit programma zal verschillend zijn per computer, maar ook per besturingssysteem. Een mogelijke uitvoer op een computer met Windows XP is:

0x22ff6c
0x22ff68

Wie bekend is het hexadecimale getallen zal opvallen dat de het verschil van de twee geheugenadressen 4 is. Dit is gelijk aan het aantal bytes dat één integer in beslag neemt.

Een introductie tot pointers

In C++ wordt veel met pointers gebruikt. In essentie wijst een pointer naar een bepaald geheugenadres waar gegevens worden opgeslagen. In een pointer slaat u dus een geheugen adres op. Kijk eens naar het onderstaande voorbeeld:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int variabele = 3;
  int *pointer = &variabele;

  cout << &variabele << endl;
  cout << pointer << endl;

  return 0;
}

Als je dit programma uitvoert zal je opvallen dat er twee keer hetzelfde geheugenadres wordt afgedrukt op het scherm. Uit dit voorbeeld blijkt ook hoe je een pointer moet declareren. Je plaatst een * voor de naam van de pointer.

Om de inhoud van het geheugen waarnaar een pointer verwijst op te vragen kent C++ de dereferentieoperator: *. Weer een voorbeeld:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int variabele = 3;
  int *pointer = &variabele;

  cout << *pointer << endl;
	
  return 0;
}

De uitvoer van dit programma is, zoals verwacht: 3. De pointer bevat immers het geheugen van variabele. Door de dereferentie-operator te plaatsen voor pointer, wordt de inhoud van het geheugen opgevraagd.

Zelf kan je ook de waarde veranderen waarnaar pointer verwijst. Dit doe je op de volgende manier:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int variabele = 3;
  int *pointer = &variabele;
  
  *pointer = 5;
  
  cout << *pointer << endl;
	
  return 0;
}

Ook hiervoor wordt de dereferentie-operator gebruikt.

Geheugen reserveren/vrijgeven

Een pointer hoeft niet perse naar een al reeds gedeclareerde variabele te wijzen. Het is ook mogelijk om tijdens de uitvoer van een programma geheugen te reserveren. De pointer zal dan verwijzen naar het nieuw gereserveerde geheugen. Gereserveerd geheugen moet ook vrijgegeven worden. Als je zelf geheugen reserveert, moet je dit ook zelf vrijgeven! Voor het reserveren van geheugen kent C++ de operator new en voor het vrijgeven van geheugen de operator delete. Een voorbeeld:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int *ptr = new int;
  *ptr = 3;

  cout << *ptr << endl;

  delete ptr;
	
  return 0;
}

Dit programma geeft 3 weer op het scherm. In de eerste regel zie je hoe een pointer naar een integer wordt gedeclareerd. De operator new heeft als operandus het type waarvoor het geheugen moet reserveren. De operator new reserveert evenveel geheugen als dat type nodig heeft. Bij een normale int is dit doorgaans 4 bytes. De operator new retourneert een geheugenadres.

In de op een na laatste regel zien we hoe het gereserveerde geheugen wordt vrij gegeven met de operator delete. Deze operator heeft als operandus de pointer waarvoor je eerder geheugen hebt gereserveerd.

Slaagt C++ er om de een of andere reden niet in geheugen te reserveren, dan zal de operator new NULL retourneren. Dit is het geheugenadres 0. Als je verder iets wilt doen met een pointer die naar NULL verwijst dan zal dit resulteren in een crash. Je kunt hiervoor een controle programmeren:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int *ptr = new int;
  
  if (ptr == NULL)
  {
    cout << "Kon geen geheugen reserveren!" << endl;
    return 1;
  }
  
  *ptr = 3;

  cout << *ptr << endl;

  delete ptr;
	
  return 0;
}

Rekenen met pointers

Het rekenen met pointers wordt ook wel pointer arithmetic genoemd. Het is mogelijk om getallen bij een pointer op te tellen en af te trekken. Telt u 1 op bij een pointer dan zal deze niet naar de volgende byte in het geheugen verwijzen. De pointer wordt met evenveel bytes verhoogt als het type waarnaar de pointer verwijst nodig heeft. Als we dus 1 op zouden tellen bij een pointer naar een integer. Dan zal hij naar een geheugenadres 4 bytes verder verwijzen. Kijk eens naar dit voorbeeld:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int array[4] = {1, 2, 3, 4};
  int *ptr = &array[0];
  
  cout << "Het eerste element van de array = " << *ptr << endl;
  
  ptr += 2;
  cout << "Het derde element van de array = " << *ptr << endl;
	
  return 0;
}

Pointers en Arrays

Met pointers kan je ook geheugen voor een hele array reserveren. Hiermee wordt het mogelijk dynamische arrays, arrays die je aanmaakt tijdends de runtime van een programma, aan te maken in C++. Onderstaant voorbeeld reserveert geheugen voor een array van 4 integers.

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int *array = new int[4];
  
  array[0] = 1;
  array[1] = 2;
  array[2] = array[0] + array[1];
  array[3] = array[2] * array[2];
  
  
  for(int i=0;i<4;++i)
    cout << "Element " << i << " van de array = " << array[i] << endl;
  
  delete[] array;
  
  return 0;
}

Je ziet dat je een dynamische array precies hetzelfde gebruikt wordt als een normale array. Om het geheugen vrij te geven moet je echter niet delete maar delete[] gebruiken.

Dynamische arrays en normale arrays verschillen niet veel van elkaar. Zowel een dynamische als een normale array slaat het begin adres van de array op. De waarde van het eerste element(element 0) van de array wordt opgeslagen op dat adres. De tweede waarde 4 bytes verder(omdat het een int betreft). In bovenstaand voorbeeld is &array[0] gelijk aan array. Beide geven het geheugenadres van het eerste element. Verder is &array[1] gelijk aan array + 1, &array[2] gelijk aan array + 2 enz. Dit geldt ook allemaal voor normale arrays! Hier een voorbeeld om dit te bewijzen:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int *array = new int[4];
  
  cout << "Dynamische array:" << endl;
  for(int i=0;i<4;++i)
  {
    cout << "\t&array[" << i << "] = " << &array[i] << endl;
    cout << "\tarray + " << i << " = " << (array + i) << endl;
  }
  
  int array2[4];
  cout << "Normale array:" << endl;
  for(int i=0;i<4;++i)
  {
    cout << "\t&array2[" << i << "] = " << &array2[i] << endl;
    cout << "\tarray2 + " << i << " = " << (array2 + i) << endl;
  }
  
  delete[] array;
  
  return 0;
}

Toch zijn er verschillen! Het geheugenadres dat een normale array bevat kan nooit verandert worden. Je kunt een normale array het beste beschouwen als een constante pointer. Als je wilt weten hoeveel bytes een normale array in beslag neemt kun je hiervoor de operator sizeof gebruiken. Doe je dit voor een dynamische array dan zal het aantal bytes dat de pointer in beslag neemt worden geretourneert(op een pentium of een dergelijke processor: 4 bytes).

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int *array = new int[4];
  cout << sizeof array << endl;
  
  int array2[4];
  cout << sizeof array2 << endl;
  
  delete[] array;
  
  return 0;
}

De uitvoer van dit programma is:

4
16

De 4 staat niet voor het aantal bytes dat een integer in beslag neemt, maar het aantal bytes dat een pointer nodig heeft om een geheugenadres op te slaan!

Pointers naar structs en objecten

Als je een applicatie hebt waarin je een struct of een object door moet geven aan een functie en je gebruikt geen pointer dan wordt er een kopie van die struct of dat object gemaakt. Vervolgens zal de functie dat kopie gebruiken en zullen de bewerkingen die de functie op dat kopie maakt geen invloed hebben op het origineel. Het origineel als het kopie zitten namelijk op een apart geheugenadres:

#include <iostream>
using namespace std;

struct test {
  int a;
};

void functie(test t);

int main(int argc, char *argv[]) 
{
  test testje;
  cout << "Adres van de test struct: " << &testje << endl;
  
  functie(testje);
  
  return 0;
}

void functie(test t) 
{
  cout << "Adres van de test struct in functie: " << &t << endl;
}

Dit voorbeeld laat zien dat beide structs op een apart adres zitten. Als je in functie t.a verandert, zal testje.a niet veranderen.

Dit probleem zou je kunnen oplossen door van testje een pointer te maken. Een pointer naar een struct declareren, geheugen reserveren en weer vrijgeven, gaat precies hetzelfde als bij een normale variabele:

struct test {
  int a;
};
int main(int argc, char *argv[]) 
{
  test *t = new test;
  delete t;
  return 0;
}

Hoe moeten we nu element a van de struct opvragen; t.a zal niet meer werken, want t bevat nu een geheugenadres. Een mogelijke oplossing is om eerst de dereferentie-operator op de pointer los te laten. Je hebt dan de struct die zich op die geheugenplaats bevindt. Dan kun je van die struct a opvragen. Dat zou er zo uit zien: (*t).a. Deze manier wordt vrijwel nooit gebruikt C++ kent namelijk een aparte operator, die je direct element a laat opvragen zonder eerst de dereferentie-operator erop los te laten, dit is de operator: ->. In plaats van (*t).a kan je ook t->a schrijven.

#include <iostream>
using namespace std;

struct test {
  int a;
};

void functie(test *t, int i);

int main(int argc, char *argv[]) 
{
  test *testje = new test;
  
  cout << "Adres van de test struct: " << testje << endl;
  
  functie(testje, 10);
  cout << "De waarde van element a is: " << testje->a << endl;
  
  delete testje;

  return 0;
}

void functie(test *t, int i) 
{
  cout << "Adres van de test struct in functie: " << t << endl;
  t->a = 10;
  (*t).a++;
}

Nu zie je dat het geheugenadres van de struct in beide functies gelijk is. Ook zie je in de functie 'functie', de twee manieren waarop je een element van de struct kan opvragen. Bij objecten gaat dat op dezelfde manier, ik laat objecten verder buiten beschouwing aangezien dit artikel niet over object geörienteerd programmeren gaat ;).

Pointers en constanten

Je hebt een constante integer, en naar die constante wil je verwijzen met een pointer. Als je hier een normale pointer naar een integer voor wilt gebruiken dan zal de compiler een fout geven. Je moet daarvoor een pointer naar een constante integer declareren. Dit gaat als volgt:

const int *pointer;

Een pointer naar een constante, kan ook naar een normale variabele verwijzen. Het zal dan echter niet mogelijk zijn om via de pointer, de waarde van die variabele te veranderen.

Het kan natuurlijk ook zijn dat je een constante pointer wilt declareren. Het geheugenadres waar een constante pointer naar verwijst kan niet veranderen. Deze declareer je als volgt:

int *const pointer;

Het is natuurlijk ook mogelijk om een constante pointer naar een constante te declareren:

const int *const pointer;

Alleen maar theorie in deze korte paragraaf. Tijd om zelf eens wat te gaan experimenteren, zou ik zeggen :).

Pointers naar voids

Een pointer naar een void(void *mijn_pointer), kan naar elk gegevenstype verwijzen, ook naar complexe gegevenstypes als structs en objecten. Voordat je de inhoud van een void pointer kunt opvragen, moet je 'm eerst casten naar een pointer naar het type van de inhoud. Dus als je een void-pointer naar een integer laat verwijzen, moet je eerst de void-pointer naar een int-pointer casten, en vervolgens de dereferentie-operator gebruiken om de inhoud op te vragen. Na al deze theorie eindelijk weer eens een voorbeeld!

#include <iostream>
using namespace std;

struct test {
  int a;
};

int main(int argc, char *argv[]) 
{
  int i = 3;
  void *ptr = &i;
  
  cout << *(static_cast<int *> (ptr)) << endl;
  
  test t;
  t.a = 3;
  
  void *ptr2 = &t;
  
  cout << (static_cast<test *> (ptr2))->a << endl;
  
  return 0;
}

Laat je niet te veel van de wijs brengen door de static_cast. Deze cast een variabele naar het type dat tussen het kleiner-dan en groter-dan teken staat. In onze gevallen naar een int-pointer, en een pointer naar de test-struct. Datgene dat je wilt omvormen zet je tussen normale haakjes. Ik laat je zelf liever even puzzelen, nu dat je weet wat een static_cast, in plaats van alles uit te leggen. Je zou het nu allemaal moeten kunnen volgen als je alle theorie bestudeert hebt. Zo niet, mail me dan( jeroen at famdehaas dot org ).

References

Naast pointers kent C++ ook references(referenties). Een reference wordt intern hetzelfde behandeld als een pointer. Maar als je de inhoud van de variabele, waarnaar een reference verwijst, wilt opvragen, heb je hiervoor geen dereferentie-operator voor nodig. Een reference wordt normaal gesproken niet gebruikt om te verwijzen naar geheugen gereserveert met new, hoewel dit wel mogelijk is. Hier een voorbeeld, dat laat zien, hoe je een reference aanmaakt:

#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
  int i = 3;
  int &reference = i;
  
  cout << "Het geheugenadres van i: " << &i << endl;
  cout << "Het geheugenadres van de reference: " << &reference << endl;
  
  reference++;
  
  cout << "i = " << i << endl;
  
  return 0;
}

De beide geheugenadressen zijn zoals te verwachten viel gelijk. Een reference declareer je door voor de naam van de reference een & te zetten. Je hoeft geen operator voor de variabele, waarnaar de reference moet wijzen, te zetten. Zodra je een reference hebt kun je deze op precies dezelfde manier behandelen als de variabele waarnaar deze verwijst. Elke keer als je iets met de reference doet, zal het effect daarvan ook aan het origineel te merken zijn. Juist dŕt maakt het gebruik van references vaak aantrekkelijk.

In C++ is het mogelijk om operators over te loaden. Dan heb je geen kopie nodig van de operandi van die operator, je laat de operator dan werken met references naar de operandi. Kijk maar eens naar dit voorbeeld:

#include <iostream>
using namespace std;

struct test {
  int a;
};

test operator+(const test &t1, const test &t2)
{
  test temp;
  temp.a = t1.a + t2.a;
  return temp;
}

int main(int argc, char *argv[]) 
{
  test test1, test2, test3;
  test1.a = 3;
  test2.a = 4;
  
  test3 = test1 + test2;
  
  cout << test3.a << endl;
  
  return 0;
}

In dit voorbeeld worden er geen lokale kopieën aangemaakt voor de operator +. Deze gebruikt references naar constante test-structs. Hierdoor kunnen de structs in de operator zelf niet veranderd worden, maar wel gebruikt worden om de derde struct te maken.

En tot slot...

Dit was hem! Mijn tutorial over geheugen management in C++. Ik zal waarschijnlijk nog meer gaan toevoegen aan de hand van de reacties die ik krijg. Wil jij reageren op deze tutorial, stuur dan een mailtje naar mij: jeroen at famdehaas dot org. En wie lang genoeg oefent en experimenteert zal zelf leuke dingen uitvinden. Hoe een reference te gebruiken om naar met new gereserveert geheugen te verwijzen, en dat gereserveerde geheugen vervolgens vrij te geven met delete, is een leuke opdracht om eens voor te gaan zitten.

Veel plezier,

Jeroen de Haas
http://2cool.free.fr

Valid XHTML 1.0!