问题

Java 中的多态是不是违背了里氏替换原则?

回答
关于 Java 中的多态是否违背里氏替换原则(Liskov Substitution Principle,LSP)的问题,这是一个值得深入探讨的细节。简单来说,Java 的多态本身是 LSP 的基石,而非违背者。 然而,在实际的 Java 编程中,不恰当的使用多态,或者创建不符合 LSP 的子类,确实会导致我们所说的“违背”现象。

要理解这一点,我们先要明确这两个概念。

多态 (Polymorphism):在面向对象编程中,多态意味着“多种形态”。在 Java 中,多态主要体现在以下几个方面:

编译时多态(静态多态):也称为方法重载(Overloading)。同一个方法名可以有不同的参数列表,编译器会根据传入的参数类型和数量选择最匹配的方法。
运行时多态(动态多态):也称为方法覆盖(Overriding)。子类可以提供自己对父类中已定义方法的具体实现。当通过父类类型的引用调用这个方法时,实际执行的是子类重写后的方法,具体执行哪个方法是在运行时确定的。这是我们通常谈论多态时强调的重点。

里氏替换原则 (Liskov Substitution Principle, LSP):由 Barbara Liskov 提出,它是面向对象设计的一个基本原则。其核心思想是:如果 S 是 T 的子类型,那么 T 的对象可以用 S 的对象来替换,而程序的行为不应该发生改变。

换句话说,子类应该能够无缝地替换父类,而不引起任何意外的错误或结果。

为什么说 Java 的多态是 LSP 的支撑,而不是违背?

Java 的运行时多态机制,正是为了实现“一个接口,多种实现”而设计的。当我们声明一个父类类型的变量,并让它指向一个子类对象时,我们期望的是“无论这个引用指向的是哪个具体的子类,调用某个方法时,都能按照该子类自身的逻辑去执行”。这正是 LSP 所倡导的“可替换性”。

例如:

```java
class Animal {
public void makeSound() {
System.out.println("Some generic sound");
}
}

class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof!");
}
}

class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow!");
}
}

// 在某个方法中使用多态
public void performSound(Animal animal) {
animal.makeSound(); // 这里调用的是哪个 makeSound() 取决于 animal 实际指向的对象
}
```

在这个例子中,`performSound` 方法接收一个 `Animal` 类型的参数。我们可以传入 `Dog` 对象或 `Cat` 对象,它们都可以被 `Animal` 引用所持有。当 `animal.makeSound()` 被调用时,JVM 会根据 `animal` 实际指向的对象(是 `Dog` 还是 `Cat`)来执行相应子类中的 `makeSound()` 方法。这完美地体现了多态的优势,并且符合 LSP:无论 `animal` 是 `Dog` 还是 `Cat`,调用 `makeSound()` 都能得到一个合乎情理的动物叫声,程序行为是可预测的。

那么,什么情况下会出现“违背 LSP”的感觉?

问题往往出在子类对父类契约(Contracts)的破坏。LSP 不仅仅是方法签名相同,更重要的是,子类必须继承并尊重父类的行为契约。这个契约包含:

1. 前置条件(Preconditions):调用方法时,需要满足的条件。子类不允许比父类有更严格的前置条件。如果父类方法允许接受 `null`,子类也不应拒绝 `null`。
2. 后置条件(Postconditions):方法执行后,期望达到的状态。子类必须保证这些后置条件能够被满足。
3. 不变式(Invariants):对象在任何时候都必须满足的条件。子类必须继承父类的所有不变式,并且不能破坏它们。
4. 历史无关性(Historyless):子类方法的行为不应依赖于父类历史上的某些行为(除非这种依赖是被明确设计并继承的)。

当子类破坏了这些契约,那么它就成为了 LSP 的“违背者”,即使它使用了多态的语法。

举几个常见的 LSP 违背场景(这些场景在 Java 中非常普遍,也容易被误认为是多态的“问题”):

1. 方法行为的根本性改变:
例子:假设有一个 `Rectangle` 类,有一个 `setWidth()` 和 `setHeight()` 方法。如果我们创建一个 `Square` 类继承 `Rectangle`,并重写 `setWidth()` 和 `setHeight()`。在 `Square` 中,当 `setWidth()` 被调用时,我们可能同时改变 `height` 以保持正方形的特性(即 `height = width`)。反之亦然。
违背:如果一个地方期望得到一个 `Rectangle`,传入一个 `Square` 对象。我们可能会先设置宽度,然后设置高度。但由于 `Square` 的特殊实现,设置高度的操作可能会导致宽度也随之改变,这与 `Rectangle` 的独立设置宽度和高度的行为不同。这意味着,用 `Square` 替换 `Rectangle` 会改变程序对“矩形”这个概念的预期行为。

2. 异常的抛出:
例子:父类方法承诺不抛出特定类型的异常,或者只抛出某个受控范围内的异常。如果子类重写该方法,却抛出了父类没有定义的异常,或者抛出了更广泛范围的异常,这就会打破 LSP。
违背:调用者可能基于父类的不抛异常承诺来编写代码,当它遇到抛出异常的子类时,就会发生意料之外的运行时错误。

