La const-correctness expliquée aux Zéros

Formation

En Ligne

Prix sur demande

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

Description

  • Typologie

    Formation

  • Méthodologie

    En ligne

Grâce à cette formation vous pourrez acquérir les connaissances nécessaires qui vous permettrons d’ajouter des compétences à votre profil et obtenir de solides aptitude qui vous offriront de nombreuses opportunités professionnelles.

Questions / Réponses

Ajoutez votre question

Nos conseillers et autres utilisateurs pourront vous répondre

À qui souhaitez-vous addresser votre question?

Saisissez vos coordonnées pour recevoir une réponse

Nous ne publierons que votre nom et votre question

Les Avis

Le programme

Introduction du cours

Bonjour ami Zéro.

Nous allons parler ici de la const-correctness. Derrière ce nom barbare se cache un élément important du C++ qui est généralement mal maîtrisé, voir complètement ignoré par les débutants.
Avec ce tuto, la constance n'aura plus de secrets pour vous. ;)

Vous ne devriez pas avoir besoin d'un bon niveau en C++ pour suivre ce tutoriel. Le Zéro moyen à qui il s'adresse est celui qui a déjà écrit quelques classes et fonctions, même très simples, sans se soucier de problèmes de constance.
Il est possible que vous ne soyez pas familiers avec certaines notions évoquées ici. Je vous ai généralement laissé un lien vers un article (le plus accessible possible) pour que vous puissiez découvrir ou réviser ce dont il est question.

IntroductionQu'est-ce que la const-correctness ?

