Swift 中的不透明类型、存在类型以及 some、any 关键字

Xcode 14 beta 3 Swift 5.7

不透明类型、some 关键字

some 关键字由 Swift 5.1 引入,它用来修饰某个协议,使之成为不透明类型

不透明类型是隐藏类型信息的抽象类型,其底层的具体类型不可动态改变。

初次接触 SwiftUI 的读者会看到这样的代码:

1
2
3
var body: some View {
Text("Hello")
}

body 是不透明类型 some View,调用者只知其是一个遵循 View 协议的抽象类型,却不知其底层的具体类型(Text),因为不透明类型对调用者隐藏了类型信息。

这里的”不可见“是对调用者而言的,而编译器具有”透视“视角,它能够在编译期获取到不透明类型底层的具体类型(Text),并确保其底层类型是静态的。

如果在 body 内这样写:

1
Bool.random() ? Text("Hello") : Image(systemName: "swift")

编译器能够诊断出 TextImage 是不同的类型,因而抛出错误。假设 body 内部可以动态地改变其底层的具体类型,这意味着更多的内存占用和复杂计算,这会导致程序的性能损耗。

基于以上特性,不透明类型非常适合在模块之间调用,它可以保护类型信息为私有状态而不被暴露,而编译器能够访问类型信息并作出优化工作。

不透明类型受实现者约束,这和泛型受调用者约束是相反的。因此,不透明类型又被称为反向泛型。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
func build1<V: View>(_ v: V) -> V {
v
}
// v1 is Text
let v1 = build1(Text("Hello"))

func build2() -> some View {
Text("Hello")
}
// v2 is View
let v2 = build2()

调用 build1 时就需要指定具体类型,此处入参为 Text 类型,因此 v1 的类型也是 Text

build2 返回的具体类型由内部实现决定,这里返回的是 Text 类型。鉴于不透明类型对调用者隐藏了类型信息,因此 v2 的类型在编译期是 View,在运行时是 Text

更优雅的泛型

下面的代码用于比较两个集合,如果所有元素相同,返回 true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func compare<C1: Collection, C2: Collection>(_ c1: C1, _ c2: C2) -> Bool
where C1.Element == C2.Element, C1.Element: Equatable {
if c1.count != c2.count { return false }
for i in 0..<c1.count {
let v1 = c1[c1.index(c1.startIndex, offsetBy: i)]
let v2 = c2[c2.index(c2.startIndex, offsetBy: i)]
if v1 != v2 {
return false
}
}
return true
}

let c1: [Int] = [1, 2, 3]
let c2: Set<Int> = [1, 2, 3]
let ans = compare(c1, c2) // true

这里使用泛型约束保证 C1C2 是集合类型,使用 where 分句确保二者的关联类型 Element 是能够判等的相同类型。功能虽已实现,但写起来非常繁琐,也不利于阅读。那么,该如何简化呢?

在简化之前,先来看看 Swift 5.7 新增的两个新特性:

  1. 使用范围更广的不透明类型

    此前,不透明类型只能用于返回值。现在,我们还可以将其用于属性、下标以及函数参数。

  2. 主要关联类型

    协议支持多个关联类型,使用尖括号声明(类似泛型写法)的则是主要关联类型。

    如下 Collection 协议中的 Element,就是主要关联类型。

    借助这一特性,在使用具有关联类型的协议时,写法可以非常简洁。比如上面的 where 分句,我们可以简写成 Collection<Equatable>

    1
    2
    3
    4
    5
    6
    public protocol Collection<Element> : Sequence {

    associatedtype Element
    associatedtype Iterator = IndexingIterator<Self>
    ...
    }

将以上两点结合起来,更优雅的写法如下:

1
2
3
func compare<E: Equatable>(_ c1: some Collection<E>, _ c2: some Collection<E>) -> Bool {
...
}

c1c2 可以是任意集合类型,如果没有使用 some 标记,它就是下文提到的存在类型,编译器会提示使用 any 修饰。但这里将其声明为不透明类型,基于以下两点:

  1. 旧函数在调用时就已经确定了入参的具体类型,这和 any 的表达的意思有悖。
  2. 此处的不透明类型并没有用作返回值,只是在函数被调用时的入参,其具体类型是固定的,没有必要使用 any,这和旧函数表达的意图一致。

仔细对比两个函数,能够发现:some PT where T: P 表达的意思其实是一样的。如果 P 带有关联类型 E,那么 T where T: P, T.E: V 可以简写为 some P<V>

存在类型、any 关键字

any 关键字由 Swift 5.6 引入,它用来修饰存在类型:一个能够容纳任意遵循某个协议的的具体类型的容器类型。

我们结合下面的代码来理解这段抽象的描述:

1
2
3
4
5
6
7
8
9
10
11
12
protocol P {}

struct CP1: P {}
struct CP2: P {}

func f1(_ p: any P) -> any P {
p
}

func f2<V: P>(_ p: V) -> V {
p
}

f1 中的 p 及其返回值都是存在类型,只要是遵循协议 P 的类型实例都是合法的。

f2 中的 p 及其返回值都不是存在类型,而是遵循协议 P 的某个具体类型

在编译期间,f1p 是存在类型(any P),它将 p 底层的具体类型包装在一个“容器”中。而在运行时,从容器中取出内容物才能得知 p 底层的具体类型。p 的类型可被任何遵循协议 P 的某个具体类型进行替换,因此存在类型具有动态分发的特性。

比如下面的代码:

1
2
3
func f3() -> any P {
Bool.random() ? CP1() : CP2()
}

f3 的返回类型在编译期间是存在类型 any P,但是在运行期间的具体类型是 CP1CP2

f2 中的 p 没有被“容器”包装,无需进行装箱、拆箱操作。由于泛型的约束,当我们调用该方法时,就已经确定了它的具体类型。无论是编译期还是运行时,它的类型都是具体的,这又称为静态分发。比如这样调用时:f2(CP1()) ,入参和返回值类型都就已经固化为 CP1,在编译期和运行时都保持为该具体类型。

因为动态分发会带来一定的性能损耗,因此 Swift 引入了 any 关键字来向我们警示存在类型的负面影响,我们应该尽量避免使用它。

上面的示例代码不使用 any 关键字还能通过编译,但从 Swift 6 开始,当我们使用存在类型时,编译器会强制要求使用 any 关键字标记,否则会报错。

在实际开发中,推荐优先使用泛型和 some,尽可能地避免使用 any,除非你真的需要一个动态的类型。


文中涉及源码参考:Source code

您的支持将鼓励我继续创作!
0%