分类
设计模式分为创建型、结构型、行为型三类,一共23种
创建型模式
创建型模式一般是用来创建一个新的对象
单例模式、抽象工厂模式、建造者模式、工厂模式、原型模式。结构型模式
描述如何将类或者对 象结合在一起形成更大的结构,就像搭积木,可以通过 简单积木的组合形成复杂的、功能更为强大的结构。
适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式、代理模式。行为型模式
行为型模式关注的是对象之间的通信,也就是描述多个类或者对象之间,通过协作共同完成一个任务。主要涉及的是 对象 和 算法之间职责的分配。
模版方法模式、命令模式、迭代器模式、观察者模式、中介者模式、备忘录模式、解释器模式、状态模式、策略模式、职责链模式(责任链模式)、访问者模式。
六大原则
学习设计模式首先要明白所有原则都是为了达到面向对象设计的可扩展可复用可维护性而出现的。
对于设计模式的原则,不同的书说法不一样,有的6个有的7个,单一职责原则和接口隔离原则多数只提了一个。
开闭原则为最重要的面向对象设计原则,它是面向对象的可复用设计的第一块基石。其他几条,则可以看做是开闭原则的实现方法。 设计模式就是实现了这些原则,从而达到了代码复用、增加可维护性的目的。
单一职责原则
定义:类职责要单一,一个类应该只做一件事情。
目的:降低代码复杂度、系统解耦合、提高可读性。单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
案例:电线类Wire为居民供电,电压为220v;但是新的需求增加,电线也输送高压电,电压为200kv,原有电线类可以增加方法实现扩充,这就违背了单一职责原则。可以提供基类,创建两个派生类,居民供电线、高压输电线。
单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。
开闭原则(OCP)
- 概念
一个软件实体(类、模块和方法)应该对扩展开放,对修改关闭。当功能需要变化的时候,我们应该是通过扩展的方式来实现,而不是通过修改已有的代码来实现。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要
- 目的
一个已有的代码模块,需要很容易增加新的扩展功能(可扩展性),这个已有模块需要是对外开放的;为了使已有模块可以复用(可复用性),已有模块需要是独立的(单一职责,高内聚,不与其他模块耦合在一起),同时为了方便维护(可维护性),已有模块最好不要对原有代码进行修改,也就是需要是对内封闭的;实现了开闭原则的设计,就达到面向对象设计可扩展可复用可维护性的目的
- 场景分析
一个软件随着时间的推移会不断更新升级,那么在程序进行更新、升级的过程中,我们应该尽量避免在原来代码上进行修改,因为这样会使得原来的程序出现异常或变得不够稳定,并且更改后我们需要对原有的功能进行反复的测试,这样的做法是不提倡的。
- 解决方案
当程序功能需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。
程序设计
现有如下程序
目前程序支持2种银行的转账,2种银行的转账手续费各不一样,1年开发小白写的代码如下:
//交通银行 class CBCBank { //转账100,扣除手续费1块钱 int transferMoney() { return 100 - 1; } } //建设银行 class ICBCBank { //转账100,扣除手续费2块钱 int transferMoney() { return 100 - 2; } } class TransferAccount { //转账方法 public void transfer(String type) { if("icbc".equals(type)) { ICBCBank bank = new ICBCBank(); bank.transferMoney(); }else if("cbc".equals(type)) { CBCBank bank = new CBCBank(); bank.transferMoney(); } } }
上述代码在线上运行得非常不错,但随着业务的扩大,需要加入新的银行转账功能,此时开发小白说没有问题,三下五除二,加了一个银行,并且更改了
TransferAccount
中的方法,如下://农业银行 class ABCBank { //转账100,扣除手续费3块钱 int transferMoney() { return 100 - 3; } } class TransferAccount { //转账方法 public void transfer(String type) { if("icbc".equals(type)) { ICBCBank bank = new ICBCBank(); bank.transferMoney(); }else if("cbc".equals(type)) { CBCBank bank = new CBCBank(); bank.transferMoney(); //增加了ABC银行的判断 }else if("ABC".equals(type)) { ABCBank bank = new ABCBank(); bank.transferMoney(); } } }
上述代码需要修改transfer已有功能的源码,增加新的逻辑判断,违反了开闭原则,现对该系统进行重构,使其符合开闭原则
使用开闭原则后的程序
程序中transfer方法需要针对每一种银行进行硬编程,业务发生变更该代码就要发生改变,故进行调整,使用抽象化的方式对系统进行重构,使得业务扩展时改代码不受影响,具体做法如下
增加一个抽象银行类
AbstractBank
,将各种具体的银行类作为其子类;abstract class AbstractBank { abstract int transferMoney(); } //交通银行 class CBCBank extends AbstractBank { int transferMoney() {...} } //建设银行 class ICBCBank extends AbstractBank { int transferMoney() {...} } //农业银行 class ABCBank extends AbstractBank{ int transferMoney() {...} }
TransferAccount
类针对抽象银行类进行编程,由用户来决定使用哪种具体银行;class TransferAccount { //转账方法 public void transfer(AbstractBank bank) { System.out.println("转账前的校验"); bank.transferMoney(); System.out.println("转账后的通知"); } }
调用程序
TransferAccount transferAccount = new TransferAccount(); //转ICBC银行 transferAccount.transfer(new ICBCBank());
- 总结
将程序调整满足开闭原则后,后续代码更新,我们无需再修改
TransferAccount
转账方法,而只需要加入一个新的银行,将新的银行继承AbstractBank
即可
- 图解程序
开闭原则优点
- 可复用性
- 可维护性
注意事项
- 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法
- 参数类型、引用对象尽量使用接口或者抽象类,而不是具体的实现类
- 抽象层尽量保持稳定,一旦确定即不允许修改,这对开发人员的经验有一定要求
里氏替换原则(LSP)
- 定义:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型
- 简单理解:任何基类可以出现的地方,子类一定可以出现
- 详细描述:在代码中将一个基类对象替换成它的子类对象,程序不会产生任何错误和异常,反过来则不成立,如果一个类实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。
例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
场景分析:例如有两个类,一个类为
BaseClass
,另一个是SubClass
类,并且SubClass
类是BaseClass
类的子类,那么一个方法如果可以接受一个BaseClass
类型的基类对象base的话,如:method1(base)
,那么它必然可以接受一个BaseClass
类型的子类对象sub,method1(sub)
能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass
类型的子类对象sub为参数:method2(sub)
,那么一般而言不可以有method2(base)
。class BaseClass { ... } class SubClass extends BaseClass{ ... } class OtherClass { //此处的BaseClass能够替换成子类的SubClass类型对象 public void method1(BaseClass base){ } //反过来,此处的SubClass对象不能够替换为BaseClass对象 public void method2(SubClass sub){ } }
我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这已经使用了里氏替换原则。
我们举一个拿手机打电话的例子,结构组成如下
abstract class AbstractPhone {
//打电话方法
public abstract void call();
}
class FixedPhone extends AbstractPhone {
@Override
public void call() {
System.out.println("用固定电话打电话");
}
}
class MobilePhone extends AbstractPhone {
@Override
public void call() {
System.out.println("用移动电话打电话");
}
}
//用户打电话
class Person {
private AbstractPhone phone;
public void setPhone(AbstractPhone phone) {
this.phone = phone;
}
public void startCall() {
System.out.println("用户开始打电话");
this.phone.call();
}
}
public class Client {
public static void main(String[] args) {
Person person = new Person();
//设置用户拿固定电话
person.setPhone(new FixedPhone());
person.startCall();
//设置用户拿移动电话
person.setPhone(new MobilePhone());
person.startCall();
}
}
里氏替换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
里氏替换原则的另一层含义是子类可以扩展父类的功能,但不能改变父类原有的功能,主要作用就是规范继承时子类的一些书写规则,接下来我们看看继承规范时的书写规则有哪些。
里氏替换原则继承规范
里氏替换原则对继承进行了规则上的约束,这种约束主要体现在四个方面:
子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法(视情况而定)
以下例举出Java小白开发者的编码,看看覆盖了父类的非抽象方法后会带来什么后果
//类A完成,两个数相加的功能 class A { public int fun1(int a,int b){ return a + b; } } //Demo执行 public class Demo3 { public static void main(String[] args){ A a = new A(); System.out.println("5+2的结果为:" + a.fun1(5,2)); } } //程序输出:5+2的结果为:7
随着业务的发展,我们需要新增一个功能,完成2个数相减,并对其结果再减去10,考虑到扩展性,我们不应该改动原来的代码,所以我们重新定义一个类B完成,如下。
//类B,在类A的基础上扩展 class B extends A { public int fun1(int a,int b){ return a - b; } public int fun2(int a,int b){ return fun1(a , b) - 10; } } //Demo执行 public class Demo3 { public static void main(String[] args){ B b = new B(); System.out.println("25+2的结果为:" + b.fun1(25,2)); System.out.println("18+2的结果为:" + b.fun1(18,2)); System.out.println("18-2-10的结果为:" + b.fun2(18,2)); } } //程序输出:25+2的结果为:23 //程序输出:18+2的结果为:16 //程序输出:18-2-10的结果为:6
此时你会发现,我们原本以为在类A的基础上去扩展的类B会正确输出,但实际类B已经覆盖了类A中已实现的方法,所以出现了错误的结果。虽然这段代码我们能够看出来有问题,但在实际开发中,在不知情的情况下去覆盖了父类的非抽象方法,这会带来意想不到的错误。
子类中可以增加自己特有的方法。
这个规则我们通过继承就能够知道,子类可以扩展自己的行为和属性,那为什么要在里氏替换中提出呢?主要是因为里氏替换原则是说子类可以胜任父类的任何工作,但父类不一定能够替换子类,所以提出该规则
我们在上述
打电话
的案例中新增一个例子,比如移动电话还有其子类产品,比如OPPO、HuaWei、Mi//移动电话的子类,华为手机 class HuaWei extends MobilePhone { public void unlock() { System.out.println("打电话之前解锁"); } public void call() { System.out.println("华为手机打电话"); } } //动作处理,由于扩展了属性,所以不再适合在之前的Person类中更改,新建一个User类直接处理 class User{ public void startCall(HuaWei huaWei) { huaWei.unlock(); huaWei.call(); } } //客户端调用类 public class Client { public static void main(String[] args) { //子类可以直接调用 User user = new User(); user.startCall(new HuaWei()); //如果现在指定了子类,你要传入父类,会出现编译错误,即使向下强转也会出运行异常 User user = new User(); user.startCall((HuaWei)(new MobilePhone()));//出现异常 } }
当子类覆盖或实现父类已实现的方法时,子类方法的输入参数(形参)要比父类方法的输入参数更宽松,不能相同。(其实是要求子类不允许覆盖父类已实现的方法)
有如下代码,运行结果为:父类被执行
//父类 class Father { public void doSomething(HashMap<String, String> map) { System.out.println("父类被执行"); } } //子类 class Son extends Father { public void doSomething(Map<String, String> map) { System.out.println("子类被执行"); } } class Client{ public static void main(String[] args) { Father father = new Father(); HashMap<String, String> map = new HashMap<String, String>(); father.doSomething(map); } }
根据里氏替换原则,父类出现的地方,子类也是可以出现的。我们把Client代码修改如下:
class Client{ public static void main(String[] args) { // 父类存在的地方,子类应该可以存在,而且结果应与父类相同 //Father father = new Father(); Son father = new Son(); HashMap<String, String> map = new HashMap<String, String>(); father.doSomething(map); } }
运行结果依然是:
父类被执行
结果一样,父类的方法的输入参数是HashMap类型,子类的方法输入参数是Map类型,也就是说子类的输入参数类型范围扩大了,子类代替父类,子类的方法不被执行,这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。
如果,我们反过来,把父类的输入参数类型放大,子类的输入参数类型缩小,让子类的输入参数类型小于父类的输入参数类型,看看会出现什么情况?
//错误实例 //父类 class C { public void fun(Map<String,String> map){ System.out.println("父类被执行..."); } } //子类 class D extends C{ public void fun(HashMap<String,String> map){ System.out.println("子类被执行..."); } } public class Client { public static void main(String[] args) { System.out.print("父类的运行结果:"); C c = new C(); HashMap<String,String> map=new HashMap<String,String>(); c.fun(map); //父类存在的地方,都可以用子类替代 //子类B替代父类A System.out.print("子类替代父类后的运行结果:"); D d=new D(); d.fun(map); } } //输出结果: 父类的运行结果:父类被执行... 子类替代父类后的运行结果:子类被执行...
在父类方法没有被重写的情况下,子方法被执行了,这样就引起了程序逻辑的混乱。所以子类中方法的前置条件必须与父类中被覆写的方法的前置条件相同或者更宽松。
当子类的方法实现父类的抽象方法时,方法的返回值要比父类更严格(范围更小)。
abstract class E { public abstract Map<String,String> fun(); } class F extends E{ @Override public HashMap<String,String> fun(){ HashMap<String,String> f = new HashMap<String,String>(); f.put("f","子类被执行..."); return f; } } public class Client { public static void main(String[] args) { E f = new F(); System.out.println(f.fun()); } }
若在继承时,子类的方法返回值类型范围比父类的方法返回值类型范围大,在子类重写该方法时编译器会报错。
- 以上就是里氏替换原则知识点,有一定的参考作用,但无需严格遵守,在实际开发中应根据实际情况来遵守
依赖倒置原则(DIP)
- 定义:
高层模块不应该依赖于底层模块,两者都应该依赖其抽象。抽象不应该依赖细节,细节应该依赖于抽象。是实现开闭原则的基础,其实就是面向接口编程的解释,理解了面向接口编程,也就理解了依赖倒置。
- 详细描述:
在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。
在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
问题由来
某天有个需求,客户要求开发一个高压锅,可以煮饭,小白开发用了3分钟就设计一个高压锅煮饭的程序,代码如下
//大米 class Rice { public String heating() { return "煮大米,加热10分钟就好"; } } //高压锅 class Pot { public void cook(Rice rice) { System.out.println("高压锅开始煮饭..."); System.out.println(rice.heating()); } } public class Demo1 { public static void main(String[] args) { Pot pot = new Pot(); pot.cook(new Rice()); } } 运行结果: 高压锅开始煮饭... 煮大米,加热10分钟就好
大米客户可以自己准备,时间客户也能够跳转,上线后程序运行得非常愉快,从未出现过bug,老板也夸奖了该小白开发,但是有一天,来了一个需求:老板说,要求该高压锅还能炒菜,还需要支持炖排骨汤,小白瞬间就傻了,因为目前的程序办不到
//炒菜、炖汤的代码如下: //炒菜 class Fry { public String heating() { return "炒菜,加热5分钟就好"; } } //炖汤 class Soup { public String heating() { return "炖汤,加热20分钟就好"; } }
该高压锅对客户称是全能高压锅,竟然不能炒菜和炖汤,若要支持该功能,还需要把锅的构成给修改下,这显然不行,锅已经卖个客户了,再回来加工显然不合适。这肯定不是一个好的设计,原因就是锅与米饭的耦合太高了,必须降低它们之间的耦合才行。
问题解决:
我们引入一个抽象层(接口或抽象类),IFood(食物),只要是食物,高压锅都支持
interface IFood{ public String heating(); }
高压锅与IFood进行依赖,而米饭、菜、汤都属于食物,它们各自去实现IFood接口,这样就符合依赖倒置原则了,修改后的代码如下:
interface IFood{ public String heating(); } //炖汤 class Soup implements IFood{ public String heating() { return "炖汤,加热20分钟就好"; } } //炒菜 class Fry implements IFood { public String heating() { return "炒菜,加热5分钟就好"; } } //大米 class Rice implements IFood { public String heating() { return "煮大米,加热10分钟就好"; } } //高压锅 class Pot { public void cook(IFood food) { System.out.println("高压锅开始运作..."); System.out.println(food.heating()); } } public class Client { public static void main(String[] args) { Pot pot = new Pot(); pot.cook(new Rice()); pot.cook(new Soup()); } }
这样修改后,只要是煮食物,我们都不再给高压锅加工,客户也不会让锅回厂重新造了,至于煮什么时候让客户自行准备就可以
- 优点
采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Pot类与Rice类直接耦合时,Pot类必须等Rice类编码完成后才可以进行编码,因为Pot类依赖于Rice类。修改后的程序则可以同时开工,互不影响,因为Pot与Rice类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。
开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段
- 依赖注入(
DependencyInjection, DI
)针对抽象编程时,我们要将具体的对象传递到方法中使用,这种传递我们叫做依赖注入。依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,
构造注入:构造注入是指通过构造函数来传入具体类的对象
设值注入:设值注入是指通过Setter方法来传入具体类的对象
接口注入:接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。
编程建议
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
接口隔离原则(ISP)
- 定义:使用多个专门的接口,而不使用一个涵盖所有功能的总接口。即客户端不应该依赖那些它不需要的接口。设计接口的时候要精简单一,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。
- 概述:根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。
接口隔离案例
未遵循接口隔离原则的设计,如下图
接口声明:
//接口 interface I { public void method1(); public void method2(); public void method3(); public void method4(); public void method5(); }
调用说明:
类A
依赖接口1
中的方法1、方法2、方法3,类E
是对类A
的调用依赖,虽然类E
用不到方法4、方法5,但类A
依然要全部重写接口1
中的所有方法。class A implements I{ public void method1() { System.out.println("类A实现接口I的方法1"); } public void method2() { System.out.println("类A实现接口I的方法2"); } public void method3() { System.out.println("类A实现接口I的方法3"); } //对于类A来说,method4和method5不是必需的,但是实现接口必须全部重写所有方法。 public void method4() {} public void method5() {} } //调用类E,完成对类A的依赖调用 class E{ public void depend1(I i){ i.method1(); } public void depend2(I i){ i.method2(); } public void depend3(I i){ i.method3(); } }
类B依赖接口1中的方法1、方法4、方法5,类F是对类B的调用依赖,虽然类F用不到方法2、方法3,但类B依然要全部重写接口1中的所有方法。
class B implements I{ public void method1() { System.out.println("类B实现接口I的方法1"); } //对于类D来说,method2和method3不是必需的,但是实现接口必须全部重写所有方法。 public void method2() {} public void method3() {} public void method4() { System.out.println("类B实现接口I的方法4"); } public void method5() { System.out.println("类B实现接口I的方法5"); } } //调用类F,完成对类B的依赖调用 class F{ public void depend1(I i){ i.method1(); } public void depend2(I i){ i.method4(); } public void depend3(I i){ i.method5(); } }
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。
遵循接口隔离原则的设计,如下图
在这里我们将原有的接口I拆分为三个接口
接口代码
interface I1 { public void method1(); } interface I2 { public void method2(); public void method3(); } interface I3 { public void method4(); public void method5(); }
调用说明:
类A依赖接口1中的方法1、接口2中的方法2、方法3,类E是对类A的调用依赖。
class A implements I1,I2{ public void method1() { System.out.println("类A实现接口I1的方法1"); } public void method2() { System.out.println("类A实现接口I2的方法2"); } public void method3() { System.out.println("类A实现接口I2的方法3"); } } //调用类E,完成对类A的依赖调用 class E{ public void depend1(I1 i){ i.method1(); } public void depend2(I2 i){ i.method2(); } public void depend3(I2 i){ i.method3(); } }
类B依赖接口1中的方法1、接口3中的方法4、方法5,类F是对类B的调用依赖。
class B implements I1,I3{ public void method1() { System.out.println("类B实现接口I1的方法1"); } public void method4() { System.out.println("类B实现接口I3的方法4"); } public void method5() { System.out.println("类B实现接口I3的方法5"); } } //调用类F,完成对类B的依赖调用 class F{ public void depend1(I1 i){ i.method1(); } public void depend2(I3 i){ i.method4(); } public void depend3(I3 i){ i.method5(); } }
- 本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性
- 与单一职责区别
其一,单一职责原则注重的是职责;而接口隔离原则注重对接口依赖的隔离。
其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
接口隔离原则注意点
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不争的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
合成/聚合复用原则(CARP)
- 定义:
合成/聚合复用原则经常又叫做合成复用原则。该原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的。尽量使用对象组合,而不是继承来达到复用的目的。
- 概述:
在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,但首先应该考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏替换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。
- 作用:
由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
案例
- 组合(Composition)contains-a关系
组合关系表示事物的整体/部分关系的较强的情况,组合关系的类具有相同的生命周期。比如类A中包含了类B的一个引用b,当类A消亡时,b这个引用所指向的对象也同时消亡,这种情况称为组合,比如人和大脑。
- 聚合(Aggregation)has-a关系
聚合关系表示事物的整体/部分关系的较弱情况,当类A对象消亡时,类B不会消亡,因为可能还有其他对象指向了它。比如人和电脑。
生动的案例
一只野狼单独很难捕获到猎物,所以它们一直过着群居的生活,不容易被饿死,这样就有了狼群。每一只野狼都有自己的狼群,每个狼群都有好多野狼,野狼与狼群的这种关系就可以称之为聚合,另外每只野狼都有4条狼腿,野狼与狼腿的关系就叫做组合。
由此可见 聚合的关系明显没有组合紧密,野狼不会因为它们的群主将狼群解散而无法生存,而狼腿就无法脱离野狼而单独生存。
组合关系
//野狼类 public class Wolf{ //狼腿 public Legs legs; public Wolf() { legs = new Legs(); } }
组合关系的类里含有另一个类的实例化。
野狼类(Wolf)在实例化之前 一定要先实例化狼腿类(Legs) 两个类紧密耦合在一起,它们有相同的生命周期,狼腿类(Legs)不可以脱离野狼类(Wolf)而独立存在
在组合关系中,客户端只认识野狼类,根本就不知道狼腿类的存在,因为狼腿类被严密的封装在野狼类中
聚合关系
//狼群类 class WolfGroup{ //狼 public Wolf wolf; public WolfGroup(Wolf wolf){ this.wolf = wolf; } }
聚合关系的类里含有另一个类作为参数。
狼群类(WolfGroup)的构造函数中要用到野狼(Wolf)作为参数把值传进来,野狼类(Wolf)可以脱离狼群类而独立存在
在聚合关系中,客户端可以同时了解狼群类和野狼类,因为他们都是独立的
- 经典案例
在我们的日常开发中,经常遇到数据源切换的问题,比如早期我们采用的是MySQL数据库,为此我们开发了一个针对MySQL数据库的MySQLDBUtil类,主要给其他Dao层提供SQL操作,该DBUtil中封装了数据库的连接,执行及关闭方法。由于该类给每个Dao都提供支持,考虑到重用性,所有的Dao层都继承MySQLDBUtil类,这样方便直接调用数据库执行方法,就这样用得很愉快。
但随着客户需求的变更,现要切换成Oracle数据库,所以我们新增了一个OracleDBUtil类来支持该数据库的连接、执行及关闭功能。由于之前的Dao层设计为继承MySQLDBUtil,所以无法继续继承OracleDBUtil,这样也违反开闭原则。现在采用组合复用原则对其代码进行重构。
根据合成复用原则,我们在实现复用时应该多用关联,少用继承。因此在本实例中我们可以使用关联复用来取代继承复用,重构后的结构如下图所示
在上图中
UserDao
和DBUtil
中的关系由继承关系变为关联关系,采用依赖注入的方式将DBUtil
注入到UserDao
中;如果需要对DBUtil
的功能进行扩展,可以通过其子类来实现,如通过子类OracleDBUtil
来连接Oracle数据库,通过MySQLDBUtil
来连接MySQL数据库,根据里氏替换原则DBUtil
的子类可以覆盖DBUtil
对象,只需要将传入对象换成子类即可,原有代码无需修改,而且可以更加灵活的增加新的数据库连接方式。
- 组合(Composition)contains-a关系
- 总结
合成和聚合均是关联关系的特殊情况,它的优点是能够直接黑箱复用组合类的其他属性和行为,因为组合对象内部细节是当前对象无法看见的,所以这种复用所需的依赖较少,组合类内部的实现动态发生改变对当前对象造成的影响会比较少。
迪米特法则(LoD)
- 定义:
一个软件实体应当尽可能少地与其他实体发生相互作用
- 详细概述:
迪米特法则又叫最少知道原则,通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。
- 问题由来:
类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。迪米特法可降低系统的耦合度,使类与类之间保持松散的耦合关系。
- 其他定义形式
迪米特法则还有一个英文解释是:talk only to your immediate friends:只与直接的朋友交流。对于一个对象,其朋友包括:成员属性、方法入参中的类对象、方法返回值中的类、如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友。
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”(如方法体内部的类对象)。
在应用迪米特法则时,一个对象只能与直接朋友发生交互,否则就是违法了迪米特原则。
迪米特法则案例
上体育课,我们经常有这样一个场景:
体育老师上课前要体育委员确认一下全班女生到了多少位,也就是体育委员清点女生的人数。类图如下:首先我们需要确认
Teacher
的朋友,在实际清点人数时,Teacher
只需要与体育委员打交道,并不需要一个个去数女生的人数,所以Teacher
的朋友只有GroupLeader
,不包含Girl
示例代码
//老师类 class Teacher{ //老师对体育委员发一个命令,让其清点女生人数 public void command(GroupLeader groupLeader){ List<Girl> listGirls = new ArrayList(); //初始化女生 for(int i=0;i<20;i++){ listGirls.add(new Girl()); } //告诉体育委员开始清点女生人数 groupLeader.countGirls(listGirls); } } //体育委员类 class GroupLeader{ //清点女生数量 public void countGirls(List<Girl> listGirls){ System.out.println("女生人数是:"+listGirls.size()); } } class Girl{ } //测试程序 public class Client{ public static void main(Strings[] args){ Teacher teacher = new Teacher(); //老师给体育委员发清点女生人数的命令 teacher.command(new GroupLeader()); } }
我们再回头看Teacher类,Teacher类只有一个朋友类GroupLeader,Girl类不是朋友类,但是Teacher与Girl类通信了,这就破坏了Teacher类的健壮性,Teacher类的方法竟然与一个不是自己的朋友类Girl类通信,这是不允许的,严重违反了迪米特原则。
我们对程序进行如下修改,将类图修改如下:
修改后的示例代码
class Teacher{ //老师对体育委员发一个命令,让其清点女生人数 public void command(GroupLeader groupLeader){ //告诉体育委员开始清点女生人数 groupLeader.countGirls(); } } class GroupLeader{ private List<Girl> listGirls; public GroupLeader(){ //产生女生群体 List<Girl> listGirls = new ArrayList<Girl>(); //初始化女生 for(int i=0;i<20;i++){ listGirls.add(new Girl()); } } //清点女生数量 public void countGirls(){ System.out.println("女生人数是:"+listGirls.size()); } } class Girl{ } class Client{ public static void main(Strings[] args){ Teacher teacher = new Teacher(); //老师给体育委员发清点女生人数的命令 teacher.command(new GroupLeader()); } }
对程序修改,把Teacher中对Girl群体的初始化移动到场景类中,同时在GroupLeader中增加对Girl的注入,避开了Teacher类对陌生类Girl的访问,降低了系统间的耦合,提高了系统的健壮性。
案例2
朋友类间也是要有距离
我们在安装软件时,经常会有一个安装向导的过程。
比如第一步确认是否安装,第二步确认License,第三步选择安装目录…..。这个是一个典型的顺序执行动作,我们定义软件安装过程的类图如下:
示例
示例代码
//安装导向类 public class Wizard{ private Random rand = new Random(System.currentTimeMillis()); //第一步 public int first(){ System.out.println("执行第一个方法....."); return rand.nextInt(100); } //第二步 public int second(){ System.out.println("执行第二个方法....."); return rand.nextInt(100); } //第三步 public int third(){ System.out.println("执行第三个方法....."); return rand.nextInt(100); } } //安装类 public class InstallSoftware{ public void installWizard(Wizard wizard){ int first = wizard.first(); //根据first返回的结果,看是否要执行下一步 if(first >50){ int second = wizard.second(); if(second >50){ wizard.third(); } } } } //测试类 public class Client{ public static void main(Strings[] args){ InstallSoftware invoker = new InstallSoftware(); invoker.installWizard(new Wizard()); } }
以上的程序非常简单,但是隐藏了一个问题。Wizard类把太多的方法暴露给InstallSoftware类,导致两者的关系太亲密,耦合关系变量异常牢固。我们把Wizard类进行重构,uml类图如下:
修改后的示例代码
//安装导向类 public class Wizard{ private Random rand = new Random(System.currentTimeMillis()); //第一步 private int first(){ System.out.println("执行第一个方法....."); return rand.nextInt(100); } //第二步 private int second(){ System.out.println("执行第二个方法....."); return rand.nextInt(100); } //第三步 private int third(){ System.out.println("执行第三个方法....."); return rand.nextInt(100); } //软件安装过程 public void installWizard(){ int first = wizard.first(); //根据first返回的结果,看是否要执行下一步 if(first >50){ int second = wizard.second(); if(second >50){ wizard.third(); } } } } //安装类 public class InstallSoftware{ public void installWizard(Wizard wizard){ wizard.installWizard() } }
通过重构,类间的耦合关系变弱了,结构变得清晰,变量的风险也变小了。
一个类公开的public方法和属性越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private,package-private,protected等访问权限,是否可以加上final关键字。- 迪米特原则注意点
迪米特原则要求类“羞涩”一点,尽量不要对外公开太多的public方法和非静态的public变量,尽量内敛,多使用private,package-private,protected等访问权限。
在实践中经常出现这样一个方法,放在本类中也可以,放到其它类中也可以。那怎么处理呢?你可以坚持一个原则:如果一个方法放在本类中,即不增加类间关系,也对本类不产生负面影响,那就放到本类中。
- 总结
迪米特原则的核心观念就是类间解耦,弱耦合,只有弱耦合后,类的复用率才可以提高。其结果就是产生了大量的中转或跳转类,导致系统复杂,为维护带来了难度。所以,我们在实践时要反复权衡,即要让结构清晰,又做到高内聚低耦合。
总结
对于原则,到底是6个还是7个,这个不重要,有的书上说的是6个,有的是7个,6大原则,一法则
- 单一职责原则
一个类应该只做一件事情,职责要单一,一个类的职责过多,就会产生职责耦合,当其中一个职责发生变化的时候,其他的职责就会受到影响。
- 开放-闭合原则
软件实体(类、模块、方法)应该开发扩展,关闭修改,对扩展是开放的,对更改是关闭的。
我们应该是面向接口/抽象编程,在增加这个系统需求的功能的时候,应该考虑扩展一个抽象的子类,而不是去直接修改以前的代码
- 里氏替换原则
凡是能够使用到父类的地方,将其替换成子类,程序也能够正常运行,结果不会发生改变
- 依赖倒置原则
高层模块不应该依赖于底层模块,2个都应该依赖于抽象,抽象不应该依赖于细节,细节应该依赖于抽象。
针对接口/抽象编程,不要对具体实现的细节进行编程。
- 接口隔离原则
使用多个专一的接口,而不使用统一的总接口,应该将这个统一的总接口划分成多个专一的接口,从而将多个行为职责划分出来,避免糅合在一起
- 合成/聚合复用原则
尽量使用合成/聚合的方式来复用代码,而不推荐使用继承来复用代码。这样可以避免由于类的继承带来的系统的繁杂的功能
- 迪米特法则
尽量与直接朋友进行通信,不与陌生人通信。朋友与朋友之间应该保持相应的距离。