Collections.unmodifiableXXX方法是否违反了LSP原则?

48

里氏替换原则SOLID原则之一。我已经多次阅读了这个原则,并试图理解它。

以下是我的理解:

该原则与类层次结构中的强行为契约有关。子类型应能够替换超类型,而不违反契约。

我也阅读了其他文章,并对此问题感到有些困惑。 Collections.unmodifiableXXX() 方法是否违反LSP?

以上摘自上述文章的一段:

换句话说,通过其基类接口使用对象时,用户仅知道基类的前置条件和后置条件。因此,派生对象不得期望这样的用户遵守比基类要求更严格的前置条件。

我为什么这么想?

在此之前

class SomeClass{
      public List<Integer> list(){
           return new ArrayList<Integer>(); //this is dumb but works
      }
}

之后

class SomeClass{
     public List<Integer> list(){
           return Collections.unmodifiableList(new ArrayList<Integer>()); //change in implementation
     }
}

我无法更改SomeClass的实现以在未来返回不可修改的列表。编译将正常工作,但如果客户端尝试修改返回的List,则会在运行时失败。

这就是为什么Guava为集合创建了单独的ImmutableXXX接口吗?

这不是LSP的直接违规吗?还是我完全弄错了?


高度相关:https://dev59.com/OmIj5IYBdhLWcg3waEf8 - cHao
好链接。谢谢@Marco13 - Narendra Pathai
4个回答

43

LSP表示每个子类都必须遵守超类相同的契约。无论是否适用于,这取决于该契约的内容。

Collections.unmodifiableXXX()返回的对象如果尝试调用任何修改方法,则会抛出异常。例如,如果调用add(),将抛出UnsupportedOperationException

add()的一般契约是什么?根据API文档,它是:

确保此集合包含指定元素(可选操作)。如果此集合由于调用而更改,则返回true。(如果此集合不允许重复项并且已经包含指定元素,则返回false。)

如果这是完整的契约,那么确实不能在所有可以使用集合的地方使用不可修改的变体。但是,规范继续说:

如果集合因为除了已经包含该元素之外的任何原因而拒绝添加特定元素,则必须抛出异常(而不是返回false)。这保留了调用返回后集合始终包含指定元素的不变性。
这明确允许实现具有不将add的参数添加到集合中但导致异常的代码。当然,这包括客户端需要考虑(合法)可能性的义务。
因此,行为子类型化(或LSP)仍得到满足。但这表明,如果计划在子类中具有不同的行为,则必须在顶级类的规范中预见到这一点。
顺便问一句,好问题。

此外,文档定义了在特定情况下拒绝添加时应抛出的异常,包括UnsupportedOperationException。 - Silly Freak
这也是为什么您不应该修改来自您不了解的 API 的集合的原因。如果没有说明,集合是可变的,您应该始终创建一个新的集合。 - Kirill Rakhman
@cypressious:这只是其中一个非常小的原因。更大的原因是,即使知道集合允许突变,也无法确定是否已经获得了集合的副本,还是集合的视图,而不应该更改它。 - supercat

15

是的,我认为你说得对。基本上,要实现LSP,你必须能够使用子类型做任何可以使用超类型完成的事情。这也是为什么椭圆/圆问题会涉及到LSP。如果一个椭圆有一个 setEccentricity 方法,而圆是椭圆的子类,并且对象应该是可变的,那么圆就无法实现 setEccentricity 方法。因此,有一些你可以用椭圆做而你不能用圆做的事情,所以违反了LSP。同样地,使用 Collections.unmodifiableList 包装的列表与常规 List 相比,有一些你可以用常规列表做而不能用包装列表做,也是LSP违规。

问题在于,我们想要的东西(不可变、不可修改、只读列表)没有被类型系统捕获。在C#中,你可以使用 IEnumerable 来表示可以遍历和读取但不能写入的序列。但在Java中,只有 List,它通常用于可变列表,但有时我们想使用它作为不可变列表。

现在,有人可能会说,圆可以实现 setEccentricity 并简单地抛出异常,同样不可修改的列表(或者来自Guava的不可变列表)在试图修改它时也会抛出异常。但从LSP的角度来看,这并不意味着它是一个 List。首先,它至少违反了最小惊讶原则。如果调用方在尝试向列表中添加项目时得到意外异常,那相当令人惊讶。而且如果调用代码需要采取措施来区分它可以修改和不能修改的列表(或者其偏心率可以设置和不能设置的形状),那么一个就不能被替换为另一个。

如果Java类型系统有一个专门允许迭代的序列或集合类型,另外一个只允许修改,那将更好。也许可以使用Iterable,但我怀疑它缺少一些真正需要的功能,比如size()。不幸的是,我认为这是当前Java集合API的局限性。

有几个人注意到Collection的文档允许实现从add方法中抛出异常。我想这确实意味着不能修改的List在遵守add合同时是合法的,但我认为应该检查代码并查看有多少地方保护了对List的变异方法(add、addAll、remove、clear)调用,以try/catch块来防止LSP被违反。也许LSP没有被违反,但这意味着所有调用List.add的代码都是错误的。

这肯定说明了很多问题。

(类似的论点还可以表明,认为null是每种类型的成员也违反了Liskov替换原则。)

† 我知道还有其他解决椭圆/圆问题的方法,比如使它们成为不可变对象,或者删除setEccentricity方法。这里只讨论最常见的情况作为类比。


