Swift 进阶【五】函数

在 Swift 中函数是一等公民。


要理解 Swift 中的函数和闭包,需要先明白三件事情,按重要程度进行大致排序如下:

  1. 函数可以像 Int 或者 String 那样被赋值给变量,也可以作为另一个函数的输入参数,或者另一个函数的返回值来使用。
  2. 函数能够捕获存在于其局部作用域之外的变量。
  3. 有两种方法可以创建函数,一种是使用 func 关键字,另一种是 { }。在 Swift 中,后一种被称为闭包表达式
函数可以被赋值给变量,也能够作为函数的输入和输出
1
2
3
4
// 这个函数接受 Int 值并将其打印
func printInt(i: Int) {
print("you passed \(i)")
}

要将函数赋值给一个变量,比如 funVar,我们只需要将函数名字作为值就可以了。注意在函数名后没有括号:

1
let funVar = printInt

现在,我们可以使用 funVar 变量来调用 printInt 函数。注意在函数名后面需要使用括号:

1
funVar(2) // 将打印 "you passed 2"

我们也能够写出一个接受函数作为参数的函数:

1
2
3
4
5
func useFunction(function: (Int) -> () ) {
function(3)
}
useFunction(function: printInt) // you passed 3
useFunction(function: funVar) // you passed 3

为什么将函数作为变量来处理这件事情如此关键?因为它让你很容易写出 “高阶” 函数,高阶函数将函数作为参数的能力使得它们在很多方面都非常有用。

你也可以在其他函数中返回一个函数:

1
2
3
4
5
6
7
8
func returnFunc() -> (Int) -> String {
func innerFunc(i: Int) -> String {
return "you passed \(i)"
}
return innerFunc
}
let myFunc = returnFunc()
myFunc(3) // you passed 3
函数可以捕获存在于它们作用范围之外的变量

当函数引用了在函数作用域外部的变量时,这个变量就被 “捕获” 了,它们将会继续存在,而不是在超过作用域后被摧毁。

1
2
3
4
5
6
7
8
func counterFunc() -> (Int) -> String {
var counter = 0
func innerFunc(i: Int) -> String {
counter += i // counter is captured
return "running total: \(counter)"
}
return innerFunc
}

一般来说,因为 counter 是一个 counterFunc 的局部变量,它在 return 语句执行之后应该离开作用域并被摧毁。但是这个因为 innerFunc 捕获了它,它将继续存在。我们在结构体和类讨论过,counter 将存在于堆上而非栈上。我们可以多次调用 innerFunc,并且看到 running total 的输出在增加:

1
2
3
let f = counterFunc()
f(3) // running total: 3
f(4) // running total: 7

如果我们再次调用 counterFunc() 函数,将会生成并“捕获”新的 counter 变量:

1
2
3
let g = counterFunc()
g(2) // running total: 2
g(2) // running total: 4

这不影响我们的第一个函数,它拥有它自己的 counter:

1
f(2) // running total: 9

你可以将这些函数以及它们所捕获的变量想象为一个类的实例,这个类拥有一个单一的方法 (也就是这里的函数) 以及一些成员变量 (这里的被捕获的变量)。

在编程术语里,一个函数和它所捕获的变量环境组合起来被称为闭包。上面 f 和 g 都是闭包的例子,因为它们捕获并使用了一个在它们外部声明的非局部变量 counter。

函数可以使用 { } 来声明为闭包表达式

在 Swift 中,定义函数的方法有两种。一种是像上面所示那样使用 func 关键字。另一种方法是使用闭包表达式

1
2
3
4
func doubler(i: Int) -> Int {
return i * 2
}
[1, 2, 3, 4].map(doubler) // [2, 4, 6, 8]

使用闭包表达式的语法来写相同的函数,像之前那样将它传给 map:

1
2
let doublerAlt = { (i: Int) -> Int in return i*2 }
[1, 2, 3, 4].map(doublerAlt) // [2, 4, 6, 8]

使用闭包表达式来定义的函数可以被想成函数的字面量,就和 1 是整数字面量,”hello” 是字符串字面量那样。与 func 相比较,它的区别在于闭包表达式是匿名的,它们没有被赋予一个名字。使用它们的唯一方法是在它们被创建时将其赋值给一个变量,就像我们这里对 doubler 进行的赋值一样。

使用闭包表达式声明的 doubler,和之前我们使用 func 关键字声明的函数,其实是完全等价的。它们甚至存在于同一个“命名空间”中,这一点和一些其他语言有所不同。

这里,我们将 doubler map 的例子用短得多的形式进行了重写:

1
[1, 2, 3].map { $0 * 2 } // [2, 4, 6]

之所以看起来和原来很不同,是因为我们使用了 Swift 中的一些特性,来让代码更加简洁。我们来一个个看看这些用到的特性:

  1. 如果你将闭包作为参数传递,并且你不再用这个闭包做其他事情的话,就没有必要现将它存储到一个局部变量中。可以想象一下比如 5*i 这样的数值表达式,你可以把它直接传递给一个接受 Int 的函数,而不必先将它计算并存储到变量里。
  2. 如果编译器可以从上下文中推断出类型的话,你就不需要指明它了。在我们的例子中,从数组元素的类型可以推断出传递给 map 的函数接受 Int 作为参数,从闭包的乘法结果的类型可以推断出闭包返回的也是 Int。
  3. 如果闭包表达式的主体部分只包括一个单一的表达式的话,它将自动返回这个表达式的结果,你可以不写 return。
  4. Swift 会自动为函数的参数提供简写形式,$0 代表第一个参数,$1 代表第二个参数,以此类推。
  5. 如果函数的最后一个参数是闭包表达式的话,你可以将这个闭包表达式移到函数调用的圆括号的外部。这样的尾随闭包语法在多行的闭包表达式中表现非常好,因为它看起来更接近于装配了一个普通的函数定义,或者是像 if (expr) { } 这样的执行块的表达形式。
  6. 最后,如果一个函数除了闭包表达式外没有别的参数,那么方法名后面的调用时的圆括号也可以一并省略。

最后要说明的是关于命名的问题。要清楚,那些使用 func 声明的函数也可以是闭包,就和用 { } 声明的是一样的。记住,闭包指的是一个函数以及被它所捕获的所有变量的组合。而使用 { } 来创建的函数被称为闭包表达式,人们常常会把这种语法简单地叫做闭包。但是不要因此就认为使用闭包表达式语法声明的函数和其他方法声明的函数有什么不同。它们都是一样的,它们都是函数,也都可以是闭包。

inout 参数和可变方法


如果你有一些 C 或者 C++ 背景的话,在 Swift 中 inout 参数前面使用的 & 符号可能会给你一种它是传递引用的印象。但事实并非如此,inout 做的事情是通过值传递,然后复制回来,而并不是传递引用。 引用官方《Swift 编程语言》中的话:

inout 参数将一个值传递给函数,函数可以改变这个值,然后将原来的值替换掉,并从函数中传出。

嵌套函数和inout


在一个嵌套函数中也可以使用 inout 关键词,Swift 依然会保证你的使用是安全的。比如说,你可以定义一个嵌套函数(使用 func 或者使用闭包表达式),然后安全地改变一个 inout 的参数:

1
2
3
4
5
6
7
8
9
10
11
func incrementTenTimes(value: inout Int) {
func inc() {
value += 1
}
for _ in 0..<10 {
inc()
}
}
var x = 0
incrementTenTimes(value: &x)
x // 10

不过,你不能够让这个 inout 参数逃逸(我们会在本章最后详细提到逃逸函数的内容):

1
2
3
4
5
6
func escapeIncrement(value: inout Int) -> () -> () {
func inc() {
value += 1
}
return inc
}

可以这么理解,因为 inout 的值会在函数返回之前复制回去,那么要是我们可以在函数返回之后再去改变它,应该要怎么做呢?是说值应该在改变以后再复制吗?要是调用源已经不存在了怎么办?编译器必须对此进行验证,因为这对保证安全十分关键。

计算属性


计算属性看起来和常规的属性很像,但是它并不使用任何内存来存储自己的值。相反,这个属性每次被访问时,返回值都将被实时计算出来。

举个简单的例子,GPS 追踪信息结构体:

1
2
3
4
5
6
7
8
9
10
11
struct GPSTrack {
private(set) var record: [(CLLocation, Date)] = []
}

extension GPSTrack {
/// 返回 GPS 追踪的所有日期
/// - 复杂度:O(n),n 是记录点的数量。
var dates: [Date] {
return record.map { $0.1 }
}
}

因为我们没有指定 setter,所以 dates 属性是只读的。它的结果不会被缓存,每次在你调用 dates 属性时,结果都要被计算一遍。Swift API 指南推荐你对所有复杂度不是 O(1) 的计算属性都应该在文档中写明,因为调用者可能会假设一个计算属性的耗时是常数时间。

延迟存储属性(懒加载)


延迟初始化一个值在 Swift 中是一种常见的模式,Swift 为此准备了一个特殊的 lazy 关键字来定义一个延迟属性(lazy property)。需要注意,延迟属性会被自动声明为 mutating,因此,这个属性也必须被声明为 var。延迟修饰符是编程记忆化的一种特殊形式。

比如,如果我们有一个 view controller 来显示 GPSTrack,我们可能会想展示一张追踪的预览图像。通过将属性改为延迟加载,我们可以将昂贵的图像生成工作推迟到属性被首次访问:

1
2
3
4
5
6
7
8
9
class GPSTrackViewController: UIViewController {
var track: GPSTrack = GPSTrack()
lazy var preview: UIImage = {
for point in self.track.record {
// 进行昂贵的计算
}
return UIImage()
}()
}

当我们第一次访问这个属性 preview 的时候,闭包将被执行(注意闭包后面的括号),它的返回值被存储在变量中。

如果 track 属性发生了改变,preview 并不会自动更新。让我们来用一个更简单的例子来看看发生了什么。我们有一个 Point 结构体,并且用延迟的方式存储了 distanceFromOrigin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point {
var x: Double = 0
var y: Double = 0
lazy var distanceFromOrigin: Double = self.x * self.x + self.y * self.y
init(x: Double, y: Double) {
self.x = x
self.y = y
}
}

var point = Point(x: 3, y: 4)
point.distanceFromOrigin // 25.0
point.x += 10
point.distanceFromOrigin // 25.0

当我们创建一个点后,可以访问 distanceFromOrigin 属性,这将会计算出值,并存储起来等待重用。不过,如果我们之后改变了 x 的值,这个变化将不会反应在 distanceFromOrigin 中。

解决办法:将其改为普通的(非延迟)计算属性。

@escaping 标注


正如我们在之前一章中看到的那样,在处理闭包时我们需要对内存格外小心。回想一下捕获列表的例子,在那个例子中为了避免引用循环,我们将 view 标记为了 weak

1
2
3
window?.onRotate = { [weak view] in
print("We now also need to update the view: \(view)")
}

但是,在我们使用 map 这样的函数的时候,我们从来不会去把什么东西标记为 weak。因为 map 将同步执行,这个闭包不会被任何地方持有,也不会有引用循环被创建出来,所以并不需要这么做。和我们传递给 map 的闭包相比,这里存储在 onRotate 中的闭包是逃逸的 (escape),两者有所区别。

一个被保存在某个地方等待稍后(比如函数返回以后)再调用的闭包就叫做逃逸闭包。而传递给 map 的闭包会在 map 中被直接使用。这意味着编译去不需要改变在闭包中被捕获的变量的引用计数。

在 Swift 3 之前,事情完全相反:那时候逃逸闭包是默认的,对非逃逸闭包,你需要标记出@noescape。Swift 3 的行为更好,因为它默认是安全的:如果一个函数参数可能导致引用循环,那么它需要被显式地标记出来。@escaping 标记可以作为一个警告,来提醒使用这个函数的开发者注意引用关系。非逃逸闭包可以被编译器高度优化,快速的执行路径将被作为基准而使用,除非你在有需要的时候显式地使用其他方法。

注意默认非逃逸的规则只对那些直接参数位置(immediate parameter position)的函数类型有效。也就是说,类型是函数的存储属性将会是逃逸的(这很正常)。出乎意料的是,对于那些使用闭包作为参数的函数,如果闭包被封装到像是多元组或者可选值等类型的话,这个闭包参数也是逃逸的。因为在这种情况下闭包不是直接参数,它将自动变为逃逸闭包。这样的结果是,你不能写出一个函数,使它接受的函数参数同时满足可选值和非逃逸。很多情况下,你可以通过为闭包提供一个默认值来避免可选值。如果这样做行不通的话,可以通过重载函数,提供一个包含可选值 (逃逸) 的函数,以及一个不可选,不逃逸的函数来绕过这个限制:

1
2
3
4
5
6
7
8
9
10
11
12
func transform(_ input: Int, with f: ((Int) -> Int)?) -> Int {
print("Using optional overload")
guard let f = f else { return input }
return f(input)
}
func transform(_ input: Int, with f: (Int) -> Int) -> Int {
print("Using non-optional overload")
return f(input)
}

transform(10, with: nil) // 使用可选值重载
transform(10) { $0 * $0 } // 使用非可选值重载

这样一来,如果用 nil 参数(或者一个可选值类型的变量)来调用函数,将使用可选值变种,而如果使用闭包字面量的调用将使用非逃逸和非可选值的重载方法。