简介
我老婆 Farhana 想要继续软件开发生涯(之前因为我们的第一个孩子出生,她不得不放弃)。我已经有了一些软件设计和开发的经验,所以这几天我就在试着帮助她学习OOD。
由于我早年在软件开发的经验,我总是发现无论一个技术问题看上去多么难搞,只要从现实生活的角度去解释或用对话的方式去讨论总能让它变得更简单。关于OOD,我们已经有了许多成果丰硕的讨论,我觉得有人可能发现这是一个学习OOD有趣的方式,所以我想我应该分享出来。
下面是我们的谈话步骤:
话题:介绍面向对象设计
丈夫:亲爱的,让我们开始学习面向对象设计。你了解面向对象规范,对吗?
妻子:你是指封装,继承和多态吗?是的,我了解这些规范。
丈夫:行,我想你已经知道怎么用类和对象了。今天我们来学习面向对象设计。
妻子:等等。了解面向对象规范对面向对象编程来说难道不够吗?我的意思是,我能够定义类,封装属性和方法。我能够根据它们的关系定义类的继承。那还有什么呢?
丈夫:很好的问题。面向对象规范和面向对象编程完全是两码事。让我展示一个现实生活中的例子来帮助你理解它们。
我们从牙牙学语起,都是先从字母表学起的,对吧?
妻子: 嗯。
丈夫: 好,然后你就能认单词了,还能通过不同的字母拼写出不同的单词来。慢慢的,你能通过一些基本的语法把这些单词串成一句话。为了使句子时态正确且没有语病,你需要用一些介词,连词,等等。。看下面这句话
"I" (代词) "want" (动词) "to" (介词) "learn" (动词) "OOD" (名词)
通过把几个单词摆放妥当一句话就好了,然后用个关键词来说明一下这句话的重点。
妻子: 亲爱的,你闲扯这些到底要说明什么呢
丈夫: 我说的这个例子跟面向对象规范很类似,面向对象规范为面向对象编程定义了基本的规范,它是面向对象编程的主要思想。面向对象规范好比基本的英语语法,这些语法教会了你怎么用一个个单词拼凑出一句句话来,而面向对象规范教你怎么用类,怎么把一些属性和方法封装在一个类里,怎么串出类之间的继承关系。
妻子: 啊哈,我知道了,那么,面向对象适用于哪里呢。
丈夫: 听我慢慢道来。现在,假设你想写点有内容有题材的文章。你当然还希望写点你比较擅长的题材的书,就会简单造几个句子是远远不够的,对吧。你需要笔耕不辍写出一些长篇大论,你还需要学习怎么可以让读者很容易就看懂你写的这些长篇大论。。。
妻子:嗯,有那么点意思。。。继续吧
丈夫:现在,假如你想写本关于面向对象设计的书,你需要把这个大的课题拆分成一些小题目。把这些小题目分几个章节写,还得写前言,简介,说明,举例,一篇里还有很多段落。你需要设计一整本书,还得练习一些写作技巧,让文章读起来浅显易懂。这就是综观全局。
在软件开发中,OOD就是用来解决从全局出发考虑问题,在设计软件的时候,类和代码可以模块化,可重复使用,可灵活应用,现在已经有很多前人总结出的类和对象的设计原理了,我们直接拿来用就行了,总之,历史的车轮已经碾压出一条清晰的车轮印,我们只要照着走就可以了。
妻子: 哎,懂了点皮毛,还有很多要学呢。
丈夫:不用担心,你很快就会上手的,让我们接着来吧。
话题:为什么要进行面向对象设计?
作者:有个很重要的问题,既然我们能够很快的创建几个类,编写程序并提交,为什么我们还要关注面向对象设计?这样不够么?
妻子:恩,以前我不知道面向对象设计,我也能开发提交项目。有什么关系?
丈夫:好吧,先让我给你看一个经典的引述:
"需求不变的程序开发会同行走在冰上一样简单。"
妻子:你是指软件开发说明书会被不断修改?
丈夫:非常正确!软件开发唯一的真理是“软件必然修改”。为什么?
要知道,你的软件解决的是现实世界中的问题,而现实生活不是一成不变的。
可能你的软件现在运行良好。但它能灵活的支持“变化”吗?如果不能,那它就不是一个敏捷设计的软件。
妻子:好,那你就解释一下什么叫做“敏捷设计的软件”!
丈夫:“一个敏捷设计的软件能轻松应对变化,能被扩展和复用。”
而应用“面向对象设计”是做到敏捷设计的关键。那么,什么时候你可以说你的程序应用了面向对象设计?
妻子:我也正想问呢。
丈夫:如果代码符合以下几点,那么你就在“面向对象设计”:
- 面向对象
- 复用
- 变化的代价极小
- 无需改代码即可扩展
妻子:然后呢?
丈夫:不只我们。很多人也花了很多时间和精力思考这个问题上,他们尝试更好的进行“面向对象设计”,并为“面向对象设计”指出几条基本的原则(你可以用在你的“面向对象设计”中)。他们也确实总结出了一些通用的设计模式(基于基本的原则)。
妻子:你能说出一些吗?
丈夫:没问题。现在有许多设计原则,但是最基本的,就是SOLID(缩写),这五项原则。(感谢鲍勃叔叔,伟大OOD导师)。
S = 单一责任原则
O = 开闭原则
L = Liscov替换原则
I = 接口隔离原则
D = 依赖倒置原则
在下面的讨论中,我们将详细了解这些。
话题:单一功能原则
作者:让我们先来看图,我们应该感谢制作这张图的人,因为它们真的太有趣了。
单一功能原则图
它的意思是:“如果你可以在一个设备中实现所有的功能,你却不能这样做”。为什么呢?因为从长远来看它增加了很多的可管理性问题。
从面向对象角度解释是:
"导致类变化的因素永远不要多于一个。"
或者换行个说法:"一个类有且只有一个职责"。
妻子:可以解释一下么?
丈夫:当然,这个原则是说,如果有多于一个原因会导致你的类改变(或者它的职责多余一个),你就需要根据其职责把这个类拆分为多个类。
妻子:嗯...这是不是意味着在一个类里不能有多个方法?
丈夫:当然不是。你当然可以在一个类中包含多个方法。问题是,他们都是为了一个目的。那么,为什么拆分很重要的?
那是因为:
- 每个职责都是轴向变化;
- 如果类包含多个职责,代码会变得耦合;
妻子:给个例子呗?
丈夫:木有问题啊,瞅瞅下面类的结构。其实,这个例子是 Bob 叔叔那儿来的,得谢谢他。
违反SRP原则的类层次结构
这里,Rectangle 类干了下面两件事:
- 计算矩形面积;
- 在界面上绘制矩形;
而且,有两个程序使用了 Rectangle 类:
- 计算几何应用程序用这个类计算面积;
- 图形程序用这个类在界面上绘制矩形;
这违反了SRP原则(单一职责原则)!
妻子:肿么回事?
丈夫:你瞅瞅,Rectangle 类干了俩不相干的事。一个方法它计算了面积,另外一个它返回一个表示矩形的 GUI 资源。这问题就有点乐了:
- 在计算几何应用程序里咱得包着 GUI。就是说,写几何应用代码,咱也得引用 GUI 库;
- 要是为了图形应用所改变 Rectangle 类,计算几何应用也可能跟着变,然后还得编译,还得测试,另一边也是;
妻子:是很乐。就是说,咱得根据类的职责分开写呗?
丈夫:必须滴。猜猜怎么干?
妻子:我想想,我寻思这得这么办:
我瞅着得按职责拆成两个类:
- Rectangle:这个类定义 Area() 方法;
- RectangleUI:这个把 Rectangle 类继承过来,定义 Draw() 方法。
丈夫:很好。这么个,计算几何应用使 Rectangle 类,图形应用使 RectangleUI 类。咱还可以把这俩类分到俩单独的 DLL 中,然后改的时候就不用管另一个了。
妻子:谢了,我大概明白 SRP 原则了一句话:SPR 就是把东西分到不能再分了,再集中化管理和复用。囔,在方法层面上,咱不也得用 SPR 原则?我是说,咱写的方法里有很多干不同事儿的代码,这也不符合 SPR原则吧。
丈夫:你说地不差。方法也得分开,一个方法干一个活。这么着你复用方法,要是改了,也不用改太多。
话题:开闭原则
作者:“开闭原则“图示如下:
图:开闭原则图
让我来解释一下,设计规则如下:
“软件实体(类,模块,函数等)应该对扩展开放,对修改关闭。”
这意味着在最基本的层面上,你可以扩展一个类的行为,而无需修改。这就像我能够穿上衣服,而对我的身体不做任何改变,哈哈。
妻子: 太有意思啦. 你可以通过穿不同的衣服来改变你的外貌, 但是你不必为此改变自己的身体.所以你是对扩展开放的, 对吧?
丈夫: 是的. 在面向对象设计中, 对扩展开放意味着模块/类的行为可以被扩展,那么当需求变化时我们可以用各种各样的方法制定功能来满足需求变更或者新需求
妻子: 除此之外你的身体是对修改关闭的. 我喜欢这个例子. 所以, 对于核心模块或类的代码在需要扩展的时候不应该被修改. 你能结合具体例子解释下吗?
丈夫: 当然了, 先看下面的例子.这个就不支持 "开放-关闭" 原则:
类的层次结构已经表明了这是违反"开放-关闭"原则的.
你看, 客户端类和服务端类都是具体的实现类. 因为, 如果某些原因导致服务端实现改变了, 客户端也需要相应变化.
妻子: 有道理. 如果一个浏览器的实现和一个指定的服务器(比如IIS)紧紧的耦合在一起, 那么如果服务器由于某种原因替换成了另外的(比如, Apache) 浏览器也需要做相应的变化或者被替换掉. 多么恐怖的一件事啊!
丈夫: 非常正确. 因为下面的将是一种好的设计方案:
类的层次关系展示了"开放-关闭"原则
在这个例子中, 添加了一个抽象的Server类, 并且客户端保持了抽象类的引用, 具体的Server类实现了这个抽象Server类. 所以, 由于某种原因Server的实现类发生了改变, 客户端不需要做任何改变.
这里的抽象的Server类对修改关闭, 具体的Server实现类对扩展开放.
妻子: 我的理解是, 抽象是关键, 对吗?
丈夫: 是的, 基本上, 你要对系统的核心业务进行抽象, 如果你抽象化做的比较好, 很可能, 在扩展功能的时候它们不必做任何改变 (比如Server就是一个抽象的概念). 你所定义的抽象的实现 (比如, IIS服务器 实现了 Server) 和 抽象的代码 (Server) 要尽可能的多. 这样在客户端代码中不需要做任何修改就会允许你定义一个新的实现(比如, ApacheServer) .
主题: 里氏替换原则
丈夫: "里氏替换原则"听起来非常的复杂,但是设计思想却是非常基础的. 看下面这个有趣的海报
里氏替换原则海报
原则描述了:
"子类型必须能够替换它们的基类."
或者, 换句话说:
"使用基类引用的函数必须能够使用派生类而无须了解派生类."
妻子: 对不起, 这听起来让我觉得有点乱. 我认为这个是面向对象编程的基本原则. 这个叫做多态性, 对吧? 为什么面向对象设计原则需要考虑这个问题?
丈夫: 非常好的问题. 这有一些答案:
在基本的面向对象原则中, "继承" 通常被描述成 "is a" 的关系. 如果一个 "开发者" 是"软件专业人员", 那么 "开发者" 类 应该 继承 "软件开发人员" 类. 这样的 "Is a" 关系 在类设计阶段非常重要, 但是这也很容易让设计者得意忘形从而以一个糟糕的继承设计告终.
"里氏替换原则" 仅仅是一种确保继承被正确使用的手段.
妻子:我明白了。真有趣。
丈夫:是的,亲爱的,确实如此。让我们来看看一个例子:
类层次结构图展示的是一个Liskov替换原则的例子.因为 KingFisher类拓展(继承)了Bird类,因此继承了Fly()这个方法,这是非常不错的.
我们再来看看下面的例子
修正过的Liskov替换原则的类层次结构图
Ostrich(鸵鸟)是一种鸟(显然是),并继承了 Bird 类。但它能飞吗?不能,这个设计就违反了里氏替换原则。
因此,即使在现实中看上去没什么问题,在类设计中,Ostrich 都不应该继承 Bird 类,而应该从 Bird 中分出一个不会飞的类,由 Ostrich 继承。
妻子:好吧,明白了。我说说为什么里氏替换原则如此重要:
- 如果不遵循 LSP原则,类继承就会混乱。如果子类实例被作为参数传递给方法,后果难以预测。
- 如果不遵循 LSP原则,基于父类编写的单元测试代码将无法成功运行子类。
我说的对吗?
作者:完全正确,你可以设计一个对象并用LSP作为验证工具来测试该对象是否能够继承。
话题:接口隔离原则
作者:今天我们讲下“接口隔离原则”,看看下面这张海报
接口隔离原则海报
妻子:这是什么意思?
作者:它的意思是这样的:“用户不应该被迫依赖他们不使用的接口。”
妻子:解释一下。
作者:好吧,解释如下:
假设你想去买一台电视机并且有两种类型可以选择,其中一种有很多开关和按钮,但是多数对你来说用不到,另一种只有几个开关和按钮,并且看来你很熟悉怎么用。如果这两种电视机提供同样的功能,你会选择哪一种?
妻子:当然是第二种了。
作者:嗯,但是为什么呢?
妻子:因为我不需要看起来很麻烦而且对我也不必要的开关和按钮。
丈夫:正确。同样的,假如你有一些类,你通过接口暴露了类的功能,这样外部就能够知道类中可用的功能,客户端也可以根据接口来设计。当然那,如果接口太大,或是暴露的方法太多,从外部看也会很混乱。接口包含的方法太多也会降低可复用性, 这种包含无用方法的”胖接口“无疑会增加类的耦合。
这还会引起其他的问题。如果一个类视图实现接口,它需要实现接口中所有的方法,哪怕一点都用不到。所以,这样会增加系统复杂度,降低系统可维护性和稳定性。
接口隔离原则确保接口实现自己的职责,且清晰明确,易于理解,具有可复用性。
妻子:我明白了,你的意思是接口只应该包括必要的方法而不是所有的。
作者:是的,让我们看一个例子。
下面的接口是一个“胖接口”,这违反接口隔离原则:
违反接口隔离原则的接口示例
注意,IBird接口定义 Fly()的行为有许多鸟类的行为。现在,如果一只鸟类(比方说,鸵鸟)实现了这个接口,它将会实现不必要的Fly()的行为(鸵鸟不会飞)。
妻子:是啊。因此,这个接口必须被分割?
作者:是的,“胖接口”应该分隔成两个不同的接口,IBird 和IFlyingBird,而IFlyingBird继承于IBird。
接口隔离原则的例子中正确版本的接口
如果有一只不会飞的鸟(比如,驼鸟),只要用IBird接口即可,如果有一保会飞的鸟(比如,翠鸟),只要用IFlyingBird接口即可。
妻子:所以,回过头来看有很多按钮开关的电视的例子,制造商应该有电视机的图纸,开关和按钮也在这个方案里。若他们想造一台新款电视机时想要复用这张图纸,他们必须添加更多的按钮和开关,否则没法复用,对么?
丈夫:对。
妻子:若是他们真的想要复用这个方案,他们应该将电视机的图纸分为更小的部分,才能在以后制造新款电视机的时候复用这些设计方案。
丈夫:你理解了。
话题:依赖倒置原则
作者:这是SOLID原则中最后的原则。图示如下:
依赖倒置原则图示
它的意思是:
“高层次的模块不应该依赖于低层次的模块,而是,都应该依赖于抽象。”
作者:我们用一个现实的例子来理解。你的汽车是用很多部件组成,比如发动机,车轮,空调和其他的部件,是吧?
妻子:是啊,当然是这样。
丈夫:你看,它们并没有严格的构建在一个部件里;就是说,它们都是“插件”,要是引擎或着车轮出了问题,你可以单独修理它,甚至换一个用。
替换时,你只需要保证沉沦符合汽车的设计(汽车能使用任何1500CC的引擎或任何18寸的车轮)。
当然,你可以在1500CC 的位置上安装2000 CC的引擎,对某些制造商都一样(丰田汽车)。
可如果你的汽车部件不是“可拔插”的呢?
妻子:那太可怕了!这样的话,要是汽车引擎故障,你得整车修理,或者买一辆新车!
丈夫:是的,那么怎么做到"可插拔"呢?
妻子:关键是”抽象“,是吧?
丈夫:对。现实世界中,汽车是高层级的模块/实体,它依赖于底层级的模块/实体,例如引擎和轮子。
相较于直接依赖于实体的引擎或轮子,汽车应该依赖于抽象的引擎或轮子的规格,这样只要是符合这个抽象规格的引擎或轮子,都可以装到车里跑。
来看看下面的图:
依赖倒置原则的类层次结构
丈夫:注意上面的 Car类,它有两个属性,且都是抽象类型(接口)而非实体的。
引擎和车轮是可插拔的,这样汽车能接受任何实现了声明接口的对象,且 Car 类无需任何改动。
妻子:所以,如果代码不遵循依赖倒置,就有下面的风险:
- 使用低层级类会破环高层级代码;
- 当低层级的类变化时,需要太多时间和代价来修改高层级代码;
- 代码可复用性不高
丈夫:亲爱的,你说到点子上了!
总结
丈夫:除 SOLID 原则外还有很多别的面向对象原则。比如:
- “组合替代继承”:是说“用组合比用继承好”;
- “笛米特法则”:是说“类对其它类知道的越少越好”;
- “共同封闭原则”:是说“相关类应该一起打包”;
- “稳定抽象原则”:这是说"类越稳定,就越应该是抽象类";
妻子:我得学习这些原则吗?
丈夫:当然了。你可以在网上学习。Google 它,学习它,理解它。有问题就找我。
妻子:我听说还有些根据设计原则编写的设计模式。
丈夫:对的。设计模式不过就是针对一些经常出现的场景的一些通用的设计建议。主要的想法还是面向对象原则。你可以认为设计模式是“框架”,OOD 原则是“规范”。
妻子:那么之后我将学习设计模式是吧?
丈夫:是的,亲爱的。
妻子:应该会很有意思。
丈夫:必须地!
- 本文固定链接: https://zxbcw.cn/post/4462/
- 转载请注明:必须在正文中标注并保留原文链接
- QQ群: PHP高手阵营官方总群(344148542)
- QQ群: Yii2.0开发(304864863)