本文基于 Session 10164 What’s new in Swift 整理。
1. 使用 if/else 和 switch 语句作为表达式
Swift 5.9 允许将 if/else
和 switch
语句用作表达式,提供了一种新的整理代码的方法。
如果我们希望根据某些复杂的条件初始化 let 变量,可能会写出这种难以阅读的复合三元表达式:
let bullet =
isRoot && (count == 0 || !willExpand) ? ""
: count == 0 ? "- "
: maxDepth <= 0 ? "▹ " : "▿ "
使用 If
表达式让我们可以使用更易读的 if
语句链:
let bullet =
if isRoot && (count == 0 || !willExpand) { "" }
else if count == 0 { "- " }
else if maxDepth <= 0 { "▹ " }
else { "▿ " }
如果我们有一个全局变量或存储属性:
let attributedName = AttributedString(markdown: displayName)
在我们希望新增一个条件时,我们必须使用一个立即执行的闭包:
let attributedName = {
if let displayName, !displayName.isEmpty {
AttributedString{markdown: displayName)
} else {
"Untitled"
}()
使用 if
表达式可以留下更简洁的代码:
let attributedName =
if let displayName, !displayName.isEmpty {
AttributedString(markdown: displayName)
} else {
"Untitled"
}
2. Result builder 的更准确的编译器诊断
Result builder 是驱动 SwiftUI 等功能的声明性语法。以前有错误的 Result builder 需要很长时间才会提示失败,因为类型检查探索了许多可能的无效路径。
Swift 5.9 在其类型检查的性能、代码补全提示和错误信息提示上有进一步的优化,尤其关注无效代码的优化。从 Swift 5.8 开始,无效代码的类型检查速度更快,并且无效代码的错误信息提示更加精确。
以前某些无效代码可能会导致 Result builder 的不同部分出现误导性错误。例如在 Swift 5.7 中,以下代码的错误提示存在误导:
struct ContentView: View {
enum Destination { case one, two }
var body: some View {
List {
NavigationLink(value: .one) { // ⬅️⬅️⬅️⬅️⬅️⬅️
// The issue actually occurs here
Text("one")
}
NavigationLink(value: .two) {
Text("two")
}
}.navigationDestination(for: Destination.self) {
$0.view // ❌❌❌❌❌
// Value of type 'ContentView.Destination' has no member 'view'
}
}
}
错误原因是 NavigationLink
的构造方法要求 value
符合 Hashable
协议:
extension NavigationLink where Destination == Never {
// ...
public init<P>(value: P?, @ViewBuilder label: () -> Label) where P : Hashable
// ...
}
在 Swift 5.9 中,我们会收到更准确的编译器诊断提示,从而定位问题:
struct ContentView: View {
enum Destination { case one, two }
var body: some View {
List {
NavigationLink(value: .one) { // ❌❌❌❌❌
// Cannot infer contextual base in reference to member 'one'
Text("one")
}
NavigationLink(value: .two) {
Text("two")
}
}.navigationDestination(for: Destination.self) {
$0.view
}
}
}
以上示例问题解决可以通过让
Destination
实现Hashable
协议:enum Destination: Hashable { case one case two func hash(into hasher: inout Hasher) { switch self { case .one: hasher.combine(1) case .two: hasher.combine(2) } } }
3. 使用 Type Parameter Pack 支持参数长度重载
泛型可以被编译器类型推断。例如,数组类型使用泛型来提供数组 —— Arrat<Element>
,使用数组时只提供元素即可,无需指定显式参数,因为编译器可以根据元素值进行类型推断。
下面是一个 evaluate()
API,它接受 Request
类型参数并生成强类型值。例如我们通过 Request<Bool>
参数,可以返回 Bool
结果:
struct Request<Result> {
let result: Result
}
struct RequestEvaluator {
func evaluate<Result>(_ request: Request<Result>) -> Result {
return request.result
}
}
func evaluate(_ request: Request<Bool>) -> Bool {
return RequestEvaluator().evaluate(request)
}
假如一些 API 不仅希望对具体类型进行抽象,还希望对传入的参数数量进行抽象。比如一个函数可能接受一个 request
并返回一个结果,或者接受两个 request
并返回两个结果:
let value = RequestEvaluator().evaluate(request)
let (x, y) = RequestEvaluator().evaluate(r1, r2)
let (x, y, z) = RequestEvaluator().evaluate(r1, r2, r3)
在 Swift 5.9 之前,实现此模式的唯一方法是为 API 支持的每个特定参数长度添加重载:
func evaluate<Result>(_:) -> (Result)
func evaluate<R1, R2>(_:_:) -> (R1, R2)
func evaluate<R1, R2, R3>(_:_:_:) -> (R1, R2, R3)
func evaluate<R1, R2, R3, R4>(_:_:_:_:)-> (R1, R2, R3, R4)
func evaluate<R1, R2, R3, R4, R5>(_:_:_:_:_:) -> (R1, R2, R3, R4, R5)
func evaluate<R1, R2, R3, R4, R5, R6>(_:_:_:_:_:_:) -> (R1, R2, R3, R4, R5, R6)
但这种方法有局限性,传递的参数数量的上限是人为的,如果传递未定义数量的参数,则会导致编译错误。在上述示例中,由于没有可以处理超过 6 个参数的重载,但如果传递 7 个参数,导致编译错误:
let results = evaluator.evaluate(ri, r2, r2, r3, r4, r5,16, r7) // ❌❌❌❌❌
// Extra argument in call
在 Swift 5.9 中,泛型系统通过启用参数长度的泛型抽象,获得了对此 API 模式的支持。 这是通过一种新的语言概念来完成的,该概念可以表示“Packed”在一起的多个单独的类型参数。 这个新概念称为 Type Parameter Pack。我们需要做的是 <Result>
替换为 <each Result>
。
使用 Type Parameter Pack,将参数长度具有单独重载的 API 折叠为单个函数:
func evaluate<each Result>(_: repeat Request<each Result>) -> (repeat each Result)
evaluate()
现在可以处理所有参数长度,不需要人为限制。该函数返回括号中的每个结果实例,该实例可以是单个值,也可以是包含每个值的元组:
struct Request<Result> {
let result: Result
}
struct RequestEvaluator {
func evaluate<each Result>(_ request: repeat Request<each Result>) -> (repeat each Result) {
return (repeat (each request).result)
}
}
let requestEvaluator = RequestEvaluator()
let result = requestEvaluator.evaluate(
Request(result: 1),
Request(result: true),
Request(result: "Hello"))
print(result)
// (1, true, "Hello")
调用现在可以处理任意数量参数的新函数,就像调用固定长度的重载函数一样。 Swift 根据我们调用函数推断每个参数的类型以及总数。更多有关 Type Parameter Pack 的内容,可以参考 WWDC23 Session Generalize APIs with parameter packs。
4. 使用 Swift 宏进行 API 设计
Swift 5.9 提供了一个新的工具,使用新的宏系统进行富有表现力的 API 设计。通过宏来扩展语言本身的功能,消除样板文件并释放更多 Swift 的表达能力。以断言函数为例,它检查条件是否为 true
。 如果条件为 false
,断言将停止程序。发生这种情况时,开发者获得错误信息很少:
assert(max(a, b) == c)
我们需要添加一些日志记录或进行调试中才能了解更多信息。 在 XCTest 中已经有了一些优化,可以获取两个值:
XCAssertEqual(max(a, b), c) // XCTAssertEqual failed: ("10") is not equal to ("17")
但我们仍然不知道这里哪个值是错误的。 是 a、b 还是 max()
函数的结果? 这种方式不能进行所有的检查。Apple 之前未在 Swift 中改进这一点,但宏使其成为可能。在此示例中,assert()
语法讲被 #assert()
宏替换。 所以当断言失败时它可以提供更丰富的信息:
import PowerAssert
#assert(max(a, b)) // Type 'Int' cannot be a used as a boolean; test for '!= 0' instead
在 Swift 中,宏是 API,就像类型或函数一样,通过导入定义它们的 Module 来访问它们,同时宏作为 Package 分发。 这里的断言宏来自 PowerAssert 库,这是一个在 GitHub上提供的开源 Swift 包。我们查看 PowerAssert 宏声明,它是用 macro
关键字引入的,但除此之外,它看起来很像一个函数:
public macro assert(_ condition: Bool)
有一个 Bool
参数用于检查条件,如果该宏产生一个值,则该结果类型将使用通常的 ->
箭头语法编写。
宏的使用将根据参数进行类型检查,如果我们在使用宏时犯了错误,将在宏展开之前立即收到有用的错误消息,以可预测的方式增强程序的代码。大多数宏被定义为“外部宏”,通过字符串指定宏实现的模块和类型:
public macro assert(_ condition: Bool) = #externalMacro(
module: “PowerAssertPlugin”,
type: “PowerAssertMacro"
)
外部宏在独立进程、安全沙盒的编译器插件的单独程序中定义,Swift 编译器从源码中提取宏的调用,转化为原始语法树传递给插件。宏的实验对原始语法树的展开,并生成新的语法树。编译器插件将新的语法树序列化后插入到源码,然后重新集成到 Swift 程序中。
Swift 提供了固定的宏角色:
总的来说 Swift 宏可以分为两大类:
- Freestanding(独立宏):可以独立存在的宏,不依赖已有的代码。
- Attached(绑定宏):需要绑定到特定源码位置的宏,如属性、方法、类等。
其中, Freestanding 宏可以分为 Expression、Declaration。Attached 宏可以分为 Peer、Accessor、MemberAttribute、Member、Conformance。
Swift 宏提供了一种新工具,作为更具表现力的 API 来消除 Swift 代码中的样板文件,从而帮助释放 Swift 的表现力。宏对它们的输入进行类型检查,生成正常的 Swift 代码,并在程序中的定义点进行集成,因此它们的效果很容易推理。当我们需要了解宏的作用时,Xcode 也支持宏展开后的源码进行断点调试。WWDC 23 Session Expand on Swift macros 和 Write Swift macros 提供了更多有关 Swift 宏的信息。
5. 使用 Swift 重写的 Foundation 框架
Swift 是一种可扩展的语言。这种可扩展性意味着我们能够将 Swift 推向比 Objective-C 更广泛的地方,比如推向低级系统,而以前我们可能需要使用 C 或 C++。 这意味着将 Swift 更清晰的代码和安全性保证带到更多地方。Apple 最近开源了用 Swift 重写的 Foundation 框架。这一举措将导致 Foundation 在 Apple 和非 Apple 平台上的单一共享实现:
从 MacOS Sonoma 和 iOS 17 开始,Date
和 Calendar
等基本类型、Locale
和 AttributedString
等格式化和国际化基本功能、 JSON 编码和解码的都有了新 Swift 实现。并且性能方面的胜利非常显着。
Calendar
计算重要日期的能力可以更好地利用 Swift 的值语义来避免中间分配,在某些基准测试中获得超过 20% 的改进。使用 FormatStyle
进行的 Date 格式化也获得了一些重大性能升级,与使用标准日期和时间模板进行格式化的基准相比,有 150% 的巨大改进。
在 JSON 解码上。 JSONDecoder 和 JSONEncoder 全新的 Swift 实现,消除了与 Objective-C 集合类型之间高昂的转换代价。在 Swift 中解析 JSON 以初始化 Codable
类型的紧密集成也提高了性能。 在解析测试数据的基准测试中,新的实现速度快了 2 到 5 倍。这些改进不仅来自于降低了从旧的 Objective-C 实现到 Swift 的桥接成本,还来自于基于 Swift 的新实现速度更快。
以一个基准测试为例,在 Ventura 中,由于桥接成本的原因,从 Objective-C 调用 enumerateDates
比从 Swift 调用稍快。 在 MacOS Sonoma 中,从 Swift 调用相同的功能要快 20%:
6. 使用 consuming 交出结构体的所有权
有时,在系统的较低级别上运行代码时,我们需要更细粒度的控制才能实现必要的性能。Swift 5.9 引入了一些新功能,可帮助我们实现这种级别的控制。这些功能侧重于所有权的概念,即哪部分代码在应用程序中传递时“拥有”某个值。这里有一个非常简单的 FileDescriptor
,为低级系的统调用提供了更好的 Swift 接口:
struct FileDescriptor {
private var fd: CInt
init(descriptor: CInt) { self.fd = descriptor }
func write(buffer: [UInt8]) throws {
let written = buffer.withUnsafeBufferPointer {
Darwin.write(fd, $0.baseAddress, $0.count)
}
// ...
}
func close() {
Darwin.close(fd)
}
}
但使用此 API 有一些容易出错的地方,例如我们可能会在调用 close()
之后尝试写入文件。或者忘记调用 close()
导致资源泄漏。
一种解决方案是使其成为一个带有 deinit()
方法的类,当类型超出范围时自动调用 close()
。使用类也有一些缺点,例如进行额外的内存分配、引用语义导致导致竞争条件,或者无意中被存储。
实际上,使用结构体,结构体的行为也类似于引用类型。它保存一个引用真实值的整数,该值是一个打开的文件。该类型的副本还可能导致在应用程序中共享可变状态,从而导致错误。我们想要的是抑制复制此结构体的能力。
Swift 类型,无论是结构体还是类,默认情况下都是可复制的。大多数时候这是正确的。但有时这种隐式复制并不是我们想要的,特别是当复制值时可能会导致正确性问题。 在 Swift 5.9 中,我们可以使用这种新语法来做到这一点,该语法可应用于结构体和枚举,并抑制隐式复制类型的能力。
一旦某个类型是不可复制的,我们就可以给它一个 deinit()
,就像给一个类一样,当该类型的值超出范围时,该方法将被运行:
struct FileDescriptor: ~Copyable {
private var fd: CInt
init(descriptor: CInt) { self.fd = descriptor }
func write(buffer: [UInt8]) throws {
let written = buffer.withUnsafeBufferPointer {
Darwin.write(fd, $0.baseAddress, $0.count)
}
// ...
}
func close() {
Darwin.close(fd)
}
deinit {
Darwin.close(fd)
}
}
close()
方法也可以标记为 consuming
,调用 consuming
方法或参数,会将值的所有权交给所调用的方法。 由于我们的类型不可复制,因此放弃所有权意味着我们无法再使用该值:
consuming func close() {
Darwin.close(fd)
}
close()
被标记为 consuming
,而不是默认的 “borrowing”,那么 close()
一定是结构体的最终使用。这意味着,如果我们先关闭文件,然后尝试调用另一个方法,例如 write()
,我们将在编译时收到错误消息,而不是运行时失败。编译器还会指出消费使用发生在哪里:
let file = FileDescriptor(fd: descriptor)
file.close() // Compiler will indicate where the consuming use is
file.write(buffer: data) // ❌❌❌
// Compiler error: 'file' used after consuming
不可复制类型是 Swift 系统级编程的一个强大的新功能,但仍处于早期阶段。Swift 的更高版本将扩展泛型代码中的不可复制类型。
7. Swift 与 C++ 的互操作性
许多应用程序具有用 C++ 实现的核心业务逻辑,与其交互并不那么容易。 通常,这意味着添加额外的桥接层,从 Swift 到 Objective-C,然后再到 C++。 Swift 5.9 引入了直接从 Swift 与 C++ 类型或函数交互的能力。 C++ 互操作性的工作方式就像 Objective-C 互操作性一样,将 C++ API 直接映射到 Swift 代码中。
C++ 拥有自己的类、方法、容器等概念。 Swift 编译器理解常见的 C++ 习惯用法,因此许多类型可以直接使用。例如,此 Person 类型定义了 C++ 值类型所需的 Copy 和 Move 构造函数、赋值运算符和析构函数:
// Person.h
struct Person {
Person(const Person &);
Person(Person &&);
Person &operator=(const Person &);
Person &operator=(Person &&);
~Person();
std::string name;
unsigned getAge() const;
};
std::vector<Person> everyone();
Swift 编译器将其视为值类型,并会在正确的时间自动调用正确的特殊成员函数。此外,vector 和 map 等 C++ 容器可以作为 Swift 集合进行访问。我们可以编写直接使用 C++ 函数和类型的简单 Swift 代码。 我们可以在 std::vector<Person>
上使用 filter
等高阶函数,使用 C++ 的成员函数并直接访问数据成员:
// Client.swift
func greetAdults() {
for person in everyone().filter { $0.getAge() >= 18 } {
print("Hello, (person.name)!")
}
}
C++ 使用 Swift 代码有与 Objective-C 使用 Swift 相同的机制。Swift 编译器将生成一个 generated header,其中包含 C++ 可见的 API。 然而与 Objective-C 不同的是,我们不需要限制只能使用带有 @objc
属性注释的 Swift 类。C++ 可以直接使用大多数 Swift 类型和 API,包括属性、方法等,而无需任何桥接开销:
// Geometry.swift
struct LabeledPoint {
var x = 0.0, y = 0.0
var label: String = “origin”
mutating func moveBy(x deltaX: Double, y deltaY: Double) { … }
var magnitude: Double { … }
}
// C++ client
#include <Geometry-Swift.h>
void test() {
Point origin = Point()
Point unit = Point::init(1.0, 1.0, “unit”)
unit.moveBy(2, -2)
std::cout << unit.label << “ moved to “ << unit.magnitude() << std::endl;
}
在这里我们可以看到 C++ 如何使用 Swift 的 Point 结构。 包含生成的 Geometry-Swift.h
后,C++ 可以调用 Swift 初始化程序来创建 Point 实例、调用方法以及访问存储和计算属性,所有这些都无需对 Swift 代码本身进行任何更改。
Swift 的 C++ 互操作性使得将 Swift 与现有 C++ 代码库集成变得比以往更容易。许多 C++ 习惯用法可以直接用 Swift 表达,Swift API 也可以直接从 C++ 访问从而可以使用 C、C++ 和 Objective-C 的任意组合。有关更多信息,可以查看 WWDC 23 Session Mix Swift and C++。
8. Swift 并发中自定义 Actor 的同步机制
几年前 App 在 Swift 中引入了一种新的并发模型,基于 async/await、结构化并发和 Actor 等。 Swift 的并发模型是一个抽象模型,可以适应不同的环境和库。抽象模型有两个主要部分:Task 和 Actor。 Task 代表一个连续的工作单元,概念上可以在任何地方运行。 只要程序中有“await”,Task 就可以挂起,在 Task 可以继续时恢复。Actor 是一种同步机制,提供对隔离状态的互斥访问。 从外部进入 Actor 需要“await”,因为它可能会暂停任务。
Task 和 Actor 被集成到抽象语言模型中,但在该模型中,它们可以以不同的方式实现,从而适应不同的环境。Task 在全局并发池上执行。 全局并发池如何决定安排工作取决于环境。对于 Apple 的平台,Dispatch 库为整个操作系统提供了优化的调度,并且针对每个平台进行了不同的调整。但在限制性更强的环境中,多线程调度程序的开销可能不可接受。Swift 的并发模型是通过单线程协作队列实现的。因为抽象模型足够灵活,相同的 Swift 代码可以映射到不同的运行时环境。
此外,与基于回调的库的互操作性,从一开始就内置于 Swift 的 async/await 支持中。 withCheckedContinuation
操作允许暂停 Task,然后在响应回调时恢复它。 这使得能够与自行管理任务的现有库集成:
withCheckedContinuation { continuation in
sendMessage(msg){ response in
continuation.resume(returning: response)
}
}
Swift 并发运行时,Actor 的标准实现是在 Actor 上执行的无锁任务队列。 如果该环境是单线程的,则不需要同步,但 Actor 模型也会维护程序的抽象并发模型。 我们仍然可以将相同的代码带到另一个多线程环境中。
在 Swift 5.9 中,自定义 Actor 允许特定 Actor 实现自己的同步机制。这使得 Actor 更加灵活并且能够适应现有环境。考虑一个管理数据库连接的 Actor,Swift 确保对该 Actor 的存储进行互斥访问,因此不会对数据库进行任何并发访问。
// Custom actor executors
actor MyConnection {
private var database: UnsafeMutablePointer<sqlite3>
init(filename: String) throws { … }
func pruneOldEntries() { … }
func fetchEntry<Entry>(named: String, type: Entry.Type) -> Entry? { … }
}
await connection.pruneOldEntries()
但如果我们需要更多地控制该怎么办?如果我们想为数据库连接使用特定的调度队列(可能是因为该队列与未采用 Actor 的代码共享),该怎么办? 使用自定义 Actor Executor。我们可以向 Actor 添加一个串行调度队列、一个 UnownedSerialExecutor
类型的计算属性,该属性生成该调度队列对应的 Executor:
actor MyConnection {
private var database: UnsafeMutablePointer<sqlite3>
// ⬇️⬇️⬇️
private let queue: DispatchSerialQueue
nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() }
// ⬆️⬆️⬆️
init(filename: String, queue: DispatchSerialQueue) throws { … }
func pruneOldEntries() { … }
func fetchEntry<Entry>(named: String, type: Entry.Type) -> Entry? { … }
}
await connection.pruneOldEntries()
通过此更改,我们的 Actor 实例的所有同步都将通过该队列进行。当我们“awiat”从 Actor 外部调用 pruneOldEntries()
时,现在将在相应的队列上执行调度异步。这使我们可以更好地控制各个 Actor 如何进行同步,甚至可以让 Actor 与尚未使用 Actor 的其他代码同步,可能是因为它是用 Objective-C 或 C++ 编写的。
由于调度队列符合新的 SerialExecutor
协议,因此通过调度队列实现 Actor 的同步成为可能。我们可以通过定义符合此协议的新类型来提供自己的同步机制,然后与 Actor 一起使用:
// Executor protocols
protocol Executor: AnyObject, Sendable {
func enqueue(_ job: consuming ExecutorJob)
}
protocol SerialExecutor: Executor {
func asUnownedSerialExecutor() -> UnownedSerialExecutor
func isSameExclusiveExecutionContext(other executor: Self) -> Bool
}
extension DispatchSerialQueue: SerialExecutor { … }
该新类型只有很少的核心操作:检查代码是否已经在 Executor 的上下文中执行。例如,是否在主线程上运行?以及提取 unownedExecutor
。Executor 最核心的操作是 enqueue()
,它获取执行者 job
的所有权。 job
是异步任务的一部分,需要在 Executor 上同步运行。 在调用 enqueue()
时,Executor 有责任在串行 Executor 上没有其他代码运行时运行该 job
。
Swift Concurrency 由 Task 和 Actor 组成的抽象模型涵盖了很大范围的并发编程任务。有关更多信息,可以参阅 WWDC23 Session Beyond the basics of structured concurrency。