Programmation C++ : CM séance 02
4. Pointeurs et références
4.1. Pointeurs
Une variable est un nom (un identificateur) qui correspond à un emplacement (ou zone) en mémoire. Cet emplacement
- a une adresse mémoire,
- contient une valeur,
- a une taille dépendant du type de la valeur.
Adresse
L'opérateur adresse-de &
donne l'adresse d'une variable, ou encore,
un pointeur vers la (zone mémoire de la) variable :
&variable
Exemple :
int i = 5;
std::cout << "La valeur de i est " << i << "\n"
<< "L'adresse de i est " << &i << std::endl;
Affiche :
La valeur de i est 5
L'adresse de i est 0x7ffcf511eea4
Pointeur
Un pointeur est une variable qui contient une adresse vers un emplacement mémoire :
type_emplacement* pointeur;
Suite de l'exemple :
int* p = &i;
std::cout << "La valeur de p est " << p << std::endl;
Affiche :
La valeur de p est 0x7ffcf511eea4
Contenu
À partir d'un pointeur, pour accéder à la valeur dans l'emplacement mémoire,
on utilise l'opérateur contenu-de *
(encore appelé indirection ou déréférencement) :
*pointeur
Exemple :
int i = 5;
std::cout << "La valeur de i est " << i << "\n"
<< "L'adresse de i est " << &i << std::endl;
int* p = &i;
std::cout << "La valeur de p est " << p << "\n"
<< "La valeur pointée par p est " << *p << std::endl;
Affiche :
La valeur de i est 5
L'adresse de i est 0x7ffcf511eea4
La valeur de p est 0x7ffcf511eea4
La valeur pointée par p est 5
Syntaxe astucieuse : les deux écritures sont équivalentes :
toto* p; // p est un toto*
toto *p; // *p est un toto
Mais attention au piège :
toto* p, q; // déclare toto* p, et toto q
toto *p, *q; // déclare toto *p, et toto *q
toto* p; toto* q; // conseillé
Remarques :
- Un pointeur vers
void
ne peut être déréférencé (erreur à la compilation). - On parlera d'allocation (avec
new
etdelete
) plus tard.
Si p
est un pointeur vers une zone non valide (adresse aléatoire,
variable détruite, zone libérée, etc) alors l'utilisation de*p
a un effet
indéterminé (plantage, ...).
Pointeur nul
Le pointeur nul est symbolisé par 0
ou par nullptr
:
int* q1 = 0, *q2 = nullptr;
std::cout << "La valeur de q1 est " << q1 << "\n"
<< "La valeur de q2 est " << q2 << std::endl;
Affiche :
La valeur de q1 est 0
La valeur de q2 est 0
Conseils :
-
Ne jamais déclarer un pointeur sans l'initialiser, soit à une adresse, soit au pointeur nul.
-
lorsqu'une fonction reçoit un pointeur en paramètre, elle doit d'abord vérifier s'il est non-nul avant de l'utiliser.
On peut encore éviter certains types de problèmes en utilisant des smart pointers (vus plus tard).
4.2. Références
Une variable peut être déclarée comme une référence (un alias) vers une
autre variable avec l'opérateur de référence &
:
type_variable cible;
type_variable& reference = cible;
Exemple :
int i = 5;
int* p = &i;
int& r = i;
std::cout << "La valeur de i est " << i << "\n"
<< "La valeur pointée par p est " << *p << "\n"
<< "La valeur référencée par r est " << r << std::endl;
i = 7; // ou *p = 7; ou r = 7;
std::cout << "La valeur de i est " << i << "\n"
<< "La valeur pointée par p est " << *p << "\n"
<< "La valeur référencée par r est " << r << std::endl;
Affiche :
La valeur de i est 5
La valeur pointée par p est 5
La valeur référencée par r est 5
La valeur de i est 7
La valeur pointée par p est 7
La valeur référencée par r est 7
Avantages :
- pas besoin de déréférencer ;
- pas de problème de pointeur invalide.
Inconvénients :
-
la variable cible doit être donnée à la déclaration de la référence :
int& r; // non initialisée --> erreur de compilation
-
références sur données temporaires → comportement indéterminé
-
Il ne peut y avoir de référence vers une référence, ni de tableau de références, ni de pointeur vers une référence.
Mécanisme très utile dans le passage de paramètres, ou pour simplifier l'écriture lorsque l'accès à une donnée est compliqué (indices, données membre...).
4.3. Paramètres
Le passage de paramètres à une fonction peut se faire de 3 façons :
type_retour nom_fonction (type_paramètre paramètre, ....); // par valeur
type_retour nom_fonction (type_paramètre* paramètre, ....); // par pointeur
type_retour nom_fonction (type_paramètre& paramètre, ....); // par référence
-
Passage par valeur (ou par recopie) : nécessite une recopie du paramètre, et ne permet pas à la fonction de modifier sa valeur de façon visible par l'appelant ; accepte des littéraux.
-
Passage par pointeur (ou par adresse) : efficace (pas de recopie), valeur modifiable ; risque de pointeur invalide ; écriture plus lourde pour les paramètres (avec
&
et*
). -
Passage par référence : efficace (pas de recopie), valeur modifiable ; pas de risque de pointeur invalide ; écriture simple.
Exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Affiche :
1 2
4 3
6 5
Paramètres const
Si on veut garantir qu'un paramètre ne peut être modifié par une fonction,
ou que la valeur retournée ne sera pas modifiable,
on les déclare avec const
.
- Il y a des subtilités sur la position de
const
, la règle est : const
s'applique sur le type immédiatement à gauche, sauf s'il n'y a rien à gauche auquel cas il s'applique sur le type immédiatement à droite.
Exemple avec des déclarations de variables :
int main()
{
const char* s = "foo"; // idem : char const *s
s[0] = 'B'; // error: assignement of read-only location
s = "bar"; // ok
char* const t = s; // error: invalid conversion
const char* const t = s; // ok
t = "baz"; // error: assignment of read-only variable
}
Exemple de passage par référence sans et avec const
:
void f (int& n) {
n = n + 10;
}
void g (const int& n) {
n = n + 100; // error: assignment of read-only reference ‘n’
}
int main()
{
int x = 3;
f(x);
g(x);
std::cout << x << std::endl; // 13
}
5. Objets
Un objet est une structure en mémoire avec
- des données, appelées les données membre ;
- des fonctions pour les manipuler, appelées fonctions membre, ou méthodes.
Un objet est créé à partir de la déclaration d'une classe ; on dit alors que l'objet est une instance de la classe.
5.1. Déclaration
Pour déclarer une classe on utilise le mot clé struct
ou class
, qui sont
presque équivalents. Exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Affiche bark!
(aboiement en anglais).
Style de codage : les types définis par le programme commencent par une majuscule
(exemple : Dog
).
Le mot clé struct
est historique ("C with classes").
La différence entre struct
et class
est au niveau de l'accès aux
membres :
struct
: par défaut tous les membres sont publics ;class
: par défaut tous les membres sont privés.
Pour faire la même chose avec class
il faut préciser que la méthode est
publique :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Il existe plusieurs types d'accès : public
, private
, protected
;
vus plus tard pour les notions d'encapsulation et de classe dérivée.
Choix conseillé :
- utiliser
struct
lorsqu'il n'y a pas de fonction membre (que des données) ; - utiliser
class
dans le cas général.
5.2. Données membre
On peut définir des données membre à n'importe quel endroit de la déclaration
d'une classe (class
ou struct
).
Style de codage : on préfixe toutes les données membre avec m_
pour éviter
des conflits.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Affiche : Bark! Bark! Bark!
Accès externe
L'accès à une donnée membre en dehors des méthodes se fait comme dans les
struct
en C :
-
à partir d'une instance, accès avec un
.
:Dog d; d.m_count = 3;
-
à partir d'un pointeur vers instance, accès par
(* ).
ou->
:Dog* p = &d; // pointeur sur l'instance d std::cout << (*p).m_count << " " // opérateur . : on déréférence d'abord << p->m_count << std::endl; // équivalent, avec opérateur ->
Affiche :
3 3
Accès interne
Dans une méthode, on accède directement aux données membre, comme dans l'exemple :
void bark ()
{
for (int i = 0; i < m_count; i++) ....
}
Dans chaque méthode on a également accès à la variable automatique this
,
qui est un pointeur sur l'instance.
On pourrait donc écrire de manière équivalente :
void bark ()
{
for (int i = 0; i < this->m_count; i++) ....
}
Le pointeur this
n'est utile que dans certaines situations ; le
principe en C++ est de l'utiliser le moins possible pour ne pas alourdir le code.
5.3. Constructeur
Dans l'exemple précédent, on a modifié une donnée membre après son instanciation, et en dehors des méthodes :
Dog d; d.m_count = 3;
Il est plus élégant de l'initialiser lors de la création de l'objet, en définissant un constructeur. Syntaxe : le constructeur est un membre qui porte le nom de la classe, sans type de retour.
class NomClasse {
public:
NomClasse (type_paramètre paramètre, ....) // déclaration constructeur
{
....
}
};
L'appel du constructeur est automatique lors de l'instanciation, qui peut prendre différentes formes :
NomClasse objet {paramètre, ....}; // init. directe par liste
NomClasse objet = {paramètre, ....}; // init. par copie de liste
NomClasse objet = NomClasse {paramètre, ....}; // init. directe avec élision
objet = {paramètre, ....}; // affectation avec recopie
objet = NomClasse {paramètre, ....}; // affectation avec recopie
.... NomClasse {paramètre, ....} .... // dans une expression
On peut aussi utiliser des parenthèses dans certains cas (ancienne syntaxe), mais il y a des risques d’ambiguïté :
NomClasse objet (paramètre, ....); // ok
NomClasse objet (); // erreur, déclaration forward de fonction
Exemple : classe avec un constructeur
class Dog {
public:
int m_count = 1;
Dog (int count) // déclaration du constructeur
{
std::cout << "Constructeur appelé: count = " << count << std::endl;
m_count = count;
}
void bark () ....
};
int main()
{
Dog d {3}; // ou encore : Dog d = {3};
d.bark();
}
Affiche :
Constructeur appelé: count = 3
Bark! Bark! Bark!
Lorsqu'il n'y a pas de constructeur défini, le compilateur synthétise un constructeur par défaut, sans paramètre et qui ne fait rien.
Comme ici on a défini un constructeur, le constructeur par défaut n'est plus
synthétisé, et donc on ne peut plus instancier Dog
sans paramètre :
Dog d; // error: no matching function for call to ‘Dog::Dog()’
Solution : comme toute fonction peut être surchargée, on peut également le faire pour le constructeur :
class Dog {
public:
int m_count = 1;
Dog() // constructeur sans paramètre
{
std::cout << "Constructeur appelé sans paramètre" << std::endl;
}
Dog (int count) // constructeur avec un paramètre
{
std::cout << "Constructeur appelé: count = " << count << std::endl;
m_count = count;
}
void bark () ....
};
int main()
{
Dog d1; d1.bark();
Dog d2 {3}; d2.bark();
}
Affiche :
Constructeur appelé sans paramètre
Bark!
Constructeur appelé: count = 3
Bark! Bark! Bark!
Remarque : dans cet exemple on aurait pu regrouper les constructeurs avec un paramètre optionnel (au détriment de l'affichage) :
Dog (int count = 1) { m_count = count; }
Initialisation explicite
Lorsque le constructeur a un seul paramètre on peut aussi écrire ceci :
Dog d = 3;
Le compilateur réalise alors une conversion implicite en
Dog d = {3};
On peut interdire la conversion implicite en rajoutant le mot réservé explicit devant le constructeur :
explicit Dog (int count = 1) { m_count = count; }
... mais cela interdit aussi l'initialisation par copie de liste :
Dog d; // sans paramètre : ok
Dog d = 3; // init. avec conversion implicite : ERREUR
Dog d {3}; // init. directe par liste : ok
Dog d = {3}; // init. par copie de liste : ERREUR
Dog d = Dog{3}; // init. directe avec élision : ok
Dog d; d = {3}; // affectation et copie de liste : ERREUR
Dog d; d = Dog{3}; // affectation et recopie : ok
5.4. Destructeur
Le destructeur est une fonction membre, appelée automatiquement lors de la destruction de l'instance. Il permet de libérer les ressources éventuelles qu'un objet a acquises pendant sa durée de vie.
Syntaxe : le destructeur est un membre, préfixé par ~
, qui porte le nom de la
classe, sans type de retour et sans paramètre :
class NomClasse {
public:
~NomClasse () // déclaration destructeur
{
....
}
};
Exemple complet :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
Affiche :
Avant bloc
Début bloc
Constructeur appelé sans paramètre
Bark!
Constructeur appelé: count = 3
Bark! Bark! Bark!
Fin bloc
Destructeur appelé, m_count = 3
Destructeur appelé, m_count = 1
Après bloc
Lorsqu'il n'est pas défini, le compilateur synthétise un destructeur par défaut qui ne fait rien.
Lors de la séquence de destruction d'un objet, son destructeur est appelé, puis les destructeurs des objets membre éventuels dans l'ordre inverse de leur construction. (On parlera des objets membre au prochain cours).
Question : le destructeur peut-il être surchargé ?