web-dev-qa-db-fra.com

Comment éviter de casser le principe de substitution de Liskov avec une classe qui implémente plusieurs interfaces?

Étant donné la classe suivante:

class Example implements Interface1, Interface2 {
    ...
}

Lorsque j'instancie la classe en utilisant Interface1:

Interface1 example = new Example();

... alors je ne peux appeler que le Interface1 méthodes, et non les Interface2 méthodes, sauf si je transtype:

((Interface2) example).someInterface2Method();

Bien sûr, pour sécuriser ce runtime, je devrais également envelopper cela avec une vérification instanceof:

if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}

Je suis conscient que je pourrais avoir une interface wrapper qui étend les deux interfaces, mais je pourrais alors me retrouver avec plusieurs interfaces pour répondre à toutes les permutations possibles d'interfaces pouvant être implémentées par la même classe. Les interfaces en question ne s'étendent pas naturellement les unes aux autres, donc l'héritage semble également faux.

L'approche instanceof/cast casse-t-elle le LSP lorsque j'interroge l'instance d'exécution pour déterminer ses implémentations?

Quelle que soit l'implémentation que j'utilise, cela semble avoir des effets secondaires, que ce soit dans une mauvaise conception ou une mauvaise utilisation.

44
jml

Je suis conscient que je pourrais avoir une interface wrapper qui étend les deux interfaces, mais je pourrais ensuite me retrouver avec plusieurs interfaces pour répondre à toutes les permutations possibles d'interfaces pouvant être implémentées par la même classe

Je soupçonne que si vous trouvez que beaucoup de vos classes implémentent différentes combinaisons d'interfaces, alors: vos classes concrètes en font trop; ou (moins probable) vos interfaces sont trop petites et trop spécialisées, au point d'être inutiles individuellement.

Si vous avez de bonnes raisons pour qu'un code nécessite quelque chose qui soit à la fois Interface1 et un Interface2 alors allez-y absolument et créez une version combinée qui étend les deux. Si vous avez du mal à trouver un nom approprié pour cela (non, pas FooAndBar), c'est un indicateur que votre conception est incorrecte.

Ne vous fiez absolument pas au casting. Il ne doit être utilisé qu'en dernier recours et généralement uniquement pour des problèmes très spécifiques (par exemple la sérialisation).

Mon motif de conception préféré et le plus utilisé est le motif de décoration. En tant que tel, la plupart de mes classes n'implémenteront qu'une seule interface (à l'exception des interfaces plus génériques telles que Comparable). Je dirais que si vos classes implémentent fréquemment/toujours plus d'une interface, c'est une odeur de code.


Si vous instanciez l'objet et l'utilisez dans la même portée, vous devez simplement écrire

Example example = new Example();

Juste pour que ce soit clair (je ne suis pas sûr que ce soit ce que vous proposiez), sous aucune circonstance ne devrait vous jamais écrit quelque chose comme ça:

Interface1 example = new Example();
if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}
35
Michael

Votre classe peut implémenter plusieurs interfaces très bien, et elle ne casse aucun OOP. Au contraire, elle suit le principe de ségrégation d'interface .

Il est difficile de comprendre pourquoi vous auriez une situation dans laquelle quelque chose de type Interface1 Devrait fournir someInterface2Method(). C'est là que votre conception est erronée.

Pensez-y d'une manière légèrement différente: Imaginez que vous avez une autre méthode, void method1(Interface1 interface1). Il ne peut pas s'attendre à ce que interface1 Soit également une instance de Interface2. Si c'était le cas, le type d'argument aurait dû être différent. L'exemple que vous avez montré est précisément celui-ci, ayant une variable de type Interface1 Mais s'attendant à ce qu'elle soit également de type Interface2.

Si vous souhaitez pouvoir appeler les deux méthodes, vous devez définir le type de votre variable example sur Example. De cette façon, vous évitez le instanceof et le transtypage de type tout à fait.

Si vos deux interfaces Interface1 Et Interface2 Ne sont pas si mal couplées, et que vous devrez souvent appeler des méthodes des deux, peut-être que séparer les interfaces n'était pas une bonne idée, ou peut-être que vous voulez d'avoir une autre interface qui étend les deux.

