Comprendre les constantes

Pour un texte plus riche sur l'importance des constantes dans un langage OO, voir ceci.

Ce texte sera bientôt enrichi pour tenir compte du très important concept de constexpr que nous offre C++ 11. C'est une simple question de temps...

Les constantes sont méconnues et incomprises de plusieurs informaticien(ne)s. Pourtant, il s'agit d'un élément important et précieux de la boîte à outils des programmeuses et des programmeurs.

Décrit simplement, une constante est un objet dont l'état ne peut plus changer une fois son caractère constante spécifié. La portée de cet énoncé varie d'un langage à l'autre :

J'ai entendu, ces dernières années, des étudiant(e)s dire « je vais ajouter mes constantes quand j'aurai fini d'écrire mon programme »... Quelle mauvaise idée!

On ne met pas des constantes dans un programme pour faire plaisir au professeur mais bien pour simplifier notre propre tâche de programmation. Les constantes sont, avec les commentaires et les types, parmi les premières choses à déterminer dans un programme!

Je propose dans l'immédiat une maxime : si un objet peut être qualifié constant, alors il devrait l'être. Au pire, ça ne coûtera rien. Au mieux, ça documentera le rôle de l'objet dans le code et ça donnera au compilateur une opportunité d'optimisation supplémentaire.

Vous noterez que la constance se gagne sans problème (on peut utiliser un X là où un X constant est requis puisque gagner en constance ne comporte aucun risque) alors qu'elle ne se perd qu'avec peine (en C++, il faut avoir recours à une conversion explicite de types ISO, un const_cast, pour enlever temporairement une qualification de constance sur un objet, ce qui est une opération risquée qu'il ne faut pas prendre à la légère).

Cet article se veut un bref résumé du rôle des constantes dans un programme. J'utiliserai C++ comme référence parce qu'il permet de couvrir (beaucoup) plus de cas que la plupart des autres langages (C, Java, les langages .NET, etc.).

Constantes connues à la compilation

Une première catégorie, classique et supportée par la plupart des langages de programmation (du moins dans le cas des types primitifs), est celle des constantes dont la valeur est connue à la compilation.

#include "Note.h"
#include <string>
const int NELEVES = 30;
const float PI = 3.14159f;
const std::string NOM_DEFAUT = "Joe Blo";
const Note SEUIL_PASSAGE = Note(60);

Un bon compilateur reconnaîtra habituellement le fait que l'entité marquée constante et dont la valeur est connue dès la compilation ne pourra pas changer d'état et brûlera souvent la valeur de la constante dans le code compilé.

Utiliser des constantes permet alors à la programmeuse ou au programmeur de travailler de manière symbolique, par exemple en parlant NELEVES plutôt que du littéral 30 pour une classe de 30 élèves. Les constantes clarifier aussi le texte du programme et simplifient l'entretien de code : si le nombre d'élèves passe de 30 à 35, il suffit de changer la valeur de la constante et de recompiler. Si, au contraitre, le littéral 30 avait été apposé manuellement ici et là dans le code, il faudrait se demander pour chaque occurrence du littéral 30 s'il s'agit bel et bien du nombre d'élèves ou s'il ne s'agit pas d'une autre information ayant la valeur 30.

Les constantes connues à la compilation :

Évidemment, la conséquence de marquer un objet comme étant constant est que le programme s'engage à ne pas en modifier l'état par la suite.

Constantes non primitives

Il faut être pragmatique : certains objets voudront permettre, souvent à des fins de performance ou de statistiques, à certains de leurs attributs d'échapper à la contrainte de constance. Ces attributs pourront alors être qualifiés du mot clé mutable. Ce détail n'est pas essentiel à une discussion simple comme celle-ci.

Notez que, dans les exemples de déclarations de constantes plus haut, celles qui sont de types non primitifs (SEUIL_PASSAGE et NOM_DEFAUT) sont des objets constants. En tant que tels, ils doivent être construits et ils doivent être détruits.

Un objet constant est considéré constant suite à sa construction et jusqu'au début de sa destruction. Pendant la construction de l'objet (donc pendant son initialisation), il est évidemment possible de le modifier, sinon il n'y aurait pas d'objet. De même, pendant sa destruction, il est possible de le modifier sinon l'objet ne pourrait pas libérer les ressources qu'il s'est attribué pendant son existence.

La règle de constance sur un objet constant est qu'on ne peut ni modifier ses attributs, ni invoquer sur lui une méthode qui n'en garantisse pas la constance. Nous y reviendrons plus bas.

Constantes et valeurs dépendantes

const int MENU_QUITTER = 0;
const int MENU_AVANCER = 1;
const int MENU_RECULER = 2;

Imaginons le cas proposé à droite. Trois options de menu sont identifiées dans un programme par des constantes dont les valeurs se succèdent. Imaginons que ce soit un choix de design, au sens où l'ajout de nouvelles options devrait se faire à la suite des valeurs existantes (selon cette stratégie, ajouter l'option MENU_GAUCHE devrait impliquer de lui associer la valeur 3 qui succède directement à la valeur de MENU_RECULER).

