iOS代码设计中的开放与封闭

我们至今所写的 iOS 代码都是遵循 OOP 这种编程范式,以对象来临摹和表达我们对于世界的理解。在设计类的时候,恪守 SOLID 五个原则会让我们的代码更易拓展和维护。SOLID 中的 O 代表的是 Open/closed principle,这篇文章所要探讨的不仅仅是类设计中的 Open 和 Closed,而是要站在更广阔的视角来看待代码中的开放与封闭。

前言

我们作为代码工作者,不能仅仅满足于写出能运行的代码,还是注意时刻提高自身的姿势水平。具体来说,就是加强对于「内功心法」的学习,逐步提升写代码的抽象和设计能力。

程序员是理工教的一大分支,我们向来以严密的逻辑推导能力为立身之本,我们很容易发现文科生思维中存在的逻辑不连贯,不缜密,不严格,我们擅长以 if, else, for, switch 等精巧的关键字来阐述逻辑和流程。用代码来表达的流程看上去确实很酷,很科学,很真理,可在数学家眼里,我们大部分程序员所写的代码其实「漏洞百出」,和「严密」二字几乎不怎么沾边,看起来并不比文科生高明多少。问题出在哪呢?姿势水平还不够。

Open vs Closed

我们先以 Open/closed principle 为切入点,对于代码的开放和封闭来建立初步的印象。Wikipedia 定义如下:

In object-oriented programming, the open/closed principle states “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

这一原则要求我们设计的功能单元,对于功能拓展是开放的,而对于代码修改则是封闭的。不知道大家对于这种抽象的描述作何感想,Peak君初次看到的时候,脑中只感觉一团云雾缭绕,怎么能让马儿跑,又不用吃草?

这段玄之又玄的描述,体现到代码中后,不过就是一些日常所用的语言技巧了,我们可以从多个角度去理解和实现代码的开放性和封闭性。

继承,简单的继承关系就可以体现 open/closed principle,如果我们设计好一个父类,这个父类在设计之初就已经有了清晰完备的功能定义,并向天起誓以后绝不修改这个父类一行代码,那么我们可以说这个父类已经 closed 了。想要拓展功能怎么办?新建一个子类继承自这个父类,在子类中添加我们所需要的新功能,这样就做到了 open。一言蔽之,父类对于代码修改是封闭的,而对于子类的功能拓展是 open 的。实际工程中,多少人能忍得住不修改父类呢?

多态,多态配合接口使用也能体现 open/closed principle,我们在设计功能单元的时候,只定义接口,而不规定具体实现的细节。在类或者模块交付的时候,我们继续向天起誓以后绝不修改接口中的定义,那么接口就是 closed 了。但是后期我们可能需要修改具体实现的细节,需要拓展功能,于是我们替换一个实现了该接口的另一个类,这个新的类实现对于代码修改是 open 的。简而言之,接口对于代码修改是封闭的,实现对于代码修改是开放的。这也是为什么,我们在写 iOS 代码的时候,需要大量运用 protocol。

这么看来,开放和封闭的定义还是很清晰的,二者针对的对象不同就可以合理共存。不过我们为什么既要封闭又要开放呢?因为封闭的事物是静态的,稳定的,安全的,不写一行代码就不会有 bug 不是吗?可是我们所做的每一个工程都是处于变化的状态,每一个新 feature 都是为了迎合不断变化的市场需求,所以 open 是不可避免的,怎么办呢?让 open 与 closed 并存,让稳定的部分不变,在 closed 的代码基础之上去做拓展,去 open 新的代码。

Algebraic data types

聊完了我们熟悉的继承和多态,下面我们进入一个稍微陌生一些的领地:Algebraic data types。

Algebraic data types 是纯函数式编程语言 Haskell 中的一种类型定义,这是一个看上去简单,实际上令初学者极其费解的技术概念。之所以费解,是由于它主要应用在数据模型的定义,和我们平常写业务所用的 int, float 这种 data type 完全不是一回事。

Algebraic data types 可以简单的理解为一些 data type 的集合,这里的 data type 就是我们传统意义上的数据类型,比如 bool, int, double 等等,在这个 data type 的集合之上,Algebraic data types 提供一些特定的代数操作,可以对 data type 集合里的每个 data type 执行逻辑。代数操作通常为两种:sum 和 product。很抽象是不是?到底有什么用?我们对应到 iOS 中的代码来理解下。

比如我们日常所用的 BOOL 类型:

BOOL isValid = true; 
isValid = false;

isValid 的值要么是 true 要么是 false,是二选一的关系,所以 isValid 的值有两种可能性,即 true 和 false 相加,所以 BOOL 类型可以理解成一种 sum type。

再看 CGPoint :

struct CGPoint {
    CGFloat x;
    CGFloat y;
};

x 和 y 同时存在于 CGPoint 这个类型当中,不是二选一,而是一种类似于组合同时存在的关系,我们把 CGPoint 这种由两个子 type 所共同构成的 data type 称之为 product type。

你可能发现了,所谓的 sum type 和 product type 就是对 data type 集合中的元素进行 and 或者 or 操作,从而拼装出各种可能的组合。OOP 下的 data type(比如我们自定义的 class)强调的是对于 property 和 function 的封装,而 Algebraic data types 完全换了一个视角,看重的是 data type 的组合方式。当我们以递归的方式使用 Algebraic data types 来描述各种 data 的时候,就开启了一扇新世界的大门。

