当前本站共有 19 篇文章

🗓️ 2025-02-28 🎖️ 设计模式专项总结 🗂️ 软件工程 🏷️ #SE

设计模式

1 关系

关联 & 依赖

PlantUML Diagram
Note

依赖强调一方要使用另一方、依赖另一方而存在

Warning

动物依赖氧气而活,这是依赖关系。动物活着也会与气候发生关系,但是动物不是直接依赖气候而活,因此是关联关系。公交司机之所以是公交司机,是因为有公交车存在,这是依赖关系。

组合 & 聚合

聚合和组合都是强调整体与部分的关系,也都可以视为一种关联关系

PlantUML Diagram
Note

聚合的部分可以脱离整体而存在,而组合的部分不能脱离整体而存在

Note

二者与关联关系的区别就在于,聚合与组合,更强调整体与部分之间的关系。鸟与翅膀、班级与学生之间自然存在关联,但他们之间有更进一步的整体部分关系,而非仅仅是关联关系。

泛化 & 实现

PlantUML Diagram
Note

继承强调类的层次关系,上层是下层的泛化,下层是上层的派生。实现强调的是方法,即定义一组规范接口,让别的类去实现,并没有泛化派生的意味

下图是一张经典的 UML 图:

2 SOLID 原则

里氏替换原则 LSP

概念

开闭原则(OCP)背后的主要机制是抽象(abstraction)和多态(polymorphism)。在静态类型语言中,比如 C+和 Java,支持抽象和多态的关键机制之一是继承(inheritance)。正是使用了继承,我们才可以创建实现其基类(base class)中抽象方法的派生类。 是什么设计规则在支配着这种特殊的继承用法呢?最佳的继承层次的特征又是什么呢?怎样的情况会使我们创建的类层次结构掉进不符合 OCP 的陷阱中去呢?这些正是 Liskov 替换原则(LSP)要解答的问题。 ——《敏捷软件开发:原则、模式和设计》

Let q(x) be a property provable about objects x of type T . Then q(y) should be true for objects y of type S where S is a subtype of T .

Warning

这里需要如下替换性质:若对每个类型 S 的对象 O1,都存在一个 T 类型的对象 O2,使得在针对 T 类型编写的程序 P 中,用 O1 替换 O2 后,程序 p 的行为功能保持不变,则 S 是 T 的子类型

总之就是一句话:

Note

子类型必须能够替换掉它们的基类型。说得长一点:当你扩展一个类时, 记住你应该要能在不修改客户端代码的情况下将子类的对象作为父类对象进行传递。

核心理解

在 OOP 中,我们通常是把表现出相同行为的一类对象,抽象为一个类。这里要特别注意,也就是说,我们真正看重的,用以标志区分类的,是对象/类的行为

继承是一种is-a关系,“派生类,是一种基类”。但是 IS-A 的含义太过宽泛了,在实际编程中可能会与真正的“行为”角度要求有抵触。看两个例子:

正方形是矩形,也不是矩形;鸵鸟是鸟,也不是鸟。这种悖论产生的原因有二1

  1. 继承关系的定义没搞清楚:面向对象的设计关注的是对象的行为,它是使用“行为”来对对象进行分类的,只有行为一致的对象才能抽象出一个类来,而不是拍脑袋决定 is-a

  2. 设计要依赖于用户需求和具体环境:需求、关注点不同,关系自然也会不同

LSP 就是一种描述如何设计类继承结构最好,不会出现上述问题的原则。它告诉我们:

Note

子类型的正确定义是“可替换性的”,子类对象是可以替换父类对象而保持功能正常的。这里的可替换性可以通过显示或隐式的契约来定义

不满足可替换性,也能写出符合语法的继承结构,也可以工作,但是不总是能正确工作。也即,违背 LSP 的代码,可以正确实现继承、多态等语法功能,但是不见得能满足我们的预期。具体看后面的例子

代码示例

“正方形不是矩形”

这里多态是能满足的,继承体系从语法上讲是没问题的,但是从预期行为上看是有毛病的。在这里,在边的行为上,Square 不能替换掉 Rectangle,违背了 LSP。

事实上可以重新设计类层次:提取公共部分,创建一个四边形抽象类。关于提取公共部分:

如果一组类都支持一个公共的职责,那么它们应该从一个公共的超类(superclass)继承该职责。如果公共的超类还不存在,那么就剑建一个,并把公共的职责放入其中。毕竟,这样一个类的有用性是确定无疑的—你已经展示了一些类会继承这些职责。然而稍后对系统的扩展也许会加入一个新的子类,该子类很可能会以新的方式来支持同样的职责。此时,这个新创建的超类可能会是一个抽象类。

有公共部分,就应当考虑提取出来创建一个新的抽象类。不然就意味着代码面临重复的问题

除了提取公共部分,还有一个原则:

*继承体系中,应当总是从抽象类继承。也就是说,继承树应该是这么一棵树:所有有子节点的类都是抽象类,所有具体类都应当是叶子节点*

具体要求

基于契约设计(DBC Design By Contract)

类的编写者可以显示的规定针对该类(父类)的契约,客户端的编写者(写子类扩展的人)可以通过契约获悉值得信赖的行为方式。契约即:

Note

为每个方法声明前置条件和后置条件。要正确执行一个方法,前置条件必须为真,且执行完之后,要保证后置条件也为真。
前置条件和后置条件的规则是:重新声明的派生类中,只能使用相等或更弱(≤)的前置条件替换原始前置条件,只能使用相等或更强(≥)的后置条件来替换原始后置条件

换句话说,通过基类接口使用对象的时候,用户只知道前置条件和后置条件。因此你不能期望用户遵从比基类前置条件更强的前置条件,同时你的行为方式和输出也要跟基类确立的限制一样。

比如接口定义输入为整数,那你写的子类就不能要求输入是正整数;接口规定输出是整数,你可以输出正整数,但不能输出浮点数。

Warning

父类中的虚函数你可以重写,注意要满足前置条件与后置条件的要求。父类中的别的内容你最好不要修改,因为那个可以理解为是一种规范、约定。

例子

所谓“子承父业”,我现在想为所有木工编写一个Father类,类中有一个接口叫earnMoney(Carpentry c1),也就是说要挣钱,挣钱的参数是做木工。现在 Father 派生除了子类Son和女儿类Daughter

儿子女儿都更新了父亲挣钱的方式,但是这个继承都是很脆弱的。儿子不仅会木工,还会搬砖,当然可以替代父亲(参数扩大 √)。但是当妈妈问父亲要钱的时候,她期待的是人民币,换成儿子之后给的可能是 Q 币(返回值扩大 ×),那肯定不行。女儿交的是人民币现金,没问题,但是她只会刨木头,仍然替换不了父亲。因此二者都违背了 LSP

后来儿子生了个孙子。孙子会干的活对,拿的钱也没问题,但是孙子有个讲究:工资低于 5000 的话不干(强化前置条件 ×),可他爹是不挑剔的。因此孙子替代他爹去干活的时候,也可能会出问题(找不到活儿)。孙女也有个讲究:干完活只收拾锤子,别的不管(弱化后置条件 ×)。可惜,工地上贼比较多,孙女不明白他爹这么做的血泪教训。因此代替他爹去干活的第二天,就只剩下个锤子能用了。【以后涨了记性,教育从外孙子干完活不仅收拾自己的家伙儿,连工友的也给收拾了。工地上再也没有丢过东西。(强化后置条件 √)】不过这是后话,反正孙子辈也全部违背了 LSP


  1. 面向对象基础设计原则:3.里氏替换原则 - 知乎 (zhihu.com) ↩︎