Swift 的类型多态
in 技术 with 0 comment

Swift 的类型多态

in 技术 with 0 comment

Polymorphism (多态)是程式设计的基本概念之一,指同一个接口的背后可以有不同的实现。
比如说在 UIKit 里面的 UIImage,它的底层实现可能是 Core Image,也可能是 Core Graphics,但我们在调用的时候通常不需要在意这些。
另一个例子是 Swift 的 String,它的底层可能是 Swift 原生的也可能是从 NSString 桥接过来的,但它们表面上的接口都是一样的。如此一来,开发者就不用针对每种实现去编写不同的代码,而只要调用一个统一的接口就好了。

多态的分类

多态主要可以分成 ad hoc polymorphism、parametric polymorphism 与 subtyping 三个领域。

接下来,就让我们从最传统的 inheritance(继承)来探讨它们的运作原理吧!

Inheritance

Inheritance 是 OOP 最根本的特色之一。在 Apple 平台上,Objective-C 从一开始就支持通过 subclassing(子类化)来达成 inheritance,而 Swift 也不例外。它的基本概念很简单,就是当我们为某个 class 增加 superclass(父类) 的时候:

在 Objective-C 里,subclass 并不会复制 superclass 的成员清单,而是依靠 runtime(运行时)直接到 superclass 那边去拿它的成员清单来看,不过这并不影响我们在概念上把 subclassing 用 value semantics(值语义)来解释。

假设我们有 Circle 与 Square 两个不同的 class:

class Circle {
    var diameter: Double = 1.0
}
class Square {
    var sideLength: Double = 1.0
}

然后有一个 class Shape:

class Shape {
    var fillColor: Color = .red
}

如果把 Circle 与 Square 都加上 Shape 为 superclass 的话:

class Circle: Shape {
    var diameter: Double = 1.0
}
class Square: Shape {
    var sideLength: Double = 1.0
}

那么它们就会把 Shape 的 fillColor 也继承下来:

Circle().fillColor // .red
Square().fillColor // .red

这跟多态有什么关系呢?

很简单,因为我们可以把 Circle() 与 Square() 都当成 Shape 来操作:

let shapeA: Shape = Circle()
shapeA.fillColor
let shapeB = Square() as Shape
shapeB.fillColor

这两种写法在 Swift 中所产生的效果是一模一样的。这样子把 subclass 当成 superclass 来操作,就是所谓的 upcasting。Upcasting 并不会改变实体本身的成员清单,只会改变标示实体的类型而已,所以当我们存取它的成员的时候,成员仍然是属于 subclass 的。而因为 superclass 的所有成员在 subclass 里必定都找得到,所以 upcasting 永远是安全的。

Upcasting:向上转型,专用类型向通用类型转换

同时,我们还可以把不同 concrete class 的实体放到同一个 Array 里面:

let shapes: [Shape] = [shapeA, shapeB]

为什么这很重要呢?因为同一个集合里面,所有的变量大小一定要一致,否则无法进行有效率的内存空间管理。Class 变量因为本身储存的内容就只是指向实体的 reference(引用的指针),所以即使 subclass 多了 stored property(存储属性),增加实体的大小,也不会影响到只放 reference 的本身。

这样子储存不同 concrete type(具体类型)实体的 Array,就是所谓的 heterogeneous collection(异质集合)。

问题

Inheritance 的运作概念虽然简单,但它也容易造成很复杂的继承阶层。另外,Swift 所推出的 value type 也不支持 inheritance,所以势必得有另一种方式来达成 value type 的 polymorphism,那就是...

Composition

Composition(组合)其实是一个比 inheritance(继承)更直觉的概念。简单来说,当我们想要给某个类型一个 supertype 来做统一的接口的时候,我们不为它加一个 superclass,而是把它的实体装在一个 container(容器)里面,透过 container 来间接操作它。而专门用来做 supertype 的 container,在 Swift 社群里被称作 existential container。

Existential Container是一种特殊的内存布局方式,用于管理遵守了相同协议的数据类型Protocol Type,这些数据类型因为不共享同一继承关系(这是V-Table实现的前提),并且内存空间尺寸不同,使用 Existential Container进行管理,使其具有存储的一致性。

Container 的接口会对应到被包装的 concrete type 的成员,而确保 concrete type 具备这些成员的,就是 protocol。
举例来说,如果我们有类型 HTTPCall 与 LoadFileOperation:

import Foundation
struct HTTPCall {
    var request: URLRequest
    var session: URLSession = .shared
    func execute(handler: @escaping (Result<Data, Error>) -> Void) {
        // 实作...
    }
}
struct LoadFileOperation {
    var fileURL: URL
    var manager: FileManager = .default
    func execute(handler: @escaping (Result<Data, Error>) -> Void) {
        // 实作...
    }
}

而如果我们想为它们设计一个 supertype 的话,首先就得透过 protocol 去定义统一的接口:

protocol Executable {
    func execute(handler: @escaping (Result<Data, Error>) -> Void)
}
extension HTTPCall: Executable { }
extension LoadFileOperation: Executable { }

如此一来,compiler 就会去检查 HTTPCall 与 LoadFileOperation 是否符合这个 protocol 的规范,我们也就可以确定它们都一定具备 execute(handler:) 这个 method(方法)了。接下来,我们就可以把它们放进 existential container 里面了。

怎么放呢?很简单,只要把它们的实体 typecast(类型转换)成 protocol,compiler 就会自动产生一个 existential container 去把它们包起来了:

let call1: Executable = HTTPCall(request: request)
let call2 = LoadFileOperation(fileURL: url) as Executable

是的,我们刚刚写的 protocol 除了确保 subtype 具备哪些成员之外,它本身也可以被当成 existential container 来用。只要一个变量的 type 是 protocol,那这个变量持有的,其实是一个相对应的 existential container。

Compiler 除了可以从一个 protocol 产生 existential container 之外,也可以从多个 protocol(可包含一个 class)所组成的 protocol composition type(协议组合类型)来产生 existential:

protocol A { /* ... */ }
protocol B { /* ... */ }
protocol C { /* ... */ }
typealias ABC = A & B & C
// abc 的值就是一个从 A、B 与 C 的综合协议所产生的 existential container。
var abc: ABC

Protocol existential container 除了给不同的 concrete type 一个统一接口之外,更解决了不同 type 的实体大小不同,所以不能放进同一个 collection 的问题。每个由 compiler 产生的 protocol existential 的大小都是一样的,如果放得进 concrete type 实体的话就 inline 直接放,放不下的话就把实体丢到 heap(堆)去再把 reference (指针)存起来。所以,用 existential container 也是可以达成 heterogeneous collection(异质集合) 的。

Protocols with Associated Types (PATs)

Protocol existential 的语法在 Swift 里极为简单,因为 Swift 团队特别把它简化成跟 class upcasting 一样用 as(类型转换关键字)来做,试图将 existential container 的概念隐藏起来。然而,这样的尝试只成功了一半,因为当一个 protocol 具备 associated type(关联类型)的要求时,Swift compiler 就没办法自动去产生 existential container。这样的 protocol,社群里称之为 PAT (protocol with associated types)。

假设我们想把 Executable 改为 PAT:

protocol Executable {
    associatedtype Response
    func execute(handler: @escaping (Response) -> Void)
}
extension HTTPCall: Executable {
    // Compiler 可以自动解析出 Response 的 concrete type,所以以下这行可省略。
    typealias Response = Result<Data, Error>
}
extension LoadFileOperation: Executable {
    // 同样可省略。
    typealias Response = Result<Data, Error>
}

这时,如果我们一样试图把 HTTPCall 与 LoadFileOperation 转换成 Executable 的话,就会得到这个警告:

Protocol ‘Executable’ can only be used as a generic constraint because it has Self or associated type requirements

这段话其实就是「Compiler 没办法自动产生 existential container」的意思。面对 PAT,我们就必须得手动去创造它的 existential container 了。而这个技巧,通常被称为 type erasure。

Type Erasure

用 Inheritance 解决

Type Erasure(类型擦除)就是在 runtime 前,把实体的 concrete type 信息给擦除掉的意思。 Type Erasure 可以有很多种方式,其中一种是透过 class inheritance,来擦除接口部分的 concrete type 信息:

// 用来作为统一接口的 abstract class(抽象类)。
// 将 PAT 的 associated type(Response)变成 class 的 generic parameter。
class AnyExecutable<Response>: Executable {
    func execute(handler: @escaping (Response) -> Void) {
        fatalError("未实现。")
    }
}
// 实际储存实体与 concrete type 信息的 subclass。
class AnyExecutableContainer<ConcreteType: Executable>: AnyExecutable<ConcreteType.Response> {
    private var instance: ConcreteType
    init(_ base: ConcreteType) {
        self.instance = base
    }
    // 将对 execute(handler:) 的调用引导给 instance。
    override func execute(handler: @escaping (ConcreteType.Response) -> Void) {
        instance.execute(handler: handler)
    }
}
// 创造 AnyExecutableContainer 实体后,可以透过 class upcast 把它们当成 AnyExecutable 来使用。
let call1: AnyExecutable = AnyExecutableContainer(HTTPCall(request: request))
let call2 = AnyExecutableContainer(LoadFileOperation(fileURL: url)) as AnyExecutable
// 只要这些 AnyExecutable 的 Response 是同样的 type,它们就可以被放到同一个 collection 里面。
let calls = [call1, call2]

这个方式是 Swift Standard Library 里,用来实现 AnyIterable 等 existential container 的底层方式,可以在 ExistentialCollection.swift.gyb 这个档案里找到相关的源代码。

这里由于 container 都是 class,所以一样可以达成 heterogeneous collection。

手动制作 Witness Table

另外,因为 Swift 的 function 是 first class function。

头等函数(first-class function)是指在程序设计语言中,函数被当作头等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中

所以我们也可以去模拟 protocol witness table,把类型的各种成员都写成是一个 structure 里的 property(属性),设计一个这样的 existential container:

// 一样把 associated type 变成是 generic parameter。
struct AnyExecutable<Response>: Executable {
    // Concrete type 的实体。
    private(set) var instance: Any
    // Concrete type 的成员 getter。这里利用 block 作为头等函数来当做参数传递。
    private let instance_execute: (Any) -> (@escaping (Response) -> Void) -> Void
    // Concrete type 是什么,只有在 initializer 里才知道,也就是这里的 generic type T。
    init<T: Executable>(_ instance: T) where T.Response == Response {
        self.instance = instance
        self.instance_execute = { instance in
            // 因为只有在这个 initializer 里面才能设定 self.instance 的型别,所以可以确定之后传入的 instance 一定是 T,可以强制 cast。
                    //把 execute 方法本身作为返回值返回,由于 execute 方法没有返回值,所以它的返回值是 Void。所以符合 instance_execute 类型的定义。
            (instance as! T).execute(handler:)
        }
    }
    func execute(handler: @escaping (Response) -> Void) {
        // 取得 self.instance 的 execute(handler:) method。
        let method = instance_execute(instance)
        // 执行 method。
        method(handler)
    }
}
let call1 = AnyExecutable(HTTPCall(request: request))
let call2 = AnyExecutable(LoadFileOperation(fileURL: url))
let calls = [call1, call2]

对于类 class 来说,每个类型都会创建虚函数表指针,指向一个叫做 V-Table 的表。拥有继承关系的子类会在虚函数表内通过继承顺序(C++可以实现多继承)去展示虚函数表指针。

但是对于 Swift 来说,class 和 struct 的实现是不同的,而属于结构体的协议 Protocol,可以拥有属性和实现方法,管理 Protocol Type 方法分派的表就叫做 Protocol Witness Table。

这个方式则是依靠 Any 来把 concrete type 擦除掉。其实 Any 本身就是一个可以包容任何 concrete type 的 existential container,所以像 [Any] 这样的 heterogeneous collection 才是可能的。同样地,仿 witness table 版的 AnyExecutable 也因为用了 Any,所以可以放进 heterogeneous collection。

这两种 type erasure 手段都用到了 generic parameter 来定义 PAT 里的 associated type,这是因为 associated type 本身就已经进到了...

Generic Programming

一开始的时候,我们就提到 generic programming(泛型编程)是把 design time 就决定的 type 移到 compile time,再去解析。什么意思呢?这就要讲到 type constructor(类型构造器)的概念了。

如果讲到 constructor,你会想到什么呢?多半是一个 type 的 initializer 吧:

struct Person {
    var name: String
    init(name: String) {
        self.name = name
    }
}

要创造属于这个 type 的值,就必须用它的 intializer:

let jane = Person(name: "Jane")
print(jane.name) // Jane

所以,initializer 可以说是一种 value constructor(值构造函数)。我们传值并调用 Person.init(name:),它就会创造一个 Person 对象给我们。

Type constructor 的概念其实也一样:我们传一个 type 给它,它创造一个 type 给我们。它在 Swift 里的形式,就是具备 generic parameter 的 type:

struct Container<Wrapped> { /* ... */ }

要创造一个 concrete type 来用,我们可以用 type alias(类型别名):

typealias IntContainer = Container<Int>

或者,我们也可以省略用 type alias 给它命名的动作来用:

var container: Container<String>

是不是与 initializer 很像呢?不过,type constructor 特别的是它可以在 compile time 就被「执行」。比如说 Container<Wrapped> 里面的实现,在 design time 时用的是 Wrapped 这个参数;但在 compile time,compiler 就会在碰到 Container<String> 这样的 type 时,去把 Container 里的Wrapped 全部替换成 String,产生一个新的 type 来用。

Generic programming 不只有 type constructor,也可以用来构建 function:

func add<Number>(x: Number, y: Number) -> Number { /* ... */ }

但是单纯的 generic parameter 本身没有什么意义,需要更多的约束条件才行。还好,我们可以给它加上 protocol 或 class 的约束条件,比如 <Number: Addable>

protocol Addable {
    static func + (lhs: Self, rhs: Self) -> Self
}
func add<Number: Addable>(x: Number, y: Number) -> Number {
    return x + y
}

Static Typing

而正是加上约束条件的能力,使 type 为 generic parameter 的实体完全就像一般的实体一样,可以进行各式各样的操作。这使得它跟 subtyping 的技巧非常地相似,比如说以下这两个 function,不只效果几乎一样,实现也长得完全相同:

protocol Runnable {
    func run()
}
// Subtyping
func start(runner: Runnable) {
    runner.run()
}
// Generic programming
func start<T: Runnable>(runner: T) {
    runner.run()
}

然而,这两者其实是完全不同的东西。

protocol Shooter {
    func shoot()
}
// Subtyping
func duel(shooterA: Shooter, shooterB: Shooter) { /* ... */ }
// Generic programming
func duel<T: Shooter>(shooterA: T, shooterB: T) { /* ... */ }

在此例中,subtyping 版本的 duel(shooterA:shooterB:) 里的 shooterA 与 shooterB 可以是不同的 concrete type,只要都有遵守 Shooter 就好。然而在 generic 版本的 duel(shooterA:shooterB:) 中,shooterA 与 shooterB 就必须是完全一样的 concrete type 才能编译。

除此之外,subtyping 里的统一接口由一个 supertype 所定义,而同一个 supertype 底下可以有不同的 subtype,也可以被放进同一个变量或同一个 collection 里面。相比之下,generic parameter 只是长得像 type,本身并不是一个 type。在 compile time 时,同一个 generic parameter 会被解析成完全一样的 concrete type,所以它只支持 homogeneous collection(同质集合),也就是全部元素的 concrete type 都一样的 collection。

Opaque Return Type

Generic parameter 顾名思义就是传入 generic type 或 generic function 的一种参数,是从外部通过参数或者返回值去指定 concrete type 的。这大大限制了 generic programming 的范围,不像 subtyping 的各种 supertype(class cluster、existential 等)到哪里都可以用。

Swift 5.1 新增的 opaque type(非透明类型)功能,就是为了要使 generic programming 能被更广泛的运用。它的概念跟 generic parameter 刚好相反,是由 generic function 内部的 scope 去决定 concrete type,而外部的 opaque type 则是要到 compile time 才会被解析出来。因此,它也被称作「反转的 generic」。

// 一般的 generic。
func doSomething<T>(with value: T) { /* ... */ }
// 由外部决定 T 的 concrete type。
// 这里,T 是 Int。
doSomething(with: 42)
// 反转的 generic。
// 跟一般 generic 不同,必须要有约束条件。
protocol Number { }
// 使 Int 遵守 Number 才能用。
extension Int: Number { }
// some Number 即为 opaque reture type,由实现决定 concrete type。
// 这里它的 type 是 Int。
func getSomething() -> some Number {
    42
}
// number 的 concrete type 由 getSomething 的返回值来决定。
let number = getSomething()

不过,因为它属于 generic programming,所以一样不能放到 heterogeneous collection 里面:

// 只要是不同 function 回传的 opaque type 都会被当成不一样。
// 即使是两个 concrete type 一样的 opaque type...
func getSomething1() -> some Number {
    42
}
func getSomething2() -> some Number {
    42
}
// Compiler 还是会把它们当作不同的 type。
let numbers = [getSomething1(), getSomething2()] // Compile error
// 也不能共用变数。
var number = getSomething1()
number = getSomething2() // Compile error

这是因为它们的 type,是跟当初产生它的 function 绑在一起的。

总结

以上,我们讲解了 subtyping 与 generic programming 两种迥异而互补的 type polymorphism 形式。在 WWDC 2015 的 408 号讲座 Protocol-Oriented Programming in Swift 里,当中一张简报是在讲一般 protocol 与有 Self 需求的 protocol 的差别,但我认为它其实就总结了 subtyping 与 generic programming 的核心差异:

图片来源:Protocol-Oriented Programming in Swift – WWDC 2015 – Videos – Apple Developer

希望你了解各个 type polymorphism 的做法差异之后,能找到最适合拿来解决问题的方式!

Responses