Maintenant, imaginons que l'on veuille plus de souplesse, par exemple parce qu'on estime important de pouvoir commencer les options de menu à 1 plutôt qu'à 0.

const int MENU_BASE = 0;
const int MENU_QUITTER = MENU_BASE + 0;
const int MENU_AVANCER = MENU_BASE + 1;
const int MENU_RECULER = MENU_BASE + 2;

Dans les circonstances, c'est très simple à réaliser : il suffit de faire en sorte que les valeurs associées aux options de menu soient calculées à partir des valeurs d'autres constantes elles aussi connues à la compilation.

Ce faisant, on gagne de la souplesse (changer la valeur de MENU_BASE change aussi les valeurs de toutes les constantes qui en dépendent) sans que cela ne réduise la clarté du code (on utilise les mêmes symboles, après tout) et sans que cela ne coûte quoi que ce soit en temps ou en espace dans le programme (MENU_BASE + 2, par exemple, est un calcul fait sur deux constantes dont les valeurs sont connues à la compilation, ce qui implique que ce calcul sera fait à la compilation).

Constantes connues à la compilation, locales et automatiques

L'exemple du sous-programme lire_puis_afficher_inverse() proposé à droite utilise une constante NELEMS dont la valeur est connue à la compilation et fixée à l'intérieur de la fonction.

Le type de NELEMS est primitif et la valeur par laquelle NELEMS est initialisé ne dépend d'aucun événement dû à l'exécution du programme (il s'agit d'un simple littéral). Dans ce cas, un compilateur est en droit de brûler la valeur du littéral dans le code là où apparaît le symbole NELEMS.

Encore une fois, le recours à une constante n'a vraiment que des avantages. Remarquez que même dans un petit sous-programme comme celui-ci, le symbole NELEMS apparaît à quatre endroits (trois si on ne compte pas la déclaration de la constante). Cela signifie que ne pas utiliser de constantes symbolique et nous limiter à utiliser des littéraux nous aurait forcé à faire trois changement dans le code si nous avions décidé de ne plus lire et afficher le même nombre de valeurs dans ce seul sous-programme.

#include <iostream>
void lire_puis_afficher_inverse()
{
   using namespace std;
   const int NELEMS = 10;
   int elems[NELEMS];
   for (int i = 0; i < NELEMS; ++i)
   {
      cout << "Élément " << i + 1 << ": ";
      cin >> elems[i];
   }
   cout << endl;
   for (int i = NELEMS - 1; i >= 0; --i)
      cout << elems[i] << ' ';
   cout << endl;
}

Les risques d'oublier l'une des trois occurrences de ce littéral sont grands, surtout à moyen terme. Il n'y a pas de bonne raison de ne pas utiliser de constantes lorsque cela s'y prête.

Constantes connues à la compilation, locales et statiques

Avec les objets, une petite nuance s'impose.

Puisque les objets voient leur construction et leur destruction gérée par programmation (les programmeuses et les programmeurs déterminent ce que signifie construire et ce que signifie détruire un objet, et ce pour chaque classe), le compilateur ne peut pas brûler la valeur de l'objet dans le code dans un cas comme celui de NOM_GENERIQUE proposé à droite.

En effet, il est possible que le code comptabilise le nombre d'appels à identifier_generique() en incrémentant une variable dans le constructeur de NOM_GENERIQUE (c'est peu probable avec la classe std::string puisque celle-ci appartient à la bibliothèque standard du langage mais c'est très possible avec des instances de classes prises au sens large).

#include <iostream>
#include <string>
void identifier_generique()
{
   using namespace std;
   const string NOM_GENERIQUE = "Joe Blo";
   string nom;
   if (cin >> nom && nom == NOM_GENERIQUE)
      cout << "Vous êtes bien générique, il me semble..."
           << endl;
}

