类的设计原则有七个,包括:开闭原则、里氏代换原则、迪米特原则(最少知道原则)、单一职责原则、接口分隔原则、依赖倒置原则、组合/聚合复用原则。
七大原则之间并不是相互孤立的,彼此间存在着一定关联,一个可以是另一个原则的加强或是基础。违反其中的某一个,可能同时违反了其余的原则。
开闭原则是面向对象的可复用设计的基石。其他设计原则是实现开闭原则的手段和工具。
一般地,可以把这七个原则分成了以下两个部分:
设计目标:开闭原则、里氏代换原则、迪米特原则
设计方法:单一职责原则、接口分隔原则、依赖倒置原则、组合/聚合复用原则
软件实体(模块,类,方法等)应该对扩展开放,对修改关闭。
开闭原则是指在进行面向对象设计中,设计类或其他程序单位时,应该遵循:
开闭原则是判断面向对象设计是否正确的最基本的原理之一。
根据开闭原则,在设计一个软件系统模块(类,方法)的时候,应该可以在不修改原有的模块(修改关闭)的基础上,能扩展其功能(扩展开放)。
通过下边的例子理解什么是扩展开放和修改关闭:
左边的设计是直接依赖实际的类,不是对扩展开放的。
右边的设计是良好的设计:
为了满足开闭原则的对修改关闭原则以及扩展开放原则,应该对软件系统中的不变的部分加以抽象,在面向对象的设计中,
接口可以被复用,但接口的实现却不一定能被复用。
接口是稳定的,关闭的,但接口的实现是可变的,开放的。
可以通过对接口的不同实现以及类的继承行为等为系统增加新的或改变系统原来的功能,实现软件系统的柔性扩展。
好处:提高系统的可复用性和可维护性。
简单地说,软件系统是否有良好的接口(抽象)设计是判断软件系统是否满足开闭原则的一种重要的判断基准。现在多把开闭原则等同于面向接口的软件设计。
需求:创建一系列多边形。
首先,下面是不满足开闭原则的设计方法:
Shape.h
enumShapeType{ isCircle, isSquare};
typedef struct Shape {
enumShapeType type
} shape;
Circle.h
typedef struct Circle {
enumShapeType type;
double radius;
Point center;
} circle;
void drawCircle( circle* );
Square.h
typedef struct Square {
enumShapeType type;
double side;
Point topleft;
} square;
void drawSquare( square* );
drawShapes.cpp
#include "Shape.h"
#include "Circle.h"
#include "Square.h"
void drawShapes( shape* list[], intn ) {
int i;
for( int i=0; itype ) {
case isSquare:
drawSquare( (square*)s );
break;
case isCircle:
drawCircle( (circle*)s );
break;
}
}
}
该设计不是对扩展开放的,当增加一个新的图形时:
此外,该设计逻辑复杂,总的来说是一个僵化的、脆弱的、具有很高的牢固性的设计。
用开闭原则重构该设计如下图:
此时,在该设计中,新增一个图形只需要实现Shape接口,满足对扩展开放;也不需要修改drawShapes()方法,对修改关闭。
软件系统的构建是一个需要不断重构的过程,在这个过程中,模块的功能抽象,模块与模块间的关系,都不会从一开始就非常清晰明了,所以构建100%满足开闭原则的软件系统是相当困难的,这就是开闭原则的相对性。
但在设计过程中,通过对模块功能的抽象(接口定义),模块之间的关系的抽象(通过接口调用),抽象与实现的分离(面向接口的程序设计)等,可以尽量接近满足开闭原则。
所有引用基类的地方必须能透明地使用其派生类的对象。
也就是说,只有满足以下2个条件的OO设计才可被认为是满足了LSP原则:
不应该在代码中出现if/else之类对派生类类型进行判断的条件。
派生类应当可以替换基类并出现在基类能够出现的任何地方,或者说如果我们把代码中使用基类的地方用它的派生类所代替,代码还能正常工作。
以下代码就违反了LSP定义。
if (obj typeof Class1) {
do something
} else if (obj typeof Class2) {
do something else
}
里氏替换原则(LSP)是使代码符合开闭原则的一个重要保证。
同时LSP体现了:
类的继承原则:如果一个派生类的对象可能会在基类出现的地方出现运行错误,则该派生类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
动作正确性保证:从另一个侧面上保证了符合LSP设计原则的类的扩展不会给已有的系统引入新的错误。
示例:
里式替换原则为我们是否应该使用继承提供了判断的依据,不再是简单地根据两者之间是否有相同之处来说使用继承。
里式替换原则的引申意义:子类可以扩展父类的功能,但不能改变父类原有的功能。
具体来说:
Rectangle是矩形,Square是正方形,Square继承于Rectangle,这样一看似乎没有问题。
假如已有的系统中存在以下既有的业务逻辑代码:
void g(Rectangle r)
{
r.SetWidth(5);
r.SetHeight(4);
assert(r.GetWidth() * r.GetHeight()) == 20);
}
则对应于扩展类Square,在调用既有业务逻辑时:
Rectangle square = new Square();
g(square);
时会抛出一个异常。这显然违反了LSP原则。说明这样的继承关系在这种业务逻辑下不应该使用。
例2:鲸鱼和鱼,应该属于什么关系?从生物学的角度看,鲸鱼应该属于哺乳动物,而不是鱼类。没错,在程序世界中我们可以得出同样的结论。如果让鲸鱼类去继承鱼类,就完全违背了Liskov替换原则。因为鱼作为基类,很多特性是鲸鱼所不具备的,例如通过腮呼吸,以及卵生繁殖。那么,二者是否具有共性呢? 有,那就是它们都可以在水中"游泳",从程序设计的角度来说,它们都共同实现了一个支持"游泳"行为的接口。
例:3:运动员和自行车例子,每个运动员都有一辆自行车,如果按照下面设计,很显然违反了LSP原则。
class Bike {
public:
void Move( );
void Stop( );
void Repair( );
protected:
int ChangeColor(int );
private:
int mColor;
};
class Player : private Bike
{
public:
void StartRace( );
void EndRace( );
protected:
int CurStrength ( );
private:
int mMaxStrength;
int mAge;
};
如果两个具体的类A,B之间的关系违反了LSP 的设计,(假设是从B到A的继承关系),那么根据具体的情况可以在下面的两种重构方案中选择一种:
创建一个新的抽象类C,作为两个具体类的基类,将A,B的共同行为移动到C中来解决问题。
从B到A的继承关系改为关联关系。
对于矩形和正方形例子,可以构造一个抽象的四边形类,把矩形和正方形共同的行为放到这个四边形类里面,让矩形和正方形都是它的派生类,问题就OK了。对于矩形和正方形,取width 和height 是它们共同的行为,但是给width 和height 赋值,两者行为不同,因此,这个抽象的四边形的类只有取值方法,没有赋值方法。
对于运动员和自行车例子,可以采用关联关系来重构:
class Player
{
public:
void StartRace( );
void EndRace( );
protected:
int CurStrength ( );
private:
int mMaxStrength;
int mAge;
Bike * abike;
};
在进行设计的时候,我们尽量从抽象类继承,而不是从具体类继承。
如果从继承等级树来看,所有叶子节点应当是具体类,而所有的树枝节点应当是抽象类或者接口。当然这只是一个一般性的指导原则,使用的时候还要具体情况具体分析。
在很多情况下,在设计初期我们类之间的关系不是很明确,LSP则给了我们一个判断和设计类之间关系的基准:需不需要继承,以及怎样设计继承关系。
迪米特原则(Law of Demeter)又叫最少知道原则(Least Knowledge Principle),可以简单说成:talk only to your immediate friends,只与你直接的朋友们通信,不要跟“陌生人”说话。
对于面向OOD来说,又被解释为下面两种方式:
1)一个软件实体应当尽可能少地与其他实体发生相互作用。
2)每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
朋友圈的确定
“朋友”条件:
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。
迪米特原则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。
迪米特原则不希望类直接建立直接的接触。如果真的有需要建立联系,也希望能通过它的友元类来转达。因此,应用迪米特原则有可能造成的一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互调用关系,这在一定程度上增加了系统的复杂度。
例如,购房者要购买楼盘A、B、C中的楼,他不必直接到楼盘去买楼,而是可以通过一个售楼处去了解情况,这样就减少了购房者与楼盘之间的耦合,如图所示。
下面的代码在方法体内部依赖了其他类,这严重违反迪米特原则
class Teacher {
public:
void command(GroupLeader groupLeader) {
list listStudents = new list;
for (int i = 0; i < 20; i++) {
listStudents.add(new Student());
}
groupLeader.countStudents(listStudents);
}
}
方法是类的一个行为,类竟然不知道自己的行为与其他类产生了依赖关系(Teacher类中依赖了Student类,然而Student类并不在Teacher类的朋友圈中,一旦Student类被修改了,Teacher类是根本不知道的),这是不允许的。
正确的做法是:
class Teacher {
public:
void command(GroupLeader groupLeader) {
groupLeader.countStudents();
}
}
class GroupLeader {
private:
list listStudents;
public:
GroupLeader(list _listStudents) {
this.listStudents = _listStudents;
}
void countStudents() {
cout<<"女生数量是:" <
一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private等。
注意: 迪米特原则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、protected等访问权限。
如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。
永远不要让一个类存在多个改变的理由。
换句话说,如果一个类需要改变,改变它的理由永远只有一个。如果存在多个改变它的理由,就需要重新设计该类。
单一职责原则原则的核心含意是:只能让一个类/接口/方法有且仅有一个职责。
如果一个类具有一个以上的职责,那么就会有多个不同的原因引起该类变化,而这种变化将影响到该类不同职责的使用者(不同用户):
既然一个类不能有多个职责,那么怎么划分职责呢?
Robert.C Martin给出了一个著名的定义:所谓一个类的一个职责是指引起该类变化的一个原因。
如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。
SRP违反例:
class Modem {
void dial(String pno); //拨号
void hangup(); //挂断
void send(char c); //发送数据
char recv(); //接收数据
};
乍一看,这是一个没有任何问题的接口设计。
但事实上,这个接口包含了2个职责:第一个是连接管理(dial,hangup);另一个是数据通信(send,recv)。
很多情况下,这2个职责没有任何共通的部分,它们因为不同的理由而改变,被不同部分的程序调用。所以它违反了SRP原则。
下面的类图将它的2个不同职责分成2个不同的接口,这样至少可以让客户端应用程序使用具有单一职责的接口:
让 ModemImplementation实现这两个接口。我们注意到,ModemImplementation又组合了2个职责,这不是我们希望的,但有时这又是必须的。通常由于某些原因,迫使我们不得不绑定多个职责到一个类中,但我们至少可以通过接口的分割来分离应用程序关心的概念。
事实上,这个例子一个更好的设计应该是这样的,如图:
例如,考虑下图的设计。
Retangle类具有两个方法,如图。一个方法把矩形绘制在屏幕上,另一个方法计算矩形的面积。
有两个不同的Application使用Rectangle类,如上图。一个是计算几何面积的,Rectangle类会在几何形状计算方面给予它帮助。另一Application实质上是绘制一个在舞台上显示的矩形。
这一设计违反了单一职责原则。Rectangle类具有了两个职责,第一个职责是提供一个矩形形状几何数据模型;第二个职责是把矩形显示在屏幕上。
对于SRP的违反导致了一些严重的问题。首先,我们必须在计算几何应用程序中包含核心显示对象的模块。其次,如果绘制矩形Application发生改变,也可能导致计算矩形面积Application发生改变,导致不必要的重新编译,和不可预测的失败。
一个较好的设计是把这两个职责分离到下图所示的两个完全不同的类中。这个设计把Rectangle类中进行计算的部分移到GeometryRectangle类中。现在矩形绘制方式的改变不会对计算矩形面积的应用产生影响了。
单一职责原则从职责(改变理由)的侧面上为我们对类(接口)的抽象的颗粒度建立了判断基准:在为系统设计类(接口)的时候应该保证它们的单一职责性。
降低了类的复杂度、提高类的可读性,提高系统的可维护性、降低变更引起的风险
不能强迫用户去依赖那些他们不使用的接口。
换句话说,使用多个专门的接口比使用单一的总接口总要好。
它包含了2层意思:
接口的设计原则:接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。如果一个接口的方法没有被使用到,则说明该接口过胖,应该将其分割成几个功能专一的接口。
接口的依赖(继承)原则:如果一个接口a继承另一个接口b,则接口a相当于继承了接口b的方法,那么继承了接口b后的接口a也应该遵循上述原则:不应该包含用户不使用的方法。 反之,则说明接口a被b给污染了,应该重新设计它们的关系。
如果用户被迫依赖他们不使用的接口,当接口发生改变时,他们也不得不跟着改变。换而言之,一个用户依赖了未使用但被其他用户使用的接口,当其他用户修改该接口时,依赖该接口的所有用户都将受到影响。这显然违反了开闭原则,也不是我们所期望的。
总而言之,接口分隔原则指导我们:
一个类对一个类的依赖应该建立在最小的接口上
建立单一接口,不要建立庞大臃肿的接口
尽量细化接口,接口中的方法尽量少
下面我们举例说明怎么设计接口或类之间的关系,使其不违反ISP原则。
假如有一个Door,有lock,unlock功能,另外,可以在Door上安装一个Alarm而使其具有报警功能。用户可以选择一般的Door,也可以选择具有报警功能的Door。
有以下几种设计方法:
ISP原则的违反例一:在Door接口里定义所有的方法。
但这样一来,依赖Door接口的CommonDoor却不得不实现未使用的alarm()方法。违反了ISP原则。
ISP原则的违反例二:在Alarm接口定义alarm方法,在Door接口定义lock,unlock方法,Door接口继承Alarm接口。
跟方法一一样,依赖Door接口的CommonDoor却不得不实现未使用的alarm()方法。违反了ISP原则。
遵循ISP原则的例一:通过多重继承实现
在Alarm接口定义alarm方法,在Door接口定义lock,unlock方法。接口之间无继承关系。CommonDoor实现Door接口,AlarmDoor有2种实现方案:
1)同时实现Door和Alarm接口。
2)继承CommonDoor,并实现Alarm接口。
第2)种方案更具有实用性。
这样的设计遵循了ISP设计原则。
遵循ISP原则的例二:通过关联实现
在这种方法里,AlarmDoor实现了Alarm接口,同时把功能lock和unlock委让给CommonDoor对象完成。
这种设计遵循了ISP设计原则。
接口分隔原则从对接口的使用上为我们对接口抽象的颗粒度建立了判断基准:在为系统设计接口的时候,使用多个专门的接口代替单一的胖接口。
符合高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性。
注意适度原则,接口分隔要适度,避免产生大量的细小接口。
单一职责强调的是接口、类、方法的职责是单一的,强调职责,方法可以多,针对程序中实现的细节;
接口分隔原则主要是约束接口,针对抽象、整体框架。
A. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
B. 抽象不应该依赖于细节,细节应该依赖于抽象 C.针对接口编程,不要针对实现编程。
依赖:在程序设计中,如果一个模块a使用/调用了另一个模块b,我们称模块a依赖模块b。
高层模块与低层模块:往往在一个应用程序中,我们有一些低层次的类,这些类实现了一些基本的或初级的操作,我们称之为低层模块;另外有一些高层次的类,这些类封装了某些复杂的逻辑,并且依赖于低层次的类,这些类我们称之为高层模块。
依赖倒置(Dependency Inversion):
面向对象程序设计相对于面向过程(结构化)程序设计而言,依赖关系被倒置了。因为传统的结构化程序设计中,高层模块总是依赖于低层模块。
问题的提出:
Robert C. Martin氏在原文中给出了“Bad Design”的定义:
系统很难改变,因为每个改变都会影响其他很多部分。
当你对某地方做一修改,系统的看似无关的其他部分都不工作了。
系统很难被另外一个应用重用,因为很难将要重用的部分从系统中分离开来。
导致“Bad Design”的很大原因是“高层模块”过分依赖“低层模块”。
一个良好的设计应该是系统的每一部分都是可替换的。如果“高层模块”过分依赖“低层模块”,一方面一旦“低层模块”需要替换或者修改,“高层模块”将受到影响;另一方面,高层模块很难可以重用。
问题的解决:
为了解决上述问题,Robert C. Martin氏提出了OO设计的Dependency Inversion Principle (DIP) 原则。
DIP给出了一个解决方案:在高层模块与低层模块之间,引入一个抽象接口层。
High Level Classes(高层模块) --> Abstraction Layer(抽象接口层) --> Low Level Classes(低层模块)
抽象接口是对低层模块的抽象,低层模块继承或实现该抽象接口。
这样,高层模块不直接依赖低层模块,而是依赖抽象接口层。抽象接口也不依赖低层模块的实现细节,而是低层模块依赖(继承或实现)抽象接口。
类与类之间都通过抽象接口层来建立关系。
示例:考虑一个控制熔炉调节器的软件。该软件从一个IO通道中读取当前的温度,并通过向另一个IO通道发送命令来指示熔炉的开或者关。
温度调节器的简单算法:
const byte THERMONETER=0x86;
const byte FURNACE=0x87;
const byte ENGAGE=1;
const byte DISENGAGE=0;
void Regulate(double minTemp,double maxTemp)
{
for(;;)
{
while (in(THERMONETER) > minTemp)
wait(1);
out(FURNACE,ENGAGE);
while (in(THERMONETER) < maxTemp)
wait(1);
out(FURNACE,DISENGAGE);
}
}
算法的高层意图是清楚的,但是实现代码中却夹杂着许多低层细节。这段代码根本不能重用于不同的控制硬件。
由于代码很少,所以这样做不会造成太大的损害。但是,即使是这样,使算法失去重用性也是可惜的。我们更愿意倒置这种依赖关系。
图中显示了 Regulate 函数接受了两个接口参数。Thermometer 接口可以读取,而 Heater 接口可以启动和停止。Regulate 算法需要的就是这些。这就倒置了依赖关系,使得高层的调节策略不再依赖于任何温度计或者熔炉的特定细节。该算法具有很好的可重用性。
通用的调节器算法:
void Regulate(Thermometer t, Heater h, double minTemp,
double maxTemp)
{
for(;;)
{
while (t.Read() > minTemp)
wait(1);
h.Engate();
while (t.Read() < maxTemp)
wait(1);
h.Disengage();
}
}
1. 依赖于抽象
如:
class class1{
class2* cls2 = new class2();
}
class class2{
.......
}
2. 设计接口而非设计实现
例外:
有些类不可能变化,在可以直接使用具体类的情况下,不需要插入抽象层,如:字符串类
3. 避免传递依赖
可以减少类间的耦合性、提高系统稳定性,提高代码可读性和可维护性,可降低修改程序所造成的风险。
尽量使用组合/聚合,不要使用类继承。
即在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。
组合和聚合都是关联的特殊种类。
聚合表示整体和部分的关系,表示“拥有”。组合则是一种更强的“拥有”,部分和整体的生命周期一样。
组合的新的对象完全支配其组成部分,包括它们的创建和湮灭等。一个组合关系的成分对象是不能与另一个组合关系共享的。
组合是值的聚合(Aggregation by Value),而一般说的聚合是引用的聚合(Aggregation by Reference)。
在面向对象设计中,有两种基本的办法可以实现复用:第一种是通过组合/聚合,第二种就是通过继承。
只有当以下的条件全部被满足时,才应当使用继承关系:
1)派生类是基类的一个特殊种类,而不是基类的一个角色,也就是区分"Has-A"和"Is-A"。只有"Is-A"关系才符合继承关系,"Has-A"关系应当用聚合来描述。
2)永远不会出现需要将派生类换成另外一个类的派生类的情况。如果不能肯定将来是否会变成另外一个派生类的话,就不要使用继承。
3)派生类具有扩展基类的责任,而不是具有置换掉(override)或注销掉(Nullify)基类的责任。如果一个派生类需要大量的置换掉基类的行为,那么这个类就不应该是这个基类的派生类。
4)只有在分类学角度上有意义时,才可以使用继承。
总的来说:
如果语义上存在着明确的"Is-A"关系,并且这种关系是稳定的、不变的,则考虑使用继承;如果没有"Is-A"关系,或者这种关系是可变的,使用组合。另外一个就是只有两个类满足里氏替换原则的时候,才可能是"Is-A" 关系。也就是说,如果两个类是"Has-A"关系,但是设计成了继承,那么肯定违反里氏替换原则。
错误的使用继承而不是组合/聚合的一个常见原因是错误的把"Has-A"当成了"Is-A" 。"Is-A"代表一个类是另外一个类的一种;"Has-A"代表一个类是另外一个类的一个角色,而不是另外一个类的特殊种类。
看一个例子:
如果我们把“人”当成一个类,然后把“雇员”,“经理”,“学生”当成是“人”的派生类。这个的错误在于把 “角色” 的等级结构和 “人” 的等级结构混淆了。“经理”,“雇员”,“学生”是一个人的角色,一个人可以同时拥有上述角色。如果按继承来设计,那么如果一个人是雇员的话,就不可能是学生,这显然不合理。
正确的设计是有个抽象类 “角色”,“人”可以拥有多个“角色”(聚合),“雇员”,“经理”,“学生”是“角色”的派生类。
优点:
缺点: 就是系统中会有较多的对象需要管理。
优点:
缺点:
继承复用破坏封装性,因为继承将基类的实现细节暴露给派生类。由于基类的内部细节常常是对于派生类透明的,所以这种复用是透明的复用,又称“白箱”复用。
如果基类发生改变,那么派生类的实现也不得不发生改变。
从基类继承而来的实现是静态的,不可能在运行时间内发生改变,没有足够的灵活性。