面向对象设计原则『SOLID』在开发中的应用

本文详细分析了面向对象设计五大原则 S(单一职责原则『SRP』)、O(开放-封闭原则 『OCP』)、L(Liskov 替换原则『LSP』)、I(接口隔离原则『ISP』)、D(依赖倒置原则『DIP』),并假以实例辅之。

©原创文章,转载请注明出处!

Overview


软件设计五大原则『SOLID』以及23种经典设计模式自成型以来已有些年头,目前在实际开发中对待它们有两种较为极端的态度:敬而远之、嗤之以鼻。
显然,笔者用了『极端』二字表明并不赞同这样的观点。
SOLID 以及经典设计模式是前人在长期的软件开发中总结出来的宝贵实践经验,值得我们学习和借鉴。当然,这并不意味着我们要时刻把它们挂在嘴边,以彰显我们的『内力』,也并不意味着就要把它们作为『最高律令』、『不可逾越的红线』早早地就套用在软件开发的过程中(这无疑将增加开发的复杂性)。
应将其作为解决问题的方案。
此时,有必要再谈谈『敏捷开发』:
在移动互联网时代大家都是以『小步快跑、快速迭代、快速试错』的节奏与时间赛跑、抢占流量。『敏捷开发』因而被时常提及,遗憾的是其大多数时候也仅是停留在嘴边。
在移动互联网时代,笔者认为敏捷开发的核心有两点:

  • 不做过度设计,始终尽力保持代码简洁、易理解、好维护(不用一开始就套用各种原则、设计模式,徒增复杂);
  • 拥抱变化,无论是因需求还是其他原因引起变化导致现有代码结构不能满足需要时,要积极地对代码进行重构,始终保持良好的代码结构,对代码的腐朽保持零容忍(出现问题后可借鉴 SOLID、设计模式等去解决问题)。

从上述两点可以看出:敏捷开发是一个持续的过程,而非一个心血来潮的事件。

ps:重构不一定是翻天覆地的大改,重命名变量、分解复杂方法等等都是重构。

本文将以 SOLID 五大设计原则为主线,辅以设计模式为解决方案,谈谈 QQ 阅读、iOS 系统 API 在代码设计上的得失(失主要是对 QQ 阅读个别代码的反思)。

单一职责原则『SRP』


SRP 非常好理解,与『内聚性』表达的是同样的关注点。
SRP 在 SOLID 五大原则中可以说是最简单、最基础的原则。然而在实际开发中,对 SRP 的把握又是最难的。单一职责,到底什么是职责?单一的粒度如何?总之不好把握,就像生活中的各种适量『煮饭时适量加点水、做菜时适量放点盐』(经常让人抓狂 v_v)。
Bob 大叔在《敏捷软件开发》一书中将职责定义为:变化的原因,单一职责即为:仅有一个引起实体(模块、类、方法等)变化的原因。在把握单一职责时,这不失为一个很好的抓手,通过观察、思考设计的实体是否有一个以上的变化原因来判断其职责是否单一。

后文为叙述方便,如无特别说明,实体指模块、类、方法等功能代码块。

笔者认为 SRP 作为最基础的设计原则,主要有两点收益:

  • 降低实体的复杂度,提升可维护性;
  • 提高实体的可复用性,当一个实体中耦合了多个职责时,其可复用性必然受到影响。即使多处复用了,其中一个职责的变化对复用其他职责的实体也会造成意想不到的影响,这不是我们想看到的。这也是 Bob 大叔将职责定义为『变化』的原因。

例1 UIView 与 CALayer

在 UIView 的层级结构中,我们知道每个 View 背后都有一个 CALayer 与之对应。
其中,UIView 的主要职责是处理用户交互,CALayer 则是布局、渲染以及动画等。
Apple 之所以要设计 UIView 与 CALayer 两套体系,就是为了使它们的职责更加单一,能更好的复用。
在 iOS 与 Mac OS 上,用户交互处理方式有本质的区别,然而在布局、渲染、动画等方面又是一致的。因此,通过将上述职责分离,CALayer 可以很好地在 iOS 与 Mac OS 间复用,而用户交互的处理则各自独立,于是有了 UIKit、AppKit。

