[hand]
Wat is het nut van een pointer?
Pointers worden wel eens
evil genoemd, wat inhoudt dat je ze alleen moet gebruiken als er geen ander alternatief is of het alternatief nog erger is. Dit betekent dat er een (rood) lampje bij je moet gaan branden op het moment dat je een pointer tegenkomt. Bijvoorbeeld:
Code:
int main()
{
int* p = new int(5);
return 0;
}
Er is geen enkele reden om een integer aan te maken met new i.p.v. gewoon een variabele van het type int aan te maken.
Code:
void Reset(int* p)
{
*p = 0;
}
Er is geen enkele reden om de integer niet 'by reference' door te geven. Sterker nog, het is beter, omdat een reference altijd verwijst naar een valide object en een pointer niet. Dit is dus beter:
Code:
void Reset(int& p)
{
p = 0;
}
Toch zijn er goede redenen om een pointer te gebruiken. Gevallen waarin er geen andere (betere) oplossing mogelijk is. Er zijn drie redenen om pointers te gebruiken. Ik zal ze eerst even noemen om ze vervolgens stuk voor stuk uit te leggen.
<ol><li>[linkje]Indirectie[/linkje].
Een pointer is een verwijzing naar een object. Het verschil met een reference is dat je kunt wijzigen naar welk object een pointer verwijst en een pointer ook naar een leeg object kan verwijzen (als de waarde van de pointer 0 is). Aan een reference moet je bij de definitie een valide object toekennen en kun je vervolgens niet meer veranderen naar welk object de reference verwijst.</li>
<li>[linkje]Dynamische allocatie[/linkje].
Een pointer kan gebruikt worden om een verwijzing naar een object dat dynamisch is gealloceerd (m.b.v. 'new') op te slaan. Hierdoor wordt de levensduur van het object niet meer beperkt tot de scope waarin het zich bevindt.</li>
<li>[linkje]Polymorfisme[/linkje].
Een pointer kan verwijzen naar de basisklasse van een object dat is afgeleid van die basisklasse. Omdat verschillende klassen kunnen zijn afgeleid van dezelfde basisklasse, kun je met een pointer een verwijzing opslaan naar een object waarvan je het type niet precies weet (je weet slechts dat het type is afgeleid van de basisklasse).</li></ol>
1. [kopje]Indirectie[/kopje]
Stel dat we een aantal variabelen van het type int hebben en we willen de variabele met de hoogste waarde met 3 verminderen. Dan kunnen we met een heleboel if/else constructies nagaan welke variabele de hoogste waarde heeft en die aanpassen, maar we kunnen ook indirectie gebruiken.
We maken een klasse aan waarin we een pointer opslaan naar de int met de hoogste waarde. Als de klasse wordt aangemaakt, moet de pointer nog naar niks verwijzen, dus initialiseren we de waarde van de pointer op 0. Vervolgens gaan we de variabelen n voor n af en kijken of de waarde groter is dan het maximum tot nu toe.
Code:
// pointers04.cpp
#include <iostream>
class Maximum
{
public:
Maximum() :m_p(0) {}
// change pointer to specified variable, if the value is larger
bool Evaluate(int& value)
{
if (!m_p || *m_p < value)
{
m_p = &value;
return true;
}
return false;
}
// retrieve a pointer to the maximum value
int* GetMaximum()
{
return m_p;
}
private:
int* m_p;
};
int main()
{
int a = 1, b = 3, c = 2;
Maximum m;
m.Evaluate(a);
m.Evaluate(b);
m.Evaluate(c);
std::cout << "Maximum of a, b and c: " << *m.GetMaximum() << std::endl;
*m.GetMaximum() -= 3;
std::cout << "The value of b is " << b;
return 0;
}
Resultaat:
Maximum of a, b and c: 3
The value of b is 0
In de functie Evaluate moeten we de pointer aanpassen als de waarde van het argument van de functie (value) groter is dan de waarde waarnaar de pointer verwijst. Maar als deze functie de eerste keer wordt aangeroepen, verwijst die pointer nog naar niks, de waarde van de pointer is 0. In dat geval moeten we voorkomen dat we de waarde van het argument gaan vergelijken met de waarde waarnaar de pointer verwijst. De pointer verwijst namelijk niet naar een valide object. Als we de pointer zouden dereferencen, terwijl die verwijst naar het geheugenadres 0, zal het programma crashen.
In de if kijken we dus eerst of de pointer een waarde ongelijk aan 0 heeft. Als dat zo is, geeft !m_p false en wordt het tweede deel (*m_p < value) gevalueerd. Als dat waar teruggeeft, moeten we de pointer aanpassen.
Een dergelijk gebruik van pointers zie je veel terug in object gerienteerde code. Een pointer wordt dan gebruikt als een verwijzing naar een object. Het feit dat je het object waarnaar een pointer verwijst kunt veranderen en een pointer naar een leeg object kan verwijzen (geheugenadres 0), maakt ontzettend veel meer mogelijk.
2. [kopje]Dynamische allocatie[/kopje]
De levensduur van een object geeft aan op welk moment het wordt gecreerd en op welk punt het verwijderd wordt. Tijdens de levensduur van een object kun je het benaderen (de waarde opvragen, functies aanroepen, enz.). Voor en na de levensduur van een object kun je er niks mee. Dit houdt dus in dat je ervoor moet zorgen dat de levensduur van een object dat je wilt gebruiken lang genoeg is om het te gebruiken.
De levensduur van een normaal object begint bij de definitie van het object en eindigt als het object 'out of scope' gaat. Dit wordt ook wel statische allocatie genoemd. Dynamische allocatie doe je in C++ m.b.v. de operator 'new'. Ieder object dat je alloceert met new, moet je later in je programma dealloceren met 'delete'. Doe je dat niet, dan ontstaat er een 'geheugenlek' (memory leak) en dat is een ernstige programmeerfout, ook al heb je er zelf niet pers last van als je je programma draait.
Een voorbeeldje:
Code:
// pointers05.cpp
#include <iostream>
int* AllocateInt(int initialValue)
{
int* p = new int(initialValue);
return p;
}
void DeallocateInt(int* p)
{
delete p;
}
int main()
{
int* ptr = 0;
ptr = AllocateInt(42);
std::cout << *ptr << std::endl;
*ptr = 24;
std::cout << *ptr << std::endl;
DeallocateInt(ptr);
return 0;
}
Resultaat:
Wanneer hebben we dynamische allocatie nou nodig?
Stel dat we een simpel tekenprogramma hebben, waarmee we lijnen kunnen tekenen in een bitmap. In dit programma willen we een zogenaamde oneindige undo mogelijk maken. Dat houdt in dat je alle bewerkingen die je op een bestand hebt uitgevoerd sinds je het hebt geopend, ongedaan kunt maken. Het is dan makkelijk om een object te maken waarin we het ongedaan maken van het tekenen van een lijn kunnen opslaan. Zo'n object zou er zo ongeveer uit kunnen zien.
Code:
// pointers06.cpp
#include <map>
#include <vector>
typedef unsigned int pixelValue;
typedef std::pair<int, int> pixelIndex;
class Undo
{
public:
Undo(pixelIndex startOfLine, pixelIndex endOfLine, const std::vector<std::vector<pixelValue> >& bitmap)
{
// store old values on line
}
void Restore(std::vector<std::vector<pixelValue> >& bitmap) const
{
// restore old values in correct places
}
private:
std::map<pixelIndex, pixelValue> m_OldValue;
};
De vraag is nu hoe we al die undo acties gaan opslaan. We zouden natuurlijk een globale variabele kunnen aanmaken die een vector van Undo objecten opslaat. Maar dat is een vrij slecht design, aangezien je Undo objecten dan voor alle code toegankelijk zijn. Dat kan voor veel programmeerfouten gaan zorgen. Het zou beter zijn om een UndoManager object aan te maken dat verantwoordelijk is voor het bijhouden van alle undo objecten en de laatste actie ongedaan kan maken.
In dit UndoManager object moeten we dan nog altijd alle Undo objecten bijhouden. We zouden een vector van Undo objecten kunnen opslaan in de UndoManager, ware het niet dat Undo daarvoor een default constructor moet hebben. Dat is een constructor die zonder argumenten kan worden aangeroepen. Ons Undo object heeft alleen een constructor waarin het begin en eind van de lijn en een const reference naar de bitmap moet worden meegegeven. Er is dus geen default constructor.
Wat we wel kunnen doen is in de UndoManager een vector van pointers naar Undo objecten opslaan. De Undo objecten worden dynamisch aangemaakt door het object dat de lijn-teken-actie uitvoert. Deze worden vervolgens doorgegeven aan de UndoManager die ze bijhoudt. Op het moment dat de laatste actie ongedaan gemaakt moet worden, krijgt de UndoManager hier de opdracht toe. De UndoManager roept de Restore functie aan van het laatste Undo object en verwijdert dat object vervolgens. De code van de UndoManager zou er ongeveer zo uitzien:
Code:
// pointers07.cpp
#include "Undo.h" // Undo class definition
#include <vector>
class UndoManager
{
public:
~UndoManager()
{
// delete all Undo objects
for (std::vector<Undo*>::iterator it = m_Actions.begin(), end = m_Actions.end(); it != end; ++it)
delete *it;
m_Actions.clear();
}
void AddUndoObject(Undo* p)
{
m_Actions.push_back(p);
}
void UndoLastAction(std::vector<std::vector<pixelValue> >& bitmap)
{
// remove last action from list and undo it
Undo* p = m_Actions.back();
m_Actions.pop_back();
p->Restore(bitmap);
delete p;
}
private:
std::vector<Undo*> m_Actions;
};
Laten we eens kijken wat nu de levensduur van een Undo object is. Op het moment dat er een lijn wordt getekend, wordt er een Undo object aangemaakt (m.b.v. new) dat deze actie ongedaan kan maken. Een pointer naar dit object wordt doorgegeven aan de UndoManager die de pointer opslaat in een vector. Wanneer de actie ongedaan gemaakt moet worden of als de levensduur van het UndoManager object eindigt, wordt het Undo object gedealloceerd. Verschillende objecten zijn dus verantwoordelijk voor de allocatie en deallocatie van een Undo object. Dit is typisch een geval waarin dynamische allocatie goed van pas komt.
Hier kunnen we best nog wat dieper op in gaan. :wink:
* Waarom kunnen we een Undo object niet statisch (normaal) alloceren?
- Als we het object statisch alloceren, beeindigt de levensduur op het moment dat het object out of scope gaat. Als we het dus in een functie aanmaken, bestaat het object niet meer nadat we de functie verlaten hebben.
* Waarom geven we het object niet 'by value' door aan de UndoManager?
- Ten eerste is dit inefficient, omdat het object meerdere malen gekopieerd moet worden. Door het dynamisch te alloceren wordt het precies n keer aangemaakt. Het enige dat we door hoeven te geven is een pointer naar het object.
Nog belangrijker is dat het soms helemaal niet mogelijk is een object te kopiren. Denk bijvoorbeeld aan een type dat een bestand opent als het wordt aangemaakt en weer sluit als het wordt verwijderd. Je kunt een object van dit type niet kopiren, omdat je het bestand dan voor de tweede keer moet openen.
3. [kopje]Polymorfisme[/kopje]
Stel dat we in ons tekenprogramma vlakke figuren, zoals een driehoek, vierkant, rechthoek, cirkel, enz. willen tekenen. We zouden het tekenen van een driehoek kunnen zien als het tekenen van drie lijnen, maar dat is wel wat beperkt. We kunnen dan bijvoorbeeld geen cirkel tekenen (of het wordt iig heel lastig) en nadat we een driehoek hebben getekend, kunnen we die niet meer veranderen. Een andere oplossing is om de tekening te zien als een verzameling vlakke figuren (een lijn is ook een vlak figuur). Maar hoe gaan we een verzameling vlakke figuren bijhouden?
Een verzameling driehoeken is makkelijk bij te houden in een vector. Voor rechthoeken, cirkels, enz. geldt hetzelfde. Maar het is natuurlijk niet erg makkelijk om voor ieder soort vlak figuur een aparte vector bij te moeten houden. Wat we eigenlijk willen is een vector van vlakke figuren bij kunnen houden. Dat kan. We kunnen alle vlakke figuren afleiden van een basis klasse Shape.
Code:
typedef unsigned int pixelValue;
typedef std::pair<int, int> pixelIndex;
class Shape
{
public:
virtual void Draw(std::vector<std::vector<pixelValue> >& bitmap) const = 0;
};
We hebben in de klasse Shape een zogenaamde
puur virtuele functie gemaakt. Dit is een virtuele functie die niet gemplementeerd is. Je doet dit door = 0 achter de functiedeclaratie te zetten. Een klasse met een puur virtuele functie noemen we een
abstracte klasse. Je kunt van een abstracte klasse geen instantie aanmaken, je kunt er alleen andere klassen van afleiden. Een afgeleiden klasse moet de puur virtuele functie implementeren.
Code:
class Triangle : public Shape
{
public:
Triangle(pixelIndex a, pixelIndex b, pixelIndex c)
: m_a(a), m_b(b), m_c(c)
{}
void Draw(std::vector<std::vector<pixelValue> >& bitmap) const
{
// draw the triangle
}
private:
pixelIndex m_a;
pixelIndex m_b;
pixelIndex m_c;
};
We kunnen nu een instantie van een Triangle aanmaken en een pointer naar dat object opslaan. Die pointer moet dan van het type Trangle* zijn. We kunnen echter ook naar dat object verwijzen met een pointer naar een Shape.
Code:
int main()
{
Triangle t(pixelIndex(0, 0), pixelIndex(0, 1), pixelIndex(1, 0));
Triangle* p1 = &t;
Shape* p2 = &t;
return 0;
}
Het object p2 slaat hetzelfde geheugenadres op als het object p1. Het verschil is dat p1 denkt dat er op dat adres een Triangle object staat en p2 denkt dat er op dat adres een Shape object staat. Met de pointer p2 kunnen we echter wel de memberfunctie Draw(...) aanroepen, omdat die in de Shape klasse gedeclareerd is. Laten we eens kijken hoe dit in zijn werk gaat.
Op het moment dat een Triangle object wordt gemaakt, wordt er eerst een Shape object gemaakt. Laten we voor de duidelijkheid het Shape object een member variabele geven. We kunnen bijvoorbeeld ieder figuur een ID geven.
Code:
class Shape
{
public:
Shape(int id) :m_id(id) {}
virtual void Draw(std::vector<std::vector<pixelValue> >& bitmap) const = 0;
private:
int m_id;
};
De klasse Shape heeft nu geen default constructor meer. In de constructor van de klasse Triangle moeten we eerst de constructor van Shape aanroepen. Dit hoefde eerst niet, omdat toen de default constructor van Shape automatisch werd aangeroepen.
Code:
class Triangle : public Shape
{
public:
Triangle(int id, pixelIndex a, pixelIndex b, pixelIndex c)
: Shape(id), m_a(a), m_b(b), m_c(c)
{}
void Draw(std::vector<std::vector<pixelValue> >& bitmap) const
{
// draw the triangle
}
private:
pixelIndex m_a;
pixelIndex m_b;
pixelIndex m_c;
};
Eerst wordt dus de data van het Shape object (de variabele m_id) in het geheugen gezet. Direct daarachter komt de data van het Triangle object. Een pointer naar het Shape object verwijst naar het geheugenadres waar de waarde van m_id begint. Een pointer naar het Triangle object verwijst naar datzelfde geheugenadres. In het laatste geval weet de compiler echter ook nog van het bestaan van de zes integers na de m_id (m_a, m_b en m_c). De compiler weet hier niet van als we een pointer naar een Shape object hebben.
Terug naar het begin. Hoe houden we nu een verzameling vlakke figuren bij? We zorgen er dus allereerst voor dat alle vlakke figuren van de klasse Shape zijn afgeleid. Ze moeten dan dus allemaal de virtuele functie Draw(...) implementeren. Vervolgens kunnen we een vector van pointers naar Shape objecten opslaan. De concrete figuren die we in de tekening willen hebben, moeten we dan dynamisch alloceren. We kunnen ze niet statisch alloceren, omdat ze moeten blijven bestaan totdat de tekening wordt afgesloten en bijgehouden moeten worden in de vector.
De hele code is terug te vinden in
pointers08.cpp
Gebruikte code:
http://ncf.ddrmmr.nl/code/pointers04.cpp
http://ncf.ddrmmr.nl/code/pointers05.cpp
http://ncf.ddrmmr.nl/code/pointers06.cpp
http://ncf.ddrmmr.nl/code/pointers07.cpp
http://ncf.ddrmmr.nl/code/pointers08.cpp
[/hand]