Swift Learning (9) - Closures (Fully Ver.)

Swift Learning (9) - Closures (Fully Ver.)

August 23, 2021·Jensen
Jensen

image

闭包是自包含的函数代码块,可以在代码中被传递和使用。Swift中的闭包与CObjective-C中的代码块(blocks)以及Python语言中的匿名函数(Lambdas)比较相似。

闭包可以捕获和存储其所在上下文中任意常量和变量的引用。被称为包裹常量和变量。Swift会管理在捕获过程中涉及到的所有内存操作。

注意

如果不熟悉捕获这个概念(Capturing)也不必担心,在值补货章节有它更详细的介绍。】

在函数章节中介绍的全局和嵌套函数实际上也是一种特殊的闭包,闭包采用如下三种形式之一:

  • 全剧函数是一个有名字但不会捕获任何值的闭包
  • 嵌套函数是一个有名字且可以捕获其封闭函数域内值的闭包
  • 闭包表达式是一个利用轻量级语法所写的可以捕获其上下文中变量或常量值的匿名闭包

Swift的闭包表达式拥有简洁的风格,并鼓励在常见场景中进行语法优化,主要优化如下:

  • 利用上下文推断出参数和返回值类型
  • 隐式返回单表达式闭包,即单表达式闭包可以省略return关键字
  • 参数名称缩写
  • 尾随闭包语法

闭包表达式

嵌套函数作为复杂函数的一部分时,它自包含代码块式的定义和命名形式在使用上带来了方便。当然,编写未完整声明和没有函数名的类函数结构代码是很有用的,尤其是在编码中涉及到函数作为参数的那些方法时。

闭包表达式是一种构建内联闭包的方式,它的语法简洁,在保证不丢失它语法清晰明了的同时,闭包表达式提供了几种优化的语法简写形式。下面通过对sorted(by:)这一案例的多次迭代改进来展示这个过程,每次迭代都使用了更加简明的方式描述了相同的功能。

排序方法

Swift标准库提供了名为sorted(by:)的方法,它会基于开发者提供的排序闭包表达式的判断结果对数组中的值(类型确定)进行排序。一旦它完成排序过程,sorted(by:)方法会返回一个与旧数组类型大小相同的类型的新数组,该数组的元素有着正确的排序顺序。原数组不会被sorted(by:)方法修改。

下面的闭包表达式示例使用sorted(by:)方法对一个String类型的数组进行字母逆序排序。以下是初始数组:

let names = ["Chris", "Alex", "Ewa", "Barry", "Jensen"]

sorted(by:)方法接受一个闭包,该闭包函数需要传入与数组元素类型相同的两个值,并返回一个布尔类型值来表明当排序结束后传入的第一个参数排在第二个参数的前面还是后面。如果第一个参数值出现在第二个参数值的前面,排序闭包函数需要返回true,反之返回false

该例子对一个String类型的数组进行排序,因此排序闭包函数类型需为(String, String) -> Bool

提供排序闭包函数的一种方式是撰写一个复合其要求的普通函数,并将其作为sorted(by:)方法的参数传入:

func backward(_ s1: String, _ s2: String) -> Bool {
    return s1 > s2
}
var reversedNames = names.sorted(by: backward)  // reversedNames为["Jensen", "Ewa", "Chris", "Barry", "Alex"]
print("复杂闭包逆序:\(reversedNames).")
---
output: 复杂闭包逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

如果第一个字符串(s1)大于第二个字符串(s2),backward(_:_:)函数会返回true,表示在新的数组中s1应该出现在s2之前。对于字符串中的字符来说,“大于”表示“按照字母顺序较晚出现”。这意味着字母“B”大于字母“A”,字符串“Tom”大于字符串“Tim”。该闭包将进行字母的逆序排序,“Jensen”将会排在“Eva”的前面。

然而,以这种方式来编写一个实际上很简单的表达式(a > b),确实太过繁琐了。对于这个例子来说,利用闭包表达式语法可以更好地构造一个内联排序闭包。

闭包表达式语法

闭包表达式语法有如下的一般形式:

/*
 { (parameters) -> return type in
     statements
 }
 */

闭包表达式参数可以是in-out参数,但不能设定默认值。如果命名了可变参数,也可以使用此可变参数,元组可以作为参数和返回值。

下面的例子展示了之前backward(_:_:)函数对应的闭包表达式版本的代码:

reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 > s2
})
print("内联闭包表达式逆序:\(reversedNames).")
---
output: 内联闭包表达式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

需要注意的是内联闭包参数和返回值类型声明与backward(_:_:)函数类型声明相同。这两种方式中,都写成了(s1: String, s2: String) -> Bool。然而在内联闭包表达式中,函数和返回值类型都写在大括号内,而不是大括号外。

闭包的函数体部分由关键字in引入。该关键字表示闭包的参数和返回值类型定义已经完成,闭包函数体即将开始。

由于这个闭包的函数体部分如此短,以至于可以将其改写成一行代码:

reversedNames = names.sorted(by: {(s1: String, s2: String) -> Bool in return s1 > s2})
print("简洁内联闭包表达式逆序:\(reversedNames).")
---
output: 简洁内联闭包表达式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

该例中sorted(by:)方法的整体调用保持不变,一对圆括号依然包裹住了方法的整个参数。然而,参数现在变成了内联闭包。

根据上下文推断类型

因为排序闭包函数是作为sorted(by:)方法的参数传入的,Swift可以推断其参数和返回值类型。sorted(by:)方法被一个字符串数组调用,因此其参数必须是(String, String) -> Bool类型的函数。这意味着(String, String)Bool类型并不需要作为闭包表达式定义的一部分。因为所有的类型都可以被正确推断,返回箭头->和围绕在参数周围的括号也可以被省略:

reversedNames = names.sorted(by: {s1, s2 in return s1 > s2})
print("隐式推断类型闭包表达式逆序:\(reversedNames).")
---
output: 隐式推断类型闭包表达式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

实际上,通过内联闭包表达式构建的闭包作为参数传递给函数或方法时,总是能够推断出闭包的参数和返回值类型。这意味着闭包作为函数或者方法的参数时,几乎不需要利用完整格式构造内联闭包。

尽管如此,仍然可以明确写出有者完整格式的闭包。如果完整格式的闭包能够提高代码的可读性,则Swift更鼓励采用完整格式的闭包。而在sorted(by:)方法这个例子里,显然闭包的目的就是排序,因此读者能够推测出这个闭包时用于字符串处理的。

单表达式闭包的隐式返回

单行表达式闭包可以通过省略return关键字来隐式返回单行表达式的结果,如上版本的例子可以改写为:

reversedNames = names.sorted(by: {s1, s2 in s1 > s2})
print("极致省略闭包逆序: \(reversedNames).")
---
output: 极致省略闭包逆序: ["Jensen", "Ewa", "Chris", "Barry", "Alex"].

在这个例子中,sorted(by:)方法的参数类型明确了闭包必须返回一个Bool类型值。因为闭包函数体只包含了一个单一表达式(s1 > s2),该表达式返回Bool类型值,因此这里没有歧义,return关键字可以省略。

参数名称缩写

Swift自动为内联闭包提供了参数名称缩写功能,可以直接通过$0$1$2来顺序调用闭包的参数,以此类推。

如果在闭包表达式中使用参数名称缩写,可以在闭包定义中省略参数列表,并且对应参数名称缩写的类型会通过函数类型进行推断。闭包接受的参数数量取决于所使用的缩写参数的最大编号。 in关键字同样可以被忽略,因为此时闭包表达式完全由闭包函数体构成:

reversedNames = names.sorted(by: { $0 > $1 })
print("省略参数名称闭包逆序:\(reversedNames).")
---
output: 省略参数名称闭包逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

在这个例子中,$0$1表示闭包中第一个和第二个String类型的参数,因为$1是编号最大的缩写参数,所以可以理解为:该闭包需要两个参数。这里的sorted(by:)函数希望得到一个参数都是字符串的闭包,因此缩写参数$0$1的类型均为String

运算符方法

实际上还有一种更简短的方式来编写上面的闭包表达式。SwiftString类型定义了关于大于号>的字符串实现,其作为一个函数接受两个String类型的参数并返回Bool类型的值。而这正好与sorted(by:)方法的参数需要的函数类型相符合。因此,可以简单地传递一个大于号,Swift可以自动推断找到系统自带的那个字符串函数的实现:

reversedNames = names.sorted(by: >)

更多关于运算符方法的内容请查看运算符方法


尾随闭包

如果需要将一个很长的闭包表达式作为最后一个参数传递给函数,将这个闭包替换成尾随闭包的形式很有用。尾随闭包是一个书写在函数圆括号之后的闭包表达式,函数支持将其作为最后一个参数调用。在使用尾随闭包时,不用写出它的参数标签:

/*
 func someFunctionThatTakesAClosure(closure: () -> Void) {
     // 函数体部分
 }
 // 以下是不使用尾随闭包进行函数调用
 someFunctionThatTakesAClosure(closure: {
     // 闭包主体部分
 })
 // 以下是使用尾随闭包进行函数调用
 someFunctionThatTakesAClosure {
     // 闭包主体部分
 }
 */

闭包表达式语法上章节的字符串排序闭包可以作为尾随闭包的形式改写在sorted(by:)方法圆括号的外面:

reversedNames = names.sorted() { $0 > $1 }
print("尾随闭包:\(reversedNames).")
---
output: 尾随闭包:["Jensen", "Ewa", "Chris", "Barry", "Alex"].

如果闭包表达式是函数或方法的唯一参数,则当使用尾随闭包时,甚至可以吧()省略掉:

reversedNames = names.sorted { $0 > $1 }
print("尾随闭包省略(): \(reversedNames).")
---
output: 尾随闭包省略(): ["Jensen", "Ewa", "Chris", "Barry", "Alex"].

当闭包非常长以至于不能在一行中进行书写的时候,尾随闭包变得非常有用。举例来说,SwiftArray类型有一个map(_:)方法,这个方法获取一个闭包表达式作为其唯一参数,该闭包表达式会为数组中的每个元素调用一次,并返回该元素所映射的值。具体的映射方式和返回值的类型由闭包来指定。

当提供给数组的闭包应用于每个数组元素后,map(_:)方法返回一个新的数组,数组中包含了与原数组中元素一一对应的映射后的值。

下例介绍了如何在map(_:)方法中使用尾随闭包将Int类型数组[16, 58, 510]转换为包含对应String类型的值的数组[“OneSix”, “FiveEight”, “FiveOneZero”]:

let digitNames = [
    0: "Zero", 1: "One", 2: "Two", 3: "Three", 4: "Four",
    5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine"
]
let numbers = [16, 58, 510]

如上代码创建了一个整型数位和它们的英文版本名字相映射的字典,同时还定义了一个准备转换为字符串数组的整形数组。

现在可以通过传递一个尾随闭包给numbers数组的map(_:)方法来创建对应的字符串版本数组:

let strings = numbers.map {
    (number) -> String in
    var number = number
    var output = ""
    repeat {
        output = digitNames[number % 10]! + output
        number /= 10
    } while number > 0
    return output
}
// `string`常量被推断为字符串类型数组,即[String]
// 其值为["OneSix", "FiveEight", "FiveOneZero"]

map(_:)为数组中每一个元素调用了一次闭包表达式,不需要指定闭包的输入参数number的类型,因为可以通过要映射的数组类型进行推断。

在该例中,局部变量number的值由闭包中的number参数获得,因此可以在闭包函数体内对其进行修改,(闭包或者函数的参数总是常量),闭包表达式指定了返回类型为String,以表明存储映射值的新数组类型为String

闭包表达式在每次被调用的时候创建了一个叫做output的字符串并返回。其使用求余运算符(number % 10)计算最后一位数字并利用digitNames字典获取所映射的字符串。这个闭包能够用于创建任意正整数的字符串表示。

注意

字典digitNames下标后跟着一个叹号(!),因为字典下标返回一个可选值(Optional Value),表明改键不存在时会查找失败,在上例中,由于可以确定number % 10总是digitNames字典的有效下标,因此叹号可以用于强制解包(Force-Unwrap)存储在下标的可选类型的返回值中的String类型值。

digitNames字典中获取的字符串被添加到output的前部,逆序建立了一个字符串版本的数字。(在表达式number % 10中,如果number为16,则返回6,58则返回8,510则返回0。)

number变量之后除以10。因为其是整数,在计算过程中未除尽部分被忽略,因此16变成了1,58变成了5,510变成了51。

整个或称重复进行,直到number /= 10为0,这时闭包会将字符串output返回,而map(_:)方法则会将字符串添加到映射的数组中。

在上面的例子中,通过尾随闭包语法,优雅地在函数后封装了闭包的具体功能,而不再需要将整个闭包包裹在map(_:)方法的括号内。


值捕获

闭包可以在其被定义的上下文中捕获常量或变量。即使定义这些常量和变量的原作用域已经不存在,闭包依然可以在闭包函数体内引用和修改这些值。

Swift中,可以捕获值的闭包的最简单形式是嵌套函数,也就是定义在其他函数的函数体内的函数。嵌套函数可以捕获其外部函数的所有的参数以及定义的常量和变量。

举个例子,这有个叫做makeIncrementer的函数,其包含了一个叫做incrementer的嵌套函数。嵌套函数incrementer()从上下文中捕获了两个值,runningTotalamount。捕获这些值之后,makeIncrementerincrementer作为闭包返回。每次调用intermenter时,其会以amount为增量增加runningTotal的值。

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

var testIncrementer = makeIncrementer(forIncrement: 10)
testIncrementer()  // 此时testIncrementer()的值为10
print(testIncrementer())  // 打印输出20, 10 + 10
---
output: 20

makeIncrementer返回类型为() -> Int。这意味着其返回的是一个函数,而非一个简单类型的值。该函数在每次调用时不接受参数,只返回一个Int类型的值。关于函数返回其他函数的内容,请查看函数类型作为返回类型

makeIncrementer(forIncrement:)函数定义了一个初始值为0的整型变量runningTotal,用来存储当前总计数值。该值为incrementer的返回值。

makeIncrementer(forIncrementer:)有一个Int类型的参数,其外部参数名为forIncrement,内部参数名为amount,该参数表示每次incrementer被调用时runningTotal将要增加的量。makeIncrementer函数还定义了一个嵌套函数incrementer,用来执行实际的增加操作。该函数简单地使runningTotal增加amount,并将其返回。

如果我们单独考虑嵌套函数incrementer(),会发现它有些不同寻常:

/*
func incrementer() -> Int {
    runningTotal += amount
    return runningTotal
}
 */

incrementer()函数并没有任何参数,但是在函数体内访问了runningTotalamount变量。这是因为它从外围函数捕获了runningTotalamount变量的引用。捕获引用保证了runningTotalamount变量在调用完makeIncrementer后不会消失,并且保证了在下一次执行incrementer函数时,runningTotal依旧存在。

注意

为了优化,如果一个值不会被闭包改变,或者在闭包创建后不会改变,Swift可能会改为捕获并保存一份对值的拷贝。

Swift也会负责被捕获变量的所有内存管理操作,包括释放不再需要的变量。

下面是一个使用makeIncrementer的例子:

let incrementByTen = makeIncrementer(forIncrement: 10)

该例子定义了一个叫做incrementByTen的常量,该常量指向一个每次调用会将其runningTotal变量增加10的incrementer函数。调用这个函数多次可以得到以下结果:

incrementByTen()  // 返回的值为10
incrementByTen()  // 返回的值为20
var result = incrementByTen()  // 返回的值为30
print("三次调用IncrementByTen()返回的结果为:\(result)。")
---
output: 三次调用IncrementByTen()返回的结果为:30

如果创建了另外一个incrementer,它会有属于自己的引用,指向一个全新、独立的runningTotal变量:

let incrementBySeven = makeIncrementer(forIncrement: 7)
result = incrementBySeven()  // 返回值为7
print("Result:\(result).")
---
output: Result7.

再次调用原来的incrementByTen会继续增加它自己的runningTotal变量,该变量和incrementBySeven中捕获的变量没有任何联系:

incrementByTen()  // 返回值为40

注意

如果将闭包赋值给一个类实例的属性,并且该闭包通过访问该实例或其成员而捕获了该实例,那将在闭包和该实例之间构建一个循环强引用。Swift使用捕获列表来打破这种循环强引用。


闭包是引用类型

上面的例子中,incrementBySevenincrementByTen都是常量,但是这些常量指向的闭包仍然可以增加其捕获的变量的值。这是因为函数和闭包都是引用类型。

无论将函数或闭包赋值给一个常量还是变量,实际上都是将变量或者常量的值设置为对应函数或闭包的引用。上面的例子中,指向闭包的引用incrementByTen是一个常量,而非闭包内容本身。

这也就意味着如果将闭包赋值给了两个不同的常量或变量,两个值都会指向同一个闭包:

let alsoIncrementByTen = incrementByTen
print("alsoIncrementByTen的值是:\(alsoIncrementByTen()).")
---
output: alsoIncrementByTen的值是:50.

逃逸闭包

当一个闭包作为参数传到一个函数中,但是这个闭包在函数返回后才被执行,我们称该闭包从函数中逃逸。当定义接受闭包作为参数的函数时,可以在参数类型前标注@escaping来指明这个闭包是允许“逃逸”出这个函数的。

一种能使闭包“逃逸”出函数的方法是,将这个闭包保存在一个函数外部定义的变量中。举个例子,很多启动异步操作的函数接受一个闭包参数作为completion handler。这类函数会在异步操作开始之后立刻返回。但是闭包直到异步操作结束时才会被调用。在这种情况下,闭包需要“逃逸”出函数,因为闭包需要在函数返回之后被调用。例如:

var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
    completionHandlers.append(completionHandler)
}

someFunctionWithEscapingClosure(_:)函数接受一个闭包作为参数,该闭包被添加到一个函数外定义的数组中。如果不将这个参数标记为@escaping,就会得到一个编译错误(Converting non-escaping parameter ‘completionHandler’ to generic parameter ‘Element’ may allow it to escape)。

将一个闭包标记为@escaping意味着必须在闭包中显式地引用self。比如说,在下面的代码中,传递到someFunctionWithEscapingClosure(_:)中的闭包是一个逃逸闭包,这意味着它需要显式地引用self。相对的,传递到someFunctionWithNoneEscapingClosure(_:)中的闭包是一个非逃逸闭包,这意味着它可以隐式引用self

func someFunctionWithNoneEscapingClosure(closure: () -> Void) {
    closure()
}
class someClass {
    var x = 10
    func doSometing() {
        someFunctionWithEscapingClosure {
            self.x = 100
        }
        someFunctionWithNoneEscapingClosure {
            x = 200
        }
    }
}
let instance = someClass()
instance.doSometing()
print(instance.x)  // 打印输出“200”
completionHandlers.first?()  // someFunctionWithEscapingClosure的闭包已经逃逸到外部,函数在返回后,可以调用逃逸的闭包  // xxx.first?() 可选链式调用
print(instance.x)  // 打印输出“100”
---
output: 200
100

自动闭包

自动闭包是一种自动创建的闭包,用于包装传递给函数作为参数的表达式。这种闭包不接受任何参数,当它被调用的时候,会返回被包装在其中的表达式的值。这种便利语法能够省略闭包的花括号,用一个普通的表达式来替代显式的闭包。

我们经常会调用采用自动闭包的函数,但是很少去实现这样的函数。举个例子来说,assert(condition:message:file:line:)函数接受自动闭包作为其condition参数和message参数;它的condition参数仅会在debug模式下被求值,它的message参数仅当condition参数为false时被计算求值。

自动闭包让开发者能够延迟求值,因为直到调用这个闭包时,代码段才会被执行。延迟求值对于那些有副作用(Side Effect)和高计算成本的代码来说是很有益处的,因为它使得开发者能够控制代码执行的时机。下面的代码展示了闭包如何延时求值:

var customersInLine = ["Chris", "Jensen", "Ewa", "Alex", "Barry"]
print(customersInLine.count)  // 打印输出“5”
let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count)  // 打印输出“5”
print("NOW SERVING \(customerProvider())!")  // 打印输出“NOW SERVING Chris!”
print(customersInLine.count)  // 打印输出“4”
---
output: 5
5
NOW SERVING Chris!
4

尽管在闭包的代码中,customersInLine的第一个元素被移除了,不过在闭包被调用之前,这个元素是不会被移除的。如果这个闭包永远不被调用,那么在闭包里面的表达式将永远不会被执行,那意味着列表中的元素永远不会被移除。请注意,customerProvider的类型不是String,而是() -> String,一个没有参数且返回值为String的函数。

将闭包作为参数传递给函数时,能获得同样的延时求值行为。

// customersInLine is ["Jensen", "Ewa", "Alex", "Barry"]
func server(customer customerProvider: () -> String) {
    print("Now serving \(customerProvider())!")
}
server(customer: { customersInLine.remove(at: 0) })  // 打印出“Now serving Jensen!”
---
output: Now serving Jensen!

上面的server(customer:)函数接受一个返回顾客的名字的显式的闭包。下面这个版本的server(customer:)完成了相同的操作,不过它没有接受一个显示的闭包,而是通过将参数标记为@autoclosure来接收一个自动闭包。现在可以将该函数当做接受String类型参数(而非闭包)的函数来调用。customerProvider参数将自动转化为一个闭包,因为该参数被标记了@autoclosure特性。

// customersInLine is ["Ewa", "Alex", "Barry"]
func server(customer customerProvider: @autoclosure () -> String) {
    print("Now Serving \(customerProvider())!")
}
server(customer: customersInLine.remove(at: 0))  // 打印输出“Now Serving Ewa!”
---
output: Now Serving Ewa!

注意

过度使用autoclosures会让代码变得难以理解。上下文和函数名应该能够清晰地表明求值是被延迟执行的。

如果想让一个自动闭包可以“逃逸”,则应该同时使用@autoclosure@escaping属性。@escaping属性的讲解见上面的逃逸闭包。

// customersInLine is ["Alex", "Barry"]
var customerProviders: [() -> String] = []
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
    customerProviders.append(customerProvider)
}
collectCustomerProviders(customersInLine.remove(at: 0))
collectCustomerProviders(customersInLine.remove(at: 0))
print("Collected \(customerProviders.count) closures.")  // 打印输出“Collected 2 closures.” 现在有两个闭包传入了外部的customerProviders数组
print(customersInLine)  // customersInLine is still ["Alex", "Barry"]
for customerProvider in customerProviders {
    print("Now serving \(customerProvider())!")
}  // 打印输出“Now serving Alex!”  // 打印输出“Now serving Barry!”
//print(customersInLine)  // 此时customersInLine数组为空
---
output: Collected 2 closures.
["Alex", "Barry"]
Now serving Alex!
Now serving Barry!

在上面的代码中,collectCustomerProviders(_:)函数并没有调用传入的customerProvider闭包,而是将闭包追加到了customerProviders数组中,这个数组定义在函数作用域范围外,这意味着数组内的闭包能够在函数返回之后被调用。因此,customerProvider参数必须允许“逃逸”出函数作用域。

Last updated on