Encapsulation et attributs publics

Je réfléchis depuis un certain temps aux conséquences d'un commentaire du toujours très pertinent (et extrêmement intelligent) Andrew Koenig, émis dans l'un de ses textes du défunt Dr Dobb's Journal (je ne me souviens plus lequel), à l'effet qu'il est parfois correct (au sens de l'encapsulation) pour un type d'avoir des attributs publics.

Il s'agit pourtant d'une pratique décriée sur toutes les tribunes et depuis longtemps, un peu comme l'est le recours aux instructions goto depuis le célèbre texte de Dijkstra, GOTO Statements Considered Harmful (texte qui a en quelque sorte donné son élan à la programmation structurée). L'idée est que l'encapsulation exige d'un objet que celui-ci contrôle les accès à ses états pour être en mesure d'assurer sa propre cohérence interne, de garantir ses invariants. Je définis d'ailleurs l'encapsulation en termes de responsabilité de l'objet sur ses états dans mes cours et dans mes écrits. Toutefois, une déclaration provoquante comme celle-ci met nos méninges en marche et nous force à remettre en question nos présupposés.

Il ne faut pas confondre « encapsulation » et « recours à des propriétés » (au sens des langages .NET ou de Delphi, par exemple) ou encore « recours à des méthodes set et get ». Il est tout à fait possible d'avoir une classe dont les états sont protégés par des accesseurs et des mutateurs de premier ordre sans assurer une encapsulation correcte; on n'a qu'à penser à une classe Carre, dont l'un des invariants est que sa largeur et sa hauteur doivent être égales, mais qui exposerait une méthode setHauteur() et une méthode setLargeur() permettant de modifier chacun de ces états à la pièce.

Il s'agirait d'une très mauvaise interface, bien sûr, mais on rencontre pourtant ce genre de code un peu partout. Peut-être par paresse, peut-être par habitude, nombreuses et nombreux sont les programmeuses et les programmeurs qui écrivent une paire set/get pour chaque attribut sans se soucier du sens du geste ainsi posé, pas plus que de ses conséquences.

La définition que faisait Koenig d'une classe pour laquelle des attributs publics serait un choix raisonnable était (je paraphrase, de mémoire) « un type pour lequel les attributs sont en fait l'interface ». Il donnait l'exemple d'une classe Point comme celle ci-dessous :

struct Point {
   int x {}, y {};
   Point() = default;
   constexpr Point(int x, int y) noexcept : x{ x }, y{ y } {
   }
   bool operator==(const Point &pt) const noexcept {
      return x == pt.x && y == pt.y;
   }
   bool operator!=(const Point &pt) const noexcept {
      return !(*this == pt);
   }
};

Dans son optique, faire de x et y ici des attributs privés et exposer des méthodes pour leur accéder serait redondant. En effet, quels sont les gains à écrire ce qui suit?

class Point {
   int x_ {}, y_ {};
public:
   Point() = default;
   constexpr Point(int x, int y) noexcept : x_{ x }, y_{ y } {
   }
   constexpr bool operator==(const Point &pt) const noexcept {
      return x() == pt.x() && y() == pt.y();
   }
   constexpr bool operator!=(const Point &pt) const noexcept {
      return !(*this == pt);
   }
   constexpr int x() const noexcept {
      return x_;
   }
   constexpr int y() const noexcept {
      return y_;
   }
   constexpr Point& x(int val) noexcept {
      x_ = val;
      return *this;
   }
   constexpr Point& y(int val) noexcept {
      y_ = val;
      return *this;
   }
};

Petite nuance tout de même : la version offrant des accesseurs (x() et y()) et des mutateurs (x(int) et y(int)) a un avantage sur la version exposant des attributs publics. En effet, il est possible de placer un point d'arrêt dans un débogueur pour repérer les tentatives d'accès aux états d'un Point. Ceci peut être avantageux en période de développement.

Étant donné que toute valeur possible est légale pour les états x et y d'un Point, il est possible de faire les mêmes choses avec les deux versions, mais celle dont les attributs sont publics est plus simple et plus concise. En ce sens, Point diffère de la classe Carre, citée en exemple plus haut, où le changement d'un état sur une base isolée peut ne pas avoir de sens : sa hauteur et sa largeur doivent aller de pair, alors si un Carre est modifiable (et il faut se demander s'il devrait l'être), alors il faut assurer sa cohérence interne (le respect de ses invariants) lors de chaque changement à l'un ou l'autre de ses états.