然而,List的操作方法指定它们可能有多种行为方式。合同仅规定必须采取其中一种方式,这适用于可修改和不可修改的List。严格来说,假设List的add方法不会抛出UnsupportedOperationException的所有人都违反了替换原则,而不是实现List的类。 - Silly Freak
我认为从技术上讲你是正确的,但是理论和实践中的合同是不同的。如果有两种类型,ImmutableList 和 List,并且 add 方法只存在于第二个类型中,我们一开始就不会陷入这种情况。 - David Conrad
3
换句话说,将一个方法放入你的API中,然后声明有时会执行某些操作,有时会抛出异常,这只会引发麻烦。这是一个潜在的bug,并且削弱了LSP本来应该保护你免受此类问题影响的功能。 - David Conrad
当然,还有其他设计可能性,其中可变和不可变列表之间存在类型区别,但我认为这并非必要。重要的是在调用者和被调用者之间传递断言 - 类型可以做到这一点。但即使没有类型区别,方法也可以在其文档中指定返回值为“不可变列表”,尽管这对编译器没有意义,但程序员可以知道他们不能修改它或将其替换为期望可修改列表的位置。 - Silly Freak
严格来说,对于可变列表也是如此,因此人们应该明确指定他们返回的列表等是否可修改,但我认为这符合最小惊讶原则的期望,因此更重要的是记录不可变列表。 - Silly Freak

5

我认为这并不违反规定,因为合同(即List接口)指出变异操作是可选的。


3
对于 LSP,"optional" 对我来说并不是很清楚。客户端代码绝不能使用它,因为可能没有它。 - djechlin
@djechlin 是的,我也这么认为。如果不支持某些操作,那么它们就不应该成为Immutable接口的一部分。 - Narendra Pathai
@NarendraPathai:如果接口Wozzler的许多实现也实现了方法Wuzzle,并且许多Wozzler的使用者希望在可用时使用方法Wuzzle,否则做其他事情,那么我认为最好的方法通常是让Wizzler包括Wuzzle,以及一种确定给定实例是否支持它的方法,而不是让Wuzzle仅出现在Wuzzler接口中而不在Wizzler中。如果支持按索引写入的固定大小集合和支持add但不支持按索引写入的可变大小集合共享一个公共接口 - supercat
然后,“WrappedObservableCollection”类型将能够与两个操作(允许客户端使用底层集合支持的任何功能)。在类型系统中分离接口功能,而不是通过功能报告方法来实现,这意味着每个集合可能具有的能力组合都需要不同的包装器类。 - supercat

-1

我认为你在这里没有混淆概念。
来自LSP

Liskov的行为子类型定义了可变对象的可替换性概念;也就是说,如果S是T的子类型,则程序中类型为T的对象可以被类型为S的对象替换,而不会改变该程序的任何期望属性(例如正确性)。

LSP指的是子类

List是一个接口而不是一个超类。它指定了一个类提供的方法列表。但是,与父类耦合的关系并不紧密。类A和类B实现相同的接口并不能保证这些类的行为。一种实现可能总是返回true,另一种抛出异常或总是返回false或其他任何东西,但两者都遵循接口,因此调用者可以在对象上调用方法。


6
为此目的,接口基本上可以被视为超类的同义词。实际上,它们在很大程度上只是用于解决缺乏多重继承的问题的一个不完整的解决方法 - 在一些具有多重继承功能的编程语言中(如C ++),"接口"实际上只是具有纯虚拟成员的类。一个接口很少仅仅提供函数名称列表;它通常还定义了这些函数的作用,任何符合该接口的内容都应该执行这些操作。 - cHao
@cHao:我不同意。接口和超类是两个不相关的概念。LSP只涉及子类型化。机制相似,可以在IntefaceA的方法中传递InterfaceImplInterfaceImpl2的参数,但我们仍然没有按照LSP所说的进行子类型化。引用自Oracle的说法:“如果你的类声称实现一个接口,在该类的源代码中必须包含该接口定义的所有方法,否则类将无法成功编译。” 请参考:http://docs.oracle.com/javase/tutorial/java/concepts/interface.html - Jim
Oracle 正在专注于 Java 语言本身。Java 不关心 SOLID,也无法有效地强制执行它。但是 LSP 仍然适用。更重要的是,你实际上依赖于它的应用。证明:假设我创建了一个 List<T>,其中每个函数都会抛出 RuntimeException。这是编译器允许的,如果且仅当接口本身具有已知的属性,而实现中缺少这些属性时,这就违反了 LSP。因此,我传递我的“列表”,你尝试使用它,结果 !现在,谁该为导致的崩溃负责?如果你责怪我,那么你就依赖于 LSP。 - cHao
我同意@cHao的观点,无论如何规定,LSP适用于契约 - Marco Lackovic
@cHao说“接口是对于多重继承的缺失的一种半吊子解决方案”就像在类似Haskell这样的语言中说“Maybe Monad是对于null的缺失的一种半吊子解决方案”。这是有意为之的。它试图在保护语言用户的同时仍然保留有用的用例。 - Coderino Javarino
@Coderino:无论是故意的还是不经意的,它仍然是半吊子的。MI并不是什么可怕的东西,每一次试图去除它但保留好的部分,都会以某种方式变得丑陋和/或破碎。该回答本身就是证据——在心理上区分接口和类导致了对LSP的错误解释。但是,半吊子与否完全与重点无关。 - cHao

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接