La création de sous-classes pour des cas spécifiques est-elle une mauvaise pratique?

37

Considérez le design suivant

public class Person
{
    public virtual string Name { get; }

    public Person (string name)
    {
        this.Name = name;
    }
}

public class Karl : Person
{
    public override string Name
    {
        get
        {
            return "Karl";
        }
    }
}

public class John : Person
{
    public override string Name
    {
        get
        {
            return "John";
        }
    }
}

Pensez-vous qu'il y a quelque chose qui ne va pas ici? Pour moi, les classes Karl et John doivent être des instances plutôt que des classes car elles sont exactement les mêmes que:

Person karl = new Person("Karl");
Person john = new Person("John");

Pourquoi devrais-je créer de nouvelles classes lorsque les instances sont suffisantes? Les classes n'ajoutent rien à l'instance.

Ignacio Soler Garcia
la source
17
Malheureusement, vous le trouverez dans beaucoup de code de production. Nettoyez-le quand vous le pouvez.
Adam Zuckerman
14
C'est un excellent design - si un développeur veut se rendre indispensable pour tout changement dans les données de l'entreprise et n'a aucun problème à être disponible 24h / 24 et 7j / 7 ;-)
Doc Brown
1
Voici une sorte de question connexe dans laquelle le PO semble croire le contraire de la sagesse conventionnelle. programmers.stackexchange.com/questions/253612/…
Dunk le
11
Concevez vos classes en fonction du comportement et non des données. Pour moi, les sous-classes n’ajoutent aucun nouveau comportement à la hiérarchie.
Songo
2
Ceci est largement utilisé avec les sous-classes anonymes (telles que les gestionnaires d'événements) en Java avant l'arrivée de lambdas dans Java 8 (c'est la même chose avec une syntaxe différente).
Marczellm

Réponses:

77

Il n'est pas nécessaire d'avoir des sous-classes spécifiques pour chaque personne.

Vous avez raison, ce devraient être des exemples à la place.

But des sous-classes: étendre les classes parentes

Les sous-classes sont utilisées pour étendre les fonctionnalités fournies par la classe parente. Par exemple, vous pouvez avoir:

  • Une classe parente Batteryqui peut Power()quelque chose et peut avoir une Voltagepropriété,

  • Et une sous-classe RechargeableBattery, qui peut hériter Power()et Voltage, mais peut aussi être Recharge()d.

Notez que vous pouvez passer une instance de RechargeableBatteryclasse en tant que paramètre à toute méthode qui accepte un Batteryen tant qu'argument. C'est ce qu'on appelle le principe de substitution de Liskov , l'un des cinq principes SOLID. De même, dans la réalité, si mon lecteur MP3 accepte deux piles AA, je peux les remplacer par deux piles AA rechargeables.

Notez qu'il est parfois difficile de déterminer si vous devez utiliser un champ ou une sous-classe pour représenter une différence entre deux éléments. Par exemple, si vous devez manipuler des piles AA, AAA et 9 Volts, créez-vous trois sous-classes ou utilisez-vous une énumération? «Remplacer la sous-classe par des champs» dans Refactoring par Martin Fowler, page 233, peut vous donner quelques idées et vous expliquer comment passer d'une base à l'autre.

Dans votre exemple, Karlet Johnne prolongez rien, ils ne fournissent aucune valeur supplémentaire: vous pouvez avoir exactement les mêmes fonctionnalités en utilisant Persondirectement la classe. Avoir plus de lignes de code sans valeur supplémentaire n'est jamais bon.

Un exemple d'analyse de rentabilisation

Que pourrait-il être une analyse de rentabilisation où il serait logique de créer une sous-classe pour une personne spécifique?

Disons que nous construisons une application qui gère les personnes travaillant dans une entreprise. L'application gère également les autorisations. Helen, le comptable, ne peut donc pas accéder au référentiel SVN, mais Thomas et Mary, deux programmeurs, ne peuvent pas accéder aux documents liés à la comptabilité.

Jimmy, le grand patron (fondateur et PDG de la société) a des privilèges très spécifiques que personne ne possède. Il peut, par exemple, arrêter tout le système ou licencier une personne. Vous avez eu l'idée.

Le modèle le plus pauvre pour une telle application consiste à avoir des classes telles que:

          Le diagramme de classes montre les classes Helen, Thomas, Mary et Jimmy et leur parent commun: la classe Person abstraite.

parce que la duplication de code va se produire très rapidement. Même dans l'exemple très basique de quatre employés, vous dupliquez le code entre les classes Thomas et Mary. Cela vous poussera à créer une classe parent commune Programmer. Étant donné que vous pouvez avoir plusieurs comptables, vous allez probablement créer une Accountantclasse également.

          Le diagramme de classes montre une personne abstraite avec trois enfants: comptable abstraite, programmeur abstraite et Jimmy.  Le comptable a une sous-classe Helen et le programmeur a deux sous-classes: Thomas et Mary.

Maintenant, vous remarquez qu’avoir la classe Helenn’est pas très utile, ainsi que de garder Thomaset Mary: la plupart de votre code fonctionne de toute façon au niveau supérieur, au niveau des comptables, des programmeurs et de Jimmy. Le serveur SVN ne se soucie pas de savoir si c'est Thomas ou Mary qui doit accéder au journal. Il lui suffit de savoir s'il s'agit d'un programmeur ou d'un comptable.

if (person is Programmer)
{
    this.AccessGranted = true;
}

Donc, vous finissez par supprimer les classes que vous n'utilisez pas:

          Le diagramme de classes contient les sous-classes Accountant, Programmer et Jimmy avec sa classe parente commune: Abstract Person.

"Mais je peux garder Jimmy en l'état, car il n'y aurait toujours qu'un seul PDG, un seul grand patron - Jimmy", vous pensez. De plus, Jimmy est beaucoup utilisé dans votre code, qui ressemble en réalité davantage à ceci, et non à l'exemple précédent:

if (person is Jimmy)
{
    this.GiveUnrestrictedAccess(); // Because Jimmy should do whatever he wants.
}
else if (person is Programmer)
{
    this.AccessGranted = true;
}

Le problème avec cette approche est que Jimmy peut toujours être frappé par un bus et qu'il y aurait un nouveau PDG. Ou bien le conseil d'administration peut décider que Mary est si géniale qu'elle devrait devenir un nouveau PDG, et que Jimmy serait rétrogradé au poste de vendeur. Vous devez donc maintenant parcourir tout votre code et tout changer.

Arseni Mourzenko
la source
6
@ DocBrown: Je suis souvent surpris de voir que les réponses courtes qui ne sont pas fausses mais pas assez profondes ont un score beaucoup plus élevé que les réponses qui expliquent en profondeur le sujet. Trop de gens n'aiment probablement pas beaucoup lire, alors les réponses très courtes leur semblent plus attrayantes. Malgré tout, j'ai modifié ma réponse pour fournir au moins une explication.
Arseni Mourzenko
3
Les réponses courtes ont tendance à être affichées en premier. Les articles précédents obtiennent plus de votes.
Tim B
1
@ TimB: Je commence généralement par les réponses de taille moyenne, puis je les édite pour qu'elles soient très complètes (voici un exemple , étant donné qu'il y avait probablement une quinzaine de révisions, quatre seulement étant montrées). J'ai remarqué que plus la réponse prend du temps, moins elle reçoit de votes positifs. une question similaire qui reste courte attire plus de votes positifs.
Arseni Mourzenko
D'accord avec @DocBrown. Par exemple, une bonne réponse dirait quelque chose comme "sous-classe pour le comportement , pas pour le contenu", et ferait référence à une référence que je ne ferai pas dans ce commentaire.
user949300
2
Au cas où ce ne serait pas clair dans le dernier paragraphe: Même si vous n'avez qu'une seule personne avec un accès illimité, vous devez tout de même faire de cette personne une instance d'une classe distincte qui implémente l'accès sans restriction. La recherche de la personne elle-même entraîne une interaction code-données et peut ouvrir toute une boîte de Pandore. Par exemple, que se passe-t-il si le conseil d'administration décide de nommer un triumvirat en tant que PDG? Si vous n'avez pas de classe "Sans restriction", vous ne devez pas simplement mettre à jour le PDG existant, mais vous devez également ajouter 2 contrôles supplémentaires pour les autres membres. Si vous l'avez, vous les définissez comme "Sans restriction".
Nzall
15

Il est idiot d'utiliser ce type de structure de classe uniquement pour faire varier les détails, qui doivent évidemment être des champs réglables sur une instance. Mais cela est particulier à votre exemple.

Créer des Personclasses différentes est probablement une mauvaise idée. Vous devez modifier votre programme et recompiler chaque fois qu'une nouvelle personne entre dans votre domaine. Cependant, cela ne signifie pas qu'hériter d'une classe existante pour qu'une chaîne différente soit renvoyée quelque part n'est parfois pas utile.

La différence réside dans la manière dont vous vous attendez à ce que les entités représentées par cette classe varient. Les gens sont presque toujours supposés aller et venir, et presque toutes les applications sérieuses traitant avec des utilisateurs devraient pouvoir ajouter et supprimer des utilisateurs au moment de l'exécution. Mais si vous modélisez des algorithmes de chiffrement différents, prendre en charge un nouvel algorithme est probablement un changement majeur, et il est donc logique d’inventer une nouvelle classe dont la myName()méthode retourne une chaîne différente (et dont la perform()méthode fait probablement autre chose).

Kilian Foth
la source
12

Pourquoi devrais-je créer de nouvelles classes lorsque les instances sont suffisantes?

Dans la plupart des cas, vous ne le feriez pas. Votre exemple est vraiment un bon cas où ce comportement n'ajoute pas de valeur réelle.

Cela viole également le principe Open Closed , dans la mesure où les sous-classes ne sont fondamentalement pas une extension mais modifient le fonctionnement interne du parent. De plus, le constructeur public du parent est maintenant irritant dans les sous-classes et l’API est donc devenu moins compréhensible.

Cependant, si vous ne disposez que d'une ou deux configurations spéciales souvent utilisées dans le code, il est parfois plus pratique et plus rapide de sous-classer un parent doté d'un constructeur complexe. Dans de tels cas particuliers, je ne vois aucun inconvénient à une telle approche. Appelons cela constructeur currying j / k

Faucon
la source
Je ne suis pas sûr d'être d'accord avec le paragraphe OCP. Si une classe expose un créateur de propriété à ses descendants, alors, en supposant qu'elle respecte de bons principes de conception, la propriété ne devrait pas faire partie de son fonctionnement interne.
Ben Aaronson
@BenAaronson Tant que le comportement de la propriété elle-même n'est pas modifié, vous ne violez pas OCP, mais ici, c'est le cas. Bien sûr, vous pouvez utiliser cette propriété dans son fonctionnement interne. Mais vous ne devriez pas modifier le comportement existant! Vous devez uniquement étendre le comportement, pas le modifier par héritage. Bien sûr, il y a des exceptions à chaque règle.
Falcon
1
Donc, toute utilisation de membres virtuels est une violation OCP?
Ben Aaronson
3
@ Falcon Il n'est pas nécessaire de dériver une nouvelle classe simplement pour éviter les appels de constructeur complexes et répétitifs. Il suffit de créer des usines pour les cas communs et de paramétrer les petites variations.
Keen
@ BenAaronson Je dirais que le fait d'avoir des membres virtuels ayant une implémentation réelle est une telle violation. Les membres abstraits, ou des méthodes qui ne font rien, seraient des points d'extension valides. En gros, lorsque vous examinez le code d'une classe de base, vous savez qu'elle sera exécutée telle quelle, au lieu que chaque méthode non finale puisse être remplacée par quelque chose de complètement différent. Ou pour le dire différemment: les manières par lesquelles une classe héréditaire peut affecter le comportement de la classe de base seront explicites et obligées d'être "additives".
Millimoose
8

Si telle est l'étendue de la pratique, alors je conviens que c'est une mauvaise pratique.

Si John et Karl ont des comportements différents, les choses changent un peu. Il se peut que John personait une méthode cleanRoom(Room room)et qu'il nettoie très efficacement, mais Karl ne le fait pas et ne nettoie pas beaucoup la pièce.

Dans ce cas, il serait logique de les placer dans leur propre sous-classe avec le comportement défini. Il existe de meilleurs moyens d’obtenir la même chose (par exemple, une CleaningBehaviorclasse), mais au moins, cette façon n’est pas une violation terrible des principes de OO.

CorsiKa
la source
Pas de comportement différent, juste des données différentes. Merci.
Ignacio Soler Garcia
6

Dans certains cas, vous savez qu'il n'y aura qu'un nombre défini d'instances et qu'il serait alors OK (bien que moche à mon avis) d'utiliser cette approche. Il est préférable d’utiliser une énumération si la langue le permet.

Exemple Java:

public enum Person
{
    KARL("Karl"),
    JOHN("John")

    private final String name;

    private Person(String name)
    {
        this.name = name;
    }

    public String getName()
    {
        return this.name;
    }
}
Adam
la source
6

Beaucoup de gens ont déjà répondu. Je pensais donner mon point de vue personnel.


Il était une fois, j'ai travaillé (et je le fais encore) sur une application qui crée de la musique.

L'application avait un résumé Scaleclasse avec plusieurs sous - classes: CMajor, DMinor, etc. Scaleavait l' air quelque chose comme ceci:

public abstract class Scale {
    protected Note[] notes;
    public Scale() {
        loadNotes();
    }
    // .. some other stuff ommited
    protected abstract void loadNotes(); /* subclasses put notes in the array
                                          in this method. */
}

Les générateurs de musique ont travaillé avec une Scaleinstance spécifique pour générer de la musique. L'utilisateur sélectionnerait une balance dans une liste pour générer de la musique.

Un jour, une idée géniale m'est venue à l'esprit: pourquoi ne pas permettre à l'utilisateur de créer ses propres balances? L'utilisateur sélectionnerait des notes dans une liste, appuierait sur un bouton et une nouvelle échelle serait ajoutée à la liste des échelles disponibles.

Mais je n'ai pas pu faire ça. En effet, toutes les échelles sont déjà définies au moment de la compilation, car elles sont exprimées sous forme de classes. Puis ça m'a frappé:

Il est souvent intuitif de penser en termes de «superclasses et sous-classes». Presque tout peut être exprimé à travers ce système: superclasse Personet sous John- classes et Mary; superclasse Caret sous Volvo- classes et Mazda; superclasse Missileet sous SpeedRocked- classes , LandMineet TrippleExplodingThingy.

C'est très naturel de penser de cette façon, surtout pour la personne relativement nouvelle en OO.

Mais nous devrions toujours nous rappeler que les classes sont des modèles et que les objets sont du contenu versé dans ces modèles . Vous pouvez verser le contenu que vous voulez dans le modèle, créant ainsi d'innombrables possibilités.

Ce n'est pas le travail de la sous-classe de remplir le modèle. C'est le travail de l'objet. Le travail de la sous-classe consiste à ajouter une fonctionnalité réelle ou à développer le modèle .

Et c’est pourquoi j’aurais dû créer une Scaleclasse concrète , avec un Note[]champ, et laisser les objets remplir ce modèle ; éventuellement par le constructeur ou quelque chose. Et finalement, alors j'ai fait.

Chaque fois que vous concevez un modèle dans une classe (par exemple, un Note[]membre vide qui doit être rempli ou un String namechamp auquel une valeur doit être affectée), rappelez-vous qu'il appartient aux objets de cette classe de remplir le modèle ( ou éventuellement ceux qui créent ces objets). Les sous-classes sont conçues pour ajouter des fonctionnalités et non pour remplir des modèles.


Vous pourriez être tenté de créer une "super classe Person, des sous John- classes et Mary" un système, comme vous l'avez fait, car vous aimez la formalité que cela vous procure.

De cette façon, vous pouvez simplement dire Person p = new Mary(), au lieu de Person p = new Person("Mary", 57, Sex.FEMALE). Cela rend les choses plus organisées et plus structurées. Mais comme nous l’avons dit, créer une nouvelle classe pour chaque combinaison de données n’est pas une bonne approche, car cela alourdit le code pour rien et vous limite en termes de capacités d’exécution.

Voici donc une solution: utiliser une usine de base, voire statique. Ainsi:

public final class PersonFactory {
    private PersonFactory() { }

    public static Person createJohn(){
        return new Person("John", 40, Sex.MALE);
    }

    public static Person createMary(){
        return new Person("Mary", 57, Sex.FEMALE);
    }

    // ...
}

De cette façon, vous pouvez facilement utiliser les "préréglages" du "venir avec le programme", comme suit:, Person mary = PersonFactory.createMary()mais vous vous réservez également le droit de concevoir de nouvelles personnes de manière dynamique, par exemple dans le cas où vous souhaitez autoriser l'utilisateur à le faire. . Par exemple:

// .. requesting the user for input ..
String name = // user input
int age = // user input
Sex sex = // user input, interpreted
Person newPerson = new Person(name, age, sex);

Ou même mieux: faites quelque chose comme ça:

public final class PersonFactory {
    private PersonFactory() { }

    private static Map<String, Person> persons = new HashMap<>();
    private static Map<String, PersonData> personBlueprints = new HashMap<>();

    public static void addPerson(Person person){
        persons.put(person.getName(), person);
    }

    public static Person getPerson(String name){
        return persons.get(name);
    }

    public static Person createPerson(String blueprintName){
        PersonData data = personBlueprints.get(blueprintName);
        return new Person(data.name, data.age, data.sex);
    }

    // .. or, alternative to the last method
    public static Person createPerson(String personName){
        Person blueprint = persons.get(personName);
        return new Person(blueprint.getName(), blueprint.getAge(), blueprint.getSex());
    }

}

public class PersonData {
    public String name;
    public int age;
    public Sex sex;

    public PersonData(String name, int age, Sex sex){
        this.name = name;
        this.age = age;
        this.sex = sex;
    }
}

Je me suis emporté. Je pense que vous avez l'idée.


Les sous-classes ne sont pas destinées à renseigner les modèles définis par leurs super-classes. Les sous-classes sont destinées à ajouter des fonctionnalités . Les objets sont destinés à remplir les modèles, c'est à quoi ils servent.

Vous ne devriez pas créer une nouvelle classe pour toutes les combinaisons possibles de données. (Tout comme je n'aurais pas dû créer une nouvelle Scalesous-classe pour toutes les combinaisons possibles de Notes).

Her'es une directive: chaque fois que vous créez une nouvelle sous-classe, demandez-vous si elle ajoute une nouvelle fonctionnalité qui n'existe pas dans la super-classe. Si la réponse à cette question est "non", vous pourriez peut-être essayer de "remplir le modèle" de la superclasse, auquel cas il suffit de créer un objet. (Et éventuellement une Factory avec des 'presets', pour rendre la vie plus facile).

J'espère que ça t'as aidé.

Aviv Cohn
la source
C'est un excellent exemple, merci beaucoup.
Ignacio Soler Garcia
@SoMoS Content d'avoir pu aider.
Aviv Cohn
2

Ceci est une exception aux "devrait être des instances" ici - bien que légèrement extrême.

Si vous souhaitez que le compilateur impose des autorisations pour accéder aux méthodes en fonction de Karl ou de John (dans cet exemple) plutôt que de demander à l'implémentation de la méthode de vérifier quelle instance a été transmise, vous pouvez utiliser différentes classes. En appliquant différentes classes plutôt que l'instanciation, vous effectuez des vérifications de différenciation entre 'Karl' et 'John' au moment de la compilation plutôt que de l'exécution, ce qui peut être utile, en particulier dans un contexte de sécurité où le compilateur est plus fiable que l'exécution. contrôles de code des bibliothèques externes, par exemple.

David Scholefield
la source
2

La duplication du code est considérée comme une mauvaise pratique .

Ceci est au moins partiellement dû au fait que les lignes de codes et donc la taille de la base de code sont corrélées aux coûts de maintenance et aux défauts .

Voici la logique de bon sens pertinente pour moi sur ce sujet particulier:

1) Si je devais changer cela, combien d’endroits devrais-je visiter? Moins c'est mieux ici.

2) Y a-t-il une raison pour que cela se fasse de cette façon? Dans votre exemple - cela ne pourrait pas être le cas - mais dans certains exemples, il pourrait y avoir une raison de le faire. Je peux penser tout d’abord à certains frameworks, comme les premiers Grails, où l’héritage ne jouait pas nécessairement avec certains plugins de GORM. Rares.

3) Est-ce plus propre et plus facile à comprendre de cette façon? Encore une fois, ce n'est pas dans votre exemple - mais il existe certains modèles d'héritage qui peuvent sembler être plus difficiles à gérer, car les classes séparées sont en fait plus faciles à gérer (certaines utilisations des modèles de commande ou de stratégie vous viennent à l'esprit).

dcgregorya
la source
1
que ce soit mauvais ou pas n'est pas gravé dans la pierre. Il existe par exemple des scénarios dans lesquels la surcharge supplémentaire liée à la création d'objet et aux appels de méthodes peut être si préjudiciable aux performances que l'application ne répond plus aux spécifications.
Jwenting
Voir le point 2. Bien sûr, il y a des raisons pour lesquelles, à l'occasion. C'est pourquoi vous devez demander avant d'éliminer le code de quelqu'un.
dcgregorya
0

Il existe un cas d'utilisation: former une référence à une fonction ou à une méthode en tant qu'objet normal.

Vous pouvez voir cela souvent dans le code de l'interface graphique Java:

ActionListener a = new ActionListener() {
    public void actionPerformed(ActionEvent arg0) {
        /* ... */
    }
};

Le compilateur vous aide ici en créant une nouvelle sous-classe anonyme.

Simon Richter
la source