Si le compilateur décidait de ne pas construire puis détruire NOM_GENERIQUE à chaque appel de identifier_generique(), cette décision pourrait nuire au bon fonctionnement du programme.

Il est toutefois possible d'indiquer au compilateur notre souhait de ne voir la constante NOM_GENERIQUE construite qu'une seule fois, soit lors de la première invocation du sous-programme identifier_generique(). Cette spécification se fait en apposant la mention static avant (ou après, peu importe) le mot const.

Notez que cela n'a de sens que si l'état initial de la constante NOM_GENERIQUE dans identifier_generique() est la même peu importe l'appel du sous-programme. Nous verrons plus bas des cas où cela ne s'appliquerait pas.

#include <iostream>
#include <string>
void identifier_generique()
{
   using namespace std;
   static const string NOM_GENERIQUE = "Joe Blo";
   string nom;
   if (cin >> nom && nom == NOM_GENERIQUE)
      cout << "Vous êtes bien générique, il me semble..."
           << endl;
}

On obtient ainsi le meilleur des mondes :

Constantes entières connues à la compilation et tableaux

Un tableau construit de manière automatique (tableau local à un sous-programme) ou statique (tableau global) doit avoir une taille entière, strictement positive (donc plus grande que zéro) et connue à la compilation.

Un exemple proposé à droite montre au moins un cas où cette dernière règle intervient. La constante SURPRISE est entière, constante dès sa déclaration et peut-être même positive, mais sa valeur dépend d'une intervention humaine et n'est donc pas connue à la compilation. Pour cette raison, la déclaration du tableau tabC de taille SURPRISE est une déclaraiton illégale. Le compilateur de sait pas quoi faire avec tabC parce qu'il n'en connaît pas la taille au moment où il génère le code.

float tabA[10]; // Ok mais ouache
const int N = 10;
float tabB[N]; // Ok
int n;
cin >> n;
const int SURPRISE = n;
// illégal: SURPRISE n'est pas
// connue à la compilation
float tabC[SURPRISE];

Nous reviendrons un peu plus loin sur le recours à un nom en majuscules dans un tel cas qui est un peu abusif.

Le cas des constantes de classe

En POO, il est possible de définir des constantes de classes. Ceci permet de regrouper ensembles une idée, par exemple celle de cours, et les idées associées, par exemple le nombre d'élèves et un tableau d'élèves.

Dans presque tous les cas, une constante de classe est déclarée dans la déclaration de sa classe puis définie dans un fichier source adjoint.

L'exemple proposé à droite est celui d'une classe Personne dans laquelle apparaît une constante de classe privée nommée NOM_DEFAUT. Le fichier d'en-tête Personne.h déclare la classe Personne et indique l'existence de la constante (son type et son nom), alors que le fichier source Personne.cpp définit (construit) cette constante en sollicitant l'un des ses constructeurs (remarquez que le mot clé static n'apparaît que lors de la déclaration).

Il est important de définir un attribut de classe comme la constante Personne::NOM_DEFAUT dans un seul fichier source. C++ procède par compilation séparée des fichiers sources, et l'édition des liens lie les divers fichiers résultant de cette compilation dans un tout qui se veut cohérent. Si la définition d'un attribut de classe apparaissait dans le fichier d'en-tête, alors comment déterminerait-on lequel des fichiers source est responsable de définir effectivement cet attribut?

#ifndef PERSONNE_H
#define PERSONNE_H
//
// Personne.h
//
#include <string>
class Personne
{
   static const std::string NOM_DEFAUT;
   // ...
};
#endif
#include "Personne.h"
#include <string>
using std::string;
//
// Personne.cpp
//
const string Personne::NOM_DEFAUT = "Joe Blo";

Pensez-y :

Lequel d'entre A.obj et B.obj devrait contenir la définition de la constante Personne::NOM_DEFAUT? La réponse est qu'aucun n'est plus qualifié que l'autre en ce sens, or si cette constante était définie dans Personne.h, alors A.cpp et B.cpp seraient tous deux aussi conscients des règles s'appliquant à sa contruction.

En pratique, on déclare donc les attributs de classe (ceux qualifiés static) dans la déclaration d'une classe (le .h dans la plupart des cas) et on les définit dans un fichier source (.cpp) associé à la classe. Le cas ci-dessus devient donc :

