2.25 不透明类型与装箱协议类型

原文链接: https://docs.swift.org/latest/documentation/the-swift-programming-language/opaquetypes/

2.25 不透明类型与装箱协议类型

隐藏关于值的类型的实现细节。

Swift 提供了两种隐藏值的类型细节的方式:不透明类型和装箱协议类型。在模块与调用该模块的代码之间的边界上,隐藏类型信息很有用,因为返回值的底层类型可以保持私有。

返回不透明类型的函数或方法会隐藏其返回值的类型信息:函数的返回类型不再写成某个具象类型,而是用返回值所支持的协议来描述。不透明类型保留类型的同一性——编译器可以访问类型信息,但模块的调用方不行。

装箱协议类型可以存放任何遵循给定协议的类型的实例。装箱协议类型不保留类型的同一性——值的具体类型要到运行时才可知,而且随着存放不同的值,它还会随时间变化。

不透明类型解决的问题

举个例子,假设你在编写一个绘制 ASCII 艺术图形的模块。ASCII 艺术图形的基本特征是一个返回该图形字符串表示的 draw() 函数,可以把它作为 Shape 协议的要求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
protocol Shape {
    func draw() -> String
}

struct Triangle: Shape {
    var size: Int
    func draw() -> String {
       var result: [String] = []
       for length in 1...size {
           result.append(String(repeating: "*", count: length))
       }
       return result.joined(separator: "\n")
    }
}
let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())
// *
// **
// ***

你可以用泛型实现诸如把图形上下翻转之类的操作,如下面的代码所示。不过这种做法有一个重要的局限:翻转后的结果会暴露用来创建它的确切泛型类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct FlippedShape<T: Shape>: Shape {
    var shape: T
    func draw() -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}
let flippedTriangle = FlippedShape(shape: smallTriangle)
print(flippedTriangle.draw())
// ***
// **
// *

用这种方式定义把两个图形上下拼接起来的 JoinedShape<T: Shape, U: Shape> 结构体(如下面的代码所示),把一个三角形与翻转后的三角形拼接,得到的类型会是 JoinedShape<Triangle, FlippedShape<Triangle>>。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct JoinedShape<T: Shape, U: Shape>: Shape {
    var top: T
    var bottom: U
    func draw() -> String {
       return top.draw() + "\n" + bottom.draw()
    }
}
let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
print(joinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *

暴露关于图形构建过程的详细信息,会让那些本不该成为 ASCII 艺术模块公开接口一部分的类型泄漏出去,因为写完整返回类型时不得不提到它们。模块内部的代码可以用各种方式构造出同一个图形,而模块外部使用该图形的其他代码不应被迫考虑这一系列变换的实现细节。JoinedShape、FlippedShape 这类包装类型对模块使用者来说并不重要,也不应当可见。模块的公开接口由拼接、翻转图形这类操作组成,而这些操作返回的是另一个 Shape 值。

返回不透明类型

你可以把不透明类型理解为泛型的反面。泛型类型让调用函数的代码可以为该函数的参数和返回值挑选类型,而且这种挑选对函数实现是抽象的。例如,下面代码中的函数返回一个取决于调用方的类型:

1
func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }

调用 max(_:_:) 的代码选择 x 和 y 的值,而这些值的类型决定了 T 的具体类型。调用代码可以使用任何遵循 Comparable 协议的类型,而函数内部的代码则写成通用的形式,以便处理调用方提供的任何类型:max(_:_:) 的实现只使用所有 Comparable 类型共有的功能。

对于带不透明返回类型的函数,这两种角色是反过来的:不透明类型让函数实现为它返回的值挑选类型,而且这种挑选对调用函数的代码是抽象的。例如,下面的例子中的函数返回一个梯形,而不暴露这个图形的底层类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Square: Shape {
    var size: Int
    func draw() -> String {
        let line = String(repeating: "*", count: size)
        let result = Array<String>(repeating: line, count: size)
        return result.joined(separator: "\n")
    }
}

func makeTrapezoid() -> some Shape {
    let top = Triangle(size: 2)
    let middle = Square(size: 2)
    let bottom = FlippedShape(shape: top)
    let trapezoid = JoinedShape(
        top: top,
        bottom: JoinedShape(top: middle, bottom: bottom)
    )
    return trapezoid
}
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
// *
// **
// **
// **
// **
// *

这个例子中的 makeTrapezoid() 函数把返回类型声明为 some Shape,因此它返回的是某个遵循 Shape 协议的给定类型的值,而不指明任何具体的具象类型。这样书写 makeTrapezoid(),让它能表达其公开接口的基本方面——返回的值是一个图形——同时又不把构成该图形的具体类型变成公开接口的一部分。这个实现使用了两个三角形和一个正方形,但函数完全可以用各种其他方式改写成绘制梯形,而无需改变返回类型。

这个例子凸显了不透明返回类型类似泛型反面这一点:makeTrapezoid() 内部的代码可以按需返回任何类型,只要该类型遵循 Shape 协议——就像泛型函数的调用方那样;而调用该函数的代码需要像泛型函数的实现那样写成通用形式,以便能处理 makeTrapezoid() 返回的任何 Shape 值。

你也可以把不透明返回类型与泛型结合起来。下面代码中的两个函数都返回某个遵循 Shape 协议的类型的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape: shape)
}
func join<T: Shape, U: Shape>(_ top: T, _ bottom: U) -> some Shape {
    JoinedShape(top: top, bottom: bottom)
}

let opaqueJoinedTriangles = join(smallTriangle, flip(smallTriangle))
print(opaqueJoinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *

这个例子中 opaqueJoinedTriangles 的值与本章前面不透明类型解决的问题一节泛型例子中的 joinedTriangles 相同。不过与那个例子中的值不同,flip(_:) 和 join(_:_:) 把这些泛型图形操作返回的底层类型包装在不透明返回类型中,使这些类型不可见。这两个函数是泛型的,因为它们依赖的类型是泛型的,而函数的类型形参传递了 FlippedShape 和 JoinedShape 所需的类型信息。

如果带不透明返回类型的函数从多个位置返回,所有可能的返回值必须是同一类型。对于泛型函数来说,这个返回类型可以使用函数的泛型类型形参,但仍然必须是单一类型。例如,下面是一个无效的图形翻转函数版本,它为正方形加了特例:

1
2
3
4
5
6
func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
    if shape is Square {
        return shape // 错误:返回类型不一致。
    }
    return FlippedShape(shape: shape) // 错误:返回类型不一致。
}

