IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Les fonctions virtuelles en C++ : Types statiques et types dynamiques


précédentsommairesuivant

XX. Fonctions virtuelles et programmation par contrat

XX-A. Un rapide rappel

Le but n'est pas de présenter la programmation par contrat (13). Si celle-ci vous est totalement inconnue, alors il est temps de vous y mettre. La programmation par contrat est un puissant outil de conception pour réaliser des classes sûres et simples clarifiant les responsabilités de chacun des acteurs d'un échange. Contentons-nous de rappeler les éléments dont nous allons avoir besoin.
Un invariant est une propriété d'un objet qui doit rester valide pendant toute la durée de vie de l'objet ou pour être plus précis dès que cet objet est utilisable par un tiers. Par exemple, un invariant de la classe std::vector est : size()<=capacity(). Cela signifie qu'un vecteur doit toujours avoir un nombre d'éléments au plus égal au nombre total d'éléments qu'il peut contenir.
Une précondition d'une fonction est une propriété qui doit être vérifiée pour que l'appel de la fonction garantisse le contrat. Par exemple, toujours pour notre std::vector, une précondition pour appeler std::vector::pop_back est que size()>=1. On ne peut supprimer le dernier élément que si le vecteur en contient au moins un. C'est à l'appelant d'une fonction de s'assurer que les préconditions sont remplies avant de solliciter cette fonction sur un objet. Les préconditions peuvent porter sur les paramètres de la fonction ou sur l'état de l'objet.
Une postcondition est une propriété que garantit la fonction à la fin de son exécution. Par exemple, std::vector<T>::push_back garantit que size()>=1. La postcondition peut même être formulée plus précisément : size() = old size() + 1. La postcondition garantit que si on demande à rajouter un élément dans le vecteur, alors la taille du vecteur augmente de un.
Si une fonction est appelée sur un objet respectant ses invariants avec des préconditions invalides, alors le bug est à chercher du côté de l'appelant. Si au retour d'une fonction appelée avec un objet respectant ses invariants et en remplissant les préconditions, les postconditions ne sont pas remplies, alors le bug est à chercher dans l'implémentation de la fonction.
Si une fonction est appelée sur un objet en ne respectant pas les préconditions, alors la fonction n'a pas à respecter le contrat : elle peut légitimement retourner un objet qui ne respecte plus les invariants ou ne pas respecter les postconditions en sortie. Ce n'est pas un bug de la fonction, mais une erreur lors de l'appel. À noter que cette situation a toutes les chances de se traduire à un moment ou un autre par au mieux un plantage au pire des résultats erronés.
Si une fonction est appelée sur un objet respectant les invariants et en respectant les préconditions mais que, pour une autre raison, la fonction ne peut garantir les postconditions alors elle doit lever une exception.
Le client désigne celui qui fait l'appel à une fonction d'un objet. Le fournisseur désigne la classe qui implémente la fonction.

XX-B. Le principe de substitution de Liskov

Le principe de substitution de LiskovQu'est-ce que le LSP ? énonce que partout où dans une expression valide on trouve un objet d'un type B, l'expression doit rester valide si on remplace l'objet par un autre objet de type D avec D dérivant publiquement de B. Avec les fonctions non virtuelles alors le problème est moindre, car leur résolution statique implique l'appel de la fonction définie dans la classe de base donc en respectant ses préconditions, celle-ci assure alors les postconditions. Avec une fonction virtuelle, lorsqu'un objet de la classe de base est substitué par un objet de la classe dérivée, la fonction appelée est celle de la classe dérivée. Se posent alors les questions suivantes :

  • Que se passe-t-il pour les invariants de la classe dérivée et de la classe de base ?
  • Quelles sont les préconditions que doit vérifier l'appelant ?
  • Quelles sont les postconditions que doit garantir la classe dérivée ?
  • Qu'en est-il des exceptions pouvant être levées ?

Nous allons voir comment répondre à ces questions. (14)

XX-C. Impact sur les invariants pour une fonction virtuelle

Le problème des invariants ne concerne pas l'appel d'une fonction virtuelle à l'extérieur de la classe de base ou de la classe dérivée, mais au sein d'une fonction membre. Une fonction peut pendant son déroulement momentanément rompre un ou des invariants à condition qu'elle les rétablisse avant de sortir vers l'appelant. Il faut donc considérer les invariants dans une fonction F1 appelée pour un objet O quand elle sollicite une fonction virtuelle F2 sur ce même objet (sur this) :

Impact sur les invariants :
Sélectionnez
struct base{
   void F1()
   {
      /* [...] */
      // Quel doit être l'état des invariants avant l'appel ?
      F2();
      // Comment F2 doit laisser les invariants après l'appel ?
      /* [...] */
   }
   virtual void F2();
};

