物件导向设计原则:里氏替换原则,定义、解析与实践

系列文章

浅谈物件导向 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.”,将需求的问题域定义成抽象介面,系统才能安全地的扩展程式码。搭配里氏替换原则对开发人员的限制,确保程式码的行为符合「介面」的定义与预期,让开放封闭原则可以信任实作「介面」的程式码,最终让系统可以用「增量式开发」的方式进行迭代释出。


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章