Constructeurs, destructeurs et membres statiques
Constructeurs et destructeurs
Le constructeur et le destructeur sont deux méthodes particulières qui sont
appelées respectivement à la création et à la destruction d'un objet. Toute
classe a un constructeur et un destructeur par défaut, fournis par le
compilateur. Ces constructeurs et destructeurs appellent les constructeurs par
défaut et les destructeurs des classes de base et des données membres de la
classe, mais en dehors de cela, ils ne font absolument rien. Il est donc souvent
nécessaire de les redéfinir afin de gérer certaines actions qui doivent avoir
lieu lors de la création d'un objet et de leur destruction. Par exemple, si
l'objet doit contenir des objets alloués dynamiquement, il faut leur réserver
de la mémoire à la création de l'objet ou au moins mettre les pointeurs
correspondants à NULL (nullptr depuis C++11). À la destruction de l'objet, il convient de restituer la
mémoire allouée, s'il en a été alloué. On peut trouver bien d'autres situations
où une phase d'initialisation et une phase de terminaison sont nécessaires.
Dès qu'un constructeur quelconque (y compris un constructeur de copie) est
déclaré par l'utilisateur, le compilateur ne génère plus automatiquement le
constructeur par défaut. De même, un destructeur défini par l'utilisateur
remplace le destructeur implicite. En particulier, si l'utilisateur définit un
constructeur prenant des paramètres, il ne sera plus possible de construire un
objet simplement, sans fournir les paramètres à ce constructeur, à moins bien
entendu de définir également un constructeur qui ne prenne pas de paramètres
(ou, depuis C++11, de le redemander explicitement avec NomClasse() = default;).
Définition des constructeurs et des destructeurs
Le constructeur se définit comme une méthode normale. Cependant, pour que le compilateur puisse la reconnaître en tant que constructeur, les deux conditions suivantes doivent être vérifiées :
- elle doit porter le même nom que la classe ;
- elle ne doit avoir aucun type de retour, pas même le type
void.
Le destructeur doit également respecter ces règles. Pour le différencier du constructeur, son nom sera toujours précédé du signe tilde.
Un constructeur est appelé automatiquement lors de l'instanciation de l'objet.
Le destructeur est appelé automatiquement lors de sa destruction. Cette
destruction a lieu lors de la sortie du bloc de portée courante pour les objets
automatiques (variables locales). Pour les objets alloués dynamiquement, le
constructeur et le destructeur sont appelés automatiquement par les expressions
qui utilisent les opérateurs new, new[], delete, delete[]. C'est pour cela
qu'il est recommandé de les utiliser à la place des fonctions malloc et free
du C pour créer dynamiquement des objets. De plus, il ne faut pas utiliser
delete ou delete[] sur des pointeurs de type void, car il n'existe pas
d'objets de type void. Le compilateur ne peut donc pas déterminer quel est le
destructeur à appeler avec ce type de pointeur (le comportement est indéfini).
Le constructeur est appelé après l'allocation de la mémoire de l'objet et le destructeur est appelé avant la libération de cette mémoire. La gestion de l'allocation dynamique de mémoire avec les classes est ainsi simplifiée. Dans le cas des tableaux, l'ordre de construction est celui des adresses croissantes, et l'ordre de destruction est celui des adresses décroissantes. C'est dans cet ordre que les constructeurs et destructeurs de chaque élément du tableau sont appelés.
Les constructeurs pourront avoir des paramètres. Ils peuvent donc être surchargés, mais pas les destructeurs. Cela est dû au fait qu'en général on connaît le contexte dans lequel un objet est créé, mais qu'on ne peut pas connaître le contexte dans lequel il est détruit : il ne peut donc y avoir qu'un seul destructeur. Les constructeurs qui ne prennent pas de paramètre ou dont tous les paramètres ont une valeur par défaut, remplacent automatiquement les constructeurs par défaut définis par le compilateur lorsqu'il n'y a aucun constructeur dans les classes. Cela signifie que ce sont ces constructeurs qui seront appelés automatiquement par les constructeurs par défaut des classes dérivées.
class chaine // Implémente une chaîne de caractères.
{
char * s; // Le pointeur sur la chaîne de caractères.
public:
chaine(void); // Le constructeur par défaut.
chaine(unsigned int); // Le constructeur. Il n'a pas de type.
~chaine(void); // Le destructeur.
};
chaine::chaine(void)
{
s=NULL; // La chaîne est initialisée avec
return ; // le pointeur nul.
}
chaine::chaine(unsigned int Taille)
{
s = new char[Taille+1]; // Alloue de la mémoire pour la chaîne.
s[0]='\0'; // Initialise la chaîne à "".
return;
}
chaine::~chaine(void)
{
if (s!=NULL) delete[] s; // Restitue la mémoire utilisée si
// nécessaire.
return;
}
Pour passer les paramètres au constructeur, on donne la liste des paramètres entre parenthèses juste après le nom de l'objet lors de son instanciation :
chaine s1; // Instancie une chaîne de caractères
// non initialisée.
chaine s2(200); // Instancie une chaîne de caractères
// de 200 caractères.
Les constructeurs devront parfois effectuer des tâches plus compliquées que celles données dans cet exemple. En général, ils peuvent faire toutes les opérations faisables dans une méthode normale, sauf utiliser les données non initialisées bien entendu. En particulier, les données des sous-objets d'un objet ne sont pas initialisées tant que les constructeurs des classes de base ne sont pas appelés. C'est pour cela qu'il faut toujours appeler les constructeurs des classes de base avant d'exécuter le constructeur de la classe en cours d'instanciation. Si les constructeurs des classes de base ne sont pas appelés explicitement, le compilateur appellera, par défaut, les constructeurs des classes mères qui ne prennent pas de paramètre ou dont tous les paramètres ont une valeur par défaut.
Comment appeler les constructeurs et les destructeurs des classes mères lors de
l'instanciation et de la destruction d'une classe dérivée ? Le compilateur ne
peut en effet pas savoir quel constructeur il faut appeler parmi les différents
constructeurs surchargés potentiellement présents. Pour appeler un autre
constructeur d'une classe de base que le constructeur ne prenant pas de
paramètre, il faut spécifier explicitement ce constructeur avec ses paramètres
après le nom du constructeur de la classe fille, en les séparant de celui-ci par
deux-points (:).
En revanche, il est inutile de préciser le destructeur à appeler, puisque celui-ci est unique. Le programmeur ne doit donc pas appeler lui-même les destructeurs des classes mères, le langage s'en charge.
#include <cstdio>
/* Déclaration de la classe mère. */
class Mere
{
int m_i;
public:
Mere(int);
~Mere(void);
};
/* Définition du constructeur de la classe mère. */
Mere::Mere(int i)
{
m_i=i;
printf("Exécution du constructeur de la classe mère.\n");
return;
}
/* Définition du destructeur de la classe mère. */
Mere::~Mere(void)
{
printf("Exécution du destructeur de la classe mère.\n");
return;
}
/* Déclaration de la classe fille. */
class Fille : public Mere
{
public:
Fille(void);
~Fille(void);
};
/* Définition du constructeur de la classe fille
avec appel du constructeur de la classe mère. */
Fille::Fille(void) : Mere(2)
{
printf("Exécution du constructeur de la classe fille.\n");
return;
}
/* Définition du destructeur de la classe fille
avec appel automatique du destructeur de la classe mère. */
Fille::~Fille(void)
{
printf("Exécution du destructeur de la classe fille.\n");
return;
}
Lors de l'instanciation d'un objet de la classe fille, le programme affichera dans l'ordre les messages suivants :
- Exécution du constructeur de la classe mère
- Exécution du constructeur de la classe fille
et lors de la destruction de l'objet :
- Exécution du destructeur de la classe fille
- Exécution du destructeur de la classe mère
Si l'on n'avait pas précisé que le constructeur à appeler pour la classe Mère était le constructeur prenant un entier en paramètre, le compilateur aurait essayé d'appeler le constructeur par défaut de cette classe. Or, ce constructeur n'étant plus généré automatiquement par le compilateur (à cause de la définition d'un constructeur prenant un paramètre), il y aurait eu une erreur de compilation.
Il est possible d'appeler plusieurs constructeurs si la classe dérive de plusieurs classes de base. Pour cela, il suffit de lister les constructeurs un à un, en séparant leurs appels par des virgules. On notera cependant que l'ordre dans lequel les constructeurs sont appelés n'est pas forcément l'ordre dans lequel ils sont listés dans la définition du constructeur de la classe fille. En effet, le C++ appelle toujours les constructeurs dans l'ordre d'apparition de leurs classes dans la liste des classes de base de la classe dérivée (les classes de base virtuelles étant construites en premier).
Une fonction virtuelle peut toujours être appelée dans un constructeur, mais la fonction effectivement appelée est celle de la classe du sous-objet en cours de construction : pas celle de la classe de l'objet complet. Ainsi, si une classe A hérite d'une classe B et qu'elles ont toutes les deux une fonction virtuelle f, l'appel de f dans le constructeur de B utilisera la fonction f de B, pas celle de A (même si l'objet que l'on instancie est de classe A).
La syntaxe utilisée pour appeler les constructeurs des classes de base peut également être utilisée pour initialiser les données membres de la classe. En particulier, cette syntaxe est obligatoire pour les données membres constantes et pour les références, car le C++ ne permet pas l'affectation d'une valeur à des variables de ce type. Encore une fois, l'ordre d'appel des constructeurs des données membres ainsi initialisées n'est pas forcément l'ordre dans lequel ils sont listés dans le constructeur de classe. En effet, le C++ utilise cette fois l'ordre de déclaration de chaque donnée membre.
class tableau
{
const int m_iTailleMax;
const int *m_pDonnees;
public:
tableau(int iTailleMax);
~tableau();
};
tableau::tableau(int iTailleMax) :
m_iTailleMax(iTailleMax) // Initialise la donnée membre constante.
{
// Allocation d'un tableau de m_iTailleMax entrées :
m_pDonnees = new int[m_iTailleMax];
}
tableau::~tableau()
{
// Destruction des données :
delete[] m_pDonnees;
}
Constructeurs de copie
Il faudra parfois créer un constructeur de copie. Le but de ce type de constructeur est d'initialiser un objet lors de son instanciation à partir d'un autre objet. Toute classe dispose d'un constructeur de copie par défaut généré automatiquement par le compilateur, dont le seul but est de recopier les champs de l'objet à recopier un à un dans les champs de l'objet à instancier. Toutefois, ce constructeur par défaut ne suffira pas toujours, et le programmeur devra parfois en fournir un explicitement.
Ce sera notamment le cas lorsque certaines données des objets auront été allouées dynamiquement. Une copie brutale des champs d'un objet dans un autre ne ferait que recopier les pointeurs, pas les données pointées. Ainsi, la modification de ces données pour un objet entraînerait la modification des données de l'autre objet, ce qui ne serait sans doute pas l'effet désiré.
La définition des constructeurs de copie se fait comme celle des constructeurs normaux. Le nom doit être celui de la classe, et il ne doit y avoir aucun type. Dans la liste des paramètres cependant, le premier paramètre doit être une référence (en général constante) sur l'objet à copier ; les éventuels autres paramètres doivent avoir une valeur par défaut.
Pour la classe chaine définie ci-dessus, il faut un constructeur de copie. Celui-ci peut être déclaré de la façon suivante :
chaine(const chaine &Source);
où Source est l'objet à copier. Si l'on rajoute la donnée membre Taille dans la déclaration de la classe, la définition de ce constructeur peut être :
chaine::chaine(const chaine &Source)
{
Taille = Source.Taille;
if (Source.s != NULL)
{
s = new char[Taille + 1]; // Effectue l'allocation.
strcpy(s, Source.s); // Recopie la chaîne de caractères source.
}
else
s = NULL; // La source est une chaîne vide.
}
Le constructeur de copie est appelé dans toute instanciation avec initialisation, comme celles qui suivent :
chaine s2(s1);
chaine s3 = s1;
Dans les deux exemples, c'est le constructeur de copie qui est appelé. En particulier, à la deuxième ligne, le constructeur normal n'est pas appelé et aucune affectation entre objets n'a lieu.
Une classe qui a besoin d'un destructeur, d'un constructeur de copie ou d'un
opérateur d'affectation écrits à la main (typiquement parce qu'elle possède une
ressource, comme la classe chaine) a en général besoin des trois : c'est la
règle des trois. Depuis C++11, le constructeur de déplacement
(chaine(chaine &&)) et l'affectation par déplacement s'y ajoutent (règle des
cinq) ; un destructeur déclaré par l'utilisateur supprime d'ailleurs la
génération implicite des opérations de déplacement. Confier la ressource à un
objet RAII de la bibliothèque standard (std::string, std::vector,
std::unique_ptr) permet de n'en écrire aucune (règle du zéro).
Utilisation des constructeurs dans les transtypages
Les constructeurs sont utilisés dans les conversions de type dans lesquelles le
type cible est celui de la classe du constructeur. Ces conversions peuvent être
soit implicites (dans une expression), soit explicites (à l'aide d'un
transtypage). Par défaut, les conversions implicites sont légales, pourvu qu'il
existe un constructeur non explicit pouvant être appelé avec un seul argument
du type de l'objet source (les éventuels autres paramètres ayant une valeur par
défaut). Par exemple, la classe Entier suivante :
class Entier
{
int i;
public:
Entier(int j)
{
i=j;
return ;
}
};
dispose d'un constructeur de transtypage pour les entiers. Les expressions
int j=2;
Entier e1(0), e2=j;
e1=j;
sont donc légales, la valeur entière située à la droite de l'expression étant convertie implicitement en un objet du type de la classe Entier.
Si, pour une raison quelconque, ce comportement n'est pas souhaitable, on peut
forcer le compilateur à n'accepter que les conversions explicites (à l'aide de
transtypage). Pour cela, il suffit de placer le mot-clé explicit avant la
déclaration du constructeur. Par exemple, le constructeur de la classe chaine
vue ci-dessus prenant un entier en paramètre risque d'être utilisé dans des
conversions implicites. Or ce constructeur ne permet pas de construire une
chaîne de caractères à partir d'un entier, et ne doit pas être utilisé dans les
opérations de transtypage. Ce constructeur doit donc être déclaré explicit :
class chaine
{
size_t Taille;
char * s;
public:
chaine(void);
// Ce constructeur permet de préciser la taille de la chaîne
// à sa création :
explicit chaine(unsigned int);
~chaine(void);
};
Avec cette déclaration, l'expression suivante
int j=2;
chaine s=j;
n'est plus valide, alors qu'elle l'était lorsque le constructeur n'était pas
explicit.
Pointeur this
Nous allons à présent voir comment les fonctions membres, qui appartiennent à la classe, peuvent accéder aux données d'un objet, qui est une instance de cette classe. Cela est indispensable pour bien comprendre les paragraphes suivants.
À chaque appel d'une fonction membre, le compilateur passe implicitement un pointeur sur les données de l'objet en paramètre. En pratique, les compilateurs le passent comme un premier paramètre caché. Ce mécanisme est complètement invisible au programmeur, et nous ne nous attarderons pas dessus.
En revanche, il faut savoir que le pointeur sur l'objet est accessible à
l'intérieur de la fonction membre. Il porte le nom this. Par conséquent,
*this représente l'objet lui-même. Nous verrons une utilisation de this dans
le paragraphe suivant.
this est un pointeur constant, c'est-à-dire qu'on ne peut pas le modifier (il
est donc impossible de faire des opérations arithmétiques dessus). Cela est tout
à fait normal, puisque le faire reviendrait à sortir de l'objet en cours (celui
pour lequel la méthode en cours d'exécution travaille).
Il est possible de transformer ce pointeur constant en un pointeur constant sur
des données constantes pour chaque fonction membre. Le pointeur ne peut toujours
pas être modifié, et les données de l'objet ne peuvent pas être modifiées non
plus. L'objet est donc considéré par la fonction membre concernée comme un objet
constant. Cela revient à dire que la fonction membre s'interdit la modification
des données de l'objet. On parvient à ce résultat en ajoutant le mot-clé const
à la suite de l'en-tête de la fonction membre.
Il est à noter qu'une méthode qui n'est pas déclarée comme étant const modifie
a priori les données de l'objet sur lequel elle travaille. Donc, si elle est
appelée sur un objet déclaré const, une erreur de compilation se produit. Ce
comportement est normal. On devra donc toujours déclarer const une méthode qui
ne modifie pas réellement l'objet, afin de laisser à l'utilisateur le choix de
déclarer const ou non les objets de sa classe.
Données et fonctions membres statiques
Nous allons voir dans ce paragraphe l'emploi du mot-clé static dans les
classes. Ce mot-clé intervient pour caractériser les données membres statiques
des classes, les fonctions membres statiques des classes, et les données
statiques des fonctions membres.
Données membres statiques
Une classe peut contenir des données membres statiques. Ces données sont soit des données membres propres à la classe, soit des données locales statiques des fonctions membres de la classe. Dans tous les cas, elles appartiennent à la classe, et non pas aux objets de cette classe. Elles sont donc communes à tous ces objets.
Il est impossible d'initialiser les données statiques d'une classe dans le constructeur de la classe, car le constructeur n'initialise que les données des nouveaux objets. Les données statiques ne sont pas spécifiques à un objet particulier et ne peuvent donc pas être initialisées dans le constructeur. En fait, leur initialisation doit se faire lors de leur définition, en dehors de la déclaration de la classe. Pour préciser la classe à laquelle appartiennent les données définies, on devra utiliser l'opérateur de résolution de portée.
class test
{
static int i; // Déclaration dans la classe.
// ...
};
int test::i=3; // Initialisation en dehors de la classe.
La variable test::i sera partagée par tous les objets de classe test, et sa
valeur initiale est 3. Font exception les données membres statiques constantes
de type entier ou énumération, qui peuvent être initialisées dans la classe
(static const int N = 10;) et, depuis C++17, les données membres déclarées
inline static, qui peuvent être définies et initialisées directement dans la
classe.
Les variables statiques des fonctions membres doivent être initialisées à l'intérieur des fonctions membres. Elles appartiennent également à la classe, et non pas aux objets. De plus, leur portée est réduite à celle du bloc dans lequel elles ont été déclarées. Ainsi le code suivant :
#include <stdio.h>
class test
{
public:
int n(void);
};
int test::n(void)
{
static int compte=0;
return compte++;
}
int main(void)
{
test objet1, objet2;
printf("%d ", objet1.n()); // Affiche 0
printf("%d\n", objet2.n()); // Affiche 1
return 0;
}
affichera 0 et 1, parce que la variable statique compte est la même pour les
deux objets.
Fonctions membres statiques
Les classes peuvent également contenir des fonctions membres statiques. Cela
peut surprendre à première vue, puisque les fonctions membres appartiennent déjà
à la classe, c'est-à-dire à tous les objets. En fait, cela signifie que ces
fonctions membres ne recevront pas le pointeur sur l'objet this, comme c'est
le cas pour les autres fonctions membres. Par conséquent, elles ne pourront
accéder directement qu'aux données statiques de la classe :
class Entier
{
int i;
static int j;
public:
static int get_value(void);
};
int Entier::j=0;
int Entier::get_value(void)
{
j=1; // Légal.
return i; // ERREUR ! get_value ne peut pas accéder à i.
}
La fonction get_value de l'exemple ci-dessus ne peut pas accéder à la donnée
membre non statique i, parce qu'elle ne travaille sur aucun objet. Son champ
d'action est uniquement la classe Entier. En revanche, elle peut modifier la
variable statique j, puisque celle-ci appartient à la classe Entier et non aux
objets de cette classe.
L'appel des fonctions membres statiques se fait exactement comme celui des fonctions membres non statiques, en spécifiant l'identificateur d'un des objets de la classe et le nom de la fonction membre, séparés par un point. Cependant, comme les fonctions membres ne travaillent pas sur les objets des classes mais plutôt sur les classes elles-mêmes, la présence de l'objet lors de l'appel est facultative. On peut donc se contenter d'appeler une fonction statique en qualifiant son nom du nom de la classe à laquelle elle appartient à l'aide de l'opérateur de résolution de portée.
class Entier
{
static int i;
public:
static int get_value(void);
};
int Entier::i=3;
int Entier::get_value(void)
{
return i;
}
int main(void)
{
// Appelle la fonction statique get_value :
int resultat=Entier::get_value();
return 0;
}
Les fonctions membres statiques sont souvent utilisées afin de regrouper un certain nombre de fonctionnalités en rapport avec leur classe. Ainsi, elles sont facilement localisables et les risques de conflits de noms entre deux fonctions membres homonymes sont réduits. Nous verrons également plus tard comment éviter les conflits de noms globaux dans le cadre des espaces de nommage.