浅谈 Swift4 中的泛型与协议
最近在用 Swift 开发一个有记录文件目录层级功能的 command line,顺便学习一下 Swift4 的新特性,而开发过程中遇到了有关于泛型和协议的一些问题,经过探索后写下本文进行记录。
前置阅读
本文会涉及到 Swift 泛型 以及 Swift Codable 协议,如果你还不了解这两块的知识,欢迎点击链接查看官方文档,另外,关于 Swift4 中内建对 JSON 序列化的支持,RayWenderlich 上的一篇教程 也值得一看。
本文执笔时,Swift 语言的版本为 4.0.3,由于 Swift 仍在高速发展,本文的内容在日后不一定正确。
问题描述
前面说到的“文件目录层级记录功能”,主要就是递归记录指定文件目录下的文件夹/文件,最终输出一份 JSON 记录文件:
1 | { |
而对应的,我写出了如下的代码:
1 | protocol AbstractFileReference : Codable{ |
在构造完文件树结构之后,我原本是想着这段代码能够 “It just works.” 的,毕竟 AbstractFileReference
中仅定义了基础类型 String
。
然而编译器却出现了 Error:
1 | type 'GroupReference' does not conform to protocol 'Decodable' |
作为对比,FileReference
是通过了编译的,也就是说编译器对于嵌套复杂数据类型的支持有限,好吧,那我们来实现一下 Encodable & Decodable
协议,为了省略篇幅,只贴 encode
部分代码:
1 | func encode(to encoder: Encoder) throws { |
到此,引出了本文的主角错误:cannot invoke 'encode' with an argument list of type '(AbstractFileReference)'
泛型?协议?
编译器给出的提示是参数类型不匹配,然而智能补全给我的提示让我摸不着头脑:
看起来,encode
方法接受一个 Encodable
类型的参数,而此处我传入的 child
是遵循 Codable
协议的,但似乎这是智能补全的一个 bug(或者是 feature?),实际上 encode
方法是一个泛型方法:
1 | public mutating func encode<T>(_ value: T) throws where T : Encodable |
协议我想大家都很熟悉了,协议定义了一套“行为规范”,遵从协议的类/结构体需要实现对应的方法/变量,以供使用者调用。协议提取出“做什么”,而不在乎怎么做,将依赖抽象化了。
泛型
泛型,在 C++ 中也称为模板,是一个和协议非常不一样的概念。如果说协议是一层解耦的抽象层,那么泛型更像是代码的搬运工。
“在 C++ 中,编译器会为使用了模板的每个类型生成独立的模板化的函数或者类的实例。”
…
“不过 Swift 可以通过泛型特化 (generic specialization) 的方式来避免这个额外开销。泛型特化是指,编译器按照具体的参数参数类型 (比如 Int),将 min这样的泛型类型或者函数进行复制。”
摘录来自: Chris Eidhof. “Swift 进阶”。
泛型某种意义上并不是抽象,只是简单的代码复用,或者说是编译器替你完成了代码拷贝,这也就是说,泛型之中的类型,是需要在编译期确定的,就像这个 encode<T>
方法,在编译器编译后实际生成了 encode<Int>
,encode<Float>
,encode<String>
等等方法(当然,具体看你有没有用到这一类型),再替换代码中的泛型调用,本质是静态派发;而协议则更多是动态派发(仅定义在协议扩展中的方法除外,这块更详细的内容可以参看 《Swift 进阶》 中的 “面向协议编程” 一章)。
所以,此处编译器给出错误,是因为我们将运行时才能确定的值传给了一个需要静态确定类型的方法。
而在接口设计时,泛型的使用往往是为了使参数在编译期就能得到类型上的保证,举个例子,标准库用于比较取小值的 min
函数:
1 | func min<T: Comparable>(_ x: T, _ y: T) -> T { |
之所以不写成 min(_ x: Comparable, _ y: Comparable) -> Comparable
是因为对比大小的两个参数不但需要可比,还应该是相同类型,拿整型和字符串对比显然是没有意义的,而取到的较小值,同样也应该和传入的参数为相同类型,因此,此处的泛型作用除了复用代码,还有一个作用就是保证编译期参数类型的确定性。
解决方案
encode
接口需要一个编译期可确定的参数,而此处 children: [AbstractFileReference]?
的类型的确是最符合当下实际场景的声明,怎么办呢?在 [stackoverflow 上的这个回答] (https://stackoverflow.com/questions/44441223/encode-decode-array-of-types-conforming-to-protocol-with-jsonencoder) 以及 《Swift 进阶》 中的 “协议的两种类型” 一章中的 “类型抹消” 提出的解决方案都是将动态类型用另外一个静态类型包裹起来。
“类型抹消”一节中的内容,是在讨论带有
associatedType
的协议类型被单独使用的场景,和我遇到的问题有共同点,但又不完全一样。
但两种方法看起来都很不直观,“类型抹消” 中更是用到了继承,而将 FileReference
和 GroupReference
统一包裹成一个静态类型,就意味着需要额外存储字段,标识它是一个文件还是一个目录,而后再在读取时动态决定创建哪个结构体。这显然也不是我想看到的,我的初衷就是能够达到 JSON 数据与目标变量的直接转化。
最后,我不得不在 GroupReference
的 encode
方法中,将协议类型 cast 成具体类型:
1 | func encode(to encoder: Encoder) throws { |
而与之对应,init(from decoder:)
方法,则是利用了类型不匹配会抛出错误的机制:
1 | while !childrenContainer.isAtEnd{ |
无奈的妥协
尽管功能实现了,但上面的代码并不好看,可维护性也不高。
本质上,泛型与协议似乎是有一些冲突的概念,func someFunc<T: someProtocol>(param: T)
实际上是为一个方法套上了双重限制:传入编译期可确定的类型作为参数、该类型遵循某个协议。而我们使用协议,很多时候又是因为我们想要动态派发、不想关心编译时具体的变量类型。日常开发中,使用协议将数据结构中的共性抽象似乎没有什么问题,但是当这样的数据结构碰上泛型接口时,我们似乎不得不将抽象重新转化回具体,想办法绕过这一语义上的限制。