如果你用 Square 调用这个函数,它返回 Square;否则返回 FlippedShape。这违反了"只能返回一种类型的值"这一要求,使 invalidFlip(_:) 成为无效代码。修复 invalidFlip(_:) 的一种做法是把正方形的特例移进 FlippedShape 的实现中,这样该函数就总能返回 FlippedShape 值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct FlippedShape<T: Shape>: Shape {
    var shape: T
    func draw() -> String {
        if shape is Square {
           return shape.draw()
        }
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}

必须始终返回单一类型这一要求,并不妨碍你在不透明返回类型中使用泛型。下面是一个把类型形参融入返回值底层类型的函数示例:

1
2
3
func `repeat`<T: Shape>(shape: T, count: Int) -> some Collection {
    return Array<T>(repeating: shape, count: count)
}

在这种情况下,返回值的底层类型随 T 而变化:无论传入什么图形,repeat(shape:count:) 都会创建并返回由该图形组成的数组。不过返回值始终具有相同的底层类型 [T],因此它符合"带不透明返回类型的函数只能返回单一类型的值"这一要求。

装箱协议类型

装箱协议类型有时也称为存在类型,这个说法来自"存在某个类型 T,使得 T 遵循该协议"这句话。要构造装箱协议类型,就在协议名之前写 any。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct VerticalShapes: Shape {
    var shapes: [any Shape]
    func draw() -> String {
        return shapes.map { $0.draw() }.joined(separator: "\n\n")
    }
}

let largeTriangle = Triangle(size: 5)
let largeSquare = Square(size: 5)
let vertical = VerticalShapes(shapes: [largeTriangle, largeSquare])
print(vertical.draw())

在上面的例子里,VerticalShapes 把 shapes 的类型声明为 [any Shape]——一个由装箱 Shape 元素组成的数组。数组中的每个元素可以是不同类型,而每个类型都必须遵循 Shape 协议。为了支持这种运行时的灵活性,Swift 在必要时会增加一层间接引用——这层间接引用称为装箱(box),并带有性能开销。

在 VerticalShapes 类型内部,代码可以使用 Shape 协议所要求的方法、属性和下标。例如 VerticalShapes 的 draw() 方法会调用数组中每个元素的 draw() 方法,这个方法之所以可用,是因为 Shape 要求提供 draw() 方法。相比之下,试图访问三角形的 size 属性,或者 Shape 未要求的任何其他属性或方法,都会产生错误。

对比一下 shapes 可以采用的三种类型:

  • 使用泛型,写成 struct VerticalShapes<S: Shape> 和 var shapes: [S],得到的数组其元素是某种特定的图形类型,而且这种特定类型的同一性对与该数组交互的任何代码都是可见的。

  • 使用不透明类型,写成 var shapes: [some Shape],得到的数组其元素是某种特定的图形类型,而这种特定类型的同一性被隐藏。

  • 使用装箱协议类型,写成 var shapes: [any Shape],得到的数组可以存放不同类型的元素,而这些类型的同一性被隐藏。

在这种情况下,只有装箱协议类型能让 VerticalShapes 的调用方把不同种类的图形混在一起。

当你知道装箱值的底层类型时,可以使用 as 转换。例如:

1
2
3
4
if let downcastTriangle = vertical.shapes[0] as? Triangle {
    print(downcastTriangle.size)
}
// 输出 "5"。

更多内容参见向下转换。

不透明类型与装箱协议类型的区别

返回不透明类型看起来与把装箱协议类型用作函数返回类型非常相似,但这两类返回类型在是否保留类型同一性上有所不同:不透明类型指向某一个特定类型,尽管函数的调用方看不到是哪一个;装箱协议类型则可以指向任何遵循该协议的类型。一般来说,装箱协议类型在底层类型上给你更多灵活性,而不透明类型让你能对底层类型做出更强的保证。

例如,下面是一个把装箱协议类型而不是不透明类型用作返回类型的 flip(_:) 版本:

1
2
3
func protoFlip<T: Shape>(_ shape: T) -> Shape {
    return FlippedShape(shape: shape)
}

这个版本的 protoFlip(_:) 与 flip(_:) 拥有相同的函数体,而且总是返回同一类型的值。与 flip(_:) 不同,protoFlip(_:) 返回的值并不要求始终是同一类型——它只需遵循 Shape 协议即可。换句话说,protoFlip(_:) 与调用方之间的 API 约定比 flip(_:) 宽松得多,它保留了返回多种类型值的灵活性:

1
2
3
4
5
6
7
func protoFlip<T: Shape>(_ shape: T) -> Shape {
    if shape is Square {
        return shape
    }

    return FlippedShape(shape: shape)
}

修改后的这个版本根据传入的图形,返回 Square 实例或 FlippedShape 实例。这个函数返回的两个翻转图形可能类型完全不同;这个函数的其他合法版本在对同一个图形的多个实例做翻转时,也可能返回不同类型的值。protoFlip(_:) 返回的类型信息不那么具体,意味着依赖类型信息的许多操作都无法在返回值上使用。例如,无法写出比较该函数返回值是否相等的 == 运算符。

1
2
3
let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing  // 错误

例子最后一行报错的原因有好几个。最直接的问题是 Shape 没有把 == 运算符列入协议要求。如果你尝试添加一个,接下来会遇到的问题是 == 运算符需要知道它左右两侧实参的类型——这类运算符通常接受 Self 类型的实参,与采纳该协议的具象类型匹配;而给协议加上 Self 要求后,又无法支持把协议当作类型使用时发生的那种类型抹除。

把装箱协议类型用作函数返回类型,让你能灵活地返回任何遵循该协议的类型;但这种灵活性的代价是某些操作无法在返回值上进行。这个例子展示了 == 运算符为什么不可用——它依赖特定的类型信息,而使用装箱协议类型并不会保留这些信息。

这种做法的另一个问题是图形变换无法嵌套。翻转一个三角形得到的是 Shape 类型的值,而 protoFlip(_:) 函数接受的实参类型是某个遵循 Shape 协议的类型;然而,装箱协议类型的值本身并不遵循该协议——protoFlip(_:) 返回的值并不遵循 Shape。这意味着像 protoFlip(protoFlip(smallTriangle)) 这样施加多次变换的代码是无效的,因为翻转后的图形不是 protoFlip(_:) 的合法实参。

相比之下,不透明类型保留底层类型的同一性。Swift 可以推断关联类型,这让你能在装箱协议类型无法用作返回值的地方使用不透明返回值。例如,下面是泛型中 Container 协议的一个版本:

1
2
3
4
5
6
protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
extension Array: Container { }

你不能把 Container 用作函数的返回类型,因为该协议有关联类型;你也不能把它用作泛型返回类型中的约束,因为函数体之外没有足够的信息去推断泛型类型应当是什么。

1
2
3
4
5
6
7
8
9
// 错误:带关联类型的协议不能用作返回类型。
func makeProtocolContainer<T>(item: T) -> Container {
    return [item]
}

// 错误:没有足够信息推断 C。
func makeProtocolContainer<T, C: Container>(item: T) -> C {
    return [item]
}

把不透明类型 some Container 用作返回类型,则表达了想要的 API 约定——函数返回一个容器,但拒绝指明这个容器的类型:

1
2
3
4
5
6
7
func makeOpaqueContainer<T>(item: T) -> some Container {
    return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// 输出 "Int"。

twelve 的类型被推断为 Int,这体现了类型推断对不透明类型同样有效。在 makeOpaqueContainer(item:) 的实现中,不透明容器的底层类型是 [T],此处 T 是 Int,因此返回值是整数数组,关联类型 Item 被推断为 Int。Container 上的下标返回 Item,这意味着 twelve 的类型也被推断为 Int。

不透明参数类型

除了用 some 返回不透明类型,你也可以在函数、下标或构造器的参数类型中写 some。不过当你在参数类型中写 some 时,它只是泛型的简短写法,而不是不透明类型。例如下面两个函数是等价的:

1
2
3
4
5
6
7
8
9
func drawTwiceGeneric<SomeShape: Shape>(_ shape: SomeShape) -> String {
    let drawn = shape.draw()
    return drawn + "\n" + drawn
}

func drawTwiceSome(_ shape: some Shape) -> String {
    let drawn = shape.draw()
    return drawn + "\n" + drawn
}

drawTwiceGeneric(_:) 函数声明了一个名为 SomeShape 的泛型类型形参,并约束 SomeShape 必须遵循 Shape 协议;drawTwiceSome(_:) 函数则为它的实参使用类型 some Shape,这会为该函数创建一个新的、无名的泛型类型形参,并约束该类型必须遵循 Shape 协议。由于这个泛型类型没有名字,你无法在函数的其他地方引用它。

如果你在多个参数的类型前写 some,每个泛型类型都是相互独立的。例如:

1
2
3
4
5
func combine(shape s1: some Shape, with s2: some Shape) -> String {
    return s1.draw() + "\n" + s2.draw()
}

combine(smallTriangle, trapezoid)

在 combine(shape:with:) 函数中,第一个和第二个参数的类型都必须遵循 Shape 协议,但并没有要求它们必须是同一类型的约束。调用 combine(shape:with) 时,你可以传入两个不同的图形——在这里是一个三角形和一个梯形。

与泛型一章中介绍的具名泛型类型形参语法不同,这种轻量语法不能包含泛型 where 子句或任何同类型(==)约束。此外,用这种轻量语法表达非常复杂的约束时可能难以阅读。