Swift 6.2 新特性:原始标识符、回溯、任务命名等
GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>
Swift 6.2 新特性:原始标识符、回溯、任务命名等
原始标识符、回溯、任务命名等等。
Paul Hudson 5 小时前 @twostraws
大家请做好准备:Swift 6.2 包含了大量的新增功能和改进,同时也对 Swift 并发进行了一些重要的改进,这应该会使其更容易在各处采用。
许多更改都很小,例如添加原始标识符、字符串插值中的默认值或 enumerated()
遵循 Collection
协议,但这些更改都可能很快在项目中传播,因为它们非常实用。
很高兴看到 Swift Testing 越来越强大,Swift 6.2 中有三个主要改进,包括退出测试和附件。
简而言之,Swift 6.2 感觉就像提供了许多人想象中的 Swift 6.0 会是什么样子——对并发的支持越来越完善,并得到了一些务实选择的支持,这些选择有助于平滑语言的学习曲线。
**注意:**在撰写本文时,Swift 6.2 仅作为 Swift.org 上的测试版本提供。以下列表代表了我对即将发生的事情的最佳猜测——如果某些内容滑到以后的版本或有其他内容意外出现,请不要感到惊讶!
TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and more!
Learn more here
Sponsor Hacking with Swift and reach the world's largest Swift community!
控制默认 Actor 隔离推断
SE-0466 引入了一种能力,允许代码默认选择在单个 Actor 上运行——有效地回到单线程程序,其中大多数代码在主 Actor 上运行,直到你另行说明。
**是的,这和你想象的一样好:**只需进行一项更改,许多应用程序开发人员或多或少可以避免考虑 Swift 并发,直到他们准备好这样做,因为除非他们另行说明,否则他们所有的类型和函数都将表现得好像它用 @MainActor
注释了一样。
要启用这个新功能,将 -default-isolation MainActor
添加到你的编译器标志,像这样的代码就变得有效:
@MainActor
class DataController {
func load() { }
func save() { }
}
struct App {
let controller = DataController()
init() {
controller.load()
}
}
正如你所看到的,App
结构体创建并使用了一个主 Actor 隔离类型,而它本身并没有被标记 @MainActor
,但这没关系,因为它会自动为我们应用——我们甚至可以删除单独的 @MainActor
注释,它仍然会应用。
在你开始恐慌这会引起各种并发问题之前,有五件重要的事情你需要知道。
首先,这个新的配置选项是_按模块_应用的,因此如果你引入外部模块,它们仍然可以在其他 Actor 上运行。 这应该允许以 UI 为中心的模块切换到默认在主 Actor 上运行,同时让以后台为中心的模块像以前一样进行并发操作。
其次,你仍然可以在你的应用程序中正常使用诸如网络之类的东西——诸如 URLSession.shared.data(from:)
之类的代码将在它自己的任务上运行,而不是阻塞你的代码。
第三,现代 iPhone 中的单个 CPU 核心以超过 4GHz 的速度运行,因此大量的 iOS 应用程序可以串行完成所有工作,而无需三思而后行。
第四,许多开发人员已经在使用“使它全部 @MainActor”作为他们处理并发的默认方法,只有在需要时才进行更改。
第五,也许也是最重要的是,此更改与其他更改一起构成了 Swift 团队发布的更大的 Improving the approachability of data-race safety 愿景文档的一部分——它不仅仅是一个孤立的更改,而是旨在降低并发学习曲线的连贯改进包的一部分。
因此,尽管此更改可能看起来与 Swift 5.5 及更高版本中发生的所有 Swift 并发工作背道而驰,但最终它解决了 Swift 并发不易学习且许多应用程序根本不需要它的一个日益严重的问题。
当然,真正的问题是 Apple 是否会默认为 Xcode 中的新应用程序项目启用此功能。 我真诚地希望如此,因为它还可以让 Xcode 默认使用 Swift 6 语言版本,而不会引入许多开发人员不需要考虑的错误和警告。
注意: 另一个 Swift Evolution 提案 SE-0478 目前正在讨论中,如果获得批准,它将允许我们使用诸如 private typealias DefaultIsolation = MainActor
之类的语法在每个文件的基础上声明默认的 Actor 隔离。 到目前为止,反馈普遍是负面的,因此可能会被退回以进行修改。
原始标识符
SE-0451 极大地扩展了我们可以用来创建标识符(变量、函数、枚举案例等名称)的字符范围——因此当它们放在反引号中时,我们可以随意命名它们。
因此,这种代码在 Swift 6.2 及更高版本中是合法的:
func `function name with spaces`() {
print("Hello, world!")
}
`function name with spaces`()
如果你想知道为什么这样的命名可能有用,请考虑以下情况:
enum HTTPError: String {
case `401` = "Unauthorized"
case `404` = "Not Found"
case `500` = "Internal Server Error"
case `502` = "Bad Gateway"
}
这使得每个 HTTP 错误代码成为我们枚举中的一个案例,以前需要将其写成其他形式,例如 case _401
或 case error401
。
如果你正在使用像这样的数字,你要么需要在每次使用时都限定类型,以避免 Swift 感到困惑,要么需要仔细地放置反引号。
例如,在下面的代码中,我们每次都使用 HTTPError
,以避免 Swift 认为 401
指的是格式错误的浮点文字:
let error = HTTPError.401
switch error {
case HTTPError.401, HTTPError.404:
print("Client error: \(error.rawValue)")
default:
print("Server error: \(error.rawValue)")
}
另一种方法是将数字本身包装在反引号中——_不_包括前面的点——像这样:
switch error {
case .`401`, .`404`:
print("Client error: \(error.rawValue)")
default:
print("Server error: \(error.rawValue)")
}
此更改的最大受益者可能是 Swift Testing,其中测试名称现在可以直接以人类可读的形式编写,而不是使用驼峰命名法并在上方添加额外的字符串描述。
因此,与其这样写:
import Testing
@Test("Strip HTML tags from string")
func stripHTMLTagsFromString() {
// test code
}
我们可以改为这样写:
@Test
func `Strip HTML tags from string`() {
// test code
}
减少了重复,这总是受欢迎的。
一个可能会让你失望的小细节是:“原始标识符可以以运算符字符开头、包含或结尾,但它不能仅包含运算符字符。” 因此,你可以将诸如 +
和 -
之类的运算符放入你的标识符名称中,但前提是它们不是其中唯一的东西。
字符串插值中的默认值
SE-0477 对字符串插值中的可选值进行了一个小而美的更改,允许我们提供一个额外的 default
值,以便在可选值为 nil 时使用。
以最简单的形式,这意味着我们将编写以下代码:
var name: String? = nil
print("Hello, \(name, default: "Anonymous")!")
而不是这样:
print("Hello, \(name ?? "Anonymous")!")
乍一看,这似乎并不是一个很大的改进,但关键是 nil 合并不适用于不同的类型。 因此,允许使用这种代码:
var age: Int? = nil
print("Age: \(age ?? 0)")
但是,取消注释后,这种代码将无法编译:
// print("Age: \(age ?? "Unknown")")
这尝试将可选整数与字符串默认值混合在一起,这是不允许的。 幸运的是,从 Swift 6.2 开始,这 是 可能的:
print("Age: \(age, default: "Unknown")")
为 enumerated() 添加 Collection 一致性
SE-0459 使 enumerated()
返回的类型符合 Collection
。
最直接的好处是,现在更容易将 enumerated()
与 SwiftUI List
或 ForEach
一起使用,如下所示:
import SwiftUI
struct ContentView: View {
var names = ["Bernard", "Laverne", "Hoagie"]
var body: some View {
List(names.enumerated(), id: \.offset) { values in
Text("User \(values.offset + 1): \(values.element)")
}
}
}
该提案 还提到了各种性能优势,包括使 (1000..<2000).enumerated().dropFirst(500)
成为常数时间操作。
方法和初始化器键路径
SE-0479 扩展了 Swift 的键路径以支持方法,以及对属性和下标的现有支持,这与 Swift 6.1 中引入的 SE-0438 一起,有望完善键路径可以实现的功能。
访问属性的代码始终可以通过键路径正常工作:
let strings = ["Hello", "world"]
let capitalized = strings.map(\.capitalized)
print(capitalized)
通过此更改,我们现在也可以访问方法,但请务必像这样实际_调用_该方法:
let uppercased = strings.map(\.uppercased())
print(uppercased)
如果你 不 调用该方法,那么你将获得一个未调用的函数,你可以在以后调用它,如下所示:
let functions = strings.map(\.uppercased)
print(functions)
for function in functions {
print(function())
}
在该代码中,functions
常量包含对我们传入的每个字符串调用 uppercased()
的数组——functions[0]
将是对 "HELLO".uppercased()
的引用,我们可以使用 functions[0]()
直接调用它。
如果两个方法具有相同的名称,你可以添加它们的参数标签来澄清你要使用的重载,如下所示:
let prefixUpTo = \Array<String>.prefix(upTo:)
let prefixThrough = \Array<String>.prefix(through:)
但是,你 不能 创建指向标记为 async
或 throws
的方法的键路径; 只是不支持。
选择启用严格的内存安全检查
SE-0458 引入了选择启用对不安全 Swift 代码标记为警告的支持,除非特别需要,这将使审核不安全代码的使用变得更加容易。
注意: 需要明确的是,不安全 代码与_崩溃_ 代码不同——诸如强制解包或在数组中读取索引 -1 之类的东西都会使你的代码崩溃,但被认为是安全的,因为这是预期的行为。 不安全 代码是绕过 Swift 的保护措施以某种方式在内存中进行探测以导致未定义行为的代码,并且通常(如果不是总是)在名称中包含“unsafe”一词,例如 UnsafeRawPointer
或 unsafelyUnwrapped
。
严格的内存安全检查引入了新的 @safe
和 @unsafe
属性,这些属性分别将代码标记为安全或不安全使用,@safe
是默认值——只有在你需要在特定情况下覆盖 @unsafe
时才需要它。
启用严格内存安全后,标记为 @unsafe
的代码必须使用新的 unsafe
关键字调用,如下所示:
let name: String?
unsafe print(name.unsafelyUnwrapped)
未能使用 unsafe
将会抛出警告,因此你可以调整代码以使用安全变体或添加 unsafe
密钥以确认代码不安全。
这与 try
和 await
的工作方式非常相似——编译器知道某些代码会抛出,某些其他代码是异步的,或者某些其他代码被标记为 @unsafe
,因此实际上这些关键字是对其他人可见的确认。
Swift 回溯 API
SE-0419 引入了一个新的 Backtrace
结构,它能够在任何给定时刻捕获有关应用程序调用堆栈的数据——导致当前点的确切函数调用序列。
默认情况下,回溯不会进行符号化,这意味着它们不会包含调用堆栈中每个函数的名称,但你可以使用 symbolicated()
来获取额外的数据。
因此,举例来说,我们可以编写一个小的函数链,其中最后一个函数打印回溯:
import Runtime
func functionA() {
functionB()
}
func functionB() {
functionC()
}
func functionC() {
if let frames = try? Backtrace.capture().symbolicated()?.frames {
print(frames)
} else {
print("Failed to get backtrace.")
}
}
functionA()
这将准确打印出调用堆栈中的函数——functionC()
、functionB()
和 functionA()
——以及每个调用的文件和行号,这对于调试非常有帮助。
weak let
SE-0481 引入了在声明类型属性时使用 weak let
的能力,以补充对 weak var
的现有支持。
重要提示: weak let
意味着属性在创建后无法更改,但它仍然可以被_销毁_,因此你需要小心使用它。
举例来说,我们可以创建两个类,如下所示:
final class User: Sendable {
let id = UUID()
}
final class Session: Sendable {
weak let user: User?
init(user: User?) {
self.user = user
}
}
然后我们可以创建并使用它们,如下所示:
var user: User? = User()
let session = Session(user: user)
print(session.user?.id ?? "No ID")
由于 user
属性是对类的 weak
引用,我们可以销毁原始类,并且该属性也会被销毁。 因此,这将打印“No ID”:
user = nil
print(session.user?.id ?? "No ID")
我们_不能_做的是_重新分配_ user
属性,这意味着如果取消注释,这两个操作都将无法编译:
// session.user? = User()
// session.user = nil
weak let
的另一个巨大优势也可以在上面的代码中看到:我们可以将这两个类都标记为符合 Sendable
,这在使用 weak var
时是不可能的。
值的事务性观察
SE-0475 创建了一个新的 Observations
结构,该结构使用闭包创建,并提供了一个 AsyncSequence
,每当任何 @Observable
数据更改时都会发出新值——它有效地赋予了我们与 SwiftUI 相同的监视更改的能力,只是以自由形式的方式。
举例来说,我们可以为具有分数的玩家创建一个简单的 @Observable
类:
@Observable
class Player {
var score = 0
}
let player1 = Player()
我们可以要求在使用 Observations
像这样更改分数时收到通知:
let playerScores = Observations { player1.score }
在该代码中,playerScores
是 Observations<Int, Never>
的一个实例,这意味着它发出整数,并且永远不会抛出错误。
然后,我们可以排队一系列示例更改,并使用 for await
循环来监视这些更改的发生:
for i in 1...5 {
Task {
try? await Task.sleep(for: .seconds(i))
player1.score += 1
}
}
for await score in playerScores {
print(score)
}
这将总共打印六个值:初始分数,以及递增到 5。
使用 Observations
时,你应该注意一些重要的用法说明:
- 它将发出初始值以及所有未来的值。
- 如果多个更改同时传入,它们可能会合并为一个发出的值。 例如,如果我们的
Task
代码将score
递增两次,则发出的值将以 2 为单位递增。 - 发出的值的
AsyncSequence
可能会永远运行,因此你应该将其放在单独的任务中或以其他方式小心处理它。 - 如果你希望迭代停止(结束循环),你应该将观察的值设置为可选,然后将其设置为 nil。
全局 Actor 隔离的一致性
SE-0470 通过使将协议一致性限制为特定全局 Actor 成为可能,解决了小而重要的并发问题。
例如,我们现在可以说“只有在主 Actor 上使用时,此 @MainActor
限制类型才符合 Equatable
”,如下所示:
@MainActor
class User: @MainActor Equatable {
var id: UUID
var name: String
init(name: String) {
self.id = UUID()
self.name = name
}
static func ==(lhs: User, rhs: User) -> Bool {
lhs.id == rhs.id
}
}
注意 @MainActor Equatable
的使用——如果我们尝试在没有在协议上使用 @MainActor
的情况下保持一致,Swift 将可以自由地在任何任务上运行 ==
方法,包括在后台运行,这由于整个类型都被标记为 @MainActor
而被明确禁止。 因此,我们的代码将无法构建。
从调用方上下文同步启动任务
SE-0472 引入了一种创建任务的新方法,以便在可能的情况下立即启动它们,而不是现有行为,现有行为只允许将任务排队以便在下一个可用机会运行。
你可以通过以下代码看到区别:
print("Starting")
Task {
print("In Task")
}
Task.immediate {
print("In Immediate Task")
}
print("Done")
try await Task.sleep(for: .seconds(0.1))
这将创建两个非结构化任务:一个常规的非结构化任务,后跟一个立即非结构化任务。 运行时,它将打印 Starting、In Immediate Task、Done,然后最终打印 In Task。
要真正理解立即任务和常规任务之间的区别,请记住 Swift 中的所有潜在挂起点都必须用 await
标记。 创建常规的非立即任务不使用 await
,因为它不标记挂起点,但它_不会_立即运行,因为它被排队以便在下一个可用机会运行。
创建_立即_任务的新能力释放了重要的新功能:如果它已经在目标执行器上,立即任务中的代码会立即开始执行,也许提供了 UI 控件正在等待的重要数据,但在初始立即响应之后,任务可以像常规任务一样使用 await
,并可能像正常一样触发挂起。 因此,立即任务中的所有内容都会立即运行,直到到达第一个挂起点。
但是,Task
和 Task.immediate
都是_非结构化_任务,任务组也已升级,以支持具有 addImmediateTask()
和 addImmediateTaskUnlessCancelled()
的立即子任务。
默认情况下在调用者的 Actor 上运行非隔离的异步函数
SE-0461 调整了调用非隔离异步函数的方式,以便它们在其调用者的 Actor 上运行。 这听起来像是一个非常抽象的更改,但它很重要,所以我建议你花时间去理解正在更改的内容以及原因。
举例来说,这是一个简单的结构体,它知道如何下载和解码代表各种温度的 Double
值数组:
struct Measurements {
func fetchLatest() async throws -> [Double] {
let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Double].self, from: data)
}
}
它没有隔离到任何特定的 Actor,因此它可以在任何地方运行其代码。
接下来,我们可以将它与另一个名为 WeatherStation
的结构体一起使用,该结构体将下载所有读数并返回它们的平均值。 但是,这次我们将该结构体标记为隔离到主 Actor:
@MainActor
struct WeatherStation {
let measurements = Measurements()
func getAverageTemperature() async throws -> Double {
let readings = try await measurements.fetchLatest()
let average = readings.reduce(0, +) / Double(readings.count)
return average
}
}
let station = WeatherStation()
try await print(station.getAverageTemperature())
因此,我们有一个非隔离的结构体,以及另一个_已_隔离的结构体,这完美地演示了更改:在 Swift 6.2 之前,对 measurements.fetchLatest()
的调用不会在主 Actor 上运行,但从 Swift 6.2 及更高版本开始,它_会_运行。
旧的行为引起了一些困惑,尤其是因为 measurements
是 WeatherStation
中的主 Actor 隔离属性,并且对 measurements.fetchLatest()
的调用发生在主 Actor 隔离的 getAverageTemperature()
方法中。
旧的行为并非偶然——Swift 5.7 中的 SE-0338 特别指出,未隔离到特定 Actor 的异步函数“不在任何 Actor 的执行器上运行”。
SE-0461 引入的新行为意味着非隔离的异步函数现在将在与其调用者相同的 Actor 上运行,除非你另行说明。 在上面的代码中,这意味着对 measurements.fetchLatest()
的调用将在主 Actor 上运行,因为 getAverageTemperature()
这样做。
如果你希望旧的行为返回(如果你希望 fetchLatest()
自动从调用者的 Actor 切换出来),你需要使用新的 @concurrent
属性标记它,如下所示:@concurrent func fetchLatest()…
。
隔离的同步 deinit
SE-0371 引入了将 Actor 隔离类的 deinitializer 标记为隔离的能力,这允许它们安全地访问类中的其他数据。
例如,这个类隔离到主 Actor,我已将其 deinit
标记为隔离,以便它可以安全地调用 cleanUp()
:
@MainActor
class DataController {
func cleanUp() {
// free up memory
}
isolated deinit {
cleanUp()
}
}
如果那里没有 isolated
关键字,则 deinitializer 不会隔离到主 Actor——全局 Actor 就是这样工作的。 使用 它,你的代码将在运行代码之前移动到 Actor 的执行器,因此它都是安全的。
这对于 deinitializer 需要访问属于类的非 Sendable
状态时特别有用。 例如,我们可能有一个 User
类,如下所示:
class User {
var isLoggedIn = false
}
然后,我们可以将其包装在一个在主 Actor 上运行的 Session
类中,该类在会话创建和销毁时自动标记用户是否已登录:
@MainActor
class Session {
let user: User
init(user: User) {
self.user = user
user.isLoggedIn = true
}
isolated deinit {
user.isLoggedIn = false
}
}
同样,需要使用 isolated
关键字才能使代码工作——没有该关键字,deinitializer 将在未隔离到主 Actor 的情况下运行,但它会尝试访问_已_隔离到主 Actor 的 user
属性,从而导致编译错误。
任务优先级提升 API
SE-0462 引入了任务检测其优先级何时提升的能力,以及在需要时手动提升任务优先级的能力。
要监视优先级提升,请使用 withTaskPriorityEscalationHandler()
函数,如下所示:
let newsFetcher = Task(priority: .medium) {
try await withTaskPriorityEscalationHandler {
let url = URL(string: "https://hws.dev/messages.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
} onPriorityEscalated: { oldPriority, newPriority in
print("Priority has been escalated to \(newPriority)")
}
}
正如你所看到的,这给了我们新旧任务优先级,我们可以根据自己的意愿响应更改。 如果你想利用这个机会提升其他任务的优先级,你应该使用新的 escalatePriority(to:)
方法,如下所示:
newsFetcher.escalatePriority(to: .high)
因为我们可以使用多个任务优先级,所以你的 onPriorityEscalated
代码可能会被多次触发——例如,你的优先级可能从低开始,然后移动到中,然后移动到高。 但是,任务优先级只能被_提高_,永远不能被降低。
注意: 任务优先级提升通常会自动发生,例如,当高优先级任务发现自己在等待低优先级任务的结果时——Swift 会自动提高低优先级任务的优先级,以便它能够更快地完成。 尽管此 API 为我们提供了额外的控制权,但最好还是尽可能让优先级提升自动发生。
任务命名
SE-0469 对我们创建任务和子任务的方式引入了一个有用的更改:我们现在可以给它们_命名_,这对于调试特定任务出错时非常有用。
此处的 API 很简单:当使用 Task.init()
和 Task.detached()
创建新任务,或者使用 addTask()
和 addTaskUnlessCancelled()
在任务组中创建子任务时,你现在可以传递一个可选的 name
参数字符串来唯一地标识该任务。 这些名称字符串可以是硬编码的,也可以使用字符串插值; 都可以工作。
以最简单的形式,新的 API 如下所示:
let task = Task(name: "MyTask") {
print("Current task name: \(Task.name ?? "Unknown")")
}
为了向你展示一个更真实的示例,我们可能有一个 NewsStory
结构体,它知道如何加载有关新闻文章的一些基本信息:
struct NewsStory: Decodable, Identifiable {
let id: Int
let title: String
let strap: String
let url: URL
}
现在,我们可以使用任务组来获取多个新闻故事来源,并将它们合并到一个数组中,同时在任何子任务遇到问题时打印一条日志消息:
let stories = await withTaskGroup { group in
for i in 1...5 {
// Give each child task a unique name
// for easier identification.
group.addTask(name: "Stories \(i)") {
do {
let url = URL(string: "https://hws.dev/news-\(i).json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([NewsStory].self, from: data)
} catch {
// This child failed – print a log
// message with its name then return
// an empty array.
print("Loading \(Task.name ?? "Unknown") failed.")
return []
}
}
}
var allStories = [NewsStory]()
for await stories in group {
allStories.append(contentsOf: stories)
}
return allStories.sorted { $0.id > $1.id }
}
print(stories)
InlineArray,固定大小的数组
SE-0453 引入了一种名为 InlineArray
的新数组类型,它存储确切数量的元素,将元组的固定大小特性与数组的自然下标特性结合在一起,同时还添加了一些受欢迎的性能改进。
可以通过显式大小或类型来创建 InlineArray
,或者你可以让类型推断通过使用情况来确定两者。 这得益于 Swift 6.2 的另一项改进 SE-0452,它添加了整数泛型参数。
因此,我们可以通过指定大小和元素类型来创建名称的内联数组:
var names1: InlineArray<4, String> = ["Moon", "Mercury", "Mars", "Tuxedo Mask"]
或者你可以通过传入正好四个字符串来让 Swift 来确定它:
var names2: InlineArray = ["Moon", "Mercury", "Mars", "Tuxedo Mask"]
无论哪种方式,这些数组的大小都是固定的,因此它们没有 append()
或 remove(at:)` 方法。 但是,你_仍然可以_在特定索引处读取和写入值,如下所示:
names1[2] = "Jupiter"
InlineArray
不 符合 Sequence
或 Collection
,因此如果你想循环遍历它们的值,你应该使用 indices
属性以及下标,如下所示:
for i in names1.indices {
print("Hello, \(names1[i])!")
}
注意: 另一个 Swift Evolution 提案 SE-0483 目前正在讨论中,如果获得批准,它将添加一个 InlineArray
文本语法,样式为 var names: [5 x String] = .init(repeating: "Anonymous")
,意思是“此数组中正好有五个字符串”。 到目前为止,反馈普遍是负面的,因此可能会被退回以进行修改。
正则表达式后视断言
[SE-0448](https://www.