01 面向对象设计原则
知识结构:
一碟开胃的小菜
小菜今年计算机专业大四了,学了不少软件开发方面的东西,也学着编了些小程序,踌躇满志,一心要找一个好单位。当投递了无数简历后,终于收到了一个单位的面试通知,小菜欣喜若狂。
到了人家单位,前台服务人员给了他一份题目,上面写着:“请用 C++、Java 或 C# 任意一种 面向对象语言 实现一个计算器控制台程序,要求输入两个数和运算符号,得到结果。”
小菜一看,这个还不简单,三下五除二,10分钟不到,小菜就写完了,感觉也没有错误。交卷后,单位说一周内等通知吧。于是小菜只得耐心等待。可是半个月过去了,什么消息也没有,小菜很纳闷,我的代码实现了呀,为什么不给我机会呢?
小菜第一个版本的程序:
static void Main(string[] args)
{
Console.WriteLine("请输入数字a:");
string a = Console.ReadLine();
Console.WriteLine("请选择输入运算符(+、-、*、/):");
string b = Console.ReadLine();
Console.WriteLine("请输入数字b:");
string c = Console.ReadLine();
string d = string.Empty;
if (b == "+")
d = Convert.ToString(Convert.ToDouble(a)
+ Convert.ToDouble(c));
if (b == "-")
d = Convert.ToString(Convert.ToDouble(a)
- Convert.ToDouble(c));
if (b == "*")
d = Convert.ToString(Convert.ToDouble(a)
* Convert.ToDouble(c));
if (b == "/")
d = Convert.ToString(Convert.ToDouble(a)
/ Convert.ToDouble(c));
Console.WriteLine("结果是:" + d);
}
小菜找到从事软件开发工作七年的表哥大鸟,请教原因,大鸟问了题目和了解了小菜代码的细节以后,哈哈大笑,说道:“小菜呀小菜,你上当了,人家单位出题的意思,你完全没有明白,当然不会再联系你了。”
小菜说:“我的代码有错吗?单位题目不就是要我实现一个计算器代码吗?我这样写有什么问题?”
大鸟说:“且先不说出题人的意思,单就你现在的代码,就有很多不足的地方需要改进。”
【1】 这样命名是非常不规范的。
【2】 判断分支,你这样的写法,意味着每个条件都要做判断,等于计算机做了三次无用功。
【3】 如果除数时,客户输入了0怎么办?如果输入的是字符符号而不是数字怎么办?
以上三点,是初学者常犯的毛病,更加惨痛的教训参见图文:一行代码蒸发了¥6,447,277,680 人民币!
小菜第一个版本程序的改进:
static void Main(string[] args)
{
try
{
Console.WriteLine("请输入数字a:");
string strNumberA = Console.ReadLine();
Console.WriteLine("请选择输入运算符(+、-、*、/):");
string strOperator = Console.ReadLine();
Console.WriteLine("请输入数字b:");
string strNumberB = Console.ReadLine();
string strResult = string.Empty;
switch (strOperator)
{
case "+":
strResult = Convert.ToString(
Convert.ToDouble(strNumberA)
+ Convert.ToDouble(strNumberB));
break;
case "-":
strResult = Convert.ToString(
Convert.ToDouble(strNumberA)
- Convert.ToDouble(strNumberB));
break;
case "*":
strResult = Convert.ToString(
Convert.ToDouble(strNumberA)
* Convert.ToDouble(strNumberB));
break;
case "/":
if (strNumberB != "0")
strResult = Convert.ToString(
Convert.ToDouble(strNumberA)
/ Convert.ToDouble(strNumberB));
else
strResult = "除数不能为零。";
break;
default:
throw new Exception("输入的运算符不合法!");
}
Console.WriteLine("结果是:" + strResult);
}
catch (Exception ex)
{
Console.WriteLine("您的输入有错:" + ex.Message);
}
}
大鸟:“吼吼,不错,不错,改得很快嘛!至少就目前代码来说,实现计算器是没有问题了,但这样写的代码是否符合出题人的意思呢?”
小菜:“我明白了,他说用任意一种面向对象语言实现,那意思是要用面向对象的编程方法去实现,对吗?OK,这个我学过,只不过当时我没想到而已。”
大鸟:“所有的初学者都会有这样的问题,就是碰到问题就直觉地用计算机能够理解的逻辑来描述和表达待解决的问题及具体的求解过程。这其实是用计算机的方式去思考,比如计算器这个程序:
- Step1:要求输入两个数和运算符号;
- Step2:根据运算符号判断选择如何运算;
- Step3:得到结果并输出;
这本身没有错,但这样的思维却使得我们的程序只为满足实现当前的需求,程序 不易维护,不易扩展,不易复用,灵活性差。从而达不到高质量代码的要求。”
1. 复制 VS. 复用
大鸟:“比如说,我现在要求你再写一个 Windows 的计算器,你现在的 代码能不能复用呢?”
小菜:“那还不简单,把代码复制过去不就行了吗?改动又不大,不算麻烦。”
大鸟:“小菜看来还是小菜呀,有人说初级程序员的工作就是 Ctrl+C 和 Ctrl+V,这其实是非常不好的编码习惯,因为当你的代码中重复的代码多到一定程度,维护的时候,可能就是一场灾难。越大的系统,这种方式带来的问题越严重,编程有一原则,就是尽可能的想办法去避免重复。想想看,你写的这段代码,有哪些是和控制台无关的,而只是和计算器有关的?”
小菜:“你的意思是分一个类出来?哦,对的,让计算和显示分开。”
大鸟:“准确的说,就是让 业务逻辑 与 界面逻辑 分开,让它们之间的耦合度下降。只有分离开,才可以达到 易维护 和 可扩展。”
小菜第二个版本的程序:
public class Operation
{
public static double GetResult(double numberA,
double numberB, string operate)
{
double result;
switch (operate)
{
case "+":
result = numberA + numberB;
break;
case "-":
result = numberA - numberB;
break;
case "*":
result = numberA*numberB;
break;
case "/":
if (Math.Abs(numberB - 0) < double.Epsilon)
throw new Exception("除数为零.");
result = numberA/numberB;
break;
default:
throw new Exception("输入的运算符不合法!");
}
return result;
}
}
static void Main(string[] args)
{
try
{
Console.WriteLine("请输入数字a:");
string strNumberA = Console.ReadLine();
Console.WriteLine("请选择输入运算符(+、-、*、/):");
string strOperator = Console.ReadLine();
Console.WriteLine("请输入数字b:");
string strNumberB = Console.ReadLine();
string strResult = Convert.ToString(
Operation.GetResult(Convert.ToDouble(strNumberA),
Convert.ToDouble(strNumberB), strOperator));
Console.WriteLine("结果是:" + strResult);
}
catch (Exception ex)
{
Console.WriteLine("您的输入有错:" + ex.Message);
}
}
小菜:“鸟哥,我写好了,你看看!如果你要我写一个 Windows 应用程序的计算器,我就可以复用这个Operation
类了。”
大鸟:“写的不错,这样就完全把业务逻辑和界面逻辑分离了。不单是 Windows 程序,Web 程序,哪怕 PDA、手机等移动系统的软件需要运算也可以用它。”
小菜:“哈,面向对象不过如此。下回写类似代码不怕啦。”
大鸟:“别急,仅此而已,实在谈不上完全面向对象,你只用了面向对象三大特征中的一个,还有两个没用呢!”
小菜:“面向对象三大特征不就是 封装、继承 和 多态 吗,这里我用到的应该是封装。不够吗?我实在看不出来,这么小的程序如何用到继承。至于多态,其实我一直也不太了解它到底有什么好处,如何使用它。”
大鸟:“慢慢来,要学的东西多着呢,你好好想想该如何应用面向对象的 继承 和 多态。”
2. 紧耦合 VS. 松耦合
大鸟:“你先考虑一下,你写的这个代码,能做到很灵活的修改和扩展吗?如果我希望增加一个“求M数的N次方(pow
)运算”,你如何修改?”
小菜:“修改Operation
类,在switch
中加一个分支就行了。”
大鸟:“问题是你要加一个 M 数的 N 次方运算,却需要让加、减、乘、除的运算都来参与编译,如果你一不小心,把加法改成减法,这岂不是大大的糟糕。打个比方,如果现在公司要求你为公司的薪资管理系统做维护,原来只有三种薪酬算法:
- 技术人员(月薪);
- 市场人员(底薪+提成);
- 经理(年薪+股份)
现在要增加兼职工作人员(时薪)的算法,但按照你的写法,公司就必须要把包含原三种算法的运算类给你,让你修改。你如果心中小算盘一打,‘公司给我的工资这么低,这下有机会了’,于是你除了增加了兼职算法以外,在技术人员(月薪)算法中写了一句:
if (员工是小菜)
{
salary = salary * 1.1;
}
那就意味着,你的月薪每月都会增加10%,本来是让你加一个功能,却使得原有的运行良好的功能代码产生了变化,这个风险太大了。你明白了吗?”
小菜:“哦,你的意思是,我应该把加、减、乘、除分离,修改其中一个不影响另外的几个,增加运算算法也不影响其它代码,是这样吗?”
大鸟:“自己想去吧,如何用继承和多态,你应该有感觉了。”
小菜第三个版本的程序:
public abstract class Operation
{
//注意class的修饰符
public double NumberA { get; set; }
public double NumberB { get; set; }
public abstract double GetResult();
protected Operation()
{
NumberA = 0.0;
NumberB = 0.0;
}
protected Operation(double nA,double nB)
{
NumberA = nA;
NumberB = nB;
}
}
internal class OperationAdd : Operation
{
public override double GetResult()
{
return NumberA + NumberB;
}
}
internal class OperationSub : Operation
{
public override double GetResult()
{
return NumberA - NumberB;
}
}
internal class OperationMul : Operation
{
public override double GetResult()
{
return NumberA * NumberB;
}
}
internal class OperationDiv : Operation
{
public override double GetResult()
{
if (Math.Abs(NumberB - 0) < double.Epsilon)
throw new Exception("除数为零.");
return NumberA/NumberB;
}
}
小菜:“鸟哥,我按照你说的方法写出来了一部分:
- 首先,是一个运算类,它有两个
Number
属性,主要用于计算器的前后数; - 然后,有一个抽象方法
GetResult()
,用于得到结果; - 最后,我把加、减、乘、除都写成了运算类的子类,继承它后,复写了
GetResult()
方法;
这样如果要修改任何一个算法,就不需要提供其它算法的代码了。但问题来了,我如何让计算器知道我是希望用哪一个算法呢?”
大鸟:“你现在的问题其实就是如何去实例化对象的问题,也就是说,到底要实例化谁,将来会不会增加实例化的对象,比如“求 M 数的 N 次方运算”,这是很容易变化的地方,应考虑用一个单独的类来做这个创造实例的过程,你只需要输入运算符号,这个类就实例化出合适的对象,通过多态,返回父类的方式实现了计算器的结果。”
public class OperationFactory
{
public static Operation CreateOperator(string operate)
{
Operation oper;
switch (operate)
{
case "+":
oper = new OperationAdd();
break;
case "-":
oper = new OperationSub();
break;
case "*":
oper = new OperationMul();
break;
case "/":
oper = new OperationDiv();
break;
default:
throw new Exception("输入的运算符不合法!");
}
return oper;
}
}
static void Main(string[] args)
{
try
{
Console.WriteLine("请输入数字a:");
string strNumberA = Console.ReadLine();
Console.WriteLine("请选择输入运算符(+、-、*、/):");
string strOperator = Console.ReadLine();
Console.WriteLine("请输入数字b:");
string strNumberB = Console.ReadLine();
Operation opr = OperationFactory.CreateOperator(
strOperator);
opr.NumberA = Convert.ToDouble(strNumberA);
opr.NumberB = Convert.ToDouble(strNumberB);
string strResult = Convert.ToString(opr.GetResult());
Console.WriteLine("结果是:" + strResult);
}
catch (Exception ex)
{
Console.WriteLine("您的输入有错:" + ex.Message);
}
}
小菜:“回想那天我面试题写的代码,我终于明白我为什么写得不成功了,原来一个小小的计算器也可以写出这么精彩的代码,谢谢鸟哥。”
大鸟:“记住哦,编程是一门技术,更是一门艺术,不能只满足于写完代码结果正确就完事,时常考虑如何让代码 可维护,可复用,可扩展,灵活性好,只有这样才可以真正得到提高。写出优雅的代码真的是一件很爽的事情。”
软件设计的目标
如何同时提高一个软件系统的 可复用性、可扩展性、易维护性 和 灵活性 是面向对象设计需要解决的核心问题,是我们设计软件的目标。
然而,什么是 可复用、可扩展、易维护 和 灵活性好 的设计呢?
我们通过一个故事来进行说明:
话说三国时期,曹操带领百万大军攻打东吴,大军在长江赤壁驻扎,军船连成一片,眼看就要灭掉东吴,统一天下,曹操大悦,于是大宴众文武,在酒席间,曹操诗兴大发,不觉吟道:
“喝酒唱歌,人生真爽。……”
众文武齐呼:“丞相好诗!”于是一臣子速命印刷工匠刻版印刷,以便流传天下。
图1 印刷第一版
样张出来给曹操一看,曹操感觉不妥,说道:
“喝与唱,此话过俗,应改为 ‘对酒当歌’ 较好!”
于是此臣就命工匠重新来过。
工匠眼看连夜刻版之工,彻底白费,心中叫苦不迭。只得照办。
图2 印刷第二版
样张再次出来请曹操过目,曹操细细一品,觉得还是不好,说:
“人生真爽太过直接,应改问句才够意境,因此应改为 ‘对酒当歌,人生几何?’ ……”
当臣转告工匠之时,工匠晕倒……!
图3 印刷第三版
由于三国时期活字印刷术尚未发明,所以要改字的时候,就必须整个刻版全部重新来刻。
如果当时有了活字印刷术,则只需要更改四个字即可,其余工作都未白做。
图4 活字印刷刻板
通过活字印刷刻板,可以带给我们如下启发:
- 要改,只需要改几个字,此为 可维护;
- 这些字并非用完这次就无用,完全可以在后来的印刷中重复使用,此为 可复用;
- 此诗若要加字,只需另刻字加入即可,此为 可扩展;
- 字的排列其实可能是竖排也可能是横排,此时只需将活字移动就可以做到满足排列需求,此为 灵活性好。
而在活字印刷术出现之前,上面的四种特征都无法满足:
- 要修改,必须重刻;
- 要加字,必须重刻;
- 要重排,必须重刻;
印完这本书后,此版已无任何复用价值。
传统印刷术的问题:所有字都刻在同一版面上导致耦合度太高
在软件开中有太多类似曹操这样的客户要改变需求,更改最初的想法。但客观地说,客户的要求并不过分,不就是改几个字吗,但面对已完成的程序代码,却是需要几乎重头来过的尴尬,痛苦不堪。
说白了,原因就是我们原先所写的程序,不容易维护、灵活性差、不容易扩展、更谈不上复用,因此面对需求变化,加班加点,对程序动大手术的哪种无奈也就成了非常正常的事了。
所以在进行软件设计的时候就要考虑通过 封装、继承、多态 把程序的耦合度降低,要考虑使用一些 设计模式 使程序更加灵活,容易修改,并且易于复用。
面向对象设计原则 就为以上目标而诞生,每一个原则都蕴含一些面向对象设计的思想,可以从不同的角度提升一个软件结构的设计水平。这些原则是从许多设计方案中总结出的指导性标准。也是我们用于评价一个设计模式的使用效果的重要指标。
最常用的七种面向对象设计原则如下表所示:
设计原则 | 核心思想 |
---|---|
单一职责原则 | 一个类应该只有一个引起它变化的原因。 |
开闭原则 | 软件实体应该对扩展开放,对修改封闭。 |
里氏代换原则 | 所有引用基类对象的地方都能透明地使用其子类的对象。 |
依赖倒转原则 | 抽象不应该依赖于具体,具体应该依赖于抽象。 |
接口隔离原则 | 使用多个专门的接口而不使用单一的总接口。 |
合成复用原则 | 尽量使用关联关系,而不是继承关系来达到复用的目的。 |
迪米特法则 | 一个软件实体应当尽可能少地与其它实体发生相互作用。 |
表1 七种常用的面向对象设计原则
七大设计原则
1. 单一职责原则
Sunny 软件公司开发人员针对某 CRM 系统的客户信息图形统计模块提出了如下图所示初始设计方案:
函数 | 解释 |
---|---|
Connection GetConnection(); | 用于连接数据库。 |
List<Customer> FindCustomers(); | 用于查询所有的客户信息。 |
void CreateChart(); | 用于创建图表。 |
void DisplayChart(); | 用于显示图表。 |
在图5中,CustomerDataChart
类承担了太多的职责:
- 包含与数据库相关的方法;
- 包含获取客户数据的方法;
- 包含与图表创建和显示相关的方法;
无论是修改数据库连接方式还是修改图表显示方式都需要修改该类,它不止一个引起它变化的原因,违背了我们程序设计 单一职责原则。
图6 重构后的结构图
将CustomerDataChart
拆分为如下三个类:
DBUtility
:负责连接数据库,包括GetConnection()
;CustomerDAO
:负责操作数据库中的Customer
表,包括对Customer
表的增、删、改、查等方法,如FindCustomers()
;CustomerDataChart
:负责图表的生成和显示,包括CreateChart()
和DisplayChart()
。
定义:单一职责原则(Single Responsibility Principle,SRP)
一个类只负责一个功能领域中的相应职责。即就一个类而言,应该只有一个引起它变化的原因。
在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它 可复用性 就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其它职责的运作。
因此要将这些职责进行分离:
- 不同的职责封装在不同的类中,即将不同变化的原因封装在不同的类中;
- 如果多个职责总是同时发生变化则可将它们封装在同一个类中;
单一职责原则 是实现高内聚、低耦合的指导方针,遵循该原则我们就可以避免类的粒度过大导致复用性降低的问题,设计出 可复用、可扩展、易维护、灵活性好 的代码。
2. 开闭原则
Sunny 软件公司开发的 CRM 系统可以显示各种类型的图表,如饼状图和柱状图等,为了支持多种图表显示方式,原始设计方案如下图所示:
图7 初始设计方案结构图
在ChartDisplay
类的display()
方法中存在如下代码片段:
public void Display(string type)
{
//...
if (type.Equals("pie"))
{
PieChart chart = new PieChart();
chart.Display();
}
else if (type.Equals("bar"))
{
BarChart chart = new BarChart();
chart.Display();
}
//...
}
在该代码中,如果需要增加一个新的图表类,如折线图LineChart
,则需要修改ChartDisplay
类的Display()
方法的源码,增加新的判断逻辑,从而违反了 开闭原则。
可以通过抽象化的方式对系统进行重构,使之增加新的图表类时无须修改源码。具体做法如下:
- 增加一个抽象图表类
AbstractChart
,将各种具体图表类作为其子类。 ChartDisplay
类针对抽象图表类进行编程,由客户端来决定使用哪种具体图表。
重构后结构如下图所示:
图8 重构后的结构图
在图8中,我们引入了抽象图表类AbstractChart
,且ChartDisplay
针对抽象图表类进行编程,并通过SetChart()
方法由客户端来设置实例化的具体图表对象,在ChartDisplay
的Display()
方法中调用chart
对象的Display()
方法显示图表。
如果需要增加一种新的图表,如折线图LineChart
,只需LineChart
也作为AbstractChart
的子类,在客户端向ChartDisplay
中注入一个LineChart
对象即可,无须修改已经完成的源码。
注意:因为 XML 或 Properties 等格式的配置文件是纯文本文件,可以直接通过记事本进行编辑,且无须编译,因此在软件开发中,一般不把对配置文件的修改认为是对系统源码的修改。
如果一个系统在扩展时只涉及到修改配置文件,而原有的代码没有做任何修改,该系统即可认为是一个符合 开闭原则 的系统。
定义:开闭原则(Open-Closed Principle,OCP)
一个软件实体应当对扩展开放,对修改关闭(Software entities should be open for extension, but closed for modification.),即软件实体应尽量在不修改原有代码的情况下进行扩展。
在 开闭原则 的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
从微观层面来看,在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化。
比如,之前写的加法程序,开始在一个
Client
类中完成,此时变化还没有发生。然后,增加一个减法功能,发现,增加功能需要修改原来这个类,这就违背了“开闭原则”,于是就该考虑重构程序,增加一个抽象的运算类,通过一些面向对象的手段,如继承,多态等来隔离具体加法、减法与Client
的耦合,需求依然可以满足,还能应对变化。这时又要加入乘法、除法功能,就不需要再去更改Client
以及加法、减法的类了,而是增加乘法和除法子类就可。
即面对需求,对程序的改动是通过增加新代码进行的,而不是更改现有的代码。
从宏观层面来看,为了满足 开闭原则,需要对系统进行抽象化设计,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。
在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过实体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的实体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到 开闭原则 的要求。
可见,抽象化是 开闭原则 的关键。我们在面对需求的变更时,利用 开闭原则 设计的系统能够保持结构的稳定,不但方便系统的维护,更有利于不断升级程序的版本。
3. 里氏代换原则
Sunny 软件公司开发的 CRM 系统中,Customer 可以分为 VIPCustomer 和 CommonCustomer 两类,系统需要提供一个发送 Email 的功能,原始设计方案如下图所示:
图9 原始结构图
在对系统进一步分析后发现,无论是 CommonCustomer 还是 VIPCustomer ,发送邮件的过程都是相同的,也就是说两个send()
方法中的代码重复,而且在本系统中或许还将增加新类型的客户。
为了让系统具有更好的扩展性,同时减少代码重复,需要依据 里氏代换原则 进行重构。
在实例中,可以考虑增加一个新的抽象类
Customer
,而将CommonCustomer
和VIPCustomer
类作为其子类,邮件发送类EmailSender
针对抽象客户类Customer
编程,根据 里氏代换原则,能够接受基类对象的地方必然能够接受子类对象,因此将EmailSender
中的send()
方法的参数类型改为Customer
,如果需要增加新类型的客户,只需要将其作为Customer
类的子类即可。
重构后的结构如下图所示:
图10 重构后的结构图
里氏代换原则 由2008年图灵奖得主、美国第一位计算机科学女博士 Barbara Liskov 教授和卡内基.梅隆大学 Jeannette Wing教授于1994年提出。
图11 Barbara Liskov
定义:里氏代换原则(Liskov Substitution Principle,LSP)
如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1替换o2时,程序P的行为没有变化,那么类型S是类型T的子类型。
上面的定义比较拗口,因此我们一般使用它的另一个通俗版定义:
定义:里氏代换原则(Liskov Substitution Principle,LSP)
所有引用基类(父类)的地方必须能透明地使用其子类的对象。
里氏代换原则 告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能使用基类对象。
例如:有两个类,一个类为
BaseClass
,另一个类是SubClass
,并且SubClass
是BaseClass
的子类,那么一个方法如果可以接收一个BaseClass
类型的基类对象base
的话,如:Method(base)
,那么它必然可以接收一个BaseClass
类型的子类对象sub
,Method(sub)
能够正常运行。反过来的替换不成立,如果一个方法
Method2
接收BaseClass
类型的子类对象sub
为参数:Method2(sub)
,那么不可以有Method2(base)
。
里氏代换原则 是实现 开闭原则 的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换基类对象,正是由于子类型的可替换性才使得使用基类型的模块在无需修改的情况下就可以扩展。
在使用 里氏代换 原则时需要注意如下两个问题:
【1】根据 里氏代换原则,为了保证系统的扩展性,在程序中通常使用基类来进行定义,如果一个方法只存在子类中,在基类中不提供相应的声明,则无法在以基类定义的对象中使用该方法。
【2】在运用 里氏代换原则 时,尽量把基类设计为抽象类或者接口,让子类继承基类或实现接口,并复写或实现在基类中声明的方法,运行时用子类实例替换基类实例。
4. 依赖倒转原则
Sunny 软件公司开发人员在开发某 CRM 系统时发现:不同的使用者,转换客户信息到数据库中的数据源是不同的,有的是 EXCEL 文件有的是 TXT 文件。
于是,开发人员准备在客户数据操作类CustomerDAO
中调用数据格式转换类的方法实现格式转换和数据插入操作,针对不同的使用者编译不同的版本。
初始设计方案结构如下图所示:
图12 初始设计方案结构图
在编码实现图12所示结构时,开发人员发现该方案存在一个非常严重的问题,不但在TXTDataConvertor
与ExcelDataConvertor
相互转换时,需要修改CustomerDAO
的源码,而且在引入并使用新的数据转换类时也不得不修改CustomerDAO
的源码,系统扩展性较差,违反了 开闭原则,需要对该方案依照 依赖倒转原则 进行重构。
在本实例中,由于CustomerDAO
针对具体数据转换类编程,因此在增加新的数据转换类或者更换数据转换类时都不得不修改CustomerDAO
源代码。
我们可以通过引入抽象数据转换类解决该问题,在引入抽象数据转换类DataConvertor
之后,CustomerDAO
针对抽象类DataConvertor
编程,而将具体数据转换类名存储在配置文件中,符合 依赖倒转原则。
根据 里氏代换原则,程序运行时,具体数据转换类对象将替换DataConvertor
类型的对象,程序不会出现任何问题。更换具体数据转换类时无须修改源码,只需要修改配置文件。如果需要增加新的具体数据转换类,只需将新增数据转换类作为DataConvertor
的子类并修改配置文件即可,原有代码无须做任何修改,满足 开闭原则。
重构后的结构如下图所示:
图13 重构后的结构图
依赖倒转原则 是 Robert C.Martin 在 1996年 为“C++ Reporter”所写的专栏 Engineering Notebook 的第三篇,后来加入到他 2002年 出版的经典著作《Agile Software Development,Principle,Patterns,and Practices》一书中。
图14 Robert C.Martin:Object Mentor公司总裁
定义:依赖倒转原则(Dependency Inversion Principle,DIP)
抽象不应该依赖于细节,细节应该依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
依赖倒转原则 要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。
注意:为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。
在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,写相应的实体类,并修改配置文件,而无须修改原有系统的源码,在不修改的情况下来扩展系统的功能,满足 开闭原则 的要求。
在实现 依赖倒转原则 时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入的方式注入到其它对象中,依赖注入是指当一个对象要与其它对象发生依赖关系时,通过抽象来注入所依赖的对象。
常用的注入方式有三种,分别是:
- 构造注入 – 通过构造函数来传入实体类的对象;
- Setter注入 – 通过Setter方法来传入实体类的对象;
- 接口注入 – 通过在接口中声明业务方法来传入实体类的对象;
这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来复写或实现父类对象。
在大多数情况下,开闭原则、里氏代换原则 和 依赖倒转原则 会同时出现:
- 开闭原则是目标;
- 里氏代换原则是基础;
- 依赖倒转原则是手段;
它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。
5. 接口隔离原则
Sunny 软件公司开发人员针对某 CRM 系统的客户数据显示模块设计了如下图所示接口。
函数 | 说明 |
---|---|
void DataRead(); | 用于从文件中读取客户数据。 |
void TransformToXml(); | 用于将客户数据转换成 XML 的格式。 |
void CreateChart(); | 用于创建图表。 |
void DisplayChart(); | 用于显示图表。 |
void CreateReport(); | 用于创建文字报告。 |
void DisplayReport(); | 用于显示文字报告。 |
在实际使用过程中发现该接口很不灵活,例如:
【1】如果一个具体的数据显示类无须进行数据转换(源文件本身就是XML
格式),但由于需要实现该接口,将不得不实现其中声明的TransformToXml()
(至少需要提供一个空实现)。
【2】如果仅需创建和显示图表,除了实现与图表相关的方法外,还需要实现其中创建和显示文字报表的方法,否则编译时将报错。
由于在接口ICustomerDataDisplay
中定义了太多方法,即该接口承担了太多职责。
- 一方面导致该接口的实现类很庞大,在不同的实现类中都不得不实现接口中定义的所有方法,灵活性较差,如果出现大量的空方法,将导致系统中产生大量的无用代码,影响代码质量。
- 另一方面由于客户端针对大接口编程,将在一定程度上破坏程序的封装性,客户端看到了不该看到的方法,没有为客户端定制接口。
需要将该接口按照 单一职责原则、接口隔离原则 进行重构,将其中的一些方法封装在不同的小接口中,确保每一个接口使用起来都较为方便,并都承担某一单一的职责。
图16 重构后的结构图
定义:接口隔离原则(Interface Segregation Principle,ISP)
使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖哪些它不需要的接口。
在面向对象编程语言中,实现一个接口就需要实现该接口中定义的所有方法,因此大的总接口使用起来不一定很方便,为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一的职责。
每个接口提供的功能尽可能单一,每个接口中只包含一个客户端(如子模块或业务逻辑类)所需要的方法即可,不应该强迫客户依赖于哪些他们不用的方法,这种机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接口,从而方便的为第三方开发者按需定制方案。
注意:在使用 接口隔离原则 时,我们需要控制接口的粒度:
- 接口太小,会导致系统中接口泛滥,不利于维护;
- 接口太大,将违背 接口隔离原则,灵活性较差,使用不便;
6. 合成复用原则
Sunny 软件公司开发人员在初期的 CRM 系统设计中,考虑到客户数量不多,系统采用 Access 作为数据库。连接数据库的方法GetConnection()
封装在DBUtil
类中,与数据库操作有关的类如CustomerDAO
等都需要用DBUtil
类的GetConnection()
方法。
于是,设计人员将CustomerDAO
作为DBUtil
类的子类,初始设计方案结构如下图所示:
图17 初始设计方案结构图
随着客户数量的增加,需要把数据库升级为 SQL Server,因此需要增加一个新的SQLDBUtil
类来连接 SQL Server 数据库,由于在初始设计方案中CustomerDAO
和DBUtil
之间是继承关系,因此在更换数据库连接方式时需要修改CustomerDAO
类或者DBUtil
类的源码,这将违反 开闭原则。需要依据 合成复用原则 对其进行重构。
本案例中我们可以使用 关联复用 来取代 继承复用,重构后的结构如下图所示:
图18 重构后的结构图
在图18中,CustomerDAO
和DBUtil
之间的关系由继承关系变为关联关系,采用依赖注入的方式将DBUtil
对象注入到CustomerDAO
中,可以使用构造注入,也可以使用Setter
注入。
如果需要对DBUtil
进行扩展,可以通过其子类来实现,如通过子类SQLDBUtil
来连接 SQL Server 数据库。由于CustomerDAO
针对DBUtil
编程,根据 里氏代换原则,DBUtil
子类的对象可以覆盖DBUtil
对象,只需在CustomerDAO
中注入子类对象即可使用子类所扩展的方法。
定义:合成复用原则(Composite/Aggregate Reuse Principle,CARP)又称为组合/聚合复用原则。
尽量使用对象组合/聚合,而不是继承来达到复用的目的。
在面向对象设计中,可以通过 继承关系 或 关联关系 在不同环境中复用已有的设计和实现。
- 首先应该考虑使用关联关系;
- 其次才考虑继承关系,在使用继承时,需要严格遵守 里氏代换原则;
通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的大部分实现细节暴露给子类,所以这种复用又称为“白箱复用”。如果基类发生改变,那么子类的实现也不得不发生改变。而且从基类继承下来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。
由于关联关系可以将已有对象(也称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用成员对象的功能,这样做可以使成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱复用”。相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作。而且合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其它对象。
一般而言,如果两个类之间是“Has-A”的关系应使用关联关系,如果是“Is-A”关系才使用继承关系。
- “Is-A”是严格的分类学意义上的定义,意思是一个类是另一个类的“一种”;
- “Has-A”则不同,它表示某一个角色具有某一项责任;
7. 迪米特法则
Sunny 软件公司所开发的 CRM 系统包含很多业务操作窗口,在这些窗口中,某些界面控件之间存在复杂的交互关系,一个控件事件的触发将导致多个其它界面控件响应,例如,当一个 Button 被单击时,对应的 List、ComboBox、TextBox、Label 等都将发生改变,在初始设计方案中,界面控件之间的交互关系可简化为如下图所示结构:
图19 初始设计方案结构图
在图19中,每一个控件都与多个其它控件相互关联和调用,若一个界面控件对象发生变化,需要跟踪与之关联的其它所有控件并进行处理,控件之间呈现一种较为复杂的 网状结构,控件之间耦合度太高,系统扩展性较差。
在本案例中,可以引入一个专门用于控制界面交互的中间类Mediator
来降低界面控件之间的耦合。引入中间类之后,界面控件之间不再直接发生引用,而是将请求先转发给中间类,再由中间类来完成对其它控件的调用,需要依据 迪米特法则 进行重构。
重构后的结构如下图所示:
图20 重构后的结构图
通过引入一个合理的第三者Mediator
来降低现有对象之间的耦合度。
定义:迪米特法则(Law of Demeter,LoD)又称为最小知识原则(Least Knowledge Principle,LKP)
一个软件实体应当尽可能少地与其它实体发生相互作用。
迪米特法则 来自于 1987年 美国东北大学(Northeastern University)一个名为 “Demeter” 的研究项目。该法则限制了软件实体之间通信的宽度和深度,可降低系统的耦合度,使类与类之间保持松散的耦合关系。如果一个系统符合 迪米特法则,那么当其中一个模块发生修改时,可以尽量少地影响其它模块,扩展相对容易。
迪米特法则 还有几种定义形式,包括:不要和“陌生人”说话,只与你的直接朋友通信等,在 迪米特法则 中,对于一个对象,其朋友包括以下几类:
- 当前对象本身(this);
- 以参数形式传入到当前对象方法中的对象;
- 当前对象的成员对象;
- 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
- 当前对象所创建的对象;
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”(如:局部变量)。
在应用 迪米特法则 时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其它对象带来影响。
补充:
首先来解释编程中的朋友:两个对象之间的耦合关系称之为朋友,以成员变量,方法的参数和返回值的形式出现。
那么为什么说是要与直接朋友通信呢?观察直接朋友出现的地方,我们发现在直接朋友出现的地方,大部分情况下可以接口或者父类来代替,可以增加灵活性。
在将 迪米特法则 运用到系统设计时,要注意以下几点:
- 在类的划分上:应当尽量创建松耦合类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;
- 在类结构设计上:要尽量降低每个类成员的访问权限,也就是说,一个类包装好自己的
private
状态,不需要让别的类知道的字段或行为就不要公开; - 在对其它类的引用上:一个对象对其它对象的引用应当降到最低;
后台回复「搜搜搜」,随机获取电子资源!
欢迎关注,请扫描二维码: