Quel code doit être inclus dans une classe abstraite?

10

Ces derniers temps, je m'inquiète de l'utilisation de classes abstraites.

Parfois, une classe abstraite est créée à l'avance et fonctionne comme un modèle de fonctionnement des classes dérivées. Cela signifie, plus ou moins, qu'ils fournissent des fonctionnalités de haut niveau mais omettent certains détails à implémenter par les classes dérivées. La classe abstraite définit la nécessité de ces détails en mettant en place des méthodes abstraites. Dans de tels cas, une classe abstraite fonctionne comme un plan directeur, une description de haut niveau de la fonctionnalité ou comme vous voulez l'appeler. Il ne peut pas être utilisé seul, mais doit être spécialisé pour définir les détails qui ont été omis de la mise en œuvre de haut niveau.

D'autres fois, il arrive que la classe abstraite soit créée après la création de certaines classes "dérivées" (puisque la classe parent / abstraite n'est pas là, elles n'ont pas encore été dérivées, mais vous savez ce que je veux dire). Dans ces cas, la classe abstraite est généralement utilisée comme un endroit où vous pouvez mettre tout type de code commun que contiennent les classes dérivées actuelles.

Ayant fait les observations ci-dessus, je me demande laquelle de ces deux affaires devrait être la règle. Devrait-on faire bouillir des détails dans la classe abstraite simplement parce qu'ils se trouvent actuellement communs à toutes les classes dérivées? Un code commun qui ne fait pas partie d'une fonctionnalité de haut niveau devrait-il être présent?

Le code qui peut ne pas avoir de sens pour la classe abstraite elle-même devrait-il être là simplement parce qu'il se trouve être commun aux classes dérivées?

Permettez-moi de donner un exemple: la classe abstraite A a une méthode a () et une méthode abstraite aq (). La méthode aq (), dans les deux classes dérivées AB et AC, utilise une méthode b (). Faut-il déplacer b () vers A? Si oui, dans le cas où quelqu'un ne regarde que A (supposons que AB et AC ne soient pas là), l'existence de b () n'aurait pas beaucoup de sens! Est-ce une mauvaise chose? Est-ce que quelqu'un devrait pouvoir regarder dans une classe abstraite et comprendre ce qui se passe sans visiter les classes dérivées?

Pour être honnête, au moment de poser cette question, j'ai tendance à croire que l'écriture d'une classe abstraite qui a du sens sans avoir à regarder dans les classes dérivées est une question de code propre et d'architecture propre. Ι n'aime pas vraiment l'idée d'une classe abstraite qui agit comme un vidage pour tout type de code se trouve être commune dans toutes les classes dérivées.

Que pensez-vous / pratiquez-vous?

Alexandros Gougousis
la source
7
Pourquoi doit-il y avoir une "règle"?
Robert Harvey
1
Writing an abstract class that makes sense without having to look in the derived classes is a matter of clean code and clean architecture.-- Pourquoi? N'est-il pas possible qu'au cours de la conception et du développement d'une application, je découvre que plusieurs classes ont des fonctionnalités communes qui peuvent naturellement être refactorisées en une classe abstraite? Dois-je être suffisamment clairvoyant pour toujours anticiper cela avant d'écrire un code pour les classes dérivées? Si je ne parviens pas à être aussi astucieux, est-il interdit d'effectuer une telle refactorisation? Dois-je jeter mon code et recommencer?
Robert Harvey
Désolé, si j'ai été mal compris! J'essayais de dire ce que je ressens (pour le moment) comme une meilleure pratique et n'impliquant pas qu'il devrait y avoir une règle absolue. De plus, je n'implique pas que tout code appartenant à la classe abstraite ne doit être écrit qu'à l'avance. Je décrivais comment, dans la pratique, une classe abstraite se retrouve avec du code de haut niveau (qui agit comme un modèle pour les classes dérivées) ainsi que du code de bas niveau (que vous ne pouvez pas comprendre que sa convivialité ne vous regarde pas les classes dérivées).
Alexandros Gougousis
@RobertHarvey pour une classe de base publique, il vous serait interdit de regarder les classes dérivées. Pour les classes internes, cela ne fait aucune différence.
Frank Hileman

Réponses:

4

