本篇文章翻译自:IF YOU'RE SUBCLASSING, YOU'RE DOING IT WRONG.
原作者:Hector Matos
原发表日期:2015-07-13
Swift的核心
我们可以通过等式的传递性来理解swift:
- Swift的核心是面向协议的编程。
- 面向协议的编程的核心是抽象(abstraction)和简化(simplicity)。
- 所以swift的核心就是抽象和简化。
你可能对我的标题感到诧异。我并不是说子类没有价值,尤其在使用单一继承(single inheritance)的情况下,类和子类当然是强有力的工具。然而我想说的是,iOS日常开发的问题是对类和继承的过度使用。作为面向对象的编程者(object-oriented programmer,后面统一替换为OOP编程者;object-oriented programming后面统一简写为OOP)我们总是会自然的倾向于使用引用类型和类去解决问题,但是我个人还是认为应该反过来,倾向于用值类型代替引用类型。我们还是要去写模块化的,可伸缩的并且可重用的代码,这一点不会变。swift中强大的值类型就可以帮我们实现此目的,且不需要对引用类型有过强的依赖。我认为不仅面向协议的编程(protocol oriented programming,后统一替换为POP)可以帮我们实现这点,另外2种编程类型也可以,且都具有抽象和简化的核心思想,这两种分别是:面向值的编程(value-oriented programming,后面统一替换成VOP)和函数式编程(functional programming)。
先说清楚,我绝不是这些种编程类型(POP,VOP和函数式编程)的专家。和你一样,从MMM时代(manual memory management - 手动内存管理)开始我就是一个OOP编程者。通过自学,从开始我就很重视值抽象(value abstraction)和简化的思想。我都没有意识到自己是一个倾向于函数式编程(functional programming)的OOP编程者,而且很多时候用的都是VOP和POP的思路。这可能是我为什么在第一天就兴高采烈的加入了swift的浪潮之中的原因。在WWDC的一整周里,swift的核心理念与我认为的该怎样去编程是如此之契合,这个感受一直充斥在我脑海中。通过这篇文章,我希望能帮助你(OOP的编程者)打开思路,去考虑该如何用更加Non-OOP(非OOP)的方式去解决问题。
OOP的问题(和我不得不去学它的原因)
我会是第一个跳出来说的:不用OOP的话做出iOS应用很难。Cocoa的核心就是OOP。没有OOP的话你根本写不出来一个iOS应用。有时候我会幻想这不是真的。如果你有不同观点,赶快证明我是错的吧。我真的需要这样,求你了,证明我是错的吧!
不管怎么样,你总会遇到必须用对象、用引用类型解决问题的时候,然后由于Cocoa的规定而被迫使用类(classes)。这种情况下你碰到的问题都是我们大家熟知并热爱的:
- 传递class的实例这个做法好像总是有种不可思议的能力:你想用一个实例的时候,让这个实例的状态(state)和你所期望的不一样。(这是由于可变状态(mutable state)导致,你这个对象的另一个享有者在它觉得合理的时候能够改变此对象的属性。)
- 如果不用多继承的话,从一个很棒的class派生出子类从而获得它的扩展功能妨碍了你使用另外一些很棒的class的更多更能,而且还增加了复杂性。(举例来说,试着去把2个
UITextField
的子类结合起来,生成一个拥有这2者特性的超级UITextField
吧。) - 上面一条的另外一个问题是会引出意外行为(unexpected behavior)。如果你遇见了类似上面一条所描述的情况,你就陷入到了一个依赖问题中:你连接了2个superclass各自的特性,对于其中一个superclass的一处改动可能会给另外一个superclass带来不良影响。这就是被周知的class之间紧耦合(tight coupling)所带来的问题。
- 单元测试中的mocking。有些classes在系统中的环境状态下耦合过于紧密,想完全测试这些classes就需要你创建每个class的假表象。我都不用告诉你本质上你并没有真正的测试了这个class,你不过是在假装测试它。这里就不提很多Mocking的库是用运行时的小把戏来造一个假的class了。
- 并发(Concurrency)问题。这和上面提到的可变状态是伴随出现的。你从多个线程中同时改变一个引用就会引起这个问题,在运行时使对象之间的同步发生异常,这点也真的不用和你说了。
-
很容易导致出现像上帝类(God classes - 承担着很多subclasses需要的重要高层级代码的所有责任),Blobs(有过多职权的classes),Lava Flow(因为含有太多的非法代码导致任何人都不敢碰的classes)等等这些种反面模式(anti patterns)。
POP 面向协议的编程
陷入OOP的反面模式特别容易。多半时间我们(包括我)就是太懒而不愿意去点File>New File。结果是在现有class的基础上添加一个函数是如此轻松,我们就不愿意从零开始建一个新的class了。如果你一直这么干,而且一直非常懒的从一个"很重要"的class派生subclass的话,你就把上帝类/死星类给弄出来了。实际上我之前就这么干过:我给一个app里的每个view Controller
都加了能呈现一个指向navigationController
的navigationBar
的error view的功能。唉,我可真蠢。直到要改动那个Error上帝类行为的时候,我不得不把整个app都改一遍。这不是聪明的做法,你真应该看看那些bug。
如果使用了 POP,这个Error上帝类很大程度上就能很容易的抽象出来,以后改进它也方便。(顺便说下如果你想学POP,我极力推荐你去看 这个视频)。想想就会觉得好笑,因为在这个视频中Apple自己都说:
"从一个protocol开始,别从class开始。"
Dave Abrahams: 毁你三观教授
这是一个能展示(之前的方式)有多残暴的例子:
class PresentErrorViewController: UIViewController {
var errorViewIsShowing: Bool = false
func presentError(message: String = “Error!", withArrow shouldShowArrow: Bool = false, backgroundColor: UIColor = ColorSalmon, withSize size: CGSize = CGSizeZero, canDismissByTappingAnywhere canDismiss: Bool = true) {
//写下了复杂的,脆弱的代码
}
}
//说一下,有100个class继承了这个class
EveryViewControllerInApp: PresentErrorViewController {}
随着项目的进行事情马上变的明了:并不是每一个UIViewController
需要这个error逻辑,或是真的需要这个class所提供的每一个功能。我团队里任何一个人都可以轻易的在这个superclass里改点儿什么,从而影响整个app。这就让代码变得脆弱。还使得代码呈现出了多态。当本应该是由子类决定它自己的行为,这里的superclass却给帮着决定了。下面是在swift 2.0中我们如何用POP来更好的构建这段代码:
protocol ErrorPopoverRenderer {
func presentError(message: String, withArrow shouldShowArrow: Bool, backgroundColor: UIColor, withSize size: CGSize, canDismissByTappingAnywhere canDismiss: Bool)
}
extension UIViewController: ErrorPopoverRenderer { //使所有遵从于ErrorPopoverRenderer协议的UIViewController具有一个presentError的默认实现
func presentError(message: String, withArrow shouldShowArrow: Bool, backgroundColor: UIColor, withSize size: CGSize, canDismissByTappingAnywhere canDismiss: Bool) {
//加上呈现error视图的默认实现
}
}
class KrakenViewController: UIViewController, ErrorPopoverRenderer { //Drop the God class and make KrakenViewController conform to the new ErrorPopoverRenderer Protocol.
func methodThatHasAnError() {
//…
//抛出error,原因是Kraken海妖今天吃人会感到不适。
presentError(/*blah blah blah 好多参数*/)
}
}
看,这里发生了很炫酷的事情。我们不仅消除了上帝类的存在,还让代码更加的模块化并增强了它的扩展性。通过创建一个 ErrorPopoverRenderer
协议,就会让任何遵循了该协议的class具有呈现出一个ErrorView
的能力。还不止这些,我们的KrakenViewController
class不用必须实现presentError
这个函数,因为我们扩展了UIViewController
,让它提供了一个默认实现。
唉不过等下!这有个问题!我们每次想要呈现一个ErrorView的时候都必须要去实现每一个参数。这就有点儿让人不爽了,因为我们不能在protocol协议函数声明中为参数提供默认值。
我还挺喜欢这些参数的!更糟的是在让代码更具模块化特征的过程中我们引入了复杂度。还是继续吧,用swift 2.0中新加的一个小妙招来多少的补偿一下:
protocol ErrorPopoverRenderer {
func presentError()
}
extension ErrorPopoverRenderer where Self: UIViewController {
func presentError() {
//在这里加默认实现,并提供ErrorView的默认参数。
}
}
class KrakenViewController: UIViewController, ErrorPopoverRenderer {
func methodThatHasAnError() {
//…
//抛出error,原因是Kraken海妖今天吃人会感到不适。
presentError() //Woohoo! 没有参数了!我们现在有默认实现了!
}
}
好了,现在看起来已经很不错了。我们不仅消除了这些烦人的参数,还用swift 2.0的新特性在protocol的层级上用Self
给了presentError
一个默认实现。用Self
意味着当且仅当协议的遵循者是继承自UIViewController
的情况下,这个扩展才会有效。这就让我们能够把ErrorPopoverRenderer
真的当做是一个UIViewController
,而甚至不需要对后者做扩展!更棒的是,从现在开始,Swift的运行时是以静态调度而非动态调度去调用presentError()
方法。大致的意思就是我们在函数调用点给presentError()
方法增强了一点性能。
哎,不过还是有个问题。到这里我们POP的旅途暂时告一段落,但对它的完善依旧不会停止。我们的问题就是如果只想对一部分参数使用默认值,对剩下的不用默认值该怎么做?在这方面用POP的话基本帮不上什么忙,但是我们可以寻求另外一种方法。现在,我们使用VOP吧。
VALUE-ORIENTED PROGRAMMING
看到了吧,POP和VOP总是伴随出现。在上面的WWDC视频链接中,Crusty提出了一些大胆的论断:我们用struct
和enum
类型就可以做到一切class能做到的事。我很大程度上同意这点,但没这么极端。依我看,protocol本质上是把VOP粘合在一起的胶水,这点我和Crusty持相同态度。实际上既然我们说到了Swift的核心理念以及VOP,我想给你们看看从Andy Matuschak的精彩访谈中关于Swift中的VOP
的话题里面摘出来的一张极好的图:
能看出来Swift的标准库中,仅有的4个class,和余下的95个struct和enum的实例共同构建了Swift功能的核心。
Andy如此阐述道:用Swift编程的时候我们要去考虑用一层很薄的 对象层,和一层很厚的 值类型层。Class是有它们的地方,但是我想尽最大程度的去认为它们的位置只应该处于对象层中的一个很高的级别上,在这里通过操纵值类型层中的逻辑来管理各种行为。
"把逻辑和行为分开"
-Andy Matuschak
和你所了解的一样,值类型被赋给一个变量或者常量,抑或是传给函数做参数时是它的值被拷贝的。这就让值类型在任何时候只有一个享有者,从而降低复杂度。和引用类型相反,在赋值过程中引用类型会有很多享有者,其中一部分你甚至都没意识到。在任何时间点使用引用的话会带来一些副作用:引用的享有者会捣蛋,在背后偷偷改变这个引用。Class = 高复杂度,值 = 低复杂度。
通过利用值类型的简约特性,咱们实现一下之前提过的默认参数的设计吧。我们用的是 Brian Gesiak的value options paradigm方法:
struct Color {
let red: Double
let green: Double
let blue: Double
init(red: Double = 0.0, green: Double = 0.0, blue: Double = 0.0) {
self.red = red
self.green = green
self.blue = blue
}
}
struct ErrorOptions {
let message: String
let showArrow: Bool
let backgroundColor: UIColor
let size: CGSize
let canDismissByTap: Bool
init(message: String = "Error!", shouldShowArrow: Bool = true, backgroundColor: Color = Color(), size: CGSize = CGSizeZero, canDismissByTappingAnywhere canDismiss: Bool = true) {
self.message = message
self.showArrow = shouldShowArrow
self.backgroundColor = backgroundColor
self.size = size
self.canDismissByTap = canDismiss
}
}
使用上面的选项型struct
(是值类型!)就使我们的POP带上了一些VOP的色彩,如下:
protocol ErrorPopoverRenderer {
func presentError(errorOptions: ErrorOptions)
}
extension ErrorPopoverRenderer where Self: UIViewController {
func presentError(errorOptions = ErrorOptions()) {
//在这里加默认实现,并提供ErrorView的默认参数。
}
}
class KrakenViewController: UIViewController, ErrorPopoverRenderer {
func failedToEatHuman() {
//…
//抛出error,原因是Kraken海妖今天吃人会感到不适。
presentError(ErrorOptions(message: "Oh noes! I didn't get to eat the Human!", size: CGSize(width: 1000.0, height: 200.0))) //Woohoo! 没有参数了!我们现在有默认实现了!
}
}
如你所见,对于用view controller
做error处理,我们给与它了一种完全抽象的,可伸缩的和模块化的方式,还不用强迫所有的view controller
去继承一个上帝类。当你有一个具有不同功能的上帝类的时候,上面的例子尤其能帮到你。除此之外,用这种方式去实现类似上面error功能的其他功能时,你把实现该功能的代码放哪儿都行,不必做太多的重构或者改变代码框架。
函数式编程
咱们来解决这个。我也刚开始接触函数式编程,不过我知道一点:这种范式(paradigm)要求一种鼓励编程者去避免可变数据(mutable data)和改变状态(changing state)的编程方式。和数学函数类似,函数式编程是由一些输出结果仅取决于输入参数的函数组成,而且函数的输出结果不会被本体之外的相依性(dependency)所影响。这就是众所周知的"data in, data out",意思是每次传进来一个值,这个值传出去的时候和传进来时候总要是一样的。想想单元测试就明白了!
如果我们用函数式的思想去写代码,就可以把VOP与函数式编程结合,利用其中的诸多优点,这些优点包括但不仅限于:
- 完全线程安全的代码(值类型变量在并发代码中被分配时是被拷贝的,意思是另一个线程更改不了与它平行线程中的变量)。
- 更详尽的单元测试
- 不再需要在单元测试中用mock(用了值类型的变量就不用再重建一个必须使用mock对象的环境,只为了去测试仅仅少部分的功能。本质上通过初始化一个从任意依赖关系中抽象出来的特性,你可以重建任何你想要的东西。)
- 代码更简洁(说实话,能和瓷器一样精致)。
- 让你身边的小伙伴惊呆
- 很炫酷
- 让Kraken疯狂的崇拜你
什么时候用子类
什么时候应该用子类呢?答案是当你没选择的时候。比如:
- 当系统要求的时候。许多Cocoa的API要求你使用class,你不应该非要用值类型来跟系统对着干。
UIViewController
是要派生子类的,要不然你的app就啥都没有了。别跟系统对着干! - 当你需要有东西来帮你管理在其他class实例之间的值类型变量,而且还需要与这些值类型变量通信的时候。对于这种情况Andy Matuschak给了一个很好的例子:用一个class把一个值类型的绘图系统计算好的值取过来,传递给一个Cocoa的class来把这个绘图系统绘制到屏幕上。
- 当你需要或者想在许多享有者之间做隐式共享的时候。此种情况的例子是Core Data。数据持久化变幻无常,用Core Data的时候,使用子类给诸多需要同步的享有者做同步就很有效。但是要小心并发问题!这是你处理此类问题的时候必须要做的取舍。
- 当你不知道对于引用类型来说它的拷贝意味着什么的时候。你会拷贝一个单例么(singleton)?不会。你会拷贝一个
UIViewController
么?不会。一个window
?绝对不会。(你其实可以,这是你的特权。) - 当一个实例的声明周期与外部效应(external effect)绑定的时候,或者就只是需要一个稳定个体(stable identity)的时候。单例就是特别典型的例子。
结论
作为OOP的编程者我们已经习惯了用class来解决问题。长期以来我们开发了很多模式来弥补引用类型所带来的弊端。我的观点是在编程中换一种思路可以有效的减轻对这类折衷方案的使用。如果我们真的重视可伸缩性和可重用性,就得接受模块化的编程才是正道。使用值类型并结合Swift 2.0中新增并改进了的protocol特性就会轻松的达到这个目的。虽然之前OOP的思维方式会使我们比较难用VOP和POP的方式来思考,但是在swift中写的多了,VOP和POP的模式就会开始成为我们的第二天性。我们的大脑可能得需要我们多写一些代码才能适应这种思维方式,但我相信iOS社区作为一个整体能接纳这些做法,从而极大的降低我们日常解决问题的难度。Swift的核心是一个极为强大的值类型系统,坦白说,我们应该一开始就用VOP的思想磨练自己来发扬这个值系统的优势。但愿这篇文章能多多少少的帮助到你,让你每天写出来更加详尽的,天生安全的代码。