Pointers

Pointers zijn items die tijdens programmeren in C en C++ steeds weer terug komen. Pointers zijn in het begin een beetje verwarrend, vooral door de syntax die je moet gebruiken om ze te laten werken. Maar pointers zijn ook een krachtig hulpmiddel waar je al snel niet meer buiten kunt.

De compiler stelt een aantal eisen aan een pointer. Als je eenmaal weet wat die eisen zijn dan blijkt dat vooral handig te zijn, en vooral bedoeld om fouten met pointers te vermijden. Laten we beginnen met een voorbeeld.

Pointer declaratie

     int  x = 0x12;
     int  y = 0x4050;
     int *pointer = &x;  
Stel je hebt een programma met 2 integer variabelen x en y, en een pointer. Dat ziet er bijvoorbeeld zo uit:

De compiler plaatst deze 3 variabelen op opeenvolgende adressen, bijvoorbeeld vanaf het begin van het werkgeheugen. Dus x krijgt 2 bytes (omdat het een 'int' is), de waarde van x = 0x0012, en die wordt geplaatst op adress 0x0060 en 0x0061 (want voor een int zijn 2 lokaties nodig. Zo ook variabele y, waarde 0x4050 en geplaatst op adress 0x0062.

De derde variabele is hier pointer genoemd. U kunt zelf een naam kiezen voor uw pointers, net als voor de andere variabelen. De declaratie lijkt op een gewone int variabele, maar het * tekentje voor de naam geeft aan dat het hier om een pointer gaat. In dit geval is pointer dus een variabele van het type int *, en dat betekent 'pointer naar int'.

Ook de waarde van de pointer is speciaal. De pointer krijgt hier de waarde &x. Het & tekentje hier betekent dat we niet de waarde van x (=0x0012) maar het adres van x als waarde van de pointer gebruiken, dus hier 0x0060.

Pointer gebruiken

Via de pointer kunnen we nu data gaan manipuleren. De pointer wordt dan gebruikt om een 'int' aan te spreken (lezen of schrijven), terwijl de inhoud van de pointer aangeeft om welke int het gaat.

   int  x = 0x12;
   int  y = 0x4050;
   int *pointer = &x;

   void Nulstellen()
   {  pointer = &x;
     *pointer = 0;
   }
  
In dit voorbeeld wordt de funktie Nulstellen() gedefiniŽerd, en binnen de funktie krijgt de pointer het adres van x en vervolgens wordt de integer via de pointer op '0' gezet. Dus hier wordt x gelijk aan 0. Het * teken voor pointer geeft hier aan dat niet de pointer wordt geschreven, maar de int die door de pointer wordt aangewezen.

Pointer als funktie parameter

   int x = 0x12;
   int y = 0x4050;

   void Nulstellen(int *pointer)
   {  *pointer = 0;
   }

   void main(void)
   {  ...
      Nulstellen(&x);
      Nulstellen(&y);
      ...
   }
  
Maar het wordt al interessanter als we de waarde van de pointer als parameter aan de funktie kunnen meegeven. Hier wordt weer de pointer gebruikt om een variable op '0' te zetten. Maar de waarde van de pointer wordt nu bij de aanroep van de funktie bepaald. Het & teken bij de aanroep van de funktie betekent inderdaad dat het adres van x en daarna het adress van y aan de funktie worden meegegeven. Op deze manier kun je dus een funktie gebruiken om een bepaalde aktie uit te voeren op verschillende variabelen.

Pointer type

Het type van een pointer wordt door de compiler streng gecontroleerd, net als het type van andere variabelen. Dus u kunt niet zomaar een getal in de pointer opslaan. Eerst een paar voorbeelden.

   int   x = 0x12;      // Een int variabele

   char  c = 'A';       // Een char variabele

   char *pointer1 = &c; // Ok. &c is 'adres van char';

   pointer1 = &x;       // Fout. &x is 'adress van int'
                        //       en pointer1 is 'pointer naar char'

   pointer1 = 0x0060;   // Fout. 0x0060 is een int, en dus niet 'pointer naar char'

   int *pointer2;       // Ok, maar pointer niet geinitialiseerd.
                        // De compiler heeft daar geen probleem mee.

   *pointer2 = 0x100;   // ?? het getal 0x100 wordt via de pointer in het geheugen geschreven
                        //  maar de pointer is niet geÔnitialiseerd. dus u weet niet waar dat getal
                        //  terecht komt. Kan heel vervelend uitwerken.

   pointer2 = &x;       // Ok. 'adress van int' naar 'pointer to int'
  

Type controle uitschakelen (type-cast)

Onze programmeertalen (C en C++) voorzien in een systeem van type-casting om het type van een variabele aan te passen. Zo een type-cast is tijdelijk, en werkt alleen maar binnen een statement (gelukkig maar), maar daarmee wordt het wel mogelijk om de type-controle te omzeilen. Een type-cast bestaat uit een type definitie tussen haakjes, die voor een waarde wordt gezet. Daarmee krijgt de waarde tijdelijk een ander type.

   int  x = 0x12;       // Een int variabele
   int *pointer2;       // Een pointer naar int

   pointer2 = 0x0060;   // Type fout, 0x0060 is geen pointer.

   pointer2 = (int *)0x0060; // Ok. 0x0060 is nu tijdelijk wel een 'int pointer'.
  
Hier ziet u dat de type-cast voor 0x0060 het type van dat getal verandert zodat de waarde nu wel in de pointer kan worden geschreven. Dit soort type-casts worden alleen gebruikt voor variabelen die op vaste adressen in het geheugen liggen, zoals bijvoorbeeld voor I/O registers. En in speciale gevallen, zoals een debug programma waarmee je direkt in het geheugen kunt kijken.

void pointer.

Een speciaal soort pointer is de void pointer. Dat is nog altijd een pointer, maar dan een pointer waarvan het type nog niet is bepaald. Die worden vooral gebruikt in combinatie met unions, waarmee je verschillende variabelen op dezelfde geheugen-lokatie kunt opslaan.

Een void pointer kunt u initialiseren met elk type pointer zonder dat de compiler daarover klaagt.

   int   x = 0x12;      // Een int variabele
   char  c = 'A';

   void *pMyPointer;    // Een 'pointer naar void'

   pMyPointer = &x;     // Ok, &x is een pointer.
   pMyPointer = &c;     // Ok, &c is ook een pointer.
   pMyPointer = 0x0060; // Fout. 0x0060 is geen pointer
   pMyPointer = (void *)0x0060; // Ok. 0x0060 is nu tijdelijk wel een pointer.
  

Dus u kunt een void pointer initialiseren zonder een type-cast, maar bij het gebruik van de pointer is dan wel altijd een type-cast nodig. Zonder type-cast weet de compiler immers niet hoeveel bytes gelezen of geschreven moeten worden. Hier weer een aantal voorbeelden.


   void MyFunction(void *pVoidPointer)
   {  *pVoidPointer = 0x00;             // Fout. Kan niet schrijven via void pointer.
      *(char *)pVoidPointer = 0x00;     // Ok. pVoidPointer nu tijdelijk een char pointer.
      ((char *)pVoidPointer)[0] = 0x00; // Ok. index via tijdelijke een char pointer.
      ((char *)pVoidPointer)[1] = 0x00; // Ok. index via tijdelijke een char pointer.
   }

   int  x;
   char y[2];
   char a;
   char b;
   MyFunction(&x);   // Ok. beide bytes worden op 0x00 gezet.
   MyFunction( y);   // Ok. Allebei de chars worden op 0x00 gezet.
   MyFunction(&a);   // Ok? Wordt door de compiler geaccepteerd,
                     // - maar de funktie schrijft 2 bytes
                     // - en dus wordt er ook naast de variabele 'a' geschreven.
                     // - In dit geval wordt dus ook 'b' op 0x00 gezet!.
  

Void pointer worden vaak gebruikt om funkties te maken die direkt in het geheugen werken zonder te weten wat er precies op die plaats in het geheugen staat. Een MemClear() funktie bijvoorbeeld kan zo worden gedefiniŽerd:


   void MemClear(void *pVoidPointer, int NrBytes)
   {  for( int i = 0; i < NrBytes; i++)
      { ((char *)pVoidPointer)[i] = 0x00;
      }
   }

   int  x;                    // 'int' dus 2 bytes.
   char y[20];                // array van 20 'char's
   char a;                    // 'char', dus 1 byte.

   MemClear(&x, sizeof(x));   // Ok. Beide bytes worden op 0x00 gezet.
   MemClear( y, sizeof(y));   // Ok. Alle 20 chars worden op 0x00 gezet.
   MemClear(&a, sizeof(a));   // Ok. Schrijft een enkele byte.
 

Zoals u ziet moet u dan wel bij het aanroepen van de funktie opgeven hoeveel bytes moeten worden gecleared. En dan wordt het ook mogelijk om gevaarlijke dingen te doen zoals bijvoorbeeld:


   int  x;                    // 'int' dus 2 bytes.
   char y[20];                // array van 20 'char's
   char a;                    // 'char', dus 1 byte.

   MemClear(&x, 2);           // Ok? Werkt alleen op machines met een 2-byte int.
   MemClear(&x, 23);          // Ok? Maar gevaarlijk. Schrijft 23 bytes vanaf het adress van x.
                              // - Grote kans dat daarmee ook 'y' en 'a' worden geÔnitialiseerd.
                              // - Maar niet zeker. De compiler kan andere variabelen tussenvoegen
                              //   of gaps tussen variabelen invoegen.
  
Dit soort construkties zijn wel mogelijk maar zijn erg gevaarlijk. Dus dat kunt u beter niet doen.

NULL pointer

Het komt vaak voor dat een pointer ongeldig is, omdat het programma nog geen destination heeft bepaald. Dan wordt de pointer vaak op 0 gezet. Voor dat doel is een speciale #define beschikbaar met de naam NULL. Zo een NULL pointer kan gemakkelijk getest worden in bijvoorbeeld if statements.

  char    x =  0;
  char    y = 12;
  char   *pMypointer = NULL;

  void MyFunction(char *pPointer)
  {  if(pPointer)
     {  *pPointer = 0;
     }
  }

  MyFunction(NULL);       // Aanroep 1
  MyFunction(pMyPointer); // Aanroep 2
  MyFunction(&x);         // Aanroep 3
  pMyPointer = &y;
  MyFunction(pMyPointer); // Aanroep 4

  
Hier ziet u 2 variabelen x en y en een pointer pMyPointer. De pointer wordt in eerste instantie op NULL gezet, en krijgt daarmee ook echt de waarde 0. Binnen een conditie zoals if of while wordt een NULL pointer gezien als 'false'. Dus daarmee kunt u testen of de pointer een waarde heeft.

Bij aanroep 1 wordt NULL als parameter meegegeven. De funktie test daarop, en het nulstellen via de pointer wordt overgeslagen.

Bij aanroep 2 wordt pMypointer als parameter meegegeven. En die is ook nog NULL, dus weer wordt het nulstellen via de pointer binnen de funktie overgeslagen.

Bij aanroep 3 wordt het adres van x als parameter meegegeven. Dat is een geldig adress, ongelijk aan NULL, dus Nu wordt het nulstellen via de pointer binnen de funktie wel uitgevoerd.

Daarna wordt pMypointer geladen met het adres van y en de funktie wordt opnieuw aangeroepen. Dit keer heeft de pointer wel een waarde en dus wordt nu y via de pointer op nul gezet.

Pointer operator.

Een operator is een teken dat een bewerking aanduidt. Hierboven hebben we gezien dat het '*' teken wordt gebruikt om een geheugen lokatie aan te spreken die door een pointer wirdt aangewezen. Maar hetzelfde '*' teken wordt ook gebeuikt als vermenigvuldig teken. Dat kan verwarring geven bij het lezen van het programma. Want wanneer is het een pointer operator en wanneer is het een multiply operator? Gelukkig ligt de volgorde wel vast (operator preference), maar u kunt ook altijd haakjes toevoegen om de leesbaarheid voor uzelf te verbeteren.

  char    x = 12;
  char   *pMypointer = NULL;

  void Multiply(int *pPointer, int Factor)
  {  if(pPointer)
     {  *pPointer = Factor * *pointer;
     }
  }

  Multiply (&x, 10);
  
Hier ziet u een voorbeeld van een Multiply funktie die een waarde via een pointer kan vermenigvuldigen met een opgegeven Factor. Hier kunt u ook zien dat het '*' tekentje telkens voor andere doelen wordt gebruikt. In de funktie declaratie int *pointer betekent int * dat pointer een pointer-naar-een-int is.

In de bewerking regel (*pPointer = Factor * *pointer; komt 3 keer een '*' teken voor. De eerste *pPointer geeft aan dat een geheugenlokatie via de pointer wordt geschreven, want *pointer staat links van het '=' teken. De eerste '*' na Factor duidt op een vermenigvuldigen, omdat er altijd een bewerking zoals +, -, * volgt tussen getallen in een expressie. De derde '*' kan dan alleen nog betekenen dat er via pointer een geheugenlokatie wordt gelezen.

Dus uiteindelijk wordt tijdens de Funktie-aanroep Multiply (&x, 10); de waarde van x met 10 vermenigvuldigd, en in x teruggeschreven. In de opdracht regel

Pointer coding style

  char*pMypointer;      // Ok,(1)
  char*    pMypointer;  // Ok (2)
  char  *  pMypointer;  // Ok (3)
  char    *pMypointer;  // Ok (4)
  
De compiler behandelt het type, het '*' teken en de naam van de pointer als aparte tokens. U kunt zelf white-space tussen voegen zoveel u wilt, en dat kan handig zijn om de leesbaarheid voor uzelf te verbetern. Hier een aantal voorbeelden van de mogelijkheden.

Voorbeeld 1 is mogeljk maar niet erg leesbaar.

Voorbeeld 2 legt de nadruk op het type van de variabele. Eigenlijk staat daar : "De variabele pMyPointer heeft type char*.

Voorbeeld 4 legt meer de nadruk op het type van de pointer. Eigenlijk staat daar dus "De pointer *pMyPointer wijst naar een char".

Voorbeeld 3 zit daar tussenin en heeft als voordeel dat het '*' tekentje meer opvalt.

U kunt zelf kiezen wat voor u het beste werkt. Persoonlijk gebruik ik meestal style 3, zoals u kunt zien in de voorbeelden op deze website. Ook kunt u zien dat de naam van de pointer hier met een 'p' begint. Ook dat is onderdeel van een coding-stijl. Persoonlijk vind ik het wel fijn als je aan de naam kunt zien dat het om een pointer gaat.

Array Pointer

   char Naam[] = "ABCD";

   void Nulstellen(char *pointer)
   {  *pointer = 'a';
       pointer += 1;
      *pointer = 'b';
       pointer += 1;

      *pointer++ = 'c';
      *pointer++ = 'd';
   }

   void main(void)
   {  ...
      Nulstellen(Naam);
      ...
   }
  
Nu worden pointers ook veel gebruikt om array's te verwerken. Een text regel is bijvoorbeeld een array van char's, dus een opeenvolgende reeks geheugencellen die elk 1 letter van de reeks vasthouden. De funktie Nulstellen(char *pointer) heeft als parameter een 'pointer naar char', en die pointer krijgt een waarde bij de aanroep van de funktie. In main() wordt de funktie aangeroepen met het statement Nulstellen(Naam);

Nu zou je verwachten dat hier een & teken gebruikt zou moeten worden om het adress van Naam te nemen maar dat hoeft hier niet omdat Naam een array is, en array's worden altijd als pointer doorgegeven aan funkties.

Binnen de funktie wordt de pointer gebruikt om de array te manipuleren. Het statement *pointer='a'; bijvoorbeeld kunt u lezen als : * (de lokatie die aangewezen wordt door) pointer = (krijgt een nieuwe waarde) 'a' (kleine letter a, ofwel 0x61).

Bij het aanroepen van de funktie krijgt pointer het adress van de eerste letter in de array. Binnen de funktie wordt de pointer verhoogd dmv het statement pointer += 1;. Hier wordt dus '1' bij de pointer opgeteld, zodat de pointer naar de volgende letter in de array wijst. Maar het rekenen met pointers is wel speciaal. '1' optellen bij de waarde van een pointer betekent altijd dat de pointer wordt verhoogd naar het volgende item in de array. Dus de feitelijke waarde die bij de pointer wordt opgeteld is afhankelijk van het type pointer. Een char pointer zoals hier wordt verhoogd met 1, terwijl een 'int pointer' in dit geval met 2 verhoogd wordt om naar de volgende int te wijzen. '1' optellen bij een pointer betekent dus feitelijk 'de pointer verhogen naar de volgende entry in de array'.

Het statement *pointer++='c'; laat zien dat dit ook in een enkel statement kan. Hier wordt de waarde 'c' via de pointer geschreven, en vervolgens wordt de pointer opgehoogd. De volgorde van de bewerkingen is hier belangrijk. Als je bijvoorbeeld het statement *pointer++; gebruikt, dan zou je denken dat een byte via de pointer wordt verhoogd, maar dat gebeurt niet. Hier wordt eerst een geheugenlokatie gelezen via de pointer *pointer, en daarna wordt de pointer opgehoogd pointer++. Om een variabele via de pointer op te hogen moet je de volgorde van de bewerkingen opgeven dmv haakjes : (*pointer)++;.

Het klinkt misschien vreemd, maar de C en C++ compilers hebben er geen probleem mee om een variabele te lezen en daar vervolgens niks mee te doen. Een statement als x; is bijvoorbeeld gewoon geldig (en tamelijk zinloos).

Pointer index

   void Nulstellen(char *pointer)
   {  pointer[0] = 'a';
      pointer[1] = 'b';
      pointer[2] = 'c';
      pointer[3] = 'd';
   }
  
Nu heeft de compiler naast * nog een andere manier om met pointers om te gaan, en wel de rechte haakjes. Dat werkt omdat C en C++ pointers en arrays in hoge mate gelijk behandelen. Dus inplaats van *pointer kunt u ook pointer[0] gebruiken. Dat heeft hetzelfde effect, maar is veel duidelijker. Het statement pointer[0]++; heeft nu wel het beoogde effect van ophogen van een byte via de pointer.

Ook hier is de berekening van de offset afhankelijk van het type van de pointer. In dit geval is pointer een char pointer, dus dan wijst de pointer naar een 1-byte variable. En dus wijst pointer[1] naar een lokatie die 1 byte verder ligt. Maar als de pointer een int pointer was geweest, dan wijst pointer[1] naar de eerst-volgende 2-byte waarde, en dus 2 bytes verder in het geheugen.

Array grenzen bewaken

   void Nulstellen(char *pointer)
   {  pointer[10] = '0';
   }
  
In 'C' en ook in 'C++' moet je zelf in het programma de grenzen van een array in de gaten houden. De funktie hiernaast bijvoorbeeld schrijft de waarde 0 op de elfde byte vanaf het begin van de array. Dat is geen probleem als de array 11 of meer bytes groot is, maar kan een probleem zijn als de array korter is. Deze funktie weet niet hoe groot de array is en schrijft de byte eventueel buiten de array, zodat een andere byte overschreven wordt. En dat kan allerlei vervelende gevolgen hebben voor het verloop van het programma. Het is daarom een goede gewoonte om bij array funkties naast de pointer ook de omvang van de array als parameter mee te geven. Dan kunt u binnen de funktie testen of u nog wel binnen de grenzen blijft.

   void Nulstellen(char *pString, int NrBytes)
   {  for(int i = 0; i < NrBytes; i++)
      {  pString[i] = '0';
      }
   }

   char StringNaam[4];
   char StringAdress[20];
   Nulstellen(StringNaam, sizeof(StringNaam));
   Nulstellen(StringAddress, sizeof(StringAddress));
  

In het programma hierboven is een funktie void Nulstellen(char *pString, int NrBytes) gedeclareerd met 2 parameters, een char pointer, en de lengte van de array. Binnen de funktie worden dan alle chars in de array op '0' gezet (ASCII '0' = 0x30).

Vervolgens wordt een char array genaamd StringNaam gedefiniŽerd met lengte 4. En tot slot wordt onze funktie aangeroepen Nulstellen(StringNaam, sizeof(StringNaam));. sizeof is een compiler funktie die een unsigned integer geeft met de grootte van de variabele (gemeten in bytes), dus in dit geval 4. De funktie weet zodoende dus hoe groot de String is, en blijft binnen de grenzen van de string.

De funktie wordt nu ook gebruikt op de langere StringAddress, en ook die wordt nu volledig gevuld met '0' characters.

References

Let wel: References zijn niet beschikbaar in C, maar wel in C++. Als u references wilt gebruiken dan moet u de C++ compiler activeren om uw programma te compileren want anders krijgt u foutmeldingen. AVR-Gcc schakelt automatisch om naar C++ als uw sourcefile eindigt op de extensie ".cpp". Dus "Programma.c" wordt als C behandeld, en "Programma.cpp" als C++.

Reference voorbeeld

Een reference is niet hetzelfde als een pointer, maar er zijn wel raakvlakken. Dmv references kunt u soortgelijke akties programmeren zonder de bijbehorende syntax. U kunt een reference zien als een 'alias', dwz een alternatieve naam van een variabele.

   int  x = 0x12;
   int &y = x;

   y = 0x14;
   y += 1;
  
In dit programma wordt de declaratie int &y = x; gebruikt. Let op het & teken dat staat voor reference. Dus y is hier gedeclareerd als 'reference naar de integer x' en vervolgens kunt u y gebruiken alsof u x gebruikt. De bewerkingen op y worden dan vertaald naar bewerkingen op de waarde van x. De instruktie y=0x14; heeft dus als gevolg dat de waarde van x verandert naar 0x14, en met y += 1; wordt er nog '1' opgeteld en daarmee wordt x dus 0x15.

Reference parameters

References zijn vooral handig als funktie parameters. Via de reference kunt u dan werken met variabelen die buiten de funktie zijn gedefiniŽerd, en toch de normale syntax gebruiken die u gewend bent.

   int  x = 0x12;
   int  y = 0x3A;

   void Increment(int &Parameter)
   {  Parameter += 1;
   }

   Increment (x); // x wordt 0x13
   Increment (y); // y wordt 0x3B
  
Hier heeft u bijvoorbeeld een void Increment(int &Parameter) funktie die een integer reference als parameter heeft, aangegeven door de & voor de naam van de parameter. Het gevolg is dat u bewerkingen kunt uitvoeren op de parameter, maar die worden dan feitelijk uitgevoerd op de externe variabelen.

In dit voorbeeld wordt in Increment(x); de funktie uitgevoerd op de variabele x, en daarna met Increment(y); op variabele y. Dus eerst wordt x verhoogd naar 0x13, en daarna wordt y verhoogd naar 0x3B;

Zoals u ziet hoeft u hier geen & te gebruiken bij de aanroep van de funktie, omdat die al in de funktie definitie staat.