Ceci nous mène au cas des attributs d'instances qui sont des tableaux. Comme toujours, certaines contraintes s'appliquent :

  • Pour déclarer un tableau sans avoir recours à de l'allocation dynamique de mémoire, il faut que sa taille soit connue au moment de générer le code
  • L'exemple proposé à droite est canonique en ce sens car la déclaration du tableau eleves_ y est illégale du fait que la valeur de NELEVES n'y est pas encore connue

Il faut comprendre ici que C++, comme C, a recours à la compilation séparée de chaque fichier source. Les fichiers qui incluront Cours.h sauront qu'il y existe une constante entière nommée NELEVES et sauront que chaque instance de Cours contiendra un tableau de NELEVES instances de Eleve, mais ne sauront pas combien d'espace réserver pour une instance de Cours puisque l'espace requis dépendra de la taille d'un Cours, qui sera au moins NELEVES*sizeof(Eleve), or la valeur de NELEVES leur est inconnue!

#ifndef COURS_H
#define COURS_H
//
// Cours.h
//
#include "Eleve.h"
class Cours
{
   static const int NELEVES;
   // illégal: valeur de NELEVES inconnue ici
   Eleve eleves_[NELEVES];
   // ...
};
#endif
#include "Cours.h"
//
// Cours.cpp
//
const int Cours::NELEVES = 30;

