Interfaces sur une classe abstraite


30

Mon collègue et moi avons des opinions différentes sur la relation entre les classes de base et les interfaces. Je pense qu'une classe ne devrait pas implémenter une interface à moins que cette classe puisse être utilisée lorsqu'une implémentation de l'interface est requise. En d'autres termes, j'aime voir du code comme celui-ci:

interface IFooWorker { void Work(); }

abstract class BaseWorker {
    ... base class behaviors ...
    public abstract void Work() { }
    protected string CleanData(string data) { ... }
}

class DbWorker : BaseWorker, IFooWorker {
    public void Work() {
        Repository.AddCleanData(base.CleanData(UI.GetDirtyData()));
    }
}

Le DbWorker est ce qui obtient l'interface IFooWorker, car c'est une implémentation instanciable de l'interface. Il remplit complètement le contrat. Mon collègue préfère le presque identique:

interface IFooWorker { void Work(); }

abstract class BaseWorker : IFooWorker {
    ... base class behaviors ...
    public abstract void Work() { }
    protected string CleanData(string data) { ... }
}

class DbWorker : BaseWorker {
    public void Work() {
        Repository.AddCleanData(base.CleanData(UI.GetDirtyData()));
    }
}

Où la classe de base obtient l'interface, et en vertu de cela, tous les héritiers de la classe de base appartiennent également à cette interface. Cela me dérange mais je ne peux pas trouver de raisons concrètes pour lesquelles, en dehors de "la classe de base ne peut pas se suffire à elle-même comme implémentation de l'interface".

Quels sont les avantages et les inconvénients de sa méthode par rapport à la mienne, et pourquoi devrait-on l’utiliser par rapport à l’autre?


Votre suggestion ressemble de très près à la succession de diamants , ce qui peut provoquer beaucoup de confusion plus loin.
Spoike

@Spoike: Comment voir ça?
Niing

@Niing le lien que j'ai fourni dans le commentaire a un diagramme de classe simple et une explication simple à une ligne à ce que c'est. on appelle cela un problème de "diamant" car la structure d'héritage est essentiellement dessinée comme une forme de diamant (ie ♦ ️).
Spoike

@ Spoike: pourquoi cette question entraînera-t-elle l'héritage des diamants?
Niing

1
@Niing: hériter de BaseWorker et IFooWorker avec la même méthode appelée Work est le problème d'héritage du diamant (dans ce cas, ils remplissent tous les deux un contrat sur la Workméthode). En Java, vous devez implémenter les méthodes de l'interface afin d' Workéviter de cette façon la méthode à utiliser par le programme. Cependant, des langages tels que C ++ ne la désambiguïsent pas pour vous.
Spoike

Réponses:


24

Je pense qu'une classe ne devrait pas implémenter une interface à moins que cette classe puisse être utilisée lorsqu'une implémentation de l'interface est requise.

BaseWorkerremplit cette condition. Ce n'est pas parce que vous ne pouvez pas instancier directement un BaseWorkerobjet que vous ne pouvez pas avoir un BaseWorker pointeur qui remplit le contrat. En effet, c'est à peu près tout l'intérêt des classes abstraites.

En outre, il est difficile de le distinguer de l'exemple simplifié que vous avez publié, mais une partie du problème peut être que l'interface et la classe abstraite sont redondantes. Sauf si vous avez d'autres classes implémentées IFooWorkerqui ne dérivent pasBaseWorker , vous n'avez pas du tout besoin de l'interface. Les interfaces ne sont que des classes abstraites avec quelques limitations supplémentaires qui facilitent l'héritage multiple.

Encore une fois, il est difficile de dire à partir d'un exemple simplifié, l'utilisation d'une méthode protégée, faisant explicitement référence à la base d'une classe dérivée, et l'absence d'un endroit non ambigu pour déclarer l'implémentation de l'interface sont des signes d'avertissement que vous utilisez de manière inappropriée l'héritage au lieu de composition. Sans cet héritage, toute votre question devient théorique.


1
+1 pour avoir souligné que le simple fait qu'il BaseWorkern'est pas instanciable directement ne signifie pas qu'un élément donné BaseWorker myWorkern'est pas implémenté IFooWorker.
Avner Shahar-Kashtan