例2 View 与 ViewModel

例1中的 View 与 Layer 属于系统实现层面,在应用层面 UIView 的职责是明确的、单一的:UI 布局。然而在实际开发中有大量展示相关的业务逻辑写到了 View 里面,严重影响了 View 的可复用性。究其原因,在非 MVVM 模式下,展示逻辑只能放在 Controller 中,势必造成 Controller 过于臃肿。于是,在 QQ 阅读中我们提出以 View-ViewModel 模式构建 UI 组件,将展示逻辑放到 ViewModel 中,View 仅处理布局逻辑。目前看效果良好,View 的逻辑更加清晰、可复用性得到很大提高。详细信息请参看『自定义 UI 组件库』一文。

开放-封闭原则 『OCP』


『唯有变化才是永恒』,对于软件开发来说更是如此,一个模块、类、方法等实体几乎不可能在第一个版本开发出来后就一直保持不变。因此,变化是开发人员必须要面对的问题(可谓爱之恨之)。
OCP 就是用于指导我们如何应对变化。
OCP 的含义是:『对扩展开放,对修改封闭』。
具体说,实体的功能可以不断扩展(变化),但实体的源码不允许修改。
看似十分矛盾!就像『东西可以随便买,但钱不允许花』。
仔细分析,OCP 的重点是扩展新功能,也就是扩展新功能时可以添加新代码,但不能修改已有代码。因为对已有代码的修改带来的影响是难于预料的,如果修改导致链锁反应,后果更是灾难性的。
如何做到?
关键在抽象
『面向接口编程,而非实现编程』这是我们经常挂在嘴边的话。
面向接口编程,也就是说依赖的是抽象接口,为的就是可以灵活的替换接口背后的实现。这不正是 OCP 需要的吗!

实现方案

在23种经典设计模式中『Template Method 模式』以及『Strategy 模式』都可以很好地实现 OCP,其中 Template Method 模式的实现依赖于继承,Strategy 模式使用的委托(接口)。

Template Method 模式


Template Method 模式类图如上图所示(来自 GoF 的《Design patterns》)。
Template Method 模式在抽象基类中定义 TemplateMethod方法,但该方法并不做实际工作,只是调用其它方法(PrimitiveOperation...,C++中须是虚函数)来完成具体的工作。
可见,TemplateMethod方法只是定义了一个任务或算法的骨架、执行步骤。
因此,可以通过派生新的子类,并实现PrimitiveOperation...方法来扩展功能。

Strategy 模式


Strategy 模式类图如上图所示(来自 GoF 的《Design patterns》)。
Strategy 模式是典型的面向接口编程,通过接口使得业务层(使用方)与实现细节完全解耦,从而可以很方便地通过扩展实现来扩展新功能,而无须对业务层进行修改。
纵观 Template Method 与 Strategy 模式,前者通过继承并重写方法(C++中的虚函数)来扩展新功能,后者通过新增实现了特定接口的类开添加新功能。
两者无谓优劣,不同的场景使用不同的方案。但是,继承会增加复杂度,这是共识,在使用 Template Method 模式时需要考虑到这点。

例1 QQ 阅读登录模块

QQ 阅读起初只有 QQ 一种登录方式,突然有一天 Apple 爸爸说不得强制用户必须登录才能使用 App。无奈之下,我们添加了游客登录模式。

上图就是增加游客登录后的结构简图。QQ 登录、游客登录看似相安无事。
但,众多业务模块直接与两种登录方式交互,严重破坏了 OCP。
后果如何?
后果是严重的!后面如果要增加其他登录方式,所有与登录态有关的模块全都要改一遍!

问题出在哪里?笔者认为最初业务层直接与 QQ 登录交互并无大碍,关键是在添加游客登录时需要察觉到其中的问题,并立即做出重构,而不是在现有代码基础上糊乱堆叠代码。

果不其然,没多久产品要求添加微信登录。于是趁机对登录做了一次彻底的重构。

