非透明类型

具有非透明返回类型的方法或者函数可以隐藏它返回值的类型信息,相较于提供一个确切的类型作为函数,它是提供一个所支持的协议来描述其返回类型。隐藏类型信息这个操作在模块和调用模块的代码之间的边界处很有用,因为返回值的基础类型可以保持私有。与返回一个协议类型不同的是,非透明类型可以保留类型标识–这样编译器可以访问类型信息,但模块的调用者不能访问。

本文译自 Swift 官方文档,解答了我在 SwiftUI 中遇到的 some View 时产生的疑惑,谈了协议在某些方面的短板及非透明类型的长处,阐述了非透明类型与范型、协议的联系和区别。

非透明类型解决了什么?

举个例子,假设你正在编写一个绘制 ASCII 艺术形状的模块,ASCII 艺术形状的基本特征可以用一个 draw() 函数来表示,这个函数会返回该艺术形状的 string 表现。你可以用这个函数作为 Shape 协议的一个要求:

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())
// *
// **
// ***

你可以使用泛型来实现像垂直翻转这样的操作,如下面的代码所示。然而,这种方法有一个重要的限制:翻转结果会暴露用于创建它的确切的泛型类型。

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())
// ***
// **
// *

像这样定义 JoinShape<T: Shape, U: Shape> 结构体,可以将两个形状垂直连接在一起,就像下面的代码所示,结果是将一个翻转的三角形与另一个三角形连接在一起的 JoinShape<FlippedShape<Triangle>, Triangle> 这样的类型。

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 艺术形状模块公共接口的类型,因为函数需要说明完整的返回类型而泄露出来。模块内部的代码可以用各种方式建立同一个形状,模块外部使用该形状的其他代码不应该需要说明关于变换列表的实现细节。像 JoinShapeFlippedShape 这样的包装类型对模块的使用者来说并不重要,它们不应该是可见的。该模块的公共接口包括加入和翻转形状等操作,这些操作应该会返回另一个 Shape 值。

返回一个非透明类型

你可以把非透明类型看作是范型的对立面,通用类型让调用函数的代码为该函数的参数选择类型,并以一种抽象于函数实现的方式返回某个值。例如,下面代码中的函数返回的类型取决于其调用者。

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

调用 max(_:_:) 的代码为 x 和 y 选择了值,这些值的类型决定了 T 的具体类型,实际上调用的代码可以使用任何符合 Comparable 协议的类型。函数内部的代码是以通用的方式编写的,因此它可以处理调用者提供的任何类型,max(_:_:) 的实现中只使用了 Comparable 类型所共有的能力。

对于一个具有非透明返回类型的函数,情况是相反的。一个非透明类型能让函数实现以一种从调用函数的代码中抽象出来的方式为它返回的值选择类型。例如,下面例子中的函数返回一个梯形,但没有暴露该形状的基础类型。

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() 可以让它表现其公有接口的基础信息—-他的返回值是一个 Shape,而不需要将形状的具体类型作为其公有接口的一部分。这个方法的实现是用了两个三角形和一个正方形,但该函数也可以被重写成以各种不同方式画一个梯形,而且不会改变他的返回类型。

这个例子强调了非透明返回类型就像范型的对立面。makeTrapezoid() 里面的代码可以返回任何它需要的类型,只要符合 Shape 协议,就像调用带范型的函数代码一样。调用该函数的代码需要像实现一个带范型的函数一样,用较通用的方式来编写,这样它就可以和 makeTrapezoid() 返回的任何 Shape 值一起工作。

你也可以将非透明返回类型与范型相结合起来。以下代码中的函数都返回一个符合 Shape 协议的某种类型的值。

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 的值与本章前面「非透明类型解决了什么?」一节中的泛型例子的 joinTriangles 相同。然而,与那个示例中的不同的是,flip(_:)join(_:_:) 将泛型操作返回的底层类型包裹在一个非透明的返回类型中,这就防止了这些类型的可见。这两个函数都是范用的,因为它们所依赖的类型是范型,函数的类型参数传递了 FlippedShapeJoinShape 所需要的类型信息。

如果一个具有非透明返回类型的函数从多个地方返回,所有可能的返回值必须具有相同的类型。对于一个范用函数,该返回类型可以使用函数的范型参数,但它仍然必须是单一具体类型。例如,这里是一个无效版本的形状翻转函数,它包含了一个翻转正方形的特殊情况:

func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
    if shape is Square {
        return shape // Error: return types don't match
    }
    return FlippedShape(shape: shape) // Error: return types don't match
}

如果你用一个 Square 调用这个函数,它就返回一个 Square,否则,它就返回一个 FlippedShape。这违反了只返回一种类型的值的要求,并使 invalidFlip(_:) 成为无效代码。修正 invalidFlip(_:) 的一个方法是将正方形的特殊情况移到 FlippedShape 的实现中,让这个函数总是返回一个 FlippedShape 值:

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")
    }
}