2
Je ne suis pas d'accord que les interfaces ne sont que des classes abstraites. Les classes abstraites définissent généralement certains détails d'implémentation (sinon une interface aurait été utilisée). Si ces détails ne vous plaisent pas, vous devez maintenant changer chaque utilisation de la classe de base par autre chose. Les interfaces sont peu profondes; ils définissent la relation "plug-and-socket" entre les dépendances et les dépendants. En tant que tel, un couplage serré à n'importe quel détail de mise en œuvre n'est plus un problème. Si une classe héritant de l'interface n'est pas à votre goût, supprimez-la et insérez entièrement autre chose; votre code s'en fout.
KeithS

5
Ils ont tous deux des avantages, mais généralement l'interface doit toujours être utilisée pour la dépendance, qu'il existe ou non une classe abstraite définissant les implémentations de base. La combinaison de l'interface et de la classe abstraite donne le meilleur des deux mondes; l'interface maintient la relation plug-and-socket de surface peu profonde, tandis que la classe abstraite fournit le code commun utile. Vous pouvez supprimer et remplacer n'importe quel niveau de celui-ci sous l'interface à volonté.
KeithS

Par "pointeur" , voulez-vous dire une instance d'un objet (auquel un pointeur ferait référence)? Les interfaces ne sont pas des classes, ce sont des types et peuvent agir comme un substitut incomplet à l'héritage multiple, pas comme un remplacement - c'est-à-dire qu'elles "incluent le comportement de plusieurs sources dans une classe".
samis

46

Je devrais être d'accord avec votre collègue.

Dans les deux exemples que vous donnez, BaseWorker définit la méthode abstraite Work (), ce qui signifie que toutes les sous-classes sont capables de respecter le contrat IFooWorker. Dans ce cas, je pense que BaseWorker devrait implémenter l'interface, et cette implémentation serait héritée par ses sous-classes. Cela vous évitera d'avoir à indiquer explicitement que chaque sous-classe est en effet un IFooWorker (le principe DRY).

Si vous ne définissiez pas Work () comme méthode de BaseWorker, ou si IFooWorker avait d'autres méthodes dont les sous-classes de BaseWorker ne voudraient pas ou n'auraient pas besoin, alors (évidemment) vous devrez indiquer quelles sous-classes implémentent réellement IFooWorker.


11
+1 aurait posté la même chose. Désolé insta, mais je pense que votre collègue a raison dans ce cas également, si quelque chose garantit l'exécution d'un contrat, il devrait hériter de ce contrat, que la garantie soit remplie par une interface, une classe abstraite ou une classe concrète. En termes simples: le garant doit hériter du contrat qu'il garantit.
Jimmy Hoffa

2
Je suis généralement d'accord, mais je voudrais souligner que des mots comme "base", "standard", "par défaut", "générique", etc. sont des odeurs de code dans un nom de classe. Si une classe de base abstraite a presque le même nom qu'une interface mais avec l'un de ces mots belettes inclus, c'est souvent un signe d'abstraction incorrecte; les interfaces définissent ce que quelque chose fait , définit l'héritage de type ce qu'il est . Si vous avez un IFooet un, BaseFoocela implique que ce IFoon'est pas une interface à granularité appropriée, ou qui BaseFooutilise l'héritage où la composition pourrait être un meilleur choix.
Aaronaught

@Aaronaught Bon point, même s'il se peut qu'il y ait vraiment quelque chose que toutes ou la plupart des implémentations de IFoo doivent hériter (comme un handle vers un service ou votre implémentation DAO).
Matthew Flynn

2
Alors, la classe de base ne devrait-elle pas être appelée MyDaoFoo ou SqlFooou HibernateFooou quoi que ce soit, pour indiquer qu'il ne s'agit que d'un arbre possible de classes à implémenter IFoo? C'est encore plus une odeur pour moi si une classe de base est couplée à une bibliothèque ou une plate-forme spécifique et il n'y a aucune mention de cela dans le nom ...
Aaronaught

@Aaronaught - Oui. Je suis complètement d'accord.
Matthew Flynn

11

Je suis généralement d'accord avec votre collègue.

Prenons votre modèle: l'interface n'est implémentée que par les classes enfants, même si la classe de base applique également l'exposition des méthodes IFooWorker. Tout d'abord, c'est redondant; que la classe enfant implémente ou non l'interface, ils sont tenus de remplacer les méthodes exposées de BaseWorker, et de même toute implémentation IFooWorker doit exposer la fonctionnalité, qu'elle obtienne ou non de l'aide de BaseWorker.

Cela rend également votre hiérarchie ambiguë; "Tous les IFooWorkers sont des BaseWorkers" n'est pas nécessairement une affirmation vraie, pas plus que "Tous les BaseWorkers ne sont des IFooWorkers". Donc, si vous souhaitez définir une variable d'instance, un paramètre ou un type de retour qui pourrait être n'importe quelle implémentation d'IFooWorker ou de BaseWorker (en tirant parti de la fonctionnalité exposée commune qui est l'une des raisons d'hériter en premier lieu), aucune des deux ceux-ci sont garantis de manière globale; certains BaseWorkers ne seront pas assignables à une variable de type IFooWorker, et vice versa.

Le modèle de votre collègue est beaucoup plus facile à utiliser et à remplacer. "Tous les BaseWorkers sont des IFooWorkers" est maintenant une vraie déclaration; vous pouvez donner n'importe quelle instance BaseWorker à n'importe quelle dépendance IFooWorker, aucun problème. L'instruction opposée "Tous les IFooWorkers sont des BaseWorkers" n'est pas vraie; qui vous permet de remplacer BaseWorker par BetterBaseWorker et votre code consommateur (qui dépend d'IFooWorker) n'aura pas à faire la différence.


J'aime le son de cette réponse mais ne suis pas tout à fait à la hauteur de la dernière partie. Vous suggérez que BetterBaseWorker serait une classe abstraite indépendante qui implémente IFooWorker, de sorte que mon plugin hypothétique pourrait simplement dire "oh boy! Le Better Base Worker est enfin sorti!" et changer toutes les références BaseWorker à BetterBaseWorker et l'appeler un jour (peut-être jouer avec les nouvelles valeurs renvoyées cool qui me permettent de supprimer d'énormes morceaux de mon code redondant, etc.)?
Anthony

Plus ou moins, oui. Le gros avantage est que vous réduirez le nombre de références à BaseWorker qui devront changer pour devenir BetterBaseWorkers à la place; la plupart de votre code ne référencera pas directement la classe concrète, au lieu d'utiliser IFooWorker, donc lorsque vous modifiez ce qui est affecté à ces dépendances IFooWorker (propriétés des classes, paramètres des constructeurs ou méthodes), le code utilisant IFooWorker ne devrait pas voir de différence en cours d'utilisation.
KeithS

6

Je dois ajouter un avertissement à ces réponses. Le couplage de la classe de base à l'interface crée une force dans la structure de cette classe. Dans votre exemple de base, il est évident que les deux devraient être couplés, mais cela peut ne pas être vrai dans tous les cas.

Prenez les classes de framework de collection Java:

abstract class AbstractList
class LinkedList extends AbstractList implements Queue

Le fait que le contrat de file d'attente soit mis en œuvre par LinkedList n'a pas poussé le problème à AbstractList .

Quelle est la distinction entre les deux cas? Le but de BaseWorker était toujours (comme indiqué par son nom et son interface) d'implémenter des opérations dans IWorker . Le but de AbstractList et celui de Queue sont divergents, mais un descendant du premier peut toujours mettre en œuvre le dernier contrat.


Cela se produit la moitié du temps. Il préfère toujours implémenter l'interface sur la classe de base, et je préfère toujours l'implémenter sur le béton final. Le scénario que vous avez présenté se produit souvent et fait partie des raisons pour lesquelles les interfaces sur la base me dérangent.
Bryan Boettcher

1
À droite, insta et je considère l'utilisation des interfaces de cette manière comme une facette de la surutilisation de l'héritage que nous voyons dans de nombreux environnements de développement. Comme le GoF l'a dit dans son livre sur les modèles de conception, préférer la composition à l'héritage , et garder l'interface hors de la classe de base est l'un des moyens de promouvoir ce principe même.
Mihai Danila

1
Je reçois l'avertissement, mais vous remarquerez que la classe abstraite de l'OP incluait des méthodes abstraites correspondant à l'interface: un BaseWorker est implicitement un IFooWorker. Le rendre explicite rend ce fait plus utilisable. Il existe cependant de nombreuses méthodes incluses dans Queue qui ne sont pas incluses dans AbstractList, et en tant que tel, AbstractList n'est pas une file d'attente, même si ses enfants peuvent l'être. AbstractList et Queue sont orthogonaux.
Matthew Flynn

Merci pour cette perspicacité; Je suis d'accord que le cas du PO tombe dans cette catégorie, mais je sentais qu'il y avait une discussion plus large à la recherche d'une règle plus profonde, et je voulais partager mes observations sur une tendance que j'ai lue (et même moi-même) pendant un court instant) de surutilisation de l'héritage, peut-être parce que c'est l'un des outils de la boîte à outils de la POO.
Mihai Danila

Dang, ça fait six ans. :)
Mihai Danila

2

Je poserais la question, que se passe-t-il lorsque vous changez IFooWorker, comme l'ajout d'une nouvelle méthode?

Si BaseWorkerimplémente l'interface, par défaut il devra déclarer la nouvelle méthode, même si elle est abstraite. En revanche, s'il n'implémente pas l'interface, vous n'obtiendrez que des erreurs de compilation sur les classes dérivées.

Pour cette raison, je ferais en sorte que la classe de base implémente l'interface , car je pourrais être en mesure d'implémenter toutes les fonctionnalités de la nouvelle méthode dans la classe de base sans toucher aux classes dérivées.


1

Pensez d'abord à ce qu'est une classe de base abstraite et à ce qu'est une interface. Considérez quand vous utiliseriez l'un ou l'autre et quand vous ne l'utiliseriez pas.

Il est courant pour les gens de penser que les deux sont des concepts très similaires, en fait, c'est une question d'entrevue courante (la différence entre les deux est ??)

Donc, une classe de base abstraite vous donne quelque chose d'interfaces qui ne peut pas être l'implémentation par défaut des méthodes. (Il y a d'autres choses, en C #, vous pouvez avoir des méthodes statiques sur une classe abstraite, pas sur une interface par exemple).

Par exemple, une utilisation courante de ceci est avec IDisposable. La classe abstraite implémente la méthode Dispose d'IDisposable, ce qui signifie que toute version dérivée de la classe de base sera automatiquement jetable. Vous pouvez ensuite jouer avec plusieurs options. Make Dispose abstract, forçant les classes dérivées à l'implémenter. Fournissez une implémentation virtuelle par défaut, pour qu'ils ne le fassent pas, ou ne la rendez ni virtuelle ni abstraite et demandez-lui d'appeler des méthodes virtuelles appelées des choses comme BeforeDispose, AfterDispose, OnDispose ou Disposing par exemple.

Donc, à chaque fois que toutes les classes dérivées doivent prendre en charge l'interface, cela va sur la classe de base. Si seulement un ou certains ont besoin de cette interface, cela ira dans la classe dérivée.

C'est en fait une grossière simplification. Une autre alternative est d'avoir des classes dérivées qui n'implémentent même pas les interfaces, mais les fournissent via un modèle d'adaptateur. Un exemple de cela que j'ai vu récemment était dans IObjectContextAdapter.


1

Je suis encore à des années de saisir pleinement la distinction entre une classe abstraite et une interface. Chaque fois que je pense maîtriser les concepts de base, je vais chercher sur stackexchange et je suis à deux pas en arrière. Mais quelques réflexions sur le sujet et la question des PO:

Première:

Il existe deux explications courantes d'une interface:

  1. Une interface est une liste de méthodes et de propriétés que n'importe quelle classe peut implémenter, et en implémentant une interface, une classe garantit que ces méthodes (et leurs signatures) et ces propriétés (et leurs types) seront disponibles lors de "l'interfaçage" avec cette classe ou un objet de cette classe. Une interface est un contrat.

  2. Les interfaces sont des classes abstraites qui ne font / ne peuvent rien faire. Ils sont utiles car vous pouvez en implémenter plusieurs, contrairement à ces classes parentales moyennes. Par exemple, je pourrais être un objet de la classe BananaBread et hériter de BaseBread, mais cela ne signifie pas que je ne peux pas également implémenter les interfaces IWithNuts et ITastesYummy. Je pourrais même implémenter l'interface IDoesTheDishes, parce que je ne suis pas seulement du pain, vous savez?

Il existe deux explications courantes d'une classe abstraite:

  1. Une classe abstraite est, yknow, la chose pas ce que la chose peut faire. C'est comme, l'essence, pas vraiment une chose réelle du tout. Attendez, cela vous aidera. Un bateau est une classe abstraite, mais un Playboy Yacht sexy serait une sous-classe de BaseBoat.

  2. J'ai lu un livre sur les classes abstraites, et peut-être devriez-vous lire ce livre, parce que vous ne le comprenez probablement pas et le ferez mal si vous ne l'avez pas lu.

Soit dit en passant, le livre Citers semble toujours impressionnant, même si je m'en vais encore confus.

Seconde:

Sur SO, quelqu'un a demandé une version plus simple de cette question, le classique, "pourquoi utiliser des interfaces? Quelle est la différence? Qu'est-ce que je manque?" Et une réponse a utilisé un pilote de l'armée de l'air comme exemple simple. Il n'a pas tout à fait atterri, mais il a suscité de grands commentaires, dont l'un a mentionné l'interface IFlyable ayant des méthodes comme takeOff, pilotEject, etc. Et cela a vraiment cliqué pour moi comme un exemple concret de la raison pour laquelle les interfaces ne sont pas seulement utiles, mais crucial. Une interface rend un objet / classe intuitif, ou du moins donne le sentiment qu'il l'est. Une interface n'est pas au profit de l'objet ou des données, mais pour quelque chose qui doit interagir avec cet objet. Le classique Fruit-> Apple-> Fuji ou Shape-> Triangle-> Les exemples équilatéraux d'héritage sont un excellent modèle pour comprendre taxonomiquement un objet donné en fonction de ses descendants. Il informe le consommateur et les processeurs de ses qualités générales, de ses comportements, que l'objet soit un groupe de choses, arrosera votre système si vous le placez au mauvais endroit, ou décrit des données sensorielles, un connecteur vers un magasin de données spécifique, ou données financières nécessaires pour faire la paie.

Un objet Plane spécifique peut ou non avoir une méthode pour un atterrissage d'urgence, mais je vais être énervé si j'assume son emergencyLand comme tout autre objet volant uniquement pour se réveiller couvert dans DumbPlane et apprendre que les développeurs sont allés avec quickLand parce qu'ils pensaient que c'était pareil. Tout comme je serais frustré si chaque fabricant de vis avait sa propre interprétation de Righty tighty ou si mon téléviseur n'avait pas le bouton d'augmentation du volume au-dessus du bouton de diminution du volume.

Les classes abstraites sont le modèle qui établit ce que tout objet descendant doit posséder pour être qualifié de classe. Si vous n'avez pas toutes les qualités d'un canard, peu importe que vous ayez l'interface IQuack implémentée, votre juste un pingouin bizarre. Les interfaces sont des choses qui ont du sens même lorsque vous ne pouvez être sûr de rien d'autre. Jeff Goldblum et Starbuck étaient tous deux capables de piloter des vaisseaux spatiaux extraterrestres parce que l'interface était fiable de manière similaire.

Troisième:

Je suis d'accord avec votre collègue, car parfois vous devez appliquer certaines méthodes au tout début. Si vous créez un ORM d'enregistrement actif, il a besoin d'une méthode de sauvegarde. Ce n'est pas à la hauteur de la sous-classe qui peut être instanciée. Et si l'interface ICRUD est suffisamment portable pour ne pas être exclusivement couplée à la seule classe abstraite, elle peut être implémentée par d'autres classes pour les rendre fiables et intuitives pour toute personne déjà familière avec l'une des classes descendantes de cette classe abstraite.

D'un autre côté, il y avait un excellent exemple plus tôt de ne pas sauter pour lier une interface à la classe abstraite, car tous les types de liste n'implémenteront pas (ou ne devraient pas) implémenter une interface de file d'attente. Vous avez dit que ce scénario se produit la moitié du temps, ce qui signifie que vous et votre collègue avez tous les deux tort la moitié du temps, et donc la meilleure chose à faire est d'argumenter, de débattre, de considérer, et s'ils s'avèrent corrects, reconnaissez et acceptez le couplage . Mais ne devenez pas un développeur qui suit une philosophie même si elle n'est pas la meilleure pour le travail à accomplir.


0

Il y a un autre aspect à la raison pour laquelle la DbWorkermise en œuvre devrait IFooWorker, comme vous le suggérez.

Dans le cas du style de votre collègue, si, pour une raison quelconque, une refactorisation ultérieure se produit et DbWorkerest réputée ne pas étendre la BaseWorkerclasse abstraite , DbWorkerl' IFooWorkerinterface perd . Cela pourrait, mais pas nécessairement, avoir un impact sur les clients qui le consomment, s'ils attendent l' IFooWorkerinterface.


-1

Pour commencer, j'aime définir différemment les interfaces et les classes abstraites:

Interface: a contract that each class must fulfill if they are to implement that interface
Abstract class: a general class (i.e vehicle is an abstract class, while porche911 is not)

Dans votre cas, tous les travailleurs mettent en œuvre work()parce que c'est ce que le contrat exige d'eux. Par conséquent, votre classe de base Baseworkerdoit implémenter cette méthode de manière explicite ou en tant que fonction virtuelle. Je vous suggère de mettre cette méthode dans votre classe de base en raison du simple fait que tous les travailleurs doivent le faire work(). Par conséquent, il est logique que votre classe de base (même si vous ne pouvez pas créer un pointeur de ce type) l'inclue en tant que fonction.

Maintenant, DbWorkersatisfait l' IFooWorkerinterface, mais s'il existe un moyen spécifique pour cette classe work(), alors elle a vraiment besoin de surcharger la définition héritée de BaseWorker. La raison pour laquelle il ne devrait pas implémenter l'interfaceIFooWorker directement est que ce n'est pas quelque chose de spécial DbWorker. Si vous le faisiez à chaque fois que vous implémentiez des classes similaires, DbWorkervous violeriez DRY .

Si deux classes implémentent la même fonction de différentes manières, vous pouvez commencer à rechercher la plus grande superclasse commune. La plupart du temps, vous en trouverez un. Sinon, continuez à chercher ou abandonnez et acceptez qu'ils n'ont pas suffisamment de choses en commun pour faire une classe de base.


-3

Wow, toutes ces réponses et aucune ne soulignant que l'interface dans l'exemple ne sert à rien. Vous n'utilisez pas l'héritage et une interface pour la même méthode, c'est inutile. Vous utilisez l'un ou l'autre. Il y a beaucoup de questions sur ce forum avec des réponses expliquant les avantages de chacun et les sénarios qui appellent l'un ou l'autre.


3
C'est un mensonge patent. L'interface est un contrat par lequel le système devrait se comporter, sans égard à la mise en œuvre. La classe de base héritée est l'une des nombreuses implémentations possibles pour cette interface. Un système à assez grande échelle aura plusieurs classes de base qui satisfont les différentes interfaces (généralement fermées par des génériques), ce qui est exactement ce que cette configuration a fait et pourquoi il a été utile de les séparer.
Bryan Boettcher

C'est un très mauvais exemple du point de vue de la POO. Comme vous dites "la classe de base n'est qu'une implémentation" (elle devrait l'être ou il n'y aurait aucun intérêt pour l'interface), vous dites qu'il y aura des classes non-ouvrières implémentant l'interface IFooWorker. Ensuite, vous aurez une classe de base qui est un fooworker spécialisé (nous devrions supposer qu'il pourrait aussi y avoir des barworkers) et vous auriez d'autres fooworkers qui ne sont même pas des travailleurs de base! C'est en arrière. À plus d'un titre.
Martin Maat

1
Peut-être que vous êtes coincé sur le mot "Worker" dans l'interface. Qu'en est-il de "Datasource"? Si nous avons un IDatasource <T>, nous pouvons trivialement avoir un SqlDatasource <T>, RestDatasource <T>, MongoDatasource <T>. L'entreprise décide quelles fermetures de l'interface générique iront aux implémentations de fermeture des sources de données abstraites.
Bryan Boettcher

Il peut être judicieux d'utiliser l'héritage uniquement pour DRY et de mettre une implémentation commune dans une classe de base. Mais vous n'aligneriez aucune méthode remplacée avec aucune méthode d'interface, la classe de base contiendrait une logique de prise en charge générale. S'ils s'alignaient, cela montrerait que l'héritage résout très bien votre problème et que l'interface ne servirait à rien. Vous utilisez une interface dans les cas où l'héritage ne résout pas votre problème car les caractéristiques communes que vous souhaitez modéliser ne correspondent pas à un arbre de classe singke. Et oui, le libellé de l'exemple le rend d'autant plus faux.
Martin Maat
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.