Permettez-moi de donner un exemple: la classe abstraite A a une méthode a () et une méthode abstraite aq (). La méthode aq (), dans les deux classes dérivées AB et AC, utilise une méthode b (). Faut-il déplacer b () vers A? Si oui, dans le cas où quelqu'un ne regarde que A (supposons que AB et AC ne soient pas là), l'existence de b () n'aurait pas beaucoup de sens! Est-ce une mauvaise chose? Est-ce que quelqu'un devrait pouvoir regarder dans une classe abstraite et comprendre ce qui se passe sans visiter les classes dérivées?

Ce que vous demandez, c'est où placer b(), et, dans un autre sens, la question est de savoir si Ale meilleur choix est la super classe immédiate pour ABet AC.

Il semble qu'il y ait trois choix:

  1. laisser b()dans les deux ABetAC
  2. créer une classe intermédiaire ABAC-Parentqui hérite de Aet qui introduit b(), puis est utilisée comme super classe immédiate pour ABetAC
  3. mis b()en A(ne pas savoir si une autre classe d'avenir ADvoudra b()ou non)

  1. souffre de ne pas être SEC .
  2. souffre de YAGNI .
  3. donc, cela laisse celui-ci.

Jusqu'à ce qu'une autre classe ADqui ne veuille pas se b()présente, (3) semble être le bon choix.

Au moment où un ADcadeau, nous pouvons refactoriser l'approche en (2) - après tout, c'est un logiciel!

Erik Eidt
la source
7
Il y a une 4ème option. Ne mettez b()aucune classe. Faites-en une fonction libre qui prend toutes ses données comme arguments et ayez les deux ABet ACappelez-la. Cela signifie que vous n'avez pas besoin de le déplacer ou de créer d'autres classes lorsque vous ajoutez AD.
user1118321
1
@ user1118321, excellent, belle réflexion hors des sentiers battus. Cela a un sens particulier s'il b()n'a pas besoin d'un état de longue durée d'instance.
Erik Eidt
Ce qui me dérange en (3), c'est que la classe abstraite ne décrit plus un comportement autonome. Il s'agit de morceaux de code et je ne peux pas comprendre le code de classe abstraite sans faire des allers-retours entre cela et toutes les classes dérivées.
Alexandros Gougousis
Pouvez-vous créer une base / valeur par défaut acqui appelle bau lieu d' acêtre abstraite?
Erik Eidt
3
Pour moi, la question la plus importante est: POURQUOI AB et AC utilisent tous deux la même méthode b (). Si c'est juste une coïncidence, je laisserais deux implémentations similaires dans AB et AC. Mais c'est probablement parce qu'il existe une abstraction commune pour AB et AC. Pour moi, DRY n'est pas tant une valeur en soi, mais un indice que vous avez probablement manqué une abstraction utile.
Ralf Kleberhoff
2

Une classe abstraite n'est pas censée être le dépotoir pour diverses fonctions ou données qui sont jetées dans la classe abstraite parce qu'elle est pratique.

La seule règle empirique qui semble fournir l'approche orientée objet la plus fiable et la plus extensible est « Préférez la composition à l'héritage ». Une classe abstraite est probablement mieux considérée comme une spécification d'interface qui ne contient aucun code.

Si vous avez une méthode qui est une sorte de méthode de bibliothèque qui va de pair avec la classe abstraite, une méthode qui est le moyen le plus probable d'exprimer certaines fonctionnalités ou comportements dont les classes dérivées d'une classe abstraite ont généralement besoin, alors il est logique de créer un classe entre la classe abstraite et les autres classes où cette méthode est disponible. Cette nouvelle classe fournit une instanciation particulière de la classe abstraite définissant une alternative ou un chemin d'implémentation particulier en fournissant cette méthode.

L'idée d'une classe abstraite est d'avoir un modèle abstrait de ce que les classes dérivées qui implémentent réellement la classe abstraite sont censées fournir en termes de service ou de comportement. L'héritage est facile à sur-utiliser et donc souvent les classes les plus utiles sont composées de différentes classes utilisant un modèle de mixage .

Cependant, il y a toujours des questions de changement, ce qui va changer et comment cela changera-t-il et pourquoi changera-t-il.

L'héritage peut conduire à des corps de source cassants et fragiles (voir aussi Héritage: arrêtez de l'utiliser déjà! ).

Le problème de la classe de base fragile est un problème architectural fondamental des systèmes de programmation orientés objet où les classes de base (superclasses) sont considérées comme "fragiles" car des modifications apparemment sûres d'une classe de base, lorsqu'elles sont héritées par les classes dérivées, peuvent entraîner un dysfonctionnement des classes dérivées . Le programmeur ne peut pas déterminer si un changement de classe de base est sûr simplement en examinant isolément les méthodes de la classe de base.