Pour cette raison, plusieurs compilateurs acceptent une dispense particulière pour les constantes de classe entières (et seulement elles; cette dispense ne s'applique pas aux autres types, même primitifs) qui peuvent être définies à même la déclaration de la classe.

L'exemple à droite est donc légal, du moins sur un nombre important de compilateurs. Une solution plus ancienne (et plus portable) pour contourner cet irritant technique est de remplacer une constante entière par une constante énumérée.

//
// Cours.h
//
#include "Eleve.h"
class Cours
{
   static const int NELEVES = 30;
   Eleve eleves_[NELEVES];
   // ...
};

Ainsi, dans la classe Cours proposée à droite, on aurait pu remplacer la définition static const int NELEVES = 30; par la définition enum { NELEVES = 30 }; pour obtenir exactement le même résultat. On pourrait d'ailleurs toujours procéder ainsi dans le cas des constantes entières, ce que certains (dont moi) font régulièrement parce que la formule résultante est plus succincte et plus compacte.

Il n'y a aucun avantage à l'une des formules en comparaison avec l'autre dans les cas des entiers. Le résultat est strictement équivalent en taille ou en vitesse ou en lisibilité (les symboles sont les mêmes dans le code et se nomment dans chaque cas de la même manière, soit Cours::NELEVES dans notre exemple). La préférence pour l'une ou l'autre de ces formes est d'ordre esthétique.

Les majuscules et le sens des constantes

Vous aurez remarqué que les constantes utilisées jusqu'ici dans cet article ont toutes des noms écrits en majuscules.

Ce choix est volontaire et respecte la tradition. La plupart des constantes système de C et de C++ sont écrites en minuscules, et utiliser dans nos progammes des noms en majuscules réduit les risques de conflits. De même, les constantes sont des indicateurs importants dans un programme et les rendre visibles est une bonne chose.

Vous remarquerez aussi que je n'appliquerai plus cette politique de manière systématique plus bas. La raison est que nous allons opérer un glissement sémantique en passant de constantes dont la valeur est connue à la compilation à des constantes dont la valeur est fixée à l'exécution.

Les deux catégories de constantes partagent une caractéristique fonamentale : elles ne peuvent changer d'état une fois qualifiées constantes. Elles ne partagent toutefois pas la caractéristique d'être connues lorsque le programme est compilé, et ne peuvent pas jouer le même rôle dans un programme.

Par exemple, pour déclarer un tableau de manière automatique dans un sous-programme (en tant que variable locale), le compilateur doit connaître la taille du tableau à la compilation pour lui réserver un espace suffisant. Une constante entière dont la valeur est connue à la compilation permet cela; une constante entière dont la valeur n'est pas connue à la compilation ne le permet pas (par définition).

J'utiliserai donc les majuscules pour les constantes statiques, dont la valeur est fixée dès la compilation d'un programme, mais pas pour les constantes dynamiques, dont la valeur initiale dépend de l'exécution du programme mais demeure fixée à partir de sa déclaration.

Constantes d'instances

Moins connues que les constantes de classes mais parfois fort sympathiques : les constantes d'instances. La différence entre les deux va comme suit :

La valeur d'une constante d'instance est fixée à la construction. Ces constantes n'ont donc pas des valeurs connues à la compilation des programmes et ne peuvent pas servir pour déterminer la taille d'un tableau sans avoir recours à de l'allocation dynamique de mémoire.

De même, sachant que les attributs d'une instance doivent être construits avant que l'instance elle-même ne le soit, il importe que les constantes d'instances soient initialisés par préconstruction, donc avant l'accolade ouvrante du constructeur de l'instance à laquelle elles appartiennent. En effet, une fois l'accolade ouvrante du constructeur atteinte, les attributs sont construits et, si un attribut est construit et est qualifié constant, alors on ne peut plus en changer l'état.

À droite, j'ai écrit le constructeur de copie à titre illustratif, mais pour une classe comme celle-ci, il serait préférable de laisser le compilateur générer implicitement ce constructeur.

class ValeurFixe
{
   const int valeur_;
public:
   ValeurFixe()
      : valeur_{}
   {
   }
   ValeurFixe(int valeur)
      : valeur_{valeur}
   {
   }
   ValeurFixe(const ValeurFixe &vf)
      : valeur_{vf.valeur()}
   {
   }
   int valeur() const
      { return valeur_; }
   // ...
};

En Java, une constante d'instance doit avoir une valeur fixée (une seule fois!) à l'intérieur du constructeur de l'instance à laquelle elle appartient, mais il n'existe pas de mécanique spécifique pour la préconstruire.

Remarquez qu'utiliser une constante d'instance restreint certaines opérations sur l'objet qui la possède. Entre autres, il est imposible de modifier cet état de l'objet, ce qui implique que l'opérateur d'affectation par défaut, généré par le compilateur si la programmeuse ou le programmeur n'en a pas suppléé(e) une lui-même ou elle-même, ne sera pas opérationnel puisque, par défaut, il cherche à réaliser une affectation membre à membre des attributs, chose illégale par définition sur un membre constant.

Constantes locales connues à l'exécution

Il peut arriver qu'un sous-programme utilise une constante locale qui soit fixée pour la duré de l'invocation du sous-programme mais qui ne soit pas connue à la compilation, par exemple dans le cas où elle dépendrait de la valeur d'un paramètre.

Dans un tel cas, clairement, il ne faut pas que la constante locale soit qualifiée static puisqu'on veut que sa valeur initiale soit fixée lors de l'invocation, pas avant, et ce à chaque invocation.

Considérant que la valeur devra être évaluée à chaque appel et ne pourra donc pas être brûlée dans le code, pourquoi ne pas se limiter à une variable ici? Pourquoi se préoccuper d'une constante?

#include <cmath>
bool est_premier(int n)
{
   using std::sqrt;
   // les cas à traiter sont 2 et les impairs alant
   //  inclusivement de 3 à la racine carrée de n
   const int SEUIL = sqrt(n);
   // ... reste du code ...
}

Pour plusieurs raisons :

Paramètres constants

Il est aussi possible d'utiliser des paamètres constants. Un trouve trois catégories de tels paramètres, soit les paramètres constants par valeur, les paramètres constants par référence et les paramètres constants par adresse.

Vous remarquerez que les noms des paramètres constants ne sont habituellement pas écrits strictement en majuscules, respectant plutôt les usages habituels pour les noms de variables. L'idée derrière cette façon de faire est que les paramètres constants sont surtout des objets sur lesquels le progamme, pour une courte période (la durée du sous-programme), s'engage à ne pas modifier quelque état que ce soit.

Le compilateur garantit le respect de cet engagement en refusant de compiler le sous-programme si le contrat n'est pas respecté. De même, le compilateur peut profiter de cet engagement pour procéder à un certain nombre d'optimisations agressives; en toute honnêteté, cela dit, la plupart n'en profitent pas.

Paramètres par valeur constants

Les paramètres par valeur constants jouent le même rôle que les constantes locales connues à l'exécution : ce sont des indications au compilateur que la valeur d'un paramètre ne sera pas modifiée lors de l'exécution d'un sous-programme.

Un cas simple est proposé en exemple à droite. La borne minimale (paramètre min) du sous-programme afficher_valeurs() est une variable, utilisée par la répétitive à l'intérieur du sous-programme à titre de compteur, alors que la borne maximale (paramètre max) est constante, indiquant au compilateur qu'il est en droit de procéder à toute forme d'optimisation reposant sur le caractère fixe de sa valeur.

Remarquez que le paramètre max porte un nom combinant majuscules et minuscules ici, respectant les usages pour les noms de variables. Il s'agit en effet d'une variable dont la valeur est fixée pour la durée de l'invocation du sous-programme (la programmeuse ou le programmeur spécifie le paramètre const pour s'engager à ne pas en modifier la valeur et pour permettre certaines optimisations) plutôt que d'une constante dont la valeur serait connue à la compilation.

