Types pour préconditions

Ce petit texte a été inspiré des travaux de la Guideline Support Library, ou GSL : https://github.com/Microsoft/GSL

Plusieurs fonctions ont des préconditions, soit des considérations qui doivent être respectées par l'appelant pour que l'appel puisse être réalisé avec succès. Il arrive que l'appelé soit en mesure valider les préconditions (par exemple, s'assurer qu'un paramètre entier soit positif, qu'une chaîne de caractères ait une certaine longueur, ou qu'un pointeur soit non-nul), mais il arrive aussi souvent, du moins en C ou en C++, qu'il ne soit pas possible, ou pas raisonnable dans une perspective de vitesse d'exécution, de demander à la fonction appelée de valider le respect des préconditions. À titre d'exemple, une fonction ne peut généralement garantir qu'un pointeur reçu en paramètre soit « valide », et vérifier à chaque accès à un indice d'un tableau que l'indice utilisé est valide peut entraîner un coût à l'exécution qui serait prohibitif en pratique pour certains créneaux applicatifs.

Prenons un cas simple de fonction générique qui n'appellerait une fonction F avec un paramètre *p que si le pointeur p est non-nul. Trois écritures possibles pour cette fonction suivent, pour C++ 11 et pour C++ 14 :

  Sans validation Validation par assertion Validation par levée d'exception
C++ 11
template <class F, class T>
   auto apply_if_non_null(F f, T *p) -> decltype(f(*p)) {
      return f(*p);
   }
#include <cassert>
template <class F, class T>
   auto apply_if_non_null(F f, T *p) -> decltype(f(*p)) {
      assert(p);
      return f(*p);
   }
class parametre_invalide{}; // ou std::invalid_argument
template <class F, class T>
   auto apply_if_non_null(F f, T *p) -> decltype(f(*p)) {
      if (!p) throw parametre_invalide{};
      return f(*p);
   }
C++ 14
template <class F, class T>
   auto apply_if_non_null(F f, T *p) {
      return f(*p);
   }
#include <cassert>
template <class F, class T>
   auto apply_if_non_null(F f, T *p) {
      assert(p);
      return f(*p);
   }
class parametre_invalide{}; // ou std::invalid_argument
template <class F, class T>
   auto apply_if_non_null(F f, T *p) {
      if (!p) throw parametre_invalide{};
      return f(*p);
   }

La version sans validation est tout aussi correcte que les deux autres dans la mesure où les préconditions de la fonction sont clairement documentées, car il est techniquement de la responsabilité de l'appelant de ne pas briser ce contrat avec l'appelé.

Un type non_null_ptr<T>

Une manière simple et efficace de représenter l'obligation du respect d'une précondition est d'utiliser le système de types du langage. Si nous supposons que l'objectif de la validation soit d'assurer que p soit non-nul, alors nous pouvons définir un type représentant par construction un pointeur non-nul, et utiliser ce type dans l'interface de la fonction. Conséquemment, l'interface de la fonction en documentera implicitement les préconditions (du moins, certaines d'entre elles) et le code sera d'office plus robuste.

Une implémentation possible pour un type non_null_ptr<T> serait :

class pointeur_invalide {};
template <class T>
   class non_null_ptr {
      T *p;
   public:
      non_null_ptr(T *p) : p{ p } {
         if (!p) throw pointeur_invalide{};
      }
      constexpr operator bool() const noexcept {
         return true;
      }
      T& operator*() noexcept { return *p; }
      const T& operator*() const noexcept { return *p; }
      T* operator->() noexcept {
         return p;
      }
      const T* operator->() const noexcept {
         return p;
      }
      bool operator==(const non_null_ptr &autre) const {
         return p == autre.p;
      }
      bool operator!=(const non_null_ptr &autre) const {
         return !(*this == autre);
      }
      // etc. selon les besoins
   };

Remarquez que cette classe n'entraîne aucun coût en espace si on la compare avec un pointeur brut. Remarquez aussi qu'elle n'a pas de constructeur par défaut, car le comportement le plus raisonnable dans un tel cas serait ici de faire de l'objet, conceptuellement, un pointeur nul, ce qui constituerait un bris d'invariant.

Notez le constructeur paramétrique, qui est la clé de cette classe : en acceptant un pointeur et en validant qu'il respecte l'invariant de cette classe (représenter un pointeur non-nul), une instance de non_null_ptr<T> devient telle qu'en vertu de l'encapsulation, il est possible de compter sur elle et d'escamoter toute validation ultérieure du caractère non-nul du pointeur encapsulé. La validation par levée d'exception utilisée ici n'est qu'une possibilité parmi plusieurs, évidemment.

Sur cette base, la fonction donnée en exemple plus haut deviendrait :

C++ 11 C++ 14
template <class F, class T>
   auto apply_if_non_null(F f, non_null_ptr<T> p) -> decltype(f(*p)) {
      return f(*p);
   }
template <class F, class T>
   auto apply_if_non_null(F f, non_null_ptr<T> p) {
      return f(*p);
   }

Le code est plus simple, mieux documenté, et tout aussi robuste qu'auparavant.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !