[ WWDC2018 ] - Swift 泛型 Swift Generics

我们首先来回顾一下 Swift 中对于泛型支持的历史变更,看看现在在 Swift 中,泛型都支持哪些特性

WX20180619-203419.png

Swift 泛型是 Swift 语言中的一个重要特性,在历届 WWDC 大会都有被提及,网上可以参考的资料也很多。这次会议上讨论了泛型特性的一些设计思路

泛型对于 Swift 的重要性

考虑一个如下的一个集合类型

struct Buffer {  
    var count: Int

    subscript(at: Int) -> Any { 
        // get/set from storage
    }
}

对于这样的一个集合类型,我们并不能定义他的 get/set 方法对应的变量类型,充其量只能定义一个万能类型(如 OC 中的 id 或 C++ 中的 void * )。Swift 中也有这样的一个万能类型 Any ,但是这样会带来非常不好的开发体验,看看下面的这个例子

var words: Buffer = ["subtyping","ftw"] 

// I know this array contains strings
words[0] as! String

// Uh-oh, now it doesn’t!
words[0] = 42  

对于 words 变量而言,可能你一直把它当作一个字符串数组来使用,但是实际上有可能在别处塞入了一个非字符串类型的变量进去,从而导致强制解包失败引起程序崩溃,这是一个令人心塞的经历。

实际上,对于以上的例子,内存管理上的问题就更为突出。 譬如对于一个整型数组来说,他的内存布局是非常紧凑的 1528932385264.png

如果是一个 Any 类型的数组,内存占用就会变得很大,因为这个时候需要预留足够多的内存空间给所有可能的变量类型使用,这样就是一个极大的浪费。 1528932479967.png

考虑一下如果用 Any 来包裹一个值类型的变量的话,内存布局上将会更加复杂了 1528933083848.png

在 OO 语言的时代,要解决以上问题的话,一般采用参数多态(Parametric Polymorphism)技术,在 Swift 中而言,就是泛型

譬如对于上述的例子,我们用Swift泛型来定义的话,应该是这个样子

struct Buffer<Element>{  
    let count: Int

    subscript(at: Int) ->Element {
       // fetch from storage
    }
}

这样子,我们就可以告诉编译器,Buffer 中应该包含有哪些类型的变量。因此,如果写了错误的代码的话,编译器就会马上报错,如

var words: Buffer<String> = ["generics","ftw"]

words[0] = 42  // error:Cannot assign value of type 'Int' to type 'String'

var boxes: Buffer<CGRect> = words // error: Cannot convert value of type 'Buffer<String>' to specified type 'Buffer<CGRect>'  
var boxes: Buffer // error: Reference to generic type 'Buffer' requires arguments in <...>  

对于泛型类型来说,在初始化的时候,编译器如果拥有足够的信息去推导变量类型的话,我们可以不用显式声明泛型的具体变量类型,如

let words: Buffer = ["generics","ftw"]

let words: Buffer<String> = ["generics","ftw"]

// 以上两者完全等价

这样子的话,我们就能对于不同的变量类型,也能做到内存中的紧凑布局,而不会浪费掉不必要的内存空间 1528934025582.png

基于类型推导技术,我们就能对泛型写更加便利的代码了,譬如

// Optimization Opportunities
let numbers: Buffer = [1,1,2,3,5,8,13]

var total = 0  
for i in 0..<numbers.count {  
    total += numbers[i]
}

然而,对于 Buffer 来说,如果直接按照 Int 的预设去写代码的话,依然会遇到编译错误,譬如

extension Buffer   {  
    func sum() -> Element {
        var total = 0
        for i in 0..<self.count { // error: Cannot convert value of type 'Element' to expected argument type 'Int'
            total += self[i]
        }
        return total // error: Cannot convert return expression of type 'Int' to return type 'Element'
    }
}

let total = numbers.sum()  

要解决这个问题,我们只需要给泛型类型添加约束即可

extension Buffer where Element == Int /* 约束成Int类型 */  {  
    // 已经可以按照Int的预设来写代码逻辑了
    func sum() -> Element {
    }
}

let total = numbers.sum()  

或者我们稍微扩展一下,定义协议也是可以的,这个时候就不仅限于 Int 的使用了

extension Buffer where Element: Numeric  {  
    // 已经可以按照Int的预设来写代码逻辑了
    func sum() -> Element {
    }
}

let total = numbers.sum()  

协议设计

在上面的例子,我们已经创建了一个泛型类型,但是我们的抽象化还不够。如果我们想要把泛型适配的范围扩展得更大一点,我们需要用协议去定义行为。我们以大家熟悉集合类型来看看怎样定义一个合理的集合协议

protocol Collection {  
    associatedtype Element
}

struct Buffer<Element> { }  
extension Buffer: Collection { }

struct Array<Element> { }  
extension Array: Collection { }

struct Dictionary<Key,Value> { }  
extension Dictionary: Collection {  
    typealias Element = (Key,Value)
}

如果我们要为集合类型添加下标操作的话,可以这样子做

protocol Collection {  
    associatedtype Element

    var count: Int { get }
    subscript(at: Int) -> Element
} 

extension Collection {  
    func dump() {
        for i in 0..<count {
            print(self[i])
        }
    }
}

实际上,这样的设计过于简单化了,考虑一下 Array 和 Dictionary 的场景,对于 Array 而言,通过下标去寻找元素是比较容易而且实现也是显而易见的,但是对于 Dictionary 而言,我们还需要合适的包装,譬如

extension Dictionary: Collection {  
    private var _storage: HashBuffer<Element>
    struct Index {
        private let _offset: Int
    }
    subscript(at: Index) -> Element {
        return _storage[at._offset]
    }

    func index(after: Index ) -> Index
    var startIndex: Index
    var endIndex: Index
}

基于以上的考虑,我们可以将集合的协议再通用化声明一次,看看效果会不会更好

protocol Collection {  
    associatedtype Element
    associatedtype Index  // 注意这里不是简单的Int,而是更泛化的Index类型

    subscript(at: Index) -> Element 

    func index(after: Index ) -> Index

    var startIndex: Index { get } 

    var endIndex: Index { get }
}

这里我们考虑到了下标操作的各种场景,并且用更灵活的 Index 类型去定义下标操作对应的索引行为,这样子基本覆盖到了我们日常遇到的场景,而将更多变的实现方式留给了具体的算法实现代码中。举个例子

extension Collection where Index: Equatable /* 注意,因为endIndex现在是Index类型,不能随便使用运算操作符,因此当需要使用运算操作符!=的时候,这里必须要加上约束条件 */ {  
    var count: Int {
        var i = 0
        var position = startIndex
        while position != endIndex {
            position = index(after: position)
            i += 1
        }
        return i
    }
}

或者我们可以高效一点,将约束条件直接写到协议中去,这样子我们就不需要针对每一个具体的泛型类型去定义相对应的约束条件了

protocol Collection {  
    associatedtype Element
    associatedtype Index : Equatable
}

extension Dictionary.Index: Equatable { }  

定制点( Customization Points )

我们来考虑对于同一个协议中的函数声明,我们可以有不同的实现方式,如

// 一般实现,性能一般
extension Collection {  
    /// The number of elements in the collection
    var count: Int {
        var i = 0
        var position = startIndex
        while position != endIndex {
            i += 1
            position = index(after: position)
        }
        return i
    }
}

// 高性能的实现方式
extension Dictionary {  
    /// The number of elements in the collection
    var count: Int {
        return _storage.entryCount
    }
}

当我们要使用 count 的时候,大部分的场景下,我们只想调用简单实现,并不太在意性能,而有些时候我们又希望能够采用高性能的解决方案,在泛型中,我们可以引入定制点的概念,从而满足各种定制化场景的需求

!<a href=1529021676023.png" />

在以上的例子,我们在协议声明的时候,预埋了 count 函数的默认实现方式,而对于 Dictionary 类型来说,将会采用更好的 count 实现方式,从而在保持泛型声明的前提下,优雅地封装了同一函数的不同实现方式。

协议继承( Protocol Inheritance )

Swift 提倡一种面向协议的编程方式,因此,我们很自然地把以前在面向对象编程中的一些很好的特性迁移到协议的设计中,考虑一下当我们需要对上述的集合协议扩展一些特殊的功能时,譬如 lastIndex 、shuffle 等,这个时候使用继承是比较合适的。用一个具体的代码表示如下

protocol BidirectionalCollection: Collection {  
    func index(before idx: Index) -> Index
}

extension BidirectionalCollection {  
    func lastIndex(where predicate: (Element) -> Bool) -> Index? {
        var position = endIndex
        while position != startIndex {
            position = index(before: position)
            if predicate(self[position]) { return position } 
        }
        return nil
    } 
}

在面向协议编程的设计中,我们要时刻记得,协议是用于定义行为确定,但实现各异的场景,而不是过于特化的去设计接口,我们来看一个不好的设计方式

/*
 * 以下是一个错误的设计,他的错误之处是
 * 1. 使用了两个特化的接口去定义shuffle这种行为
 * 2. 这个协议看起来不太可能展示出多态的行为,基本上一个协议就只对应一种shuffle实现
 */

protocol ShuffleCollection: Collection {  
    func index(startIndex idx: Index, offsetBy: Int) -> Index
    func swapAt(pos idx: Index, otherPos idx: Index)
}

extension ShuffleCollection {  
    mutating func shuffle() {
        let n = count
        guard n > 1 else { return }
        for (i, pos) in indices.dropLast().enumerated() {
            let otherPos = index(startIndex, offsetBy: Int.random(in: i..<n))
            swapAt(pos, otherPos) 
        }
    }
}

仔细考虑一下,对于 ShuffleCollection 而言,需要的是两种行为 - 随机访问元素 - 修改内部变量实现 shuffle

因此,通过定义粒度更细的协议,可以让上诉行为展示出更多的多态性

protocol RandomAccessCollection: BidirectionalCollection {  
    func index(_ position: Index, offsetBy n: Int) -> Index  
    func distance(from start: Index, to end: Index) -> Int 
}

protocol MutableCollection: Collection {  
    subscript (index: Index) -> Element { get set } 
    mutating func swapAt(_: Index, _: Index) { }   
}

extension RandomAccessCollection where Self: MutableCollection {  
    mutating func shuffle() {
        let n = count
        guard n > 1 else { return }
        for (i, pos) in indices.dropLast().enumerated() {
            let otherPos = index(startIndex, offsetBy: Int.random(in: i..<n))
            swapAt(pos, otherPos) 
        }
    } 
}

合理的协议设计思路,我们可以用如下的类图来表达 1529021676023.png

自上而下看,泛化的协议会变得越来越特化,对应于越来越窄的适用场景(然而这个时候还是要尽可能确保场景适用面广),从下往上看,超类的协议总是能够把更多态的行为抽象出来表达。协议的继承关系应该比类的继承关系更用心地去设计,尽可能地保留协议行为的原子性和多态性,让代码更加易于扩展和组装。

条件一致性( Conditional Conformance )

条件一致性在 Swift 4.1 中引入,表达了这样的一个语义:泛型类型在特定条件下会遵循一个特定的协议,譬如

//定义Purchaseable协议
protocol Purchaseable {  
    func buy()
}

//定义一个符合该协议的结构体
struct Book: Purchaseable {  
    func buy() {
        print("You bought a book")
    }
}

//数组遵循该协议, 并且每一个元素也遵循该协议
extension Array: Purchaseable where Element: Purchaseable {  
    func buy() {
        for item in self {
            item.buy()
        }
    }
}

// 以下的方式在Swift4.1会产生运行崩溃,但是在Swift4.2已经修复了该问题
let items: Any = [Book(), Book(), Book()]

if let books = items as? Purchaseable {  
    books.buy()
}

Swift4.2 中,条件一致性能力的增强,使得我们可以做更多的事情。譬如多协议的条件一致性检查也是可以的

extension Range: Collection, BidirectionalCollection, RandomAccessCollection  where Bound: Strideable, Bound.Stride: SignedInteger {  
    // 拥有了多个约束性
}

// 也可以用typealias将约束包装一下,看起来更方便
extension CountableRange: Collection, BidirectionalCollection, RandomAccessCollection {  
    // ...
}

typealias CountableRange<Bound: Strideable> = Range<Bound>  where Bound.Stride: SignedInteger  

泛型和类怎样抉择

Swift 是一个多编程范式的语言,你既可以将它用于面向协议编程(POP),也可以用于面向对象编程(OOP)

如果采用对象继承的方式,要时刻记得『里氏替换原则』,我们来重温一下它的具体概念

里氏替换原则( Liskov Substitution Principle LSP )面向对象设计的基本原则之一。 里氏替换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP 是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。 因此,继承的原则是:继承必须确保超类所拥有的性质在子类中仍然成立。也就是说,当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系,即构成继承关系。

简单的代码演示如下

class Vehicle { ... }  
class Taxi: Vehicle { ... }  
class PoliceCar: Vehicle { ... }  

extension Vehicle {  
    func drive() { ... }  
}

taxi.drive()  

不过在实际编程中应用 OOP 常常会遇到这样的困境:超类的设计往往跟子类有关,如果当一个新的子类出现,是修改超类行为从而覆盖新的子类呢,还是变成组合关系?

我们使用 POP 的话,结合泛型可以提供了行为的多态性,在某种意义上来说对于是一种更柔性的解决方案。在 Swift 中,协议可以拥有默认实现,可以增加约束,可以有条件一致性提供查询,最重要的,还能提供继承关系。这些特性都可以帮助我们更好地在 POP 下重用代码。

POP 下,里氏替换原则依然适用于协议的继承关系,如下述代码所示

class Vehicle { ... }  
class Taxi: Vehicle { ... }  
class PoliceCar: Vehicle { ... }

protocol Drivable {  
    func drive()
}

extension Vehicle: Drivable { }

// 父类通过协议定义了sundayDrive的行为
extension Drivable {  
    func sundayDrive() {
        if Date().isSunday { 
            drive()
        }  
    }
}

// 所有的子类也同样具有了该协议定义的行为
PoliceCar().sundayDrive()  

下面我们考虑一下 POP 下的工厂模式实现,这是一个教科书式的例子来帮助我们理解 Swift 中,协议相关的强大特性

// Initializer Requirements 
protocol Decodable {  
    required/* 注意:这里的required不能省略,用于约束所有的子类都实现自己的初始化方法,从而完成工厂模式的实现 */ init(from decoder: Decoder) throws { ... } 
}

extension Decodable {  
    static func decode(from decoder: Decoder) throws -> Self {
        return try self.init(from: decoder)  
    } 
} 

class Vehicle: Decodable {  
    required /* 不能省略 */ init(from decoder: Decoder) throws { ... } 
}

class Taxi: Vehicle {  
    var hourlyRate: Double 
    required /* 不能省略 */ init(from decoder: Decoder) throws { ... } 
}

Taxi.decode(from: decoder) // produces a Taxi  

最后,在 Swift 中我们还可以用 final 来修饰一个类,用于表达这个类是不能被继承的

final class EndOfTheLine: Decodable {  
    init(from decoder: Decoder) { ... } // 这个时候就不需要写required了,编译器会知道init方法的实现只需要在这里查找,而不用通过继承链去找
}

总结

本次 Session 探讨了很多泛型和协议相关的话题,我们来简单回顾和总结一下要点 1. 泛型设计对于 Swift 语言来说是一个很重要的特性,能够既保持静态类型的特点又能够达到代码重用的目的
2. 协议的设计要遵循自上而下和自下而上的原则
- 自上而下:协议的继承表达了更特化的行为描述 - 自下而上:父类协议应该能够将更抽象的行为进行封装从而达到代码重用的效果 3. 继承的设计要遵循里氏替换原则

本文总阅读量