重构过程中,我们添加了『鉴权中心』模块QRAuthenticatonCenter统一处理登录相关的问题,同时使用了 Strategy 模式将各种登录方式的实现细节与QRAuthenticatonCenter以及业务层隔离开来。
不久之后,我们又添加了起点登录、QQ 登录也由原来腾讯内部的 Wlogin 登录方式切换到统一互联登录。
针对这两个变动,业务层无任何修改,QRAuthenticatonCenter也只是添加了初始化QRYWAuthenticatorQROpenQQAuthenticator的代码。变动的主要工作就是按照QRAuthenticatorDelegate接口分别去实现QRYWAuthenticator以及QROpenQQAuthenticator

通过 Abstract Factory 模式,可以使得在添加新登录方式时QRAuthenticatonCenter也无需修改,但笔者认为在该场景下其带来的收益不足以弥补其复杂性,即弊大于利,故弃之。

上述可见,通过 Strategy 模式重构后的登录模块实现了 OCP,也在后续迭代变更过程中充分享受了其带来的收益。

例2 QQ 阅读引擎模块

QQ 阅读的 txt 引擎是整个工程里面最核心,也是最古老的一个模块。
起初,引擎里面有两种类型的段落:文字、空段落,并通过一个int型变量type加以表示。
随着迭代,越来越多非内容本身的交互性元素加入阅读页,如:作者的话、大神说等等。目前type的值已扩展到十五、六类之多,每添加一种新类型都要在最核心的引擎里面修改一、二十处,可谓如覆薄冰。
这就是一个严重违反 OCP,并产生严重后果的例子。
找到了问题所在,重构方案也就变得明了:通过 Strategy 模式,将每种类型段落的逻辑抽取成一个类,并遵守相同的接口,txt 引擎依赖抽象接口,使之遵守 OCP。

Liskov 替换原则『LSP』


LSP:子类型必须能够替换其基类型。
直白点,就是任何使用基类类型的地方(如调用方法时的入参)都能替换成其子类类型,而不会出现意想不到的错误。
看完 LSP 的定义,不禁要问:其有何用?
为了回答这个问题,不防从反面思考一下:若不遵守 LSP 如何?
以方法参数为例:若方法 M 有一个类型为类 B 的参数,如果类 B 的子类没有遵守 LSP,在调用方法 M 时传入了一个类 B 的子类,M 会出错。此时,为了不出错,方法 M 势必要对 B 的子类作特殊处理(if...else...)。
熟悉的味道!这是不是违反了 OCP!

引自《敏捷软件开发》:对于 LSP 的违反往往会导致以明显违反 OCP 的方式使用运行时类型识别『RTTI』。

例1 正方形与长方形

Bob 大叔在《敏捷软件开发》中有一个关于正方形和长方形的例子。
『正方形是一种特殊的长方形』,这可谓是常识。因此,让正方形类Square继承自长方形类Rectangle再合理不过。
然而在对待长度、宽度上,正方形与长方形似乎不那么一致:
正方形的长、宽必须相等,因此Square类必须重写其基类RectanglesetWidthsetHeight方法来保证每次调用这两个方法后正方形的长宽依然相等。这看上去似乎也并无不妥,然而在下面这个方法中就有问题了:

1
2
3
4
5
void g(Rectangle &r) {
r.setWidth(5);
r.setHeight(4);
assert(r.area() == 20);
}

函数g对于长方形的认知完全正确,然而若调用函数g时传入的是个Square类型的引用,就出错了!
很明显,SquareRectangle间的继承关系违反了 LSP。

引自《敏捷软件开发》:LSP 让我们得出一个非常重要的结论:一个模型,如果孤立地看,并不具有真正意义上的有效性。模型的有效性只能通过它的客户程序来表现。在考虑一个特定设计是否恰当时,不能完全孤立地来看这个解决方案。必须要根据该设计的使用者所做出的合理假设来审视它。
因此,是否违反 LSP,在很大程度上取决于客户程序。

SquareRectangle间的继承之所以会违反 LSP,是因为在设置长、宽的行为上它们间不具备”IS-A”关系。

引自《敏捷软件开发》:从行为方式的角度来看,Square不是Rectangle,对象的行为方式才是软件真正所关注的问题。LSP 清楚地指出,OOD 中 IS-A 关系是就行为方式而言的,行为方式是可以进行合理假设的,是客户程序所依赖的。

LSP 与多态

