本文对 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 的版本不同,并且争取行文简练一些,以节约读者的时间。
与并发无关的新特性
#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
复制代码
注意:条件分支之后必须都是后缀表达式,不能是其他类型的表达式。
CGFloat
和 Double
类型可以互换使用
SE-0307 引入:Swift 现在能够在 CGFloat
和 Double
之间按需自动进行隐式转换。
如 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)
复制代码
与并发相关的新特性
async
和 await
关键字
SE-0296 为 Swift 引入了异步函数,这使得我们可以像编写同步代码那样处理异步代码。简单来说,我们需要两个步骤:第一步是用新的 async
关键字标记函数为异步,第二步是用 await
关键字来调用异步函数。这同 C# 和 JavaScript 是类似的。
当然,有 async/await 机制的编程语言除了 C# 和 JavaScript,还有 Python, F#, Kotlin, Rust, Dart 等,它们有的是实现为关键字,有的则是实现为函数或者库。Swift 的步伐虽然慢了一些,但也不算晚。
在引入 async
和 await
之前,假设我们要实现一个逻辑:从服务端拉取海量的数据记录,然后进行计算,最后上传回服务器。那么代码可能会长下面这个样子:
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
几乎一致,除了你需要让实现的类型遵守 AsyncSequence
和 AsyncIterator
,并且 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 的只读属性,以支持 async
和 throws
关键字(可以单独或者同时使用)。
举个例子,我们创建一个 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
}
复制代码
结构化并发主要的变化是引入了 Task
和 TaskGroup
这两个新的类型,它们可以让我们以独立或者协同的方式执行并发操作。
最简单的使用形式是创建一个 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 为我们提供了 high
,default
,low
以及 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花园