3. 参数的限制:
例子:父类的一个方法接受任何整数作为参数。子类重写此方法,却只接受正整数。
违背:如果代码期望传入任何整数,并传递了一个负数给子类实例,子类可能会抛出异常或产生不正确的结果,而父类实例则不会。

4. 返回值类型和可空性:
例子:父类的方法返回一个非空字符串。子类重写的方法却返回 `null`。
违背:依赖父类不返回 `null` 的调用者,在处理子类实例时就会遇到 `NullPointerException`。

总结

Java 的多态机制本身是支持 LSP 的,它允许我们通过父类类型来引用不同的子类对象,并在运行时动态地选择正确的实现。多态的威力在于其“一个接口,多种实现”的能力,这正是 LSP 所要达到的“可替换性”的目标。

真正导致“违背 LSP”现象的,不是多态语法本身,而是设计不良的继承关系。当子类没有完全继承父类的行为契约,而是通过改变方法的前置条件、后置条件、不变式,或者引入不可预期的行为来“实现”父类方法时,就会发生 LSP 的破坏。

因此,在 Java 编程中,我们应该充分利用多态的优势,但同时也要警惕并避免创建不符合 LSP 的子类。这意味着在设计继承层次时,要仔细考虑父类的方法行为如何被子类继承和扩展,确保子类能够“忠实地”替换父类,而不改变程序的整体行为和可靠性。这需要设计师对“契约”有深刻的理解,并将其贯彻到具体的类设计中。

网友意见

user avatar
里氏替换原则要求子类避免重写父类方法

来源请求……



基本上这是胡说八道……

就算不考虑SOLID原则仅仅只具备指导意义,L的说法是,派生类对象应当可以完全代换基类对象。

没有人说不能override,如果不能override,那你还派生个毛,整个面向对象的基础都没了,直接mixin不香么?要搞什么OO?


说白了,L的本质就是,接口的实现必须满足调用者对接口的所有期望而不是仅仅满足接口的签名。当然这样讲你们不是听不明白么?就只好婆婆妈妈的说什么派生类必须可以替代基类什么的废话……