Richard Chambers
la source
Je suis sûr que tout concept de POO peut entraîner des problèmes s'il est mal utilisé ou surutilisé ou abusé! De plus, faire l'hypothèse que b () est une méthode de bibliothèque (par exemple une fonction pure sans autres dépendances) réduit la portée de ma question. Evitons cela.
Alexandros Gougousis
@AlexandrosGougousis Je ne suppose pas que ce b()soit une fonction pure. Ce pourrait être un fonctoid ou un modèle ou autre chose. J'utilise l'expression «méthode de bibliothèque» comme signifiant un composant, qu'il s'agisse d'une fonction pure, d'un objet COM ou de tout ce qui est utilisé pour la fonctionnalité qu'il fournit à la solution.
Richard Chambers
Désolé, si je n'ai pas été clair! J'ai mentionné la fonction pure comme l'un des nombreux exemples (vous en avez donné plus).
Alexandros Gougousis
1

Votre question suggère une approche soit des classes abstraites. Mais je pense que vous devriez les considérer comme juste un autre outil dans votre boîte à outils. Et puis la question devient: Pour quels emplois / problèmes les classes abstraites sont-elles le bon outil?

Un excellent cas d'utilisation consiste à implémenter le modèle de méthode de modèle . Vous mettez toute la logique invariante dans la classe abstraite et la logique variante dans ses sous-classes. Notez que la logique partagée en elle-même est incomplète et non fonctionnelle. La plupart du temps, il s'agit d'implémenter un algorithme, où plusieurs étapes sont toujours les mêmes, mais au moins une étape varie. Mettez cette étape comme une méthode abstraite qui est appelée à partir d'une des fonctions à l'intérieur de la classe abstraite.

Parfois, une classe abstraite est créée à l'avance et fonctionne comme un modèle de fonctionnement des classes dérivées. Cela signifie, plus ou moins, qu'ils fournissent des fonctionnalités de haut niveau mais omettent certains détails à implémenter par les classes dérivées. La classe abstraite définit la nécessité de ces détails en mettant en place des méthodes abstraites. Dans de tels cas, une classe abstraite fonctionne comme un plan directeur, une description de haut niveau de la fonctionnalité ou comme vous voulez l'appeler. Il ne peut pas être utilisé seul, mais doit être spécialisé pour définir les détails qui ont été omis de la mise en œuvre de haut niveau.

Je pense que votre premier exemple est essentiellement une description du modèle de méthode de modèle (corrigez-moi si je me trompe), donc je considérerais cela comme un cas d'utilisation parfaitement valide des classes abstraites.

D'autres fois, il arrive que la classe abstraite soit créée après la création de certaines classes "dérivées" (puisque la classe parent / abstraite n'est pas là, elles n'ont pas encore été dérivées, mais vous savez ce que je veux dire). Dans ces cas, la classe abstraite est généralement utilisée comme un endroit où vous pouvez mettre tout type de code commun que contiennent les classes dérivées actuelles.

Pour votre deuxième exemple, je pense que l'utilisation de classes abstraites n'est pas le choix optimal, car il existe des méthodes supérieures pour gérer la logique partagée et le code dupliqué. Disons que vous avez une classe abstraite A, des classes dérivées Bet Cque les deux classes dérivées partagent une certaine logique sous forme de méthode s(). Pour décider de la bonne approche pour se débarrasser de la duplication, il est important de savoir si la méthode s()fait partie de l'interface publique ou non.

Si ce n'est pas le cas (comme dans votre propre exemple concret avec méthode b()), le cas est assez simple. Il suffit de créer une classe distincte de celle-ci, en extrayant également le contexte nécessaire pour effectuer l'opération. Il s'agit d'un exemple classique de composition sur héritage . S'il y a peu ou pas de contexte nécessaire, une simple fonction d'aide pourrait déjà suffire, comme suggéré dans certains commentaires.

Si s()fait partie de l'interface publique, cela devient un peu plus délicat. Dans l'hypothèse qui s()n'a rien à voir avec Bet avec laquelle vous êtes Clié A, vous ne devriez pas mettre à l' s()intérieur A. Alors, où le mettre alors? Je plaiderais pour déclarer une interface distincte I, qui définit s(). Là encore, vous devez créer une classe distincte qui contient la logique d'implémentation s()et les deux Bet en Cdépendre.

Enfin, voici un lien vers une excellente réponse à une question intéressante sur SO qui pourrait vous aider à décider quand aller pour une interface et quand pour une classe abstraite:

Une bonne classe abstraite réduira la quantité de code qui doit être réécrite car sa fonctionnalité ou son état peuvent être partagés.

larsbe
la source
Je conviens que le fait que s () fasse partie de l'interface publique rend les choses beaucoup plus délicates. J'éviterais généralement de définir des méthodes publiques appelant d'autres méthodes publiques dans la même classe.
Alexandros Gougousis
1

Il me semble que vous manquez le point d'orientation de l'objet, à la fois logiquement et techniquement. Vous décrivez deux scénarios: regroupement du comportement commun des types dans une classe de base et polymorphisme. Ce sont deux applications légitimes d'une classe abstraite. Mais si vous devez rendre la classe abstraite ou non, cela dépend de votre modèle analytique et non des possibilités techniques.

Vous devez reconnaître un type qui n'a aucune incarnation dans le monde réel, tout en jetant les bases de types spécifiques qui existent. Exemple: un animal. Un animal n'existe pas. C'est toujours un chien ou un chat ou autre mais il n'y a pas de véritable animal. Pourtant, Animal les encadre tous.

Ensuite, vous parlez de niveaux. Les niveaux ne font pas partie de l'accord en matière d'héritage. La reconnaissance de données ou de comportements communs n'est pas non plus une approche technique qui n'aidera probablement pas. Vous devez reconnaître un stéréotype, puis insérer la classe de base. S'il n'y a pas un tel stéréotype, vous pouvez être mieux avec quelques interfaces implémentées par plusieurs classes.

Le nom de votre classe abstraite avec ses membres devrait avoir un sens. Elle doit être indépendante de toute classe dérivée, à la fois techniquement et logiquement. Comme Animal pourrait avoir une méthode abstraite Eat (qui serait polymorphe) et des propriétés abstraites booléennes IsPet et IsLifestock, qui ont du sens sans connaître les chats, les chiens ou les porcs. Toute dépendance (technique ou logique) ne doit aller que dans un sens: du descendant à la base. Les classes de base elles-mêmes ne devraient pas non plus avoir connaissance des classes descendantes.

Martin Maat
la source
Le stéréotype que vous mentionnez est plus ou moins la même chose que je veux dire lorsque je parle de fonctionnalités de niveau supérieur ou lorsque quelqu'un d'autre parle de concepts généralisés décrits par classe plus haut dans la hiérarchie (par rapport à des concepts plus spécialisés décrits par des classes inférieures dans le hiérarchie).
Alexandros Gougousis
"Les classes de base elles-mêmes ne devraient pas non plus avoir connaissance des classes descendantes." Je suis complètement d'accord avec ça. Une classe parente (abstraite ou non) doit contenir un élément de logique métier autonome qui n'a pas besoin d'être inspecté pour implémenter la classe enfant.
Alexandros Gougousis
Il y a autre chose à propos d'une classe de base connaissant ses sous-types. Chaque fois qu'un nouveau sous-type est développé, la classe de base doit être modifiée, ce qui est en arrière et viole le principe ouvert-fermé. J'ai récemment rencontré cela dans le code existant. Une classe de base avait une méthode statique qui créait des instances de sous-types en fonction de la configuration de l'application. L'intention était un modèle d'usine. Le faire de cette manière viole également SRP. Avec certains points SOLIDES, je pensais "duh, c'est évident" ou "la langue s'en occupera automatiquement" mais j'ai trouvé que les gens sont plus créatifs que je ne pouvais l'imaginer.
Martin Maat
0

Premier cas , à partir de votre question:

Dans de tels cas, une classe abstraite fonctionne comme un plan directeur, une description de haut niveau de la fonctionnalité ou comme vous voulez l'appeler.

Deuxième cas:

D'autres fois ... la classe abstraite est généralement utilisée comme un endroit où vous pouvez mettre n'importe quel type de code commun que les classes dérivées actuelles contiennent.

Ensuite, votre question :

Je me demande lequel de ces deux cas devrait être la règle. ... Un code qui pourrait ne pas avoir de sens pour la classe abstraite elle-même devrait-il être là simplement parce qu'il se trouve être commun aux classes dérivées?

