Vous voudrez peut-être aussi consulter constexpr.html pour en savoir plus à propos du mot-clé constexpr, qui étend le concept de constante à des expressions telles que des constructeurs ou des appels de fonctions.
Notez que ce document fait des comparatifs entre quelques langages, ce qui explique la présence de mutateurs (des méthodes « Set »); en pratique, je n'utilise presque jamais de telles méthodes.
Merci à celles et ceux qui ont relu ce texte et ont suggéré des corrections ou des raffinements (avec un merci particulier à Pierre Prud'homme qui fut particulièrement prolifique et pertinent).
L'importance des constantes est une chose qu'on enseigne habituellement aux étudiantes et aux étudiants en informatique lors des tout premiers cours de programmation et à laquelle trop de gens (certains enseignants inclus) n'offrent pas suffisamment de considération.
L'argument traditionnel pour utiliser des constantes se détaille à peu près comme suit :
Un bémol s'impose : les compilateurs C++ tendent à être très agressifs dans leurs optimisations, et pourront souvent détecter des situations telles que celle de la fonction f() à droite, où une variable locale comme n (car oui, le paramètre n étant passé par valeur, il sera pour f() une variable locale) ne changera pas d'état pendant l'exécution de la fonction, et traiter localement cette variable comme une constante. Le compilateur pourra d'ailleurs aussi constater, dans bien des cas, que cette fonction fait la somme des entiers pairs positifs et remplacer l'incrémentation ++i par i+=2 et éviter le test et le modulo dans le corps de la boucle... Qualifier n de const int ici n'apportera probablement rien... mais cela ne coûtera rien non plus. |
|
En général, si une entité peut être qualifiée constante, alors elle devrait probablement l'être. On peut ne rien gagner, mais il est pratiquement certain qu'on n'y perdra rien. C'est là une maxime fort utile à retenir, mais une maxime sans compréhension est d'une utilité limitée. Il y a beaucoup d'autres raisons pour avoir recours aux constantes, en particulier dans un monde OO, et c'est sur ces cas fort intéressants que nous poserons notre regard ici.
Ma définition de l'encapsulation dépasse le seuil des mots privé, protégé et public qu'on retrouve dans la plupart des langages de programmation OO et se situe à un niveau plus philosophique. Elle se décline en deux temps :
Contrairement à ce que les gens pensent souvent, il est très difficile d'assurer le respect du principe d'encapsulation exprimé en ces termes en Java et dans les langages .NET, du moins sans que le code client de l'objet ne soutienne activement l'objet lui-même (en C#, par un bloc using appliqué à une classe implémentation l'interface IDisposable; en Java, du moins à partir de Java 7, dans une variante du bloc try).
Le schéma de finalisation dans un bloc using à partir d'un objet IDisposable en C# sert à libérer les ressources externes au Framework .NET lui-même. Pour les ressources du Framework, il faut en partie faire confiance au moteur, en partie finaliser dans le code client (par exemple, un bloc finally pour fermer les fichiers et les sockets).
La clause sur les effets de bord y est difficile à mettre en place malgré (ou même à cause de) la présence d'un moteur de collecte automatique d'ordures, comme le démontre cet article. Le problème est que Java n'offre aucune garantie d'appel à la méthode de nettoyage finalize() (elle n'est souvent pas appelée du tout) et que les langages .NET invoquent Dispose() ou son équivalent mais sans qu'aucune référence .NET dans l'objet en cours de nettoyage ne puisse être considérée valide.
Intégrer ces langages à toute ressource externe exige la mise en place de schémas de conception (IDisposable sous .NET) et l'insertion de code pour libérer les ressources manuellement, comme par exemple appeler close() sur les fichiers ou insérer des blocs finally dans le traitement d'exceptions.
Ces reliquats d'une époque pré-OO sont la conséquence directe de la collecte d'ordures prise en charge, qui doit fonctionner même quand deux objets se réfèrent mutuellement l'un et l'autre: il s'agit de l'inverse d'une médaille a priori fort jolie, qui nous rappelle qu'il n'y a rien de gratuit et que ce qui semble simplifier le code dans certains cas devient, à l'inverse, un cauchemar d'entretien dans le monde intégré et interopérable vers lequel nous tendons au moment d'écrire ceci. Prudence : la collecte automatique d'ordures n'est pas en soi une mauvaise chose. Elle règle des problèmes et permet certains styles de programmation mais ce n'est pas une panacée.
La principale cause de l'incapacité d'implémenter l'encapsulation au sens des garanties d'intégrité en Java et dans les langages .NET tient quant à elle de l'incapacité d'exprimer des constantes dans ces langages. C'est ce que nous démontrerons ici, en apportant les nuances qui s'imposent pour les classes immuables et en expliquant le prix à payer pour faire du code robuste sans avoir accès à la possibilité d'exprimer quelque chose d'aussi fondamental que des constantes.
Parenthèse – mais je n'ai jamais eu de problèmes, moi!
Je rencontre souvent des gens qui me disent ne jamais vivre de problèmes d'intégrité de code en Java et en C#, plusieurs étant des programmeuses et des programmeurs de qualité. Je suis certain que cela correspond à leur expérience de développement. Cependant, l'absence dans leur vécu de difficultés comme celles évoquées ici tient surtout au contexte dans lequel leur code a été développé et utilisé.
Certains problèmes, même parmi les plus fondamentaux, n'apparaissent au grand jour que lorsqu'un programme est manipulé par d'autres que ceux qui l'ont écrit. C'est l'une des raisons pour lesquelles j'aime parler, dans de telles circonstances, de code de bibliothèque. J'entends par code de bibliothèque du code conçu par certains et utilisé par (beaucoup!) d'autres, donc qui doit être aussi robuste que possible tout en permettant d'atteindre des niveaux élevés de performance. En effet, si un module est sollicité massivement dans un programme, comme du code de bibliothèque risque de l'être, alors il doit être extrêmement robuste et aussi rapide que possible.
à mon avis, tout objet doit être conçu comme s'il pouvait évoluer de manière à devoir se conformer aux contraintes propres à du code de bibliothèque. Une fois un objet publié, il est susceptible d'être utilisé un peu partout et de servir comme point névralgique dans un système complexe. C'est pourquoi il faut au moins lui offrir la possibilité d'évoluer vers le niveau de qualité attendu pour du code de bibliothèque.
Le respect de l'encapsulation est crucial en ce sens. Toute brèche dans la barrière d'abstraction d'un objet (par exemple l'introduction d'un attribut protégé ou public) brise immédiatement la capacité qu'a un objet X d'être garant de sa propre intégrité, du fait qu'un tiers (instance d'une classe dérivée ou autre objet du système, selon le cas) devient capable de modifier l'état de X à son insu.
Beaucoup de (bon!) code Java et .NET se trouve en circulation aujourd'hui, mais une trop grande partie de ce code est du gruyère du point de vue de l'encapsulation. La principale cause n'est pas la qualité des programmeuses et des programmeurs mais bien la difficulté beaucoup plus grande que ne le pensent la plupart des gens d'écrire du code à la fois robuste et rapide dans ces langages.
Imaginons une classe Rectangle offrant un ensemble de services, en particulier celui permettant à un Rectangle de se dessiner. Certains choix d'implémentation doivent être faits mais ne devraient pas affecter l'interface par laquelle un Rectangle est utilisé – par exemple, si un Rectangle offre des services permettant d'en connaître la hauteur et la largeur, il importe peu pour un client du Rectangle de savoir si ces données sont calculées à partir des coordonnées de deux coins opposés du Rectangle ou si elles sont entreposées sous forme d'attributs.
Nous débuterons notre discussion en présumant les services suivants pour la classe Rectangle. Notez que même l'acte d'établir une liste de services est un geste politique dont la portée est sujette à discussion (certains choix de services proposés ci-dessous sont porteurs de conséquences distinctes selon les langages).
Nom | Vocation | Peut modifier l'objet | Remarques |
---|---|---|---|
Constructeur par défaut |
Initialiser un Rectangle typique. |
Oui |
Le sens de Rectangle typique est politique et dépend des besoins et des règles locales. Cette méthode ne dépend d'aucun apport externe et devrait donc tendre à réduire les validations au minimum. |
Constructeur paramétrique |
Initialiser un Rectangle à partir de paramètres suppléés par le sous-programme procédant à son instanciation. |
Cette méthode est susceptible de se faire offrir des paramètres incorrects et doit donc procéder à une validation exhaustive de ses intrants. |
|
Constructeur de copie |
Initialiser un Rectangle à partir d'un autre Rectangle de manière à ce que, suite à la construction, la copie et l'original soient identiques au sens d'une comparaison de contenu. |
Cette méthode est un support mécanique important en C++ du fait que ce langage exploite beaucoup les objets selon une sémantique par valeur. Lors de manipulation d'objets à travers une sémantique indirecte (pointeurs, références), ce que permet C++ (Java et les langages .NET ne supportent pas vraiment d'autres sémantiques que les sémantiques indirectes), les constructeurs par copie sont dangereux s'ils sont exposés de manière publique, mais sont utiles à l'interne (exposés de manière protégée) pour mettre en place une stratégie subjective et polymorphique de clonage. |
|
Méthode de clonage |
Demander à un Rectangle d'instancier dynamiquement une copie de lui-même et de retourner cette copie. On préfère le clonage à la copie dans le cas de classes susceptibles d'avoir des enfants ou exposant au moins un service polymorphique. |
Non |
Dans les langages sans collecte automatique d'ordures (en particulier C++), il importe de faire très attention aux pertes de mémoire, surtout dans le cas où une exception est levée. Sachant cela, en C++, plusieurs privilégieront enrober les pointeurs retournés par une méthode telle que celle-ci dans un pointeur intelligent tel que std::unique_ptr. |
Destructeur |
Libérer les ressources qu'une instance de Rectangle possède avant que celle-ci ne soit détruite. |
Oui |
Nous le laisserons vide dans ce cas-ci. Les divers langages OO les plus commerciaux ont des approches très différentes sur ce sujet. Voir cet article pour une discussion plus détaillée. |
Accesseur pour la hauteur |
Permettre d'interroger un Rectangle pour en connaître la hauteur. |
Non |
Peuvent s'implémenter sous forme de propriété dans certains langages, en particularité les langages .NET, mais cela n'ajoute rien et n'enlève rien au propos alors nous ne nous en occuperons pas ici. |
Accesseur pour la largeur |
Permettre d'interroger un Rectangle pour en connaître la largeur. |
||
Mutateur pour la hauteur |
Permettre de tenter de modifier la hauteur d'un Rectangle. |
Oui |
Peuvent s'implémenter sous forme de propriété dans certains langages, en particularité les langages .NET, mais cela n'ajoute rien et n'enlève rien au propos alors nous ne nous en occuperons pas ici. Le principe d'encapsulation nous rappelle que le Rectangle propriétaire de la méthode est responsable de sa propre intégrité. Les paramètres passés à un mutateur public ou protégé doivent donc être validés de manière rigoureuse pour garantir qu'ils respectent les politiques en place pour l'objet visé. Il est utile, dans une optique de performance, de décliner les mutateurs en deux catégories : les mutateurs privés, optimisés pour la vitesse et qui ne procèdent à aucune validation, et les mutateurs protégés et publics, blindés contre les assauts de tiers hostiles. Confidence : en pratique, personnellement, je n'en utilise presque jamais. Je préfère les objets immuables. |
Mutateur pour la largeur |
Permettre de tenter de modifier la largeur d'un Rectangle. |
||
Méthode dessiner() |
Demander à un Rectangle de se projeter sur un flux de sortie. |
Non |
Nous présumerons que le flux de sortie en question sera la console pour que le code reste simple. |
Autres méthodes (opérateurs relationnels ou équivalent; opérateur d'affectation ou équivalent; méthodes de translation ou de rotation; etc.) |
Selon les besoins: comparer deux instances de Rectangle entre elles; les classer en ordre croissant selon un critère d'ordonnancement donné; déplacer une instance de Rectangle sur un plan; ... |
Variable |
Laissé à votre imagination. La gamme des possibles est un ensemble ouvert. |
Portez une attention particulière à la colonne Peut modifier l'objet. Elle indique s'il est raisonnable d'accepter que l'invocation de cette méthode provoque une modification à l'état de l'objet qui en est propriétaire. Certains cas peuvent sembler évidents, mais la situation, dans son ensemble, est plus subtile qu'il n'y paraît.
Un exemple de code C++ couvrant les diverses opérations proposées pour la classe Rectangle ci-dessus serait le suivant.
//
// Rectangle.h
//
#include <iosfwd>
class HauteurIncorrecte {};
class LargeurIncorrecte {};
class Rectangle {
public:
// type interne public
using size_type = short;
private:
// une implémentation possible (parmi plusieurs)
size_type hauteur_,
largeur_;
enum : size_type {
LARGEUR_MIN = 1, HAUTEUR_MIN = 1,
LARGEUR_MAX = 80, HAUTEUR_MAX = 24,
LARGEUR_DEFAUT = 5, HAUTEUR_DEFAUT = 3,
};
static bool est_hauteur_valide(size_type hauteur) {
return HAUTEUR_MIN <= hauteur && hauteur <= HAUTEUR_MAX;
}
static bool est_largeur_valide (size_type largeur) {
return LARGEUR_MIN <= largeur && largeur <= LARGEUR_MAX;
}
static size_type valider_hauteur(size_type valeur) {
return !est_hauteur_valide(valeur)? throw HauteurIncorrecte{} : valeur;
}
static size_type valider_largeur(size_type valeur) {
return !est_largeur_valide(valeur)? throw LargeurIncorrecte{} : valeur;
}
public:
Rectangle() : largeur_{LARGEUR_DEFAUT}, hauteur_{HAUTEUR_DEFAUT} {
}
Rectangle(size_type hauteur, size_type largeur)
: hauteur_{valider_hauteur(hauteur)}, largeur_{valider_largeur(largeur)}
{
}
//
// Sainte-Trinité : implicite
//
// accesseurs publics -- interface de consultation
// accesseurs de premier ordre
size_type largeur() const noexcept
{ return largeur_; }
size_type hauteur() const noexcept {
return hauteur_;
}
// accesseurs de second ordre
size_type perimetre() const noexcept {
return 2 * (largeur() + hauteur());
}
size_type surface () const noexcept {
return largeur() * hauteur();
}
// méthode de projection à la console
void dessiner() const;
//
// ...plusieurs autres méthodes (laissez-vous aller)...
//
};
// projection sur un flux arbitraire
std::ostream& operator<<(std::ostream&, const Rectangle&);
//
// Rectangle.cpp
//
#include "Rectangle.h"
#include <iostream>
using namespace std;
void Rectangle::dessiner() const {
cout << *this;
}
ostream& operator<<(ostream &os, const Rectangle &r) {
using size_type = Rectangle::size_type;
for (size_type i = 0; i < r.hauteur(); ++i) {
for (size_type j = 0; j < r.largeur(); ++j)
os << '*';
os << endl;
}
return os;
}
Remarques quant à la Sainte-Trinité
Trois opérations fondamentales sont, en C++, implémentées automatiquement par le compilateur sauf si le programmeur supplée ses propres versions. Ces opérations sont le constructeur de copie, l'opérateur d'affectation et le destructeur, et ont toutes trois un rôle clé à jouer dans la vie des objets. Par défaut, dans une classe, le constructeur de copie fait une copie des attributs d'instance, attribut par attribut; l'affectation fait une affectation des attributs d'instance, attribut par attribut; et le destructeur invoque le destructeur des attributs.
Lorsqu'une classe implémente l'une des opérations de la Sainte-Trinité, il est probable qu'elle doive implémenter les trois (il y a des cas d'exception, évidemment, mais c'est un indicateur utile à mémoriser). Par exemple, si une instance d'une certaine classe est responsable d'une ressource externe à elle (un pointeur, une connexion à une base de données, un socket, ...), les opérations de la Sainte-Trinité sont les endroits clés où la question de la responsabilité face à cette ressource doit être posée : copier l'objet signifie-t-il dupliquer la ressource? La partager? Ne pas s'estimer responsable du tout (ce qu'on nomme une sémantique de référence)?
Quand les attributs d'instance d'une instance particulière (un Rectangle, par exemple) implémentent tous la Sainte-Trinité correctement (comme le font entre autres les primitifs, qui peuvent être copiés à loisir), alors il est en général préférable de ne pas implémenter la Sainte-Trinité du tout : le code que produira le compilateur sera optimal. Ici, par contre, la Sainte-Trinité a été implémentée pour les fins de l'illustration.
Remarques quant au clonage
Il est préférable de dupliquer les instances de classes polymorphiques par clonage plutôt que par copie. En général, si une classe n'expose aucun service polymorphique, ses instances seront utilisées en tant que teles, et la construction par copie est la manière privilégiée de dupliquer les instances; si une classe expose au moins un service polymorphique, alors ses instances seront souvent des indirections (pointeurs ou références) par lesquelles on invoquera des services des classes dérivées, et il est alors préférable de dupliquer polymorphiquement (par clonage) les objets pour être certains de bel et bien copier le bon type.
Procéder par clonage implique rédiger un service polymorphique de clonage et un constructeur de copie protégé. Vos notes de cours vont plus en détail sur ce sujet.
Ici, l'exemple C++ n'a pas besoin de clonage puisqu'il s'agit d'une classe concrète, qui ne devrait pas avoir d'enfants. En Java, la situation est moins claire : la classe Rectangle est qualifiée final (elle n'aura pas d'enfants), donc en pratique il serait raisonnable de ne pas y implémenter le clonage, mais en retour, en Java, toutes les classes (dont Rectangle) dérivent de Object, qui offre des services polymorphiques, alors... La raison pour laquelle j'ai implémenté cloner() un peu partout est pour donner un reflet syntaxique des équivalents d'un langage à l'autre.
Remarquez la présence dans ce code de garanties de constance. Ces garanties sont indiquées par la présence du mot clé const, qui sert à plusieurs sauces mais dans une optique précise :
Notez qu'une méthode d'instance reçoit un paramètre implicite (this) et que le mot clé const suivant la parenthèse fermante d'une liste de paramètres passés à une méthode d'instance est simplement appliqué à this. Il y a, au fond, une seule règle, pas deux.
Sachant quelles sont ces quelques règles, il devient clair qu'un objet constant est un objet sur lequel on ne peut réaliser que des opérations garantissant la non modification de ses états.
Plus concrètement, dans le code proposé à droite :
|
|
Examinons maintenant le cas où un Rectangle sert en tant qu'attribut dans une autre classe.
à ce stade du développement, il est possible que vous ne soyez pas convaincu(e) de l'intérêt d'avoir un objet constant. Le Rectangle qu'on souhaite dessiner à la console semble être un cas trop simple pour qu'on juge pertinent de vouloir en garantir la constance. Cependant, imaginons une classe comme la classe Bouton proposée à droite et imaginons que la dimension d'un Bouton soit un Rectangle. |
|
La méthode dimension() de la classe Bouton y est légitimement marquée const du fait qu'elle retourne une copie du Rectangle représentant la dimension d'un bouton, chose inoffensive pour le Bouton.
Imaginons maintenant que, pour quelque raison que ce soit, l'opération de copie d'un Rectangle soit jugée coûteuse (par exemple parce que la masse d'information dans un Rectangle se soit accrue au fil du temps). On voudra peut-être alors éviter de copier un Rectangle à chaque invocation de dimension() puisque cela implique solliciter un constructeur par copie et un destructeur à chaque fois pour la variable temporaire retournée par la méthode.
Une stratégie intuitive serait alors de procéder comme on le fait habituellement dans les langages ne supportant que les sémantiques indirectes (Java et les langages .NET en particulier) et de retourner une référence sur l'attribut dimension_. On aurait alors le code proposé (en abrégé) à droite. Le problème est que l'appelant de dimension() obtient alors une référence sur un attribut d'un Bouton. à travers cette référence, l'appelant peut modifier l'état du Bouton à l'insu de ce dernier, ce qui constitue un bris flagrant (mais trop facile à oublier) d'encapsulation. |
|
En effet, si l'état du Bouton peut changer à son insu, le Bouton ne peut plus être considéré responsable de sa propre intégrité et, par conséquent, ce Bouton devient vulnérable. Tout appelant hostile pouvant le détruire de l'intérieur; l'intégrité d'un Bouton ne peut plus être garantie. Notez que même si la référence sur un Rectangle retournée par la méthode demeurera une référence sur un Rectangle valide en tant que Rectangle, il est possible que les politiques de validité d'une dimension de Bouton soient plus strictes que celles assurant la validité d'un Rectangle; priver le Bouton de la capacité d'assurer son intégrité est une faute grave même si certaines règles restent applicables au niveau de Rectangle.
En pratique, il arrive qu'on souhaite exposer des entités permettant d'accéder directement aux attributs d'un objet. On n'a qu'à penser aux itérateurs dans un conteneur comme std::vector. Certains objets ont une vocation de sécurité, d'autres ont plus une vocation utilitaires. En pratique, il y a de la place pour les deux.
Heureusement, il y a une solution, simple : retourner une référence constante plutôt qu'une simple référence. Ce faisant, la référence retournée est assujettie aux règles de constance et il devient illégal de lui appliquer une opération ne respectant pas les règles à cet effet. Concrètement, en C++ contemporain, l'idéal est de retourner des copies et d'optimiser ces opérations. Retourner des références-vers-const peut nous jouer des tours. Il faut comprendre que de plus en plus, pour tirer profit des architectures matérielles à plusieurs processeurs ou à plusieurs coeurs, les programmes que nous rédigeons sont multiprogrammés, souvent à l'aide de plusieurs threads. |
|
Imaginez la situation suivante :
#include <string>
class Y {
const std::string &nom_; // ne faites pas ça; ce n'est qu'un exemple!
public:
Y(const std::string &nom) : nom_{nom} {
}
std::string nom() const {
return nom_;
}
};
class X {
std::string nom_;
public:
X(const std::string &nom) : nom_{nom} {
}
const std::string &nom() const { // est-ce sage?
return nom_;
}
void renommer(const std::string &nom) {
nom_ = nom;
}
};
#include <iostream>
usinf namespace std;
void f(const X &x) {
X x{"J'aime mon prof");
Y y{x.nom()};
cout << "Le nom de y est const --> " << y.nom() << endl;
x.renommer("Surprise");
cout << "Le nom de y est ... Oups! " << y.nom() << endl;
}
Ici, y possède une référence qualifée const et, concrètement, ne fait rien pour briser cette qualification; de son côté, x retourne une référence qualifiée const par sa méthode nom(), elle-même qualifiée const. Tout est donc correct localement. Le problème est plus global : l'invocation de x.renommer() modifie la string dans x, à laquelle réfère y. La « constante » logée dans y est donc modifiée, à son insu!
Si X::nom() retournait une string plutôt qu'une const string&, ce problème serait évacué. Conséquemment, dans le respect de la sémantique attendue d'une constante, les copies sont un outil inestimable.
La capacité de manipuler des objets constants est donc fondamentale. Un objet doit être en mesure d'utiliser des objets à la fois pour représenter ses états et pour communiquer avec les autres objets. Pour qu'un objet puisse assurer le respect du principe d'encapsulation, il lui faut être certain que ses clients n'obtiendront pas à son insu un accès privilégié à ses entrailles de manière telle qu'il deviendrait possible de le saboter.
Lorsque les objets sont manipulés par sémantique de valeur, la constance prend une moins grande place du fait que la plupart des opérations susceptibles de compromettre un objet ou l'autre tendent à générer des copies. En retour, les sémantiques indirectes compromettent souvent, facilement et sournoisement l'encapsulation et, par le fait même, la solidité des programmes.
Un exemple de code Java couvrant les diverses opérations proposées pour la classe Rectangle serait :
class TailleInvalide extends Exception {
public TailleInvalide () {
super ("Taille invalide");
}
}
public class Rectangle {
private static final short
LARGEUR_MIN = 1, HAUTEUR_MIN = 1,
LARGEUR_MAX = 80, HAUTEUR_MAX = 24,
LARGEUR_DÉFAUT = 5, HAUTEUR_DÉFAUT = 3;
private static boolean estHauteurValide(short hauteur) {
return HAUTEUR_MIN <= hauteur && hauteur <= HAUTEUR_MAX;
}
private static boolean estLargeurValide(short Largeur) {
return LARGEUR_MIN <= largeur && largeur <= LARGEUR_MAX;
}
public Rectangle() {
setLargeurBrut(LARGEUR_DÉFAUT);
setHauteurBrut(HAUTEUR_DÉFAUT);
}
public Rectangle(short hauteur, short largeur) throws TailleInvalide {
// versions publiques et blindées
setHauteur(hauteur);
setLargeur(largeur);
}
protected Rectangle(Rectangle r) {
setLargeurBrut(r.largeur());
setHauteurBrut(r.hauteur());
}
// accesseurs publics -- interface de consultation
// accesseurs de premier ordre
public short getLargeur() {
return largeur_;
}
public short getHauteur() {
return hauteur_;
}
// accesseurs de second ordre
public short getPérimètre() {
return 2 * (largeur() + hauteur());
}
public short getSurface() {
return largeur() * hauteur();
}
// mutateurs publics -- interface de modification contrôlée
// versions publiques et blindées
public void setHauteur(short hauteur) throws TailleInvalide {
if (!estHauteurValide(hauteur)) {
throw new TailleInvalide();
}
setHauteurBrut(hauteur);
}
public void setLargeur(short largeur) throws TailleInvalide {
if (!estLargeurValide(largeur)) {
throw new TailleInvalide();
}
setLargeurBrut(largeur);
}
// mutateurs privés -- interface de modification primitive
// versions privées et rapides
private void setHauteurBrut(short hauteur) {
hauteur_ = hauteur;
}
private void setLargeurBrut(short largeur) {
largeur_ = largeur;
}
// méthode de projection à la console
public void dessiner() {
for (short i = 0; i < getHauteur(); ++i) {
for (short j = 0; j < getLargeur(); ++j) {
System.out.print("*");
}
System.out.println();
}
}
public Rectangle cloner() {
return new Rectangle(this);
}
//
// ...plusieurs autres méthodes (laissez-vous aller)...
//
}
Le code C# et le code VB.NET sont du même niveau de complexité, avec les mêmes qualités, les mêmes défauts et les mêmes stratégies de solution. Je m'en tiendrai ici au cas Java par souci d'économie et de simplicité.
Avec C#, le mot clé readonly peut jouer un rôle similaire à celui des méthodes const, en permettant à un attribut de n'être modifié que dans un constructeur.
Un effet semblable est aussi possible avec une propriété auto-initialisée qui n'offre qu'un get, pas de set.
Remarquons tout d'abord que la version Java est fonctionnellement équivalente à la version C++. Cependant, en comparaison avec la version C++, les clauses de constance sont disparues. La raison en est simple : Java et les langages .NET ne supportent pas le concept de constante au niveau des objets ou des méthodes.
En apparence, ceci ne semble pas poser de problème. En effet, plusieurs programmes .NET et plusieurs programmes Java existent sur la planète et tout semble fonctionner comme sur des roulettes. En pratique, malgré la réputation de langage dangereux faite à C++ et la réputation de langage sécuritaire faite à Java et aux langages .NET, la situation concrète est que l'absence d'objets constants rend l'immense majorité des programmes Java et .NET vulnérables. Pas au même titre, bien sûr (en C++ après tout, on peut prendre un pointeur, changer son sens avec un reinterpret_cast et faire à peu près ce qu'on veut, à l'endroit de notre choix), mais au sens où puisque tous les objets sont manipulés indirectement, il asrrive très, très souvent que deux références mènent au même objet; dans un tel cas, modifier l'objet par l'une des références modifie aussi l'objet pour l'autre référence (c'est, tout simplement, le même objet). C'est d'ailleurs ce qu'on appelle du aliasing.
Attention : Java et les langages .NET n'empêchent pas la rédaction de programmes sécuritaires mais leurs modèles respectifs en compliquent fortement la rédaction. La plupart des programmeurs génèrent du code à risque sans même s'en apercevoir.
Le problème est plus visible si nous reprenons l'illustration à partir de la classe Bouton proposée plus haut. Le code y est en apparence plus simple mais il est, en réalité, beaucoup plus pernicieux. En effet, les langages .NET et Java reposent tous deux sur des sémantiques indirectes. Les classes y sont toujours instanciées dynamiquement (à partir de l'opérateur new). Ce faisant, on ne manipule jamais d'objets dans ces langages; on y manipule toujours des références sur des objets. Cela signifie que dans l'exemple à droite, dimension_ n'est pas un Rectangle mais bien une référence sur un Rectangle, et il en va de même pour toute manipulation d'objet. Cela implique que l'expression return dimension_ ne retourne pas une copie de dimension_ mais bien une copie d'une référence sur dimension_. L'appelant obtient donc une référence directe sur un attribut du Bouton et peut en modifier l'état à sa guise, sans que le Bouton ne puisse assurer le respect de ses propres politiques de validité. En Java comme dans les langages .NET, toute opération susceptible de donner une référence directe sur un attribut d'un objet modifiable constitue un bris flagrant d'encapsulation, susceptible d'être lourd de conséquences. Ces bris peuvent résulter de l'obtention d'une référence (typiquement un accesseur comme getDimension()) ou de l'injection d'une référence (typiquement un mutateur comme setDimension()). |
|
Remarquez que le code client (classe Test) est aussi vulnérable que l'objet en tant que tel: si un Bouton et son client ont tous deux une référence sur un même Rectangle, le Bouton peut tout autant modifier le Rectangle à l'insu de son client que le client peut modifier le Rectangle à l'insu du Bouton. En l'absence de constantes, les deux se mettent mutuellement à risque.
Deux options existent pour permettre à un objet à la fois d'offrir des services et d'assurer sa propre intégrité : adopter des stratégies de programmation défensive et avoir recours à des classes immuables.
La programmation défensive est une manière par laquelle un objet, manipulé indirectement comme le sont toutes les classes Java, toutes les classes C#, et les objets manipulés par des indirections (pointeurs ou références) en C++, peut réduire les risques de coruption non-sollicitée. Évidemment, la meilleure solution à ce problème est d'avoir un accès direct aux objets, mais cela n'est pas possible en Java ou en C# alors il faut parfois se faire violence (en toute honnêteté, la plupart des programmeuses et des programmeurs ne se rendront pas jusque là, et accepteront que leur programme soit brisé, ou – mieux – utiliseront des classes immuables).
L'une des stratégies possibles pour défendre un objet contre les bris d'encapsulation en l'absence de constantes est de faire ce que je nommerai de la programmation défensive. L'idée derrière cette stratégie est de toujours présumer que l'interlocuteur a des intentions hostiles et de dupliquer manuellement le contenu de toute référence transigée avec lui. Dans cette optique :
|
|
Vous remarquerez tout de suite que la programmation défensive est très coûteuse en ressources. Cette stratégie implique d'insérer beaucoup de code manuel de duplication (par clonage puisque les objets retournés par les méthodes ou utilisés comme paramètres à des méthodes sont habituellement polymorphiques) même si, dans bien des cas, le client et son objet réaliseront la duplication deux fois plutôt qu'une, chacun d'entre eux devant se protéger et ne pouvant présumer de la bonne foi de l'autre.
Le résultat direct est que le code sera beaucoup plus lent et que la collecte automatique d'ordures sera utilisée beaucoup plus souvent en pratique, le nombre d'objets temporaires (et souvent inutiles) augmentant de manière significative. Ce coût est nécessaire en l'absence d'un mécanisme capable d'assurer la constance des objets dans ces langages.
Entendons-nous: le mot nécessaire ici signifie nécessaire en général. Pour des systèmes qui ne sont utilisés qu'à l'intérieur d'une entreprise et qui ne sont pas susceptibles d'être exposés au monde extérieur, la compromission de l'encapsulation demeure mais les chances qu'un tiers exploite (volontairement ou accidentellement) cette faille sont plus faibles. Les bris d'encapsulation sont surtout dangereux lorsque le code est utilisé dans plusieurs milieux ou par plusieurs types de clientèles – pour une API ou pour du code de bibliothèque, les fautes de ce genre sont impardonnables.
Détail délicat : le clonage est une opération subjective, qu'on implémente habituellement à l'aide du constructeur par copie de l'objet cloné. Un compilateur ne peut se porter garant du respect par le code de cette façon de faire. Ainsi, un objet hostile pourrait aisément remplacer return new Rectangle(this); par return this; dans la méthode cloner() de Rectangle et toutes les tentatives de duplication subjective du code client seraient en vain.
L'alternative à privilégier dans les langages qui ne supportent pas les constantes et qui n'offrent que des sémantiques indirectes est l'approche par classes et interfaces immuables. Pour être stricts, nous pourrions indiquer qu'une classe peut être immuable et qu'une interface peut suggérer l'immutabilité puisqu'une interface ne contient pas de code à proprement dit.
Une entité immuable est une classe ou une interface n'offrant aucun service par lequel il est possible de modifier ses états. Plusieurs classes standard des infrastructures Java et .NET sont immuables, la plus célèbre étant la classe String (ou string en C#).
Puisqu'une entité immuable ne peut être modifiée une fois qu'elle a été construite, la programmation à l'aide d'objets immuables sollicite à fond les mécanismes d'allocation dynamique de mémoire et de collecte automatique d'ordures. Pour cette raison, la plupart des classes immuables ont une contrepartie qui n'est pas immuable et permet la modification d'états sans imposer un recours démesuré à ces mécanismes.
En Java, la classe String a une contrepartie modifiable nommée StringBuffer. Sous .NET, la classe System.String (qui a un alias C# nommé string) a une contrepartie modifiable nommée System.Text.StringBuilder. Dans les deux cas, la raison est simple: les manipulations sur des chaînes coûtent tellement cher en création et en récupération d'objets temporaires qu'elles constituent des goulots d'étranglement dans un nombre important de programmes et il est essentiel d'offrir une contrepartie efficace aux classes plus typiques mais inefficaces en pratique.
Si nous décidons d'appliquer une stratégie d'immutabilité à la classe Rectangle, nous avons deux grandes familles d'options.
La première est de mettre au point une interface de consultation de Rectangle (nommons-la RectangleConsultation) qui n'offre que des services de consultation[1], puis faire en sorte que Rectangle implémente cette interface. Ceci implique qu'il faille modifier Bouton pour que cette classe ne retourne que des RectangleConsultation et supprimer son mutateur setDimension(); en effet, l'immuabilité d'une interface ne garantit pas qu'il soit impossible qu'un objet susceptible de changer d'état à l'insu de son propriétaire se cache derrière elle. |
|
Interdire d'hériter de ces classes permet d'éviter qu'un tiers ne conçoive un enfant de l'une d'elles dans le but d'infiltrer le système et de le saboter. Faire en sorte qu'une instance de l'une de ces deux classes puisse être construite à partir d'une instance de l'autre et inversement (des constructeurs de copie mutuels) permet ensuite de générer un seul niveau de copie défensive, à même l'objet. La seconde est de définir deux classes à part entière, dont on ne peut dériver (final en Java, sealed en C#, NotInheritable en VB.NET) et qui se connaissent mutuellement comme le font les classes Java String et StringBuffer. |
|
En général, dans les langages .NET comme avec Java et d'autres langages similaires du point de vue du système de types, les classes et les interfaces immuables sont l'idiome à privilégier pour prévenir les bris d'encapsulation.
Cet idiome n'est malheureusement pas une panacée. Il ne permet pas d'introduire de constance une instance à la fois ou d'offrir des garanties de constance méthode par méthode. Il exige aussi un effort de programmation supplémentaire considérable en imposant la duplication d'une partie importante du code.
Revenons aux constantes d'un point de vue plus pragmatique. Prenons la classe Rectangle dans sa déclinaison C++ et imaginons qu'on souhaite évaluer le rapport entre la fréquence des appels aux accesseurs de second ordre (surface() et perimetre()) et la fréquence d'appels aux mutateurs primitifs (en insérant des mutateurs privés SetLargeurBrut() et SetHauteurBrut()).
L'idée est que nous souhaitons déterminer s'il est avantageux (ou non) de conserver la surface et le périmètre d'un Rectangle dans des attributs. Une telle stratégie sera en effet avantageuse (côté vitesse) dans le cas où la hauteur et la largeur changent peu souvent ou dans le cas où on demande souvent à un Rectangle sa surface et son périmètre, du fait que les calculs seront faits occasionnellement et leur résultat sera utilisé fréquemment. Elle sera par contre désavantageuse si la largeur et la hauteur changent souvent ou dans le cas où on demande rarement à un Rectangle sa surface et son périmètre, ce qui entraînerait des calculs fréquents pour des consultations occasionnelles.
Le problème est alors que, pour être à la fois propre et efficace, nous voudrons qu'un Rectangle tienne à jour ses propres statistiques d'utilisation. Cependant, si un attribut compte les appels aux accesseurs de second ordre, nous voudrons qu'il soit incrémenté lors de chaque appel, mais ceci nous empêchera de spécifier surface() et perimetre() constants!
//
// Rectangle.h
//
#include <iosfwd>
class Rectangle {
public:
// type interne public
using size_type = short;
private:
// une implémentation possible (parmi plusieurs)
size_type hauteur_,
largeur_;
enum : size_type {
LARGEUR_MIN = 1, HAUTEUR_MIN = 1,
LARGEUR_MAX = 80, HAUTEUR_MAX = 24,
LARGEUR_DEFAUT = 5, HAUTEUR_DEFAUT = 3,
};
static bool est_hauteur_valide(const size_type hauteur) {
return HAUTEUR_MIN <= hauteur && hauteur <= HAUTEUR_MAX;
}
static bool est_largeur_valide (const size_type largeur) {
return LARGEUR_MIN <= largeur && largeur <= LARGEUR_MAX;
}
int nbconsultations_ = 0,
nbmodifications_ = 0;
public:
//
// Sainte-Trinité omise car implicitement correcte
//
Rectangle() : largeur_{LARGEUR_DEFAUT}, hauteur_{HAUTEUR_DEFAUT} {
}
Rectangle(size_type hauteur, size_type largeur) {
// versions publiques et blindées
SetHauteur(hauteur);
SetLargeur(largeur);
}
// accesseurs publics -- interface de consultation
// accesseurs de premier ordre
size_type largeur () const noexcept {
return largeur_;
}
size_type hauteur () const noexcept {
return hauteur_;
}
// accesseurs de second ordre
size_type perimetre() const noexcept {
++nbconsultations_; // illégal si la méthode est const!
return 2 * (largeur() + hauteur());
}
size_type surface() const noexcept {
++nbconsultations_; // illégal si la méthode est const!
return largeur() * hauteur();
}
// mutateurs publics -- interface de modification contrôlée
// versions publiques et blindées
void SetHauteur(size_type hauteur) {
if (!est_hauteur_valide(hauteur)) throw TailleInvalide{};
SetHauteurBrut(hauteur);
}
void SetLargeur(size_type largeur) {
if (!est_largeur_valide(largeur)) throw TailleInvalide{};
SetLargeurBrut(largeur);
}
private:
// mutateurs privés -- interface de modification primitive
// versions privées et rapides
void SetHauteurBrut(size_type hauteur) noexcept {
++nbmodifications_;
hauteur_ = hauteur;
}
void SetLargeurBrut(size_type largeur) noexcept {
++nbmodifications_;
largeur_ = largeur;
}
public:
// méthode de projection à la console
void dessiner() const;
//
// ...plusieurs autres méthodes (laissez-vous aller)...
//
};
// projection sur un flux arbitraire
std::ostream& operator<<(std::ostream&, const Rectangle&);
//
// Rectangle.cpp
//
#include "Rectangle.h"
#include <iostream>
using namespace std;
void Rectangle::dessiner() const {
cout << *this;
}
ostream& operator<<(ostream &os, const Rectangle &r) {
using size_type = Rectangle::size_type;
for (size_type i = 0; i < r.hauteur(); ++i) {
for (size_type j = 0; j < r.largeur(); ++j)
os << '*';
os << endl;
}
return os;
}
Remarquez toutefois que les attributs à vocation statistique ne sont pas vraiment des attributs de Rectangle. Ce sont des données d'appoint, utiles à des fins techniques sans être conceptuellement reliées à l'intégrité d'un Rectangle. Que nous souhaitions les utiliser pour compter des appels ne change rien au fait que les dimensions du Rectangle demeurent intactes.
Dans un tel cas, nous voulons (sans abuser!) pouvoir indiquer au compilateur que certains attributs n'ont pas à être soumis aux règles de constance. Un attribut qui sera soustrait à la contrainte de constance est un attribut mutable. Identifier une donnée comme mutable permet d'écrire du code à la fois robuste (respect plein et entier de l'encapsulation[2]) et pragmatique (flexibilité à l'interne quant à l'application des règles et contraintes de constance).
Les cas types d'attributs mutables sont des attributs à vocation statistique et des attributs à vocation d'optimisation (pensez à une antémémoire qui éviterait certains accès à un média plus lent).
Le code ajusté suit.
//
// Rectangle.h
//
#include <iosfwd>
class Rectangle {
public:
// type interne public
using size_type = short;
private:
// une implémentation possible (parmi plusieurs)
size_type hauteur_,
largeur_;
enum : size_type {
LARGEUR_MIN = 1, HAUTEUR_MIN = 1,
LARGEUR_MAX = 80, HAUTEUR_MAX = 24,
LARGEUR_DEFAUT = 5, HAUTEUR_DEFAUT = 3,
};
static bool est_hauteur_valide(const size_type hauteur) {
return HAUTEUR_MIN <= hauteur && hauteur <= HAUTEUR_MAX;
}
static bool est_largeur_valide (const size_type largeur) {
return LARGEUR_MIN <= largeur && largeur <= LARGEUR_MAX;
}
mutable int nbconsultations_ = 0,
nbmodifications_ = 0;
public:
//
// Sainte-Trinité omise car implicitement correcte
//
Rectangle() : largeur_{LARGEUR_DEFAUT}, hauteur_{HAUTEUR_DEFAUT} {
}
Rectangle(size_type hauteur, size_type largeur) {
// versions publiques et blindées
SetHauteur(hauteur);
SetLargeur(largeur);
}
// accesseurs publics -- interface de consultation
// accesseurs de premier ordre
size_type largeur () const noexcept {
return largeur_;
}
size_type hauteur () const noexcept {
return hauteur_;
}
// accesseurs de second ordre
size_type perimetre() const noexcept {
++nbconsultations_; // illégal si la méthode est const!
return 2 * (largeur() + hauteur());
}
size_type surface() const noexcept {
++nbconsultations_; // illégal si la méthode est const!
return largeur() * hauteur();
}
// mutateurs publics -- interface de modification contrôlée
// versions publiques et blindées
void SetHauteur(size_type hauteur) {
if (!est_hauteur_valide(hauteur)) throw TailleInvalide{};
SetHauteurBrut(hauteur);
}
void SetLargeur(size_type largeur) {
if (!est_largeur_valide(largeur)) throw TailleInvalide{};
SetLargeurBrut(largeur);
}
private:
// mutateurs privés -- interface de modification primitive
// versions privées et rapides
void SetHauteurBrut(size_type hauteur) noexcept {
++nbmodifications_;
hauteur_ = hauteur;
}
void SetLargeurBrut(size_type largeur) noexcept {
++nbmodifications_;
largeur_ = largeur;
}
public:
// méthode de projection à la console
void dessiner() const;
//
// ...plusieurs autres méthodes (laissez-vous aller)...
//
};
// projection sur un flux arbitraire
std::ostream & operator << (std::ostream &, const Rectangle &);
//
// Rectangle.cpp
//
#include "Rectangle.h"
#include <iostream>
using namespace std;
void Rectangle::dessiner() const {
cout << *this;
}
ostream& operator<<(ostream &os, const Rectangle &r) {
using size_type = Rectangle::size_type;
for (size_type i = 0; i < r.hauteur(); ++i) {
for (size_type j = 0; j < r.largeur(); ++j)
os << '*';
os << endl;
}
return os;
}
Pour en savoir plus sur constexpr, voir constexpr.html
Depuis C++ 11, le concept de constance va plus loin encore et il devient possible d'exprimer au compilateur qu'une fonction doit être évaluée à la compilation, dans la mesure où ses paramètres sont connus à ce stade. Ce mécanisme couvre même les constructeurs, ce qui permet d'avoir des objets construits à même le code machine généré, avant le début de l'exécution d'un programme, et de les placer en mémoire Read-Only si cela s'avère à propos.
Ce mécanisme repose sur le mot clé constexpr, qui ne signifie pas « ne changera pas d'état » mais bien « est résolu dès la compilation ». Un exemple un peu banal suit; observez les méthodes qualifiées constexpr, car elles partagent quelques caractéristiques (pour être légales avec C++ 11) :
//
// Rectangle.h
//
#include <iosfwd>
class HauteurIncorrecte {};
class LargeurIncorrecte {};
class Rectangle {
public:
// type interne public
using size_type = short;
private:
// une implémentation possible (parmi plusieurs)
size_type hauteur_,
largeur_;
enum : size_type {
LARGEUR_MIN = 1, HAUTEUR_MIN = 1,
LARGEUR_MAX = 80, HAUTEUR_MAX = 24,
LARGEUR_DEFAUT = 5, HAUTEUR_DEFAUT = 3,
};
static constexpr bool est_hauteur_valide(size_type hauteur) {
return HAUTEUR_MIN <= hauteur && hauteur <= HAUTEUR_MAX;
}
static constexpr bool est_largeur_valide(size_type largeur) {
return LARGEUR_MIN <= largeur && largeur <= LARGEUR_MAX;
}
static constexpr size_type valider_hauteur(size_type valeur) {
return !est_hauteur_valide(valeur)? throw HauteurIncorrecte{} : valeur;
}
static constexpr size_type valider_largeur(size_type valeur) {
return !est_largeur_valide(valeur)? throw LargeurIncorrecte{} : valeur;
}
public:
constexpr Rectangle() : largeur_{ LARGEUR_DEFAUT }, hauteur_{ HAUTEUR_DEFAUT } {
}
constexpr Rectangle(size_type hauteur, size_type largeur)
: hauteur_{ valider_hauteur(hauteur) }, largeur_{ valider_largeur(largeur) }
{
}
//
// Sainte-Trinité : implicite
//
// accesseurs publics -- interface de consultation
// accesseurs de premier ordre
constexpr size_type largeur() const noexcept {
return largeur_;
}
constexpr size_type hauteur() const noexcept {
return hauteur_;
}
// accesseurs de second ordre
constexpr size_type perimetre() const noexcept {
return 2 * (largeur() + hauteur());
}
constexpr size_type surface () const noexcept {
return largeur() * hauteur();
}
// méthode de projection à la console
void dessiner() const;
//
// ...plusieurs autres méthodes (laissez-vous aller)...
//
};
// projection sur un flux arbitraire
std::ostream& operator<<(std::ostream&, const Rectangle&);
//
// Rectangle.cpp
//
#include "Rectangle.h"
#include <iostream>
using namespace std;
void Rectangle::dessiner() const {
cout << *this;
}
ostream& operator<<(ostream &os, const Rectangle &r) {
using size_type = Rectangle::size_type;
for (size_type i = 0; i < r.hauteur(); ++i) {
for (size_type j = 0; j < r.largeur(); ++j)
os << '*';
os << endl;
}
return os;
}
Un programme de test simple mais correct pour cette classe serait :
int main() {
constexpr Rectangle rect{3,7};
cout << rect << endl;
}
Ici, rect sera évaluée et construit dès la compilation, mais son affichage sera réalisé à l'exécution.
Plusieurs ont écrit sur le sujet de l'immuabilité et de la constance. Quelques propositions de lectures suivent.
À propos du mot const :
À propos de l'immuabilité :
Autres :
[0] En premier lieu, j'avais utilisé effets de bord, mais effets secondaires est plus adéquat. Un éminent collègue m'a suggéré effets secondaires et effets indésirables en privilégiant effets indésirables; cependant, la plupart des effets secondaires découlant de la vie d'un objet me semblent désirés (après tout, dans la plupart des cas, un objet qui consomme des ressources le fait volontairement) alors j'ai privilégié effets secondaires, plus près de ma pensée ici.
[1] Prenez consultation au sens de qui ne modifient pas l'objet.
[2] Cette affirmation a fait réagir certains de mes estimés collègues et amis, et je comprends leur réaction. L'idée que je veux mettre de l'avant ici est que l'objet veut protéger ses états (on me souligne que états fondamentaux aurait peut-être été moins sujet à polémique ici), mais que certains attributs d'instance, bien qu'ils soient utiles à l'objet, ne sont pas des états au sens où on l'entend. Dans le cas du Rectangle, le décompte statistique des invocations de chaque catégorie de service est un état temporaire et qui n'est pas tant un état de Rectangle qu'un état utilitaire (et peut-être transitoire) pour la mise au point du programme. Conceptuellement, les véritables états d'un objet devraient rester constants lors de l'invocation d'une méthode constante (il y a peut-être des cas d'exception, mais ceux-ci devraient être rares et pointus); on s'attend à ce que les attributs mutables soient auxiliaires et disjoints de la nature de l'objet représenté. C'est ce qui explique mon choix de parler de respect plein et entier de l'encapsulation ici: au sens où je l'entends dans ce passage, un attribut comme nbmodifications_ n'est pas un état réel de Rectangle. Cela dit, je comprends le caractère polémique de l'affirmation et je ne prétends pas avoir trouvé la meilleure formule pour exprimer l'idée que je cherche à mettre de l'avant dans ce passage, alors les suggestions sont les bienvenues.