Swift 中的不透明类型、存在类型以及 some、any 关键字
July 22nd, 2022

Xcode 14 beta 3 Swift 5.7

不透明类型、some 关键字

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

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

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

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

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

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

如果在 body 内这样写:

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

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

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

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

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。

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

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

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

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

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

  2. 主要关联类型

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

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

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

    public protocol Collection<Element> : Sequence {
      
      associatedtype Element
      associatedtype Iterator = IndexingIterator<Self>
      ...
    }
    
    

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

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

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

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

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

存在类型、any 关键字

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

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

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 的某个具体类型

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

比如下面的代码:

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

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

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

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

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

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


文中涉及源码参考:Source code

Subscribe to zzzwco
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from zzzwco

Skeleton

Skeleton

Skeleton