【问题标题】:How do I avoid breaking the Liskov substitution principle with a class that implements multiple interfaces?如何避免使用实现多个接口的类来破坏 Liskov 替换原则?
【发布时间】:2019-01-14 15:25:21
【问题描述】:

给定以下类:

class Example implements Interface1, Interface2 {
    ...
}

当我使用Interface1 实例化类时:

Interface1 example = new Example();

...那么我只能调用Interface1 方法,而不能调用Interface2 方法,除非我强制转换:

((Interface2) example).someInterface2Method();

当然,为了让这个运行时安全,我还应该用instanceof 检查来包装它:

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

我知道我可以有一个包装器接口来扩展这两个接口,但是我最终可以使用多个接口来满足同一类可以实现的所有可能的接口排列。有问题的接口不会自然地相互扩展,因此继承似乎也是错误的。

instanceof/cast 方法是否会在我询问运行时实例以确定其实现时破坏 LSP?

我使用的任何实现似乎在设计或使用方面都有一些副作用。

【问题讨论】:

  • 我不会添加支票。如果我必须在一个范围内同时使用两个接口,我会创建编译时类型 Example,而不是 Interface1。
  • 在这种情况下你绝对不需要施法。它应该始终是最后的手段。可能超过 90% 的演员阵容仅仅是糟糕设计的结果。
  • 创建第三个接口,扩展您提到的两个接口,并在整个代码中使用前者。或使用泛型更宽容:public <T extends Interfac1 & Interface2> void doSomething(T t)
  • 你不能用Example example = new Example();吗?
  • 鉴于强制转换是 Java 语言的一个合理特性,如果不知道类和接口的目的以及为什么要强制转换,我们无法回答这些问题。

标签: java liskov-substitution-principle


【解决方案1】:

我知道我可以有一个包装接口来扩展两者 接口,但我最终可能会得到多个接口来满足 对于所有可能的接口排列 由同一个类实现

我怀疑,如果您发现许多类实现了不同的接口组合,那么:您的具体类做得太多;或者(不太可能)你的界面太小太专业,以至于单独使用时毫无用处。

如果您有充分的理由让某些代码同时需要Interface1Interface2,那么绝对可以继续创建一个扩展两者的组合版本。如果您很难为此想一个合适的名称(不,不是FooAndBar),那么这表明您的设计是错误的。

绝对不要依赖投射任何东西。它只能作为最后的手段使用,并且通常只用于非常具体的问题(例如序列化)。

我最喜欢和最常用的设计模式是装饰器模式。因此,我的大多数类只会实现一个接口(除了更通用的接口,例如Comparable)。我会说,如果您的类经常/总是实现多个接口,那么这就是代码异味。


如果您要实例化对象并在同一范围内使用它,那么您应该只是编写

Example example = new Example();

很清楚(我不确定这是否是您的建议),在任何情况你应该永远写这样的东西:

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

【讨论】:

  • 当你说“到了单独无用的地步”时,你提出了一个有趣的观点——这个例子中的 Interface2 实际上是指一个我称之为 HasParameters 的接口,它有方法 getParams() 和 setParams () 因为并非所有 Interface1 的实现实际上都需要使用参数。我试图通过使用空的 getParams() 和 setParams() 方法进行大量实现来不破坏接口隔离原则,但就像你说的那样,Interface2 本身毫无用处......
  • @jml 那么使用您所说的方法的 Interface1 规范可能会更好(interface Interface2 extends Interface1 { /* get and set params */ }
  • @jml 那么Lino 建议的可能就是您想要的。 RequestParametizedRequest 优于 RequestHasParameters
  • @jml HasParameters 感觉不像是一个界面,而是一个属性。也许实际上您需要做的是将方法添加到Request,例如hasParameters()getParameters()setParameters()。使用 Java 8,如果您不想一直实现它们,您甚至可以分别拥有 defaultfalseemptyList()throw OperationNotSupportedException。 Java 的 Collection 类一直都在这样做。
  • 使用 Java 8,您可以Interface1 中包含您的 getParams()setParams(),并使用默认的无操作实现,例如 public default void setParams(Params p) {}public default Params getParams() { return null; }跨度>
【解决方案2】:

您的类可以很好地实现多个接口,并且不违反任何 OOP 原则。相反,它是在关注interface segregation principle

令人困惑的是,为什么您会遇到Interface1 类型的东西预计会提供someInterface2Method() 的情况。这就是你的设计错误的地方。

换个角度想一想:想象一下你有另一种方法,void method1(Interface1 interface1)。它不能指望interface1 也是Interface2 的一个实例。如果是这样的话,参数的类型应该是不同的。您展示的示例正是这个,具有Interface1 类型的变量,但期望它也是Interface2 类型。

如果您希望能够同时调用这两种方法,您应该将变量example 的类型设置为Example。这样您就可以避免 instanceof 并完全进行类型转换。

如果您的两个接口 Interface1Interface2 没有那么松散耦合,并且您经常需要从两者调用方法,那么分离接口可能不是一个好主意,或者您可能想要另一个扩展两者的接口。

通常(尽管并非总是如此),instanceof 检查和类型转换通常表明存在一些 OO 设计缺陷。有时该设计适合程序的其余部分,但您会遇到一个小情况,类型转换比重构所有内容更简单。但是,如果可能的话,作为设计的一部分,您应该始终努力一开始就避免它。

【讨论】:

    【解决方案3】:

    您有两种不同的选择(我敢打赌还有很多)。

    第一个是创建自己的interface,它扩展了其他两个:

    interface Interface3 extends Interface1, Interface2 {}
    

    然后在整个代码中使用它:

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

    另一种方法(我认为更好的方法)是在每个方法中使用泛型:

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

    后一个选项实际上比前一个选项限制更少,因为泛型类型T 被动态推断,从而导致更少的耦合(类不必实现特定的分组接口,如第一个示例) .

    【讨论】:

    • "然后在整​​个代码中使用它" 这样做的显着缺点是你必须让 Example(和任何其他类)实现这个类。
    • @Andy 我同意,这就是为什么第二种方法更灵活,可能更受欢迎
    • 值得先说明您的首选选项;或者,至少,声明“我的首选方式”或类似的,这比“另一种方式”更有力的认可。
    • 当您为该问题提供正确的技术解决方案时,当基础问题是架构问题时,请注意实施变通办法。最终,您可能会发现自己因设计不正确而遇到更多问题。
    【解决方案4】:

    核心问题

    稍微调整您的示例,以便我可以解决核心问题:

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

    所以你定义了方法DoTheThing(Interface1 example)。这基本上是在说“要做这件事,我需要一个Interface1 对象”。

    但是,在您的方法主体中,您似乎实际上需要一个Interface2 对象。那你为什么不在你的方法参数中要求一个呢?很明显,你应该一直要求Interface2

    您在这里所做的是假设您获得的任何Interface1 对象也将是Interface2 对象。这不是你可以依赖的。你可能有一些类同时实现了这两个接口,但你也可能有一些类只实现了一个接口而不实现另一个接口。

    没有内在要求Interface1Interface2 需要在同一个对象上实现。你不知道(也不依赖假设)情况就是这样。

    除非您定义并应用内在要求

    interface InterfaceBoth extends Interface1, Interface2 {}
    
    public void DoTheThing(InterfaceBoth example)
    {
        example.someInterface2Method();
    }
    

    在这种情况下,您需要InterfaceBoth 对象来实现Interface1Interface2。因此,无论何时您请求InterfaceBoth 对象,您都可以确保获得一个同时实现Interface1Interface2 的对象,因此您可以使用任一接口中的方法,甚至无需强制转换或检查类型。

    您(和编译器)知道此方法将始终可用,并且不可能不工作。

    注意:您可以使用Example 而不是创建InterfaceBoth 接口,但是您将只能使用Example 类型的对象,而不能使用任何其他可以实现这两个接口的类.我假设您有兴趣处理任何实现这两个接口的类,而不仅仅是Example

    进一步解构问题

    看看这段代码:

    ICarrot myObject = new Superman();
    

    如果你假设这段代码可以编译,你能告诉我关于Superman 类的什么? 它清楚地实现了ICarrot 接口。这就是你能告诉我的。你不知道Superman 是否实现了IShovel 接口。

    所以如果我尝试这样做:

    myObject.SomeMethodThatIsFromSupermanButNotFromICarrot();
    

    或者这个:

    myObject.SomeMethodThatIsFromIShovelButNotFromICarrot();
    

    如果我告诉你这段代码可以编译,你会感到惊讶吗?您应该这样做,因为此代码无法编译

    你可能会说“但我知道这是一个 Superman 对象,它有这个方法!”。但是你会忘记你只是告诉编译器这是一个ICarrot变量,而不是Superman变量。

    你可能会说“但我知道它是一个实现IShovel 接口的Superman 对象!”。但是您会忘记您只是告诉编译器这是一个ICarrot 变量,而不是SupermanIShovel 变量。

    知道了这一点,让我们回顾一下你的代码。

    Interface1 example = new Example();
    

    你所说的只是你有一个Interface1 变量。

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

    假设这个Interface1 对象也恰好实现了第二个不相关的接口是没有意义的。即使此代码在技术层面上有效,这是糟糕设计的标志,开发人员仍期望两个接口之间存在某种内在关联,但实际上并未创建这种关联。

    您可能会说“但我知道我将一个 Example 对象放入其中,编译器也应该知道这一点!”但是你会错过这一点,如果这是一个方法参数,你将无法知道你的方法的调用者正在发送什么。

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

    当其他调用者调用此方法时,如果传递的对象没有实现Interface1,编译器只会停止它们。编译器不会阻止某人传递实现Interface1 但未实现Interface2 的类的对象。

    【讨论】:

    • 您回答的前约 50% 是在解释 OP 已经以非常冗长的方式理解的内容。 “我知道我可以有一个扩展两个接口的包装接口”
    • @Michael:这仅适用于“除非您定义...”这一小段,而不适用于“核心问题”。添加第二段不仅是为了提供解决方案,而且是为了解释它在意识形态上与 OP 的初始情况有何不同,以进一步阐明为什么编译器不承认 OP 的隐含期望。理解你能做到(这是 OP 所知道的,你是对的)与理解你为什么应该这样做以及为什么编译器期望你这样做并拒绝接受是不一样的代码。
    【解决方案5】:

    您的示例没有破坏 LSP,但似乎破坏了 SRP。如果遇到需要将对象强制转换到其第二个接口的情况,则可以认为包含此类代码的方法很忙。

    在一个类中实现 2 个(或更多)接口是可以的。在决定使用哪个接口作为其数据类型时,完全取决于将使用它的代码的上下文。

    投射很好,尤其是在更改上下文时。

    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
        }
    }
    

    【讨论】:

    • LSV?你不是说 LSP 吗?
    • 这肯定是一个错字,很抱歉。
    【解决方案6】:

    您所描述的问题通常是由于过度热心地应用接口隔离原则而引起的,这是由于语言无法指定一个接口的成员默认情况下应该链接到可以实现合理行为的静态方法。

    例如,考虑一个基本的序列/枚举接口和以下行为:

    1. 如果尚未创建其他迭代器,则生成一个可以读取对象的枚举器。

    2. 生成一个枚举器,即使已经创建和使用了另一个迭代器,它也可以读取对象。

    3. 报告序列中有多少项

    4. 报告序列中第N项的值

    5. 将一系列项目从对象复制到该类型的数组中。

    6. 产生对不可变对象的引用,该对象可以有效地适应上述操作,内容保证永远不会改变。

    我建议这些功能应该是基本序列/枚举接口的一部分,以及一个方法/属性,以指示上述哪些操作得到有意义的支持。某些类型的单次按需枚举器(例如,无限的真正随机序列生成器)可能无法支持任何这些功能,但是将这些功能分离到单独的接口中将使为多种类型生成有效包装器变得更加困难操作。

    可以在任何支持第一种能力的有限序列上生成一个封装类,该类将容纳上述所有操作,尽管不一定有效。但是,如果该类用于包装已经支持其中一些功能的对象(例如访问第 N 个项目),则让包装器使用底层行为可能比通过上面的第二个函数执行所有操作要高效得多(例如,创建一个新的枚举器,并使用它迭代地读取和忽略序列中的项目,直到达到所需的项目)。

    让产生任何类型序列的所有对象都支持包含上述所有内容的接口,以及支持哪些能力的指示,这比尝试为不同的能力子集使用不同的接口并要求包装类明确规定了他们想要向客户公开的任何组合。

    【讨论】:

      【解决方案7】:

      通常,许多特定于客户端的接口都很好,并且在某种程度上是Interface segregation principleSOLID 中的“I”)的一部分。在其他答案中已经提到了技术层面的一些更具体的观点。

      特别是你可以在这种隔离上走得太远,通过有一个类似的类

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

      或者,相反,你有一个过于强大的实现类,如

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

      我认为接口隔离原则的要点是接口应该是客户端特定的。他们应该基本上“总结”某个客户针对某项任务所需的功能。

      这么说:问题是要在我上面勾勒的极端之间取得适当的平衡。当我试图弄清楚接口及其关系(相互之间,以及实现它们的类)时,我总是试图退后一步,以一种天真的方式问自己: 将收到 what,而 what 他将用它做什么?

      关于您的示例:当您的所有客户总是同时需要Interface1Interface2 的功能时,您应该考虑定义一个

      interface Combined extends Interface1, Interface2 { }
      

      或者一开始就没有不同的接口。另一方面,当功能完全不同且不相关并且从未一起使用时,您应该想知道为什么单个类同时实现它们。

      此时,可以参考另一个原则,即Composition over inheritance。尽管传统上与实现多个接口无关,但在这种情况下,组合 也是有利的。例如,您可以更改您的类以不直接实现接口,而只提供实现它们的实例:

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

      在这个Example(原文如此!)中看起来有点奇怪,但根据Interface1Interface2 实现的复杂性,将它们分开确实很有意义。


      根据评论编辑:

      这里的意图是将具体类Example 传递给需要这两个接口的方法。这可能有意义的情况是,当一个类结合了两个接口的功能,但不是通过同时直接实现它们来做到这一点。很难编出一个看起来不太做作的例子,但这样的事情可能会让人想到这个想法:

      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; }
      }
      

      客户端仍将依赖接口。方法如

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

      然后可以调用

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

      分别。

      【讨论】:

      • 这是正确答案。该接口是为客户端代码需要实现的合约而构建的。如果客户端代码合理地期望能够调用 someInterface1Method 和 someInterface2Method,那么这是一个新合约。来自 ISP 的维基百科:“不应强迫任何客户端依赖它不使用的方法。”并且“客户只需了解他们感兴趣的方法。”
      • 我不确定我是否同意最后一点。如果您已经决定不能在概念上创建诸如Combined 之类的接口,因为这两个接口不相关,那么将这两个对象转储到一个组合对象中也不会更好。如果它们在概念上不相关,则使用方法应该只接受两个参数。
      • @Michael 我对最后一点犹豫不决,因为它可能看起来很奇怪或引起误解。明确一点:您是否理解这一点,就像 Example-object 应该传递给需要 both 接口的“消费”方法?我的意思是不是。 (如果可以这样理解,我会尽量让这个更清楚......)
      • 是的,就是那种东西。你还会用它做什么?
      • @Michael 我添加了一个“编辑”。如果您认为这太牵强或做作,我宁愿省略(最初)最后一段和“编辑”...
      【解决方案8】:

      借助这个页面上的各种帖子和cmet,已经产生了一个解决方案,我觉得这对我的场景是正确的。

      下面显示了解决方案的迭代更改以满足 SOLID 原则。

      要求

      为了生成 Web 服务的响应,键 + 对象对被添加到响应对象中。有许多不同的键 + 对象对需要添加,每个可能需要独特的处理将数据从源转换为响应中所需的格式。

      由此可以清楚地看出,虽然不同的键/值对在将源数据转换为目标响应对象时可能具有不同的处理要求,但它们都有一个共同的目标,即向响应对象添加对象。

      因此,在方案迭代1中产生了如下界面:

      解决方案迭代 1

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

      任何需要向响应中添加对象的开发人员现在都可以使用符合其要求的现有实现来执行此操作,或者在给定新场景的情况下添加新实现

      这很好,因为我们有一个通用接口,作为添加响应对象的这种常见做法的合同

      但是,一种情况要求目标对象应取自源对象,并给出特定的键“标识符”。

      这里有几个选项,第一个是添加一个已有接口的实现,如下:

      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"));
        }
      }
      

      这可行,但是其他源对象键(“startDate”、“endDate”等...)可能需要此方案,因此应使此实现更通用,以便在此方案中重用。

      另外,其他实现可能需要更多的上下文信息来执行 addObject 操作......所以应该添加一个新的泛型类型来满足这一点

      解决方案迭代 2

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

      该接口同时满足两种使用场景;需要额外参数来执行 addObject 操作的实现和不需要的实现

      但是,考虑到后者的使用场景,不需要额外参数的实现将打破 SOLID 接口隔离原则,因为这些实现将覆盖 getParams 和 setParams 方法但不实现它们。例如:

      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
          }
      
      }
      

      解决方案迭代 3

      为了解决接口隔离问题,getParams 和 setParams 接口方法被移到了一个新的接口中:

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

      需要参数的实现现在可以实现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));
        }
      }
      

      这解决了接口隔离问题,但又导致了两个问题......如果调用客户端想要对接口进行编程,即:

      ResponseObjectProvider responseObjectProvider = new  GetObjectBySourceKeyResponseObjectProvider<>();
      

      那么 addObject 方法将可用于实例,但不能使用 ParametersProvider 接口的 getParams 和 setParams 方法...调用这些方法需要强制转换,并且为了安全起见,还应该执行 instanceof 检查:

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

      这不仅是不可取的,它还破坏了 Liskov 替换原则 - “如果 S 是 T 的子类型,那么程序中 T 类型的对象可以被 S 类型的对象替换,而不会改变任何可取的该程序的属性"

      即如果我们用不实现 ParametersProvider 的实现替换了同样实现 ParametersProvider 的 ResponseObjectProvider 实现,那么这可能会改变程序的一些理想属性......此外,客户端需要知道正在使用哪个实现调用正确的方法

      另一个问题是调用客户端的用法。如果调用客户端想要使用实现这两个接口的实例多次执行 addObject,则需要在 addObject 之前调用 setParams 方法...如果调用时不小心,这可能会导致可避免的错误。

      解决方案迭代 4 - 最终解决方案

      解决方案迭代 3 生成的接口解决了所有当前已知的使用要求,泛型为使用不同类型的实现提供了一些灵活性。但是,此解决方案打破了 Liskov 替换原则,并且调用客户端的 setParams 用法并不明显

      解决方案是有两个独立的接口,ParameterisedResponseObjectProvider 和 ResponseObjectProvider。

      这允许客户端对接口进行编程,并根据添加到响应中的对象是否需要额外的参数来选择适当的接口

      新接口最初是作为 ResponseObjectProvider 的扩展实现的:

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

      但是,这仍然存在使用问题,调用客户端首先需要在调用 addObject 之前调用 setParams,这也会降低代码的可读性。

      所以最终的解决方案有两个独立的接口,定义如下:

      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);
      }
      

      该解决方案解决了对接口隔离和 Liskov 替换原则的违反,同时也改进了调用客户端的使用,提高了代码的可读性。

      这确实意味着客户需要了解不同的接口,但由于合同不同,这似乎是一个合理的决定,尤其是在考虑到解决方案已避免的所有问题时。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-05-04
        • 2019-10-29
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多