当前位置: 首页 > 编程日记 > 正文

技术图文:01 面向对象设计原则

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));elsestrResult = "除数不能为零。";break;default:throw new Exception("输入的运算符不合法!");}Console.WriteLine("结果是:" + strResult);}catch (Exception ex){Console.WriteLine("您的输入有错:" + ex.Message);}
}

大鸟:“吼吼,不错,不错,改得很快嘛!至少就目前代码来说,实现计算器是没有问题了,但这样写的代码是否符合出题人的意思呢?”

小菜:“我明白了,他说用任意一种面向对象语言实现,那意思是要用面向对象的编程方法去实现,对吗?OK,这个我学过,只不过当时我没想到而已。”

大鸟:“所有的初学者都会有这样的问题,就是碰到问题就直觉地用计算机能够理解的逻辑来描述和表达待解决的问题及具体的求解过程。这其实是用计算机的方式去思考,比如计算器这个程序:

  • Step1:要求输入两个数和运算符号;
  • Step2:根据运算符号判断选择如何运算;
  • Step3:得到结果并输出;

这本身没有错,但这样的思维却使得我们的程序只为满足实现当前的需求,程序 不易维护不易扩展不易复用灵活性差。从而达不到高质量代码的要求。”

1. 复制 VS. 复用

大鸟:“比如说,我现在要求你再写一个 Windows 的计算器,你现在的 代码能不能复用呢?”

小菜:“那还不简单,把代码复制过去不就行了吗?改动又不大,不算麻烦。”

大鸟:“小菜看来还是小菜呀,有人说初级程序员的工作就是 Ctrl+CCtrl+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()方法由客户端来设置实例化的具体图表对象,在ChartDisplayDisplay()方法中调用chart对象的Display()方法显示图表。

如果需要增加一种新的图表,如折线图LineChart,只需LineChart也作为AbstractChart的子类,在客户端向ChartDisplay中注入一个LineChart对象即可,无须修改已经完成的源码。


注意:因为 XMLProperties 等格式的配置文件是纯文本文件,可以直接通过记事本进行编辑,且无须编译,因此在软件开发中,一般不把对配置文件的修改认为是对系统源码的修改。
如果一个系统在扩展时只涉及到修改配置文件,而原有的代码没有做任何修改,该系统即可认为是一个符合 开闭原则 的系统。

定义:开闭原则(Open-Closed Principle,OCP)
一个软件实体应当对扩展开放,对修改关闭(Software entities should be open for extension, but closed for modification.),即软件实体应尽量在不修改原有代码的情况下进行扩展。


开闭原则 的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。

从微观层面来看,在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化

比如,之前写的加法程序,开始在一个Client类中完成,此时变化还没有发生。然后,增加一个减法功能,发现,增加功能需要修改原来这个类,这就违背了“开闭原则”,于是就该考虑重构程序,增加一个抽象的运算类,通过一些面向对象的手段,如继承,多态等来隔离具体加法、减法与Client的耦合,需求依然可以满足,还能应对变化。这时又要加入乘法、除法功能,就不需要再去更改Client以及加法、减法的类了,而是增加乘法和除法子类就可。

即面对需求,对程序的改动是通过增加新代码进行的,而不是更改现有的代码。

从宏观层面来看,为了满足 开闭原则,需要对系统进行抽象化设计,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成

在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过实体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的实体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到 开闭原则 的要求。

可见,抽象化是 开闭原则 的关键。我们在面对需求的变更时,利用 开闭原则 设计的系统能够保持结构的稳定,不但方便系统的维护,更有利于不断升级程序的版本。

3. 里氏代换原则

Sunny 软件公司开发的 CRM 系统中,Customer 可以分为 VIPCustomerCommonCustomer 两类,系统需要提供一个发送 Email 的功能,原始设计方案如下图所示:

图9 原始结构图

在对系统进一步分析后发现,无论是 CommonCustomer 还是 VIPCustomer ,发送邮件的过程都是相同的,也就是说两个send()方法中的代码重复,而且在本系统中或许还将增加新类型的客户。

为了让系统具有更好的扩展性,同时减少代码重复,需要依据 里氏代换原则 进行重构。