讨论 LSP 的前提就是多态,否则无从谈起。
然而,多态本质上就是子类的方法覆盖基类的虚函数。这与 LSP 要求的子类可以替换基类是否矛盾?因为通过基类指针最终调用的是子类的方法。
答案自是不矛盾,相反 LSP 能够更好地指导我们如何使用继承。
为了满足 LSP,子类只能对基类的功能进行扩展,而不能『篡改』。
这不正是『继承』的本质内涵吗!

因此,LSP至少有三点作用:

  • 实现 OCP 的重要保障之一;
  • 降低继承带来的复杂度,继承只能扩展基类的功能,而非『篡改』(可以无差别的对待基类及其所有子类);
  • 在决定使用继承前,可以更好地判别两者是否真具有”IS-A”的关系。

启发式判断规则与改进方案

LSP 有时是很微妙的,在开发过程中往往难于察觉。
Bob 大叔提出两个启发式规则供大家参考:

  • 派生类存在退化函数,如下述代码基类Base中的方法f是有功能的,但到其子类Derivedf退化为空方法,这往往预示违反了 LSP,值得警惕:

    1
    2
    3
    4
    5
    6
    7
    public class Base {
    public void f() { /*some code*/ }
    }

    public calss Derived : Base {
    public void f() {}
    }
  • 从派生类中抛出异常,即从派生类的方法中抛出了基类不会抛出的异常,这往往是调用方不曾预料的。

违反 LSP 说明继承已经不适合了,此时可以将这对『父子』中公共的代码提取出来。
之后要么让他们成为『兄弟』,都从提取的代码派生、要么以组合的方式集成提取的代码。

接口隔离原则『ISP』


ISP:不应迫使客户程序依赖于它们不需要的接口。即,客户程序依赖的类中不应该含有其不需要的方法,从而降低系统的复杂度,减少类之间的耦合。
相反,若某客户程序依赖的类含有大量其不需要的方法,而这些方法又是其他客户程序所需的,当这些方法因需求需要变化时或需要添加新方法时,势必会殃及不需要这些方法的客户程序,从而增加系统的耦合度。
怎么解决?
当然是『隔离、拆分』接口了!
在支持接口/协议的语言(如Objective-C)中,很好处理,将类的公共方法分解到多个接口中;
而在像 C++ 这样不支持接口的语言中,可通过多继承、委托等方式分解接口。

例1 UITableView 之 DataSource、Delegate

iOS 开发对 UITableView 恐是再熟悉不过了,其提供了两套接口:UITableViewDataSourceUITableViewDelegate
从场景上说,这两套接口都是为 UITableView 提供服务的。
之所以要把它们分开,就是为了可以将为 UITableView 提供数据、处理用户交互的职责拆分到不同的类中。

例2 QQ 阅读登录接口

在『OCP』一节,简要介绍了 QQ 阅读的登录模块,我们知道具体的登录细节由QRQQAuthenticatorQRWechatAuthenticator以及QRGuestAuthenticator等处理。这些Authenticator都实现了QRAuthenticatorDelegate接口:

1
2
3
4
5
6
7
8
9
@protocol QRAuthenticatorDelegate <NSObject>

// 主动登录
- (void)authenticateWithCompletion:(QRAuthenticateCompletion)completion;

// 续期
- (void)refreshTokenWithCompletion:(QRAuthenticateCompletion)completion;
...
@end

然而,对于 QQ 登录,在没有安装 QQ 时,需要QRQQAuthenticator作特殊处理。
由于这样的特殊处理只是 QQ 登录需要,因此把对应的接口放到QRAuthenticatorDelegate中是不合适的。
最终,我们将其定义为独立的接口QRQQManuallyAuthenticationDelegate

1
2
3
4
5
6
@protocol QRQQManuallyAuthenticationDelegate <NSObject>

- (void)manuallyAuthenticateWithAccount:(Account *)account;
- (void)checkVerifyCode:(NSString *)verifyCode account:(Account *)account;

@end

并让QRQQAuthenticator实现这两个接口:

1
2
@interface QRQQAuthenticator : NSObject<QRAuthenticatorDelegate, QRQQManuallyAuthenticationDelegate>
@end