On pourrait (parce qu'on ne le fait jamais) traduire le terme de const-correctness par « correction de la constance », le mot « correction » renvoyant bien sûr à la qualité de ce qui est correct. La const-correctness est donc tout simplement la manière d'utiliser correctement la constance dans un code, et un code écrit de cette manière est dit const-correct.

La constance en C++ est matérialisée par un mot-clé : const qui, vous le savez, permet notamment de déclarer une variable comme étant constante, c'est-à-dire que sa valeur n'est pas sensée changer au cours de l'exécution du programme.
La const-correctness inclue également l'utilisation d'un mot-clé et d'un opérateur que l'on emploie moins mais que nous allons étudier ici : mutable et const_cast. Ces deux éléments permettent dans une certaine mesure d'ignorer la constance d'une variable.

Pourquoi écrire un code const-correct ?

L'existence de mutable et de const_cast nous amène à cette terrible vérité : une variable constante peut voir son état modifié durant l'exécution du programme.

...

o_O

Mais dans ce cas, quel est l'intérêt de déclarer une variable constante si elle peut être modifiée ?

Certains programmeurs pensent que le mot-clé const permet au compilateur de faire des optimisations. C'est faux la plupart du temps, on n'est jamais à l'abri d'un const_cast ou d'un mutable qui viendrait rompre l'immutabilité supposée de notre objet. Je n'entrerais pas dans le détail des cas où la présence de const permet ou non des optimisations, si cela vous intéresse, je vous renvoie à cet article (en anglais) d'Herb Sutter qui traite de cette question bien mieux que je ne saurais le faire. Nous allons tout de même aborder une de ces optimisations, très importante, facile à mettre en place et qui à elle seule va justifier l'écriture d'un code const-correct.

Hormis ce cas la const-correctness est utile au développeur comme sécurité lorsqu'il programme, puisque modifier accidentellement une variable constante entrainera une erreur de compilation. Elle sert également de documentation. Par exemple, utiliser des paramètres constants indique immédiatement à l'utilisateur que la fonction ne modifiera pas les variables qu'on lui envoie.

Au cours de ce tutoriel, nous allons approfondir ces avantages et en découvrir de nouveaux. Vous verrez que bientôt la const-correctness vous deviendra indispensable.

Éléments de syntaxes et généralitésLes valeurs constantes

Comme le veut la politique du site, nous allons partir de zéro et reprendre la base de l'utilisation de const. La const-correctness touchant le développement d'une application, la plupart des exemples que je vais présenter n'auront d'intérêt qu'à la compilation, ne vous en étonnez pas.

Le mot-clé const empêche toute modification ultérieure de la valeur d'une variable. L'état de cette variable ne pourra plus être modifié. Essayer de le faire malgré tout entrainera une erreur à la compilation. Dans le cas d'une classe ou d'une structure, tous les sous-objets de l'objet constant sont également constants.

Déclarer une variable constante se fait comme ceci :

int const maVariable;

Cette déclaration peut vous sembler étonnante. Vous avez certainement appris, via le tuto de M@teo21 par exemple, qu'une constante se déclarait :

const int maVariable;

Cela n'est pas tout à fait exact. En fait, la règle générale est que le mot-clé const s'applique à ce qui se trouve directement à sa gauche ou, s'il n'y a rien qui le précède dans la déclaration à ce qui se trouve à sa droite. Ajoutons à cela que dans le cas d'un type composé (comme un pointeur ou un tableau), la constance est applicable exclusivement à l'élément directement à gauche (ou à droite s'il n'y a rien à gauche) et non à l'ensemble de la déclaration.

Pour s'en convaincre, examinons le code suivant :

int i, j; int const * p = &i; *p = j; //Erreur, la valeur pointée par p est constante. p = &j; //Correct, le pointeur p n'est pas constant

Seule la deuxième ligne compile, ce qui corrobore bien l'affirmation faite plus haut. Pour ma part, je préfère mettre le const à gauche lorsque c'est possible pour faciliter la lecture et éviter une confusion si le lecteur ne connait pas la règle.

Si vous avez essayé de compiler les exemples précédents, vous vous serez aperçu que cela ne fonctionne pas. La raison est simple : une variable constante doit être initialisée lorsqu'elle est déclarée. Et c'est assez logique : en C++ si vous n'initialisez pas une variable celle-ci peut prendre n'importe quelle valeur, et comme vous ne pourrez la modifier par la suite, le résultat serait être assez hasardeux.
Je me permets parfois de ne pas initialiser les variables dans les exemples pour ne conserver que ce qui a de l'importance. De votre côté n'oubliez pas d'attribuer une valeur à chaque constante que vous déclarerez.

Question subsidiaire : comment déclarer un pointeur constant ? Un pointeur constant sur une valeur constante ?

Si vous avez compris ce que je viens de dire, cela ne devrait poser aucun problème.

Voici la réponse :

int * const p; //Pointeur constant sur un int non constant const int * const q; //Pointeur constant sur un int constant

Un pointeur constant ne peut voir l'adresse qu'il contient modifiée. A la syntaxe près, il est donc identique à une référence et l'on préfèrera systématiquement utiliser cette dernière.

Une dernière petite remarque pour clore ce paragraphe :

int& const i;

Cette déclaration est absurde. Vous le savez maintenant, ici le mot-clé const ne s'applique pas à int, et il est impossible de réinitialiser une référence pour en faire un alias d'une autre variable. En clair, une référence est toujours constante, c'est le type pointé qui peut l'être ou non (toutefois, par abus de langage, on désignera par « référence constante » une référence vers un type constant).
La norme interdit cette syntaxe mais certains compilateurs s'ils ne sont pas bien réglés laisserons passer l'erreur. Notez qu'il est possible d'arriver à une déclaration de ce genre via l'utilisation de typedef. Dans ce cas on ne considère pas ça comme une erreur : un type plusieurs fois qualifié constant étant simplement constant.

Les fonctions constantes

Le terme de fonction constante ne peut s'appliquer qu'à une fonction membre. Et pour cause ! Le principe d'une fonction constante est qu'elle ne modifie pas l'objet sur lequel elle est appelée.

Une fonction constante est définie comme ceci :

struct Exemple { void bar() const { } //Ou, si l'implémentation est dissociée de la déclaration : void foo() const; }; void Exemple::foo() const { }

Comme pour les valeurs constantes, le mot-clé vient se placer à droite de ce qui est qualifié (le prototype) mais avant le corps de la fonction. Notez la répétition du mot-clé lorsque l'implémentation est séparée de la définition : elle est obligatoire.

Si votre fonction doit être virtuelle pure, la déclaration est la suivante :

struct Exemple { virtual void foo() const = 0; };

Pour s'assurer qu'aucune instruction exécutée dans votre code ne viendra modifier l'état de votre objet au moment de l'appel, tous les attributs utilisés dans votre fonction constante sont considérés comme étant constants. De même, la valeur pointée par le pointeur this sera aussi constante. En fait, c'est parce que votre objet (pointé par this) est considéré comme étant constant que tous ses sous-objets (ses attributs, que vous utilisez dans votre fonction) seront constants.

struct Exemple { void foo() const { mon_int = 0; //Erreur, mon_int est constant. } int mon_int; };

Fort heureusement, ceci ne s'applique qu'à l'objet sur lequel est appelée la fonction, et non aux paramètres, même s'ils sont du même type.

struct Exemple { void foo(Exemple& exemple) const { exemple.mon_int = 0; //Ok, mon_int appartenant à exemple n'est pas constant. } int mon_int; }; L'intérêt de la const-correctness, enfin !Une optimisation bien utile : le passage de paramètres par référence constante

Je vous parlais d'une optimisation en introduction, nous y voilà. Imaginez un objet très lourd en mémoire. Imaginez maintenant que vous deviez le passer en paramètre d'une fonction qui ne le modifiera pas (donc pas besoin de pointeurs/références à priori). Une implémentation naïve de cette fonction pourrait être :

void maFonction(UneClasseAvecBeaucoupDeDonnees unObjetTresLourd) { //Utilisation d' unObjetTresLourd }

Ici, l'objet est copié en mémoire, on va se retrouver avec deux instances de notre classe très lourde alors que finalement, si nous n'avions pas écrit de fonction et mis le code directement dans le main, nous n'aurions eu besoin que d'un objet.

Et si nous voulions à l'intérieur de notre fonction passer cet objet à une autre fonction ? L'objet en question serait à nouveau dupliqué, multipliant ainsi les instances inutiles et consommant beaucoup plus de ressources que nécessaires.

C'est pour échapper à ce problème que l'on passe les objets par référence. Le principe est simplement de remplacer notre paramètre par une référence (ou un pointeur, mais c'est moins pratique) pour éviter la copie de l'objet. Notre prototype deviendrait donc :

void maFonction(const UneClasseAvecBeaucoupDeDonnees& unObjetTresLourd);

Au lieu de copier l'intégralité de l'objet, on se contente de créer une référence (très légère) qui pointe dessus et qui s'utilisera de la même façon. Bien entendu, comme notre objet n'est pas sensé être modifié par la fonction nous déclarons notre référence comme étant constante.
De manière générale, on préfèrera toujours passer un objet par référence constante plutôt que par copie, même si le gain semble minime. C'est l'exception qui confirme la fameuse First Rule of Program Optimization (Don't do it !).

Attention, tout cela ne s'applique qu'aux objets, c'est-à-dire aux instances d'une classe, et non aux types fondamentaux (int, char, double...) !
Ceux-là sont déjà suffisamment légers pour que l'on se permette de les passer systématiquement par copie.
Ne m'écrivez surtout pas une horreur comme void maFonction(const bool&);

Notons deux avantages supplémentaires au passage par référence : premièrement l'usage d'une référence vous permet de tirer parti du polymorphisme d'inclusion et secondement il vous autorise à passer en paramètre de vos fonctions des objets à sémantiques d'entité qui sont par définition non copiables.
Maintenant vous n'avez plus aucune excuse pour ne pas implémenter vos sémantiques correctement ;)

Références contre références constantes, un choix pas si anodin

Utiliser des références pour alléger les appels de fonction c'est bien beau, mais certains trouveront encore à me dire que sans les const qui se promènent le prototype serait plus lisible. Un commentaire indiquant qu'on ne touchera pas à l'état de l'objet pointé et hop on aura un code plus clair et tout aussi efficace.

J'aimerai que cela soit aussi simple, malheureusement si vous tentez l'expérience, vous allez vite vous retrouver confronté à des ennuis.
Faisons l'essai avec un cas récurrent, celui de la surcharge des opérateurs. Reprenons par exemple une classe définie dans le tuto sur la surcharge des opérateurs :

class Duree { public: explicit Duree(int heures = 0, int minutes = 0, int secondes = 0) : my_time(heures*3600 + minutes*60 + secondes) {} private: friend Duree operator+(Duree&,Duree&); //Essayons avec des références non constantes unsigned int my_time; };

Je l'ai un peu simplifiée mais elle joue le même rôle.

Définissons maintenant l'opérateur d'addition et testons voir s'il fonctionne.

Duree operator+(Duree& lhs, Duree& rhs) //Normalement on ne définit pas exactement cet opérateur de cette manière mais je me le permet ici par souci de simplicité. { Duree tmp; tmp.my_time = lhs.my_time + rhs.my_time; return tmp; } int main() { Duree d1(1,56,10), d2(2,3,50); Duree d3 = d1+d2; }

Jusqu'ici pas de problème, mais le gros intérêt de la surcharge des opérateurs c'est de pouvoir chainer les opérations. Essayons :

Duree d4 = d1 + d2 + d3;

Et là, c'est le drame. Le compilateur nous sort une erreur incompréhensible du genre :

error: no match for ‘operator+’ in ‘operator+((* & d1), (* & d2)) + d3’|

Que s'est-t-il passé ? Eh bien, cher ami zéro, nous venons d'avoir la preuve que const n'est pas qu'un accessoire pour le développeur C++.

La raison de notre erreur est la suivante : une référence non constante ne peux pas être initialisée par un objet temporaire. Or c'est précisément ce que doit renvoyer l'opérateur d'addition.
Dans notre cas, le résultat de d1 + d2 est calculé en premier et l'objet récupéré, qui est temporaire (appelons le d5) est passé en paramètre de l'opération suivante : d5 + d3.
Nous n'avons d'autre choix que d'ajouter const pour transformer nos références en références constantes qui, elles, peuvent être initialisées avec un objet temporaire.

Compilez (n'oubliez pas de changer la déclaration friend dans la classe), ça marche, notre problème est résolu et à l'avenir vous utiliserez des références constantes dans vos prototypes ;) -

const fait des vagues

Supposons, et c'est légitime, que nous voulions afficher notre classe Duree. Ajoutons donc une fonction membre comme le ferait la plupart des zéros :

class Duree { public: explicit Duree(int heures = 0, int minutes = 0, int secondes = 0) : my_time(heures*3600 + minutes*60 + secondes) {} void afficher() //Être SRP-correct ou ne pas l'être ? --private-joke { std::cout << my_time / 3600 << "h" << my_time % 3600 / 60 << "m" <<...

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

La const-correctness expliquée aux Zéros

Prix sur demande