En général (mais pas toujours), les vérifications et les transtypages de instanceof indiquent souvent des défauts de conception OO. Parfois, la conception conviendrait au reste du programme, mais vous auriez un petit cas où il est plus simple de taper fonte plutôt que de tout refaire. Mais si possible, vous devriez toujours vous efforcer de l'éviter au début, dans le cadre de votre conception.

25
jbx

Vous avez deux options différentes (je parie qu'il y en a beaucoup plus).

La première consiste à créer votre propre interface qui étend les deux autres:

interface Interface3 extends Interface1, Interface2 {}

Et puis utilisez-le dans votre code:

public void doSomething(Interface3 interface3){
    ...
}

L'autre façon (et à mon avis la meilleure) est d'utiliser des génériques par méthode:

public <T extends Interface1 & Interface2> void doSomething(T t){
    ...
}

Cette dernière option est en fait moins restreinte que la première, car le type générique T est inféré dynamiquement et conduit donc à moins de couplage (une classe n'a pas à implémenter une interface de regroupement spécifique, comme le premier exemple) .

11
Lino

Le problème principal

Ajuster légèrement votre exemple pour que je puisse résoudre le problème principal:

public void DoTheThing(Interface1 example)
{
    if (example instanceof Interface2) 
    {
        ((Interface2) example).someInterface2Method();
    }
}

Vous avez donc défini la méthode DoTheThing(Interface1 example). Cela revient à dire "pour faire la chose, j'ai besoin d'un objet Interface1".

Mais alors, dans votre corps de méthode, il semble que vous ayez réellement besoin d'un objet Interface2. Alors pourquoi n'en avez-vous pas demandé un dans vos paramètres de méthode? De toute évidence, vous auriez dû demander un Interface2

Ce que vous faites ici est en supposant que tout objet Interface1 Que vous obtenez sera également un objet Interface2. Ce n'est pas quelque chose sur lequel vous pouvez compter. Vous pourriez avoir des classes qui implémentent les deux interfaces, mais vous pourriez aussi bien avoir des classes qui n'implémentent que l'une et pas l'autre.

Il n'y a aucune exigence inhérente selon laquelle Interface1 Et Interface2 Doivent tous deux être implémentés sur le même objet. Vous ne pouvez pas savoir (ni compter sur l'hypothèse) que c'est le cas.

Sauf si vous définissez l'exigence inhérente et l'appliquez.

interface InterfaceBoth extends Interface1, Interface2 {}

public void DoTheThing(InterfaceBoth example)
{
    example.someInterface2Method();
}

Dans ce cas, vous avez requis InterfaceBoth objet pour implémenter Interface1 Et Interface2. Ainsi, chaque fois que vous demandez un objet InterfaceBoth, vous pouvez être sûr d'obtenir un objet qui implémente à la fois Interface1 Et Interface2, Et ainsi vous pouvez utiliser des méthodes de l'une ou l'autre interface sans même besoin de lancer ou de vérifier le type.

Vous (et le compilateur) savez que cette méthode toujours sera disponible, et il n'y a aucune chance que cela ne fonctionne pas.

Remarque: Vous auriez pu utiliser Example au lieu de créer l'interface InterfaceBoth, mais vous ne pourriez alors utiliser que des objets de type Example et pas toute autre classe qui implémenterait les deux interfaces. Je suppose que vous êtes intéressé à gérer n'importe quelle classe qui implémente les deux interfaces, pas seulement Example.

Déconstruire davantage le problème.

Regardez ce code:

ICarrot myObject = new Superman();

Si vous supposez que ce code compile, que pouvez-vous me dire sur la classe Superman? Qu'il implémente clairement l'interface ICarrot. C'est tout ce que tu peux me dire. Vous ne savez pas si Superman implémente ou non l'interface IShovel.

Donc si j'essaye de faire ça:

myObject.SomeMethodThatIsFromSupermanButNotFromICarrot();

ou ca:

myObject.SomeMethodThatIsFromIShovelButNotFromICarrot();

Devriez-vous être surpris si je vous disais que ce code se compile? Vous devriez, car ce code ne compile pas.

Vous pouvez dire "mais je sais que c'est un objet Superman qui a cette méthode!". Mais alors vous oublieriez que vous avez seulement dit au compilateur que c'était une variable ICarrot, pas une variable Superman.

Vous pouvez dire "mais je sais que c'est un objet Superman qui implémente l'interface IShovel!". Mais alors vous oublieriez que vous avez seulement dit au compilateur que c'était une variable ICarrot, pas une variable Superman ou IShovel.

Sachant cela, revenons à votre code.

Interface1 example = new Example();

Tout ce que vous avez dit, c'est que vous avez une variable Interface1.

if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}

Cela n'a aucun sens pour vous de supposer que cet objet Interface1 Arrive également à implémenter une seconde interface non liée. Même si ce code fonctionne à un niveau technique, c'est un signe de mauvaise conception, le développeur attend une corrélation inhérente entre deux interfaces sans avoir réellement créé cette corrélation.

Vous pouvez dire "mais je sais que je mets un objet Example dedans, le compilateur devrait le savoir aussi!" mais il vous manquerait le point que s'il s'agissait d'un paramètre de méthode, vous n'auriez aucun moyen de savoir ce que les appelants de votre méthode envoient.

public void DoTheThing(Interface1 example)
{
    if (example instanceof Interface2) 
    {
        ((Interface2) example).someInterface2Method();
    }
}

Lorsque d'autres appelants appellent cette méthode, le compilateur ne les arrêtera que si l'objet passé n'implémente pas Interface1. Le compilateur n'empêchera pas quelqu'un de passer un objet d'une classe qui implémente Interface1 Mais n'implémente pas Interface2.

8
Flater

Votre exemple ne rompt pas LSP, mais il semble rompre SRP. Si vous rencontrez un tel cas où vous devez convertir un objet sur sa 2e interface, la méthode qui contient un tel code peut être considérée comme occupée.

L'implémentation de 2 (ou plus) interfaces dans une classe est très bien. Le choix de l'interface à utiliser comme type de données dépend entièrement du contexte du code qui l'utilisera.

La diffusion est très bien, surtout lorsque vous changez de contexte.

class Payment implements Expirable, Limited {
 /* ... */
}

class PaymentProcessor {
    // Using payment here because i'm working with payments.
    public void process(Payment payment) {
        boolean expired = expirationChecker.check(payment);
        boolean pastLimit = limitChecker.check(payment);

        if (!expired && !pastLimit) {
          acceptPayment(payment);
        }
    }
}

class ExpirationChecker {
    // This the `Expirable` world, so i'm  using Expirable here
    public boolean check(Expirable expirable) {
        // code
    }
}

class LimitChecker {
    // This class is about checking limits, thats why im using `Limited` here
    public boolean check(Limited limited) {
        // code
    }
}
7
sweet suman

Habituellement, de nombreuses interfaces spécifiques au client sont correctes et font partie du principe de ségrégation des interfaces (le "je" dans SOLIDE ). Certains points plus spécifiques, sur le plan technique, ont déjà été mentionnés dans d'autres réponses.

Notamment que vous pouvez aller trop loin avec cette ségrégation, en ayant une classe comme

class Person implements FirstNameProvider, LastNameProvider, AgeProvider ... {
    @Override String getFirstName() {...}
    @Override String getLastName() {...}
    @Override int getAge() {...}
    ...
}

Ou, inversement, que vous avez une classe d'implémentation qui est trop puissante, comme dans

class Application implements DatabaseReader, DataProcessor, UserInteraction, Visualizer {
    ...
}

Je pense que le point principal du Principe de ségrégation des interfaces est que les interfaces doivent être spécifiques au client . Ils devraient essentiellement "résumer" les fonctions requises par un certain client, pour une certaine tâche.

En d'autres termes: la question est de trouver le bon équilibre entre les extrêmes que j'ai esquissés ci-dessus. Lorsque j'essaie de comprendre les interfaces et leurs relations (mutuellement et en termes de classes qui les implémentent), j'essaie toujours de prendre du recul et de me demander, d'une manière intentionnellement naïve: Qui va recevoir quoi , et quoi va-t-il en faire?

Concernant votre exemple: Lorsque tous vos clients ont toujours besoin des fonctionnalités de Interface1 et Interface2 en même temps, alors vous devriez envisager de définir un

interface Combined extends Interface1, Interface2 { }

ou pas avoir des interfaces différentes en premier lieu. D'un autre côté, lorsque les fonctionnalités sont complètement distinctes et indépendantes et jamais utilisées ensemble, alors vous devriez vous demander pourquoi la classe unique les implémente en même temps temps.

À ce stade, on pourrait se référer à un autre principe, à savoir Composition sur héritage . Bien qu'elle ne soit pas classiquement liée à l'implémentation de plusieurs interfaces, la composition peut est également favorable dans ce cas. Par exemple, vous pouvez modifier votre classe pour ne pas implémenter les interfaces directement, mais uniquement fournir des instances qui les implémentent:

class Example {
    Interface1 getInterface1() { ... }
    Interface2 getInterface2() { ... }
}

Cela semble un peu étrange dans ce Example (sic!), Mais en fonction de la complexité de l'implémentation de Interface1 et Interface2, il peut être judicieux de les séparer.


Modifié en réponse au commentaire:

L'intention ici n'est pas pas de passer la classe concrète Example aux méthodes qui ont besoin des deux interfaces. Un cas où cela pourrait avoir un sens est plutôt lorsqu'une classe combine les fonctionnalités des deux interfaces, mais ne le fait pas en les implémentant directement en même temps. Il est difficile de créer un exemple qui ne semble pas trop artificiel, mais quelque chose comme ça pourrait faire passer l'idée:

interface DatabaseReader { String read(); }
interface DatabaseWriter { void write(String s); }

class Database {
    DatabaseConnection connection = create();
    DatabaseReader reader = createReader(connection);
    DatabaseReader writer = createWriter(connection);

    DatabaseReader getReader() { return reader; }
    DatabaseReader getWriter() { return writer; }
}

Le client s'appuiera toujours sur les interfaces. Des méthodes comme

void create(DatabaseWriter writer) { ... }
void read  (DatabaseReader reader) { ... }
void update(DatabaseReader reader, DatabaseWriter writer) { ... }

pourrait alors être appelé avec

create(database.getWriter());
read  (database.getReader());
update(database.getReader(), database.getWriter());

respectivement.

6
Marco13

À l'aide de divers articles et commentaires sur cette page, une solution a été produite, qui me semble appropriée pour mon scénario.

Ce qui suit montre les changements itératifs de la solution pour répondre aux principes SOLID.

Exigence

Pour produire la réponse d'un service Web, des paires clé + objet sont ajoutées à un objet de réponse. Il existe de nombreuses paires de clés + objets différentes qui doivent être ajoutées, chacune pouvant nécessiter un traitement unique pour transformer les données de la source au format requis dans la réponse.

De cela, il est clair que si les différentes paires clé/valeur peuvent avoir des exigences de traitement différentes pour transformer les données source en l'objet de réponse cible, elles ont toutes un objectif commun d'ajouter un objet à l'objet de réponse.

Par conséquent, l'interface suivante a été produite dans l'itération de solution 1:

Itération de la solution 1

ResponseObjectProvider<T, S> {
    void addObject(T targetObject, S sourceObject, String targetKey);
}

Tout développeur qui doit ajouter un objet à la réponse peut désormais le faire en utilisant une implémentation existante qui correspond à ses besoins, ou ajouter une nouvelle implémentation en fonction d'un nouveau scénario

C'est formidable car nous avons une interface commune qui agit comme un contrat pour cette pratique courante d'ajouter des objets de réponse

Cependant, un scénario nécessite que l'objet cible soit extrait de l'objet source à l'aide d'une clé particulière, "identifiant".

Il existe des options ici, la première consiste à ajouter une implémentation de l'interface existante comme suit:

public class GetIdentifierResponseObjectProvider<T extends Map, S extends Map> implements ResponseObjectProvider<T, S> {
  public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
     targetObject.put(targetKey, sourceObject.get("identifier"));
  }
}

Cela fonctionne, mais ce scénario pourrait être requis pour d'autres clés d'objet source ("startDate", "endDate" etc ...) donc cette implémentation devrait être rendue plus générique pour permettre sa réutilisation dans ce scénario.

De plus, d'autres implémentations peuvent nécessiter plus d'informations contextuelles pour effectuer l'opération addObject ... Par conséquent, un nouveau type générique doit être ajouté pour répondre à cette

Solution Itération 2

ResponseObjectProvider<T, S, U> {
    void addObject(T targetObject, S sourceObject, String targetKey);
    void setParams(U params);
    U getParams();
}

Cette interface répond aux deux scénarios d'utilisation; les implémentations qui nécessitent des paramètres supplémentaires pour effectuer l'opération addObject et les implémentations qui ne

Cependant, compte tenu du dernier des scénarios d'utilisation, les implémentations qui ne nécessitent pas de paramètres supplémentaires briseront le SOLID Principe de ségrégation d'interface car ces implémentations remplaceront les méthodes getParams et setParams mais ne les implémenteront pas. Par exemple:

public class GetObjectBySourceKeyResponseObjectProvider<T extends Map, S extends Map, U extends String> implements ResponseObjectProvider<T, S, U> {
    public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
        targetObject.put(targetKey, sourceObject.get(U));
    }

    public void setParams(U params) {
        //unimplemented method
    }

    U getParams() {
        //unimplemented method
    }

}

Solution Iteration

Pour résoudre le problème de ségrégation d'interface, les méthodes d'interface getParams et setParams ont été déplacées dans une nouvelle interface:

public interface ParametersProvider<T> {
    void setParams(T params);
    T getParams();
}

Les implémentations qui nécessitent des paramètres peuvent désormais implémenter l'interface ParametersProvider:

public class GetObjectBySourceKeyResponseObjectProvider<T extends Map, S extends Map, U extends String> implements ResponseObjectProvider<T, S>, ParametersProvider<U>

  private String params;
  public void setParams(U params) {
      this.params = params;
  }

  public U getParams() {
    return this.params;
  }

  public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
     targetObject.put(targetKey, sourceObject.get(params));
  }
}

Cela résout le problème de ségrégation d'interface mais provoque deux autres problèmes ... Si le client appelant veut programmer sur une interface, c'est-à-dire:

ResponseObjectProvider responseObjectProvider = new  GetObjectBySourceKeyResponseObjectProvider<>();

Ensuite, la méthode addObject sera disponible pour l'instance, mais PAS les méthodes getParams et setParams de l'interface ParametersProvider ... Pour les appeler, une conversion est requise et, pour être sûr, une vérification de l'instance doit également être effectuée:

if(responseObjectProvider instanceof ParametersProvider) {
      ((ParametersProvider)responseObjectProvider).setParams("identifier");
}

Non seulement cela est indésirable, mais il rompt également le principe de substitution de Liskov - " si S est un sous-type de T, alors les objets de type T dans un programme peuvent être remplacés par des objets de type S sans en altérer aucun. des propriétés souhaitables de ce programme "

c'est-à-dire que si nous remplaçons une implémentation de ResponseObjectProvider qui implémente également ParametersProvider, par une implémentation qui n'implémente pas ParametersProvider, cela pourrait altérer certaines des propriétés souhaitables du programme ... De plus, le client doit savoir quelle implémentation se trouve dans utiliser pour appeler les bonnes méthodes

Un problème supplémentaire est l'utilisation pour appeler des clients. Si le client appelant voulait utiliser une instance qui implémente les deux interfaces pour effectuer plusieurs fois addObject, la méthode setParams devrait être appelée avant addObject ... Cela pourrait provoquer des bogues évitables si aucune précaution n'est prise lors de l'appel.

Solution Iteration 4 - Solution finale

Les interfaces produites à partir de Solution Iteration 3 répondent à toutes les exigences d'utilisation actuellement connues, avec une certaine flexibilité fournie par les génériques pour une implémentation utilisant différents types. Cependant, cette solution rompt le principe de substitution Liskov et a une utilisation non évidente de setParams pour le client appelant

La solution consiste à avoir deux interfaces distinctes, ParameterisedResponseObjectProvider et ResponseObjectProvider.

Cela permet au client de programmer sur une interface et sélectionnerait l'interface appropriée selon que les objets ajoutés à la réponse nécessitent ou non des paramètres supplémentaires.

La nouvelle interface a d'abord été implémentée comme une extension de ResponseObjectProvider:

public interface ParameterisedResponseObjectProvider<T,S,U> extends ResponseObjectProvider<T, S> {
    void setParams(U params);   
    U getParams();
}

Cependant, cela posait toujours le problème d'utilisation, où le client appelant devait d'abord appeler setParams avant d'appeler addObject et également rendre le code moins lisible.

La solution finale a donc deux interfaces distinctes définies comme suit:

public interface ResponseObjectProvider<T, S> {
    void addObject(T targetObject, S sourceObject, String targetKey);   
}


public interface ParameterisedResponseObjectProvider<T,S,U> {
    void addObject(T targetObject, S sourceObject, String targetKey, U params);
}

Cette solution résout les violations des principes de séparation d'interface et de substitution de Liskov et améliore également l'utilisation pour appeler les clients et améliore la lisibilité du code.

Cela signifie que le client doit être conscient des différentes interfaces, mais comme les contrats sont différents, cela semble être une décision justifiée, surtout si l'on considère tous les problèmes que la solution a évités.

4
jml

Le problème que vous décrivez découle souvent d'une application trop zélée du principe de ségrégation des interfaces, encouragée par l'incapacité des langues à spécifier que les membres d'une interface doivent, par défaut, être enchaînés à des méthodes statiques qui pourraient implémenter des comportements sensés.

Considérez, par exemple, une interface de séquence/énumération de base et les comportements suivants:

  1. Produisez un énumérateur qui peut lire les objets si aucun autre itérateur n'a encore été créé.

  2. Produisez un énumérateur qui peut lire les objets même si un autre itérateur a déjà été créé et utilisé.

  3. Indiquez le nombre d'éléments dans la séquence

  4. Signaler la valeur du Nième élément dans la séquence

  5. Copiez une plage d'éléments de l'objet dans un tableau de ce type.

  6. Fournissez une référence à un objet immuable qui peut s'adapter efficacement aux opérations ci-dessus avec des contenus garantis de ne jamais changer.

Je suggérerais que de telles capacités devraient faire partie de l'interface de séquence/énumération de base, avec une méthode/propriété pour indiquer lesquelles des opérations ci-dessus sont significativement prises en charge. Certains types d'énumérateurs à la demande à coup unique (par exemple, un générateur de séquences infiniment véritablement aléatoire) pourraient ne pas être en mesure de prendre en charge l'une de ces fonctions, mais la séparation de ces fonctions dans des interfaces distinctes rendra beaucoup plus difficile la production d'encapsuleurs efficaces pour de nombreux types des opérations.

On pourrait produire une classe wrapper qui pourrait accueillir toutes les opérations ci-dessus, mais pas nécessairement efficacement, sur toute séquence finie qui prend en charge la première capacité. Si, cependant, la classe est utilisée pour envelopper un objet qui prend déjà en charge certaines de ces capacités (par exemple, accéder au Nème élément), avoir le wrapper utiliser les comportements sous-jacents pourrait être beaucoup plus efficace que de tout faire via la deuxième fonction ci-dessus (par exemple, créer un nouvel énumérateur et l'utiliser pour lire et ignorer de manière itérative les éléments de la séquence jusqu'à ce que celui souhaité soit atteint).

Avoir tous les objets qui produisent n'importe quel type de séquence prennent en charge une interface qui inclut tout ce qui précède, ainsi qu'une indication des capacités prises en charge, serait plus propre que d'essayer d'avoir différentes interfaces pour différents sous-ensembles de capacités, et nécessitant que les classes wrapper fassent disposition explicite pour toutes les combinaisons qu'ils souhaitent exposer à leurs clients.

1
supercat