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

22

Remarque: ce qui suit est du code C ++ 03, mais nous nous attendons à un passage à C ++ 11 au cours des 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 le découpage : 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 l'ê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 s'ils ont besoin de ces méthodes (et dans notre code, nous avons des classes de valeur auxquelles notre client peut toujours accéder via 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 attribué par copie.

De plus, 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 "= défaut" 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 accordé 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 virtuellement détruite
  • Cette classe peut être héritée sans imposer de contraintes indues 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.

paercebal
la source
5
Ne pouvez-vous pas simplement utiliser des fonctions virtuelles pures dans l'interface? virtual void bar() = 0;par exemple? Cela empêcherait votre interface d'être instanciée.
Morwenn
@Morwenn: Comme dit dans la question, cela résoudrait 99% des cas (je vise 100% si possible). Même si nous choisissons d'ignorer le 1% manquant, cela ne résoudra pas non plus le découpage des affectations. Donc, non, ce n'est pas une bonne solution.
paercebal
@Morwenn: Sérieusement? ... :-D ... J'ai d'abord écrit cette question sur StackOverflow, puis j'ai changé d'avis juste avant de la soumettre. Croyez-vous que je devrais le supprimer ici et le soumettre à SO?
paercebal
Si j'ai raison, tout ce dont vous avez besoin est virtual ~VirtuallyDestructible() = 0un héritage virtuel des classes d'interface (avec des membres abstraits, uniquement). Vous pourriez omettre ce VirtuallyDestructible, probablement.
Dieter Lücking
5
@paercebal: Si le compilateur s'étouffe avec des classes virtuelles pures, il appartient à la corbeille. Une véritable interface est par définition pure virtuelle.
Personne

Réponses:

13

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 deleteun pointeur sur 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 (ce qui signifie qu'il doit être pré-C ++ 98), alors votre option 2 (ayant des constructeurs protégés) est une bonne seconde.

L'utilisation boost::noncopyablen'est pas recommandée pour cette tâche, car elle envoie le message que toutes les classes de la hiérarchie doivent être non copiables et cela peut donc créer de la confusion pour les développeurs plus expérimentés qui ne connaissent pas vos intentions de l'utiliser comme ça.

Bart van Ingen Schenau
la source
If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Ceci est la racine de mon problème. Les cas où j'ai besoin d'une interface pour prendre en charge l'affectation doivent être rares en effet. En revanche, les cas où je veux passer une interface par référence (les cas où NULL n'est pas acceptable), et donc, veulent éviter un no-op ou un découpage qui se compilent sont beaucoup plus importants.
paercebal
Comme l'opérateur d'affectation ne doit jamais être appelé, pourquoi lui donnez-vous une définition? En aparté, pourquoi ne pas le faire private? De plus, vous voudrez peut-être traiter les paramètres par défaut et de copie.
Déduplicateur
5

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 ("les rendre 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 protectedmot - clé),
  • 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 directive (en particulier pour les débutants), vous devez vous concentrer sur l'enseignement et non 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 ne suis pas censé faire [Y] ..." , ... parce qu'ils pensent que ce n'est pas possible, et non parce qu'ils 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 que "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 ne 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 ne s'agit que d'une simple inertie, la situation n'évoluera jamais si vous ou vos collègues ne signalez pas de problèmes de productivité ou de dette technique.

coredump
la source
Am I paranoid...: "Rendez vos interfaces faciles à utiliser correctement et difficiles à utiliser incorrectement". J'ai goûté à ce principe particulier lorsque quelqu'un a signalé qu'une de mes méthodes statiques était, par erreur, mal utilisée. L'erreur produite ne semblait pas liée et il a fallu plusieurs heures à un ingénieur pour trouver la source. Cette "erreur d'interface" est comparable à l'attribution d'une référence d'interface à une autre. Donc, oui, je veux éviter ce genre d'erreur. De plus, en C ++, la philosophie est de capturer autant que possible au moment de la compilation, et le langage nous donne ce pouvoir, alors nous allons avec.
paercebal
Best solution: Je suis d'accord. . . Better solution: C'est une réponse géniale. Je vais y travailler ... Maintenant, à propos de Pure virtual classes: Qu'est-ce que c'est? Une interface abstraite C ++? (classe sans état et uniquement des méthodes virtuelles pures?). Comment cette «classe virtuelle pure» m'a protégé contre le découpage? (les méthodes virtuelles pures feront que l'instanciation ne se compilera pas, mais l'affectation de copie le fera et l'affectation de déplacement le sera aussi IIRC).
paercebal
About the compiler: Nous sommes d'accord, mais nos compilateurs n'entrent pas dans mon champ de responsabilité (pas que cela m'empêche de commentaires sournois ... :-p ...). Je ne divulguerai pas les détails (j'aimerais pouvoir le faire) mais cela est lié à des raisons internes (comme les suites de tests) et à des raisons externes (par exemple, le client se connectant à nos bibliothèques). En fin de compte, changer la version du compilateur (ou même le patcher) n'est PAS une opération triviale. Sans parler de remplacer un compilateur cassé par un gcc récent.
paercebal
@paercebal merci pour vos commentaires; sur les classes virtuelles pures, vous avez raison, cela ne résout pas toutes vos contraintes (je supprimerai cette partie). Je comprends la partie "erreur d'interface" et l'utilité de détecter les erreurs au moment de la compilation: mais vous avez demandé si vous êtes paranoïaque, et je pense que l'approche rationnelle consiste à équilibrer votre besoin de contrôles statiques avec la probabilité que l'erreur se produise. Bonne chance avec le compilateur :)
coredump
1
Je ne suis pas fan des macros, surtout parce que les directives s'adressent (également) aux juniors hommes. Trop souvent, j'ai vu des gens à qui l'on avait donné des outils «pratiques» pour les appliquer à l'aveuglette et qui ne comprenaient jamais ce qui se passait réellement. Ils en viennent à croire que ce que fait la macro doit être la chose la plus compliquée parce que leur patron a pensé qu'il serait trop difficile pour eux de le faire eux-mêmes. Et parce que la macro n'existe que dans votre entreprise, ils ne peuvent même pas la rechercher sur le Web alors que pour une directive documentée, quelles fonctions de membre déclarer et pourquoi, ils le pourraient.
5gon12eder
2

Le problème du découpage en tranches 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 plutôt de le contourner 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 de membre de clone virtuel. Je pense que le problème le plus profond avec 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::functionest 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érédité est la classe de base du mal (version courte)

Polymorphisme basé sur la sémantique et les concepts de valeur (version longue; plus facile à suivre, mais le son n'est pas excellent)

D Drmmr
la source
0

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 considéreront comme étrange et mystérieuse, je suggérerais 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 bonne partie 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.

Ben
la source