设计模式
设计模式的两大精髓
1、对接口编程而不是对实现编程
2、优先使用对象组合而不是继承
设计模式的基石
封装 顺序
继承 判断
多态 循环
设计模式总览
| 创建型 | 结构型 | 行为型 |
|---|---|---|
| 工厂方法模式[factoryMethod] | 适配器模式[adapter] | 策略模式[strategy] |
| 抽象工厂模式[abstractFactory] | 装饰器模式[decorator] | 模板方法模式[templateMethod] |
| 单例模式[singleton] | 代理模式[proxy] | 观察者模式[observer] |
| 建造者模式[builder] | 外观模式[facade] | 迭代器模式[iterator] |
| 原型模式[prototype] | 桥接模式[bridge] | 责任链模式[chainOfResponsibility] |
| 组合模式[composite] | 命令模式[command] | |
| 享元模式[flyweight] | 备忘录模式[memento] | |
| 状态模式[state] | ||
| 访问者模式[visitor] | ||
| 中介者模式[mediator] | ||
| 解释器模式[interpreter] |
组间的生命周期
[结构型]组件的定义:例如我需要一个功能A,对A这个类需要定义,定义里面有什么方法,什么属性。
[创建型]组件的创建:得到类以后,创建类对象。
[行为型]组件的服役:调用类对象的相关方法或类的方法。
组件销毁:功能实现,方法走完,由垃圾回收管理。
1 设计模式--原则
| 设计模式原则名称 | 设计原则简介 |
|---|---|
| 开闭原则 (Open-Closed Principle, OCP) | 对扩展开放,对修改关闭。 |
| 单一职责原则 (Single Responsibility Principle, SRP) | 控制类的粒度大小、将对象解耦、提高其内聚性。 |
| 里氏代换原则 (Liskov Substitution Principle, LSP) | 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义。 |
| 依赖倒转原则 (Dependency Inversion Principle, DIP) | 要面向接口编程,不要面向实现编程。 |
| 接口隔离原则 (Interface Segregation Principle, ISP) | 要为各个类建立它们需要的专用接口。 |
| 迪米特法则 (Law of Demeter, LoD) | 一个类应该保持对其它对象最少的了解,降低耦合度。 |
| 合成复用原则 (Composite Reuse Principle, CRP) | 尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。 |
把这些原则的首字母联合起来(两个 L 算做一个)就是SOLID(solid,稳定的),其代表的含义就是这些原则结合使用的好处:建立稳定、灵活、健壮的设计。
设计模式原则体现很多编程的底层逻辑:高内聚、低耦合、面向对象编程、面向接口编程、面向抽象编程,最终实现可读、可复用、可维护性。
1.1 开闭原则
📌Software entities like classes,modules and functions should be open for extension but closed for modifications.
一个软件实体如类、 模块和函数应该对扩展开放, 对修改关闭。
✔️扩展开放表示,未来业务需求是变化万千,代码应该保持灵活的应变能力。
✔️修改关闭表示,不允许在原来类修改,保持稳定性。
解决问题
因为日常需求是不断迭代更新的,所以我们经常需要在原来的代码中修改。
如果代码设计得不好,扩展性不强,每次需求迭代,都要在原来代码中修改,很可能会引入bug。
因此,我们的代码应该遵循开闭原则,也就是对扩展开放,对修改关闭。
总结
- 提高软件实体的可复用性
- 提高软件实体的可拓展性
- 提供软件实体的可维护性
- 开闭原则( Open Closed Principle )是编程中最基础、最重要的设计原则
- 一个软件实体如类,模块和函数应该对扩展开放 ( 对提供方 ) ,对修改关闭 ( 对使用方) 。用抽象构建框架,用实现扩展细节。
- 当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
- 编程中遵循其它原则,以及使用设计模式的目的就是遵循开闭原则
注意
面向对象的抽象难度大,如果在刚开始使用抽象构建的框架考虑不全,后期已经拓展了很多功能特性,一旦抽象的基础框架发生变动,下面的拓展部分都有可能受到影响;
因此需要很强、很系统的抽象能力把基础框架抽象出来,才能减少后期带来的不必要影响。
例子:
假设有这样的业务场景,大数据系统把文件推送过来,根据不同类型采取不同的解析方式。多数的小伙伴就会写出以下的代码:
if(type=="A"){
//按照A格式解析
}else if(type=="B"){
//按B格式解析
}else{
//按照默认格式解析
}
- 如果分支变多,这里的代码就会变得臃肿,难以维护,可读性低。
- 如果你需要接入一种新的解析类型,那只能在原有代码上修改。
显然,增加、删除某个逻辑,都需要修改到原来类的代码,这就违反了开闭原则了。为了解决这个问题,我们可以使用策略模式去优化它。
你可以先声明一个文件解析的接口,如下:
public interface IFileStrategy {
//属于哪种文件解析类型,A或者B
FileTypeResolveEnum gainFileType();
//封装的公用算法(具体的解析方法)
void resolve(Object param);
}
然后实现不同策略的解析文件,如类型A解析:
@Component
public class AFileResolve implements IFileStrategy {
@Override
public FileTypeResolveEnum gainFileType() {
return FileTypeResolveEnum.File_A_RESOLVE;
}
@Override
public void resolve(Object objectparam) {
logger.info("A 类型解析文件,参数:{}",objectparam);
//A类型解析具体逻辑
}
}
如果未来需求变更的话,比如增加、删除某个逻辑,不会再修改到原来的类啦,只需要修改对应的文件解析类型的类即可。
1.2 单一职责原则
📌There should never be more than one reason for a class to change.
一个类应该有且只有一个变化的原因
假设一个类负责两个职责,一旦发生需求变更,修改其中一个职责的逻辑代码,有可能导致另一个职责的功能发生故障。这样一来,这个类就存在两个导致类变更的原因。
如何解决这个问题呢,将两个职责用两个类来实现,进行解耦。后期需求变更维护互不影响。这样的设计,可以降低类的复杂度,提高类的可读性,提高类的可维护性,降低变更引起的风险。总体来说,就是一个类、接口和方法只负责一项职责。
但在某些情况下一个实体有多种不分类的职责,职责不是一成不变的,我们需要根据具体的情况来划分职责,决定职责颗粒度的大小。
解决问题
是实现高内聚、低耦合的指导方针;
总结
- 降低类的复杂度。一个类只负责一项职责,其逻辑肯定要比负责多项职责简单得多。
- 提高类的可读性。复杂性降低,自然其可读性会提高。
- 提高系统的可维护性。可读性提高,那自然更容易维护了。
- 变更引起的风险降低。变更是必然的,如果单一职责原则遵守得好,当修改一个功能时,可以显著降低对其他功能的影响。
如果我们能对接口、类、方法都做到单一职责原则那当然是最好,但是每个类都实现单一职责原则,则需要使用组合模式,这会引起类间的耦合过重、类的数量增加,这也增加了设计的复杂性。对于单一职责原则,建议是接口一定要做到单一职责原则,类的设计尽量做到只有一个原因引起变化。
1.3 里氏替换原则
第一种定义, 也是最正宗的定义:📌 If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.
如果对每一个类型为S的对象o1, 都有类型为T的对象o2, 使得以T定义的所有程序P在所有的对象o1都代换成o2时, 程序P的行为没有发生变化, 那么类型S是类型T的子类型
第二种定义: 📌Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
所有引用基类的地方必须能透明地使用其子类的对象
第二个定义是最清晰明确的, 通俗点讲, 只要父类能出现的地方子类就可以出现, 而且替换为子类也不会产生任何错误或异常, 使用者可能根本就不需要知道是父类还是子类。 但是, 反过来就不行了, 有子类出现的地方, 父类未必就能适应。
解决问题
我们知道面向对象编程的三个基本原则: 封装,继承和多态。
里氏替换原则就是继承的体现,主要用来解决继承带来的问题。
里氏替换原则的核心是抽象,抽象又依赖于继承。
里氏替换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
里氏代换原则是实现开闭原则的重要方式之一;
总结
里氏替换原则为良好的继承定义了一个规范, 一句简单的定义包含了4层含义。
- 子类必须完全实现父类的方法;子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类中可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)可比父类的方法更宽松
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等
由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
为什么叫里氏替换原则?
里氏替换原则在SOLID这五个设计原则中是比较特殊的存在:
- 如果违反了里氏替换原则,不只是降低软件设计的优雅性,很可能会导致Bug
- 只有里氏替换原则是以人名命令的
里氏替换原则译自Liskov substitution principle。Liskov是一位计算机科学家,也就是Barbara Liskov,麻省理工学院教授,也是美国第一个计算机科学女博士,师从图灵奖得主John McCarthy教授,人工智能概念的提出者。
里氏替换原则最初由Barbara Liskov在1987年的一次学术会议中提出,而真正正式发表是在1994年,Barbara Liskov 和 Jeannette Wing发表的一篇学术论文《A behavioral notion of subtyping》.
什么是替换
替换的前提是面向对象语言所支持的多态特性,同一个行为具有多个不同表现形式或形态的能力。以JDK的集合框架为例,List接口的定义为有序集合,List接口有多个派生类,比如大家耳熟能详的ArrayList,LinkedList。那当某个方法参数或变量是List接口类型时,既可以是ArrayList的实现, 也可以是LinkedList的实现,这就是替换。
举例:
public String getFirst(List<String> values) {
return values.get(0);
}
对于getFirst方法,接受一个List接口类型的参数,那既可以传递一个ArrayList类型的参数:
List<String> values = new ArrayList<>();
values.add("a");
String firstValue = getFirst(values);
又可以接收一个LinkedList参数:
List<String> values = new LinkedList<>();
values.add("a");
String firstValue = getFirst(values);
例子:
public class Cache {
public void set(String key, String value) {}
}
public class RedisCache extends Cache {
public void set(String key, String value) {}
}
这里例子是没有违反里氏替换原则的,任何父类、父接口出现的地方子类都可以出现。如果给RedisCache加上参数校验,如下:
public class Cache {
public void set(String key, String value) {}
}
public class RedisCache extends Cache {
public void set(String key, String value) {
if (key == null || key.length() < 10 || key.length() > 100) {
System.out.println("key的长度不符合要求");
throw new IllegalArgumentException();
}
}
}
这就违反了里氏替换原则了,因为子类方法增加了参数校验,抛出了异常,虽然子类仍然可以来替换父类。
1.4 依赖倒置原则
📌High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details.Details should depend upon abstractions.
翻译过来, 包含三层含义: ● 高层模块不应该依赖低层模块, 两者都应该依赖其抽象; ● 抽象不应该依赖细节; ● 细节应该依赖抽象。
核心思想:面向接口编程,而不是面向实现编程
简单说,
在码代码的时候,一定都写过接口,写过抽象类,然后再进行实例化得到具体对象。
然后让实例化得到的具体对象依赖于接口或者抽象类,做到这点也就意味着自己的代码遵循了依赖倒置原则。
解决问题
在商品经济萌芽的时候出现以物易物,假如买一件衣服,老板要你拿一头猪换,可是你定不会养猪,你只会编程。你找到养猪户,决定写一个APP换他一头猪,他说换猪可以,但是得用一条金链来换…
所以这里就出现了一连串的对象依赖,从而造成了严重的耦合灾难。解决这个问题的最好的办法就是,买卖双方都依赖于抽象——也就是货币——来进行交换,这样一来耦合度就大为降低了
依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。
总结
- 依赖倒置原则可以降低类间的耦合性。
- 依赖倒置原则可以提高系统的稳定性。
- 依赖倒置原则可以减少并行开发引起的风险。
- 依赖倒置原则可以提高代码的可读性和可维护性。
由于在软件设计中,细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。
使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成。
例子:
违反依赖倒置原则的代码,业务需求是:顾客从淘宝购物。
class Customer{
public void shopping(TaoBaoShop shop){ //购物
System.out.println(shop.buy());
}
}
以上代码是存在问题的,如果未来产品变更需求,改为顾客从京东上购物,就需要把代码修改为:
class Customer{
public void shopping(JingDongShop shop){ //购物
System.out.println(shop.buy());
}
}
如果产品又变更为从天猫购物呢?那有得修改代码了,显然这违反了开闭原则。顾客类设计时,同具体的购物平台类绑定了,这违背了依赖倒置原则。可以设计一个shop接口,不同购物平台(如淘宝、京东)实现于这个接口,即修改顾客类面向该接口编程,就可以解决这个问题了。代码如下:
class Customer{
public void shopping(Shop shop){ //购物
System.out.println(shop.buy());
}
}
interface Shop{
String buy();
}
Class TaoBaoShop implements Shop{
public String buy(){
return "从淘宝购物";
}
}
Class JingDongShop implements Shop{
public String buy(){
return "从京东购物";
}
}
1.5 接口隔离原则
📌Clients should not be forced to depend upon interfaces that they don't use.
客户端不应该依赖它不需要的接口
📌The dependency of one class to another one should depend on the smallest possible interface.
类间的依赖关系应该建立在最小的接口上
它要求建立单一的接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少,让接口中只包含客户(调用者)感兴趣的方法。即一个类对另一个类的依赖应该建立在最小的接口上。也就是说使用多个接口来替代一个统一的接口;
解决问题
用来解决所谓 “胖接口” 的问题。 就是接口中定义了很多抽象的方法,但这些方法 可能对于不同客户端来说有些方法,可能根本不需要来实现。 导致客户端(接口的使用者)拥有了一些他们根本都不需要的方法。这就是 “胖接口” 带来的问题。
总结
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
1.6 迪米特法则
迪米特法则来自于1987年美国东北大学(Northeastern University)一个名为“Demeter”的研究项目。也称为最少知识原则(Least Knowledge Principle, LKP),虽然名字不同, 但描述的是同一个规则:
一个对象应该对其他对象有最少的了解。
通俗地讲,
一个类应该对自己需要耦合或调用的类知道得最少, 你(被耦合或调用的类) 的内部是如何复杂都和我没关系, 那是你的事情, 我就知道你提供的这么多public方法, 我就调用这么多, 其他的我一概不关心。
解释:无需直接交互的两个类,如果需要交互,使用中间者 注意:过度使用迪米特法则会使系统产生大量中介类,从而增加系统的复杂性,使模块直接通信效率降低。
迪米特法则对类的低耦合提出了明确的要求, 其包含以下4层含义。
- 只和朋友交流
- 朋友间也是有距离的
- 是自己的就是自己的
- 谨慎使用Serializable
解决问题
类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。使用迪米特法则,降低类与类之间的耦合。
总结
在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;
在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;
在类的设计上,只要有可能,一个类型应当设计成不变类;
在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
1.7 合成复用原则
尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。
解决问题
通常类的复用分为继承复用和合成复用两种,继承复用虽然有简单和易实现的优点,但它也存在以下缺点。
- 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
- 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点。
- 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
- 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
- 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。
总结
如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。
例子:
class A{
public void method1(){}
public void method2(){}
}
class B extends A{
}
首先A类需要添加一个新方法method3(),但是B类用不到,如果用继承的方式去调用,那么method3也会被B继承;很明显偶合性高。
使用 合成/聚合/组合方式降低偶合
//合成
class A{
public void method1(){}
public void method2(){}
}
class B {
private A a;
public setA(A a){
this.a=a
}
public call_method1(){
a.method1();//返回a对象,然后通过a对象调用method1
}
}
//聚合
class A{
public void method1(){}
public void method2(){}
}
class B {
public methods_b(A a){
a.method1();//聚合的方式调用method1
}
}
//组合
class A{
public void method1(){}
public void method2(){}
}
class B {
private A a = new A();
public methods_b(){
a.method1();//聚合的方式调用method1
}
}
耦合性排序:合成 <聚合< 组合<继承