探索 Swift 5.2 的新函数式特性
作者 | John Sundell
来源 | Swift by Sundell,点击“阅读原文”查看作者更多文章
就新的语言特性而言,Swift 5.2 只是一个次要版本,因为此新版本的重点主要在于提高 Swift 底层基础结构的速度和稳定性,例如如何报告编译器错误,以及如何解决构建级别的依赖关系。
但是,尽管 Swift 5.2 的新语言特性数量可能相对较少,但却包含两项新特性,它们可能会对 Swift 作为函数式编程语言的整体功能产生很大影响。
在这里让我们来探讨一下这两个特性,以及我们如何使用它们,以了解一些在函数式编程世界中非常流行的不同范例,同时又以一种在面向对象的 Swift 代码更加一致和熟悉的方式。
在开始之前需要说明的是,由于 Swift 5.2(在撰写本文时)仍是附带在 Xcode 11.4 的 beta 版中,所以本文是一篇探索性的文章,是我对这些新语言特性的一些探索。后续随着我对新功能的使用积累经验越来越多,我的看法可能也会发生变化。尽管我会尽力持续更新本文,但我还是建议您纯粹将本文作为一种探索。
有了这个小小的免责声明,让我们开始探索吧!
将类型作为函数来调用
尽管 Swift 不是严格的函数式编程语言,但毫无疑问,函数在其总体设计和使用中起着非常重要的作用。从如何将闭包用作异步回调,到集合如何大量使用经典函数模式(例如map和reduce),可以说函数是无处不在。
在这方面,Swift 5.2 的有趣之处在于它开始模糊函数和类型之间的界限。尽管我们一直能够将任何给定类型的实例方法作为函数传递,但是我们现在可以像调用函数一样来调用某些类型。
让我们先来看一个示例,该示例使用我们在 “Caching in Swift” 中构建的 Cache 类型 - 该示例在包装的 NSCache 之上提供了一个更加友好的 API:
class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
private let dateProvider: () -> Date
private let entryLifetime: TimeInterval
...
func insert(_ value: Value, forKey key: Key) {
...
}
}
假设我们想在上述类型中添加一个便捷 API,以便在当前的 Value 类型符合标准库的 Identifiable 协议的情况下,让我们自动使用注入的值的 id 作为其缓存键。虽然我们也可以将新 API 简单地命名为 insert,但我们将使用一个非常特殊的名称 - callAsFunction:
extension Cache where Value: Identifiable, Key == Value.ID {
func callAsFunction(_ value: Value) {
insert(value, forKey: value.id)
}
}
这似乎是一个奇怪的命名约定,但是通过这种方式命名我们的新便捷方法,我们实际上为 Cache 类型赋予了一种有趣的新功能 -- 现在可以像调用函数一样调用它的实例,就像这样:
let document: Document = ...
let cache = Cache<Document.ID, Document>()
// We can now call our 'cache' variable as if it was referencing a
// function or a closure:
cache(document)
可以说,这很酷,也很奇怪。但问题是 –- 这有什么用?让我们继续研究一下 DocumentRenderer 协议,该协议定义了用于在应用程序中呈现 Document 实例的各种类型的通用接口:
protocol DocumentRenderer {
func render(_ document: Document,
in context: DocumentRenderingContext,
enableAnnotations: Bool)
}
与我们之前向 Cache 类型添加基于函数的便捷 API 的方式类似,让我们在这里做同样的事情 -- 只是这次,我们将扩展上述协议,以使任何符合条件的类型都可以通过一组默认参数来调用:
extension DocumentRenderer {
func callAsFunction(_ document: Document) {
render(document,
in: .makeDefaultContext(),
enableAnnotations: false
)
}
}
上面的两个更改相互独立时没有什么,但是如果将它们放在一起,就可以为一些更复杂的类型提供基于函数的便捷 API。例如,在这里我们构建了一个DocumentViewController,它同时使用了我们的 Cache 类型和实现了 DocumentRenderer 协议的基于 Core Animation 的类型,在加载文档时都可以简单地以类似函数的方式来调用:
class DocumentViewController: UIViewController {
private let cache: Cache<Document.ID, Document>
private let render: CoreAnimationDocumentRenderer
...
private func documentDidLoad(_ document: Document) {
cache(document)
render(document)
}
}
这非常酷,特别是如果我们希望实现更轻量级的 API 设计,或者我们正在构建某种形式的 DSL。尽管始终可以通过以类似于闭包的形式传递实例方法来获得类似的结果,但通过直接调用我们的类型,我们避免了手动传递这些方法,并且可以保留任何我们的 API 可能正在使用的外部参数标签。
例如,假设我们希望使 PriceCalculator 成为可调用类型。为了维护原始 API 的语义,即使声明了 callAsFunction 实现,我们也将保留 for 外部参数标签,如下所示:
extension PriceCalculator {
func callAsFunction(for product: Product) -> Int {
calculatePrice(for: product)
}
}
以下是上面的方法与我们使用变量来存储对类型的 calculatePrice 方法的引用的对比 -— 请注意,第一段代码如何丢弃参数标签,而第二段代码如何保留参数标签:
// Using a method reference:
let calculatePrice = PriceCalculator().calculatePrice
...
calculatePrice(product)
// Calling our type directly:
let calculatePrice = PriceCalculator()
...
calculatePrice(for: product)
使类型像函数一样被调用是一个非常有趣的概念,但也许更有趣的是,我们还能够将函数转换为正确的类型。
面向对象方式的函数式编程
尽管许多函数式编程概念的功能非常强大,但是在使用高度面向对象的框架中应用这些概念和模式通常会非常具有挑战。让我们看看 Swift 5.2 新的可调用类型特性是否可以帮助我们改善这种状况。
由于我们现在可以使任何类型作为可调用类型,因此我们还可以将任何函数转换为类型,同时仍可以按正常方式来调用该函数。为了实现这一点,让我们定义一个称为 Function 的类型,如下所示:
struct Function<Input, Output> {
let raw: (Input) -> Output
init(_ raw: @escaping (Input) -> Output) {
self.raw = raw
}
func callAsFunction(_ input: Input) -> Output {
raw(input)
}
}
就像我们之前定义的可调用类型一样,可以直接调用 Function 实例,使它们在大多数情况下的行为与其基础函数相同。
为了调用不接受任何输入的函数而又无需手动指定 Void 作为参数,我们还需定义 Function 的以下扩展,以将 Void 作为 Input 类型:
extension Function where Input == Void {
func callAsFunction() -> Output {
raw(Void())
}
}
上述包装器类型的妙处在于,它让我们能够以更加面向对象的方式使用强大的函数式编程概念。让我们看一下两个概念 -- 部分施用和管道。前者使我们可以将一个函数与一个值结合起来以产生一个不需要任何输入的新函数,而后者使我们可以将两个函数链接在一起 -- 现在可以像这样实现:
extension Function {
func combined(with value: Input) -> Function<Void, Output> {
Function<Void, Output> { self.raw(value) }
}
func chained<T>(to next: @escaping (Output) -> T) -> Function<Input, T> {
Function<Input, T> { next(self.raw($0)) }
}
}
请注意,我们将上述两个函数命名为 combined 和 chained,以使它们更适用于 Swift,而不是使用通常在更严格的函数式编程语言中常见的名称。
上面的设置使我们能够以面向对象的方式使用基于函数的依赖注入之类的技术。例如,我们在这里构建了一个用于编辑笔记的视图控制器,该控制器具有两个功能,一个是用于加载正在编辑的笔记的当前版本,另一个是用于向我们应用的数据存储提交更新:
class NoteEditorViewController: UIViewController {
private let provideNote: Function<Void, Note>
private let updateNote: Function<Note, Void>
init(provideNote: Function<Void, Note>,
updateNote: Function<Note, Void>) {
self.provideNote = provideNote
self.updateNote = updateNote
super.init(nibName: nil, bundle: nil)
}
...
private func editorTextDidChange(to text: String) {
var note = provideNote()
note.text = text
updateNote(note)
}
}
上述方法的优点在于,它使我们能够将构建用户界面和处理模型与数据逻辑完全分离。例如,上面的视图控制器实际使用的函数在这种情况下是 NoteManager 类型的方法,如下所示:
class NoteManager {
...
func loadNote(withID id: Note.ID) -> Note {
...
}
func updateNote(_ note: Note) {
...
}
}
然后,当我们创建视图控制器的实例时,我们使用 Function 类型将上述两种方法转换为我们的 UI 代码可以直接调用的函数 -- 无需知道任何底层类型或细节:
func makeEditorViewController(
forNoteID noteID: Note.ID
) -> UIViewController {
let provider = Function(noteManager.loadNote).combined(with: noteID)
let updater = Function(noteManager.updateNote)
return NoteEditorViewController(
provideNote: provider,
updateNote: updater
)
}
上面的方法不仅使我们分离了关注点,而且还使测试变得更容易,因为我们不再需要模拟任何协议或与基于单例的全局状态作斗争 -- 我们可以简单地注入我们希望的任何行为来进行测试,而只需要传入测试专用函数。
将 key path 作为函数传递
Swift 5.2 中引入的另一个非常有趣的新特性是,关键路径现在可以作为函数传递。在我们仅使用闭包从属性中提取数据的情况下,这非常方便 -- 因为我们现在可以直接传递该属性的键路径:
let notes: [Note] = ...
// Before:
let titles = notes.map { $0.title }
// After:
let titles = notes.map(\.title)
将该功能与上面的 Function 类型结合起来,现在我们可以轻松地构造函数链,让我们加载给定值,然后从中提取属性。在这里,我们要做的就是创建一个函数,使我们可以轻松查找与给定 note ID 关联的标签:
func tagLoader(forNoteID noteID: Note.ID) -> Function<Void, [Tag]> {
Function(noteManager.loadNote)
.combined(with: noteID)
.chained(to: \.tags)
}
当然,以上示例几乎没有涉及当我们开始将函数式编程模式与面向对象的 API 混合在一起时可能发生的事情,因此,这绝对是我们以后将要讨论的主题。
结论
Swift 5.2 和 Xcode 11.4 都是相当重要的版本 -- 带有针对编译器错误的新诊断引擎,许多新的测试和调试特性等。但是从语法的角度来看,Swift 5.2 还是一个有趣的版本,因为它继续拓宽了 Swift 采用函数式编程概念的方式,以及它如何开始模糊类型和函数之间的界限。
•
•
•