web-dev-qa-db-fra.com

Quel est le modèle pour une interface sûre en C ++

Remarque: ce qui suit est du code C++ 03, mais nous nous attendons à un passage au C++ 11 dans les deux prochaines années, nous devons donc garder cela à l'esprit.

J'écris une directive (pour les débutants, entre autres) sur la façon d'écrire une interface abstraite en C++. J'ai lu les deux articles de Sutter sur le sujet, recherché des exemples et des réponses sur Internet et fait quelques tests.

Ce code ne doit PAS être compilé!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Tous les comportements ci-dessus trouvent la source de leur problème dans - slicing: L'interface abstraite (ou la classe non-feuille dans la hiérarchie) ne doit pas être constructible ni copiable/assignable, MÊME si la classe dérivée peut être.

0ème solution: l'interface de base

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Cette solution est simple et quelque peu naïve: elle échoue à toutes nos contraintes: elle peut être construite par défaut, copiée et copiée (je ne suis même pas sûr de déplacer les constructeurs et les affectations, mais j'ai encore 2 ans pour comprendre IT out).

  1. Nous ne pouvons pas déclarer le destructeur pur virtuel parce que nous devons le garder en ligne, et certains de nos compilateurs ne digéreront pas les méthodes virtuelles pures avec un corps vide en ligne.
  2. Oui, le seul point de cette classe est de rendre les implémenteurs virtuellement destructibles, ce qui est un cas rare.
  3. Même si nous avions une méthode pure virtuelle supplémentaire (ce qui est la majorité des cas), cette classe serait toujours attribuable à la copie.

Donc non...

1ère solution: boost :: non copiable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Cette solution est la meilleure, car elle est simple, claire et C++ (pas de macros)

Le problème est qu'il ne fonctionne toujours pas pour cette interface spécifique car VirtuallyConstructible peut toujours être construit par défaut .

  1. Nous ne pouvons pas déclarer le destructeur pur virtuel car nous devons le garder en ligne, et certains de nos compilateurs ne le digéreront pas.
  2. Oui, le seul point de cette classe est de rendre les implémenteurs virtuellement destructibles, ce qui est un cas rare.

Un autre problème est que les classes implémentant l'interface non copiable doivent alors déclarer/définir explicitement le constructeur de copie et l'opérateur d'affectation si elles ont besoin de ces méthodes ( et dans notre code, nous avons des classes de valeur auxquelles notre client peut toujours accéder via des interfaces).

Cela va à l'encontre de la règle du zéro, c'est là que nous voulons aller: si l'implémentation par défaut est correcte, alors nous devrions pouvoir l'utiliser.

2ème solution: faites-les protégés!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Ce modèle suit les contraintes techniques que nous avions (au moins dans le code utilisateur): MyInterface ne peut pas être construit par défaut, ne peut pas être construit par copie et ne peut pas être copié -attribué.

En outre, il n'impose aucune contrainte artificielle à l'implémentation de classes , qui sont alors libres de suivre la règle de zéro, ou même de déclarer quelques constructeurs/opérateurs comme " = default "en C++ 11/14 sans problème.

Maintenant, c'est assez verbeux, et une alternative serait d'utiliser une macro, quelque chose comme:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Le protégé doit rester en dehors de la macro (car il n'a pas de portée).

Correctement "espacée" (c'est-à-dire précédée du nom de votre entreprise ou produit), la macro doit être inoffensive.

Et l'avantage est que le code est factorisé dans une seule source, au lieu d'être copié-collé dans toutes les interfaces. Si le constructeur de déplacement et l'affectation de déplacement devaient être explicitement désactivés de la même manière à l'avenir, ce serait un changement très léger dans le code.

Conclusion

  • Suis-je paranoïaque pour vouloir que le code soit protégé contre le découpage dans les interfaces? (Je crois que non, mais on ne sait jamais ...)
  • Quelle est la meilleure solution parmi celles ci-dessus?
  • Y a-t-il une autre meilleure solution?

N'oubliez pas qu'il s'agit d'un modèle qui servira de guide pour les débutants (entre autres), donc une solution comme: "Chaque cas devrait avoir sa mise en œuvre" n'est pas une solution viable.

Bounty et résultats

J'ai attribué la prime à coredump en raison du temps passé à répondre aux questions et de la pertinence des réponses.

Ma solution au problème ira probablement à quelque chose comme ça:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... avec la macro suivante:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Il s'agit d'une solution viable à mon problème pour les raisons suivantes:

  • Cette classe ne peut pas être instanciée (les constructeurs sont protégés)
  • Cette classe peut être pratiquement détruite
  • Cette classe peut être héritée sans imposer de contraintes excessives sur les classes héritées (par exemple, la classe héritée pourrait être copiable par défaut)
  • L'utilisation de la macro signifie que la "déclaration" de l'interface est facilement reconnaissable (et consultable), et son code est factorisé en un seul endroit, ce qui le rend plus facile à modifier (un nom convenablement préfixé supprimera les conflits de noms indésirables)

Notez que les autres réponses ont donné un aperçu précieux. Merci à tous ceux qui ont essayé.

Notez que je suppose que je peux encore mettre une autre prime sur cette question, et j'apprécie suffisamment les réponses éclairantes pour que si j'en vois une, j'ouvrirais une prime juste pour l'attribuer à cette réponse.

22
paercebal

La manière canonique de créer une interface en C++ est de lui donner un destructeur virtuel pur. Cela garantit que

  • Aucune instance de la classe d'interface elle-même ne peut être créée, car C++ ne vous permet pas de créer une instance d'une classe abstraite. Cela prend en charge les exigences non constructibles (par défaut et copie).
  • Appeler delete sur un pointeur vers l'interface fait la bonne chose: il appelle le destructeur de la classe la plus dérivée pour cette instance.

le simple fait d'avoir un destructeur virtuel pur n'empêche pas l'affectation sur une référence à l'interface. Si vous avez également besoin que cela échoue, vous devez ajouter un opérateur d'affectation protégé à votre interface.

Tout compilateur C++ devrait être capable de gérer une classe/interface comme celle-ci (le tout dans un fichier d'en-tête):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Si vous avez un compilateur qui s'étouffe avec cela (ce qui signifie qu'il doit être pré-C++ 98), alors votre option 2 (ayant des constructeurs protégés) est une bonne seconde.

En utilisant boost::noncopyable n'est pas recommandé pour cette tâche, car il envoie le message que les classes all dans la hiérarchie ne doivent pas être copiables et cela peut donc créer de la confusion pour les développeurs plus expérimentés qui ne connaissent pas votre l'intention de l'utiliser comme ça.

13

Suis-je paranoïaque ...

  • Suis-je paranoïaque pour vouloir que le code soit protégé contre le découpage dans les interfaces? (Je crois que non, mais on ne sait jamais ...)

N'est-ce pas un problème de gestion des risques?

  • craignez-vous qu'un bug lié au découpage soit susceptible d'être introduit?
  • pensez-vous que cela peut passer inaperçu et provoquer des bugs irrécupérables?
  • dans quelle mesure êtes-vous prêt à aller pour éviter de trancher?

Meilleure solution

  • Quelle est la meilleure solution parmi celles ci-dessus?

Votre deuxième solution ("rendez-les protégés") semble bonne, mais gardez à l'esprit que je ne suis pas un expert en C++.
Au moins, les utilisations invalides semblent être correctement signalées comme erronées par mon compilateur (g ++).

Maintenant, avez-vous besoin de macros? Je dirais "oui", car même si vous ne dites pas quel est le but de la directive que vous écrivez, je suppose que c'est pour appliquer un ensemble particulier de meilleures pratiques dans le code de votre produit.

À cet effet, les macros peuvent aider à détecter quand les gens appliquent efficacement le modèle: un filtre de base des validations peut vous dire si la macro a été utilisée:

  • s'il est utilisé, alors le modèle est susceptible d'être appliqué, et plus important encore, correctement appliqué (il suffit de vérifier qu'il existe un mot clé protected),
  • s'il n'est pas utilisé, vous pouvez essayer d'en rechercher la raison.

Sans macros, vous devez vérifier si le modèle est nécessaire et bien implémenté dans tous les cas.

Meilleure solution

  • Y a-t-il une autre meilleure solution?

Le découpage en C++ n'est rien de plus qu'une particularité du langage. Puisque vous écrivez une ligne directrice (en particulier pour les débutants), vous devez vous concentrer sur l'enseignement et pas seulement sur l'énumération des "règles de codage". Vous devez vous assurer que vous expliquez vraiment comment et pourquoi le découpage se produit, ainsi que des exemples et des exercices (ne réinventez pas la roue, inspirez-vous des livres et des tutoriels).

Par exemple, le titre d'un exercice pourrait être " Quel est le modèle d'une interface sûre en C++ ?"

Ainsi, votre meilleure décision serait de vous assurer que vos développeurs C++ comprennent ce qui se passe lors du découpage. Je suis convaincu que s'ils le font, ils ne feront pas autant d'erreurs dans le code que vous le craindriez, même sans appliquer formellement ce modèle particulier (mais vous pouvez toujours le faire appliquer, les avertissements du compilateur sont bons).

À propos du compilateur

Vous dites :

Je n'ai aucun pouvoir sur le choix des compilateurs pour ce produit,

Souvent, les gens disent "Je n'ai pas le droit de faire [X]" , "Je suis pas censé faire [Y] ... ", ... parce qu'ils pensent que ce n'est pas possible, et non parce qu'ils ont essayé ou demandé.

Cela fait probablement partie de votre description de poste de donner votre avis sur des questions techniques; si vous pensez vraiment que le compilateur est le choix parfait (ou unique) pour votre domaine problématique, alors utilisez-le. Mais vous avez également dit "les destructeurs virtuels purs avec implémentation en ligne ne sont pas le pire point d'étouffement que j'ai vu" ; d'après ma compréhension, le compilateur est si spécial que même les développeurs C++ compétents ont des difficultés à l'utiliser: votre compilateur hérité/interne est maintenant une dette technique, et vous avez le droit (le devoir?) de discuter de ce problème avec d'autres développeurs et gestionnaires .

Essayez d'évaluer le coût de conservation du compilateur par rapport au coût d'utilisation d'un autre:

  1. Qu'est-ce que le compilateur actuel vous apporte que personne d'autre ne peut faire?
  2. Votre code produit est-il facilement compilable à l'aide d'un autre compilateur? Pourquoi pas ?

Je ne connais pas votre situation, et en fait, vous avez probablement des raisons valables d'être lié à un compilateur spécifique.
Mais dans le cas où il s'agit simplement d'une simple inertie, la situation n'évoluera jamais si vous ou vos collègues ne signalez pas de problèmes de productivité ou d'endettement technique.

5
coredump

Le problème du découpage est un, mais certainement pas le seul, introduit lorsque vous exposez une interface polymorphe d'exécution à vos utilisateurs. Pensez aux pointeurs nuls, à la gestion de la mémoire, aux données partagées. Aucun de ces problèmes n'est facilement résolu dans tous les cas (les pointeurs intelligents sont excellents, mais même ils ne sont pas une solution miracle). En fait, à partir de votre message, il ne semble pas que vous essayez de résoudre le problème du découpage, mais évitez-le plutôt en ne permettant pas aux utilisateurs de faire des copies. Tout ce que vous devez faire pour proposer une solution au problème de découpage est d'ajouter une fonction membre de clone virtuel. Je pense que le problème le plus profond de l'exposition d'une interface polymorphe au moment de l'exécution est que vous forcez les utilisateurs à gérer la sémantique de référence, qui est plus difficile à raisonner que la sémantique de valeur.

La meilleure façon que je connaisse pour éviter ces problèmes en C++ est d'utiliser l'effacement de type . Il s'agit d'une technique dans laquelle vous masquez une interface polymorphe d'exécution, derrière une interface de classe normale. Cette interface de classe normale a alors une sémantique de valeur et s'occupe de tout le "désordre" polymorphe derrière les écrans. std::function est un excellent exemple d'effacement de type.

Pour une excellente explication des raisons pour lesquelles l'exposition de l'héritage à vos utilisateurs est mauvaise et comment l'effacement de type peut aider à résoudre ce problème, voyez ces présentations par Sean Parent:

L'héritage est la classe de base du mal (version courte)

Value Semantics and Concepts-based Polymorphism (version longue; plus facile à suivre, mais le son n'est pas génial)

2
D Drmmr

Tu n'es pas paranoïaque. Ma première tâche professionnelle en tant que programmeur C++ a entraîné le découpage et le plantage. J'en connais d'autres. Il n'y a pas beaucoup de bonnes solutions pour cela.

Compte tenu de vos contraintes de compilation, l'option 2 est la meilleure. Au lieu de créer une macro, que vos nouveaux programmeurs verront comme étrange et mystérieuse, je suggère un script ou un outil pour générer automatiquement le code. Si vos nouveaux employés utiliseront un IDE, vous devriez pouvoir créer un outil "Nouvelle interface MYCOMPANY" qui demandera le nom de l'interface et créer la structure que vous recherchez.

Si vos programmeurs utilisent la ligne de commande, utilisez le langage de script disponible pour créer le script NewMyCompanyInterface pour générer le code.

J'ai utilisé cette approche dans le passé pour les modèles de code courants (interfaces, machines à états, etc.). La partie intéressante est que les nouveaux programmeurs peuvent lire la sortie et la comprendre facilement, reproduisant le code nécessaire lorsqu'ils ont besoin de quelque chose qui ne peut pas être généré.

Les macros et autres approches de méta-programmation ont tendance à obscurcir ce qui se passe, et les nouveaux programmeurs n'apprennent pas ce qui se passe "derrière le rideau". Quand ils doivent rompre le schéma, ils sont tout aussi perdus qu'auparavant.

0
Ben