设计模式之美 精华总结 笔记
Posted 深林无鹿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了设计模式之美 精华总结 笔记相关的知识,希望对你有一定的参考价值。
文章目录
设计模式之美 精华总结 笔记(二)
一、面向对象精解
四大特性:封装、抽象、继承、多态四个特性,是代码设计和实现的基石 。
1、封装
作用:信息隐藏和数据访问保护(通过访问权限控制)
2、抽象
作用:
- 1、隐藏方法的具体实现,让调用者只关心方法提供了哪些功能。
如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我
们的大脑过滤掉许多非必要的信息。
-
2、代码设计中,思想的指导作用
比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等
3、继承
描述性定义: is-a 关系
作用:代码复用
继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码, 还需要按照继承关系一层一层地往上查看“父类、父类的父类…”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。
所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。关于这个问题,在后面讲到“多用组合少用继承”这种设计思想的时候,我会非常详细地再讲解,这里暂时就不展开讲解了。
4、多态
描述性定义:子类可以替换父类。
作用:提高代码的扩展性和复用性。
多态是一些设计原则、设计模式的指导思想:
策略模式、 基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句 等等。
- 代码重用依存于超类(父类)的设计
Java实现多态有三个必要条件:继承、重写、向上转型。
继承:在多态中必须存在有继承关系的子类和父类。
重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的方法。
向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才能够具备技能调用父类的方法和子类的方法。
当子类转父类时,自动转型,也就是向上转型,当父类转子类时,需要强制类型转化向上转型:
B b = new B(); A a = b; // 相当于A a = new B(); 内存中的本质还是B类型,因为赋给A,所以只有A中的方法,能力弱化了
向下转型:
A a1 = new B(); B b1 = (B)a1; A a2 = new A(); B b2 = (B)a;//编译正常,运行报错 //结论:父类强制转换为子类时只有当引用类型真正的身份为子类时才会强制转换成功
duck-typing 实现多态:
class Logger:
def record(self):
print(“I write a log into file.”)
class DB:
def record(self):
print(“I insert data into db. ”)
def test(recorder):
recorder.record()
def demo():
logger = Logger()
db = DB()
test(logger)
test(db)
5、思考:为什么有些语言不允许多继承
JAVA为例:
首先java类支持多继承的话,两个父类有同名方法,在子类中调用的时候就不知道调用哪 个了,出现决议(钻石问题或菱形问题)问题 而接口支持多继承因为接口定义的方法不能有方法体,所以不会出现决议问题。
结论:JAVA 语言中 接口可以实现多继承,类不可以实现多继承。
一些奇怪的解释:
- 多态:现在调用将来
二、面向对象优于面向过程的地方
1、形式上
- 清晰化与模块化的代码组织方式
- 对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了。
2、复用、扩展、维护角度上
- 面向对象的四大特性使得oop的代码更加容易复用,且方便扩展和维护
- 面向过程要实现复用,扩展,维护所要付出的成本更加高一点
3、思考角度
可以把更多的思考放在如何设计业务功能
4、总结
1. 什么是面向过程编程?什么是面向过程编程语言?
实际上,面向过程编程和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性,比如继承、多态、封装。
2. 面向对象编程相比面向过程编程有哪些优势?
面向对象编程相比起面向过程编程的优势主要有三个。 - 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对对这种复杂类型的程序开发。 - 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。 - 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
5、思考:Linux的开发
面向对象编程比面向过程编程,更加容易应对大规模复杂程序的开发。但像 Unix、Linux 这些复杂的系统,也都是基于 C 语言这种面向过程的编程语言开发的,你怎么看待这个现象?
操作系统虽然是用面向过程的C语言实现的但是其设计逻辑是面向对象的。 C语言没有类和对象的概念,但是用结构体(struct)同样实现了信息的封装,内核源码中 也不乏继承和多态思想的体现。
面向对象思想,不局限于具体语言。
操作系统更多的是和硬件打交道,需要考虑到语言本身翻译成机器语言的成本和执行效率,尤其重要。
对于Java 语言的一点思考
最常用的 JVM,也就是 OpenJDK 和 OracleJDK 里的 Hotspot 是 C、C++ 和汇编混写的
Java在运行时 通过编译成C++代码等底层源码运行,JavaOOP 更加趋近于人性化开发;
三、面向对象的误区(大部分人在犯)
在我们日常的开发中,有很多本质上是面向过程的代码被误认为面向对象,具体如下
1、滥用getter、setter方法
很多人在编写一个类代码的时候就顺手加上了所有的getter、setter。更有甚者使用ide、lombok等插件直接进行getter、setter的生成。很多人这么做的理由如下:以后可能会用到,现在生成了,以后省事,反正用不到也没关系。
这是一个非常错误的想法,它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。
例如:
public class Wallet {
private double money;
private String lastDealTime;
public double getMoney() {
return money;
}
public void setMoney(double money) {
this.money = money;
}
public String getLastDealTime() {
return lastDealTime;
}
public void setLastDealTime(String lastDealTime) {
this.lastDealTime = lastDealTime;
}
}
在上面的代码中,我们可以看到,在wallet类中对money 属性直接实现了 getter、setter方法,相当于外部可以直接对 money 属性进行重写覆盖,但是这并不合理。这种代码的编写方式使得我们的数据可以直接被外部修改,相当于我们的私有属性并不私有,直接失去了面向对象中最重要的封装的性质。
2、滥用全局变量和全局方法
在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。
单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成 员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。 而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到 一个 Constants 类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种 Utils 类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。
常见的代码中例如:
public class Constants {
private static final int MIN_VALUE = 1;
private static final int MAX_VALUE = 100;
private static final String username = "admin";
private static final String password = "admin";
}
这样设计的一些弊端:
-
影响代码的可维护性
参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。
-
影响代码的复用性
如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。 即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。
解决方案:(两种思路)
- 第一种是将 Constants 类拆解为功能更加单一的多个类,比如跟 mysql 配置相关的常 量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。
- 第二种是我个人觉得更好的设计思路,那就是并不单独地设 计 Constants 常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在 RedisConfig 中,这样也提高了类设计的内聚性和代码的复用性。
以上是针对全局变量的设计,那么针对全局方法,如Utils类该如何解决?
Utils类的使用场景:如果我们有两个类 A 和 B,它们要用到一块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复地实现两遍。
实际上,只包含静态方法不包含任何属性的 Utils 类,是彻彻底底的面向过程的编程风格。 但这并不是说,我们就要杜绝使Utils 类了。实际上,从刚刚讲的 Utils 类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用问题。所以,这里并不是说完全不能用 Utils 类,而是说,要尽量避免滥用,不要不加思考地随意去定义Utils类。
在定义 Utils 类之前,你要问一下自己,你真的需要单独定义这样一个 Utils 类吗?是否可 以把 Utils 类中的某些方法定义到其他类中呢?如果在回答完这些问题之后,你还是觉得确 实有必要去定义这样一个 Utils 类,那就大胆地去定义它吧。因为即便在面向对象编程中, 我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量,我们 就可以适度地去使用。
在分清楚Utils类是否真的需要之后,我们可以参照滥用全局变量的解决方案,针对不同的功能需求对Utils类进行细分,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等, 不要设计一个过于大而全的 Utils 类。
3、基于贫血模型的开发模式(定义数据和方法分离的类)
常见的面向过程风格的代码。那就是,数据定义 在一个类中,方法定义在另一个类中。
大家通常觉得自己不会写这样的代码,但是如果是在开发MVC架构的一些应用时,这样的代码可能就出现很多。
传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、 Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑, Repository 层负责数据读写。而在每一层中,我们又会定义相应的 VO(View Object)、 BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、 Repository 类中。这就是典型的面向过程的编程风格。
实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种 Web 项目的开发模式。看到这里,你内心里应该有很多疑惑吧?既然这种开发模式明显违背面向对象的编程风格,为什么大部分 Web 项目都是基于这种开发模式来开发呢?
针对这个问题可以看一下我的这篇文章: 基于贫血模型的开发模式与基于充血模型的开发模式
4、总结
把握最终目的:不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
在用面向 对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比 如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。
四、接口和抽象类
1、抽象类有哪些特性
-
抽象类不允许被实例化,只能被继承。
-
抽象类可以包含属性和方法。方法既可以包含代码实现方法,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。
-
子类继承抽象类,必须实现抽象类中的所有抽象方法。
2、接口有哪些特性
- 接口不能包含属性(也就是成员变量)。
- 接口只能声明方法,方法不能包含代码实现。
- 类实现接口的时候,必须实现接口中声明的所有方法。
3、接口和抽象类的区别
不同语言对接口和抽象的支持不同,比如C++ 这种编程语言只支持抽象类,不支持接口;而像 Python 这样的动态编程语言,既不支持抽象类,也不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类,我们仍然可以通过一 些手段来模拟实现这两个语法概念。
-
明确了抽象类和接口的特性,可以知道这两者还是有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,两者也有比较大的区别。
-
抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关 系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于 接口,有一个更加形象的叫法,那就是协议(contract)。
4、它们能解决什么问题
由上面讲过的内容可以知道, 抽象类和接口都是为了代码复用而产生
为什么需要抽象类?
可不可以不用抽象类,而是用普通的父类模拟一个抽象类呢?
举个栗子~
public class logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel) { //代码省略...
}
protected boolean isLoggable {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intVal
return loggable;
}
}
// 子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;
public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filepath) { //代码省略...
}
public void log(Level level, String mesage) { if (!isLoggable()) return;
// 格式化 level 和 message, 输出到日志文件 fileWriter.write(...);
} }
// 子类: 输出日志到消息中间件 (比如 kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) { //.代码省略...
}
public void log(Level level, String mesage) { if (!isLoggable()) return;
// 格式化 level 和 message, 输出到消息中间件 msgQueueClient.send(...);
} }
该设计思路达到了代码复用的目的但是无法实现多态,运行下列代码会报错
Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/wangzhen
logger.log(Level.ERROR, "This is a test log message.");
因为父类中并没有定义log()方法。
这时候,有的人说了,那就在父类中定一个空log()方法呢?
这个思路没有问题,但是显然没有抽象类实现的优雅,并且可能存在几个隐患:
- 在 Logger 中定义一个空的方法,会影响代码的可读性。如果我们不熟悉 Logger 背后 的设计思想,代码注释又不怎么给力,我们在阅读 Logger 代码的时候,就可能对为什么定义一个空的 log() 方法而感到疑惑,需要查看 Logger、FileLogger、 MessageQueueLogger 之间的继承关系,才能弄明白其设计意图。
- 当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 log() 方法。 之前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误。你可能会说,我既然要定义一个新的 Logger 子类,怎么会忘记重新实现 log() 方法呢?我们举的例子比较简单,Logger 中的方法不多,代码行数也很少。但是,如果 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计非常熟悉,否则忘记重新实 现 log() 方法,也不是不可能的。
- Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 log() 方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
为什么需要接口
-
抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降 低代码间的耦合性,提高代码的可扩展性。
-
接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。
5、什么时候使用抽象类,什么时候使用接口
十分简明的判断的标准:
-
如果我们要表示一种 is-a 的关系,并且是为了解决代码复用 的问题,我们就用抽象类;
-
如果我们要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。
6、总结
类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
五、为什么基于接口而非实现进行编程?有必要为每个类实现接口吗
1、为什么基于接口而非实现进行编程?
这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一 就是需求的不断变化,这也是考验代码设计好坏的一个标准。**越抽象、越顶层、越脱离具体 某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅 能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情 况下灵活应对。**而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
结合一个具体的实战案例来进一步进行分析。
假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。这种设计实现非常简单, 类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我 们将图片存储在阿里云的业务需求。
不过,软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?
我们需要重新设计实现一个存储图片到私有云的 PrivateImageStore 类,并用它替换掉项目中所有的 AliyunImageStore 类对象。这样的修改听起来并不复杂,只是简单替换而 已,对整个代码的改动并不大。不过,我们经常说,“细节是魔鬼”。这句话在软件开发中 特别适用。实际上,刚刚的设计实现方式,就隐藏了很多容易出问题的“魔鬼细节”,我们 一块来看看都有哪些。
新的 PrivateImageStore 类需要设计实现哪些方法,才能在尽量最小化代码修改的情况 下,替换掉 AliyunImageStore 类呢?这就要求我们必须将 AliyunImageStore 类中所定 义的所有 public 方法,在 PrivateImageStore 类中都逐一定义并重新实现一遍。而这样做 就会存在一些问题,我总结了下面两点。
首先,AliyunImageStore 类中有些函数命名暴露了实现细节,比如,uploadToAliyun() 和 downloadFromAliyun()。如果开发这个功能的同事没有接口意识、抽象思维,那这种 暴露实现细节的命名方式就不足为奇了,毕竟最初我们只考虑将图片存储在阿里云上。而我 们把这种包含“aliyun”字眼的方法,照抄到 PrivateImageStore 类中,显然是不合适 的。如果我们在新类中重新命名 uploadToAliyun()、downloadFromAliyun() 这些方法, 那就意味着,我们要修改项目中所有使用到这两个方法的代码,代码修改量可能就会很大。
其次,将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的。比 如,阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token。一方面,AliyunImageStore 中定义的 generateAccessToken() 方法不能照抄到 PrivateImageStore 中;另一方面,我们在使用 AliyunImageStore 上传、下载图片的时 候,代码中用到了 generateAccessToken() 方法,如果要改为私有云的上传下载流程,这 些代码都需要做调整。
解决方案:
-
1、函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
-
2、封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
-
3、为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
核心思维:将功能抽象并向上抽取
有必要为每个类实现接口吗?
只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。
前面我们也提到,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。
六、如何理解多用组合少用继承
1、为什么不推荐使用继承
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问 题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。所以,对于是否应该在项目中使用继承,网上有很多争议。很多人觉得继承是一种反模式,应该尽
量少用,甚至不用。为什么会有这样的争议?我们通过一个例子来解释一下。
假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽 象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。
我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方 法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具 有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事 物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛 出 UnSupportedMethodException 异常不就可以了吗?
这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多, 比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设 计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则 (Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接 口给外部,增加了类使用过程中被误用的概率。
你可能又会说,那我们再通过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的 鸟都继承 AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类,不就可以了吗?具体的继承关系如下图所示:
继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?
是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不 会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类 (AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、 AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。
如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码…一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装 特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用承。那刚刚例子中继承存在的问题,我们又该如何来解决呢?
解决方案:利用组合
实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。
根据前面讲到接口的时候说过,接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这 些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口。
但是!我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。 然后,通过组合和委托技术来消除代码重复。举个例子:
public interface Flyable {
void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
// 省略 Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable { // 鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); // 组合
private EggLayAbility eggLayAbility = new EggLayAbility(); // 组合
//... 省略其他属性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
这样子就可以实现啦~~
我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用呢,都可以通过其他技术手段来达成。
比如 is-a 关系,我们可以通过组合和接口的 has-a 关系 来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上来讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
2、如何判断使用组合还是继承
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在
实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。如果类之间的继承结构稳定(不会轻易改变),**继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。**反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
前面我们讲到继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时 候,从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是 父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到 代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙, 理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现如下所示:
public class Url {
//...
}
public class Crawler {
private Url url; // 组合
public Crawler() {
this.url = new Url();
}
}
public class PageAnalyser {
private Url url; //组合
public PageAnalyser() {
this.url = new Url();
}
}
但是!还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。
public class FeignClient { // feighn client 框架代码
//... 省略其他代码...
public void encode(String url) { //... }
}
public void demofunction(FeignClient feignClient) {
//...
feignClient.encode(url);
//...
}
public class CustomizedFeignClient extends FeignClient {
@Override
public void encode(String url) { //... 重写 encode 的实现...}
}
// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);
因此尽管有些人说,要杜绝继承,100% 用组合代替继承,但是我的观点没那么极端!之所 以“多用组合少用继承”这个口号喊得这么响,只是因为,长期以来,我们过度使用继承。 还是那句话,组合并不完美,继承也不是一无是处。只要我们控制好它们的副作用、发挥它 们各自的优势,在不同的场合下,恰当地选择使用继承还是组合,这才是我们所追求的境界。
以上是关于设计模式之美 精华总结 笔记的主要内容,如果未能解决你的问题,请参考以下文章