系列文章
浅谈物件导向 SOLID 原则对工程师的好处与如何影响能力再谈 SOLID 原则,Why SOLID?物件导向设计原则:单一职责原则,定义、解析与实践物件导向设计原则:开放封闭原则,定义、解析与实践物件导向设计原则:里氏替换原则,定义、解析与实践里氏替换原则(Liskov Substitution Principle)
定义
Subtypes must be substitutable for their base types.
-
子类别必须能取代父类别
里式替换原则是从 开放封闭原则 延伸出来的原则,若对开放封闭原则还不了解,建议先去了解开放封闭原则如何透过引入抽象来扩充程式码的行为,再来学习里氏替换原则!
目的
让开发人员确实地按照「介面」的定义进行实作,确保程式码名符其实,避免发生无法预料的事情。
程式码在编译阶段可以检查出型别错误,却不能检查出开发人员犯傻。
因此里式替换原则要求开发人员确实地按照「介面」的定义进行实作,否则程式的行为将变得「不可预测」。换句话说,程式码虽然可以“绕过”型别检查使编译成功,但有可能产生不可预知且不容易察觉的 Bugs。
解析
在开始讲解之前,必须先引用 Uncle Bob 在 2017 年《Clean Architecture》对里氏替换原则的补充:
物件导向革命的最初几年,里氏替换原则被用来指导「继承的使用」。然而,多年以来里氏替换原则已经涉及到介面与实作,演变成了更广泛的软体设计原则。
引用这段是为了让读者知道,里氏替换原则不但适用于 继承,也适用于 介面实作。
接下来将会讲解为什么里氏替换原则可以同时套用到 继承 与 介面实作,以及里氏替换原则对物件导向开发的影响。
物件跟「抽象」与「介面」息息相关
「抽象」是人类处理複杂事物的方式。
人的大脑可以接收的讯息有限,因此在现实生活中,人类往往会对複杂的事物进行简化,或将类似的事物归纳成同一类。对事物进行「抽象」虽然会忽略某些细节,但也让人类更易于沟通、学习与管理。
举例来说,向餐厅大厨点一份炒高丽菜就是利用「简化」进行抽象,我们不会告诉大厨怎么切菜、火要多大以及料理的顺序;学校常见的告示牌“教室内不能喝饮料”也则是透过「归纳」进行抽象,不可能将绿茶、奶茶、果汁、啤酒 ...等等全部写到告示牌上。
开发人员也会透过物件「封装」的功能对程式码进行抽象,把複杂的流程或业务规则隐藏到物件的内部。当程式码被抽象成为物件后,就可以透过「外部视角」和「内部视角」来观察一个物件:从「外部视角」观察物件时,只能看见程式码被简化成一系列的 抽象行为。从内部观察物件时,则可以看见每个行为的实作内容。
在外部视角中,只能得到物件公开(Public)的资讯,包含:公开属性、常数、方法签名(Signature,指方法名称与其参数)。我们会将这些物件公开的资讯统称为「介面」,所以很多物件导向设计(OOAD)的书籍提到介面时,可能同时是在讲 Interface、类别 和 抽象类别。
开发人员常常透过「介面」描述一个业务逻辑的基本特徵,包含要实现的功能目标与涉及範围。并忽略介面的实际结构与行为实作内容。
「继承」是为了共用父类别的介面
为了促使程式码遵循 开放封闭原则,开发人员可以透过物件导向的继承技术,继承父类别的「介面」来扩充业务规则的逻辑。
不论是继承或是介面,目的都是利用多型的特性来扩充业务规则的逻辑。这也是为什么里氏替换原则可以同时适用于继承与介面实作。
「继承」不为了共用父类别的程式码
若只是想要共用父类别的逻辑,应该使用组合,而不是使用继承。虽然没有人会限制开发人员随意地使用继承,但如果使用继承的目的不是为了「多型」,不但没有让继承功能派上用场,还会迫使子类别公开父类别的「介面」。
契约式设计(Design by Contract)
里氏替换原则延伸出契约式设计,契约式设计用了三个条件来规範开发人员应该如何遵循「介面」的实作:
前置条件(pre-conditions)
实作「介面」的实体物件,必须包含并保留所有「介面」的公开资讯。确保依赖「介面」的程式可以调用「介面」提供的功能。只有前置条件达成时,程式码才会执行后置条件的逻辑。
后置条件(post-conditions)
实作「介面」的实体物件,在执行完「介面」提供的功能后,必须回传「介面」指定的回传型别(Return Type)。约束开发人员要按照介面的定义实作功能。
不变性(invariants)
若 前置条件 或 后置条件 任一项条件没有达成,程式码就会报错。
这三个条件就是物件导向语言中的 Interface 的限制条件,因此 Interface 也经常被称作契约(Contract)。
範例
接下来利用 系统通知信件 示範违反与符合里氏替换原则的案例。
某系统有通知信件的功能,可以因应多种情境寄送对应的通知信件内容:
class EmailSender{ private $mail; private $emails; /** * 加入信件 * * @param string $address * @param EmailMaker $emailMaker 用于建立信件内容 */ public function addEmail($address, EmailMaker $emailMaker) { $email = [ 'address' => $address, 'emailHTML' => $emailMaker->makeEmailHTML(), ]; array_push($this->emails, $email); } /** * 寄送信件 */ public function send() { foreach ($this->emails as $email) { $this->mail->setAddress($email['address']); $this->mail->setBody($email['emailHTML']); $this->mail->Send(); } }}
在这个系统中,所有情境的通知信件都是透过 EmailSender
来寄送信件。从上面程式码中可以发现,开发人员希望透过 多型
来建立不同情境的信件样板,因此在 addEmail
方法中引入一个专门用来建立信件样板的介面 EmailMaker
:
interface EmailMaker{ /** * 建立信件 HTML 内容 * @return string */ public function makeEmailHTML(): string;}
到目前为止,EmailSender
已经建立起 开放封闭原则
的 Plugin 架构,开发人员只需要新增实作 EmailMaker
介面的类别,就能替系统建立全新的通知信件种类(开放扩充)。完全不需要更改 EmailSender
的程式码(关闭修改)。
里氏替换原则就像一个审查机制,监督开发人员在实作 开放封闭原则 Plugin 架构的介面(EmailMaker)时,让程式码的行为符合介面的定义。目的是确保开放封闭原则的核心业务逻辑(EmailSender)可以安全地使用 Plugin 来扩充逻辑。
违反里氏替换原则
/** * 上课迟到通知信件 HTML 产生器 */class LateForClassEmailHTML implements EmailMaker{ public function __construct($studentId, $classInfo) { $this->studentId = $studentId; $this->classInfo = $classInfo; } /** * 建立信件 HTML 内容 * @return string */ public function makeEmailHTML(): string { // 建立 上课迟到通知信件 HTML 样板 $this->template = new Template('emails'); $template = $this->template->load('emails/template/lateForClass', $this->classInfo); // 扣除学生课程总成绩 $studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']); $studentCourse->totalScore = $studentCourse->totalScore - 1; $studentCourse->save(); return $template; }}
在这个案例中,需求为「若学生上课迟到就寄送迟到通知信件,并扣除学生的课程总成绩 1 分」。
开发人员新增 LateForClassEmailHTML
类别并实作 EmailMaker
介面替系统新增「学生上课迟到」通知信件内容。
但是上面的範例违反了里氏替换原则,因为 EmailMaker
介面明确定义 makeEmailHTML
的目的是「建立信件 HTML 内容」,但开发人员却将「扣除学生的课程总成绩」逻辑写在 makeEmailHTML
函式中。虽然程式码仍然会通过型别检查(Type Hint),但却会增加维护系统的困难度。
这些「不符合介面定义的程式码」被放在不合理的地方,就会成为系统的技术债,开发人员会需要更多时间找碴程式码,例如,从 Controller 层根本看不出「扣除学生的课程总成绩」的逻辑在哪里被执行:
// Controller 层public function StudentLateForClass { /** ...省略 */ $emailMaker = new LateForClassEmailHTML($student->id, $classInfo); $emailSender = new EmailSender(); $emailSender->addEmail($student->email, $emailMaker); $emailSender->send();}
符合里氏替换原则
开发人员在实作「介面」的时候,应该完全按照介面的「定义」来撰写功能,而且要不多也不少:
/** * 上课迟到通知信件 HTML 产生器 */class LateForClassEmailHTML implements EmailMaker{ public function __construct($studentId, $classInfo) { $this->classInfo = $classInfo; } /** * 建立信件 HTML 内容 * @return string */ public function makeEmailHTML(): string { // 建立 上课迟到通知信件 HTML 样板 $this->template = new Template('emails'); $template = $this->template->load('emails/template/lateForClass', $this->classInfo); return $template; }}
「介面」不只是定义了一个类别的职责,也画出类别的边界。如果程式码不符合「介面」所定义的範围,就要将不符合定义的程式码从介面中搬移到适合的地方:
// Controller 层public function StudentLateForClass { /** ...省略 */ $emailMaker = new LateForClassEmailHTML($student->id, $classInfo); $emailSender = new EmailSender(); $emailSender->addEmail($student->email, $emailMaker); $emailSender->send(); // 扣除学生的课程总成绩 $studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']); $studentCourse->totalScore = $studentCourse->totalScore - 1; $studentCourse->save();}
结论
开放封闭原则必须透过 统一的抽象介面 来扩充核心业务规则的逻辑,因此在设计模式中作者们提出 “Program to an interface, not an implementation.”,将需求的问题域定义成抽象介面,系统才能安全地的扩展程式码。搭配里氏替换原则对开发人员的限制,确保程式码的行为符合「介面」的定义与预期,让开放封闭原则可以信任实作「介面」的程式码,最终让系统可以用「增量式开发」的方式进行迭代释出。