在实例中,可以考虑增加一个新的抽象类Customer,而将CommonCustomerVIPCustomer类作为其子类,邮件发送类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,并且SubClassBaseClass的子类,那么一个方法如果可以接收一个BaseClass类型的基类对象base的话,如:Method(base),那么它必然可以接收一个BaseClass类型的子类对象subMethod(sub)能够正常运行。

反过来的替换不成立,如果一个方法Method2接收BaseClass类型的子类对象sub为参数:Method2(sub),那么不可以有Method2(base)

里氏代换原则 是实现 开闭原则 的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换基类对象,正是由于子类型的可替换性才使得使用基类型的模块在无需修改的情况下就可以扩展。

在使用 里氏代换 原则时需要注意如下两个问题:

【1】根据 里氏代换原则,为了保证系统的扩展性,在程序中通常使用基类来进行定义,如果一个方法只存在子类中,在基类中不提供相应的声明,则无法在以基类定义的对象中使用该方法。

【2】在运用 里氏代换原则 时,尽量把基类设计为抽象类或者接口,让子类继承基类或实现接口,并复写或实现在基类中声明的方法,运行时用子类实例替换基类实例。

4. 依赖倒转原则

Sunny 软件公司开发人员在开发某 CRM 系统时发现:不同的使用者,转换客户信息到数据库中的数据源是不同的,有的是 EXCEL 文件有的是 TXT 文件。

于是,开发人员准备在客户数据操作类CustomerDAO中调用数据格式转换类的方法实现格式转换和数据插入操作,针对不同的使用者编译不同的版本。

初始设计方案结构如下图所示:

图12 初始设计方案结构图

在编码实现图12所示结构时,开发人员发现该方案存在一个非常严重的问题,不但在TXTDataConvertorExcelDataConvertor相互转换时,需要修改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 数据库,由于在初始设计方案中CustomerDAODBUtil之间是继承关系,因此在更换数据库连接方式时需要修改CustomerDAO类或者DBUtil类的源码,这将违反 开闭原则。需要依据 合成复用原则 对其进行重构。

本案例中我们可以使用 关联复用 来取代 继承复用,重构后的结构如下图所示:

图18 重构后的结构图

在图18中,CustomerDAODBUtil之间的关系由继承关系变为关联关系,采用依赖注入的方式将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 被单击时,对应的 ListComboBoxTextBoxLabel 等都将发生改变,在初始设计方案中,界面控件之间的交互关系可简化为如下图所示结构:

图19 初始设计方案结构图

在图19中,每一个控件都与多个其它控件相互关联和调用,若一个界面控件对象发生变化,需要跟踪与之关联的其它所有控件并进行处理,控件之间呈现一种较为复杂的 网状结构,控件之间耦合度太高,系统扩展性较差。

在本案例中,可以引入一个专门用于控制界面交互的中间类Mediator来降低界面控件之间的耦合。引入中间类之后,界面控件之间不再直接发生引用,而是将请求先转发给中间类,再由中间类来完成对其它控件的调用,需要依据 迪米特法则 进行重构。

重构后的结构如下图所示:

图20 重构后的结构图

通过引入一个合理的第三者Mediator来降低现有对象之间的耦合度。

定义:迪米特法则(Law of Demeter,LoD)又称为最小知识原则(Least Knowledge Principle,LKP)
一个软件实体应当尽可能少地与其它实体发生相互作用。


迪米特法则 来自于 1987年 美国东北大学(Northeastern University)一个名为 “Demeter” 的研究项目。该法则限制了软件实体之间通信的宽度和深度,可降低系统的耦合度,使类与类之间保持松散的耦合关系。如果一个系统符合 迪米特法则,那么当其中一个模块发生修改时,可以尽量少地影响其它模块,扩展相对容易。

迪米特法则 还有几种定义形式,包括:不要和“陌生人”说话,只与你的直接朋友通信等,在 迪米特法则 中,对于一个对象,其朋友包括以下几类:

  • 当前对象本身(this);
  • 以参数形式传入到当前对象方法中的对象;
  • 当前对象的成员对象;
  • 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  • 当前对象所创建的对象;

任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”(如:局部变量)。

在应用 迪米特法则 时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其它对象带来影响。

补充:

首先来解释编程中的朋友:两个对象之间的耦合关系称之为朋友,以成员变量,方法的参数和返回值的形式出现。

那么为什么说是要与直接朋友通信呢?观察直接朋友出现的地方,我们发现在直接朋友出现的地方,大部分情况下可以接口或者父类来代替,可以增加灵活性。

在将 迪米特法则 运用到系统设计时,要注意以下几点:

  • 在类的划分上:应当尽量创建松耦合类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;
  • 在类结构设计上:要尽量降低每个类成员的访问权限,也就是说,一个类包装好自己的private状态,不需要让别的类知道的字段或行为就不要公开;
  • 在对其它类的引用上:一个对象对其它对象的引用应当降到最低;

后台回复「搜搜搜」,随机获取电子资源!
欢迎关注,请扫描二维码:



相关文章:

关于GridView手动绑定的一段代码,一切尽在不言中

为GridView绑定主键的方法&#xff0c;在前台的DataGrid标签中加 DataKeyNames"ID" 后台获取ID&#xff1a; int idint.parse(this.GridView.DataKeys[e.RowIndex].Value.Tostring()); 如果DataKeyNames绑定了多个列取法&#xff1a;int idint.parse(this.G…

linux 服务器FTP服务安装教程

1.更新yum源 首先需要更新系统的yum源&#xff0c;便捷工具下载地址&#xff1a;http://help.aliyun.com/manual?spm0.0.0.0.zJ3dBU&helpId1692 2.安装vsftp 使用yum命令安装vsftp #yum install vsftpd -y 3.添加ftp帐号和目录 先检查一下nologin的位置&#xff0c;通常在…

CSS3颜色不透明度如何设置

web前端技术包含HTML和CSS样式&#xff0c;两者是相辅相成的&#xff0c;学习CSS样式不必可少&#xff0c;那么在学习CSS样式中&#xff0c;CSS3颜色不透明度如何设置?在CSS3之前&#xff0c;我们设置颜色的方式包含十六进制颜色(如#F00)、rgb模式颜色、或指定颜色的英文名称(…

技术图文:02 创建型设计模式(上)

创建型设计模式&#xff08;上&#xff09; 知识结构&#xff1a; 图1 知识结构 简单工厂模式 Sunny 软件公司欲基于 C# 语言开发一套图表库&#xff0c;该图表库可以为应用系统提供各种不同外观的图表&#xff0c;如&#xff1a; 柱状图&#xff08;histogram&#xff09;饼…

转:初探 jQuery 的 Sizzle 选择器

这是一篇关于介绍jQuery Sizzle选择器的文章&#xff0c;由我和obility共同完成。在文中&#xff0c;我们试图用自己的语言配以适量的代码向读者展现出Sizzle在处理选择符时的流程原理&#xff0c;以及末了以少许文字给你展示出如何借用Sizzle之手实现自定义选择器&#xff08;…

安装hadoop图文

1.下载hadoop-2.5.1,存放根目录 2.通过tar -zxvf 包名 来进行解压 3.通过mv命令将解压后的hadoop包移动到/home下 4.修改hadoop-en.sh配置文件,添加jdk的安装目录,操作如下图所示 5.修改core-site.xml配置文件,添加namenode的配置信息 6.修改hdfs-site.xml配置文件,添加seconda…

Java中父类方法重写有哪些需要注意的?

在继承关系中&#xff0c;子类会自动继承父类中公共的方法&#xff0c;但有时在子类中需要对继承的方法进行一些修改&#xff0c;即对父类的方法进行重写。需要注意的是&#xff0c;子类中重写的方法需要和父类被重写的方法具有相同的方法名、参数列表以及返回值类型。 在上一节…

技术图文:02 创建型设计模式(下)

创建型设计模式&#xff08;下&#xff09; 知识结构&#xff1a; 图1 知识结构 单例模式 – 确保对象的唯一性 Sunny 软件公司承接了一个服务器负载均衡软件的开发工作&#xff0c;该软件运行在一台负载均衡服务器上&#xff0c;可以将并发访问和数据流量分发到服务器集群中…

[转载]C# 二进制与十进制,十进制与十六进制相互转换

原文地址&#xff1a;C# 二进制与十进制,十进制与十六进制相互转换作者&#xff1a;tonytonglx十进制转二进制&#xff1a;用2辗转相除至结果为1 将余数和最后的1从下向上倒序写就是结果例如302302/2 151 余0151/2 75 余175/2 37 余137/2 18 余118/2 9 余09/2 4 余14/2 …

感知哈希算法——找出相似的图片

参考Neal Krawetz博士的这篇文章, 实现这种功能的关键技术叫做"感知哈希算法"(Perceptual Hash Algorithm), 意思是为图片生成一个指纹(字符串格式), 两张图片的指纹越相似, 说明两张图片就越相似. 但关键是如何根据图片计算出"指纹"呢? 下面用最简单的步…

学web前端需要了解哪些常识

想要学好web前端技术&#xff0c;那么一定要掌握足够的知识&#xff0c;web前端技术包含很多方面的知识&#xff0c;具体学web前端需要了解哪些常识?来看看下面的详细介绍。 学web前端需要了解哪些常识? html css javascript。 要学的内容实在很多&#xff0c;如果没有其他编…

linux下后台执行shell脚本

一句话 nohup sh startup_Server.sh & 转载于:https://www.cnblogs.com/phpcode/archive/2012/04/24/2522761.html

线性代数:第一章 线性方程组

本讲义是自己上课所用幻灯片&#xff0c;里面没有详细的推导过程&#xff08;笔者板书推导&#xff09;只以大纲的方式来展示课上的内容&#xff0c;以方便大家下来复习。 从本章开始&#xff0c;我们一起来学习线性代数的有关知识&#xff0c;线性代数的应用之一就是求解复杂…

菜鸟也来学习ORACLE(1)_linux下安装oracle 11g

加入 oracle Club 之前&#xff0c;学长给我们开了个小会 说是看看我们加入的意愿&#xff0c;哎哎 其实直无聊&#xff0c;但是大体比较重视linux 服务器的搭建 以及在linux 下安装oracle 搭建一个oracle 环境吧、我就想这东西能有多难&#xff0c;于是回来就搭建起了&#x…

CSS浮动元素特点有什么

什么是浮动? 元素的浮动是指设置了浮动属性(flot)的元素。 CSS浮动有什么作用? 1.让多个盒子水平排列成一行&#xff0c;浮动成为布局的重要手段; 2.可以实现盒子的左右对齐等等; 3.浮动最早是用来控制图片&#xff0c;实现文字环绕图片的效果。 CSS浮动的语法&#xff1a; 选…

数据结构与算法:11 Leetcode同步练习(四)

目录 题目01&#xff1a;最小栈题目02&#xff1a;有效的括号题目03&#xff1a;用队列实现栈题目04&#xff1a;整数反转题目05&#xff1a;逆波兰表达式求值题目06&#xff1a;全排列题目07&#xff1a;字符串转换整数 (atoi)题目08&#xff1a;设计循环双端队列题目09&…

trie树 详解

前几天学习了并查集和trie树&#xff0c;这里总结一下trie。 本文讨论一棵最简单的trie树&#xff0c;基于英文26个字母组成的字符串&#xff0c;讨论插入字符串、判断前缀是否存在、查找字符串等基本操作&#xff1b;至于trie树的删除单个节点实在是少见&#xff0c;故在此…

启动hadoop的节点

1.启动hadoop的节点 start-dfs.sh 本文转自 素颜猪 51CTO博客&#xff0c;原文链接:http://blog.51cto.com/suyanzhu/1959242

什么是Python线程?Python线程如何创建?

相信正在学习Python技术或者对Python语言有一定了解的人对于Python线程应该都不陌生&#xff0c;但是也有刚接触Python的小伙伴对于Python线程并不了解&#xff0c;今天小编就跟大家聊聊什么是Python线程&#xff0c;又该如何创建Python线程! 什么是Python线程?Python线程如何…

ItemAdding实现数据验证--中文字段,properties.AfterProperties值为null的问题

最近写事件接收器&#xff0c;发现中文字段如果直接用properties.AfterProperties[“申请人"]这样获取的值为null&#xff0c;无法得到值。后拉忽然发现用英文字段可以得到值。难道中文字段需要编码&#xff1f;经过测试果真如此。 代码部分如下&#xff1a;public overri…

jstl c:choose、c:when和c:otherwise标签

在用spring mvc中&#xff0c;页面前端老用jstl&#xff0c;记录一下。 <c:choose>、<c:when>和<c:otherwise>在一起连用&#xff0c;可以实现Java语言中的if-else语句的功能。例如以下代码根据username请求参数的值来打印不同的结果&#xff1a; <c:choo…

怎样设计出优秀的测试用例?看看下面就知道了

想要成为一名合格的软件测试工程师&#xff0c;一份合格软件测试报告是非常重要的&#xff0c;软件测试的核心也就是测试的用例了&#xff0c;我们通过用例可以看出怎么设计出来可以发现问题&#xff0c;可以有效的覆盖需求的&#xff0c;没有冗余的用例是每个测试工程师必须跨…

数据结构与算法:12 数组与稀疏矩阵

12 数组与稀疏矩阵 知识结构&#xff1a; 1. 数组 1.1 数组的定义 数组是具有一定顺序关系的若干对象组成的集合&#xff0c;组成数组的对象称为数组元素。 例如&#xff1a; 向量对应一维数组 A(a0,a1,⋯,an−1)A(a_0,a_1,\cdots,a_{n-1}) A(a0​,a1​,⋯,an−1​) 矩阵…

管理索引表:深入研究B树索引--重建,合并,删除(理论篇3)

重建索引  如果表中记录频繁地被删除或插入&#xff0c;尽管表中的记录总量保持不变&#xff0c;索引空间的使用量会不断增加。虽然记录从索引中被删除&#xff0c;但是该记录索引项的使用空间不能被重新使用。因此&#xff0c;如果表变化不定&#xff0c;索引空间量会不断增…

模块架构不是软件成功的“决定因素”

【本文是09年的一篇旧文&#xff0c;出于某些原因&#xff0c;对原文内容有删减&#xff0c;在这里整理后重新发表】 前言感谢XXX对我们技术&#xff0c;对我们公司产品提出这些意见&#xff0c;我们公司卖的是软件产品&#xff0c;开发软件是一件技术活&#xff0c;说实话&…

JavaScript面向对象修改标签页详解

双击标签页组件中的li小标签或者section 中的文本&#xff0c;可以对文本进行编辑。为了实现这个功能&#xff0c;需要先给li和section元素绑定双击事件&#xff0c;当双击文本后&#xff0c;将文本改成一个文本框&#xff0c;用来输入新的内容&#xff0c;在文本框中显示原来的…

数据结构与算法:13 字符串与整数集合

13 字符串与整数集合 知识点&#xff1a; 1. 字符串 我们古人没有电影电视&#xff0c;没有游戏网络&#xff0c;所以文人们就会想出一些文字游戏来娱乐。比如宋代的李禺写了这样一首诗&#xff1a;“枯眼望遥山隔水&#xff0c;往来曾见几心知&#xff1f;壶空怕酌一杯酒&am…

是时候开始使用JavaScript严格模式了怎样启用javascri

E是时候开始使用JavaScript严格模式了怎样启用javascriCMAScript5将严格模式(strictmode)引入了Javascript中&#xff0c;目的是允许开发人员能够选择“更好”的Javascript版本&#xff0c;这个版本能用不同的方式处理那些普遍而又臭名昭著的错误。一开始的时候&#xff0c;我对…

Linux服务器日志备份到本地

1、确定线上服务器的日志文件名称和路径 2、一台本地服务器能连接公网&#xff0c;创建一个日志账户&#xff0c;设置密码 3、线上服务器要求&#xff1a; a、确定是否已安装sshpass包 [rootiZwz9ghdadtaey1msor7gnZ sh]# rpm -qa|grep sshpass sshpass-1.06-1.el7.x86_64 如不…