#include <ostream>
void afficher_valeurs
   (int min, const int max, std::ostream &os)
{
   for(; min <= max; ++min)
      os << min << ' ';
   os.flush();
}

Plusieurs compilateurs négligent d'exploiter ce potentiel d'optimisation, ce qui mène certains experts à négliger de spécifier constants les paramètres par valeur qui s'y prêteraient, mais souvenez-vous que la rigueur, ici, ne peut pas nuire : au pire, vous ne gagnez rien mais vous ne perdez rien non plus alors qu'au mieux, vous gagnez.

Notez aussi que le compilateur ne peut faire la différence entre un sous-programme dont les paramètres sont passés par valeur et un autre dont les paramètres sont passés par valeur et sont constants (donc int f(int); et int f(const int); ne poeuvent être distingués l'un de l'autre par un compilateur... Pensez-y!). Évitez l'ambiguïté et ne cherchez pas à distinguer deux sous-programmes sur cette base.

Paramètres par référence-vers-const

Une donnée d'un type primitif est habituellement de petite taille et peut être copiée sans coût réel lorsqu'on la passe en paramètre à un sous-programme. En retour, copier un objet est une opération de complexité arbitrairement grande puisque l'action de copier un objet implique un appel de sous-programme (son contructeur de copie) pour créer la temporaire résultante et un autre appel de sous-programme (son destructeur) pour l'éliminer.

Il y a un coût caché dans cette paire d'opérations qui peut être très élevé si copie un objet (et détruire la variable temporaire résultante) s'avère être une opération coûteuse. Ainsi, chaque appel au sous-programme f0() dans l'exemple à droite impliquera appeler le constructeur par copie de X (par X::X(const X&)) pour créer le X local à f0() et un appel à son destructeur (X::~X()) lorsqu'il faudra détruire cette variable.

Passer cet objet par référence éliminerait le coût de la copie (une référence, vue de près, est simplement une adresse) et accélérerait ainsi les invocations au sous-programme en question mais la conséquence de ce geste est une réduction de la sécurité puisque l'objet passé en paramètre peut alors être modifié par le sous-programme appelé à l'insu du sous-programme appelant. C'est la situation qu'on rencontre par défaut avec Java et les langages .NET et qui rend délicate la tâche d'écrire des programmes sécuritaires avec ces langages.

class X
{
   // ...
};
//
// sans danger, mais implique créer et
// détruire un X temporaire
//
void f0(X);
// rapide mais met le X original à risque
void f1(X&);
//
// rapide et sans danger. Règle générale,
// mieux que f0() si la fonction ne fait
// pas de copie locale du paramètre
//
void f2(const X&);

En retour, f2() combine sécurité et rapidité : son paramètre est une référence sur un X const, ce qui signifie que le X original ne sera pas copié à l'invocation de f2() (le paramètre étant une référence) et que le X original ne risquera pas d'être modifié par l'exécution de f2() (la référence étant constante).

C'est pourquoi, si un sous-programme ne compte pas modifier une donnée dont le type n'est pas primitif, il est pratiquement toujours préférable de la passer par référence constante plutôt que de la passer par valeur. Évidemment, si un sous-programme compte modifier la donnée, la passer par référence tout court reste la chose à faire.

Petite parenthèse : pour les types primitifs, le passage par valeur (constante ou non) reste à privilégier. Utiliser une référence implique accéder indirectement au référé, et pour un type primitif cela ralentira inutilement l'exécution du code.

Uune référence est une adresse, donc quelque chose d'à peu près aussi gros qu'un int, donc une référence n'est pas passée plus rapidement en paramètre que ne l'est un simple entier ou un nombre à virgule flottante. Dans les exemples à droite, les deux meilleurs candidats sont g1() et g2() (selon les besoins).

//
// sans danger; crée un double temporaire
//
void g0(double);
//
// permet à g1() de modifier le double original
//
void g1(double&);
//
// équivalent à g0(); un bon compilateur pourra
// mieux faire avec g2() qu'avec g0(), mais ces
// compilateurs sont rares
//
void g2(const double);
//
// avec un type primitif, g3() est souvent moins
// efficace que g2(). Cela dit, faut tester!
//
void g3(const double&);