La réponse à la seconde question est la plus facile : qu'une fonction soit virtuelle ou non, qu'elle soit appelée depuis l'extérieur ou non, elle doit de toute façon rétablir les invariants qu'elle a elle-même rompus.
En revanche, savoir s'il faut rétablir les invariants avant l'appel d'une fonction virtuelle est une question qui suscite des positions variées. Cette question a fait l'objet d'une discussion : Ruptures des invariants en C++Discussion : Ruptures des invariants en C++. La question s'articule alors entre savoir si le contrat doit être abordé de façon stricte au niveau de la classe. Ou si au contraire, il est préférable d'avoir une approche à un niveau juste au-dessus - le module (bien cela n'est pas de sens en C++) - qui est le seul pertinent d'un point de vue de conception (définition du service, réutilisabilité, etc.).

XX-D. Impact sur les préconditions pour une fonction virtuelle

Supposons que la classe de base B pose (P1 && P2 && P3 … && Pn) (15) comme préconditions pour l'appel de la fonction virtuelle F. Le client a la charge de vérifier les préconditions avant d'appeler F. Le code peut ressembler alors à :

Exemple de vérification des préconditions avant l'appel d'une fonction :
Sélectionnez
void client::some_function()
{
   /* on suppose que l'on a une référence de type statique B &rb valide 
      sur un objet de type dynamique B */
   if(P1(rb)&&P2(rb)&&...&&Pn(rb)){
      rb.F();
   }
   /*else : gestion de ce cas */
}

Maintenant, supposons qu'une classe dérivée D demande une précondition supplémentaire Pk. Alors pour rester dans la programmation par contrat, le code devrait être changé en :

Vérifier les conditions supplémentaires ?
Sélectionnez
void client::some_function()
{
   /* on suppose que l'on a une référence B &rb valide*/
   if(is_dynamic_type_a_B(rb)){
      if(P1(rb)&&P2(rb)&&...&&Pn(rb)){
         rb.F();
      }
   }
   else
   if(is_dynamic_type_a_D(rb)){
      if(P1(rb)&&P2(rb)&&...&&Pn(rb)&&Pk(rb)){
         rb.F();
      }
   }
}

On viole un des principes fondamentaux de la programmation objet (le principe ouvert/fermé) : à chaque fois qu'une nouvelle classe dérivée est ajoutée, il faut venir changer cet appel. Qui plus est, avoir à se soucier du type dynamique avant d'appeler une fonction virtuelle enlève tout l'intérêt de ce mécanisme. Ce n'est donc pas envisageable. D'où la conclusion :

Une classe dérivée ne peut pas imposer des préconditions plus restrictives pour une fonction virtuelle que celles demandées par la classe de base.

Les préconditions ne peuvent être renforcées dans les classes dérivées.
Peut-on supprimer une précondition dans la classe dérivée ? Supposons que la classe dérivée supprime la précondition Pi. Cela veut dire qu'un appel à la fonction dérivée doit vérifier (P0 && P1 && P2 && … && (Pi || !Pi) && … && Pn). Cette expression est vraie dès que Pi est vrai. Dit autrement, (P0 && P1 && P2 && … && Pi && … && Pn) implique (P0 && P1 && P2 && … && (Pi || !Pi) && … && Pn). Donc notre code ne tenant pas compte du type dynamique reste valide pour une classe dérivée :

Les préconditions peuvent être relâchées :
Sélectionnez
void client::some_function()
{
   /* on suppose que l'on a une référence de type statique B &rb valide */
   if(P1(rb)&&P2(rb)&&...&&Pn(rb)){
      rb.F();
   }
}

Que Pi soit vérifiée est une contrainte supplémentaire qui n'est pas demandée par la classe dérivée, mais qui, si elle l'est, permet tout autant à la classe dérivée de remplir son contrat :

Une classe dérivée peut imposer des préconditions plus larges pour une fonction virtuelle que celles demandées par la classe de base.

Reformuler autrement, une classe dérivée doit savoir faire la même chose que sa classe de base (les préconditions ne peuvent pas être plus fortes), mais peut en savoir faire plus (les préconditions peuvent être plus faibles).

XX-E. Impact sur les postconditions pour une fonction virtuelle

Examinons les postconditions en suivant un raisonnement similaire au précédent. Soit une classe B proposant une fonction virtuelle F garantissant les postconditions (P1 && P2 && P3 … && Pn) :

Vérifier les postconditions :
Sélectionnez
void client::some_function()
{
   /* on suppose que l'on a une référence de type statique B &rb valide 
      sur un objet de type dynamique B */
   if(check_preconditions_for_F(rb)){
      rb.F();
      assert( P1(rb) && P2(rb) && .. && Pn(rb)); 
              // F doit avoir correctement fait son boulot !
   }
}

Si la classe dérivée est plus souple que la classe de base et décide de ne plus assurer la postcondition Pi. Alors la garantie précédente peut ne plus être vérifiée chez le client en sortie de F, le seul moyen qu'aurait le client serait à nouveau de différencier le traitement selon le type dynamique. Nous avons vu que cela était une impasse. Une classe dérivée ne peut donc supprimer une postcondition.