类似的话题

  • 回答
    关于 Java 中的多态是否违背里氏替换原则(Liskov Substitution Principle,LSP)的问题,这是一个值得深入探讨的细节。简单来说,Java 的多态本身是 LSP 的基石,而非违背者。 然而,在实际的 Java 编程中,不恰当的使用多态,或者创建不符合 LSP 的子类,确.............
  • 回答
    在多核CPU环境下,Java中的`Thread.currentThread()`调用返回的是一个`Thread`对象,它代表了当前正在执行这个方法的线程。然而,这个`Thread`对象本身并不直接包含它当前被调度执行在哪一个具体的CPU核心上的信息。你可以这样理解:线程是一个逻辑概念,CPU核心是物.............
  • 回答
    在 Java 中,接口的多继承(准确说是接口的“继承”)之所以会对拥有相同方法签名(方法名、返回类型、参数列表)但不同返回类型的方法产生报警,甚至阻止编译,根本原因在于 Java 语言设计上对多继承的一种“妥协”和对类型的明确性要求。想象一下,如果你有两个接口,A 和 B,它们都声明了一个名为 `g.............
  • 回答
    Java 中 `==` 和 `equals()` 的区别:刨根问底在 Java 编程的世界里,我们经常会遇到比较对象是否相等的需求。这时候,两个最直观的工具便是 `==` 操作符和 `equals()` 方法。然而,它们虽然都用于比较,但其内涵和适用场景却有着天壤之别。理解这两者的区别,是掌握 Ja.............
  • 回答
    在Java语言的世界里,那些被赋予了特殊含义、在编写代码时具有固定用途的词汇,也就是我们常说的“关键字”,它们并非随意存在,而是深深地嵌入在Java语言的语法结构和核心设计之中。可以想象,Java关键字就好比一个国家的法律条文,它们是由Java语言的设计者们在创造这门语言时,根据语言的特性、目的以及.............
  • 回答
    Python 的 `lambda` 和 Java 的 `lambda`,虽然名字相同,都服务于函数式编程的概念,但在实现方式、使用场景和语言特性上,它们有着本质的区别,这使得它们在实际运用中展现出不同的风貌。我们先从 Python 的 `lambda` 说起。Python 的 `lambda`,可以.............
  • 回答
    我们来聊聊Java中,当一个对象a“持有”另一个对象b的静态常量时,这对于垃圾回收器(GC)而言,会产生什么影响。首先,我们需要明确一点:静态常量在Java中是与类相关联的,而不是与类的某个特定实例(对象)相关联的。 也就是说,无论你创建了多少个对象b,或者根本没有创建对象b,只要类b被加载到JVM.............
  • 回答
    Java 泛型类型推导,说白了,就是编译器在某些情况下,能够“聪明”地猜出我们想要使用的泛型类型,而不需要我们明确写出来。这大大简化了代码,减少了繁琐的书写。打个比方,想象你在一个大型超市购物。你手里拿着一个购物篮,你知道你打算买很多东西。场景一:最简单的“显而易见”你走进超市,看到一个标着“新鲜水.............
  • 回答
    关于Java中堆和栈的运行速度差异,这不仅仅是“谁快谁慢”这么简单,背后涉及到它们各自的内存管理机制和数据访问方式。理解这一点,我们需要深入剖析它们的工作原理。栈:速度的直接体现首先,我们来看看栈。栈在Java中主要用于存储局部变量、方法调用时的参数以及方法执行过程中的返回地址。你可以想象成一个整洁.............
  • 回答
    Java 平台中的 JVM (Java Virtual Machine) 和 .NET 平台下的 CLR (Common Language Runtime) 是各自平台的核心组件,负责托管和执行代码。它们都是复杂的软件系统,通常会使用多种编程语言来构建,以充分发挥不同语言的优势。下面将详细介绍 JV.............
  • 回答
    这段 Java 代码中的局部变量,理论上确实存在被提前回收的可能性。不过,这里的“提前回收”并非我们直观理解的,在代码执行完毕前就完全从内存中消失。更准确的说法是,这些局部变量的内存占用可以在其生命周期结束后,但不等到方法执行结束就被JVM判定为“无用”,从而有机会被垃圾回收器(Garbage Co.............
  • 回答
    Java 栈内存之所以存取速度极快,仅次于 CPU 内部的寄存器,这主要得益于其固定的内存分配方式以及遵循后进先出(LIFO)的单向操作模式。我们来深入剖析一下其中的奥秘。1. 栈内存的结构与分配:简单、有序、预分配想象一个仓库,里面有很多堆叠起来的箱子。栈内存就像是这样一个仓库,但它的特点是: .............
  • 回答
    作为一名在Java世界里摸爬滚打多年的开发者,我总会时不时地被Java的某些设计巧思所折服,同时也曾浪费过不少时间在一些细枝末节上,今天就来和大家聊聊,哪些地方是真正值得我们深入钻研的“精华”,哪些地方可能只是“旁枝末节”,不必过于纠结。 Java的“精华”:值得你投入热情和时间去领悟的部分在我看来.............
  • 回答
    你已经掌握了 C 语言的基础,这为你进一步学习编程语言打下了非常坚实的地基。C 语言的指针、内存管理、以及面向过程的编程思想,这些都是理解更高级语言的关键。那么,在你面前的 C、C++、Java、Swift 中,哪个更适合你接着深入呢?这确实是个值得好好琢磨的问题,因为它们各有千秋,也代表着不同的技.............
  • 回答
    “Java 在虚拟机中运行”,这句话确实是理解 Java 运行机制的关键,但把 Java 虚拟机(JVM)简单地视为一个“解释器”,其实只说对了一部分,而且是比较片面的一面。要详细说清楚,我们需要先拆解一下JVM到底做了什么。首先,我们得明白,Java 代码在被 JVM 运行之前,并不是直接以我们写.............
  • 回答
    你遇到的问题很常见,就是在一个for循环里逐个调用耗时的网络API,导致整体执行时间很长。解决这类问题,关键在于并行化和优化。下面我将从几个层面,详细讲解如何在Java中减少这种for循环调用网络API的耗时。 核心思想:从“串行”到“并行”想象一下,你有一个长长的待处理任务列表(就是你的for循环.............
  • 回答
    这个问题很有意思,也很常见,很多人初学Java时会遇到类似的疑惑。其实,Java 接口之所以能调用 `toString()` 方法,并不是接口本身“拥有”或“定义”了 `toString()`,而是Java语言设计中的一个重要机制在起作用。首先,我们需要明确一点:Java 中的接口(interfac.............
  • 回答
    在 Java 编程中,我们常常会看到这样一种写法:使用 `Map` 或 `List` 这样的接口声明变量,而不是直接使用 `HashMap`、`ArrayList` 这样的具体实现类。这背后蕴含着一种非常重要的编程思想,也是 Java 语言设计上的一个亮点,我们来深入聊聊为什么这样做。核心思想:面向.............
  • 回答
    这个问题,就像问是在崎岖的山路上徒步,还是在平坦的公路开车,各有各的精彩,也各有各的挑战。C++ 和 Java,这两位编程界的“巨头”,各有千秋,选择哪一个,完全取决于你的目的地和对旅途的要求。咱们先从 C++ 说起,这位老兄,绝对是编程界的“老炮儿”。C++:力量与控制的艺术如果你想要的是极致的性.............
  • 回答
    许多开发者在讨论依赖注入(Dependency Injection,DI)时,常常会将其与 Java 技术栈紧密联系在一起。确实,在 Java 生态系统中,Spring 框架的普及使得 DI 成为了构建大型、可维护应用程序的标准模式。然而,将 DI 视为 Java 独有的概念,或者认为它在 Go 和.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有