始终返回单一类型的要求并不妨碍你在非透明的返回类型中使用泛型。下面是一个函数的例子,它将其类型参数融入到返回值的基础类型中。

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

在这种情况下,返回值的底层类型因 T 而异:无论传递给它什么形状,repeat(shape:count:) 都会创建并返回该形状的数组。尽管如此,返回值总是具有相同的底层类型 [T],因此它遵循了具有非透明返回类型的函数必须只返回单一类型的值的要求。

非透明类型和协议的区别

返回一个非透明类型与使用协议类型作为函数的返回类型看起来非常相似,但这两种返回类型在是否保留类型标识方面有所不同。一个非透明类型指的是一种特定的类型,尽管函数的调用者无法看到是哪种类型;协议类型指任何符合协议的类型。简单来说,协议类型让你对它们存储的值的底层类型有更多的灵活性,而非透明类型让你对这些底层类型需要做出更强的保证。

例如,这里有一个使用协议类型作为返回类型而不是非透明类型的 flip(_:) 方法:

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

这个版本的 protoFlip(_:)flip(_:) 的函数体是相同的,且它总是返回一个相同类型的值。与 flip(_:) 不同的是,protoFlip(_:) 返回的值不需要总是具有相同的类型–它只需要符合 Shape 协议。换句话说,protoFlip(_:) 与调用者签订的 API 要求要比 flip(_:) 宽松得多,它保留了返回多种类型值的灵活性:

func protoFlip<T: Shape>(_ shape: T) -> Shape {
    if shape is Square {
        return shape
    }

    return FlippedShape(shape: shape)
}

修改后的代码版本会返回一个 Square 的实例或 FlippedShape 的实例,这取决于传递进来的形状。该函数返回的两个翻转形状可能具有完全不同的类型,当翻转多个相同形状的实例时,该函数的其他有效版本可能返回不同类型的值。protoFlip(_:) 的返回类型信息不那么具体,这意味着许多依赖于类型信息的操作在返回值上是不可用的。例如,不可能写一个 == 操作符来比较这个函数返回的结果。

let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing  // Error

示例最后一行错误的发生有几个原因,最直接的问题是 Shape 没有把 == 操作符作为协议要求的一部分,如果你试着添加,下一个问题是 == 操作符需要知道它的左手和右手参数的类型,这类操作符通常会接受类型为 Self 的参数,为了与采用协议的任何具体类型相匹配,但在协议中添加一个 Self 要求也不允许在你将协议作为类型使用时发生类型擦除。

使用协议类型作为函数的返回类型,可以灵活地返回任何符合协议的类型。然而,这种灵活性的代价是,有些操作在返回值上是不可能的。这个例子显示了 == 操作符是如何不可用的–它依赖于特定的类型信息,而这些信息并没有通过使用协议类型来保存。

这种方法的另一个问题是,形状变换不能嵌套。翻转三角形的结果是一个 Shape 类型的值,protoFlip(_:) 函数接受一个符合 Shape 协议的某个类型的参数。然而,一个协议类型的值并不符合该协议,protoFlip(_:) 返回的值并不符合 Shape 协议。这意味着像 protoFlip(protoFlip(smallTriange)) 这样应用多次变换的代码是无效的,因为被翻转的形状不是 protoFlip(_:) 的有效参数。

相反,非透明类型会保留底层类型的标识。Swift可以推断关联类型,这让你可以在协议类型不适用的地方使用非透明类型作为返回值。例如,这是一个来自 GenericsContainer 协议:

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
extension Array: Container { }

你不能使用Container作为函数的返回类型,因为该协议有一个关联类型,你也不能在一个范型返回类型中使用它作为约束,因为在函数体之外没有足够的信息来推断范型需要是什么。

// Error: Protocol with associated types can't be used as a return type.
func makeProtocolContainer<T>(item: T) -> Container {
    return [item]
}

// Error: Not enough information to infer C.
func makeProtocolContainer<T, C: Container>(item: T) -> C {
    return [item]
}

而使用非透明类型 some Container 作为返回类型,表达了所需的 API 要求–函数返回一个 Container,但拒绝指定 Container 的类型:

func makeOpaqueContainer<T>(item: T) -> some Container {
    return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// Prints "Int"

twelve 的类型被推断为 Int,这说明了类型推断对非透明类型有效。在 makeOpaqueContainer(item:) 的实现中,非透明容器的底层类型是 [T]。在这种情况下,TInt,所以返回值是一个整数数组,Item 关联类型被推断为 IntContainer 上的下标返回 Item,也就是说,twelve 的类型也被推断为 Int