Une classe dérivée doit garantir toutes les postconditions d'une fonction virtuelle indiquées dans la classe de base.

À l'inverse, supposons que la classe dérivée garantisse une nouvelle postcondition Pk. Cela n'empêche pas les autres postconditions d'être valides. Le client peut donc continuer de s'appuyer sur les autres postconditions sans se soucier de ce 'bonus'.

Une classe dérivée peut ajouter de nouvelles postconditions au contrat d'une fonction virtuelle de la classe de base.

En d'autres termes, une classe dérivée doit faire au minimum la même chose que la classe de base (pas de postconditions en moins), mais peut en faire plus (on peut rajouter des postconditions).

On peut résumer les contraintes sur les préconditions et les postconditions par cette simple image : un sous-traitant (classe dérivée) DOIT faire autant (postconditions) et au même prix (préconditions) qu'un fournisseur (classe de base), mais il peut faire plus (renforcement des postconditions) pour moins cher (allégement des préconditions). En aucun cas il ne peut faire moins (allégement des postconditions) pour plus cher (renforcement de préconditions) :

Un sous-traitant doit faire autant au même prix
Un sous-traitant doit faire autant au même prix

XXI. Le pattern N.V.I.

Comme beaucoup de bonnes idées, le pattern N.V.I.Mes fonctions virtuelles doivent-elles être publiques, protégées, ou privées ? Le pattern NVI. a probablement été introduit par plusieurs auteurs simultanément. L'article certainement le plus cité pour décrire ce pattern et écrit par Herb Sutter le présente sous le nom de Template Method dans le C/C++ User JournalVirtuality : Template Method. Mais ce nom a deux inconvénients majeurs : introduire le mot template alors qu'il ne s'agit pas de programmation générique, mais surtout de créer la confusion avec le patron de conception de même nom (D.P. Template Method) dont les objectifs sont différents. On parle donc de pattern N.V.I. pour Non Virtual Interface.
Le principe est simple : déclarer les fonctions publiques comme non virtuelles et les fonctions virtuelles comme privées. Les premières s'appuyant sur les secondes :

Pattern NVI :
Sélectionnez
struct non_virtual_interface
{
   public:
   void function()
   {
      do_function();
   }
   private:
   virtual void do_function()
   {
   }
};

struct derived_non_virtual_interface : public non_virtual_interface
{
   private :
   virtual void do_function()
   {
   }
};

Au regard de ce que nous avons déjà vu, les avantages sont évidents :

  • Différenciation du contrat : les utilisateurs de la classe doivent respecter le contrat défini par la fonction publique. Les classes dérivées doivent respecter le contrat défini par la fonction virtuelle. Il est dès lors plus facile de faire évoluer l'un indépendamment de l'autre. Les rôles sont bien distribués.
  • Cela permet de regrouper les vérifications du contrat dans la fonction non virtuelle pour garantir l'appel des spécialisations dans les fonctions dérivées.
  • Cela permet de regrouper des fonctions d'instrumentation (trace par exemple) dans la fonction non virtuelle sans avoir à les dupliquer.
  • Comme évoqué dans la section concernant les fonctions virtuelles pures, le pattern N.V.I. permet d'entourer l'appel de la fonction virtuelle de prétraitements ou de posttraitements.

Pourquoi rendre la fonction virtuelle privée ?
Tout d'abord, nous avons vu que la visibilité de la fonction n'influe pas sur l'appel dynamique et n'empêche donc pas la fonction non virtuelle de l'invoquer. L'intérêt est que même les classes dérivées ne seront pas tentées d'appeler directement la fonction virtuelle et doivent passer par l'interface publique de la classe de base.

Quelles différences entre le pattern N.V.I. et le patron de conception Template Method ?
Le DP Template Method a pour objectif d'introduire des points de variation dans un algorithme. La fonction de base enchaîne différentes opérations dont certaines peuvent changer ou dépendre d'un contexte particulier : elles doivent être spécialisées par les classes dérivées.
La pattern N.V.I. a un tout autre objectif : séparer l'interface d'une classe en fonction de ses clients. L'interface publique et non virtuelle s'adresse aux utilisateurs de la classe. L'interface privée et virtuelle s'adresse aux classes dérivées.


précédentsommairesuivant
La programmation par contrat a été introduite et développée par Bertrand Meyer dans son livre Conception et programmation orientées objetConception et programmation orientées objet, par Bertrand Meyer dont la lecture est toujours recommandée.
Dans toute cette section, il est supposé que les fonctions sont appelées avec un objet par référence ou par pointeur afin de faire jouer le mécanisme de résolution dynamique de l'appel des fonctions virtuelles. Sans quoi, la fonction de la classe de base est toujours appelée ce qui ne nous intéresse pas dans ce cas.
Les préconditions sont bien sûr non contradictoires : pour être vraie Pi ne doit pas nécessité que Pj soit fausse.

Copyright © 2009 3DArchi. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.