WWDC23 - Swift 5.9 New Features Quick Look

This article is based on Session 10164 What's new in Swift .

1. Using if/else and switch statements as expressions

Swift 5.9 allows the if/elseand switchstatements to be used as expressions, providing a new way to tidy up your code.

If we wanted to initialize let variables based on some complex condition, we might write this kind of unreadable compound ternary expression:

let bullet =
    isRoot && (count == 0 || !willExpand) ? ""
        : count == 0    ? "- "
        : maxDepth <= 0 ? "▹ " : "▿ "

Using Ifexpressions allows us to use more readable ifstatement chains:

let bullet =
    if isRoot && (count == 0 || !willExpand) { "" }
    else if count == 0 { "- " }
    else if maxDepth <= 0 { "▹ " }
    else { "▿ " }

If we have a global variable or stored property:

let attributedName = AttributedString(markdown: displayName)

Whenever we wish to add a condition, we must use an immediate closure:

let attributedName = {
    if let displayName, !displayName.isEmpty {
        AttributedString{markdown: displayName)
    } else {
        "Untitled"
}()

Using ifexpressions leaves much cleaner code:

let attributedName = 
    if let displayName, !displayName.isEmpty {
        AttributedString(markdown: displayName)
    } else {
        "Untitled"
    }

2. More accurate compiler diagnostics for Result builder

Result builders are the declarative syntax that drives features like SwiftUI. Previously buggy Result builders took a long time to fail because type checking explored many possible invalid paths.

Swift 5.9 has further optimized the performance of its type checking, code completion prompts and error message prompts, especially focusing on the optimization of invalid code. Starting with Swift 5.8, type checking for invalid code is faster, and error messages for invalid code are more precise.

Previously some invalid code could cause misleading errors in different parts of the Result builder. For example, in Swift 5.7, the error message for the following code is misleading:

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'
        }
    }
}

The reason for the error is NavigationLinkthat the constructor of requires valueconforming to Hashablethe protocol:

extension NavigationLink where Destination == Never {
  // ...
  public init<P>(value: P?, @ViewBuilder label: () -> Label) where P : Hashable
  // ...
}

In Swift 5.9, we'll receive more accurate compiler diagnostics to pinpoint problems:

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 
        }
    }
}

The above example problem solving can be done by letting Destinationimplement Hashablethe protocol:

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 Macro

Swift 提供了固定的宏角色:

总的来说 Swift 宏可以分为两大类:

  1. Freestanding(独立宏):可以独立存在的宏,不依赖已有的代码。
  2. Attached(绑定宏):需要绑定到特定源码位置的宏,如属性、方法、类等。

其中, Freestanding 宏可以分为 Expression、Declaration。Attached 宏可以分为 Peer、Accessor、MemberAttribute、Member、Conformance。

Available macro roles

Swift 宏提供了一种新工具,作为更具表现力的 API 来消除 Swift 代码中的样板文件,从而帮助释放 Swift 的表现力。宏对它们的输入进行类型检查,生成正常的 Swift 代码,并在程序中的定义点进行集成,因此它们的效果很容易推理。当我们需要了解宏的作用时,Xcode 也支持宏展开后的源码进行断点调试。WWDC 23 Session Expand on Swift macrosWrite Swift macros 提供了更多有关 Swift 宏的信息。

5. 使用 Swift 重写的 Foundation 框架

Swift 是一种可扩展的语言。这种可扩展性意味着我们能够将 Swift 推向比 Objective-C 更广泛的地方,比如推向低级系统,而以前我们可能需要使用 C 或 C++。 这意味着将 Swift 更清晰的代码和安全性保证带到更多地方。Apple 最近开源了用 Swift 重写的 Foundation 框架。这一举措将导致 Foundation 在 Apple 和非 Apple 平台上的单一共享实现:

Swift Foundation

从 MacOS Sonoma 和 iOS 17 开始,DateCalendar 等基本类型、LocaleAttributedString 等格式化和国际化基本功能、 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%:

Objective-C 和 Swift 的比较

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 代码中。

Swift 与 C++ 的互操作性

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()

With this change, all synchronization of our Actor instances will happen through this queue. When we "awat" a call from outside the Actor pruneOldEntries(), the dispatch will now be performed asynchronously on the corresponding queue. This gives us more control over how the individual Actors are synchronized, and can even synchronize the Actor with other code that doesn't already use the Actor, perhaps because it's written in Objective-C or C++.

Since the dispatch queue conforms to the new SerialExecutorprotocol, it is possible to realize the synchronization of Actors through the dispatch queue. We can provide our own synchronization mechanism by defining a new type conforming to this protocol, which can then be used with Actors:

// Executor protocolsprotocol Executor: AnyObject, Sendable {
    func enqueue(_ job: consuming ExecutorJob)
}
​
protocol SerialExecutor: Executor {
    func asUnownedSerialExecutor() -> UnownedSerialExecutor
    func isSameExclusiveExecutionContext(other executor: Self) -> Bool
}
​
extension DispatchSerialQueue: SerialExecutor {  }

This new type has only a few core operations: checking whether code has already been executed in the Executor's context. For example, is it running on the main thread? and extract unownedExecutor. The core operation of Executor is enqueue()that it acquires the ownership of the executor job. jobIs part of an asynchronous task that needs to run synchronously on the Executor. When called enqueue(), it is the Executor's responsibility to run this when no other code is running on the serial Executor job.

Swift Concurrency's abstract model consisting of Task and Actor covers a wide range of concurrent programming tasks. For more information, you can refer to WWDC23 Session Beyond the basics of structured concurrency .

Guess you like

Origin juejin.im/post/7254127644211216445