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() noexcept
      : x{}, y{}
   {
   }
   Point(int x, int y) noexcept
      : x{x}, y{y} // oui, c'est une écriture légale et correcte en C++
   {
   }
   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() noexcept
      : x_{}, y_{}
   {
   }
   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); }
   int x() const noexcept
      { return x_; }
   int y() const noexcept
      { return y_; }
   Point& x(int val) noexcept
   {
      x_ = val;
      return *this;
   }
   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?

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !