Implémenter des propriétés avec C++

Un merci bien senti à Simon Laperle, étudiant en informatique de gestion S6 au Collège Lionel-Groulx à l'hiver 2009, qui a expérimenté avec une version antérieure de ce document et qui a amené à moi quelques suggestions de raffinement fort pertinentes.

Bien que j'aie une position tiède face aux propriétés comme celles de Delphi ou des langages .NET, je suis le premier conscient du fait que :

Sachant cela, l'envie m'a prise de mettre au point l'ébauche d'un système de propriétés pour C++ au cas où quelqu'un en aurait envie. Voici ce que cela donne.

Implémentation générique des propriétés (simpliste)

L'idée d'une propriété est de faire apparaître un triplet valeur/ méthode d'affectation/ méthode de consultation comme une seule et même variable, dans le but de remplacer du code tel que celui proposé à droite...

#include <iostream>
int main() {
   using namespace std;
   Personne p;
   p.SetNom("Roger");
   cout << p.GetNom() << endl;
}

...par ceci, sans qu'il n'y ait de perte côté encapsulation. Pour y arriver, il faut que Nom (pour prendre cet exemple) soit un objet encapsulant la véritable valeur et implémentant, dans son opérateur d'affectation, des politiques de validation convenables à la donnée encapsulée.

#include <iostream>
int main() {
   using namespace std;
   Personne p;
   p.Nom = "Roger";
   cout << p.Nom << endl;
}

Une version simple (simpliste!) de la classe Propriete est proposée à droite. L'idée de base est là. Cette version pourrait être raffinée avec des traits sur T. Ceci permettrait entre autres de mettre en place un opérateur -> si T est un pointeur ou ne pas implémenter l'affectation si T est constant.

Ces problèmes ne sont pas très difficiles à résoudre, alors permettez-moi de vous les laisser en exercice.

Notez que cette version ne fait qu'enrober une donnée de type T sans offrir de validation. En soi, une telle approche est insuffisante pour nos fins.

template <class T>
   class Propriete {
      T prop{};
   public:
      Propriete() = default;
      Propriete(const T &val)
         : prop{ val } {
      }
      Propriete& operator=(const T &val) {
         prop = val;
         return *this;
      }
      operator T() const {
         return prop;
      }
   };

Une classe Personne ayant une propriété Nom encapsulant une std::string aurait l'air de ceci.

Notez l'absence de mutateurs ou d'accesseurs ici, le tout étant déjà en place à travers Nom qui implémente à la fois l'affectation et la conversion implicite dans le type de la donnée encapsulée par la propriété.

#include "Proprietes.h"
#include <string>
#include <string_view>
struct Personne {
   using str_type = std::string;
   Propriete<str_type> Nom;
   Personne() = default;
   Personne(std::string_view nom) : Nom{ nom } {
   }
};

Aurait-il, selon vous, été sage d'implémenter la construction par copie dans la classe Propriete? Expliquez votre position.

Un programme de test raisonnable pour ceci serait celui proposé à droite.

Ce programme, bien que simple, montre une déficience de notre modèle trop simple : l'incapacité d'afficher p.Nom sans passer par une variable temporaire.

#include "Personne.h"
#include <string>
#include <iostream>
int main() {
   using namespace std;
   Personne p;
   p.Nom = "Roger";
   string s = p.Nom;
   cout << s << endl;
}

Il faut comprendre ici que cout << p.Nom; ne compilerait pas du fait que le compilateur chercherait un opérateur d'écriture sur un flux prenant en paramètre une Propriete<string> et ne chercherait pas à la convertir en string. En retour, l'opération cout << static_cast<string>(p.Nom); aurait fonctionné mais je doute que nous souhaitions exiger cela du code client.

La solution la plus simple (et probablement la meilleure dans ce cas-ci) est de surcharger les opérateurs de projection sur un flux et d'extraction d'un flux pour une Propriete<T>.

Dans les deux cas, l'implémentation est plutôt simple. Remarquez qu'il n'y a pas de perte d'encapsulation lors de la lecture puisque l'introduction d'une valeur dans la Propriete<T> se fait par l'opérateur d'affectation, donc à la fois de la même manière et selon les mêmes règles que dans le code client.


#include <iosfwd>
using namespace std;
template <class T>
   ostream& operator<<(ostream &os, const Propriete<T> &p) {
      return os << static_cast<const T&>(p);
   }
template <class T>
   istream& operator>>(istream &is, Propriete<T> &p) {
      if (!is) return is;
      if (T val; is >> val)
         p = val;
      return is;
   }

Implémentation générique des propriétés (plus raffinée)

Pour réaliser l'encapsulation à l'aide de propriétés, nous voulons être en mesure de choisir des politiques de validation propres à chacune des propriétés prise individuellement.

Ce comportement est décidé à la compilation, de manière statique (dans les langages .NET, pour encapsuler la donnée d'une propriété non-triviale, on rédigerait le code d'une méthode!), et se prête beaucoup mieux à des politiques qu'à du polymorphisme.

L'ajout d'une classe politique dans le code à droite montre que l'ajout de règles de validation peut être à la fois simple et efficace.

La politique de validation par défaut, ToujoursOk, sera un foncteur Pass-Through, ne réalisant aucune validation et présumant que toute valeur est admissible. Nous l'examinerons plus bas.

Notez que ceci vient de transformer Propriete qui vient de passer d'un type générique sur la base d'un seul type à un type générique sur la base de plusieurs types.

Remarquez la distinction entre les types Validate et InitValidate. Le premier permet de valider les tentatives de modification (par affectation) aux états de la propriété, alors que le second n'est applicable qu'à son état initial. Par défaut, j'ai utilisé ToujoursOk pour validation initiale, mais dans le respect des invariants de l'objet possédant la propriété, nous aurions pu faire en sorte que les deux correspondent à une seule et même politique (écrire class InitValidate = Validate plutôt que class InitValidate = ToujoursOk).

class invalide {};
template <class T,
          class Validate = ToujoursOk,
          class InitValidate = ToujoursOk>
   class Propriete {
      T prop;
      static constexpr T validate(const T &value) {
         return Validate{}(value) ? value : throw invalide{};
      }
      static constexpr T initial_validate(const T &value) {
         return InitValidate{}(value) ? value : throw invalide{};
      }
   public:
      constexpr Propriete() : prop{ initial_validate(T{}) } {
      }
      constexpr Propriete(T value) : prop{ initial_validate(value) } {
      }
      Propriete& operator=(const T &value) {
         prop = validate(value);
         return *this;
      }
      constexpr operator T() const {
         return prop;
      }
      operator T&() {
         return prop;
      }
      const T& operator*() const {
         return *prop;
      }
      T& operator*() {
         return *prop;
      }
      const T& operator->() const {
         return prop;
      }
      T& operator->() {
         return prop;
      }
   };

En particulier, ce changement affecte la signature et l'implémentation des opérations d'accès (en lecture et en écriture) aux flux. Ces changements sont, heureusement, complètement transparents du point de vue du code client.

template <class T, class V, class I>
   std::ostream& operator<<(std::ostream &os, const Propriete<T, V, I> &p) {
      return os << static_cast<const T&>(p);
   }
template <class T, class V, class I>
   std::istream& operator>>(std::istream &is, Propriete<T, V, I> &p) {
      if (!is) return is;
      if (T val; is >> val)
         p = val;
      return is;
   }

Une classe Personne utilisant cette nouvelle version de Propriete et faisant en sorte qu'un nom de moins de cinq caractères soit illégal et qu'un voisin nul soit inacceptable serait celle proposée à droite. Pour comprendre le sens des politiques utilisées ici, voir un peu plus bas.

#ifndef PERSONNE_H
#define PERSONNE_H
#include "Proprietes.h"
#include <string>
#include <string_view>
#include <memory>
struct Personne {
   using str_type = std::string;
   Propriete<Personne*, NonNul> Voisin;
   Propriete<str_type, LongueurAuMoins<5>> Nom;
   Personne(string_view nom) : Nom{ nom } {
   }
};
#endif

Une autre classe, EntierPositif, n'accepte que des entiers strictement positifs et est telle que ses instances peuvent être construites à la compilation, même si elle contient une propriété.

#ifndef ENTIER_POSITIF_H
#define ENTIER_POSITIF_H
#include "Proprietes.h"
struct EntierPositif {
   Propriete<int, StrictementPositif, StrictementPositif> Valeur;
   constexpr EntierPositif(int valeur) : Valeur{ valeur } {
   }
};
#endif

Un programme correct serait, quant à lui, celui proposé à droite. Des blocs try ... catch ont été placés là où des tentatives de corruption des propriétés (en fonction des politiques mises en place) sont réalisées, de manière à attraper les exceptions levées et à permettre au programme de démonstration de poursuivre normalement ses opérations.

Notez qu'avec les politiques de validation mises en place, ce programme affichera :

Valeur de e : 3
Freddy a pour voisin Cocotte et Cocotte a pour voisin Freddy
Politique non-respectee
Politique non-respectee
Politique non-respectee

L'affectation de "Joe" à p0.Nom échouera parce que ce nom est trop court pour notre politique de validation. Les autres échecs devraient être évidents.

#include "Personne.h"
#include <iostream>
using namespace std;
int main() {
   constexpr EntierPositif e = 3;
   static_assert(static_cast<int>(e.Valeur) == 3);
   Personne p0, p1;
   p0.Nom = "Roger";
   p0.Nom = "Freddy";
   p1.Nom = "Cocotte";
   p0.Voisin = &p1;
   p1.Voisin = &p0;
   cout << p0.Nom << " a pour voisin " << p1.Nom << " et "
        << p1.Nom << " a pour voisin " << p0.Nom << endl;
   try {
      p0.Nom = "Joe";
   } catch (...) {
      cerr << "Politique non-respectee\n";
   }
   try {
      p1.Voisin = nullptr;
   } catch (...) {
      cerr << "Politique non-respectee\n";
   }
   try {
      EntierPositif e0 = -2;
   } catch (...) {
      cerr << "Politique non-respectee\n";
   }
}

Quelques politiques de validation

Toutes nos politiques de validation seront des prédicats, idéalement constexpr. La liste proposée ici se veut illustrative (l'ensemble des possibles est infini).

Nous avons déjà aperçu, du moins d'un point de vue utilisation, la petite politique ToujoursOk qui correspond conceptuellement à aucune validation appliquée; le paramètre est nécessairement correct. Il s'agit d'une sorte de no-op pour les besoins de la cause. Une propriété ToujoursOk est essentiellement un attribut.

struct ToujoursOk {
   template <class T>
      constexpr bool operator()(T&&) const noexcept {
         return true;
      }
};

Deux politiques typiques sur des types numériques seraient d'accepter une valeur seulement si elle est positive (ou strictement positive, selon le cas). Des cas plus généraux sont envisageables : plus grand (ou plus grand ou égal) qu'un certain seuil, par exemple.

Les exemples de politiques proposés à droite couvrent certains des cas envisageables. On pourrait généraliser encode un peu plus la démarche (je vous laisse vous amuser si vous avez envie de pousser le tout un peu plus loin).

struct NonNul {
   template <class T>
      constexpr bool operator()(const T &p) const {
         return p != nullptr;
      }
};
struct Positif {
   template <class T>
      constexpr bool operator()(const T &val) const {
         return val >= 0;
      }
};
struct StrictementPositif {
   template <class T>
      constexpr bool operator()(const T &val) const {
         return val > 0;
      }
};

Il est aussi possible d'envisager des règles de validation pour les propriétés qui s'appliquent à celles dont la valeur est un conteneur. Quelques exemples apparaissent à droite.

D'autres cas, non couverts ici, sont faciles à déterminer et sujets à être fort utiles (des combinaisons de politiques, par exemple). Amusez-vous à souhait.

struct NonVide {
   template <class T>
      bool operator()(const T &val) const {
         return !val.empty();
      }
};
template <std::size_t SEUIL>
   struct LongueurPlusGrandeQue {
      template <class T>
         constexpr bool operator()(const T &val) const {
            return val.size () > SEUIL;
         }
   };
template <std::size_t SEUIL>
   struct LongueurAuMoins {
      template <class T>
         constexpr bool operator()(const T &val) const {
            return val.size() >= SEUIL;
         }
   };

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !