Swift 5.7 新特性(下)

5,301 阅读12分钟

译自 www.hackingwithswift.com/articles/24… 更多内容,欢迎关注公众号「Swift花园」

所有协议类型支持 Existentials

SE-0309 使得 Swift 放宽了把具有 Self 或关联类型的协议用作类型的禁令,转向仅基于特定属性或方法不受限制的模型。

译注:关联把协议用作类型的禁令,大家对下面这个无情的提示应该都不陌生:

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

简单来说,这意味着以下代码变得合法:

let firstName: any Equatable = "Paul"
let lastName: any Equatable = "Hudson"

Equatable 是一个有 Self 要求的协议,这意味着它提供的功能引用了特定的遵循该协议的类型。例如,Int 遵循 Equatable,所以当我们说 4 == 4 时,实际上是在运行一个接受两个整数并在它们匹配时返回 true 的函数。

我们当然可以使用类似于 func ==(first: Int, second: Int) -> Bool 的函数来实现这个功能,但这不能很好地扩展 —— 我们得编写许多这样的函数来处理布尔值、字符串、数组等。因此,Equatable 协议有这样的要求:func ==(lhs: Self, rhs: Self) -> Bool。这意味着需要能够接受两个相同类型的实例。

在 Swift 5.7 之前,只要 Self 出现在协议中,编译器就不允许我们像下面这样使用协议:

let tvShow: [any Equatable] = ["Brooklyn", 99]

从 Swift 5.7 开始,上面的代码允许的。限制被转移到我们尝试在 Swift 实际执行类型约束的地方。所以这里不能firstName == lastName 。因为之前提到, == 必须确保它有两个相同类型的实例才能工作。通过使用 any Equatable ,我们隐藏了数据的确切类型。由此获得的是对我们的数据进行运行时检查的能力,以明确我们正在处理的内容。

比如针对混合数组,我们可以这样写:

for parts in tvShow {
    if let item = item as? String {
        print("Found string: \(item)")
    } else if let item = item as? Int {
        print("Found integer: \(item)")
    }
}

或者是两个字符串:

if let firstName = firstName as? String, let lastName = lastName as? String {
    print(firstName == lastName)
}

理解这个变化的关键在于记住它使得我们能够更自由地使用协议,而不必了解类型内部的事情。

例如,我们可以编写代码来检查任意序列中的所有项目是否符合 Identifiable 协议:

func canBeIdentified(_ input: any Sequence) -> Bool {
    input.allSatisfy { $0 is any Identifiable }
}

Primary Associated Type 的轻量级同类型要求

SE-0346 添加了更新、更简单的语法来引用具有特定关联类型的协议。

例如,如果我们要编写代码以不同的方式缓存不同类型的数据,我们可能会像下面这样做:

protocol Cache<Content> {
    associatedtype Content

    var items: [Content] { get set }

    init(items: [Content])
    mutating func add(item: Content)
}

注意,这个协议现在看起来既是一个协议,又是一个泛型类型。

尖括号中的部分是 Swift 的 primary associated type,之所以冠以 primary, 是因为并不是所有的关联类型都应该在那里声明。相反,应该只列出调用代码最关心的那些类型,例如字典的键和值的类型或者 Identifiable 协议中的标识符类型。在我们的例子中,缓存的内容 —— 字符串、图像、用户等就是它的主要关联类型。

至此,我们可以像以前一样继续使用协议 —— 创建某种我们想要缓存的数据,然后创建一个遵循协议的具体缓存类型,如下所示:

struct File {
    let name: String
}

struct LocalFileCache: Cache {
    var items = [File]()

    mutating func add(item: File) {
        items.append(item)
    }
}

新的好处在这里:当我们要创建缓存时,我们本来当然可以直接创建一个特定类型:

func loadDefaultCache() -> LocalFileCache {
    LocalFileCache(items: [])
}

但更为常见的做法应当是像下面这样:

func loadDefaultCacheOld() -> some Cache {
    LocalFileCache(items: [])
}

使用 some Cache 让我们可以灵活地改变返回的缓存类型,而 SE-0346 则是在绝对的具体类型和相当模糊的不透明类型之间提供一个折衷方案。为此,我们可以特型化协议,像下面这样:

func loadDefaultCacheNew() -> some Cache<File> {
    LocalFileCache(items: [])
}

这样一来,我们既保留了在未来迁移到遵循 Cache 协议的不同类型的能力,又明确了缓存加载函数处理的内容是文件。

这种更智能的语法也可以拓展到扩展:

extension Cache<File> {
    func clean() {
        print("Deleting all cached files…")
    }
}

以及泛型约束:

func merge<C: Cache<File>>(_ lhs: C, _ rhs: C) -> C {
    print("Copying all files into a new location…")
    // now send back a new cache with items from both other caches
    return C(items: lhs.items + rhs.items)
}

但最有帮助的是 SE-0358 把主要关联类型也引入了 Swift 的标准库,包括 SequenceCollection 等都将受益。

约束的 existential 类型

SE-0353 提供了把 SE-0309 (“所有协议类型支持 Existentials ”) 和 SE-0346(“Primary Associated Type 的轻量级同类型要求”) 组合在一起的能力,因此我们可以使用 any Sequence<String> 这样的写法。

分布式 actor 隔离

SE-0336SE-0344 引入让 Actor 具备了分布式工作能力 —— 使用远程过程调用 (RPC) 来调用远程方法或者读写属性。

这其中你可能想象到的复杂性大致可以被分解为下面三点:

  1. Swift 的 位置透明 机制有效地迫使我们假设 Actor 是远程的,而实际上我们无法在编译期确定 Actor 是本地的还是远程的。因为都是使用相同的 await 调用,如果 Actor 恰好是本地的,那么调用将作为常规的本地 Actor 函数处理。
  2. Apple 没有强迫我们建立自己的 Actor 通信系统,而是提供了一个 现成的实现 ,背后的理念是 “希望少数成熟的实现最终能够登上舞台”。不过 Swift 中的所有分布式 Actor 特性都是不可知的,不管你是使用哪种通信系统。
  3. 要从一个分布式 Actor 转移到另一个分布式 Actor,我们主要是编写distributed actor,然后按需编写distributed func

例如,我们可以编写下面这样的代码来模拟跟踪卡牌交易系统:

typealias DefaultDistributedActorSystem = ClusterSystem

distributed actor CardCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

    distributed func send(card selected: String, to person: CardCollector) async -> Bool {
        guard deck.contains(selected) else { return false }

        do {
            try await person.transfer(card: selected)
            deck.remove(selected)
            return true
        } catch {
            return false
        }
    }

    distributed func transfer(card: String) {
        deck.insert(card)
    }
}

由于分布式 Actor 调用的抛出特性,如果对 person.transfer(card:) 的调用没有抛出错误,我们可以确定从一个 collector 中移除卡牌是安全的。

Swift 的目标是让你可以很容易地将你对 Actor 的了解转移到分布式 Actor 上,但是这里面有一些重要的区别。

首先,所有分布式函数都必须使用 tryawait 调用,即使该函数没有被标记为 throwing,因为网络调用出错可能会导致失败。

其次,分布式方法的所有参数和返回值必须遵循你选择的序列化过程,例如 Codable。这会在编译期被检查,因此 Swift 可以保证它能够发送和接收来自远程参与者的数据。

第三,你应该考虑调整你的 Actor API 以最小化数据请求。例如,如果你想读取分布式参与者的 usernamefirstNamelastName 属性,使用单个方法调用来请求所有这三个属性,而不是将它们作为单独的属性来请求,以避免过多的潜在网络传输。

用于结果生成器的 buildPartialBlock

SE-0348 极大地简化了实现复杂结果生成器所需的重载,正是因为这个特性 Swift 高级正则表达式才成为可能。同时,理论上它还取消了 SwiftUI 的 10 View 限制。

为了说明这个特性,请看下面这个 SwiftUI 的 ViewBuilder 简化版本:

@resultBuilder
struct SimpleViewBuilderOld {
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View {
        TupleView((c0, c1))
    }

    static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0: View, C1: View, C2: View {
        TupleView((c0, c1, c2))
    }
}

这个结果生成器可以用于函数或者属性:

@SimpleViewBuilderOld func createTextOld() -> some View {
    Text("1")
    Text("2")
    Text("3")
}

buildBlock<C0, C1, C2>() 变体接收三个 Text 视图,并返回一个包含所有这些的 TupleView。在这个简化的示例中,无法添加第四个 视图,因为我们没有提供任何重载方法。

新的 buildPartialBlock() 就是为了解决这个问题。它的工作方式类似于序列的 reduce() 方法:有一个初始值,然后通过加上新值来累计。

据此,我们可以创建一个新的结果生成器,它接收单个视图,以及如何将该视图与另一个视图组合的 block:

@resultBuilder
struct SimpleViewBuilderNew {
    static func buildPartialBlock<Content>(first content: Content) -> Content where Content: View {
        content
    }

    static func buildPartialBlock<C0, C1>(accumulated: C0, next: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        TupleView((accumulated, next))
    }
}

这样一来,我们就可以按需累加任意多的视图:

@SimpleViewBuilderNew func createTextNew() -> some View {
    Text("1")
    Text("2")
    Text("3")
}

代码看起来一样,但是类型是不同的:在之前的写法中,我们将返回一个 TupleView<Text, Text, Text>,而现在的写法我们将返回一个 TupleView<(TupleView<(Text, Text)>, Text)> —— 一个嵌套的 TupleView

提示: buildPartialBlock() 是 Swift 的语言的一部分,而不是基于特定平台的运行时,所以假如我们要适配它的话,是可以向后发布到更早的系统版本的。

隐式打开的 existentials

SE-0352 允许 Swift 在许多情况下使用协议调用泛型函数,以消除以前存在的一个有点奇怪的障碍。

例如,这是一个简单的通用函数,它能够处理任何类型的 Numberic 值:

func double<T: Numeric>(_ number: T) -> T {
    number * 2
}

如果我们直接调用它,例如double(5),那么 Swift 编译器可以选择 特型化 函数—— 创建一个直接接收 Int 的版本。这么做是出于性能考量。

而 SE-0352 引入的特性是允许我们在只知道数据符合协议而不知道具体类型时调用该函数,如下所示:

let first = 1
let second = 2.0
let third: Float = 3

let numbers: [any Numeric] = [first, second, third]

for number in numbers {
    print(double(number))
}

我们使用的实际数据类型处在一个盒子里,当我们调用盒子上的方法时,Swift 隐式调用盒子内部数据的方法。 SE-0352 也将同样的能力扩展到了函数调用:我们循环中的 number 值是 existential 类型(一个包含 IntDoubleFloat 的盒子),但 Swift 能够把盒子里的值发送给泛型 double()函数。

这个特性也有限制,比如下面的代码是无法通过编译的:

func areEqual<T: Numeric>(_ a: T, _ b: T) -> Bool {
    a == b
}

print(areEqual(numbers[0], numbers[1]))

因为 Swift 无法静态地校验(在编译期)两个值之间可以使用 == 来比较。

Swift snippets

SE-0356 引入了 snippets 的概念,用于填补工程文档中的一个小而重要的空白:当你的示例代码比简单的 API 文档复杂,又比一个示例项目简单时,可以采用它。它可以展示项目中的一个特定内容。

表面上看,这似乎很简单,可以想象,我们可以提供演示单个 API 或特定问题解决方案的 snippets,但有三个细节值得注意:

  1. 可以在注释中放置少量特殊标记,以调整 snippets 的呈现方式。
  2. 可以从命令行轻松构建和运行它们。
  3. Snippet 和 DocC 完美集成。

特殊标记有两种形式可以使用 //! 注释为每个片段创建简短描述,并且可以使用 // MARK: Hide// MARK: Show 创建不可见的块代码。想象我们需要演示代码,而代码中包含了一段和演示的内容不特别相关的工作。

例如,我们可以创建一个这样的 Snippet:

//! Demonstrates how to use conditional conformance
//! to add protocols to a data type if it matches
//! some constraint.

struct Queue<Element> {
    private var array = [Element]() 
    // MARK: Hide

    mutating func append(_ element: Element) {
        array.append(element)
    }

    mutating func dequeue() -> Element? {
        guard array.count > 0 else { return nil }
        return array.remove(at: 0)
    }
    // MARK: Show
}

extension Queue: Encodable where Element: Encodable { }
extension Queue: Decodable where Element: Decodable { }
extension Queue: Equatable where Element: Equatable { }
extension Queue: Hashable where Element: Hashable { }

let queue1 = Queue<Int>()
let queue2 = Queue<Int>()
print(queue1 == queue2)

使用 // MARK: Hide// MARK: Show 隐藏一些实现细节,让读者专注于重要的部分。

至于命令行支持,我们现在可以运行三个新的命令变体:

  • swift build --build-snippets 构建所有源目标,包括所有片段 # 构建源目标,包括片段。
  • swift build SomeSnippet 将 SomeSnippet.swift 构建为独立的可执行文件。
  • swift run SomeSnippet 立即运行 SomeSnippet.swift。

不可异步属性

SE-0340 通过允许我们将类型和函数标记为在异步上下文中不可用,部分屏蔽了 Swift 并发模型中的潜在风险情况。

要将某些内容标记为在异步上下文中不可用,需要使用 @available,然后在末尾添加 noasync,例如:

@available(*, noasync)
func doRiskyWork() {

}

在常规的同步函数中,我们可以正常调用它:

func synchronousCaller() {
    doRiskyWork()
}

假如我们试图在异步函数中也这么做,Swift 会指出错误:

func asynchronousCaller() async {
    doRiskyWork()
}

这个保护机制是一种改进,但我们不应过度依赖它。因为它无法防范我们把调用嵌套到同步函数内部的情况,例如:

func sneakyCaller() async {
    synchronousCaller()
}

上面的代码在异步上下文中运行,但调用同步函数,该函数又可以调用 noasync 函数 doRiskyWork()

所以,使用 noasync 的时候还是需要小心的。不过 Swift Evolution 提案提到 “该属性预计将用于一组相当有限的专门用例” —— 希望我们用不上这个关键字吧。

封面来自 Marliese Streefland on Unsplash