II Cycle de vie des objets

La vie d'un objet se résume à trois épisodes fondamentaux : sa création, son utilisation puis sa destruction. La création et la destruction font appel à des méthodes particulières : respectivement les constructeurs et le destructeur.

Plan du chapitre :

  1. Création d'objets : les constructeurs
  2. Instanciation
    1. Les classes d'allocation
    2. Création d'instances simples
  3. Invocation de méthodes
    1. Appel de méthodes d'instance
    2. Appel de méthodes de classe
    3. Accès aux attributs
  4. Appels de méthodes et objets constants
  5. Mort des objets
  6. Création et destruction de tableaux — constructeur par défaut

Liste des figures

Liste des programmes

Liste des tableaux

2.1 Création d'objets : les constructeurs

La création d'objets repose sur des méthodes spéciales nommées constructeurs. Celles-ci ont pour but de réaliser la partie « instanciation » de la création d'un objet, c'est à dire, le positionnement de la valeur initiale des variables d'instance.

Les constructeurs sont faciles à repérer : ils portent le même nom que la classe.

Par exemple, si l'on reprend la classe Point, son constructeur est facile à repérer :

Point::Point(int absc=0, int ordo=0);

Cette méthode n'a qu'un seul but : initialiser les attributs abscisse_ et ordonnee_ du nouvel objet créé et tenir à jour le nombre d'instances disponibles dans le système. Notez au passage qu'elle utilise la possibilité offerte par le C++ de proposer des valeurs par défaut aux arguments des fonctions qui s'étend tout naturellement aux méthodes. Examinons son code de plus près :

Point::Point(int absc=0, int ordo=0)
{
  abscisse_=absc;
  ordonnee_=ordo;
  NbPoints_++;
}

Programme 2.1 Le constructeur de la classe Point première mouture

Ce code peut néanmoins être utilisé en utilisant une liste d'initialisation comme dans l'exemple suivant :

Point::Point(int absc=0, int ordo=0) :
             abscisse_(absc),
             ordonnee_(ordo)
{
  NbPoints_++;
}

Programme 2.2 constructeur de Point utilisant une liste d'initialisation

Vous notez ici que le corps de la méthode est limité à l'incrémentation du compteur vide . La liste d'initialisation, où sont initialisés abscisse_ et ordonnee_ commence après le signe ":"

La syntaxe de cette liste est :

: attribut(valeur_initialisation) {, attribut(valeur_initialisation)}n

Ainsi, si l'on se réfère au code précédent, les attributs abscisse_ et ordonnée sont respectivement initialisés avec les valeurs des paramètres absc et ordo. N'allez pas croire que vous pouvez uniquement utiliser des paramètres directs dans ce genre de construction, valeur_initialisation peut être n'importe quelle expression C++ valide comprenant des opérateurs, des appels de fonctions ou de méthodes sur tout autre objet que celui en cours de création. Par exemple, si nous avions eu un attribut codant la distance du point à l'origine et nommé distance_, l'initialisation aurait pu être :

Point::Point(int x=0, int y=0) : 
  abscisse_(x), 
  ordonnee_(y),
  distance_(sqrt(x*x)+(y*y)) 
{
  NbPoints_++;
};

Programme 2.3 Utilisation d'une expression complexe dans la liste d'initialisation

Pour les attributs atomiques, vous avez le choix entre l'utilisation d'affectations dans le corps du constructeur et la liste d'initialisation. Il est même possible de mixer les deux !

class Point
{

// Code inutile ici

Point(int x=0, int y=0) : abscisse_(x)
{
  ordonnee_ = y;
  NbPoints_++;
};

Programme 2.4 Utilisation mixte de liste d'initialisation et affectations dans le corps du constructeur

Certains attributs ne peuvent pas être initialisés dans le corps du constructeur et devront donc obligatoirement l'être dans la liste d'initialisation : il s'agit des attributs qui sont eux mêmes des objets ou des références sur objets dans le cadre des relations d'aggrégation et d'association. Nous y reviendrons plus tard lorsque nous traiterons de l'agrégation.

Deux petites choses à retenir impérativement concernant les listes d'initialisation :

  1. Les attributs doivent apparaître dans leur ordre de déclaration. Par exemple si la classe déclare :
    class Chose 
    {
    
      // Code supprime 
    
      private:
        int    a;
        double b;
    };

    Alors, le code :

    class Chose
    {
      public:
        Chose (double valB, double valA) : b(valB), a (valA)
        {
          // code initialisation
        }
    
    };

    est illégal car vous essayez d'initialiser b avant a, alors que a est déclaré avant b. Certains compilateurs effectuent d'eux mêmes la correction en emettant un message d'avertissement. Certains autres se contentent de générer du code faux sans avertissement. Il faut donc dans tous les cas faire attention à respecter l'ordre de déclaration !!! Le code correct, dans ce cas, est le suivant :

    class Chose
    {
      public:
        Chose (double valB, double valA) : a (valA), b(valB)
        {
          // code initialisation
        }
    
    };
  2. Les initialisations réalisées dans la liste d'initialisation sont effectuées avant les instructions situées dans le code du corps du constructeur prenez donc gare à ne jamais placer de code nécessitant l'exécution du corps du constructeur au sein de la liste d'initialisation.

2.2 Instanciation des objets

La création d'objets (opération d'instanciation) se fait en appelant le constructeur. La syntaxe diffère selon la classe d'allocation de l'objet.

2.2.1 Les classes d'allocations

Il y a trois classes d'allocation :

Allocation statique
Elle concerne les objets placés en variable globale, c'est à dire à l'écart de toute fonction ou méthode.
On recense également dans cette catégorie les objets explicitement mis en allocation statique dans une méthode ou fonction à l'aide du modificateur d'allocation static. Rappelons que dans ce cas, ils gardent leur valeur d'un appel de la fonction / méthode sur l'autre.
Créés dès le début du programme, ils sont détruits automatiquement à la fin de l'exécution de celui-ci sans que vous ayez à vous en occuper. Il n'existe qu'un seul cas où ces objets ne sont pas détruits : l'arrêt du programme par la fonction abort.
Allocation automatique
Tout objet créé sur la pile est dit à allocation automatique. Cela concerne donc les variables temporaires dans une fonction / méthode ainsi que les objets renvoyés par une fonction / méthode qui sont momentanément stockés sur la pile.
Un objet automatique est créé à chaque lancement de la fonction / méthode à l'intérieur de laquelle il est déclaré et détruit automatiquement sans que vous ayez à vous en soucier dès le retour à l'appelant.
Allocation dynamique
Il s'agit des objets créés sur le tas et auxquels vous ne pouvez accéder qu'au travers d'un pointeur. Ils sont créés par un appel à l'opérateur new et doivent être détruits explicitement par un appel à l'opérateur delete.

2.2.2 Création d'instances simples

La création d'une instance automatique ou statique est simple et répond à la syntaxe suivante :

IdentificateurClasse identificateurObjet(parametres d'un constructeur)

Dans le cas d'une instance dynamique, il faut commencer par déclarer un pointeur, puis appeler new suivi du nom du constructeur avec ses paramètres. Ce qui peut se faire sur une ou plusieurs lignes

IdentificateurClasse *identificateurPointeurObjet;

(parametres d'un constructeur)

Point P1; // Objet statique

int main(int, char **)
{
  Point  p1;        // Utilise le constructeur Point::Point(int, int) 
                    // avec les valeurs par defaut des arguments
  Point  p2(10,20); // Crée un nouveau point avec x=10 et y=20
  Point *p3;        // Déclaration d'un pointeur sur un objet de type Point
  
  p3=new Point(20,30); // Instanciation de l'objet dynamique avec arguments explicites
  p4=new Point;        // instanciation de l'objet dynamique avec arguments par défaut

  delete p3;           // On n'oublie pas de rendre la mémoire !
  delete p4;           // Idem => voir section suivante !
  return 0;
}

Programme 2.5 Exemple d'instantion d'objets simples, statiques, automatiques ou dynamiques

2.3 Appel de méthodes

La syntaxe d'appel d'une méthode se différencie selon 2 modalités : le type de la méthode (instance ou méthode) et, dans le cas d'une méthode d'instance, la classe d'allocation de l'objet cible.

2.3.1 Appel de méthodes d'instance

Le code se différencie selon que vous utilisez un objet d'allocation dynamique ou non. Pour les objets à classe d'allocation statique ou automatique, la syntaxe d'appel est la suivante :

objet.methodeInstance(paramètres);

Par exemple, appliquons les opérations suivantes à l'objet de classe Point nommé p1 tell qu'il a été instancié au paragraphe précédent :

  1. afficher son abscisse
  2. le déplacer de 10 unités sur x et 20 unités sur y relativement à sa position actuelle
  3. afficher son ordonnée

nous obtenons le code suivant :

cout << p1.x() << endl;
p1.deplacerDe(10,20);
cout << p1.y();

Programme 2.6 Exemple d'appels de méthodes d'instance sur des objets non dynamiques

Pour un objet dynamique, ce n'est guère plus compliqué. Il fout juste changer le "." par une flèche "->". Ainsi, si l'on reprend l'exemple précédent avec l'objet p4 alloué dynamiquement, l'on obtient :

cout << p4->x() << endl;
p4->deplacerDe(10,20);
cout << p4->y();

Programme 2.7 Exemple d'appels de méthodes d'instance sur des objets dynamiques

2.3.2 Appel de méthodes de classe

Les méthodes de classe ne sont pas invoquées à travers un objet mais bel et bien à l'aide du nom de la classe. La syntaxe générale est la suivante :

NomClasse::NomMethodeClasse(paramètres);

Ainsi, si vous souhaitez afficher le nombre d'objets graphiques dans votre système en invoquant la méthode NbPoints, vous devrez utiliser :

cout << "Nombre de points présents : " << Point::NbPoints() << endl;

Accès aux attributs

Vous aurez remarqué que je n'ai pas parlé d'accès aux attributs. Tout d'abord, soyons clairs : aucun attribut ne doit être visible de l'extérieur. Donc si vous accédez à un attribut ca ne peut être que dans 2 conditions bien particulières :

Quoiqu'il en soit, la syntaxe est la même. On accède à un attribut de la même manière qu'e l'on accède aux méthodes en notation pointée pour les instances statiques ou avec une flèche pour les instances dynamiques.

Les règles suivantes s'appliquent aux objets constants :

  1. On déclare un objet constant avec le modificateur const.
  2. On ne peut appliquer que des méthodes constantes sur un objet constant
  3. Un objet passé en paramètre sous forme de référence constante est considéré comme constant

2.4 Appels de méthodes et objets constants

Il est dorénavant important de se consacrer aux objets constants car les règles d'appels de méthodes sur eux sont spéciales.

Les règles suivantes s'appliquent aux objets constants :

  1. On déclare un objet constant avec le modificateur const.
  2. On ne peut appliquer que des méthodes constantes sur un objet constant
  3. Un objet passé en paramètre sous forme de référence constante est considéré comme constant

Vous allez maintenant me demander ce qu'est une méthode constante ... et bien c'est tout simplement une méthode qui ne modifie aucun des attributs des objets. Elle est donc tout à fait applicable sur un objet constant ! D'où la règle importante suivante :

Il est impératif de déclarer constante toute méthode ne modifiant pas l'état des objets auxquels elle s'applique

Si vous appliquez scrupuleusement ce principe, vous pourrez toujours passer en référence constante les objets que vous ne souhaitez pas modifier. C'est une habitude de programmation à prendre dès le début !

Quoi ? vous me demandez maintenant comment l'on rend une méthode constante ? vous êtes insatiables ! allez, je suis bon prince, je vous réponds : il suffit de faire suivre son prototype du mot clef const, que ce soit lors de la déclaration ou lors de la définition. Ainsi le code devient :

class Chose
{

  // Code omis

  void metConstDep(int i, double d) const;
  void metConstInl(int j, const char *z) const
  {
    // Tout plein de code vachement utile
  }
  void metNonConst(double h)
  {
    // Code non moins utile
  }
};

void Chose::metConstDep(int i, double d) const
{
  // Code vachement important lui aussi
}

Programme 2.8 déclaration et définition de méthodes constantes

Utilisons maintenant ce beau code !

int main(int, char **)
{
  const Chose OBJET_CONSTANT(params d'initialisation); // Objet constant !

  OBJET_CONSTANT.metConstDep(1,2.5); // Ok, methode constante sur objet constant

  OBJET_CONSTANT.metNonConst(35.5); // NON ! metNonConst est une méthode non constante
                                    // qui ne peut en aucun cas être appliquée sur un objet constant

  OBJET_CONSTANT.metConstInk(3,"coucou"); // Ok, methode constante sur objet constant

  return 0;
}

Programme 2.9 Appel de méthodes sur objets contants

Très important : deux méthodes peuvent ne de différencier que par leur caractère constant ! nous reviendrons la dessus lors de la surcharge des opérateurs.

2.5 Mort des objets

Nous avons vu que la construction d'un objet s'effectue à l'aide d'une méthode spéciale : le constructeur. La mort des objets s'accompagne de l'appel d'une autre méthode spéciale : le destructeur. Notons immédiatement que si un constructeur peut être surchargé, le desctructeur lui ne peut pas l'être. en effet, son appel étant automatique, le système ne saurait pas quel destructeur appeler.

La syntaxe du destructeur est simple. Si les constructeurs de la classe Classe s'appellent Classe::Classe, le destructeur lui est Classe::~Classe. On reconnait ici le symbole ~ associe à l'opérateur binaire NON ! A l'instar du constructeur, le destructeur ne renvoie rien ; en outre, il ne prend pas de paramètre.

Le but du destructeur est de procéder à toutes les opérations de nettoyage nécessaires à la destruction correcte d'un objet. Par exemple, si le constructeur ou une autre méthode réalise des allocations dynamiques, le destructeur veillera à rendre la mémoire afin d'éviter que celle-ci ne soit perdue. Autre cas intéressant, un objet utilise des ressources systèmes, par exemple des outils graphiques Windows, avant que l'objet ne soit détruit, le destructeur rendra la ressource système.

Il est important de noter que vous n'avez pas besoin de spécifier un destructeur si votre classe ne nécessite pas de nettoyage spécifique. Si vous ne spécifiez pas explicitement un destructeur, un destructeur par défaut est créé automatiquement par le compilateur. Ce dernier est très simple : il ne fait rien !

Le processus de destruction se passe en deux fois :

  1. Appel du destructeur
  2. Nettoyage de l'espace mémoire occupé par les données membres de l'objet

Par exemple, examinons le cas d'un objet possédant 2 attributs d'instance : un entier et un pointeur vers un tableau d'entiers, l'entier spécifiant en fait la taille du tableau. Au cours de la construction de l'objet, nous allouons un tableau. Lors de la destruction, il faudra le détruire.

class Tableau
{
  public:
    Tableau(int laTailleDuTableau=5) : taille_(laTailleDuTableau)
    {
      if (_taille)
        tab_=new int [taille_];
      else
        tab_=0;
    }

    ~Tableau(void)
    {
      delete [] tab_;
    }
  private:
    int  taille_;
    int *tab_;
};

Construction et destruction d'un objet effectuant une allocation dynamique
Figure 2.1 Construction et Destruction d'un objet effectuant une allocation dynamique

Lorsqu'un objet a les classes d'allocation statique (variable globale) ou automatique (variable locale dans une fonction/méthode), il est détruit automatiquement dès qu'il sort de portée, c'est à dire :

Il en est tout autre pour une variable dynamique, c'est à dire un objet créé dynamiquement à l'aide de new ou de new []. En effet, tout objet instancié dynamiquement doit être explicitement détruit :

Remarque importante : il n'est pas nécessaire de spécifier la taille du tableau lors de sa destruction avec delete []. Seuls quelques compilateurs antiques et démodés l'exigent encore.

2.6 Création et destruction de tableaux —constructeur par défaut

L'utilisation de tableaux nécessite un constructeur par défaut. C'est à dire un constructeur pouvant ne prendre aucun paramètre lors de son invocation. Un tel constructeur peut :

Par exemple, le constructeur Point::Point(int absc=0, int ordo=0) est un constructeur par défaut !

Et pourquoi avons nous besoin d'un constructeur par défaut allez vous me demander ? et bien c'est lié à la syntaxe de construction des tableaux, par exemple, pour un vecteur à une dimension :

IdentificateurClasse identificateurObjet[tailleTableau];

Comme vous pouvez le constater, il n'y a pas de place pour des paramètres de construction. La construction doit donc se faire sans paramètre, d'où la nécessité d'un constructeur par défaut.

Que faire si l'on doit créer un tableau d'objets sans constructeur par défaut ? La solution est un peu alambiquée :

  1. On créé un tableau de pointeurs
  2. On appelle le constructeur souhaité sur chacun des pointeurs

A ce sujet, il ne faut pas confondre certaines notions. A partir d'une même classe T, on peut avoir :

Type de tableau Construction des objets Destruction Remarques
Tableau statique d'instances statiques
T tableau[TAILLE];
Automatique Constructeur par défaut obligatoire.
Tout est détruit automatiquement
Tableau statique d'instances dynamiques
T *tableau[TAILLE];
for (int i=0; i<TAILLE;i++)
tableau[i]=new T(params);
for (int i=0;i<TAILLE;i++)
delete tableau[i];
Le constructeur étant appelé par l'utilisateur, n'importe lequel fait l'affaire.
Attention, chaque objet doit être détruit individuellement
Tableau dynamique d'instances statiques
T *tableau;
tableau = new T[TAILLE]
delete [] tableau;
Constructeur par défaut obligatoire
Seul le tableau doit être détruit explicitement
Tableau dynamique d'instances dynamiques
typedef T* PT;
PT *tableau[TAILLE];
tableau = new PT[TAILLE]; for (int i=0;i<TAILLE;i++) tableau[i]=new T(params);
for (int i=0;i<TAILLE;i++)
  delete *tableau[i];
delete [] tableau;
Les éléments doivent être détruits individuellement, avant de détruire le tableau

Tableau 2.1 Les 4 différents types de tableaux