Interface et héritage: le meilleur des deux mondes?

10

J'ai «découvert» les interfaces et j'ai commencé à les aimer. La beauté d'une interface est qu'elle est un contrat, et tout objet qui remplit ce contrat peut être utilisé partout où cette interface est requise.

Le problème avec une interface est qu'elle ne peut pas avoir d'implémentation par défaut, ce qui nuit aux propriétés banales et bat DRY. C'est également bon, car cela maintient la mise en œuvre et le système découplés. L'hérédité, en revanche, maintient un couplage plus étroit et a le potentiel de briser l'encapsulation.

Cas 1 (héritage avec des membres privés, bonne encapsulation, étroitement couplé)

class Employee
{
int money_earned;
string name;

public:
 void do_work(){money_earned++;};
 string get_name(return name;);
};


class Nurse : public Employee: 
{
   public:
   void do_work(/*do work. Oops, can't update money_earned. Unaware I have to call superclass' do_work()*/);

};

void HireNurse(Nurse *n)
{
   nurse->do_work();
)

Cas 2 (juste une interface)

class IEmployee
{
     virtual void do_work()=0;
     virtual string get_name()=0;
};

//class Nurse implements IEmployee.
//But now, for each employee, must repeat the get_name() implementation,
//and add a name member string, which breaks DRY.

Cas 3: (le meilleur des deux mondes?)

Similaire au cas 1 . Cependant, imaginez que (hypothétiquement) C ++ n'autorise pas les méthodes de substitution, sauf les méthodes qui sont purement virtuelles .

Ainsi, dans le cas 1 , la substitution de do_work () entraînerait une erreur de compilation. Pour résoudre ce problème, nous définissons do_work () comme purement virtuel et ajoutons une méthode distincte increment_money_earned (). Par exemple:

class Employee
{
int money_earned;
string name;

public:
 virtual void do_work()=0;
 void increment_money_earned(money_earned++;);
 string get_name(return name;);
};


class Nurse : public Employee: 
{
   public:
   void do_work(/*do work*/ increment_money_earned(); ); .
};

Mais même cela a des problèmes. Et si dans 3 mois, Joe Coder crée un médecin employé, mais il oublie d'appeler increment_money_earned () dans do_work ()?


La question:

  • Le cas 3 est-il supérieur au cas 1 ? Est-ce parce qu'il s'agit d'une «meilleure encapsulation» ou d'un «couplage plus lâche», ou pour une autre raison?

  • Le cas 3 est-il supérieur au cas 2 car il est conforme à SEC?

MustafaM
la source
2
... réinventez-vous des classes abstraites ou quoi?
ZJR

Réponses:

10

Une façon de résoudre le problème de l'oubli d'appeler la superclasse est de redonner le contrôle à la superclasse! J'ai re-jigger votre premier exemple pour montrer comment (et le faire compiler;)). Oh, je suppose aussi que do_work()dans Employeeétait censé être virtualdans votre premier exemple.

#include <string>

using namespace std;

class Employee
{
    int money_earned;
    string name;
    virtual void on_do_work() {}

    public:
        void do_work() { money_earned++; on_do_work(); }
        string get_name() { return name; }
};

class Nurse : public Employee
{
    void on_do_work() { /* do more work. Oh, and I don't have to call do_work()! */ }
};

void HireNurse(Nurse* nurse)
{
    nurse->do_work();
}

Maintenant, do_work()ne peut pas être annulé. Si vous voulez l'étendre, vous devez le faire via on_do_work()qui do_work()a le contrôle.

Ceci, bien sûr, peut également être utilisé avec l'interface de votre deuxième exemple s'il l' Employeeétend. Donc, si je vous comprends bien, je pense que cela rend ce cas 3 mais sans avoir à utiliser d'hypothétique C ++! C'est SEC et il a une forte encapsulation.

Gyan aka Gary Buyn
la source
3
Et c'est le modèle de conception connu sous le nom de "méthode de modèle" ( en.wikipedia.org/wiki/Template_method_pattern ).
Joris Timmermans
Oui, c'est conforme au cas 3. Cela semble prometteur. Examinera en détail. C'est aussi une sorte de système d'événements. Y a-t-il un nom pour ce «modèle»?
MustafaM
@MadKeithV êtes-vous sûr qu'il s'agit de la «méthode du modèle»?
MustafaM
@illmath - oui, c'est une méthode publique non virtuelle qui délègue des parties de ses détails d'implémentation à des méthodes virtuelles protégées / privées.
Joris Timmermans
@illmath Je n'y avais pas pensé auparavant comme méthode de modèle mais je crois que c'est un exemple de base. Je viens de trouver cet article que vous voudrez peut-être lire où l'auteur pense qu'il mérite son propre nom: Idiome d'interface non virtuelle
Gyan aka Gary Buyn
1

Le problème avec une interface est qu'elle ne peut pas avoir d'implémentation par défaut, ce qui nuit aux propriétés banales et bat DRY.

À mon avis, les interfaces ne devraient avoir que des méthodes pures - sans implémentation par défaut. Cela ne rompt en rien le principe DRY, car les interfaces montrent comment accéder à une entité. Juste pour les références, je regarde l'explication DRY ici :
"Chaque élément de connaissance doit avoir une représentation unique, sans ambiguïté et faisant autorité au sein d'un système."

D'un autre côté, le SOLID vous indique que chaque classe doit avoir une interface.

Le cas 3 est-il supérieur au cas 1? Est-ce à cause d'une «meilleure encapsulation» ou d'un «couplage plus lâche», ou pour une autre raison?

Non, le cas 3 n'est pas supérieur au cas 1. Vous devez vous décider. Si vous souhaitez avoir une implémentation par défaut, faites-le. Si vous voulez une méthode pure, allez-y.

Et si dans 3 mois, Joe Coder crée un médecin employé, mais il oublie d'appeler increment_money_earned () dans do_work ()?

Ensuite, Joe Coder devrait obtenir ce qu'il mérite pour ignorer les tests unitaires ayant échoué. Il a testé cette classe, non? :)

Quel cas est le meilleur pour un projet logiciel qui pourrait avoir 40 000 lignes de code?

Une taille unique ne convient pas à tous. Il est impossible de dire lequel est le meilleur. Il y a des cas où l'un irait mieux que l'autre.

Vous devriez peut-être apprendre certains modèles de conception au lieu d'essayer d'en inventer quelques-uns.


Je viens de réaliser que vous recherchez un modèle de conception d' interface non virtuelle , car c'est à cela que ressemble votre classe de cas 3.

BЈовић
la source
Merci pour le commentaire. J'ai mis à jour le cas 3 pour clarifier mon intention.
MustafaM
1
Je vais devoir vous -1 ici. Il n'y a aucune raison de dire que toutes les interfaces doivent être pures ou que toutes les classes doivent hériter d'une interface.
DeadMG
@DeadMG ISP
BЈовић
@VJovic: Il y a une grande différence entre SOLID et "Tout doit hériter d'une interface".
DeadMG
«Une taille unique ne convient pas à tous» et «apprendre certains modèles de conception» sont corrects - le reste de votre réponse viole votre propre suggestion qu'une taille unique ne convient pas à tous.
Joris Timmermans
0

Les interfaces peuvent avoir des implémentations par défaut en C ++. Rien ne dit qu'une implémentation par défaut d'une fonction ne dépend pas uniquement d'autres membres virtuels (et arguments), donc n'augmente aucun type de couplage.

Pour le cas 2, DRY remplace ici. L'encapsulation existe pour protéger votre programme du changement, des différentes implémentations, mais dans ce cas, vous n'avez pas d'implémentations différentes. Donc encapsulation YAGNI.

En fait, les interfaces d'exécution sont généralement considérées comme inférieures à leurs équivalents au moment de la compilation. Dans le cas de la compilation, vous pouvez avoir à la fois le cas 1 et le cas 2 dans le même paquet, sans parler de ses nombreux autres avantages. Ou même au moment de l'exécution, vous pouvez simplement faire Employee : public IEmployeepour effectivement le même avantage. Il existe de nombreuses façons de gérer de telles choses.

