如何避免通过实现多个接口的类破坏Liskov替换原理?

时间:2019-01-14 15:25:22

标签: java liskov-substitution-principle

提供以下课程:

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?

我使用的任何实现似乎在不良设计或用法中都有一些副作用。

8 个答案:

答案 0 :(得分:35)

  

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

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

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

绝对不依赖于投射任何东西。它只能用作不得已的方法,通常只能用于非常特殊的问题(例如序列化)。

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


如果要实例化对象并在相同范围内使用它,则应该只写

Example example = new Example();

很明显(我不确定这是否是您的建议),在任何情况下,您永远都不会写这样的东西:

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

答案 1 :(得分:25)

您的类可以很好地实现多个接口,并且没有违反任何OOP原则。相反,它紧跟interface segregation principle

令人困惑的是,为什么您会遇到Interface1类型的someInterface2Method()类型的事物。那就是您的设计错误的地方。

以一种稍微不同的方式来考虑它:假设您有另一种方法void method1(Interface1 interface1)。它不能期望interface1也是Interface2的实例。如果是这种情况,则参数的类型应该已经不同。您显示的示例正是这样,它的变量类型为Interface1,但希望它的变量类型也为Interface2

如果您希望能够调用这两种方法,则应将变量example的类型设置为Example。这样一来,您就可以避免使用instanceof并完全键入强制转换。

如果您的两个接口Interface1Interface2松耦合,并且您经常需要从这两个接口中调用方法,那么也许分开接口不是一个好主意,或者希望有一个扩展两者的接口。

通常(尽管并非总是如此),instanceof检查和类型强制转换通常表明某些OO设计缺陷。有时,该设计适合该程序的其余部分,但是您会遇到一个小案例,即键入强制类型转换而不是重构所有内容都更简单。但是,如果可能的话,作为设计的一部分,您应该一开始就尽量避免这样做。

答案 2 :(得分:11)

您有两种选择(我敢打赌,还有更多选择)。

首先是创建自己的interface,以扩展其他两个:

interface Interface3 extends Interface1, Interface2 {}

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

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

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

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

事实上,后一个选项的限制比前一个选项受限制,因为泛型类型T是动态推断的,因此导致耦合较少(类不必像第一个那样实现特定的分组接口例如)。

答案 3 :(得分:8)

核心问题

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

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变量。

您可能会说“但我知道这是一个实现Superman接口的IShovel对象!”。但是,您会忘记了您只告诉编译器它是一个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的类的对象。

答案 4 :(得分:7)

您的示例没有破坏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
    }
}

答案 5 :(得分:5)

通常,许多特定于客户端的接口都可以,并且在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 {
    ...
}

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

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

关于您的示例:当所有客户总是同时需要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());

分别。

答案 6 :(得分:4)

在此页面上的各种帖子和评论的帮助下,已经产生了一种解决方案,我认为这对我的情况是正确的。

下面显示了对解决方案的迭代更改,以满足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接口方法移至新的Interface:

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的非显而易见用法

解决方案是拥有两个单独的接口,ParameterizedResponseObjectProvider和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替换原理的问题,还改善了调用客户端的用法并提高了代码的可读性。

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

答案 7 :(得分:1)

您描述的问题通常是由于过分热衷于应用接口隔离原理,这是由于语言无法指定一个接口的成员默认情况下应链接到可以实现明智行为的静态方法而引起的。 / p>

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

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

  2. 产生一个枚举器,即使已经创建并使用了另一个迭代器,该枚举器也可以读出对象。

  3. 报告序列中有多少项

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

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

  6. 提供了对不可变对象的引用,该对象可以保证内容永不更改,从而可以有效地容纳上述操作。

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

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

让所有能够产生任何序列的对象都支持一个包含以上所有内容的接口,并指出所支持的能力,这比尝试为不同能力的子集使用不同的接口要干净,并且要求包装器类为要公开给客户的任何组合提供了明确的规定。