QRWechatAuthenticatorQRGuestAuthenticator等只需实现QRAuthenticatorDelegate即可。

对于 ISP 大家可能会有疑问:根据 SRP,类的职责应该是单一的,为何需要实现多个接口?
在现实中,确实存在从接口层面内聚性较低的类。如,例2中的QRQQAuthenticator类,正常的登录、续期需要处理,手动登录同样需要处理,在接口上就不具备高内聚的特征。
ISP 就是用于在此情况下指导如何拆分接口。

依赖倒置原则『DIP』


在开发中,较大的模块一般会由几位同学协同开发,分工一般会按分层的方式进行。
此时,经常会听到负责低层模块的同学向负责高层模块的同学说:『我给你提供了这这几个方法,代码已提交,你看一下。』
从 DIP 的角度看,犯了两个错误!
其一,在制定双方接口上低层模块起了主导作用;其二,两者间缺少抽象。
DIP:

  • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象;
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

依赖倒置原则其中的『倒置』强调的就是高层模块与低层模块间的关系:高层模块作为需求方提出需求(提出接口),低层模块去实现高层模块提出的需求(接口)。
为何?

  • 高层模块不应知道低层模块的细节;
  • 若是由低层模块制定接口,很可能不由自主地将实现细节曝露在接口中,这是我们不希望看到的。

例1 分页加载

列表类应用场景模板化一文中,我们提到『大多数 App 的大多数应用场景都是列表类的』,分页加载是列表类应用场景的标配。
那么在制定接口时,若由低层模块(Model)负责,很可能会将分页的细节曝露在接口中:

1
- (void)requestMoreDataWithPageStamp:(NSInteger)pageStamp completion:(void (^)(NSError *, id))completion;

很明显,pageStamp是 Model 与服务端交互的细节,是高层模块不关心,也不应关心的问题。
若是由高层模块(Controller)提出需求(接口),接口可能会是这样:

1
- (void)requestMoreDataWithCompletion:(void (^)(NSError *, id))completion;

当然,这个例子较简单,稍有经验的开发人员也不会在接口中曝露pageStamp信息。
但,由低层模块制定接口会曝露细节的问题值得关注。

例2 通过抽象解耦高、低层模块

同时,DIP 提出高层模块与低层模块不能直接有依赖关系,它们都应依赖于抽象(接口)。
如此可使得高层模块与低层模块解耦,促使高层模块具有更好的可复用性。

上图是在『列表类应用场景模板化』一文中介绍的列表类模块的类图。
其中,Controller 与 Manager 、Controller 与 Module 间都是面向接口编程(依赖于抽象)。
在 QQ 阅读中,书籍分为 txt 和精排两种格式,它们都支持批量下载。在展示、用户交互上两者并无太大区别,但背后的业务逻辑却大不相同。
因此,批量下载的 Controller 可以复用,但 Manager 不可。
通过 DIP 可以很方便的隔离 Controller 与 Manager,使批量下载的 Controller 在两种格式间复用。

DIP 可以说是 SOLID 中实现成本最小的原则,但其带来的收益却十分可观,因此,DIP 应该是我们自始至终都应遵守的原则。

小结


综观 S、O、L、I、D 五大原则,本质上它们都是帮助我们降低软件系统的复杂度。只不过,各自关注的维度不同:

  • SRP:要求软件实体(模块、类、方法)只有单一的职责,降低实体的复杂度,提高实体的内聚性;
  • OCP:要求软件实体对扩展开放、对修改封闭,使得软件系统在扩展功能时,减少对系统已有部分的影响;
  • LSP:对继承关系提出要求,子类须可替换基类,降低继承带来的复杂度以及减少误用继承的可能;
  • ISP:将复杂接口拆分开来,避免强迫高层模块依赖于其不需要的接口,减少不必要的耦合;
  • DIP:避免由低层模块制定接口时无意曝露低层细节,通过抽象解耦高层与低层模块。

对于 SOLID 以及其他的各种设计原则、模式,无须天天挂在嘴边,而是在遇到问题时,能通过它们解决问题。

参考资料:
《敏捷软件开发——原则、模式与实践》
《Design Patterns: Elements of Reusable Object-Oriented Software》
《重构——改善既有代码的设计》