Les pointeurs - Quelques notions de base
Patrick Lavoie
anti-bug informatique
5 mai 2002
Copyright © 2002
Pointeur
Un pointeur n'est simplement qu'une variable qui pointe sur un emplacement dans la
mémoire. La valeur qu'il contient, c'est l'adresse mémoire.
Allocation
Une règle générale concernant l'allocation et la déallocation de la mémoire est que celui
qui alloue de la mémoire est également celui qui libère la mémoire allouée. Si par exemple
un objet alloue de la mémoire pour la retourner à l'appelant, seulement cet objet devrait
pouvoir libérer la mémoire et fournir une méthode.
class foo
{
public :
foo()
{
m_memory = new char[ 10 ];
}
~foo()
{
delete m_memory;
}
foo* CreateObject()
{
return new foo;
}
void ReleaseObject( foo* object )
{
delete object;
}
private :
char* m_memory;
};
void main()
{
foo _myFoo;
foo* _newFoo = _myFoo.CreateObject();
_myFoo.ReleaseObject();
}
Dans l'exemple précédant, on peut voir que la méthode ReleaseObject libère la
mémoire allouée par CreateObject . En procédant de cette manière, on peut
facilement décider d'utiliser un autre mécanisme d'allocation de mémoire sans affecter le
reste du programme.
Par cette même règle, on libère la mémoire de la même façon qu'elle a été allouée. De plus,
pour libérer la mémoire allouée, si on n'utilise pas la bonne méthode de déallocation, on
aura comme résultat de le mémoire corrompue ou une fuite de mémoire. Alors voici quelques
équivalences :
new - delete (C++)
new[ ] - delete[ ] (C++)
malloc - free (C run-time)
calloc - free (C run-time)
GlobalAlloc - GlobalFree (Win32)
LocalAlloc - LocalFree (Win32)
En suivant ces règles, on évite plusieurs maux de têtes en recherchant des fuites de mémoire
ou de la corruption de la mémoire. Par le fait même, on ouvre également la porte à la
possibilité d'apporter des changements plus facilement, plus rapidement et surtout en
minimisant le risque d'introduire des nouvelles erreurs.
Assignation
En assignant une valeur à un pointeur, on ne fait que déplacer l'endroit vers lequel il
pointe dans la mémoire. Pour pouvoir accéder au contenu vers lequel in pointe, on doit le
déréférencer. Pour déréférencer un pointeur, on ajoute un * devant le pointeur.
int i = 10;
int j = 7;
int* pi = &i; // pi pointe sur i
int* pj = &j; // pj pointe sur j
*pi = 20; // on déréférence pi pour accéder à son contenu. i = 20
*pj = 14; // on déréférence pj pour accéder à son contenu. j = 14
pi = pj; // pi pointe au même endroit que pj
*pj = 5; // j = 5, *pj = 5 et *pi = 5 parce que pi pointe sur pj
Références
Les références ont été introduites avec le C++. Les références jouent le même rôle que les
pointeurs à l'exception que leur but est de simplifier la lecture et la compréhension du
code. Une référence ne fait que référencer une adresse mémoire exactement comme un pointeur.
Un pointeur peut être NULL tandis qu'une référence ne peut être NULL
puisque la référence fonctionne comme un pointeur déréférencé et qu'on ne peut déréférencer
un pointeur NULL . Une référence se lit comme une variable ordinaire,
c'est-à-dire qu'on n'a pas besoin d'ajouter un * pour le déréférencer.
int i = 5;
int* pi = &i;
int& ri = i; // référence de ri sur i
int& rj = *p; // référence de rj sur le contenu de p
*pi = 10; // on doit déréférencer pi pour accéder à son contenu
ri = 5; // tandis qu'une référence fonctionne comme une variable
rj = 10; // ou un pointeur déjà déréférencé
Où vont les symboles * et &?
Ces symboles prennent différentes significations dépendant de l'endroit où ils sont situés.
Le symbole & peut vouloir dire qu'on déclare une référence ou bien vouloir dire qu'on veut
l'adresse mémoire d'une variable. Le symbole * peut vouloir dire qu'on veut déréférencer un
pointeur ou qu'on veut déclarer un type de pointeur.
int* pi = new int( 5 ); // * après le type indique un type de pointeur
int i = *pi; // * avant une variable indique qu'on déréférence
int& ri = i; // & après le type indique un type de référence
int* pj = &i; // & avant une variable indique l'adresse mémoire
Arithmétique de pointeurs
Les pointeurs permettent d'accéder et de parcourir la mémoire. Si on a un tableau de valeurs,
on peut accéder à ses valeurs de deux manières. Une façon claire d'accéder aux valeurs est
d'utiliser les symboles [ ].
int _array[ 10 ];
_array[ 3 ] = 10;
Ce qui se passe en réalité pour accéder au quatrième élément du tableau, c'est qu'on prend
l'adresse de base du tableau et qu'on lui additionne 4 fois la taille d'un élément.
int _array[ 10 ];
int* p = _array;
p += 4;
*p = 10;
ou
int _array[ 10 ];
int* p = _array;
*( p + 4 ) = 10;
L'arithmétique de pointeur prend en compte la taille d'un élément. Alors incrémenter un
pointeur de int de 1, augmente en réalité l'adresse mémoire sur laquelle il
pointe de 4 parce que la taille d'un int est 4 octets. Tandis qu'un pointeur de
char augmenterait seulement d'un octet en mémoire puisque qu'un char
n'occupe qu'un seul octet.
Si, par exemple, on a un tableau de deux double s, voici sa représentation en
mémoire :
double _array[ 2 ];
// p pointe à l'adresse mémoire 0x20 puisque _array se situe à 0x20.
double* p = _array;
// Ici, on fait pointer _element1 à l'adresse mémoire occupé par le premier
// élément du tableau. Faire pointer sur le premier élément ou à l'espace
// mémoire du tableau, c'est équivalent. Donc _element1 pointe aussi sur 0x20.
// Notez bien le symbole & devant _array. Si on avait seulement écrit
// _array[ 0 ], ça aurait indiqué le contenu du premier élément. En ajoutant
// le symbole & devant _array, ça indique qu'on veut en fait l'adresse du
// premier élément.
double * _element1 = &_array[ 0 ];
// Ici, _element2 pointe sur le deuxième élément du tableau. Il pointe donc
// à l'adresse 0x28 puisque la taile d'un double est de 8 octets.
double * _element2 = &_array[ 1 ];
// On aurait aussi bien pu accéder au deuxième élément de cette manière.
// _element2 pointe aussi sur 0x28. En additionnant 1, le pointeur
// s'incrémente de 1 fois la taille d'un double, soit de 8 octets. Ici, on
// n'a pas besoin d'ajouter le symbole & devant _array puisqu'on n'a pas
// indiqué qu'on voulait le contenu d'un élément en utilisant les [].
// _array est déjà un pointeur et on pointe sur l'élément suivant en lui
// additionnant 1.
_element2 = _array + 1;
Cast C vs Cast C++
En C++, il y a quatre types de cast. Il y a reinterpret_cast ,
static_cast , dynamic_cast et const_cast .
L'utilité du const_cast se limite à éliminer le modificateur const .
Le reinterpret_cast , convertit aveuglément n'importe quoi en n'importe quoi. Le
compilateur n'effectue aucune validation sur la conversion. Il est donc très facile de mal
utiliser reinterpret_cast et de causer des problèmes. Le reinterpret_cast
est équivalent au cast C où on ne fait que mettre entre parenthèses le type vers lequel on veut
convertir. Il est souvent utilisé pour convertir un pointeur quelconque en pointeur
void ou en valeur int et vice-versa.
Le static_cast , fait quelques validations au moment de la compilation. Il est
donc plus sécuritaire que le reinterpret_cast . Il peut cependant laisser passer
quelques erreurs qui ne peuvent qu'être détectées au moment de l'exécution.
class Base {};
class Derivee : public Base {};
void foo( Base* pb, Derivee* pd )
{
// Correct parce pd est un pointeur sur Derivee et qu'il dérive de Base
Base* pTmpB = static_cast< Base* >( pd );
// Peut-être incorrect parce pb ne pointe pas nécessairement sur une
// instance de Base. Ce genre de validation ne peut être fait qu'au moment
// de l'exécution.
Derivee* pTmpD = static_cast< Derivee* >( pb );
}
Par contre, le dynamic_cast fait sa validation au moment de l'exécution. Alors
dans l'exemple précédent, si on avait remplacé le second static_cast par un
dynamic_cast , on aurait pu détecter si le pointeur pb pointait effectivement
sur une instance de Derivee. Si dynamic_cast détecte une erreur, il retourne
NULL . Avec le dynamic_cast on n'a donc jamais de pointeur invalide
contrairement aux autre casts.
|