OMI, vous avez deux scénarios différents, vous ne pouvez donc pas dessiner une seule règle pour décider quel design doit être appliqué.

Pour le premier cas, nous avons des choses comme le modèle de méthode de modèle déjà mentionné dans d'autres réponses.

Pour le second cas, vous avez donné l'exemple de la classe abstraite A recevant la méthode ab (); IMO, la méthode b () ne doit être déplacée que si elle a du sens pour TOUTES les autres classes dérivées. En d'autres termes, si vous le déplacez parce qu'il est utilisé à deux endroits, ce n'est probablement pas un bon choix, car demain il pourrait y avoir une nouvelle classe dérivée concrète sur laquelle b () n'a aucun sens. En outre, comme vous l'avez dit, si vous regardez la classe A séparément et que b () n'a également aucun sens dans ce contexte, alors c'est probablement un indice que ce n'est pas un bon choix de conception également.

Emerson Cardoso
la source
-1

J'ai eu exactement les mêmes questions il y a quelque temps!

Ce à quoi je suis arrivé, c'est que chaque fois que je tombe sur ce problème, je trouve qu'il y a un concept caché que je n'ai pas trouvé. Et ce concept devrait probablement être exprimé avec un objet de valeur . Vous avez un exemple très abstrait, donc je crains de ne pas pouvoir démontrer ce que je veux dire en utilisant votre code. Mais voici un cas de ma propre pratique. J'avais deux classes qui représentaient un moyen d'envoyer une demande à une ressource externe et d'analyser la réponse. Il y avait des similitudes dans la façon dont les demandes ont été formulées et comment les réponses ont été analysées. Voilà à quoi ressemblait ma hiérarchie:

abstract class AbstractProtocol
{
    /**
     * @return array Registration params to send
     */
    abstract protected function assembleRegistrationPart();

    /**
     * @return array Payment params to send
     */
    abstract protected function assemblePaymentPart();

    protected function doSend(array $data)
    {
        return
            (new HttpClient(
                [
                    'timeout' => 60,
                    'encoding' => 'utf-8',
                    'language' => 'en',
                ]
            ))
                ->send($data);
    }

    protected function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}

class ClassicProtocol extends AbstractProtocol
{
    public function send()
    {
        $registration = $this->assembleRegistrationPart();
        $payment = $this->assemblePaymentPart();
        $specificParams = $this->assembleClassicSpecificPart();

        $dataToSend =
            array_merge(
                $registration, $payment, $specificParams
            );

        $this->log($dataToSend);

        $this->doSend($dataToSend);
    }

    protected function assembleRegistrationPart()
    {
        return ['hello' => 'there'];
    }

    protected function assemblePaymentPart()
    {
        return ['pay' => 'yes'];
    }
}

Un tel code indique que j'utilise simplement l'héritage à mauvais escient. Voilà comment cela pourrait être refactorisé:

class ClassicProtocol
{
    private $request;
    private $logger;

    public function __construct(Request $request, Logger $logger, Client $client)
    {
        $this->request = $request;
        $this->client = $client;
        $this->logger = $logger;
    }

    public function send()
    {
        $this->logger->log($this->request->getData());
        $this->client->send($this->request->getData());
    }
}

$protocol =
    new ClassicProtocol(
        new PaymentRequest(
            new RegistrationData(),
            new PaymentData(),
            new ClassicSpecificData()
        ),
        new ClassicLogger(),
        new ClassicClient()
    );

class RegistrationData
{
    public function getData()
    {
        return ['hello' => 'there'];
    }
}

class PaymentData
{
    public function getData()
    {
        return ['pay' => 'yes'];
    }
}

class ClassicLogger
{
    public function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}
class ClassicClient
{
    private $properties;

    public function __construct()
    {
        $this->properties =
            [
                'timeout' => 60,
                'encoding' => 'utf-8',
                'language' => 'en',
            ];
    }
}

Depuis lors, je traite l'héritage très soigneusement car j'ai été blessé à plusieurs reprises.

Depuis lors, je suis arrivé à une autre conclusion sur l'héritage. Je suis fortement contre l' héritage basé sur la structure interne . Ça casse l'encapsulation, c'est fragile, c'est procédural après tout. Et lorsque je décompose mon domaine de la bonne façon , l'héritage ne se produit tout simplement pas très souvent.

Vadim Samokhin
la source
@Downvoter, faites-moi plaisir et commentez ce qui ne va pas avec cette réponse.
Vadim Samokhin