面向对象设计原则总的来说就是:人们在不断进行系统设计、代码设计时总结出的一些具有实践意义的经验准则,能够帮助程序设计人员更好地设计、重构与优化代码。本文主要对七个设计原则进行详细描述。
单一职责原则指的是将某一个模块、一个类或者一个方法中的功能点(职责)设计的越少越好,以减少其被复用的次数以及可能性,通过提高模块的内聚性来减少模块间的耦合程度,保证软件实体只有一个引起它变化的原因。
例如现在有个接口类IPhone,它包含了四个方法。可以发现该接口类的职责可以被拆分为两个模块:通讯协议管理模块与通讯数据传输模块。
public interface IPhone {
void dial(String phoneNumber); //根据协议建立连接
void chat(Object o); //通话
void answer(Object o); //回应
void hangUp(); //通话完毕
}
根据单一职责原则,可以将IPhone接口类拆分为以下两个接口类。
public interface IConnectionManagaer {
void dial(String phoneNumber); //根据协议建立连接
void hangUp(); //通话完毕
}
public interface IDataTransfer {
void chat(Object o, IConnectionManager cm);
void answer(Object o, IConnectionManager cm);
}
开闭原则中的“开闭”指的是对扩展开放而对修改关闭,具体意思是当需要扩展一个实体的功能时,能够在不修改原先模块代码的前提下进行功能的扩展。
该原则的关键是抽象化。意味着我们需要找到模块中的可变因素,通过抽象化对这些可变因素进行封装,而其他模块只需要引用抽象层即可。
在面向对象设计中,我们可以基于抽象化将相似的类进行统一封装。例如在Java中,可以通过abstract与interface关键字将具有相似功能逻辑的类进行抽象,让这些类extends或者implement这些抽象类即可。
当我们需要扩展功能时,只需要增加这些抽象类的子类即可,原先的代码均不需要发生改变。
例如现在有一个图表展示类(ChartDisplay),这个类可以根据display方法中传入的参数来创建不同的具体图表类(PieChart、BarChart)。此时若我想要增加一个新的图表类(LinearChart),我就需要在ChartDisplay类的display方法中增加新的判断逻辑。这明显不符合开闭原则定义(需要修改原先代码)。
我现在在上述类图中对这些具体图表Chart类进行抽象化,增加一个AbstractChart类,让这些具体实现类均实现该抽象类。此时当我在新增一个LinearChart类时,原先的代码不需要做任何修改。我只需要每次在ChartDisplay类中的setChart方法中传入不同的具体图表类(如PieChart)即可,在它的display方法中通过chart.display()实现具体的代码逻辑。
当然,客户端调用ChartDisplay类的业务逻辑代码肯定要改(每次构建一个新的图表便需要传入不同的图表子类),这里的对修改关闭主要指的是业务流程代码不需要进行更改。
依赖倒转指的是,高层模块不应该直接依赖于底层模块,应该依赖于底层模块的抽象层。在Java中,模块之间的依赖关系应体现对接口类或者抽象类的依赖,而不应依赖于它们的子类(实现类)。
依赖倒转原则关键是要针对接口编程,而不是针对实现编程。
可以发现,开闭原则与依赖倒转原则都强调了抽象化。可以说依赖倒转原则是开闭原则的一种重要的实现方式。
如果说开闭原则是面向对象设计的目标,那么依赖倒转原则就是面向对象设计的主要手段。
以先前的图表为例,高层模块(ChartDisplay)不应该直接与底层模块(PieChart、BarChart)进行关联,这样会造成二者紧耦合,不利于代码扩展。
所以我们可以对这些具体的图表实现类进行抽象(AbstractChart),高层模块只需要依赖于抽象类即可,不需要直接与底层模块进行关联。
里氏替换其实是让子类可以直接替换或者向上转型成基类,而使得原先的代码业务逻辑并没有发生冲突,程序依然能够正常运行。可以说里氏替换原则是为了更好地实现依赖倒转原则与开闭原则,指导我们如何对代码进行抽象化。
通俗的说就是子类可以扩展父类的功能,但不能改变父类原有的功能。
对于后面两层的含义的理解,我找到了一个比较好的例子予以说明。例如父类有一个方法正整数 operate(正整数)
,那么子类的override可以是正偶数 operate(整数)
,首先输入参数变为整数,代表子类扩展了父类的功能,从只接收正整数变为了可以接收负整数;而返回参数从正整数变为了正偶数,并没有影响父类原有的功能,因为正偶数是正整数的一部分。如果子类的override是整数 operate(正偶数)
,则说明子类缩小了父类可以处理的参数范围,改变了父类原有的功能,不符合里氏替换原则;而返回参数由正整数变为整数,则最终客户端的调用结果有可能为负整数,从而出现逻辑错误,也不符合里氏替换原则。
从下述类图中可以发现,该类图符合开闭原则与依赖倒转原则,也符合单一职责原则。
那么它是否符合里氏替换原则呢?里氏替换原则定义中的基类在这里指的是AbstractGun
,而子类就是HandGun(手枪)、Rifle(步枪)、MachineGun(机枪)、ToyGun(玩具枪)
。结合代码语义与现实场景中可以知道,ToyGun
并不能使Soldier
士兵类执行killEnemy
方法。而前三者(手枪、步枪、机枪)可以使Soldier
执行killEnemy
方法。
所以当客户端(Client)调用Soldier类时,如果传入的参数是ToyGun,则代码的逻辑便出现了问题,从而说明了该代码设计中子类并不能直接替换为基类。所以该类图并不符合
里氏替换原则。
所以为了满足里氏替换原则,我们可以将ToyGun单独进行抽象,增加一个AbstractToy
抽象类。这样从代码语义与业务逻辑的角度来看,各个子类在替换各自的基类时,并不会产生逻辑上的冲突。
客户端不应该依赖于它不需要或者不使用的接口方法。当一个接口中定义的方法较多,我们可以将其拆分为粒度更小的接口,而客户端只需要依赖于那些它真正需要使用的接口。
接口隔离原则可以看成是单一职责原则在抽象化角度上的扩展。
在下述类图中,假设ClientA只使用operatorA()方法,ClientB只使用operatorB()方法,ClientC只使用operatorC()方法,那么该类图便不符合接口隔离原则,因为该类图中定义了一个AbstractService(胖接口)来服务所有的客户端类。我们便可以根据接口隔离原则对下述类图进行优化重构。
以下是重构之后的类图,将原先的AbstractService
拆分成三个粒度更小的接口,分别服务于三个Client,以此来满足接口隔离原则。这种重构方法在服务行业又被称为定制服务。
合成复用原则指的是需要将类之间的继承关系
转变为组合或聚合关系
来达到复用的目的。
继承复用(“白箱”
复用):实现简单(超类的功能自动进入子类),易于扩展(实现多个子类,重写容易)。当需要调用某个类的方法时,通过继承该类的方式来直接调用该类的方法。但是不够灵活,调用的父类的方法在程序运行过程中是静态的,子类不能动态指定具体的实现。还破坏了系统的模块封装性,因为子类可以通过重写机制修改父类的方法逻辑,并且可能会违反里氏替换原则,从而导致代码维护上的困难,造成父类和子类的紧耦合。另外,若父类的实现方法发生改变,则导致其实现子类均需要发生变化(例如改变方法传入的参数)。
组合/聚合复用(“黑箱”
复用):耦合度相对较低,灵活性较高,可以在程序运行时基于抽象化动态指定成员对象的具体实现类。不过若所调用的成员对象的方法也发生改变,那么所有调用过该方法的类都需要发生变化,这点与继承复用相类似。
例如现在StudentDAO与TeacherDAO都需要调用DBUtil中的getConnection()方法,而在下述类图中,是通过继承复用的形式进行调用。那么我们可以将其重构为组合复用形式。
重构之后的类图如下,StudentDAO与TeacherDAO均有DBUtil成员变量,通过组合关系调用getConnection()方法,体现了合成复用原则。同时DBUtil还实现了抽象化,两个DAO类便可以动态指定新的数据库连接而不需要修改DAO类的代码,符合开闭原则与依赖倒转原则。
只有满足以下三点时,才可以使用继承复用。
“Is”
的关系,而不是“Has”的关系。“Is”的关系符合继承关系语义,“Has”的关系应当用组合/聚合来描述;一个软件实体应该尽可能少的与其他实体相互作用,只与“朋友”交流,而不与“陌生人”交流,以此减少实体间的耦合度。
狭义的迪米特法则
强调可以通过创建中介者类(第三者类)来协助两个不必直接通信的类进行信息传输,但会导致通讯效率降低。
广义的迪米特法则
强调一个类或者一个模块需要控制对外的信息隐藏,从而独立各个模块之间的开发、优化、使用和修改,促进模块复用,让各个模块脱耦。
现在有如下类图与Friend
、Someone
类的Java实现代码。
public class Someone {
void operate(Friend friend) {
friend.getStranger().operate();
}
}
public class Friend {
private Stranger stranger = new Stranger();
public Stranger getStranger() {
return this.stranger;
}
}
从上述类图与代码中可以看出,Friend
类与Stranger
类是“朋友”关系,Friend
类与Someone
类也为“朋友”关系,但Someone
类与Stranger
类为“陌生人”关系,由迪米特法则可知,Someone
类不能直接调用Stranger
类的operate
方法。一种解决方法就是调用转发,将Someone
类对Stranger
的方法调用转变为Friend
对Stranger
的方法调用。
以下是重构后的类图与Java代码实现。
public class Someone {
void operate(Friend friend) {
friend.operateByStranger();
}
}
public class Friend {
private Stranger stranger = new Stranger();
public void operateByStranger() {
this.stranger.operate();
}
}
在下述类图中,多个Form类对多个DAO类的关联关系较为复杂,针对该问题可以创建一个中介控制层,以该控制层代替Form来引用对应的DAO,将调用的过程进行封装。
以下是重构后的类图。
Alexander给出的关于模式的定义如下:
A pattern is a
solution
to aproblem
in acontext
.
一个模式的提出首先要有Context
,即前提条件约束与应用场景。在特定的应用场景下还要有对应的Problem
,需要明确我们需要解决什么样的问题,有明确的目标。针对该问题所提出的具有实践意义的Solution
才能称为模式。
1990年,软件工程界开始关注Christopher Alexander等在这一住宅、公共建筑与城市规划领域的重大突破,最早将该模式的思想引入软件工程方法学的是1991-1992年以“四人组(Gang of Four,
GoF
,分别是Erich Gamma, Richard Helm, Ralph Johnson和John Vlissides)”自称的四位著名软件工程学者,他们在1994年归纳发表了23种在软件开发中使用频率较高的设计模式,旨在用模式来统一沟通面向对象方法在分析、设计和实现间的鸿沟
。
软件模式
可以认为是在一定条件下对软件开发这一特定“问题”的“解法”的某种统一表示。
设计模式(Design Pattern)
是一套被反复使用、多数人知晓的、经过分类编目的代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。
根据Problem
将二十三种设计模式分为创建型、结构型行为型三种设计模式。
类模式
主要指的是在构建代码时,类与类之间的关系是通过继承形式完成的;而对象模式
指的是类与类之间的关系是通过成员对象之间的引用、调用来完成。
在浅克隆中,改变源对象的引用类型变量(除String),则克隆对象对应的引用类型变量也随之改变;而在深克隆中,改变源对象的引用类型变量,克隆对象对应的引用类型变量不会发生改变。
远程(Remote)代理
:为一个位于不同的地址空间的对象提供一个本地的代理对象,这个不同的地址空间可以是在同一台主机中,也可是在另一台主机中,远程代理又叫做大使(Ambassador)。虚拟(Virtual)代理
:如果需要创建一个资源消耗较大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时才会被真正创建。Copy-on-Write代理
:它是虚拟代理的一种,把复制(克隆)操作延迟到只有在客户端真正需要时才执行。一般来说,对象的深克隆是一个开销较大的操作,Copy-on-Write代理可以让这个操作延迟,只有对象被用到的时候才被克隆。保护(Protect or Access)代理
:控制对一个对象的访问,可以给不同的用户提供不同级别的使用权限。缓冲(Cache)代理
:为某一个目标操作的结果提供临时的存储空间,以便多个客户端可以共享这些结果。防火墙(Firewall)代理
:保护目标不让恶意用户接近。同步化(Synchronization)代理
:使几个用户能够同时使用一个对象而没有冲突。智能引用(Smart Reference)代理
:当一个对象被引用时,提供一些额外的操作,如将此对象被调用的次数记录下来等。当Cat
执行cry
方法时,Mouse
与Dog
均要对Cat
发出的通知作出响应。
实现表示层和数据逻辑层的分离
,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种各样不同的表示层作为具体观察者角色;建立一个抽象的耦合
;将所有的观察者都通知到会花费很多时间
。循环依赖
的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃
。怎么发生变化
的,而仅仅只是知道观察目标发生了变化
。一个方面依赖于另一个方面
。将这些方面封装在独立的对象中使它们可以各自独立地改变和复用。不知道这些对象是谁
。硬编码
方式使得代码耦合度高,硬编码实现类的代码较为复杂,维护困难。必须知道所有的策略类
,并自行决定使用哪一个策略类。将造成产生很多策略类
,可以通过使用享元模式在一定程度上减少对象的数量。区别仅在于它们的行为
,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。迪米特法则
将请求的发送者和请求的处理者解耦
。Context
的setState()
方法进行状态的转换操作,不符合开闭原则。public class Originator {
private String state;
public Originator(){}
// 创建一个备忘录对象
public Memento createMemento(){
return new Memento(this);
}
// 根据备忘录对象恢复原发器状态
public void restoreMemento(Memento m){
state = m.state;
}
public void setState(String state)
{
this.state=state;
}
public String getState()
{
return this.state;
}
}
class Memento {
private String state;
public Memento(Originator o){
state = o.state;
}
public void setState(String state)
{
this.state=state;
}
public String getState()
{
return this.state;
}
}
public class Caretaker
{
private Memento memento;
public Memento getMemento()
{
return memento;
}
public void setMemento(Memento memento)
{
this.memento=memento;
}
}