Case 3: (best of both worlds?)

Similar to Case 1. However, imagine that (hypothetically)

J'ai arrêté de lire. YAGNI. Le C ++ est ce qu'est le C ++, et le comité des normes ne mettra jamais en œuvre un tel changement, pour d'excellentes raisons.

DeadMG
la source
Vous dites "vous n'avez pas d'implémentations différentes". Mais je fais. J'ai une implémentation d'infirmière d'employé et je pourrais avoir d'autres implémentations plus tard (un médecin, un concierge, etc.). J'ai mis à jour le cas 3 pour clarifier ce que je voulais dire.
MustafaM
@illmath: Mais vous n'avez aucune autre implémentation de get_name. Toutes vos implémentations proposées partageraient la même implémentation de get_name. De plus, comme je l'ai dit, il n'y a aucune raison de choisir, vous pouvez avoir les deux. De plus, le cas 3 ne vaut absolument rien. Vous pouvez remplacer les virtuels non purs, alors oubliez une conception où vous ne pouvez pas.
DeadMG
Non seulement les interfaces peuvent avoir des implémentations par défaut en C ++, elles peuvent avoir des implémentations par défaut et être toujours abstraites! ie void virtuel IMethod () = 0 {std :: cout << "Ni!" << std :: endl; }
Joris Timmermans
@MadKeithV: Je ne pense pas que vous puissiez les définir en ligne, mais le point est toujours le même.
DeadMG
@MadKeith: Comme si Visual Studio avait jamais été une représentation particulièrement précise de Standard C ++.
DeadMG
0

Le cas 3 est-il supérieur au cas 1? Est-ce à cause d'une «meilleure encapsulation» ou d'un «couplage plus lâche», ou pour une autre raison?

D'après ce que je vois dans votre implémentation, votre implémentation de cas 3 nécessite une classe abstraite qui peut implémenter des méthodes virtuelles pures qui peuvent ensuite être modifiées dans la classe dérivée. Le cas 3 serait préférable car la classe dérivée peut modifier l'implémentation de do_work au fur et à mesure des besoins et toutes les instances dérivées appartiendraient essentiellement au type abstrait de base.

Quel cas est le meilleur pour un projet logiciel qui pourrait avoir 40 000 lignes de code.

Je dirais que cela dépend uniquement de la conception de votre implémentation et de l'objectif que vous souhaitez atteindre. La classe abstraite et les interfaces sont implémentées en fonction du problème à résoudre.

Modifier sur la question

Et si dans 3 mois, Joe Coder crée un médecin employé, mais il oublie d'appeler increment_money_earned () dans do_work ()?

Des tests unitaires peuvent être effectués pour vérifier si chaque classe confirme le comportement attendu. Donc, si des tests unitaires appropriés sont appliqués, les bogues peuvent être évités lorsque Joe Coder implémente la nouvelle classe.

Karthik Sreenivasan
la source
0

L'utilisation d'interfaces n'interrompt DRY que si chaque implémentation est une copie de toutes les autres. Vous pouvez résoudre ce dilemme en appliquant à la fois l'interface et l' héritage, mais dans certains cas, vous souhaiterez peut-être implémenter la même interface sur un certain nombre de classes, mais varier le comportement dans chacune des classes, et cela restera conforme au principe de SEC. Que vous choisissiez d'utiliser l'une des 3 approches que vous avez décrites se résume aux choix que vous devez faire pour appliquer la meilleure technique pour correspondre à une situation donnée. D'un autre côté, vous constaterez probablement qu'avec le temps, vous utilisez davantage les interfaces et n'appliquez l'héritage que là où vous souhaitez supprimer la répétition. Cela ne veut pas dire que c'est le seul raison de l'héritage, mais qu'il est préférable de minimiser l'utilisation de l'héritage pour vous permettre de garder vos options ouvertes si vous trouvez que votre conception doit changer plus tard, et si vous souhaitez minimiser l'impact sur les classes descendantes des effets qu'un changement introduirait dans une classe parent.

S.Robins
la source