文章目录
- 前言
- Noncopyable 结构体和枚举
- 结束变量绑定的生命周期
- makeStream() 方法
- 添加 sleep(for:) 到 Clock
- Discarding task groups
- 总结
前言
虽然 Swift 6 已经在地平线上浮现,但 5.x 版本仍然有很多新功能-更简单的 if 和 switch 用法、宏、非可复制类型、自定义 actor 执行器等等都将在 Swift 5.9 中推出,再次带来了一个巨大的更新。
在本文中,将介绍这个版本中最重要的变化,提供代码示例和解释,以便可以自行尝试。需要在 Xcode 14 中安装最新的 Swift 5.9 工具链,或者使用 Xcode 15 beta。
Noncopyable 结构体和枚举
SE-0390 引入了无法复制的结构体和枚举的概念,从而允许在代码的多个位置共享一个结构体或枚举的单个实例,虽然只有一个所有者,但现在可以在代码的不同部分访问。
首先,此更改引入了用于取消要求的新语法:~Copyable
。这意味着 “此类型不能被复制”,并且此取消语法目前在其他地方不可用 - 例如,我们不能使用 ~Equatable
来退出类型的 ==
。
因此,我们可以像下面代码创建一个新的不可复制的 User
结构体:
struct User: ~Copyable {
var name: String
}
注意:Noncopyable 不能满足除 Sendable
之外的任何协议。
一旦创建了 User
实例,其不可复制的特性意味着它与 Swift 的先前版本不一样。例如,下面的示例代码:
func createUser() {
let newUser = User(name: "Anonymous")
var userCopy = newUser
print(userCopy.name)
}
createUser()
但是我们已经声明了 User
结构体为不可复制,也无法复制 newUser
,将 newUser
分配给 userCopy
导致原始的 newUser
值被消耗,这意味着不能使用,因为所有权现在属于 userCopy
。如果尝试将 print(userCopy.name)
更改为 print(newUser.name)
,Swift 会抛出一个编译器错误。
新的限制还适用于如何将非可复制类型用作函数参数:SE-0377 规定函数必须明确指定是打算消费值并在函数完成后使其在调用点无效,还是希望借用值以便与代码中的其他借用部分同时读取其数据。
因此,可以编写一个函数来创建用户,另一个函数来借用用户以获得只读访问其数据的权限:
func createAndGreetUser() {
let newUser = User(name: "Anonymous")
greet(newUser)
print("Goodbye, \(newUser.name)")
}
func greet(_ user: borrowing User) {
print("Hello, \(user.name)!")
}
createAndGreetUser()
与此相反,如果我们使 greet()
函数使用 consuming User
,则 print("Goodbye, \(newUser.name)")
将不被允许 - Swift 将认为 greet()
运行后,newUser
值将无效。另一方面,由于 consuming 方法必须结束对象的生命周期,可以自由地修改其属性。
这种共享行为赋予了非可复制结构体以前仅限于类和 actor 的超能力:当对非可复制实例的最后一个引用被销毁时,可以提供自动运行的析构函数。
重要提示: 这与类上的析构函数的行为略有不同,可能是早期实现的问题或有意为之。
首先,下面是使用类的析构函数的代码示例:
class Movie {
var name: String
init(name: String) {
self.name = name
}
deinit {
print("\(name) is no longer available")
}
}
func watchMovie() {
let movie = Movie(name: "The Hunt for Red October")
print("Watching \(movie.name)")
}
watchMovie()
当运行该代码时,会先打印 “Watching The Hunt for Red October”,然后打印 “The Hunt for Red October is no longer available”。但是,如果将类型的定义从 class Movie
更改为 struct Movie: ~Copyable
,将会看到这两个 print()
语句以相反的顺序运行 - 先说电影不再可用,然后说正在观看。
非可复制类型内部的方法默认情况下是借用的,但是可以像可复制类型一样标记为 mutating
,并且还可以标记为 consuming,表示该值在方法运行后无效。
例如,我们熟悉的电影和电视剧《碟中谍》,秘密特工们通过一卷只能播放一次的自毁磁带获得任务指令。对于这样的方式,非可复制结构体非常适合:
struct MissionImpossibleMessage: ~Copyable {
private var message: String
init(message: String) {
self.message = message
}
consuming func read() {
print(message)
}
这样标记的 message 本身是私有的,因此只能通过调用消费实例的 read()
方法来访问它。
与变异方法不同,消费方法可以在类型的常量实例上运行。因此,像下面这样的代码是可以的:
func createMessage() {
let message = MissionImpossibleMessage(message: "You need to abseil down a skyscraper for some reason.")
message.read()
}
createMessage()
注意: 因为 message.read()
消费了 message
实例,所以尝试第二次调用 message.read()
将会报错。
与析构函数结合使用时,消费方法会使清理工作重复执行。例如,如果在游戏中跟踪高分,可能希望具有一个消费的 finalize()
方法,将最新的高分写入永久存储,并阻止其他人进一步更改分数,但在对象销毁时也保存最新的分数到磁盘。
为了避免这个问题,Swift 5.9 引入了一个新的 discard
操作符,可以用于非可复制类型的消费方法。在消费方法中使用 discard self
可以阻止该对象的析构函数运行。
因此,可以像这样实现 HighScore
结构:
struct HighScore: ~Copyable {
var value = 0
consuming func finalize() {
print("Saving score to disk…")
discard self
}
deinit {
print("Deinit is saving score to disk…")
}
}
func createHighScore() {
var highScore = HighScore()
highScore.value = 20
highScore.finalize()
}
createHighScore()
提示: 当运行该代码时,你会看到 deinitializer
消息被打印两次 - 一次是在更改 value
属性时,实际上销毁并重新创建了结构体,一次是在 createHighScore()
方法结束时。
在使用这个新功能时,还有一些额外的复杂性需要注意:
- 类和 actor 不能是非可复制的。
- 非可复制类型暂时不支持泛型,这排除了可选的非可复制对象和非可复制对象数组。
- 如果在另一个结构体或枚举类型中将非可复制类型用作属性,那么父结构体或枚举类型也必须是非可复制的。
- 当对现有类型添加或移除
Copyable
时需要非常小心,因为会改变用法。如果在库中发布代码,这将破坏 ABI。
结束变量绑定的生命周期
使用消耗运算符结束变量绑定的生命周期
SE-0366 扩展了对可复制类型的局部变量和常量的消耗值概念,这对于希望避免在其数据传递过程中发生不必要的保留/释放调用的开发人员可能很有益处。
最简单的形式下,消耗运算符如下所示:
struct User {
var name: String
}
func createUser() {
let newUser = User(name: "Anonymous")
let userCopy = consume newUser
print(userCopy.name)
}
createUser()
其中重要的是 let userCopy
这一行,同时执行两个操作:
- 将
newUser
的值复制到userCopy
中。 - 结束
newUser
的生命周期,因此任何进一步访问它的尝试都会引发错误。
这样可以明确告诉编译器“不允许再次使用这个值”,这将代表强制执行这个规则。
可以看到这在使用所谓的黑洞 _
时特别常见,我们不希望复制数据,而只是想将其标记为已销毁,例如:
func consumeUser() {
let newUser = User(name: "Anonymous")
_ = consume newUser
}
实际上,最常见的情况可能是将值传递给如下的函数:
func createAndProcessUser() {
let newUser = User(name: "Anonymous")
process(user: consume newUser)
}
func process(user: User) {
print("Processing \(name)…")
}
createAndProcessUser()
有两件特别值得了解的事情。
首先,Swift 跟踪代码的哪些分支消耗了值,并有条件地强制执行规则。因此,在这段代码中,两种可能性中只有一种消耗了 User
实例:
func greetRandomly() {
let user = User(name: "Taylor Swift")
if Bool.random() {
let userCopy = consume user
print("Hello, \(userCopy.name)")
} else {
print("Greetings, \(user.name)")
}
}
greetRandomly()
其次,严格来说,consume
操作符作用于绑定而不是值。实践中,这意味着如果使用一个变量进行消耗,可以重新初始化该变量并正常使用:
func createThenRecreate() {
var user = User(name: "Roy Kent")
_ = consume user
user = User(name: "Jamie Tartt")
print(user.name)
}
createThenRecreate()
makeStream() 方法
SE-0388 在 AsyncStream
和 AsyncThrowingStream
中添加了一个新的 makeStream()
方法,返回流本身以及其 continuation。
因此,不再需要编写以下代码:
var continuation: AsyncStream<String>.Continuation!
let stream = AsyncStream<String> { continuation = $0 }
现在可以同时获取:
let (stream, continuation) = AsyncStream.makeStream(of: String.self)
这在需要在当前上下文之外访问 continuation 的地方特别方便,例如在另一个方法中。例如,以前可能会像下面这样编写一个简单的数字生成器,需要将 continuation 存储为自己的属性,以便能够从 queueWork()
方法中调用:
struct OldNumberGenerator {
private var continuation: AsyncStream<Int>.Continuation!
var stream: AsyncStream<Int>!
init() {
stream = AsyncStream(Int.self) { continuation in
self.continuation = continuation
}
}
func queueWork() {
Task {
for i in 1...10 {
try await Task.sleep(for: .seconds(1))
continuation.yield(i)
}
continuation.finish()
}
}
}
使用新的 makeStream(of:)
方法,这段代码变得简单多了:
struct NewNumberGenerator {
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
func queueWork() {
Task {
for i in 1...10 {
try await Task.sleep(for: .seconds(1))
continuation.yield(i)
}
continuation.finish()
}
}
}
添加 sleep(for:) 到 Clock
SE-0374 在 Swift 的 Clock
协议中添加了一个新的扩展方法,允许暂停执行一段时间,同时还支持特定容差的基于持续时间的任务睡眠。
Clock
的更改虽然很小,但非常重要,特别是在模拟具体 Clock
实例以消除在测试中存在于生产环境中的延迟时。
例如,可以使用任何类型的 Clock
创建这个类,并在触发保存操作之前使用该 Clock 进行睡眠:
class DataController: ObservableObject {
var clock: any Clock<Duration>
init(clock: any Clock<Duration>) {
self.clock = clock
}
func delayedSave() async throws {
try await clock.sleep(for: .seconds(1))
print("Saving…")
}
}
由于使用了 any Clock<Duration>
,因此在生产中可以使用 ContinuousClock
,而在测试中可以使用自定义的 DummyClock
,其中忽略所有的 sleep()
命令以使测试运行快速。
在较旧的 Swift 版本中,相应的代码理论上可能是 try await clock.sleep(until: clock.now.advanced(by: .seconds(1)))
,但在这个示例中不起作用,因为 Swift 不知道具体使用了哪种类型的时钟,因此无法获得 clock.now
。
至于对于 Task
睡眠的改变,可以从以下代码:
try await Task.sleep(until: .now + .seconds(1), tolerance: .seconds(0.5))
简化为:
try await Task.sleep(for: .seconds(1), tolerance: .seconds(0.5))
Discarding task groups
SE-0381 添加了新的 Discarding task groups,填补了当前 API 中的一个重要空白:在任务组内部创建的任务在完成后会自动丢弃和销毁,这意味着长时间运行的任务组(或者在 Web 服务器等情况下可能一直运行的任务组)不会随着时间的推移泄漏内存。
在使用原始的 withTaskGroup()
API 时,可能会遇到问题,因为 Swift 只在调用 next()
或循环遍历任务组的子任务时才丢弃子任务及其结果数据。调用 next()
会导致代码在所有子任务都在执行时暂停,因此面临的问题是:希望服务器始终监听连接以便添加任务来处理,但是还需要定期停止以清理已完成的旧任务。
在 Swift 5.9 中引入了解决这个问题的清晰方案,添加了 withDiscardingTaskGroup()
和 withThrowingDiscardingTaskGroup()
函数,用于创建新的丢弃式任务组。这些任务组会自动在每个任务完成后丢弃和销毁任务,无需手动调用 next()
来消费它。
为了了解触发问题的情况,可以实现一个简单的目录监视器,循环运行并报告已添加或删除的文件或目录的名称:
struct FileWatcher {
// 正在监视文件更改的 URL。
let url: URL
// 已返回的 URL 集合。
private var handled = Set<URL>()
init(url: URL) {
self.url = url
}
mutating func next() async throws -> URL? {
while true {
// 读取我们目录的最新内容,或者如果发生问题则退出。
guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
return nil
}
// 找出我们尚未处理的 URL。
let unhandled = handled.symmetricDifference(contents)
if let newURL = unhandled.first {
// 如果我们已经处理过此 URL,则它必须已被删除。
if handled.contains(newURL) {
handled.remove(newURL)
} else {
// 否则,此 URL 是新的,因此将其标记为已处理。
handled.insert(newURL)
return newURL
}
} else {
// 没有文件差异;睡眠几秒钟后重试。
try await Task.sleep(for: .microseconds(1000))
}
}
}
}
然后可以从一个简单的应用程序中使用,尽管出于简洁起见,只打印 URL 而不进行任何复杂的处理:
struct FileProcessor {
static func main() async throws {
var watcher = FileWatcher(url: URL(filePath: "/Users/twostraws"))
try await withThrowingTaskGroup(of: Void.self) { group in
while let newURL = try await watcher.next() {
group.addTask {
process(newURL)
}
}
}
}
static func process(_ url: URL) {
print("Processing \(url.path())")
}
}
这段代码将永远运行,或者至少直到用户终止程序或监视的目录不再可访问为止。然而,由于使用了 withThrowingDiscardingTaskGroup()
,这个问题就不存在了:每次调用 addTask()
时都会创建一个新的子任务,但由于没有在任何地方调用 group.next()
,这些子任务永远不会被销毁。每次可能只增加几百字节,这段代码将占用越来越多的内存,直到最终操作系统耗尽内存并被迫终止程序。
这个问题在 Discarding task groups 中完全消失:只需将 withThrowingTaskGroup(of: Void.self)
替换为 withThrowingDiscardingTaskGroup
,每个子任务在完成工作后将自动销毁。
总结
特别感谢 Swift社区 编辑部的每一位编辑,感谢大家的辛苦付出,为 Swift社区 提供优质内容,为 Swift 语言的发展贡献自己的力量。