Autre petite parenthèse : bien que passer un objet en paramètre par référence-vers-const soit habituellement plus efficace que de le passer par valeur, les deux options restent possibles. Le système de types de C++ demeure très homogène.

Paramètres par adresse constants

Les paramètres par adresse constants existent à la fois en C et en C++. L'idée d'un paramètre par adresse constant est de permettre de manipuler une indirection tout en offrant une garantie de non-modification.

L'exemple type est la fonction strcpy() de la bibliothèque <string.h> du langage C, placée dans l'espace nommé std et dans la bibliothèque <cstring> en C++. Cette fonction prend deux paramètres :

  • Le paramètre dest qui pointe sur une zone mémoire où l'on va écrire (un char *), et
  • Le paramètre src qui pointe sur une zone mémoire que l'on se limitera à consulter (un const char *)

La fonction copie chaque byte (chaque char) de src vers dest jusqu'à un octet de valeur 0 (ou '\0', ce qui est identique), la copie du dernier octet incluse. La valeur 0 sur un byte sert de délimiteur dans la tradition des chaînes ASCIIZ du langage C. La fonction strcpy() laisse au code client la responsabilité d'allouer au préalable pour dest un espace suffisant pour recevoir les données de src. C'est rapide et brutal.

// version compacte et traditionnelle
char *strcpy(char *dest, const char *src)
{
   for (; *dest++ = *src++; )
      ;
   return dest;
}
// si vous préférez quelque chose de plus convivial
char *strcpy_gentil(char *dest, const char *src)
{
   char *p = dest;
   while (*src != '\0')
   {
      *p = *src;
      ++p;
      ++src;
   };
   *p= '\0';
   return dest;
}

En examinant l'une ou l'autre des formes de cette fonction, on remarquera effectivement que *dest (là où pointe dest) se fait affecter des valeurs alors que *src (là où pointe src) ne sert qu'en lecture (à droite des affectations). En fait, on utilise un pointeur temporaire (non constant) nommé p pour itérer à partir de dest parce que la convention est que strcpy() retourne l'adresse de destination suite à la copie des données.

Remarquez que la protection offerte par une adresse constante est limitée : ici, si src et dest se chevauchent, écrire dans *dest risque de modifier la zone où mène src. Cela fait partie des risques de code reposant sur des pointeurs, jouet puissant mais dangereux s'il en est un.

En fait, la forme de pointeur constant vers une donnée que je connais le plus sert à implémenter... des références, qui ne sont essentiellement que ça – une référence est un pointeur sur lequel l'artihmétique de pointeurs est illégale.

Remarquez aussi qu'incrémenter un pointeur vers une donnée constante n'est pas illégal puisque cela déplace le pointeur sans changer la valeur du pointé. Il est toutefois possible, en déplaçant le mot const, de spécifier p est un pointeur constant vers une donnée (p. ex. : char * const p) ou de spécifier p est un pointeur constant vers une donnée constante (p. ex. : const char * const p), mais la forme p est un pointeur vers une donnée constante (p. ex. : const char *p) est la plus fréquente et la plus utile en général.

Méthodes const

Élément très important pour un système de types OO homogène : les méthodes const, qui sont toujours des méthodes d'instance.

Sur le plan conceptuel, une méthode const garantit qu'elle laissera son instance intacte de par ses actions.

Sur le plan syntaxique, une méthode constante est qualifiée const suite à la parenthèse fermante de sa liste de paramètres (voir valeur(), à droite). Techniquement, qualifier une méthode avec const implique rendre *this constant pour la durée de l'invocation de cette méthode.

Une méthode const garantit que son action ne changera rien à l'état de l'instance à laquelle elle appartient. Par définition, donc :

  • Un constructeur n'est jamais const
  • Un destructeur n'est jamais const
  • Une méthode de classe (qualifiée static) n'est jamais const; et (à moins d'agir avec une perversion non recommandable)
  • Les mutateurs (méthodes set) et les opérateurs d'affectation (=, +=, -=, etc.) ne sont jamais constants