sum type 和 product type 都是 Algebraic data types。按照这种规则定义的 data type 到底有什么用处?好处有很多,其中之一和这篇文章的主题相关。Algebraic data types 有个重要的特性:Algebraic data types 对于自身 data type 集合中的每个 type 的处理是以穷举的方式,而且 data type 集合中的一旦定义好之后是不允许修改的,closed!这一点和我们在 OOP 下自定义的 Model Class 非常不同,Class 是允许被继承来拓展功能的,而 Algebraic data types 一旦定义好就已经 closed 了。

比如 isValid 如果定义包含 true 和 false 之后,是不允许添加 half-true 的,同时所有对于 isValid 的操作要穷举 true 和 false 两种可能性。

Algebraic data types 的 closed 和 exhaustive 特性可以让代码更加稳定,当然这种特性需要语言层面的支持,Objective C 并没有相关的特性,但我们可以在代码设计中借鉴其思想。

我们在平时写业务的时候,经常需要设计各种各样的 model 类。Facebook 在 2016 年开源了一个专门用来管理和生成 model 的 framework,叫做 Remodel。这个库功能强大而且全面,其中之一就是生成符合 Algebraic data types 特性的 model。以如下代码为例,描述的是一个具有多种类型的消息 model:

@interface MessageContent : NSObject <NSCopying, NSCoding>

+ (instancetype)imageWithPhoto:(Photo *)photo;

+ (instancetype)stickerWithStickerId:(NSInteger)stickerId;

+ (instancetype)textWithBody:(NSString *)body;

- (void)matchImage:(MessageContentImageMatchHandler)imageMatchHandler sticker:(MessageContentStickerMatchHandler)stickerMatchHandler text:(MessageContentTextMatchHandler)textMatchHandler;

@end

MessageContent 有三种可能的类型,image, sticker, text。MessageContent 提供的 match 方法以穷举的方式来处理所有可能的场景,对于 MessageContent 的使用者来说,一定不会漏处理任何一种可能性,强制 model 的使用者考虑所有的场景。

这种做法的好处是代码一旦生成就极其稳定可靠,不允许修改,closed。缺点也很明显,一旦业务要求我们增加一种新的 type,比如 MessageContent 为 voice 的语音消息,会难以下手,因为一旦修改就必须改变 match 方法签名,以穷举的方式新增一种 type 处理,代码的改动牵涉面必然很广。

所以你看,到底是设计成 closed 还是 open 的,其实是一次根据业务场景的取舍,在变与不变之间做权衡。这里介绍 Algebraic data types 目的在于说明,我们在做代码设计的时候,closed 和 exhaustive 的设计方式会让我们的代码更加可靠和稳定。

Optional in Swift

刚开始学习 Swift 的时候,不知道大家有没有好奇过为什么要引入 optional 这样一个新类型,optional 使用的场景也非常之多,有很多的文档去介绍在不同的语法下 optional 如何使用,可为什么要 optional 呢?和我们用 Objective C 时判断是否为 nil 有什么区别呢?

我们先看下面一段函数:

- (User*)getLuckyUser {
  //perform some calculation...
  return _user;
}

这段很常见的代码没有考虑一种场景,就是 _user 为 nil 的情况。你可能会说函数返回 nil ,函数的调用方自己去判断就可以了。当然如果返回 nil,在 Objective C 的 runtime 里,给 nil 对象发送消息也是安全的,这种安全只是表示不会 crash,但有可能原本应该执行的逻辑就没有继续下去了,从这一角度去看,nil 对象是对业务不安全的。而且我们把这种 nil 的 case 所造成的影响延迟到了 run time 。

更合理的做法是在编译时就考虑 nil 这种 case。optional 正是为此而生,如果我们定义返回值为 optional,那么 optional 的使用方就一定要考虑值不存在的场景,如果漏处理了为 nil 的场景,就会编译器报错,这样不光不会 crash,而且对业务逻辑来说也是安全的。

感觉灵敏的同学可能发现了,optional 类型和上面提到的 Algebraic data types 中的 sum type 非常相像,它表达的也是一种 or 的关系,即值要么存在,要么为 nil。当我们使用 Algebraic data types 来描述 data 的时候,语言本身会强制我们做 exhaustive checking,去考虑 data 的所有可能性。这是另一个 Swift 比 Objective C 更安全的有力证据,Swift 吸收了函数式编程语言中的很多优秀特性。

总结就是,当我们使用 optional 来写业务的时候,Swift 会强制我们去考虑 data 的各种可能性,这样写出来的函数,其逻辑就是完整的,全面的。

总结

还有不少能体现 open 和 closed 设计思想的例子,比如 java 中的 final 关键字,又比如设计模式中的 Visitor Pattern,大家也可以联想下类似的例子。我个人比较喜欢写这类随意遐想的文章,畅想不同技术概念之间在设计思想上的关联,加以总结和巩固。好啦,啰啰嗦嗦说了一堆抽象的概念,读到此处还没有放弃的朋友们辛苦了,为你们的耐心,干杯!

欢迎关注公众号:MrPeakTech