设计准则和设计原则
简介
在学习具体的设计模式之前,首先有必要弄清楚我们学习的目的之所在。
设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。项目中合理地运用设计模式可以完美地解决很多问题,每种模式在现实中都有相应的原理来与之对应,每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。
而这些设计模式,代表的是一种解决问题的思想。我们在解决问题和设计这些模式的时候本身也是有原则可循的,甚至可以制定一些需要强制遵守的准则。
这里我们不纠结实现的语言,当然毕竟是前端,尽量使用 es6 或者 ts。
1. Lnix/Uinux 设计准则
Linux 是一个伟大的操作系统,在深入我们的学习之前,我们来了解一下其设计哲学。
Linux/Unix 设计思想将 Linux 的开发方式与 Unix 的原理有效地结合起来,总结出Linux 与 Unix 软件开发中的设计原则。《LinuxUnix设计思想/图灵程序设计丛书》前8章分别介绍了 Linu x与 Unix 中 9 条基本的哲学准则和 10 条次要准则。
1.1 九大准则
- 准则1:小即是美
- 准则2:让每个程序只做好一件事
- 准则3:快速建立原型
- 准则4:舍弃高效率而取可移植性
- 准则5:采用纯文本来存储数据
- 准则6:充分利用软件的杠杆效应(软件复用)
- 准则7:使用 shell 脚本来提高杠杆效应和可移植性
- 准则8:避免强制性的用户界面
- 准则9:让每个程序都称为过滤器
准则1: 小即是美
其实我一直有把车换成高尔夫 GTI 的冲动 —— 车小,性能还不错
任何程序的大部分代码, 实际上都没在执行它所宣称的功能.
比如, 一个复制文件的小程序, 执行步骤往往如下:
(1)输入源文件地址
(2)检查源文件是否存在
…
(4)询问目标文件地址
…
(10)将源文件的数据, 复制到目标文件里
(11)关闭源文件
(12)关闭目标文件
请注意: 文件只在步骤(10)中, 才被真正复制. 其他步骤的操作, 其实也可以用在除了文件复制之外的其他众多任务中. 它们是个公共的流程操作而已.
一个遵循 Unix 哲学的程序, 应该在调用时, 就已经获取了有效的源文件地址和目标文件地址. 既然不由它自己来获取, 那么由谁来获取呢? 从 linux 中自带提供的所有小程序里面。
众多小程序, 分别提供了执行不同的功能: 获取文件名, 检查文件是否存在, 确定其中的内容是否为空.
小程序的参考指标 ---- 可维护性:
如果你编写的软件有这些迹象, 表明已经偏离了Unix的操作理念:
- 传递给函数调用的参数数量过多, 导致代码超出了一个屏幕的宽度
- 子程序代码的长度, 超过了整个屏幕, 或是一张A4纸的长度
- 要依靠代码注释, 才能记住该子程序到底在做些什么
- 在获取目录列表的时候, 一个屏幕已经显示不下这些源文件的名称
- 某个文件已经变得很难控制, 无法再定义程序的全局变量了
- 你已经无法记住一个给定的错误信息, 是在什么条件下引发的
- 小程序避免去预测未来的情况, 未来, 新的接口会出现, 数据格式也将发展, 程序的互动方式, 也会随着大家口味的变化而不同. 新硬件技术出来, 旧算法就会显得过时。
书中讲到了小的好处:
- 易于理解和学习。如果你想要写出全世界都是用的程序,那这一点很重要,无论是大牛还是小白,都能轻松是用,才能推广开来。
- 易于维护。即便是自己写的代码,过半年自己都忘记当时写的是什么了,要考虑这一点。
- 消耗更少的资源。“小”到制作一件事,用多少就消耗多少,不做一点额外的开销和浪费。
- 更易于和其他工具结合。即可扩展性更好,符合开放封闭原则。
越是大的程序,越需要模块小,函数小,越是大的企业,越需要螺丝钉。
准则2:让每个程序只做好一件事
把准则 1 做到了极致。越是大型的系统,这个原则越重要,否则越大就越乱。
书中列举了一个范例 —— ls命令。ls本来是很简单的一个命令,现在却搞的有 20 多个参数,而且正在逐步增加。这就使得ls慢慢变成一个很庞大的命令,但我们日常 90% 的场景都使用它最简单的功能。理想的做法是,ls还保持简洁的功能,另外开发新的命令来满足其他配置参数实现的功能。这就例如,cat可查看全部内容,想看头或者尾,分别使用 head 和 tail —— 这就分的清晰了。
准则3:快速建立原型
大教堂与集市,我们选择集市。
软件不是汽车,它可以每天都迭代更新,它可以今天发现有问题然后明天修复过来。而且,谁都无法预测它未来将会怎样变化,客户也无法通过语言清楚的描述他们需要的软件。基于以上所有原因,软件都需要先有原型,再不断慢慢完善,这也是一个降低风险、慢慢学习的过程。
- 永远不要自己猜测用户想要的软件
- 永远不要相信用户开始时描述的他们想要的软件
- 永远不要想一次做完永远不改
准则4:舍弃高效率而取可移植性
越高效的程序往往越不可移植,但是好的程序往往会被移植到各个地方使用 —— 从这里能看出,对于程序来说,最重要的是可移植性,而非高效。
至于效率问题,不用花费太多的精力去优化,它会很快因为硬件的更新而得到解决 —— 摩尔定律。
准则5:采用纯文本来存储数据
采用纯文本存储数据,可能没有二进制方式效率高,但是有以下好处:
- 方便转换格式:所有系统都支持文本编辑,而且文本的编码规范,业界都是统一标准的。但是对于二进制 —— 每个供应商都提供了自己的二进制编码,且相互不兼容
- 易于阅读和编辑:首先,文本格式人类一眼就认识;其次,可通过简单的工具进行编辑、编辑完直接保存无需转换;第三,文本格式非常适合 linux 中的管道(pipe)操作
- 易于系统处理:存储是文本格式,那么 linux 的标准输入输出就可以全部用文本格式,linux 的实用工具只处理文本格式即可。如grep diff等
虽然效率不佳,但是可移植性好,而且易于阅读和操作,这些都是符合本书其他原则的。最后,效率不佳的问题,会通过明年硬件的升级而得到解决(摩尔定律)
准则6:充分利用软件的杠杆效应(软件复用)
NIH —— Not Invent Here ,即要借用(或复用)现有的软件,而不是重造轮子
这个道理大家都明白,但是书中有两点我觉得很重要:
- 软件想要被复用,就得符合工业标准。第一,软件贡献者要熟悉标准;第二,软件使用者也要熟悉标准;第三,当某个软件在业界还没有标准的时候,要勇敢的去自己制定(如 jQuery)。
- 更多的人贡献软件,开源社区和开源文化很重要。
准则7:使用 shell 脚本来提高杠杆效应和可移植性
Linux 提倡使用 Shell 脚本,不需要编译,而且可移植性好。
准则8:避免强制性的用户界面
linux 系统中,GUI 都只是一个普通的软件而已,并不是强行和系统绑定的。如果软件有了强制性的用户界面,会带来各种各样的问题
- 强制要求用户是人类,但是一个软件的用户很可能不是人类
- 导致软件庞大,占用资源多
- 扩展性差
- 无法发挥杠杆效应
- ……
准则9:让每个程序都成为过滤器
程序不会创造数据,只有人类才会创造数据。因此,每个程序都仅仅是一个过滤器而已。
linux 的常用都是过滤器,例如ls | grep 'README.md'
,就是找出当前目录下的 README.md文件。其中ls
grep
都是过滤器,过滤器就必须有:输入、输出。 这其实正好对应着 linux 的标准输入输出(stdio)—— stdin stdout stderr。
1.2 十条小准则
(1)允许用户定制环境。Unix用户喜欢掌控系统环境,并且是整个环境。很多Unix应用程序绝对不会一刀切地使用交互风格,而是将选择的权利交给用户。它的基本思想就是,程序应该只是提供解决问题的机制,而不是为解决问题的方法限定标准。让用户探索属于自己的通往计算机的家境之路吧。
(2)尽量使操作系统内核小而轻巧。尽管对新功能的追求永无止境,Unix开发人员还是喜欢让操作系统最核心部分保持最小的规模。当然,他们并不总是能做到这一点,但这是他们的目标。
(3)使用小写字母,并尽量保持简短。使用小写字母是Unix环境中的传统,尽管这么做的理由已不复存在,但人们还是保留了这个传统。今天,许多Unix用户之所以要使用小写的命令和神秘的名字,不再是因为有其限制条件,而是他们就喜欢这么做。
(4)保护树木。Unix用户普遍不太赞成使用纸质文档。而是在线存储所有文字档案。此外,使用功能强大的在线工具来处理文件是非常环保的做法。
(5)沉默是金。在需要提供出错信息的时候,Unix命令是出了名的喜欢保持沉默。虽然很多经验丰富的Unix用户认为这是可取得做法,可其他操作系统的用户却并不赞同这种观点。
(6)并行思考。大多数任务都能分解成更小的子任务。这些子任务可以并行运行,因而,在完成一项大任务的时间内,可以完成更多子任务。今天已涌现出大量对称处理(symmetric multiprocessing,SMP)设计,这说明计算机行业正朝着并行处理的方向发展。
(7)各部分之和大于整体。小程序集合而成的大型应用程序比单个的大程序更灵活,也更为实用,本条准则正式源于此想法。两种解决方案可能具备同样的功能,可集合小程序的做法更具有前瞻性。
(8)寻找90%的解决方案。百分百的完成任何事情都是很困难的。完成90%的目标会更有效率,并且更节省成本。Unix开发人员总是在寻找能够满足目标用户90%要求的解决方案,剩下的10%则任其自生自灭。
(9)更坏就是更好。Unix爱好者认为具有”最小公分母“的系统是最容易存活的系统。比起高品质而昂贵的系统,那些便宜但有效的系统更容易得到普及。于是,PC兼容机的世界从Unix世界借鉴了此想法,并取得了巨大成功。这其中的关键字就是包容。如果某一事物的包容性强到足以涵盖几乎所有事物,那它就比那些”独家”系统要好得多。
(10)层次化思考。Unix用户和开发人员都喜欢层次来组织事物。例如,Unix目录结构是最早将树结构应用于文件系统的架构之一。Unix的层次化思考已扩展到其他领域,如网络服务命名器、窗口管理、面向对象开发。
2. 面向对象设计的七大原则
这里常说的有五大原则:即 SOLID 原则,在此之外还有 L 和 C/A 原则,我们一一介绍。
2.1 S(Single responsibility principle)——单一职责原则
2.1.1 定义
所谓职责是指类变化的原因。如果一个类有多于一个的动机被改变,那么这个类就具有多于一个的职责。而单一职责原则就是指一个类或者模块应该有且只有一个改变的原因。
2.1.2 理解
不同的类应该承担不同的职责。做系统设计时,如果发现有一个类拥有了两种职责,那么就要问一个问题,这些职责真的有必要放在一个类吗,可以再继续拆分么?如果答案是肯定的,就应该继续拆分。
2.1.3 意义
- 类的复杂性降低,职责清晰,可读性和可维护性提高。
- 变更引起的风险降低。变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
2.1.4 示例
class ShoppinCar {
constructor(){
this.goods = [];
}
addGoods(good){
this.goods = [good];
}
getGoodsList(){
return this.goods;
}
}
class Settlement {
constructor(){
this.result = 0;
}
calculatePrice(list,key){
let allPrice = 0;
list.forEach((el) => {
allPrice += el[key];
})
this.result = allPrice;
}
getAllPrice(){
return this.result;
}
}
上述代码中, ShoppinCar 类存在两个方法 addGoods 和 getGoodsList,分别是添加商品和获取商品列表。Settlement 类中存在两个方法 calculatePrice 和getAllPrice 分别做的事情是计算价钱与获取总价钱。ShoppinCar 与 Settlement 都是在做自己的事情。添加商品与计算价格,虽然在业务上是相互依赖的,但是在代码中分散在两个类,然后他们自己做自己的事情。其中任何一个类更改不会对另一个类进行更改。
2.2 O(Open Closed Principle, OCP)——开闭原则
2.2.1 定义
在面向对象编程领域中,开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”,这意味着一个实体是允许在不改变它的源代码的前提下变更它的行为。
2.2.2 理解
这是所有原则中最核心和基础的原则,软件功能发生变化时,应该通过扩展实体(类、模块、函数等)行为去适应,而不要修改已有代码。其核心是封装变化,封装变化有两层含义,第一是将相同的变化封装到一个接口或抽象类中;第二是将不同的变化封装到不同的接口或抽象类中。
2.2.3 意义
- 开闭原则有利于进行单元测试
- 开闭原则可以提高复用性
- 开闭原则可以提高可维护性
- 面向对象开发的要求
2.2.4 实例
class Teacher {
constructor(name, age) {
this.name = name;
this.age = age;
}
teach() {
console.log('teach students');
}
eat() {
console.log('eat food');
}
}
class MathsTeacher extends Teacher{
teach() {
console.log('teach maths')
}
}
一个教师对象,具有教书的方法,现在需要扩展一个专门教数学的功能,那么我们使用继承并重写该方法即可。还要实现其他的教学方法的话,可以照此继续添加。这样不同的对象互不影响。
3. L(Liskov Substitution Principle, LSP)——里氏替换原则
3.1 定义
里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
3.2 理解
假设我有一个类是鸟,定义了一个 fly 的方法,可以在天空飞。而我认为鸵鸟也是鸟,并且继承了这个鸟类。但是我发现鸵鸟不会飞,如果把使用飞鸟的地方替换成鸵鸟,那么程序就会挂掉。你当然也可以在鸵鸟类破坏性地重写这个 fly 方法,但这显然违背了鸟类的设计意图。
定义包含四层意思:
1) 子类可以实现父类的抽象方法,但不能覆写父类的非抽象方法。 父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。
2) 子类中可以增加自己特有的方法。
3) 覆写或实现父类的方法时,输入参数可以被放大。
4) 覆写或实现父类的方法时输出结果可以被缩小。
我理解就是推荐重载而不是重写,需要重写的地方,最好是定义一个抽象方法或者接口,在子类实现。当然实际情况下,使用重写是不可避免的,但是不要进行破坏性的重写。
如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,在两者基础上新增一个更高级的抽象类,或者使用依赖、聚集、组合等关系代替继承。
3.3 意义
里氏替换原则为良好的继承定义了一个规范:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性
- 提高代码的重用性
- 子类可以形似父类,但是又异于父类。
- 提高代码的可扩展性,实现父类的方法就可以了。许多开源框架的扩展接口都是通过继承父类来完成。
- 提高产品或项目的开放性
3.4 实例
// 定义鸟类
class bird {
constructor(name){
this.name = name
}
eat() {
console.log('eat');
}
}
// 定义会飞的鸟类
class flyingBird {
fly() {
console.log('fly');
}
}
// 定义不会飞的鸟类
class runningBird {
run() {
console.log('run');
}
}
// 定义鸵鸟类
class Ostrich extends OrunningBird {
constructor(name,color) {
super(name);
this.color = color
}
color(){
console.log(this.color)
}
}
鸵鸟类不是直接继承鸟类,而是从原鸟类中去除 fly 方法,抽出了一个更抽象的鸟类,其下有会飞的鸟类和会跑的鸟类,鸵鸟类继承会跑的鸟类。
4. I (Interface Segregation Principle, ISP)——接口独立原则
4.1 定义
客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上。
4.2 理解
打个比方,注册一般需要校验姓名,密码,动态验证码。现在我有个简单的注册系统,不需要验证码。我当然也可以依赖上述接口去实现,并且将验证码校验置位空。但是对于这个类来讲,理论上它就是支持验正码校验的,一旦第三方调用该对象时使用了验证码校验就可能会出问题。
如果我们将上述三个功能全部都单独抽成一个接口,就方便我们随时进行组合了。这其实是单一职责在接口设计上的体现。不过实际设计中我们很少说拆的那么细,需要根据实际情况设计大小合适的接口。可以根据如下标准来设计:
1)一个接口只服务于一个子模块或业务逻辑
2)通过业务逻辑压缩接口中的public方法
3)已被污染的接口尽量去修改,若变更风险大,可用适配器模式进行转化处理
4)了解业务背景,避免生搬硬套模式。
4.3 意义
- 避免接口污染
- 提高灵活性
- 提供定制服务
- 实现高内聚
4.4 实例
interface simpleRegister {
validName(): boolean;
validPwd(): boolean;
}
interface IVerifycode {
validVerifyCode(): boolean;
}
class Register implements simpleRegister, IVerifycode {
constructor() {
//
}
validName() {
//
}
validPwd() {
//
}
validVerifyCode() {
//
}
}
5. D(Dependence Inversion Principle, DIP)——依赖倒置原则
5.1 定义
依赖倒置原则(Dependence Inversion Principle)是程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
5.2 理解
驾驶员开车,车会跑起来,至于这辆车是怎么跑的,两个轮子还是四个轮子,两驱还是四驱,驾驶员是不需要去关注的,这是每个车自己去关注和实现的。
5.3 意义
- 通过依赖于接口,隔离了具体实现类
- 底层的变动并不会导致高层的变动
- 提高了代码的容错性、扩展性和可维护性
5.4 实例
// 错误实例,一旦这个驾驶员新增了车辆种类,就又要改变依赖和实现。这是因为 driver 类依赖了底层的具体车型类,关注了应该只由底层去关注的 run 方法具体实现。
class BenzCar {
run() {
console.log('benz run');
}
}
class BmwCar {
run() {
console.log('bmw run');
}
}
class Driver {
private carType;
private benzCar;
private bmwCar;
constructor(carType) {
this.carType = carType;
this.benzCar = new BenzCar();
this.bmwCar = new BmwCar();
}
drive() {
switch (this.carType) {
case 'benz':
this.benzCar.run();
return;
case 'bmw':
this.bmwCar.run();
return;
}
}
}
// 正确实现,驾驶员类依赖的是所有车的抽象接口
interface Icar {
run(): void;
}
class Driver {
private car;
constructor(car: Icar) {
this.car = car;
}
drive() {
this.car.run();
}
}
6. L(Law of Demeter, LoD)——迪米特法则
6.1 定义
迪米特法则(Law of Demeter)又叫作最少知识原则(Least Knowledge Principle 简写LKP),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。英文简写为: LoD。
6.2 理解
顾客向厨师点菜,厨师要准备材料,配料,制作,但顾客不关心你具体用的哪些材料,配料,也不关心你先准备材料还是配料,他不需要关心任何制作的细节,他只想吃到自己点的菜。作为厨师,也不需要顾客来关心这些,这都是他自己的私有方法,爱怎么做怎么做,只要最后能给到用户他点的菜即可。
一个对象应该对其他对象有最少的了解,一个类只需要知道自己需要耦合或者调用类的public方法即可。
尽量保证风险的不扩散,修改的地方越少,代码就越好。
一个类公开的public方法越多,修改时涉及的面也越大,变更的风险也越大。
出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类,迪米特法则告诉我们,一个类只和他的朋友类做交流,老师和体育委员交流、体育委员和学生交流。
在实际中如果遇到,一个方法放在本类中也可以,放在其他类中也合适,那么你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也 对本类不产生负面影响,那就放置在本类中。
迪米特法则的核心观念就是类间的解耦,弱耦合。但是也要衡量,既要让结构清晰,又要高内聚低耦合。
6.3 意义
- 减少对象之间的耦合性
6.4 实例
class Cooker {
constructor(name) {
this.name = name;
}
// 准备材料
private prepareMaterial(dishName) {
//
}
// 准备配料
private prepareIngredients(dishName) {
//
}
// 制作
private cook(dishName) {
//
}
// 出品
out(dishName) {
this.prepareMaterial(dishName);
this.prepareIngredients(dishName);
this.cook(dishName);
}
}
class Guest {
constructor(name) {
this.name = name;
}
// 点菜
order(cooker: Cooker, dishName: string) {
cooker.out(dishName);
}
}
7. C/A(Composite/Aggregate Reuse Principle, C/ARP)——组合/聚合复用原则
7.2 定义
在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生改变,则子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用合成/聚合,不要使用类继承。即在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。
7.3 理解
这么说吧,你要造车,你可以自己去建造发动机,车身,轮子等,也可以直接在外面定做好这些,然后在车间去组装它,哪一个更容易呢?利用继承去实现一个功能,就好比你要给家庭做清洁,你或者你的父亲必须有一个人拥有这个技能,如果你还想做饭,你们还得去学,如果你们现在要装修房子,你还得学习木工。这样的话成本是巨大的,不如干脆请保洁,厨师和木工来做专业的事情。你要开公司,就聚合一帮现成的人来干,比你从头学习所有技能,一个人来做肯定快得多,也好的多。
其实说了这些,就是表达一个意思,有现成的,你就用,不要啥都自己干。这里也简单为大家普及一下对象之间的关系:
继承:对于类来说,这种关系叫做继承,对于接口来说,这种关系叫做实现。继承是一种“is-a”关系。
依赖:依赖简单的理解,就是一个类A中的方法使用到了另一个类B。这种使用关系是具有偶然性的、临时性的、非常弱的,但是B类的变化会影响到A。在前面锁具的例子中,我们有一个驾驶员开车的例子,这就是一种依赖,驾驶员 drive 的使用会实际调用 car 的 run 方法。car 的 run 方法修改会影响到驾驶员,但是驾驶员如果一旦不 drive,两者之间的关系就断开了。一般而言,依赖关系在 TS 中体现为局域变量、方法的形参,或者对静态方法的调用。
关联:关联体现的是两个类、或者类与接口之间语义级别的一种强依赖关系。被关联类B以类属性的形式出现在关联类A中,或者关联类A引用了一个类型为被关联类B的全局变量的这种关系,就叫关联关系。就比如说某个司机拥有某良特定的车,车和司机就产生了关联。在 TS 中,关联关系一般使用成员变量来实现。
这种关系比依赖更强、不存在依赖关系的偶然性、关系也不是临时性的,一般是长期性的,而且双方的关系一般是平等的、关联可以是单向、双向的。
聚合:聚合表示整体与部分的关系,部分可以脱离整体作为独立个体存在,即 ‘has-a’ 的关系。在代码层面,聚合和关联关系是一致的,只能从语义级别来区分。普通的关联关系中,A 类和B 类没有必然的联系,而聚合中,需要 B 类是 A 类的一部分,是一种 ‘has-a’ 的关系,即 A has-a B; 比如家庭有孩子,屋子里有院子,班级有学生,雁群有大雁。但是,has 不是 must has,A 可以有B,也可以没有。A 是整体, 是部分,整体与部分之间是可分离的,他们可以具有各自的生命周期,走了一两只大雁,雁群并没有影响。雁群消失,大雁也还可以存活。部分可以属于多个整体对象,也可以为多个整体对象共享,比如两家可以共享一个院子。
不同于关联关系的平等地位,聚合关系中两个类的地位是不平等。
组合:组合也是关联关系的一种特例,他体现的是一种 ‘contains-a’ 的关系,这种关系比聚合更强,也称为强聚合。组合同样体现整体与部分间的关系,但此时整体与部分是不可分的,部分不可作为独立个体单独存在,部分的生命周期不能超过整体的生命周期,整体的生命周期结束也就意味着部分的生命周期结束。心脏是人体组成的一部分,发动机是车组成的一部分。
组合关系中,两个类关系也是不平等的。
只看代码,是无法区分关联,聚合和组合的,具体是哪一种关系,只能从语义级别来区分。较真来讲,心脏可以移植,发动机也可以换一辆车继续工作,这个时候,你可以将其视作一种聚合。
理解他们无须太刻意,视具体情况而定。比如你俩本来可能只是互有关联的同学,后来聚合成了男女盆友,最后组合成了一个家庭,密不可分。这个关系是层层递进的,理解这一点就好。
所以它们之间,尤其是组合和聚合,大多时候不必分得太清晰,毕竟代码形式上表现是一样的。
组合和聚合均是关联的特殊情况。聚合用来表示“拥有”关系或者整体与部分的关系;而组合则用来表示一种强得多的“拥有”关系。在一个组合关系里面,部分和整体的生命周期是一样的。一个组合的新的对象完全拥有对其组成部分的支配权,包括它们的创建和销毁等。使用程序语言的术语来说,组合而成的新对象对组成部分的内存分配、内存释放有绝对的责任。要正确的选择组合/聚合复用和继承,必须透彻地理解里氏替换原则和Coad法则。(Coad法则由Peter Coad提出,总结了一些什么时候使用继承作为复用工具的条件。Coad法则:只有当以下Coad条件全部被满足时,才应当使用继承关系)
(1).子类是基类的一个特殊种类,而不是基类的一个角色。区分“Has-A”和“Is-A”。只有“Is-A”关系才符合继承关系,“Has-A”关系应当用聚合来描述。
(2).永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
(3).子类具有扩展基类的责任,而不是具有置换掉(override)或注销掉(Nullify)基类的责任。如果一个子类需要大量的置换掉基类的行为,那么这个类就不应该是这个基类的子类。
(4).只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。
7.3 意义
详细解析可以参见java 依赖、组合、聚合与继承
继承的优缺点
优点:
- 子类能自动继承父类的接口
- 创建子类的对象时,无须创建父类的对象
缺点: - 破坏封装,子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性
- 支持扩展,但是往往以增加系统结构的复杂度为代价
- 不支持动态继承。在运行时,子类无法选择不同的父类
- 子类不能改变父类的接口
组合/聚合的优缺点
优点:
- 不破坏封装,整体类与局部类之间松耦合,彼此相对独立
- 具有较好的可扩展性
- 支持动态组合。在运行时,整体对象可以选择不同类型的局部对象
- 整体类可以对局部类进行包装,封装局部类的接口,提供新的接口
缺点: - 整体类不能自动获得和局部类同样的接口
- 创建整体类的对象时,需要创建所有局部类的对象
7.4 实例
车由引擎,车身和轮子组成,如果我们使用多继承的方式去实现一辆车,如下:
interface IEngine {
activate(): void;
}
interface IBody {
color: string;
}
interface IWheel {
roll(): void;
}
class Car implements IEngine, IBody, IWheel {
private color: string;
constructor(color) {
this.color = color;
}
activate(): void {
// 机械式点火
console.log('Mechanical ignition');
}
roll(): void {
console.log('wheel roll');
}
run(): void {
this.activate();
this.roll();
}
}
我们需要在 car 类中去实现这些抽象接口,一旦我们的接口或者实现发生变化,比如这里我们将引擎的机械式打火升级为电子打火,那么我们就要在 car 类中去修改其实现。违反了开闭原则。
而如果我们使用组合方式去实现,如下:
class Car {
private engine: IEngine;
private body: IBody;
private wheel: IWheel
constructor(engine: IEngine, body: IBody, wheel: IWheel) {
this.engine = engine;
this.body = body;
this.wheel = this.wheel;
}
run(): void {
this.engine.activate();
this.wheel.roll();
}
}
当传入的 engine 是实现了电子打火的引擎,即可满足升级后的功能,而我们的 car 完全无需做任何修改。
8. 小结
事实上 js 是天然多态的,没有抽象,重写也非常方便,这种优势带来了编程的极简体验,也产生了理解和维护难的副作用。所以在使用 oop 设计时建议用 ts 来代替 js 进行编程。
另外,理解对象之间的关系 - 继承,依赖,关联,聚合,组合,以及面向对象设计的核心原则 - 开闭原则,我们才能更好地理解设计模式产生的意义,并且据此设计出更合理的类和接口。
参考
设计模式 | 菜鸟教程
Linux/Unix 系统设计的九大准则
<linux / unix 设计哲学> 笔记
《Linux/Unix设计思想》随笔 ——Linux/Unix哲学概述
javascript设计模式与六大原则
web前端进阶之js设计模式之设计原则篇
Java设计模式-六大原则
js 面向对象七大原则
JavaScript面向对象之七大基本原则实例详解
百度百科-单一职责原则
百度百科-开闭原则
百度百科-里氏替换原则
百度百科-里氏代换原则
百度百科-接口隔离原则
六大设计原则之四:接口隔离原则
百度百科-迪米特法则
java 依赖、组合、聚合与继承
七个原则7-合成(组合)/聚合复用原则
聚合,组合,继承的区别
聚合、组合的区别
Java学习笔记(二)--组合与继承
为什么组合优于继承?
设计模式---->组合/聚合复用原则
组合/聚合复用原则详解--七大面向对象设计原则(7)
合成聚合复用原则
组合/聚合复用原则