class Entier
{
   int valeur_;
   //
   // ne peut être qualifiée const (modifie
   // l'instance à laquelle elle appartient)
   //
   void valeur(int val)
      { valeur_ = val; }
public:
   Entier(int val)
      : valeur_{val}
   {
   }
   //
   // Ok: appel de méthode const sur un objet
   // const (mais il serait préférable que le
   // compilateur génère implicitement cette
   // méthode puisqu'elle est triviale)
   //
   Entier(const Entier &e)
      : valeur_{e.valeur()}
   {
   }
   //
   // Ok: méthode qualifiée const, laisse
   // l'instance intacte
   //
   int valeur() const
      { return valeur_; }
   //
   // ne peut être qualifiée const (modifie
   // l'instance active). Il serait préférable
   // de laisser le compilateur la générer
   // implicitement puisqu'elle est triviale
   Entier& operator=(const Entier &e)
   {
      //
      // ok: e est const, valeur() aussi, alors que
      // valeur(int) n'est pas const et this non plus
      //
      valeur(e.valeur());
      return *this;
   }
};

En retour, plusieurs méthodes sont typiquement const :

Certains cas, comme l'opérateur [] par exemple, se déclinent souvent de manière à la fois const et non-const pour une même classe. Pour bien saisir les nuances dans chaque cas, discutez avec votre professeur de POO.

Les méthodes const sont nécessaires à un système de types homogène si celui-ci admet des objets. Sur un objet const, seules les méthodes const peuvent être utilisées. Ainsi, si la programmeuse ou le programmeur d'une classe donnée néglige de qualifier const les méthodes qui devraient l'être, alors il devient impossible de manipuler une instance constante de cette classe. Le code devient inefficace ou tout simplement inutilisable.

La position du mot const dans une signature de méthode ou de fonction est importante. Dans l'exemple ci-dessous (où le type interne et public str_type, correspondant à std::string, e st défini pour alléger l'écriture) :

  • La méthode nom() est const – sa qualification const entre la parenthèse fermante et l'accolade ouvrante – parce que son action ne modifie en rien l'état de l'instance de Professeur à laquelle elle appartient
  • La méthode nom() retourne une référence sur une const str_type (type de la méthode) pour éviter de retourner une copie inutile. Ceci n'a de sens que si la donnée retournée existe même après invocation de la méthode (ici, nom_ est un attribut de Professeur et ne sera pas détruit à la fin de l'invocation de nom())
  • La méthode titre() est const parce que son action ne modifie en rien l'état de l'instance de Professeur à laquelle elle appartient. Remarquez qu'elle n'invoque que des méthodes d'instance elles-mêmes constantes (une méthode const ne peut invoquer une méthode non const du même objet, par définition). Elle retourne un str_type par copie, une obligation du fait que la valeur retournée est celle d'une variable temporaire anonyme qui n'existera plus suite à l'invocation de titre()
  • Dans la méthode titre(), la constante PREFIXE une str_type construite une seule fois (une static const) plutôt qu'une fois par appel. Son état initial ne dépend que d'un littéral, donc d'une information connue à la compilation
  • Le paramètre passé au constructeur paramétrique de Professeur est une référence sur un const str_type, ce qui évite une copie inutile (donc la génération et la destruction d'une variable temporaire). Aucune opération susceptible de modifier l'état du paramètre n'est réalisée par le constructeur
  • Le paramètre passé à l'opérateur == permettant de comparer l'instance active de Professeur avec une autre instance de Professeur est une référence sur un const Professeur, ce qui évite une copie inutile. Aucune opération susceptible de modifier l'état du paramètre n'est réalisée par cette méthode (seule la méthode nom(), elle-même const, est invoquée sur cet objet)
  • Les opérateurs == et != permettant de comparer l'instance active de Professeur avec une autre instance de Professeur sont elles-mêmes const. Aucune opération susceptible de modifier l'état du paramètre n'est réalisée par cette méthode (seule la méthode nom(), elle-même const, est invoquée sur *this)
#include <string>
class Professeur
{
public:
   using str_type = std::string;
private:
   str_type nom_;
public:
   // ...
   //
   // La méthode nom() retourne un const str_type&
   // pour donner un exemple; en pratique, je vous
   // recommande de retourner une copie (donc un
   // str_type tout simplement)
   //
   const str_type &nom() const
      { return nom_; }
   str_type titre() const
   {
      static const str_type PREFIXE = "Professeur ";
      return PREFIXE + nom();
   }
   Professeur(const str_type &nom)
      : nom_(nom)
   {
   }
   bool operator==(const Professeur &p) const
      { return nom() == p.nom(); }
   bool operator!=(const Professeur &p) const
      { return !(*this == p); }
   //
   // ...
   //
};

Valid XHTML 1.0 Transitional

CSS Valide !