Notez que les constructeurs et certains opérateurs (ici, ceux pour tester deux instances de Point pour l'égalité ou non) sont présents dans les deux cas : l'approche OO, après tout, cela signifie aussi joindre à chaque type les opérations clés pour son fonctionnement correct et s'assurer que chaque objet sont dans un état valide du début à la fin de son existence.

Notez aussi le choix des noms dans chacune des deux versions de Point proposées ci-dessus. Dans le cas où les attributs sont publics, j'ai utilisé les noms x et y pour faciliter la tâche du code client. Dans le cas où les attributs sont privés, j'ai utilisé x_ et y_ à l'interne et j'ai gardé x() et y() pour la face publique. L'idée dans chaque cas est de faciliter l'utilisation des instances de ce type.

Un autre cas intéressant où l'encapsulation va de pair avec des attributs publics serait celui où un type n'aurait pour attributs que des constantes d'instances. Prenons à titre d'exemple la classe HorsBornes ci-dessous, destinée à servir d'exception lorsqu'un entier ne se situe pas inclusivement entre deux bornes :

struct HorsBornes {
   const int valeur, minval, maxval;
   HorsBornes(int valeur, int minval, int maxval) : valeur{ valeur }, minval{ minval }, maxval{ maxval } {
   }
};

Omettons la logique menant à utiliser ce type, sinon pour dire que dans un cas comme celui d'une classe Note, dont les valeurs doivent se situer entre 0 et 100 inclusivement, tenter de créer un Note(103) pourrait mener à lever un HorsBornes(103,0,100), ce qui permettrait au code client de diagnostiquer avec précision la raison de l'exception.

Ici, nous avons une classe avec un invariant plus complexe, soit :

valeur < minval || maxval < valeur

Il serait possible de valider cet invariant dès la construction d'une instance de HorsBornes :

#include <cassert>
struct HorsBornes {
   const int valeur, minval, maxval;
   HorsBornes(int valeur, int minval, int maxval) : valeur{ valeur }, minval{ minval }, maxval{ maxval } {
      assert(valeur < minval || maxval < valeur);
   }
};

Notez que nous n'avons pas levé une exception si l'invariant n'est pas rencontré dès le début; lever une exception pendant qu'on lève une exception forcerait le runtime de C++ à appeler std::unexpected() et à planter. Conséquemment, planter avec assert() est plus léger et bien plus pertinent dans ce cas-ci.

Pourquoi alors des attributs publics sont-ils corrects dans un tel cas? Parce qu'ils ne sont pas modifiables une fois construits. Ainsi, si les invariants sont respectés suite à la construction d'un HorsBornes, alors ils le demeureront forcément jusqu'à la fin de son existence.

Voyez-vous d'autres cas pertinents?

Attributs protégés

J'ai reçu un courriel du brillant Rémi Padioleau, étudiant de la cohorte 13 du DDJV, que je paraphrase ici. Sa question provenait d'une remarque de ma part à l'effet qu'un attribut protected devrait être une chose rare, pas quelque chose de commun :

« Mot clé protected pour des variables d'une classe. Conseilles-tu d'enlever le mot clé protected et d'enfermer les attributs en private et faire une redondance dans chaque classe? Ou alors est-il intéressant de faire une interface s'assurant de la gestion de ces éléments (private si possible, sinon protected))? »

La question, par le recours au passage « enfermer les attributs... et faire une redondance dans chaque classe », indique que protected lui apparaissait comme un mécanisme pour déposer des états dans une classe parent pour ne pas les écrire dans les classes enfants (redondance supposée), et laisser les enfants accéder librement à ces états. Rémi est brillant, et cette perception de protected est plus répandue que l'on pense.

 J'ai essayé de clarifier ma pensée à ce sujet, que je paraphrase ici.

En résumé : un objet, c'est une Rock Star; ça ne connaît pas ses enfants. Ce que je veux dire par là, c'est que protected, règle générale (car il y a des exceptions), c'est pas plus utile pour la gestion des données que public : on dérive de la classe et le cauchemar commence. Pour cette raison, mieux vaut appliquer une discipline semblable pour protected et pour public : si un attribut est protected, il sera modifié sans contrôle par des inconnus. C'est donc sain strictement si :

Pour cette raison, un attribut protected, sans êtr impossible, devrait être rare et faire partie d'un design où son faible seuil de protection apporte quelque chose. Si le recours aux attributs protected est systématique, c'est que le design cloche.

Raisonnement général : si le code utilise un attribut protected, c'est que les enfants vont y accéder en écriture indépendamment des autres attributs du même objet. Ça signifie donc que l'objet est modélisé avec une cohésion faible ou nulle, un peu comme une classe avec des « setters » (setNom(), setÂge(), setPrénom()...) où on utiliserait au fond l'entité modélisée (une Personne) comme un groupe de variables un peu disparates.

Supposons un compte bancaire. Si la classe offre un setSolde() public, son interface est déficiente : la vraie métaphore pour un compte bancaire, c'est déposer() et retirer(), de même que solde() pour consulter le compte et en connaître le contenu. On aurait pu utiliser un attribut solde qualifié protected plutôt qu'une telle gamme de services, mais notre interface n'aurait pas modélisé correctement le domaine visé.

Tout est question de trouver la bonne abstraction ou la bonne interface, au fond.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !