本篇是读《设计模式之禅》一书有感而写,之前对设计模式概念比较模糊,看一些开源库源码设计也只是看到些架势,通过学习后回过头细想,一些背后的设计思想才慢慢浮出水面。
书中虽是用 java 语言做的示例(插图),但是原理相通,iOS 童鞋也不必担心,基本能看懂,有些和 OC 差别较大的名词也做了注释,不影响对原则的理解!
本书包含:六大设计原则、23 种设计模式、各模式间 VS、 扩展篇等五大模块,建议先从设计原则入手,设计原则可看作为内功心法,而设计模式则作为武学招式,需内外兼修。对原则很好的理解,再去看 23 种模式时,会发现处处透露出这六大原则的身影!
本篇先说下六大原则:
单一职责(只有一个原因使类发生变更)
里氏替换(继承)
依赖倒置 (具体类、抽象类)
接口隔离(接口细化)
迪米特法则(知道的越少越好)
开闭原则(对扩展开放,对修改关闭)
1 单一职责原则
单一职责原则的英文名称是 Single Responsibility Principle,简称是 SRP。
定义:有且只有一个原因使类发生变更。
这个原则备受争议,主要争议之处在于对“职责”的定义。
1.1 什么是职责?
职责不可度量,因需求而异。
举个例子:
一个用户对象,其中包含有用户的信息和行为,这样的一个用户类接口如下:
这样代码有什么问题吗?满足一个对象的所有方法。
但是若另一种定义来说,UserInfo 类中包含用户的属性和行为, 两者任一的改动都会引起当前 userInfo 类的改动, 从严格上来看,并不符合单一职责原则。
IUserBo 负责属性: 只有用户属性修改才使当前类发生变化。
IUserBiz 负责行为:只有用户行为变化才使当前类发生变化。
符合我们所说的单一职责原则,这样说:职责定义不同,导致我们的业务模块拆分也不同。
1.2 如何分清职责?
在这里再扩展下定义:
单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事。
举个例子:
修改一个 IUserManager 类的接口:这个接口 change User 有很多原因使其更改, 比如: userName 、 HomeAddress 、 officeTel…等
如果修改这样呢:
每个接口职责分明,结构清晰。
单一职责原则有什么好处:
1)类的复杂性降低,实现什么职责都有清晰明确的定义;
2)可读性提高,复杂性降低,那当然可读性提高了;
3)可维护性提高,可读性提高,那当然更容易维护了;
4)变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个 5)接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
1.3 活用活用,不要教条主义
翻看很多开源的框架就类而言很少满足单一原则的,比如 userInfo 中拆分两个类,我们势必要维护两个相同的生命周期,另外完全按照单一原则来分类,可能划分出多个类来,人为的增加复杂性和维护成本,我们不奉行教条主义。
所以对于单一职责原则, 我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
注意: 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
2 里氏原则
定义: 所有引用基类的地方必须透明地使用其子类的对象。
通俗点讲:子类完全可以替代父类,反之不成立,主要为继承量身打造。
2.1 熟悉场景
面向对象语言,我们用过继承。它有如下优点:
1)代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
2)提高代码的重用性;
3)子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;
4)提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
5)提高产品或项目的开放性。
自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能挑出骨头来,继承的缺点如下:
1)继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
2)降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
3)增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
2.2 定义解读
里氏替换原则为良好的继承定制了一个规范,不要小看一句简单的定义,却包含了 4 层含义:
1)子类必须完全实现父类的方法
举个例子:
CS 游戏的枪支类图
抽象基类 AbstractGun 作为枪支,具备 shoot()射击功能,如果加入一种玩具枪(不具备射击功能)呢,要不要还继承这个抽象基类呢,继承好像也没问题,可以不调用或覆写 shoot(),使其不具备射击功能,但是不建议这样做,更合理方式类图:
注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
2)子类可以有自己的个性
子类可以有自己的方法和属性,相应子类可以胜任父类,但是父类不可以胜任子类。
3)覆盖或者实现父类的方法时输入参数被放大:
父类
子类
子类与父类方法名相同,参数范围不同,这不是覆写算作重载, 子类输入参数范围大于父类,子类调用 doSomething 会执行父类方法,如果我们想执行子类方法必须重写,符合我们想要方式,但是如果父类输入参数范围大于子类呢,请记住一句话:有父类的地方,子类应该完全胜任, 我们调用子类的方法,不走父类方法,不通过覆写方式,就实现了子类不走父类,这和我们常用逻辑不符,有可能我们实际场景中调用方法是想要走父类方法,但是效果走了子类方法。
4)覆写或实现父类的方法时返回结果可以被缩小
当父类返回一个类型 T,子类的相同方法(覆写或重载)返回值为 S,里氏替换原则要求 S 必须小于等于 T,也就是说要么 S 和 T 一个类型,要么 S 是 T 的子集,为什么,在默念这一句话:有父类的地方,子类应该完全胜任。
3 依赖倒置
原始定义是:High level modules should not depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstractions.
翻译过来,包含三层含义:
1)高层模块不应该依赖低层模块,两者都应该依赖其抽象;
2)抽象不应该依赖细节;
3)细节应该依赖抽象。
简单的例图:
一个司机开着一辆奔驰轿车,Driver 作为司机类,Benz 作为汽车类:
那如果司机在买一辆宝马呢?我们会重新创建个宝马类,依次增加吗? 他们具有抽象的共性,都是汽车,都能 run(), 此时我们应该建立抽象类 ICar, 奔驰和宝马性能不同车型不同,再由具体类来细化。
总结:
依赖倒置原则的本质就是通过抽象(接口(OC 中类似协议)或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合。可以遵循以下规则:
1)每个类尽量都有接口或抽象类,或者抽象类和接口两则兼备;
2)变量的表面类型尽量是接口或抽象类;
3)任何类都不应该是从具体类派生过来;
4)尽量不要覆写基类方法;
5)结合里氏替换原则使用。
4 接口隔离
定义: 客户端不应该依赖他不需要的接口。
是不是有点不好理解,来看它的第二种定义: 类间的依赖关系应该建立在最小的接口上。
4.1 那什么是接口?
这里接口可以理解为一个实例类对象的实例接口和类接口,Java 开发的是不是有点疑惑,换个角度来看,Java 中的类也是一种接口,这话是作者说的不是我说的,注意砖头方位,作为 OC 开发更容易接受这个概念。
4.2 如何是最小接口呢?
1)接口尽量小
就是和上文提到的单一职责像呼应,尽量职责单一。
2)接口高内聚
比如你的领导让你做一件事,这件事可能要分 a、b、c 完成,你只需要汇报领导任务完成,领导不需要知道过程细节,结合单一职责来说 a、b、c 应该划分单独事件,但是对外只需要提供完成任务接口就行;高内聚,尽量少暴露公开;对于属性对外只读,决不给读写权限,公开方法也是,减少外部影响,接口是对外的承诺,承诺越少对系统开发越有利。
3)模块定制
为个别业务提供定制接口,减少对全部接口的访问。
举个例子:
一个图书馆内查询系统,我们提供类具有按照作者、标题、出版社、等分类查询和混合查询方式。
这是其他业务方不清楚使用,每次查询都适用混合查询方式,导致系统速度异常慢,做个隔离,为业务方提供定制接口 。
4)接口设计有限度
接口的设计粒度越小,系统越灵活,这是不争的事实,但是灵活也带来结构的复杂化,开发难度增加,可维护性降低,所以接口设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。
5 迪米特法则
定义:也为最少知道原则,一个对象应该对其他对象有最少的了解。
通俗讲:一个类应该对自己需要耦合或调用的类的内部知道的最少,被调用的类内部如何复杂都和我没关系,我就知道你提供的这么多 Public 方法,我就关心调用这么多,其他一概不关心。
其中主要包含 3 层含义:
1)只有朋友交流
最少朋友思维,不必要和朋友的朋友都认识,只要认识你这个朋友,你的朋友会找到他的朋友帮你办好事。
只对自己必然要联系对象进行关联,不必要的对象减少耦合,不应和过多对象建立关系,如果过多就该考虑如何分出管理了,“尽量做到满身筋骨,而不是肥嘟嘟!”
2)朋友间应该保持适当距离
即使关联类之间,也应该保持相应“距离”, 不能无所不知,不需要完全暴露所有细节,这就是前面说的高内聚,只提供公共方法,具体实现对外不需要暴露,
注意: 迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的 public 方法和非静态的 public 变量,尽量内敛,多使用 private 访问权限。
3)自己的还是自己
在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错,那怎么去衡量呢?你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。
总结: 迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。
6 开闭原则(重中之重)
核心重点终于来了,就是开闭原则,哲学上说矛盾法则是唯物辩证的最根本法则,那开闭原则,可以作为最基础的设计原则。
定义:Software entities like classes,modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)
我最喜欢这一句话:对修改关闭,对扩展开放。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
举个例子:以一个书店销售小说书籍为例,IBook 作为抽象类,定义了数据的三个属性:姓名 、价格、作者,NovelBook 为具体子类, 获取小说书信息:
若此时书店搞活动打折处理,我们怎么修改上面类呢,我们会早 NovelBook 类 getPrice()中加入打折信息,其实不建议,想到那句话: 对修改关闭,对扩展开放, 符合开闭原则来说,我们对原有 NovelBook 类不应进行修改,而是扩展一个打折 OffnoveBook 子类, 结构如下:
如果后期加入其他书籍销售呢,比如计算机类书籍,我们也是只需要在原有基础上扩展新的子类即可,结构如下:
总结:开闭原则是个很抽象感念,也是很虚的概念,它定义简单,但又不简单,我们在一直拥抱变化,试想如何让其保持遵循开闭原则。
使用开闭原则需要注意什么:
1)开闭只是一个原则,口号实现拥抱变化的方法很多,前提条件是:类必须做到高内聚,低耦合,这样拥抱变化时减少不可预料故障。
2)项目规章非常重要, 有个稳定的规章,也是所有成员必须遵守的约定,能给我们带来非常多的好处,如提高开发效率,降低缺陷率,减少维护成本等。
3)预知变化
在实战过程中,架构师或项目经理一旦发现有发生变化的可能,则需要考虑现有架构能否轻松适用这一变化。架构师设计的一套系统不仅要符合现有的需求,也要适应可能发生的变化。
开闭原则是一个终极目标,任何人包括大师级人物都无法百分之百做到,但朝这个方向努力,可以非常显著地改善一个系统的架构,真正做到“拥抱变化”。
7 总结
软件设计痛苦之处在于应对需求的变化,但是需求有不可预估,要求我们为不可预估的需求做好准备。
大师们总结的 6 大设计原则和 23 种设计模式,是来帮助我们“拥抱”未来的变化, 但它不是教条主义,成为限制我们的边框。灵活使用,因地而异,有可能几大原则之间会存在场景冲突的时候,我们应有自己理解使用!
作者介绍:
方丈山(企业代号名),目前负责贝壳找房装修平台移动端 iOS 研发工作。
本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。
原文链接:
https://mp.weixin.qq.com/s/7NTLIDkB6_rmhrp2xm1z4Q
评论