综述
要理解 Swift 中的函数和闭包,需要切实弄明白三件事情:
- 函数可以像 Int 或者 String 那样被赋值给变量,也可以作为另一个函数的输入参数,或 者另一个函数的返回值来使用。
- 函数能够捕获存在于其局部作用域之外的变量。
- 有两种方法可以创建函数,一种是使用 func 关键字,另一种是 { }。在 Swift 中,后一种被称闭包表达式。
函数可以被赋值给变量,也能够作为函数的输入和输出
Swift 和很多现代化编程语言相同,都把函数视为 “头等对象”。既可以将函数赋值给变量,也可以将它作为其他函数的参数或返回值。
从一个简单的函数开始,它会打印一个整数:
func printInt(i: Int) { print("You passed \(i).") }
复制代码
要将函数赋值给一个变量,比如 funVar,只需要将函数名字作为值。注意在函数名后面没有括号:
let funVar = printInt
复制代码
现在,我们可以使用 funVar 变量来调用 printInt 函数。注意在变量名后面需要使用括号:
funVar(2) // You passed 2.
复制代码
这里值得注意的是,我们不能在 funVar 调用时包含参数标签,而在 printInt 的调用 (像是 printInt(i: 2)) 却要求有参数标签。Swift 只允许在函数声明中包含标签,这些标签不是函数类型的一部分。也就是说,现在你不能将参数标签赋值给一个类型是函数的变量.
一个接受函数作为参数的函数:
func useFunction(function: (Int) -> () ) { function(3) }
useFunction(function: printInt) // You passed 3.
useFunction(function: funVar) // You passed 3.
复制代码
为什么函数可以作为变量使用的这种能力如此关键呢?因为很容易写出 “高阶” 函数,高阶函数将函数作为参数的能力使得它们在很多方面都非常有用。
函数也可以返回其他函数:
func returnFunc() -> (Int) -> String {
func innerFunc(i: Int) -> String {
return "you passed \(i)"
}
return innerFunc }
let myFunc = returnFunc()
myFunc(3) // you passed 3
复制代码
函数可以捕获存在于它们作用域之外的变量
当函数引用了在其作用域之外的变量时,这个变量就被捕获了,它们将会继续存在,而不是在超过作用域后被摧毁。
为了研究这一点,让我们修改一下 returnFunc 函数。这次我们添加一个计数器,每次调用这个函数时,计数器将会增加:
func counterFunc() -> (Int) -> String {
var counter = 0
func innerFunc(i: Int) -> String {
counter += i // counter 被捕获
return "Running total: \(counter)" }
return innerFunc
}
复制代码
一般来说,因为 counter 是 counterFunc 的局部变量,它在 return 语句执行之后就应该离开作 用域并被摧毁。但因为 innerFunc 捕获了它,所以 Swift 运行时将一直保证它的存在,直到捕获它的函数被销毁为止。我们可以多次调用 innerFunc,并且看到 running total 的输出在增加:
let f = counterFunc()
f(3) // Running total: 3
f(4) // Running total: 7
复制代码
如果我们再次调用 counterFunc() 函数,将会生成并捕获一个新的 counter 变量:
let g = counterFunc()
g(2) // Running total: 2
g(2) // Running total: 4
复制代码
这并不影响我们的第一个函数,它拥有属于自己的 counter:
f(2) // Running total: 9
复制代码
可以将这些函数以及它们所捕获的变量想象为一个类的实例,这个类拥有一个单一的方法 (也就是这里的函数) 以及一些成员变量 (这里的被捕获的变量)。
在编程术语里,一个函数和它所捕获的变量环境组合起来被称为闭包。上面 f 和 g 都是闭包的例子,因为它们捕获并使用了一个在它们作用域之外声明的非局部变量 counter。
函数可以使用 { } 来声明为闭包表达式
在 Swift 中,定义函数的方法有两种。一种是使用 func 关键字。另一种方法是使用闭包表达
式。下面这个简单的函数将会把数字翻倍:
func doubler(i: Int) -> Int { return i * 2 }
print([1,2,3,4].map(doubler)) // [2, 4, 6, 8]
复制代码
使用闭包表达式的语法来写相同的函数,像之前那样将它传给 map:
let doublerAlt = { (i: Int) -> Int in return 2 * i }
print([1,2,3,4].map(doublerAlt)) // [2, 4, 6, 8]
复制代码
使用闭包表达式来定义的函数可以被想成函数的字面量 (function literals),就像 1 是整数字面 量,”hello” 是字符串字面量那样。与 func 相比,它的区别在于闭包表达式是匿名的,它们没有被赋予一个名字。使用它们的方式只能是在它们被创建时将其赋值给一个变量,或者是将它们传递给另一个函数或方法。
其实还有第三种使用匿名函数的方法:你可以在定义一个表达式的同时,对它进行 调用。这个方法在定义那些初始化时代码多于一行的属性时会很有用。
使用闭包表达式声明的 doubler,和之前使用 func 关键字声明的函数,除了在参数标签上的处理上略有不同以外,其实是完全等价的。它们甚至存在于同一个 “命名空间” 中,这一点和有些编程语言有所不同。
那么 { } 语法有什么用呢?为什么不每次都使用 func 呢?因为闭包表达式可以简洁得多,特别是在像是 map 这样的将一个快速实现的函数传递给另一个函数时,这个特点更为明显。
这里, 我们将 doubler map 的例子用更短的形式进行了重写:
[1, 2, 3].map { $0 * 2 } // [2, 4, 6]
复制代码
之所以看起来和原来很不同,是因为这里使用了 Swift 中的一些可以让代码更加简洁的特性:
- 如果你将闭包作为参数传递,并且你不再用这个闭包做其他事情的话,就没有必要先将 它存储到一个局部变量中。
- 如果编译器可以从上下文中推断出类型的话,你就不需要指明它了。
- 如果闭包表达式的主体部分只包括一个单一的表达式的话,它将自动返回这个表达式的结果,可以不写 return。
- Swift 会自动为函数的参数提供简写形式,1 代表第二个参数,以此类推。
- 如果函数的最后一个参数是闭包表达式的话,可以将这个闭包表达式移到函数调用的圆括号的外部。这样的尾随闭包语法 (trailing closure syntax) 在多行的闭包表达式中表现非常好,因为它看起来更接近于装配了一个普通的函数定义,或者是像 if (expr) { } 这样的执行块的表达形式。
- 如果一个函数除了闭包表达式外没有别的参数,那么调用的时候在方法名后面的 圆括号也可以一并省略。
[1, 2, 3].map( { (i: Int) -> Int in return i * 2 } )
[1, 2, 3].map( { i in return i * 2 } )
[1, 2, 3].map( { i in i * 2 } )
[1, 2, 3].map( { $0 * 2 } )
[1, 2, 3].map() { $0 * 2 }
[1, 2, 3].map { $0*2 }
复制代码
还有一些时候,Swift 会要求你用更明确的方式进行调用。假设要得到一个随机数数组,一种快速的方法就是通过 Range.map 方法,并在 map 的函数中生成并返回随机数。这里,无论如何都要为 map 的函数提供一个参数。或者明确使用 _ 告诉编译器你承认这里有一个参数,但并不关心它究竟是什么:
0..<3).map { _ in Int.random(in: 1..<100) } // [53, 63, 88]
复制代码
当你需要显式地指定变量类型时,不一定要在闭包表达式内部来设定。比如,定义一个 isEven,它不指定任何类型:
let isEven = { $0 % 2 == 0 }
复制代码
在上面,isEven 被推断为 Int -> Bool。这和 let i = 1 被推断为 Int 是一个道理,因为 Int 是整数字面量的默认类型。
如果需要 isEven 是别的类型的话,也可以在闭包表达式中为参数和返回值指定类型:
let isEven = { $0 % 2 == 0 }
let isEvenAlt = { (i: Int) -> Bool in i % 2 == 0 }
let isEvenAlt2: (Int) -> Bool = { $0 % 2 == 0 }
let isEvenAlt3 = { $0 % 2 == 0 } as (Int) -> Bool
复制代码
因为闭包表达式最常见的使用情景就是在一些已经存在输入或者输出类型的上下文中,所以这
种写法并不是经常需要,不过知道它还是会很有用。
当然,如果能定义一个对所有整数类型都适用的 isEven 的泛用版本的计算属性会更好:
// 所有浮点型类型都遵守BinaryFloatingPoint, 所有整型都遵守BinaryInteger.
extension BinaryInteger {
var isEven: Bool { return self % 2 == 0 }
}
// 我们也可以选择为所有的 Integer 类型定义一个全局函数:
func isEven<T: BinaryInteger>(_ i: T) -> Bool {
returni % 2 == 0
}
复制代码
要把这个全局函数赋值给变量的话,你需要先决定它的参数类型。变量不能持有泛型函数,它
只能持有一个类型具体化之后的版本:
let int8isEven: (Int8) -> Bool = isEven
复制代码
函数的灵活性
在 Swift 中为集合排序很简单:
let myArray = [3, 1, 2]
myArray.sorted() // [1, 2, 3]
复制代码
一共有四个排序的方法:不可变版本的 sorted(by:) 和可变的 sort(by:),以及两者在待排序对象遵守 Comparable 时进行升序排序的无参数版本。如果你需要用不同于默认升序的顺序进行排序的话,只需提供一个排序函数:
myArray.sorted(by: >) // [3, 2, 1]
复制代码
就算待排序的元素不遵守 Comparable,但是只要有 < 操作符,你就可以使用这个方法来进行排序,比如元组就是一个例子:
var numberStrings = [(2, "two"), (1, "one"), (3, "three")] numberStrings.sort(by: <)
numberStrings // [(1, "one"), (2, "two"), (3, "three")]
复制代码
或者,可以使用一个更复杂的函数,来按照任意需要的计算标准进行排序:
let animals = ["elephant", "zebra", "dog"]
print(animals.sorted { lhs, rhs in
let l = lhs.reversed()
let r = rhs.reversed()
return l.lexicographicallyPrecedes(r)
}) // ["zebra", "dog", "elephant"]
复制代码
Swift 的排序还有一个能力,它可以使用任意的比较函数来对集合进行排序。
对比一下 Objective-C 中的排序方式。如果想用 Foundation 进行排序,会遇到一长串不同的选项,它有接受 selector、block 或者函数指针作为比较断言的排序方法,或者也可以传入一个 NSSortDescriptor 数组来定义如何排序。所有这些都提供了大量的灵活度和各式功能,但代价是使排序变得相当复杂 – 没有一个选项可以让我能 “只需要基于默认顺序进行一个常规的排序”。Foundation 中那些像是接受 block 作为比较断言的方法,在实质上和 Swift 的 sorted(by:) 方法是一样的;其他的像是接受 NSSortDescriptor 数组的方法,很好地利用了 Objective-C 的动态特性,使它们变成十分灵活和强大 (但是是弱类型) 的 API,但它们不能被直接移植到 Swift 中。
对于 selector 和动态派发的支持在 Swift 中依然有效,但是 Swift 标准库更倾向于使用基于函 数的方式。
从定义一个 Person 类型开始。因为展示 Objective-C 强大的运行时的工作方式,所以我们将它定义为 NSObject 的子类 (在纯 Swift 中,使用结构体会是更好的选择)。将这个类标记为 @objcMembers,这样它的所有成员都将在 Objective-C 中可见:
@objcMembers
final class Person: NSObject {
let first: String
let last: String
let yearOfBirth: Int
init(first: String, last: String, yearOfBirth: Int) {
self.first = first
self.last = last
self.yearOfBirth = yearOfBirth
// super.init() 在这里被隐式调用
}
}
let people = [
Person(first: "Emily", last: "Young", yearOfBirth: 2002),
Person(first: "David", last: "Gray", yearOfBirth: 1991),
Person(first: "Robert", last: "Barnes", yearOfBirth: 1985),
Person(first: "Ava", last: "Barnes", yearOfBirth: 2000),
Person(first: "Joanne", last: "Miller", yearOfBirth: 1994),
Person(first: "Ava", last: "Barnes", yearOfBirth: 1998),
]
复制代码
想要对这个数组进行排序,规则是先按照姓排序,再按照名排序,最后是出生年份。排序应该遵照用户的区域设置。
let lastDescriptor = NSSortDescriptor(key: #keyPath(Person.last),
ascending: true,
selector: #selector(NSString.localizedStandardCompare(_:)))
let firstDescriptor = NSSortDescriptor(key: #keyPath(Person.first),
ascending: true,
selector: #selector(NSString.localizedStandardCompare(_:)))
let yearDescriptor = NSSortDescriptor(key: #keyPath(Person.yearOfBirth),
ascending: true)
复制代码
要对数组进行排序,我们使用 NSArray 的 sortedArray(using:) 方法。这个方法可以接受一系列 排序描述符。为了确定两个元素的顺序,它会先使用第一个描述符,并检查其结果。如果两个 元素在第一个描述符下相同,那么它将使用第二个描述符,以此类推:
let descriptors = [lastDescriptor, firstDescriptor, yearDescriptor]
print((people as NSArray).sortedArray(using: descriptors).map({ "\(($0 as AnyObject).first!) \(($0 as AnyObject).last!)) (\(($0 as AnyObject).yearOfBirth!))" }))
// ["Ava Barnes) (1998)", "Ava Barnes) (2000)", "Robert Barnes) (1985)", "David Gray) (1991)", "Joanne Miller) (1994)", "Emily Young) (2002)"]
复制代码
排序描述符用到了 Objective-C 的两个运行时特性:首先,key 是 Objective-C 的键路径,它其 实是一个包含属性名字的链表.
其次是键值编程 (key-value-coding),它可以在运行时通过键查找一个对象上的对应值。 selector 参数接受一个 selector (实际上也是一个用来描述方法名字的字符串),在运行时,这 个 selector 将被用来查找比较函数,当对两个对象进行比较时,这个函数将使用指定键对应的值进行比较。
这是运行时编程的一个很酷的用例,排序描述符的数组可以在运行时构建,这一点在实现比如用户点击某一列时按照该列进行排序这种需求时会特别有用。
swift 捏?
var strings = ["Hello", "hallo", "Hallo", "hello"]
print(strings.sorted(by: { $0.localizedStandardCompare($1) == .orderedDescending}))
// ["Hello", "hello", "Hallo", "hallo"]
people.sorted { $0.yearOfBirth < $1.yearOfBirth }
/*
[Robert Barnes (1985), David Gray (1991), Joanne Miller (1994),
Ava Barnes (1998), Ava Barnes (2000), Emily Young (2002)] */
复制代码
不过,当把可选值属性与像是 localizedStandardCompare 这样的方法结合起来使用的话, 这条路就有点儿走不通了。代码会迅速变得丑陋不堪。
var files = ["one", "file.h", "file.c", "test.h"]
files.sort { l, r in r.fileExtension.flatMap {
l.fileExtension?.localizedStandardCompare($0)
} == .orderedAscending
}
files // ["one", "file.c", "file.h", "test.h"]
复制代码
这真的很丑。稍后我们会让可选值的排序稍微容易一些。不过就目前而言,我们甚至还没尝试对多个属性进行排序。要同时排序姓和名,我们可以用标准库的 lexicographicallyPrecedes 方 法来进行实现。这个方法接受两个序列,并对它们执行一个电话簿方式的比较,也就是说,这 个比较将顺次从两个序列中各取一个元素来进行比较,直到发现不相等的元素。所以,我们可 以用姓和名构建两个数组,然后使用 lexicographicallyPrecedes 来比较它们。我们还需要一个 函数来执行这个比较,这里我们把使用了 localizedStandardCompare 的比较代码放到这个函 数中:
people.sorted { p0, p1 in
let left = [p0.last, p0.first]
let right = [p1.last, p1.first]
return left.lexicographicallyPrecedes(right) {
$0.localizedStandardCompare($1) == .orderedAscending }
}
/*
[Ava Barnes (2000), Ava Barnes (1998), Robert Barnes (1985),
David Gray (1991), Joanne Miller (1994), Emily Young (2002)] */
复制代码
在每次比较的时候都构建一个数组是非常没有效率的,比较操作也是被写死的,通过这种方法我们将无法实现对 yearOfBirth 的排序。
函数作为依据
我们不会选择去写一个更复杂的函数来进行排序,先回头看看现状。排序描述符的方式要清晰不少,但是它用到了运行时编程。我们写的函数没有使用运行时编程,不过它们不太容易写出来或者读懂。
除了把排序信息存储在类里,我们还可以定义一个描述对象顺序的函数。其中,最简单的一种实现就是接受两个对象作为参数,并在它们顺序正确的时候,返回 true。这个函数的类型正是标准库中 sort(by:) 和 sorted(by:) 的参数类型。接下来,让我们先定义一个泛型别名来表达这种函数形式的排序描述符:
/// 一个排序断言,当第一个值应当排在第二个值之前时,返回 `true`
typealias SortDescriptor<Root> = (Root, Root) -> Bool
复制代码
现在,就可以用这个别名定义比较 Person 对象的排序描述符了。它可以比较出生年份,也可以比较姓的字符串:
let sortByYear: SortDescriptor<Person> = { $0.yearOfBirth < $1.yearOfBirth }
let sortByLastName: SortDescriptor<Person> = {
$0.last.localizedStandardCompare($1.last) == .orderedAscending }
复制代码
为了避免复制粘贴,我们可以定义一个函数,它和 NSSortDescriptor 大体相似,但不涉及运行时编程。这个函数的第一个参数是一个名为 key 的函数,此函数接受一个正在排序的数组的元素,并返回这个排序描述符所处理的属性的值。然后,我们使用第二个参数 areInIncreasingOrder 比较 key 返回的结果。最后,用 SortDescriptor 把这两个参数包装一 下,就是要返回的排序描述符了:
/// `key` 函数,根据输入的参数返回要进行比较的元素
/// `by` 进行比较的断言
/// 通过用 `by` 比较 `key` 返回值的方式构建 `SortDescriptor` 函数
func sortDescriptor<Root, Value>(
key: @escaping(Root) -> Value,
by areInIncreasingOrder:@escaping(Value, Value) -> Bool)
-> SortDescriptor<Root> {
return { areInIncreasingOrder(key($0), key($1)) }
}
复制代码
有了这个函数,我们就可以用另外一种方式来定义 sortByYear 了:
let sortByYearAlt: SortDescriptor<Person> = sortDescriptor(key: { $0.yearOfBirth }, by: <)
people.sorted(by: sortByYearAlt)
/*
[Robert Barnes (1985), David Gray (1991), Joanne Miller (1994), Ava Barnes (1998), Ava Barnes (2000), Emily Young (2002)]
*/
复制代码
甚至,我们还可以为所有实现了 Comparable 的类型定义一个重载版本:
func sortDescriptor<Root, Value>(key: @escaping (Root) -> Value) -> SortDescriptor<Root> where Value: Comparable
{
return { key($0) < key($1) }
}
let sortByYearAlt2: SortDescriptor<Person> =
sortDescriptor(key: { $0.yearOfBirth })
复制代码
这两个 sortDescriptor 都使用了返回布尔值的排序函数,因为这是标准库中对于比较断言的约 定。但另一方面,Foundation 中像是 localizedStandardCompare 这样的 API,返回的却是一 个包含 (升序,降序,相等) 三种值的 ComparisonResult。给 sortDescriptor 增加这种支持也 很简单:
func sortDescriptor<Root, Value>(
key: @escaping (Root) -> Value,
ascending: Bool = true,
by comparator: @escaping (Value) -> (Value) -> ComparisonResult) -> SortDescriptor<Root>
{
return { lhs, rhs in
let order: ComparisonResult = ascending?.orderedAscending : .orderedDescending
return comparator(key(lhs))(key(rhs)) == order
}
}
复制代码
这样,我们就可以用简短清晰得多的方式来写 sortByFirstName 了:
let sortByFirstName: SortDescriptor<Person> =
sortDescriptor(key: { $0.first }, by: String.localizedStandardCompare)
people.sorted(by: sortByFirstName)
/*
[Ava Barnes (2000), Ava Barnes (1998), David Gray (1991),
Emily Young (2002), Joanne Miller (1994), Robert Barnes (1985)] */
复制代码
现在,SortDescriptor 和 NSSortDescriptor 就拥有了同样地表达能力,不过它是类型安全的, 而且不依赖于运行时编程。
目前我们只能用一个单一的 SortDescriptor 函数对数组进行排序。如果你还记得,我们曾经用 NSArray.sortedArray(using:) 方法指定了多个比较运算符对数组进行排序。给 Array 甚至是 Sequence 协议添加类似的功能也很简单。不过,我们要添加两个版本的 sort:一个用于原地排序,另一个返回排序后的新数组。
为了避免添加多个扩展,我们使用了一种不同的实现方式:我们定义了一个把多个排序描述符 合并为一个的函数。它的工作方式和 sortedArray(using:) 类似:首先它会使用第一个描述符, 并检查比较的结果。如果相等,再使用第二个,第三个,直到全部用完:
func combine<Root>
(sortDescriptors: [SortDescriptor<Root>]) -> SortDescriptor<Root> {
return { lhs, rhs in
for areInIncreasingOrder in sortDescriptors {
if areInIncreasingOrder(lhs, rhs) { return true }
if areInIncreasingOrder(rhs, lhs) { return false }
}
return false
}
}
复制代码
现在我们可以最终把一开始的例子重写为这样:
let combined: SortDescriptor<Person> = combine(
sortDescriptors: [sortByLastName, sortByFirstName, sortByYear] )
people.sorted(by: combined)
/*
[Ava Barnes (1998), Ava Barnes (2000), Robert Barnes (1985),
David Gray (1991), Joanne Miller (1994), Emily Young (2002)] */
复制代码
最终,我们得到了一个与 Foundation 中的版本在行为和功能上等价的实现方法,但是我们的 方式要更安全,也更符合 Swift 的语言习惯。因为 Swift 的版本不依赖于运行时编程,所以编译 器有机会对它进行更好的优化。另外,我们也可以使用它排序结构体或非 Objective-C 的对象。
基于函数的方式有一个不足,那就是函数是不透明的。我们可以获取一个 NSSortDescriptor 并将它打印到控制台,我们也能从排序描述符中获得一些信息,比如键路径,selector 的名字,以及排序顺序等。但是在基于函数的方式中,这些都无法做到。如果这些信息很重要的话,我们可以将函数封装到一个结构体或类中,然后在其中存储一些额外的调试信息。
把函数作为数据使用的这种方式 (例如:在运行时构建包含排序函数的数组),把语言的动态行 为带到了一个新的高度。这使得像 Swift 这种需要编译的静态语言也可以实现诸如 Objective-C 或 Ruby 中的一部分动态特性。
我们也看到了合并其他函数的函数的用武之地,它也是函数式编程的构建模块之一。例如, combine(sortDescriptors:) 函数接受一个排序描述符的数组,并将它们合并成了单个的排序描述符。在很多不同的应用场景下,这项技术都非常强大。
函数作为代理
Cocoa 风格的代理
protocol AlertViewDelegate: AnyObject {
func buttonTapped(atIndex: Int)
}
复制代码
AlertViewDelegate 是一个只有类才能实现的协议 (它继承自 AnyObject),因为我们希望在 AlertView 中持有一个代理的弱引用。这样一来,就不用担心引用循环的问题了。AlertView 不会强引用它的代理,所以即使是代理 (直接或者间接) 强引用了 alert view,也不会出现什么问题。如果代理被析构了,delegate 属性也会自动变为 nil.
这种模式在处理类的时候非常好用。假设有一个 ViewController 类,初始化 alert view,并把自己设为这个视图的代理。因为代理被标记为 weak,不需要担心引用循环.
class AlertView {
var buttons: [String]
weak var delegate: AlertViewDelegate?
init(buttons: [String] = ["OK", "Cancel"]) { self.buttons = buttons }
func fire() { delegate?.buttonTapped(atIndex: 1) }
}
class ViewController: AlertViewDelegate {
let alert: AlertView
init() { alert = AlertView(buttons: ["OK", "Cancel"]) alert.delegate = self }
func buttonTapped(atIndex index: Int) { print("Button tapped: \(index)") }
}
复制代码
将代理属性标记为 weak 在实践中非常常见,这个约定让内存管理变得很容易。实现代理协议的类不需要担心引入引用循环的问题。
结构体上实现代理
可以放宽 AlertViewDelegate 的定义,让它不限于只针对类。将 buttonTapped(atIndex:) 标记为 mutating。这样,结构体就可以在方法被调用时改变自身的内容了:
protocol AlertViewDelegate {
mutating func buttonTapped(atIndex: Int)
}
复制代码
需要对 AlertView 的 delegate 属性进行更改,不能再是弱引用了:
class AlertView {
var buttons: [String]
var delegate: AlertViewDelegate?
init(buttons: [String] = ["OK", "Cancel"]) { self.buttons = buttons }
func fire() { delegate?.buttonTapped(atIndex: 1) }
}
复制代码
如果将一个对象赋值给 delegate 属性,这个对象将被强引用。特别在处理代理的时候,这种强引用意味着很容易就在某个时候引入引用循环。不过,现在可以使用结构体。举例来说,可以创建一个记录所有按钮事件的结构体:
struct TapLogger: AlertViewDelegate {
var taps: [Int] = []
mutating func buttonTapped(atIndex index: Int) { taps.append(index) }
}
复制代码
一开始,可能看起来一切正常。我们创建一个 alert view 和一个 logger,然后将两个关联起来。不过,如果我们在事件被触发后再检查 logger.taps,会发现数组依然为空:
let alert = AlertView()
var logger = TapLogger()
alert.delegate = logger
alert.fire()
logger.taps // []
复制代码
当给 alert.delegate 赋值的时候,Swift 将结构体进行了复制。所以,更新的并不是我们期望中的 logger.taps,而是 alert.delegate.taps。但糟糕的是,当我们这样赋值之后,实现了 AlertViewDelegate 的具体类型就丢了。为了把它找回来,我们还得用一个条件类型转换:
if let theLogger = alert.delegate as? TapLogger {
print(theLogger.taps)
}
// [1]
复制代码
很显然这种方式并不能很好地工作。当使用类时,很容易造成引用循环,当使用结构体时,原
来的值不会被改变。一句话总结:在代理和协议的模式中,并不适合使用结构体。
使用函数,而非代理
如果代理协议中只定义了一个函数的话,完全可以用一个存储回调函数的属性来替换原来的代理属性。可以用一个可选的 buttonTapped 属性来做到,默认情况下这个属性是 nil:
class AlertView {
var buttons: [String]
var buttonTapped: ((_ buttonIndex: Int) -> ())?
init(buttons: [String] = ["OK", "Cancel"]) { self.buttons = buttons }
func fire() { buttonTapped?(1) }
}
复制代码
和之前一样,可以创建一个 logger 结构体,一个 alert view 实例以及一个 logger 变量:
struct TapLogger {
var taps: [Int] = []
mutating func logTap(index: Int) { taps.append(index) }
}
let alert = AlertView()
var logger = TapLogger()
alert.buttonTapped = logger.logTap // 错误
复制代码
不过,不能简单地将 logTap 方法赋值给 buttonTapped 属性。Swift 编译器会告诉我们 “不允许部分应用 ‘可变’ 方法”:
在上面的代码中,这个赋值的结果不明确。是 logger 需要复制一份呢,还是 buttonTapped 需
要改变它原来的状态 (即 logger 被捕获) 呢?
要修正这个错误,需要将赋值的右侧用一个闭包封装起来。这让代码变得十分清楚,是想要捕获原来的 logger 变量 (不是其中的值),然后我们将改变它:
alert.buttonTapped = { logger.logTap(index: $0) }
// 这么做还有一个额外的好处,就是命名现在解耦了:
// 回调属性的名字是 buttonTapped,而实 现它的函数叫做 logTap。
// 除了使用方法以外,我们也可以指定一个匿名函数:
alert.buttonTapped = { print("Button \($0) was tapped") }
复制代码
当将回调和类合在一起使用时,我们有一些忠告。让我们回到我们的 view controller 例子中, 在它的初始化方法里,view controller 现在可以将它的 buttonTapped 赋值给 alert view 的回 调,而不是将自身赋值为 alert view 的代理:
class ViewController {
let alert: AlertView
init() {
alert = AlertView(buttons: ["OK", "Cancel"])
alert.buttonTapped = self.buttonTapped(atIndex:)
}
func buttonTapped(atIndex index: Int) {
print("Button tapped: \(index)")
}
}
复制代码
alert.buttonTapped = self.buttonTapped(atIndex:)
这行代码看起来是个无害的赋值语句,但是:刚刚创建了一个引用循环!所有指向某个对象的实例方法的引用 (比如这个例子中的 self.buttonTapped) 都会在背后捕获这个对象。要理解为什么一定要这样做,可以站在 alert view 的视角来考虑问题:当 alert view 要调用存储在它的 buttonTapped 属性中的回调函数时,这个函数必须 “知道” 它到底需要调用哪个对象的实例方法 – 因此不光要存储一个指向 ViewController.buttonTapped(atIndex:)
的引用,还需要存储实例本身。
想要避免强引用,通常我们需要将方法调用包装在另一个闭包中,这个闭包通过弱引用的方式
捕获对象:
alert.buttonTapped = { [weak self] index in
self?.buttonTapped(atIndex: index)
}
复制代码
这样一来,alert view 就不会强引用 view controller 了。如果我们能保证 alert view 的生命周 期和 view controller 绑定的话,另一个选项是使用 unowned 来替代 weak。使用 weak 时,当 alert view 的生命周期超过 view controller 时,当函数被调用时,闭包里的 self 将为 nil。
如检查 ViewController.buttonTapped 这个表达式的类型时,会发现它是 (ViewController) -> (Int) -> ()。这是什么?在底层,实例方法会被处理为这样一 个函数:如果给定某个实例,它将返回另一个可以在该实例上进行操作的函 数。someVC.buttonTapped 实际上只是 ViewController.buttonTapped(someVC) 的 另一种写法 – 两种表达式返回的都是类型为 (Int) -> () 的函数,这个函数是一个强引用 了 someVC 实例的闭包.
正如我们所看到的,在使用协议和回调函数之间,一定是存在权衡的。协议的方式比较啰嗦,但是一个只针对类的协议配合 weak 代理可以让我们完全不用担心引用循环。将代理用函数替代可以带来更多的灵活性,让我们可以使用结构体和匿名函数。不过,当处理类的时候,你需要特别小心,避免引入引用循环。
另外,当你需要多个紧密相连的回调函数 (比如,为一个 table view 提供数据) 的时候最好将 它们组织在一个协议里,而不是去使用单个回调。另一方面,当使用协议的时候,遵守协议的类型需要实现其中的所有方法。
要注销一个代理或者函数回调,我们可以简单地将它设为 nil。但如果我们的类型是用一个数组 来存储代理或者回调呢?对于基于类的代理,我们可以直接将它从代理列表中移除;不过对于回调函数,就没那么简单了,因为函数不能被比较,所以我们需要添加额外的逻辑去进行移除。
inout 参数和可变方法
如果你有些 C 或 C++ 的背景,Swift 中用在 inout 参数前面的 & 可能会给你一种这是在传递引 用的错觉。但事实并非如此,inout 做的事情是传值,然后复制回来,并不是传递引用。引用官 方《Swift 编程语言》中的话:
一个 inout 参数持有一个传递给函数的值,函数可以改变这个值,然后从函数中传出 并替换掉原来的值。
为了了解什么样的表达式可以作为 inout 参数,需要区分 lvalue 和 rvalue。lvalue 描述的是一个内存地址,它是 “左值 (left value)” 的缩写,因为 lvalues 是可以存在于赋值语句左侧的表达式。举例来说,array[0] 是一个 lvalue,因为它描述了数组中第一个元素所在的内存位置。而 rvalue 描述的是一个值。2 + 2 是一个 rvalue,它描述的是 4 这个值。你不能把 2 + 2 或者 4 放到赋值语句的左侧。
对于 inout 参数,你只能传递左值,因为右值是不能被修改的。当你在普通的函数或者方法中使用 inout 时,需要显式地将它们传入:即在每个左值前面加上 & 符号。例如,当调用 increment 时 (它有一个 inout int 参数),我们就要在传入的变量前添加 &:
func increment(value: inout Int) { value += 1 }
vari=0
increment(value: &i)
复制代码
如果用 let 定义一个变量的话,它就不能被用作一个 lvalue 了。因为不能改变 let 变量,所 以将它用作 inout 也是没有意义的,只能使用那些 “可更改” 的 lvalue。
运算符也可以接受 inout 值,但是为了简化,在调用时我们不需要加上 & 符号,简单地使用 lvalue 就可以了。比如,自增运算符在 Swift 3 中被移除了,不过我们可以自己把它加回来:
postfix func ++(x: inout Int) { x+=1 }
point.x++
point // Point(x: 2, y: 0)
复制代码
可变运算符甚至还可以与可选链一起使用。将自增操作连接到字典下标访问后:
var dictionary = ["one": 1]
dictionary["one"]?++
dictionary["one"] // Optional(2)
// 注意,在字典查找返回 nil 的时候,++ 操作符不会被执行。
复制代码
编译器可能会把 inout 变量优化成引用传递,而非传入和传出时的复制。不过,文档已经明确
指出我们不应该依赖这个行为。
嵌套函数和 inout
可以在嵌套函数中使用 inout 参数,但 Swift 会保证使用是安全的。
func incrementTenTimes(value: inout Int) {
func inc() { value += 1 }
for _ in 0..<10 { inc() }
}
varx=0
incrementTenTimes(value: &x) x // 10
复制代码
不过,不能够让这个 inout 参数逃逸:
func escapeIncrement(value: inout Int) -> () -> () {
func inc() { value += 1 }
// error: 嵌套函数不能捕获 inout 参数然后让其逃逸
return inc
}
复制代码
& 不意味 inout 的情况
说到不安全 (unsafe) 的函数,应该小心 & 的另一种含义:把一个函数参数转换为一个不安全
指针。
如果一个函数接受 UnsafeMutablePointer 作为参数,可以用和 inout 参数类似的方法,在一个 var 变量前面加上 & 传递给它。在这种情况下,确实在传递引用,更确切地说,是在传递指针。
这里是一个没有使用 inout,而是接收不安全的可变指针作为参数的 increment 函数的例子:
func incref(pointer: UnsafeMutablePointer<Int>) -> () -> Int {
// 将指针的的复制存储在闭包中
return {
pointer.pointee += 1
return pointer.pointee }
}
// Swift 的数组可以无缝地隐式退化为指针,这使得将 Swift 和 C 一 起使用的时候非常方便。现在,假设在调用这个函数之前,你传入的数组已经离开其作用域了:
let fun: () -> Int
do {
var array = [0]
fun = incref(pointer: &array)
}
fun() // 有时候是 0,有时候是 1,有时候是 140362397107840,有时候就直接崩溃了。
复制代码
当处理不安 全的指针时,你需要非常小心变量的生命周期。
属性
有两种方法和其他普通的方法有所不同,那就是计算属性和下标操作符。计算属性看起来和常规的属性很像,但是它并不使用任何内存来存储自己的值。相反,这个属性每次被访问时,返回值都将被实时计算出来。计算属性实际上只是一个方法,只是它的定义和调用约定不太寻常。
来看看定义属性的各种方式。我们以表示 GPS 追踪信息的结构体作为开始,它在一个叫做 record 的数组中存储了所有的记录点:
struct GPSTrack {
var record: [(CLLocation, Date)] = []
}
// 如果我们想要将 record 属性作为外部只读,
// 内部可读写的话,我们可以使用 private(set)
// 或者 fileprivate(set) 修饰符:
struct GPSTrack {
private(set) var record: [(CLLocation, Date)] = []
}
// 想要获取 GPS 追踪中所有记录的时间戳,我们可以创建一个计算属性:
extension GPSTrack {
/// 返回 GPS 追踪的所有时间戳
/// - 复杂度:O(n),n 是记录点的数量。
var timestamps: [Date] { return record.map { $0.1 } }
复制代码
因为没有指定 setter,所以 timestamps 属性是只读的。它的结果不会被缓存每次你访 问这个属性时,结果都要被计算一遍。Swift API 指南推荐你对所有复杂度不是 O(1) 的计算属性都应该在文档中写明,因为调用者可能会假设访问一个属性的耗时是常数时间。
变更观察者
我们也可以为属性和变量实现 willSet 和 didSet 方法,每次当一个属性被设置时 (就算它的值没有发生变化),这两个方法都会被调用。它们会分别在设置前和设置后被立即调用。在使用 Interface Builder 时,这个技巧会很有用:我们可以为 IBOutlet 实现一个 didSet,这样就可以知道它是什么时候被连接的了。在这个 didSet 中我们可以执行额外的配置操作。比如说,如果我们想要在标签可用时就设置文本颜色,那么可以这么做:
class SettingsController: UIViewController {
@IBOutlet weak var label: UILabel? {
didSet { label?.textColor = .black}
}
}
复制代码
属性观察者必须在声明一个属性的时候就被定义,你无法在扩展里进行追加。所以,这不是一个提供给类型用户的工具,它是专门提供给类型的设计者的。willSet 和 didSet 本质上是一对属性的简写:一个是存储值的私有存储属性;另一个是读取值的公开计算属性,这个计算属性的 setter 会在将值存储到私有存储属性之前和/或之后,进行额外的工作。这和 Foundation 中 的键值观察 (KVO,key-value observing)有本质的不同,键值观察通常是对象的消费者来观察对象内部变化的手段,而与类的设计者是否希望如此无关。
不过,可以在子类中重写一个属性,来添加观察者。
class Robot {
enum State {
case stopped, movingForward, turningRight, turningLeft
}
var state = State.stopped
}
class ObservableRobot: Robot {
override var state: State {
willSet {
print("Transitioning from \(state) to \(newValue)")
}
}
}
var robot = ObservableRobot()
robot.state = .movingForward // Transitioning from stopped to movingForward
复制代码
这种做法和 “改变观察者是一个类型的内部特性” 并不矛盾。即便这种做法不被允许,子类也还
是可以通过使用一个计算属性的 setter 对父类存储属性进行重写,并完成那些额外的工作。
使用上的差异被反应在这些特性的实现中。KVO 使用 Objective-C 的运行时特性,动态地在类的 setter 中添加观察者,这在现在的 Swift 中,特别是对值类型来说,是无法实现的。Swift 的属性观察是一个纯粹的编译时特性。KVO 和属性观察者在用法上的差异也反映在了它们各自的实现上。KVO 是基于 Objective-C 运行时特性的,它可以动态地在类的 setter 中添加观察者,这在现如今的 Swift 上,特别是对值类型来说,是无法实现的。而属性观察者,则是一个纯粹的编译时特性。
延迟存储属性
延迟初始化一个值在 Swift 中是一种常见的模式,为了定义一个延迟初始化的属性,Swift 提供了一个专用的关键字 lazy。关键字来定义一个延迟属性 (lazy property)。要注意的是,延迟属性只能用 var 定义,因为在初始化方法完成后,它的初始值可能仍旧是未设置的。而 Swift 对 let 常量则有着严格的规则,它必须在实例的初始化方法完成之前就拥有值。延迟修饰符是编程记忆化的一种具体的表现形式。
例如,在一个显示 GPSTrack 的 view controller 上,我们可能会想展示一张追踪路径的预览图像。通过延迟加载,我们可以将耗时的图像生成工作推迟到属性被首次访问的时候:
class GPSTrackViewController: UIViewController {
var track: GPSTrack = GPSTrack()
lazy var preview: UIImage = {
for point in track.record {
// 进行昂贵的计算
}
return UIImage(/* … */) }()
}
注意我们是如何定义延迟属性的:它是一个返回存储值 (在我们的例子中,就是一张图片) 的闭包表达式。当属性第一次被访问时,闭包将被执行 (注意闭包后面的括号),它的返回值将被存储在属性中。对于需要多行代码来初始化的延迟属性来说,这种闭包方式是很常见的。
因为延迟属性需要存储,所以我们需要在 GPSTrackViewController 的定义中来加入这个延迟 属性。和计算属性不同,存储属性和需要存储的延迟属性不能被定义在扩展中。
如果 track 属性发生了改变,preview 并不会自动更新。用一个更简单的例子来看看发生了什么。有一个 Point 结构体,并且用延迟的方式存储了 distanceFromOrigin:
struct Point {
var x: Double
var y: Double
private(set) lazy var distanceFromOrigin: Double = (x*x + y*y).squareRoot()
init(x: Double, y: Double) { self.x = x self.y = y }
}
复制代码
当我们创建一个点后,可以访问 distanceFromOrigin 属性,这将会计算出值,并存储起来等待 重用。不过,如果我们之后改变了 x 的值,这个变化将不会反应在 distanceFromOrigin 中:
var point = Point(x: 3, y: 4)
point.distanceFromOrigin // 5.0
point.x += 10
point.distanceFromOrigin // 5.0
复制代码
一种解决的办法是在 x 和 y 的 didSet 中重新计算 distanceFromOrigin,不过这样一来 distanceFromOrigin 就不是真正的延迟属性了,在每次 x 或者 y 变化的时候它都将被重新计算。当然,在这个例子中,更好的解决方式是,我们一开始就将 distanceFromOrigin 设置为一个普通的 (非延迟) 计算属性。
访问一个延迟属性是 mutating 操作,因为这个属性的初始值会在第一次访问时被设置。当结构体包含一个延迟属性时,这个结构体的所有者如果想要访问该延迟属性的话,也需要将结构体声明为可变量,因为访问这个属性的同时,也会潜在地对这个属性的容器进行改变。所以,下面的代码是不被允许的:
let immutablePoint = Point(x: 3, y: 4)
immutablePoint.distanceFromOrigin
// 错误:不能在一个不可变量上使用可变 getter
复制代码
让想访问这个延迟属性的所有 Point 用户都使用 var 是非常不方便的事情,所以在结构体中使 用延迟属性通常不是一个好主意。
另外需要注意,lazy 关键字不会进行任何线程同步。如果在一个延迟属性完成计算之前,多个线程同时尝试访问它的话,计算有可能进行多次,计算过程中的各种副作用也会发生多次。
下标
在标准库中,我们已经看到过一些下标用法了,例如:用dictionary[key]
这样的方式在字典查找元素。这些下标很像函数和计算属性的混合体,只不过它们使用了特殊的语法。之所以像函数,是因为它们也可以接受参数;之所以像计算属性,是因为它们要么是只读的 (只提供 get),要么是可读写的 (同时提供 get 和 set)。和普通的函数类似,我们可以通过重载提供不同类型的下标操作符。比如,数组默认有两个下标操作,一个用来访问单个元素,另一个用来返回一个切片 (更精确地说,它们是被定义在 Collection 协议中的):
let fibs = [0, 1, 1, 2, 3, 5]
let first = fibs[0] // 0
fibs[1..<3] // [1, 1]
复制代码
自定义下标操作
我们可以为自己的类型添加下标支持,也可以为已经存在的类型添加新的下标重载。举个例子,让我们给 Collection 添加一个接受索引列表为参数的下标方法,它返回一个包含这些索引位置上的元素的数组:
extension Collection {
subscript(indexs indexs: Index...) -> [Element] {
var res: [Element] = []
for index in indexs {
res .append(self[index])
}
return res
}
}
var arr = [1,2,3,4,5,6,7,8]
print(arr[indexs:1,2,3]) // [2, 3, 4]
复制代码
请注意我们是如何使用一个显式的参数标签,来将我们的下标方法和标准库中的方法区分开来的。三个点表示 indexList 是一个可变长度参数 (variadic parameter)。调用者可以传入零个或多个以逗号分隔的指定类型的值 (在这里是 Collection 的 Index 类型)。在函数中,这个参数将 被作为数组来使用。
下标进阶
标还可以在参数或者返回类型上使用泛型。考虑下面这个类型为[String: Any]
的异值字典:
var japan: [String: Any] = [
"name": "Japan",
"capital": "Tokyo",
"population": 126_740_000,
"coordinates": [
"latitude": 35.0,
"longitude": 139.0
]
]
japan["coordinate"]?["latitude"] // Error
print((japan["coordinates"] as? [String: Double])?["latitude"])
复制代码
不仅代码很快变得很丑,而且它也依然无法工作。问题在于你不能修改一个类型转换后的变量 – japan[“coordinates”] as? [String: Double]
这个表达式已经不再是一个左值了。你需要先将这个嵌套的字典存储到一个局部变量中,修改它,然后再把这个变量赋值回顶层的键。
我们可以通过为 Dictionary 提供一个泛型下标的扩展,来更好地完成这件事。这个下标方法的 第二个参数接受目标类型,并且在下标实现中进行类型转换的尝试:
extension Dictionary {
subscript<Result>(_ key:Key, as type:Result.Type) -> Result? {
get {
return self[key] as? Result
}
set {
guard let value = newValue else {
self[key] = nil
return
}
guard let value2 = value as? Value else {
return
}
self[key] = value2
}
}
}
var japan: [String: Any] = [
"name": "Japan",
"capital": "Tokyo",
"population": 126_740_000,
"coordinates": [
"latitude": 35.0,
"longitude": 139.0
]
]
japan["coordinates", as:[String: Double].self]?["latitude"] = 100
print(japan["coordinates", as:[String: Double].self]?["latitude"]) // Optional(100.0)
复制代码
泛型下标方法为类型系统填上了一个大洞。不过,你可能会觉得这个例子最终的语法还是有些丑陋。基本上来说,Swift 并不适合用来处理像上面字典这样的异值集合。在大多数情况下,可能为你的数据定义一个自定义类型 (比如这里可以定义一个 Country 结构体),然后让这些类型满足 Codable 协议,来在值和数据交换格式之间进行转换,会是更好的选择。
键路径
Swift 4 中添加了键路径 (key paths) 的概念。键路径是一个指向属性的未调用的引用,它和对某个方法的未使用的引用很类似。键路径也为 Swift 的类型系统补全了缺失的很大一块拼图。在之前,你无法像引用方法 (比如 String.uppercased) 那样引用一个类型的属性 (比如 String.count)。和 Objective-C 及 Foundation 中的键路径相比,除了拥有共同的名字以外,Swift 中的键路径有很大不同。
键路径表达式以一个反斜杠开头,比如 \String.count。反斜杠是为了将键路径和同名的类型属性区分开来 (假如 String 也有一个 static count 属性的话,String.count 返回的就会是这个属性值了)。类型推断对键路径也是有效的,在上下文中如果编译器可以推断出类型的话,你可以将类型名省略,只留下 .count。
正如其名,键路径描述了一个值从根开始的层级路径。举例来说,在下面的 Person 和 Address 类型中,\Person.address.street 表达了一个人的街道住址的键路径:
struct Address {
var street: String
var city: String
var zipCode: Int
}
struct Person {
let name: String
var address: Address
}
let streetKeyPath = \Person.address.street // Swift.WritableKeyPath<Person, Swift.String>
let nameKeyPath = \Person.name // Swift.KeyPath<Person, Swift.String>
复制代码
键路径可以由任意的存储和计算属性组合而成,其中还可以包括可选链操作符。编译器会自动为所有类型生成 [keyPath:]
的下标方法。通过这个方法来 “调用” 某个键路径。对键路径的调用,也就是在某个实例上访问由键路径所描述的属性。所以,"Hello"[keyPath: \.count]
等效于 "Hello".count
。
let simpsonResidence = Address(street: "1094 Evergreen Terrace", city: "Springfield", zipCode: 97475)
var lisa = Person(name: "Lisa Simpson", address: simpsonResidence)
print(lisa[keyPath: nameKeyPath]) // Lisa Simpson
复制代码
如果检查上面两个键路径变量的类型,会注意到 nameKeyPath 的类型是 KeyPath<Person, String>。这个键路径是强类型的,表示该键路径可以作用于 Person,并 返回一个 String。而 streetKeyPath 是一个 WritableKeyPath,这是因为构成这个键路径的所有属性都是可变的,所以这个可写键路径本身允许其中的值发生变化:
lisa[keyPath: streetKeyPath] = "742 Evergreen Terrace"
.
对 nameKeyPath
做同样的操作会造成错误,因为它背后的属性不是可变的。
键路径不仅可以描述属性,我们也可以用它们来描述下标操作。例如,可以用下面这样的语法 提取数组里第二个 person 对象的 name 属性:
var bart = Person(name: "Bart Simpson", address: simpsonResidence)
let people = [lisa, bart]
people[keyPath: \.[1].name] // Bart Simpson
复制代码
同样的语法也可用于在键路径中包含字典下标。
可以通过函数建模的键路径
一个将基础类型 Root 映射为类型为 Value 的属性的键路径,和一个具有 (Root) -> Value 类型 的函数十分类似。而对于可写的键路径来说,则对应着一对获取和设置值的函数。相对于这样的函数,键路径除了在语法上更简洁外,最大的优势在于它们是值。你可以测试键路径是否相等,也可以将它们用作字典的键 (因为它们遵守 Hashable)。另外,不像函数,键路径是不包含状态的,所以它也不会捕获可变的状态。如果使用普通的函数的话,这些都是无法做到的。
键路径还可以通过将一个键路径附加到另一个键路径的方式来生成。这么做时,类型必须要匹配;如果你有一个从 A 到 B 的键路径,那么你要附加的键路径的根类型必须为 B,得到的将会 是一个从 A 到 C 的键路径,其中 C 是所附加的键路径的值的类型:
// KeyPath<Person, String> + KeyPath<String, Int> = KeyPath<Person, Int>
let nameCountKeyPath = nameKeyPath.appending(path: \.count)
// Swift.KeyPath<Person, Swift.Int>
复制代码
用键路径代替函数来重写本章前面提到的排序描述符。我们之前通过一个 (Root) -> Value 函数来定义了 sortDescriptor:
typealias SortDescriptor<Root> = (Root, Root) -> Bool
func sortDescriptor<Root, Value>(key: @escaping (Root) -> Value)
-> SortDescriptor<Root> where Value: Comparable {
return { key($0) < key($1) } }
//使用
let streetSD: SortDescriptor<Person> = sortDescriptor { $0.address.street }
// 我们可以通过键路径来添加一种排序描述符的构建方式。通过键路径的下标来访问值:
func sortDescriptor<Root, Value>(key: KeyPath<Root, Value>)
-> SortDescriptor<Root> where Value: Comparable {
return { $0[keyPath: key] < $1[keyPath: key] }
}
//使用
let streetSDKeyPath: SortDescriptor<Person> = sortDescriptor(key: \.address.street)
复制代码
不过虽然拥有一个接受键路径的 sortDescriptor 很有用,不过它并没有给我们和函数一样的灵 活度。键路径依赖 Value 满足 Comparable 这一前提。只使用键路径的话,我们无法很轻易地使用另一种排序断言 (比如,使用忽略大小写的按区域设置的比较)。
可写键路径
可写键路径比较特殊:你可以用它来读取或者写入一个值。因此,它和一对函数等效:一个负 责获取属性值 ((Root) ->Value),另一个负责设置属性值 ((inout Root, Value) -> Void)。相比于 只读键路径,可写键路径要复杂的多。首先,它将很多代码包括在了简洁的语法中。将 streetKeyPath 与等效的 getter 和 setter 对进行比较:
let streetKeyPath = \Person.address.street
let getStreet: (Person) -> String = { person in
return person.address.street
}
let setStreet: (inout Person, String) -> () = { person, newValue in
person.address.street = newValue
}
// 使用 Setter
lisa[keyPath: streetKeyPath] = "1234 Evergreen Terrace"
setStreet(&lisa, "1234 Evergreen Terrace")
复制代码
可写键路径对于数据绑定特别有用,想将两个属性互相绑定:属性 1 发生变化的时候,属性 2 的值会自动更新,反之亦然。比如,你可以将一个 model.name 属性绑定到 textField.text 上。API 的用户需要指定如何读写 model.name 和 textField.text,而键路径所解决的正是这个问题。
还需要对属性的变化进行观察。在 Cocoa 中,我们使用键值观察机制来达到这个目的,不过这样的方式只能作用于类上面,并局限在 Apple 平台上。Foundation 提供了一种新的类型 安全的 KVO 的 API,它们可以将 Objective-C 世界中基于字符串的键路径隐藏起来。NSObject 上的 observe(_:options:changeHandler:)
方法将会对一个 (Swift 的强类型) 键路径进行观察, 并在属性发生变化的时候调用 handler。不要忘记你还需要将要观察的属性标记为
@objc dynamic
,否则 KVO 将不会工作。
我们的目标是在两个 NSObject 之间实现双向绑定,不过让我们从单向绑定开始:每当 self 上的被观察值变更,我们就同时变更另一个对象。键路径可以让我们的代码更加泛用,而不必拘泥于某个特定的属性:调用者只需要指定两个对象以及两个键路径,这个方法就可以处理其他的事情:
extension NSObjectProtocol where Self: NSObject {
func observe<A, Other>(_ keyPath: KeyPath<Self, A>,
writeTo other: Other,
_ otherKeyPath: ReferenceWritableKeyPath<Other, A>)
-> NSKeyValueObservation
where A: Equatable, Other: NSObjectProtocol
{
return observe(keyPath, options: .new) { _, change in
guard let newValue = change.newValue,
other[keyPath: otherKeyPath] != newValue else {
return // prevent endless feedback loop
}
other[keyPath: otherKeyPath] = newValue
}
}
}
复制代码
这段代码中有不少值得一说的东西。首先,我们对所有 NSObject 的子类定义了这个方法,通过扩展 NSObjectProtocol 而不是 NSObject,我们可以使用 Self。 ReferenceWritableKeyPath 和 WritableKeyPath 很相似,不过它可以让我们对 (other 这样的) 使用 let 声明的引用变量进行写操作。为了避免不必要的写操作,我们只在值发生改变时才对 other 进行写入。返回值 NSKeyValueObservation 是一个 token,调用者使用这个 token 来控制观察的生命周期:属性观察会在这个 token 对象被销毁或者调用者调用了它的 invalidate 方法时停止。
有了 observe(:writeTo::),双向绑定也就很直接了:我们对两个对象都调用 observe,它们 将返回两个观察 token:
extension NSObjectProtocol where Self: NSObject {
func bind<A, Other>(_ keyPath: ReferenceWritableKeyPath<Self,A>,
to other: Other,
_ otherKeyPath: ReferenceWritableKeyPath<Other,A>)
-> (NSKeyValueObservation, NSKeyValueObservation)
where A: Equatable, Other: NSObject
{
let one = observe(keyPath, writeTo: other, otherKeyPath)
let two = other.observe(otherKeyPath, writeTo: self, keyPath) return (one,two)
}
}
复制代码
现在,我们可构建两个不同的对象,Person 和 TextField,然后将 name 和 text 属性互相绑定:
final class Person: NSObject {
@objc dynamic var name: String = ""
}
class TextField: NSObject {
@objc dynamic var text: String = ""
}
let person = Person()
let textField = TextField()
let observation = person.bind(\.name, to: textField, \.text) person.name = "John"
textField.text // John
textField.text = "Sarah"
person.name // Sarah
复制代码
键路径层级
键路径有五种不同的类型,每种类型都在前一种上添加了更加精确的描述及功能:
→ AnyKeyPath 和 (Any) -> Any? 类型的函数相似。
→ PartialKeyPath<Source> 和 (Source) -> Any? 函数相似。
→ KeyPath<Source, Target> 和 (Source) -> Target 函数相似。
→ WritableKeyPath<Source, Target> 和 (Source) -> Target 与 (inout Source, Target) -> () 这一对函数相似。
→ ReferenceWritableKeyPath<Source, Target> 和 (Source) -> Target 与
(Source, Target) -> () 这一对函数相似。第二个函数可以用 Target 来更新 Source 的值, 且要求 Source 是一个引用类型。对 WritableKeyPath 和 ReferenceWritableKeyPath 进行区分是必要的,前一个类型的 setter 要求它的参数是 inout 的。
复制代码
这几种键路径的层级结构现在是通过类的继承来实现的。理想状态下,这些特性应该由协议来完成,但是 Swift 的泛型系统还缺少一些使之可行的特性。这种类的层级有意地保持了对外不可见,这样以便于未来在更新时,现有的代码也不会被破坏。
我们前面也提到,键路径不同于函数,它们是满足 Hashable 的,而且在将来它们很有可能还会满足 Codable。这也是为什么我们强调 AnyKeyPath 和 (Any) -> Any 只是相似的原因。虽然我们能够将一个键路径转换为对应的函数,但是我们无法做相反的操作。
对比 Objective-C 的键路径
在 Foundation 和 Objective-C 中,键路径是通过字符串来建模的 (我们会将它们称为 Foundation 键路径,以区别 Swift 的键路径)。由于 Foundation 键路径是字符串,它们不含有 任何的类型信息。从这个角度看,它们和 AnyKeyPath 类似。如果一个 Foundation 键路径拼写错误、没有正确生成、或者它的类型不匹配的话,程序可能会崩溃。(Swift 中的 ##keyPath 指令对拼写错误的问题进行了一些改善,编译器可以检查特定名字所对应的属性是否存在。) Swift 的 KeyPath、WritableKeypath 和 ReferenceWritableKeyPath 从构造开始就是正确的: 它们不可能被拼错,也不会有类型错误。
很多 Cocoa API 在原本用函数会更好的地方使用了 (Foundation) 键路径。这其中有一部分是历 史原因:匿名函数 (或者在 Objective-C 中所谓的 block) 其实是相对最近才添加的特性,而键 路径的存在则要长久得多。在 block 被引入 Objective-C 之前,想要在不用键路径 “address.street” 的条件下,表达类似 { $0.address.street } 这样的函数是很困难的。
自动闭包
我们都对 “逻辑与”,也就是 && 操作符如何对其参数求值很熟悉了。它会先对左边的操作数求值,如果左边的求值为 false 时,则直接返回。只有当左侧值为 true 时,右边的操作数才会被求值。这是因为,一旦左边的结果是 false 的话,整个表达式就不可能是 true 了。这种行为又
被叫做短路求值。举个例子,如果我们想要检查数组的第一个元素是否满足某个要求,我们可以这样做:
let evens = [2,4,6]
if !evens.isEmpty && evens[0] > 10 {
//执行操作
}
复制代码
在上面的代码中,我们依赖了短路求值:对于数组的访问仅仅发生在第一个条件满足时。如果
没有条件短路的话,代码将会在空数组的时候发生崩溃。
在几乎所有的语言中,对于&& 和 || 操作符的短路求值都是内建在语言中的。想要定义一个你 自己的带有短路逻辑的操作符或者方法往往是不可能的。如果一门语言支持头等函数,我们就 可以通过提供匿名函数而非值的方式,来模拟短路求值操作。比如,我们想在 Swift 中定义一 个和 && 操作符具有相同功能的 and 函数:
func and(_ l: Bool, _ r: () -> Bool) -> Bool {
guard l else { return false }
return r()
}
复制代码
上面的函数首先对 l 进行检查,如果 l 的值为 false 的话,就直接返回 false。只有当 l 是 true 的时候,才会返回闭包 r 的求值结果。但它要比 && 操作符的使用复杂一些,因为右边的操作数现在必须是一个函数:
if and(!evens.isEmpty, { evens[0] > 10 }) {
//执行操作
}
复制代码
在 Swift 中有一个很好的特性,能让代码更漂亮。我们可以使用 @autoclosure 标注来告诉编译器它应该将一个特定的参数用闭包表达式包装起来。通过这种方式构建的 and 的定义和上面几乎一样,除了在 r 参数前加上了 @autoclosure 标注:
func and(_ l: Bool, _ r: @autoclosure () -> Bool) -> Bool {
guard l else { return false }
return r()
}
复制代码
and 的使用现在就要简单得多了,因为我们不再需要将第二个参数封装到闭包中了。我们只需 要像使用普通的 Bool 参数那样来调用它,编译器将 “透明地” 把参数包装到闭包表达式中:
if and(!evens.isEmpty, evens[0] > 10) {
//执行操作
}
复制代码
自动闭包在实现日志函数的时候也很有用。比如,下面是一个只在条件为 true 的时候才会对日 志消息进行求值的 log 函数:
func log(ifFalse condition: Bool,
message: @autoclosure () -> (String),
file: String = #file, function: String = #function, line: Int = #line)
{
guard !condition else { return }
print("Assertion failed: \(message()), \(file):\(function) (line \(line))")
}
复制代码
这意味着你可以在传入的表达式中进行昂贵的计算,而不必担心在这个值没有使用时所带来的 开销。这个 log 函数使用了像是 ##file,##function 和 ##line 这样的调试标识符。当被用作 一个函数的默认参数时,它们代表的值分别是调用者所在的文件名、函数名以及行号,这会非 常有用。
不过请谨慎使用自动闭包特性。它们的行为与一般的期望有冲突 – 比如,要是某个表达式被自 动闭包包装的话,它有可能不被执行,而导致其中的某些副作用没有生效。引用 Apple 的 Swift 书中的一段:
过度使用自动闭包可能会让你的代码难以理解。使用时的上下文和函数名应该清晰地指出实际求值会被推迟.
@escaping 标注
你有可能已经注意到了,在一些闭包表达式中,编译器要求你显式地访问 self,而在另外一些 表达式中却不需要这样。例如,我们需要在一个网络请求的完成回调中显式地使用 self,但是 在传递给 map 或 filter 的闭包中却不需要这样做。两者的不同在于是否会为了稍后的使用而把 闭包保存下来 (正如网络请求),或者说闭包是否只在函数的作用域中被同步调用 (就像 map 和 filter 这样)。
一个被保存在某个地方 (比如一个属性中) 等待稍后再调用的闭包就叫做逃逸闭包。相对的,永远不会离开一个函数的局部作用域的闭包就是非逃逸闭包。对于逃逸闭包,编译器强制我们在闭包表达式中显式地使用 self,因为无意中对于 self 的强引用,是发生引用循环的最常见原因之一。当一个函数返回的时候,非逃逸闭包会自动销毁,所以它不会创建一个固定的引用循环。
闭包参数默认是非逃逸的。如果你想要保存一个闭包稍后再用,你需要将闭包参数标记为 @escaping。编译器将会对此进行验证,如果你没有将闭包标记为 @escaping,编译器将不允许你保存这个闭包 (或者比如将它返回给调用者)。
注意默认非逃逸的规则只对函数参数,以及那些直接参数位置 (immediate parameter
position) 的函数类型有效。也就是说,如果一个存储属性的类型是函数的话,那么它将会是逃 逸的 (这很正常)。出乎意料的是,对于那些使用闭包作为参数的函数,如果闭包被封装到像是 元组或者可选值等类型的话,这个闭包参数也是逃逸的。因为在这种情况下闭包不是直接参数, 它将自动变为逃逸闭包。这样的结果是,你不能写出一个函数,使它接受的函数参数同时满足 可选值和非逃逸。很多情况下,你可以通过为闭包提供一个默认值来避免可选值。如果这样做 行不通的话,可以通过重载函数,提供一个包含可选值 (逃逸) 的函数,以及一个不是可选值, 非逃逸的函数来绕过这个限制:
func transform(_ input: Int, with f: ((Int) -> Int)?) -> Int {
print("使用可选值重载")
guard let f = f else { return input }
return f(input)
}
func transform(_ input: Int, with f: (Int) -> Int) -> Int {
print("使用非可选值重载")
return f(input)
}
// 这样一来,如果用 nil 参数 (或者一个可选值类型的变量) 来调用函数,
// 将使用可选值变种,而如果使用闭包字面量的调用将使用非逃逸和非可选值的重载方法:
transform(10, with: nil) // 使用可选值重载
transform(10) { $0 * $0 } // 使用非可选值重载
复制代码
withoutActuallyEscaping
可能你会遇到这种情况:你确实知道一个闭包不会逃逸,但是编译器无法证明这点,所以它会 强制你添加 @escaping 标注。为了说明这点,让我们看一个标准库文档中的例子。我们在 Array 上实现一个自定义的 allSatisfy 方法,这个方法在内部使用一个数组的延迟视图 (lazy view) (不要和我们上面讨论的延迟属性搞混了)。然后我们在这个延迟视图上应用一个 filter 来 检查是否有任何的元素满足 filter 的条件 (也就是说至少有一个元素不满足断言)。我们的首次尝试导致了一个编译错误:
extension Array {
func allSatisfy2(_ predicate: (Element) -> Bool) -> Bool {
// 错误: 使用非逃逸参数 'predicate' 的闭包有可能允许它逃逸。
return self.lazy.filter({ !predicate($0) }).isEmpty }
}
复制代码
延迟视图为了之 后使用后续的转换 (例如说这里传递给 filter 的闭包) 会把它们保存到一个内部的属性中去。这 就要求传入的任何闭包都是逃逸的,这也是这个错误的原因,因为我们的 predicate 参数是非 逃逸的。
我们可以通过把参数标注成 @escaping 来解决这个问题,但在这个情况下,我们知道这个闭包是不会逃逸的,因为这个延迟集合视图的生命周期是同这个函数的生命周期绑定的。对于类似这样的情况,Swift 提供了一个 withoutActuallyEscaping 函数来作为一种 “安全出口”。这个函数允许你对一个接受逃逸闭包的函数,传入一个非逃逸的闭包。这下可以编译通过并正常工作了:
extension Array {
func allSatisfy2(_ predicate: (Element) -> Bool) -> Bool {
return withoutActuallyEscaping(predicate) { escapablePredicate in
self.lazy.filter { !escapablePredicate($0) }.isEmpty }
} }
let areAllEven = [1,2,3,4].allSatisfy2 { $0 % 2 == 0 } // false
let areAllOneDigit = [1,2,3,4].allSatisfy2 { $0 < 10 } // true
复制代码
注意,使用 withoutActuallyEscaping 后,你就进入了 Swift 中不安全的领域。让闭包的复制从 withoutActuallyEscaping 调用的结果中逃逸的话,会造成不确定的行为。