Loading... > 我们就来看看可以把继承体系设计好的设计原则:Liskov 替换法则。 我们需要站在父类的角度去看,设计行为一致的子类,而站在子类的角度,常常是破坏 LSP 的做法 多态更多是语法方面的,里式替换更多强调设计方面的。即使多态语法支持,也可能违反了里式替换原则。 子类必须能够替换它们的基类(IS-A),多态+子类比父类更宽松(可以有自己特有的方法) # 一、Liskov与多态之间的区别 虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。 多态是面向对象编程的一大特性,也是面向对象编程语言的`一种语法`。它是一种代码实现的思路。 而里式替换是一种`设计原则`,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。 # 二、Liskov 替换原则 ```cpp 如下替换性质:若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2, 使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。 或者: 子类对象(object of subtype/derived class)能够替换程序(program)中 父类对象(object of base/parent class)出现的任何地方, 并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。 ``` 子类型(subtype)必须能够替换其父类型(base type)。 比如,父类型规定接口不能抛出异常,而子类型抛出了异常,就会导致程序运行的失败。这就违反了LSP。 ```cpp void handle(final Handler handler) { if (handler instanceof ReportHandler) { // 生成报告 ((ReportHandler)handler).report(); return; } if (handler instanceof NotificationHandler) { // 发送通知 ((NotificationHandler)handler).sendNotification(); } ... } ``` 这段代码显然是违反了 OCP 的,在这段代码的处理中,是通过运行时类型识别(Run-Time Type Identification,简称 RTTI),也就是这里的 instanceof,知道子类型是什么的,然后去做相应的业务处理。 但是,ReportHandler 和 NotificationHandler 虽然都是 Handler 的子类,但它们没有统一的处理接口,如果你发现了任何做运行时类型识别的代码,很有可能已经破坏了 LSP。 `代码中出现RTTI(比如`**dynamic\_cast**`)相关的代码肯定是违反Liskov变换规则`。 # 三、基于行为的 IS-A > 如果 B 是 A 的子类,就需要满足 B 是一个 A(B is a A) IS-A 的判定是基于行为的,`只有行为相同`,才能说是满足 IS-A 的关系。 ## 3.1、类的继承 经典问题:长方形正方形问题,正方形确实是一种长方形吗? ```cpp class Rectangle { public: void setLength(int length) { m_nLength = length;} void setWidth(int width) { m_nWidth = width; } int area() { return m_nWidth * m_nLength; } private: int m_nLength; int m_nWidth; }; class Square : public Rectangle { public: void setLength(int length) { m_nSide = length;} void setWidth(int width) { m_nSide = width; } int area() { return m_nSide * m_nSide; } private: int m_nSide; }; void testArea(Rectangle *rec) { rec->setLength(3); rec->setWidth(4); Q_ASSERT(12 == rec->area()); } ``` 因为它在下面这个测试里会失败: ```cpp Rectangle rect = new Square(); testArea(rect); ``` 如果想保证断言(assert)的正确性,Rectangle 和 Square 二者在这里是不能互相替换的。使用 Rectangle 的代码必须知道自己使用的到底是 Rectangle 还是 Square。 问题分析: 本质上,`在定义上,子类不能比父类更严格,更严格就不适用于里式替换原则了`。 其实,从前面的分析中,你也能看出一些端倪来,IS-A 的判定是基于行为的,只有行为相同,才能说是满足 IS-A 的关系。对于C++中的继承关系,(is-A关系),**要求子类只能比父类更宽松,不能更严格。才能保证父类所有的性质在子类中都成立**。 回到经典问题: 我们平时在数学中说的“正方形是特殊的长方形”,更精确的理解应该是“正方形属于长方形”,而不是“正方形是长方形”,当然中文中可以理解成一个意思,英文不同,is-a和beyond to是两个不同的意思。 延伸一下,“属于”可以说“正方形是长方形的”,又别扭了,因为主体变了,后者“长方形”不具备拥有属性,虽然前者具备属于属性。 有人说:为了保证里式替换原则,也就是保证继承的纯粹性,就要做到: 1:子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。 2:子类中可以增加自己特有的方法。 3:当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。 4:当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。 ## 3.2、继承数据结构 ```cpp class Students : public List<Student> { ... } ``` 这种做法想做的就是实现继承,而我们在前面讲继承的时候,就说过这种做法的问题。 LSP 的关注点让人把注意力放到父类上,而一旦子类成了重点,我们必须小心谨慎。在前面讲继承的时候,我们说过,关心子类是一种实现继承的表现. # 四、更广泛的 LSP # 五、哪些代码违背了LSP > 里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计” 子类在设计的时候,要遵守父类的行为约定(或者叫协议)。**父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定**。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。 ## 5.1. 子类违背父类声明要实现的功能 父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。 ## 5.2. 子类违背父类对输入、输出、异常的约定 在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。 **父类的方法,子类不去使用,使用异常,其实这种场景更适合用组合而不是继承** ## 5.3. 子类违背父类注释中所罗列的任何特殊说明 父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。 以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。 **理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。** 最后修改:2025 年 07 月 01 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