Aller au contenu

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 et delete) 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
#include <iostream>

void afficher (int a, int b)
{
    std::cout << a << " " << b << std::endl;
}

void echanger_par_valeur (int a, int b)
{
    int tmp = a; a = b; b = tmp;
}

void echanger_par_pointeur (int* a, int* b)
{
    int tmp = *a; *a = *b; *b = tmp;
}

void echanger_par_reference (int& a, int& b)
{
    int tmp = a; a = b; b = tmp;
}

int main()
{
    int x, y;
    x = 1; y = 2; echanger_par_valeur    ( x,  y); afficher (x, y);
    x = 3; y = 4; echanger_par_pointeur  (&x, &y); afficher (x, y);
    x = 5; y = 6; echanger_par_reference ( x,  y); afficher (x, y);
}

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
#include <iostream>

struct Dog {                            // déclaration du struct
    void bark ()                        // déclaration d'une méthode
    {
        std::cout << "Bark!" << std::endl; 
    }
};                                      // ; obligatoire

int main()
{
    Dog d;                              // instanciation du struct Dog
    d.bark();                           // appel de la méthode
}

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
#include <iostream>

class Dog {                             // déclaration de la class
public:                                 // Accès publique
    void bark ()                        // déclaration d'une méthode
    {
        std::cout << "Bark!" << std::endl; 
    }
};                                      // ; obligatoire

int main()
{
    Dog d;                              // instanciation de la class Dog
    d.bark();                           // appel de la méthode
}

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
#include <iostream>

class Dog {
public:
    int m_count = 1;                        // déclaration donnée membre

    void bark ()
    {
        for (int i = 0; i < m_count; i++)   // utilisation
            std::cout << "Bark! ";
        std::cout << std::endl; 
    }
};

int main()
{
    Dog d;
    d.m_count = 3;                          // modification
    d.bark();
}

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
#include <iostream>

class Dog {                             // déclaration de la class
public:                                 // Accès publique
    int m_count = 1;                    // donnée membre

    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;
    }

    ~Dog ()                             // destructeur
    {
        std::cout << "Destructeur appelé, m_count = " << m_count << std::endl;
    }

    void bark ()                        // déclaration d'une méthode
    {
        for (int i = 0; i < m_count; i++)
            std::cout << "Bark! ";
        std::cout << std::endl; 
    }
};                                      // ; obligatoire

int main()
{
    std::cout << "Avant bloc" << std::endl;
    {
        std::cout << "Début bloc" << std::endl;
        Dog d1;     d1.bark();
        Dog d2 {3}; d2.bark();
        std::cout << "Fin bloc" << std::endl;
    }
    std::cout << "Après bloc" << std::endl;
}

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é ?