Swift 5.5新特性(上)

Swift 5.5新特性(上)

本文对 www.whatsnewinswift.com/?from=5.4&t… 进行了节选和翻译

相比于 Xcode 和 SwiftUI 的新特性和改进,Swift 语言本身在 5.5 版本迎来的变化可谓巨大了。Paul Hudson 在其 “What's new in Swift?” 网站上已经更新了 Swift 5.4 到 Swift 5.5 的变化,文档和范例都非常详细,同时也很琐碎。笔者挑选了自己认为比较重要的特性,在本文中和读者一起探索学习。

动手实践是最好的学习方式。建议想要尝试和学习 Swift 5.5 新特性,同时又希望省点力的读者,可以下载 Paul 放在 Github 上的 Playground,利用里面的代码快速上手实践。本文在介绍 Swift 5.5 新特性时,也会直接使用该 Playground 里面的代码范例。但是本文的组织结构会与 Paul 的版本不同,并且争取行文简练一些,以节约读者的时间。

what's-new-in-swift-5-5

与并发无关的新特性

#if 后缀成员表达式

SE-0308 使得 Swift 可以在后缀成员表达式前使用 #if 条件。代码范例如下:

Text("Welcome")
#if os(iOS)
    .font(.largeTitle)
#else
    .font(.headline)
#endif
复制代码

条件还可以嵌套:

#if os(iOS)
    .font(.largeTitle)
    #if DEBUG
        .foregroundColor(.red)
    #endif
#else
    .font(.headline)
#endif
复制代码

注意:条件分支之后必须都是后缀表达式,不能是其他类型的表达式。

CGFloatDouble 类型可以互换使用

SE-0307 引入:Swift 现在能够在 CGFloatDouble 之间按需自动进行隐式转换。

如 Paul 所言,这真的是一个微小但却提升 Swift 程序员生活质量的改进。

大量使用 CGFloat 的 API,现在由 Swift 默默地帮你桥接到 Double

带关联值的枚举类型的 Codable 自动合成

SE-0295 升级了 Swift 的 Codable 系统,现在能支持带关联值的枚举。例如:

enum Weather: Codable {
    case sun
    case wind(speed: Int)
    case rain(amount: Int, chance: Int)
}
复制代码

上面的代码有一个简单的 case,一个带单一关联值的 case,还有一个带两个关联值的 case。我们可以在 Swift 5.5 中用 JSONEncoder 或者其他类型的编码器对下面的枚举变量进行编码并取得 JSON 字符串。

let forecast: [Weather] = [
    .sun,
    .wind(speed: 10),
    .sun,
    .rain(amount: 5, chance: 50)
]

do {
    let result = try JSONEncoder().encode(forecast)
    let jsonString = String(decoding: result, as: UTF8.self)
    print(jsonString)
} catch {
    print("Encoding error: \(error.localizedDescription)")
}
复制代码

lazy 关键字现在也能用于局部作用域

func printGreeting(to: String) -> String {
    print("In printGreeting()")
    return "Hello, \(to)"
}
    
func lazyTest() {
    print("Before lazy")
    lazy var greeting = printGreeting(to: "Paul")
    print("After lazy")
    print(greeting)
}
    
lazyTest()
复制代码

在实践中,这个特性对于选择性运行代码非常有用:你可以以懒加载的方式准备某个结果,但只有在实际用到该结果时才会执行相关的工作。

属性包装器现在可用于函数和闭包的参数

SE-0293 拓展了属性包装器,现在它们可以应用于函数和闭包的参数。

对参数应用属性包装器并不会改变参数传递的不可变属性,并且你仍然可以通过下划线来访问包装器内封装的类型。

看下面的代码:

func setScore1(to score: Int) {
    print("Setting score to \(score)")
}

// 调用
setScore1(to: 50)
setScore1(to: -50)
setScore1(to: 500)
复制代码

假如我们希望分数只能处于 0...100 的范围,我们可以编写一个简单的属性包装器:

@propertyWrapper
struct Clamped<T: Comparable> {
    let wrappedValue: T
    
    init(wrappedValue: T, range: ClosedRange<T>) {
        self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}
复制代码

然后把上面的函数改写成:

func setScore2(@Clamped(range: 0...100) to score: Int) {
    print("Setting score to \(score)")
}

setScore2(to: 50)
setScore2(to: -50)
setScore2(to: 500)
复制代码

用相同的数值调用 setScore2() 产生的结果和 setScore() 不同,因为数字会被 clamped 为 50,0,100。

在泛型上下文中使用静态成员查找

SE-0299 使得 Swift 可以在泛型函数中执行静态成员查找。听起来有点晦涩,看下面的例子更容易理解。

之前我们在 SwiftUI 中可能写过这样的代码:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(SwitchToggleStyle())
复制代码

现在可以改成这样:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(.switch)
复制代码

与并发相关的新特性

asyncawait 关键字

SE-0296 为 Swift 引入了异步函数,这使得我们可以像编写同步代码那样处理异步代码。简单来说,我们需要两个步骤:第一步是用新的 async 关键字标记函数为异步,第二步是用 await 关键字来调用异步函数。这同 C# 和 JavaScript 是类似的。

当然,有 async/await 机制的编程语言除了 C# 和 JavaScript,还有 Python, F#, Kotlin, Rust, Dart 等,它们有的是实现为关键字,有的则是实现为函数或者库。Swift 的步伐虽然慢了一些,但也不算晚。

在引入 asyncawait 之前,假设我们要实现一个逻辑:从服务端拉取海量的数据记录,然后进行计算,最后上传回服务器。那么代码可能会长下面这个样子:

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // 省略复杂的网络代码,这里我们直接用100000条记录来代替
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // 对数组求和然后求平均值
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // 省略网络代码,发送回服务器
    DispatchQueue.global().async {
        completion("OK")
    }
}
复制代码

上面的网络代码我有意用捏造的数据来代替了,因为网络部分跟我们的主题无关。读者只需要知道这些函数很耗时,所以我们采用了完成闭包来处理,而不是阻塞的方式。当我们要使用这几个函数时,我们需要将它们链接起来,给每个函数调用都提供完成闭包。代码可能如下:

fetchWeatherHistory { records in
    calculateAverageTemperature(for: records) { average in
        upload(result: average) { response in
            print("Server response: \(response)")
        }
    }
}
复制代码

希望你能看出上面这种方式的问题:

  • 完成闭包可能会被多处调用,也可能被忘记调用
  • @escaping (String) -> Void 这样的参数语法阅读起来相对困难
  • 随着每一层完成闭包的增加,调用方的代码结构会演变成所谓的“末日金字塔”(也常称为“回调地狱”)
  • 在 Swift 5.0 引入 Result 之前,完成处理要回传错误也是更加困难

在 Swift 5.5,我们可以通过标记这些函数为异步,并且返回值而不是依赖完成闭包来解决上面提到的问题,代码如下:

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}
复制代码

借助异步返回值的语法,我们已经可以移除很多代码,而调用方的代码则更简洁:

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}
复制代码

如你所见,所有的闭包和缩进都不见了,这使得代码的形式变成所谓的“直行代码” —— 除去 await 关键字,它们看起来就跟同步代码一样。

对于异步函数的工作方式,有一些很直接且特定的规则:

  • 同步函数不能直接调用异步函数 —— 这样做不合理,因此 Swift 会抛出错误
  • 异步函数可以调用其他异步函数,但同时也可以调用同步函数
  • 假设你同时有可供同步和异步调用的函数,Swift 会根据当前上下文优选相应的版本 —— 如果调用方当前是异步的 Swift 就会调用异步的函数,否则它就会调用同步的函数。

上面的最后一点很重要,因为这使得库的开发者可以同时提供同步和异步的函数,而不必为异步版本另行命名。

新增的 async/await 能够完美地配合 try/catch 一起使用,这意味着异步函数或者构造器可以按需抛出错误。Swift 在这里施加的唯一限制是关键字的顺序,调用方和函数刚好是相反的。

看下面的代码:

enum UserError: Error {
    case invalidCount, dataTooLong
}

func fetchUsers(count: Int) async throws -> [String] {
    if count > 3 {
        throw UserError.invalidCount
    }

    return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}

func save(users: [String]) async throws -> String {
    let savedUsers = users.joined(separator: ",")

    if savedUsers.count > 32 {
        throw UserError.dataTooLong
    } else {
        return "Saved \(savedUsers)!"
    }
}

func updateUsers() async {
    do {
        let users = try await fetchUsers(count: 3)
        let result = try await save(users: users)
        print(result)
    } catch {
        print("Oops!")
    }
}
复制代码

我们可以看到,定义可抛出错误的异步函数的关键字顺序是 async throws,而调用方则需要写作 try await

async/await:异步序列

SE-0298 引入了一个新的协议:AsyncSequence,用于遍历异步的序列。

AsyncSequence 的使用方式跟 Sequence 几乎一致,除了你需要让实现的类型遵守 AsyncSequenceAsyncIterator,并且 next 方法必须以 async 标记。当迭代推进到序列的末尾时, next() 要返回 nil,这和 Sequence 是一样的。

举个例子,我们实现一个 DoubleGenerator,从 1 开始,每次被调用时返回前一个数值的两倍:

struct DoubleGenerator: AsyncSequence {
    typealias Element = Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 1

        mutating func next() async -> Int? {
            defer { current &*= 2 }

            if current < 0 {
                return nil
            } else {
                return current
            }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator()
    }
}
复制代码

**提示:**如果你把上面代码中的 async 都移除,你相当于拥有了一个完成相同事情的 Sequence —— 所以说两种序列很相似。当然,相应的协议约束也要改变

一旦我们有了异步序列,我们就可以用 for await 语句在异步上下文中遍历它,像下面这样:

func printAllDoubles() async {
    for await number in DoubleGenerator() {
        print(number)
    }
}
复制代码

AsyncSequence 协议还提供了许多常见方法的默认实现,包括 map()compactMap()allSatisfy() 等。 例如,我们可以用 contains 来检查生成器是否包含特定数字:

func containsExactNumber() async {
    let doubles = DoubleGenerator()
    let match = await doubles.contains(16_777_216)
    print(match)
}
复制代码

当然,这些方法都需要在异步上下文中使用。

更高效的只读属性

SE-0310 升级了 Swift 的只读属性,以支持 asyncthrows 关键字(可以单独或者同时使用)。

举个例子,我们创建一个 BundleFile struct 来加载一个文件的内容,可能遇到文件不存在、文件内容无法读取、或者内容太大读取时间很长等等情况。我们可以像下面这样标记 contents 属性为 async throws:

enum FileError: Error {
    case missing, unreadable
}

struct BundleFile {
    let filename: String

    var contents: String {
        get async throws {
            guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw FileError.missing
            }

            do {
                return try String(contentsOf: url)
            } catch {
                throw FileError.unreadable
            }
        }
    }
}
复制代码

因为 contents 同时是异步和可抛出错误的,我们必须使用 try await 来读取:

func printHighScores() async throws {
    let file = BundleFile(filename: "highscores")
    try await print(file.contents)
}
复制代码

结构化并发

SE-0304 引入了一整套执行、取消以及监控并发的操作,这些是基于 async/await 关键字和异步序列。

出于演示的目的,我们引入下面这两个函数 —— 一个模拟从特定地点拉取天气指数的异步函数,一个获取斐波那契数列指定位置上的数字的同步函数。

enum LocationError: Error {
    case unknown
}

func getWeatherReadings(for location: String) async throws -> [Double] {
    switch location {
    case "London":
        return (1...100).map { _ in Double.random(in: 6...26) }
    case "Rome":
        return (1...100).map { _ in Double.random(in: 10...32) }
    case "San Francisco":
        return (1...100).map { _ in Double.random(in: 12...20) }
    default:
        throw LocationError.unknown
    }
}

func fibonacci(of number: Int) -> Int {
    var first = 0
    var second = 1

    for _ in 0..<number {
        let previous = first
        first = second
        second = previous + first
    }

    return first
}
复制代码

结构化并发主要的变化是引入了 TaskTaskGroup 这两个新的类型,它们可以让我们以独立或者协同的方式执行并发操作。

最简单的使用形式是创建一个 Task 对象,然后把希望执行的异步操作传给它。这会立即启动一个异步线程来执行,而我们可以用 await 来等待结果完成。

比如,我们可以在后台线程多次调用 fibonacci(of:),以取代序列中的前50个数字:

func printFibonacciSequence() async {
    let task1 = Task { () -> [Int] in
        var numbers = [Int]()

        for i in 0..<50 {
            let result = fibonacci(of: i)
            numbers.append(result)
        }

        return numbers
    }

    let result1 = await task1.value
    print("斐波那契数列中的前50个数字: \(result1)")
}
复制代码

如你所见,我显式编写了 Task { () -> [Int] in } 以便 Swift 知道任务会返回。我们也可以利用类型推断和 map 函数,写出下面这样更极简的代码:

let task1 = Task {
    (0..<50).map(fibonacci)
}
复制代码

再次强调,任务一经创建就会开始运行。printFibonacciSequence() 会在其所处的线程上继续往下执行,同时斐波那契数列的数字也被计算。

**提示:**我们的任务操作是一个非逃逸闭包,因为任务是即时运行。因此当你在一个类或者结构体中使用 Task 时,你并不需要使用 self 来访问属性或者方法。

当我们要读取完成的数字时,await task1.value 能够确保 printFibonacciSequence() 暂定住,直到任务完成输出就绪。假设你并不需要关心任务的返回结果 —— 只需要任务启动,任其自行结束 —— 那么你并不需要存储任务。

对于会抛出未捕获错误的任务操作,读取任务的 value 属性也会自动抛出这些错误。因此,我们可以代码中同时编写多个任务,等待它们全部完成:

func runMultipleCalculations() async throws {
    let task1 = Task {
        (0..<50).map(fibonacci)
    }

    let task2 = Task {
        try await getWeatherReadings(for: "Rome")
    }

    let result1 = await task1.value
    let result2 = try await task2.value
    print("斐波那契数列中的前50个数字: \(result1)")
    print("罗马的天气指数: \(result2)")
}
复制代码

Swift 为我们提供了 highdefaultlow 以及 background 几种内建的任务优先级,可以通过在创建任务时由构造器 Task(priority: .high) 来定制。如果仅针对苹果的平台,还可以使用我们更为熟悉的 userInitiated 代替 hight,使用 utility 代替 low,但 userInteractive 是保留给主线程使用的。

除了执行操作,Task 还为我们提供了一些静态方法以便控制代码的运行:

  • 调用 Task.sleep() 会导致当前任务休眠指定纳秒的时间,所以,要指定 1秒,参数要提供 1_000_000_000
  • 调用 Task.checkCancellation() 会检查是否有人通过 cancel() 方法取消了任务,如果有则会抛出一个 CancellationError.
  • 调用 Task.yield() 会挂起当前任务一段时间,以便让出时间片给其他正在等待的任务。这个 API 是很重要,尤其是在你在一个循环中执行开销非常昂贵的工作时。

我们可以用下面的代码来理解上面几种操作:

func cancelSleepingTask() async {
    let task = Task { () -> String in
        print("Starting")
        await Task.sleep(1_000_000_000)
        try Task.checkCancellation()
        return "Done"
    }

    // 任务已经开始,但我们趁它处于休眠时把它取消掉
    task.cancel()

    do {
        let result = try await task.value
        print("结果: \(result)")
    } catch {
        print("任务已经被取消")
    }
}
复制代码

在上面的代码中,Task.checkCancellation() 会发现任务已经被取消,于是立即抛出 CancellationError,但这个错误并不会马上来到我们面前,知道我们尝试读取 task.value

提示: 我们可以使用 task.result 来获取一个 Result 的值,它包含了任务成功或者失败的值。比如,上面的代码我们会获得 Result<String, Error>。这就不要求 try 语句了,因为我们需要自行处理成功和失败的情况。

为了避免篇幅过长,Swift 5.5 的新特性介绍将拆分为两篇文章来完成。


更多文章,欢迎关注微信公众号:Swift花园

分类:
iOS
标签: