Swift深入理解闭包捕获机制
Swift深入理解闭包捕获机制

Swift深入理解闭包捕获机制

闭包可以捕获其所在上下文中的变量值。比如,当闭包在函数中定义时,它可以访问函数作用域中的变量。

闭包是如何捕获的?

捕获的核心在于闭包的作用域扩展机制。

当一个闭包在定义时引用了外部变量,Swift 编译器会将这个变量捕获到闭包的环境中。这使得闭包能够在离开变量所在的作用域后继续访问和使用这些变量。

var name = "Ali"
var sayName = {
    return "My Name is \(name)"
}
print(sayName())

闭包捕获变量的工作原理

1、Swift 编译器检测外部变量

当闭包访问外部变量 name 时,Swift 编译器会识别 name 作为闭包作用域外的变量。

编译器会生成一个特殊的「捕获环境」(closure capture context),将 name 捕获到这个环境中。

2、捕获行为

捕获的行为是引用捕获,也就是说,闭包内实际上会持有 name 的引用,而不是对其值的拷贝。

如果 name 的值在闭包外被修改,闭包内通过捕获的引用访问到的也是最新的值。

3、捕获的存储

编译器会在运行时生成一个存储空间,用于保存捕获的变量的引用。这个存储空间是闭包与捕获变量交互的桥梁。

在代码中,外部变量会被保存在闭包捕获的环境中。

name 是如何传递给闭包的?

name 是通过引用捕获进入闭包的,其流程如下:

1、编译器捕获变量

Swift 编译器在解析闭包代码时,识别 name 来自外部作用域。

编译器生成闭包的捕获上下文,并将外部变量 name 的引用存入上下文。

2、闭包与捕获环境绑定

sayName 被赋值为闭包表达式时,闭包的捕获环境也被创建。

捕获环境中存储了 name 的引用,因此闭包中的代码可以通过这个引用访问外部变量。

3、运行时访问捕获的变量

当 sayName() 被调用时,闭包的代码会访问捕获的上下文,从上下文中取出 name 的引用,并根据它的当前值返回结果。

实际验证:捕获动态变化

为了验证捕获机制,可以通过代码观察 name 的变化:

var name = "Ali"
var sayName = {
    return "My Name is \(name)"
}
print(sayName()) // 输出: My Name is Ali

name = "John"
print(sayName()) // 输出: My Name is John

修改外部变量 name 后,闭包输出的值会随之变化,这说明闭包捕获的是引用,而不是静态值。

闭包内持有外部变量 name 的引用,实际上是指闭包捕获了变量的内存地址或对该变量的某种间接访问方式(在 Swift 中,可以理解为引用地址),从而使闭包能够动态地获取和修改变量的值。

Swift 中的引用捕获

Swift 默认对闭包捕获的变量采用引用捕获,这意味着:

闭包不会直接存储变量的值,而是捕获外部变量的引用。

引用可以理解为变量在内存中的地址(或指针),通过这个引用,闭包能够读取或更新变量的值。

捕获引用会延长被捕获变量的生命周期,直到闭包被销毁。

引用捕获

闭包捕获的是变量的引用。通过这个引用,闭包可以访问或修改变量的最新值。

var name = "Ali"
let sayName = { return "My Name is \(name)" }

name = "John"
print(sayName()) // 输出: "My Name is John"

sayName 闭包捕获了变量 name 的引用。

当闭包被调用时,它通过引用获取 name 的值,而不是在闭包创建时固定的值。

修改外部变量 name 的值会影响闭包的输出。

捕获静态值

捕获 name 的值(而不是引用),需要使用捕获列表

var name = "Ali"
var sayName = { [name] in
    return "My Name is \(name)"
}
print(sayName()) // 输出: My Name is Ali

name = "John"
print(sayName()) // 仍然输出: My Name is Ali

捕获列表 [name] 会让闭包在创建时捕获 name 的当前值,而不是引用。

Swift的捕获类型

Swift 中的变量捕获是引用的,但具体机制取决于变量的存储方式(栈或堆)和类型:

1、栈上的变量

如果变量是局部变量(存储在栈上),Swift 会在闭包捕获时将其提升为堆分配(heap allocation),这样闭包可以持有其引用。

捕获的是提升后的堆存储的引用地址。

2、堆上的对象(引用类型)

如果变量本身是引用类型(例如 class 实例),闭包捕获的引用实际上是指向该对象的内存地址。

无需提升到堆,因为对象本身已经在堆上。

闭包示例

func makeMultiplier(by value: Int) -> (Int) -> Int {
    return { $0 * value } // 捕获了外部的 `value`
}
let multiplyByThree = makeMultiplier(by: 3)
print(multiplyByThree(4)) // 输出:12

当 return { $0 * value } 执行时,Swift 识别到 { $0 * value } 的定义中用到了 value。

Swift 自动将 value 捕获到闭包的环境中,这个环境会存储 value 的值(这里是 3)。

返回的闭包也会携带这个环境,因此在闭包被调用时,它能够访问 value。

为什么返回的闭包可以使用 value?

函数 makeMultiplier(by:) 已经执行完毕,按常规来说,它的局部变量 value 应该被销毁。但因为闭包捕获了 value,闭包持有了对 value 的引用,闭包的生命周期超出了函数作用域的限制

捕获是如何实现的?

当闭包捕获了变量(比如 value),Swift 会将 value 从栈(局部变量存储的地方)中转移到堆中。

这个捕获变量会绑定到闭包的环境中,闭包持有这个环境,所以即使函数返回了,value 仍然可以被使用。

代码中的场景

let multiplyByThree = makeMultiplier(by: 3)
// multiplyByThree 是闭包,内部捕获了 value = 3。
print(multiplyByThree(4)) // 闭包被调用,使用捕获的 value。

即使 makeMultiplier 的调用已经结束,因为multiplyByThree还引用着value,所以value 依然被闭包保存下来了

捕获环境的本质

捕获环境是 Swift 编译器在运行时为闭包分配的内存区域,用来存储闭包所捕获的变量。捕获环境与变量作用域相关,但其生命周期独立于定义这些变量的作用域。捕获环境主要负责:

保存被捕获变量的值或引用,确保变量在原作用域结束后依然可用。

管理变量的生命周期,直到捕获环境不再被任何闭包引用。

变量存储方式

如果捕获的变量是值类型(如 Int、String 等):捕获环境存储变量的拷贝。

如果捕获的变量是引用类型(如类实例):捕获环境存储变量的引用。

本质上,捕获环境是一段内存区域,存储变量的值或引用,并为闭包提供访问这些变量的机制。

两种捕获方式

1、值捕获(Capture by Value)

如果捕获的变量是值类型(比如 Int),Swift 会在闭包环境中存储一份变量的拷贝。

例如,value 是 Int 类型,所以闭包环境中存储的是 value 的值。

2、引用捕获(Capture by Reference)

如果捕获的变量是引用类型(比如 class 实例),Swift 会在闭包中存储变量的引用。

这意味着闭包内外对变量的修改会相互影响。

闭包的生命周期

在 Swift 中,闭包的生命周期取决于闭包的作用域和变量的引用计数(Reference Counting, ARC)。

1、默认情况下的闭包生命周期

闭包的生命周期通常与其作用域绑定。如果闭包仅仅是一个局部变量,并且没有被返回或赋值到外部变量,那么闭包会在作用域结束时被销毁:

func example() {
    let closure = {
        print("This is a closure")
    }
    closure() // 闭包在此作用域内执行
} // 作用域结束,closure 被销毁

2、闭包返回后生命周期延长的情况

func makeClosure() -> () -> Void {
    var count = 0 // 定义在函数作用域内的局部变量
    let closure = {
        count += 1 // 闭包捕获了变量 count
        print("Count is \(count)")
    }
    return closure // 闭包返回到外部作用域
}

let myClosure = makeClosure() // 此时 count 被捕获并存储在闭包环境中
myClosure() // 输出: Count is 1
myClosure() // 输出: Count is 2

makeClosure() 返回了一个闭包,这意味着闭包超出了函数的作用域。为了保证闭包能够正常使用,它的生命周期会被延长,并绑定到接收它的外部变量 myClosure:

let myClosure = makeClosure()

此时,myClosure 持有闭包的引用,这会导致以下结果:

闭包不会在 makeClosure() 函数结束时被销毁。

捕获的变量 count 的生命周期也被延长,与闭包一起存活。

闭包生命周期的本质

Swift 使用 ARC(自动引用计数) 来管理闭包的生命周期。

当闭包被返回并赋值给 myClosure 时,ARC 会对闭包的引用计数增加一次。

闭包和它捕获的环境(包括 count)都会存活,直到闭包的引用计数变为 0。

因为myClosure引用着makeClosure方法返回的闭包,所以闭包的引用计数为1,如果myClosure不再引用闭包,闭包的引用计数为0时,闭包和捕获环境将一同销毁。

销毁的过程包括

1、释放闭包自身占用的内存。

2、释放闭包捕获的变量(如果这些变量的引用计数也归零)。

变量从捕获到销毁的流程

变量捕获的流程

1、当闭包定义时,Swift 编译器检测闭包内是否访问了外部变量

2、如果访问了外部变量

Swift 会将变量从栈上“提升”到堆上。

捕获的变量存储在捕获环境中。

捕获环境的引用计数开始管理该变量的生命周期。

3、捕获变量在堆上的存储有两种情况

共享存储(多个闭包捕获同一变量):捕获环境中存储单一的变量实例,所有闭包共享访问。

独立存储(仅一个闭包捕获该变量):捕获环境中只为该闭包存储变量。

捕获的变量在哪?

捕获的变量存储在闭包的捕获环境中,这个捕获环境是一个特殊的结构,负责保存被捕获的变量或它们的引用。

捕获环境通常存储在堆(Heap)中,以确保变量的生命周期能超越它们原本的作用域。

闭包通过捕获环境访问这些变量。

变量销毁的流程

当捕获的变量不再被任何闭包引用时,捕获环境会被销毁,变量存储被释放。

如果捕获的是值类型变量(如 Int 或 String),这些值会随着捕获环境一起销毁。

如果捕获的是引用类型(如类实例),引用计数会减少。如果引用计数归零,引用类型对象也会被销毁。

示例:捕获环境的销毁

func makeClosure() -> (() -> Void) {
    var count = 0
    let closure = { count += 1; print("Count: \(count)") }
    return closure
}

var myClosure = makeClosure()
myClosure() // Count: 1
myClosure() // Count: 2
myClosure = {} // 捕获环境销毁,count 被释放

count 被 closure 捕获。

当 myClosure 被赋值为新的空闭包 {} 时,原来的捕获环境被销毁,count 被释放。

闭包销毁需要注意的情况:循环引用

如果闭包捕获了引用类型,并且闭包本身又被该引用类型的实例持有,就会导致循环引用,闭包和实例都无法被销毁。

class Person {
    var name: String
    lazy var introduce: () -> Void = {
        print("Hi, my name is \(self.name)")
    }
    init(name: String) { self.name = name }
    deinit {
        print("\(name) is being deinitialized")
    }
}

var person: Person? = Person(name: "Ali")
person?.introduce() // 输出: Hi, my name is Ali
person = nil // 循环引用,deinit 不会被调用

闭包捕获了 self,导致 Person 和闭包之间形成强引用环。

即使将 person 置为 nil,闭包和 Person 实例都无法释放,造成内存泄漏。

相关文章《Swift闭包在类中的引用问题》。

共享与独立捕获环境的意义

共享捕获环境

当多个闭包捕获了相同的变量,Swift 会为这些变量在捕获环境中开辟一个共享存储区域。共享捕获变量的意义在于:

1、内存效率:当一个变量只被某个闭包捕获时,或者多个闭包捕获了不同的变量,Swift 为每个闭包单独创建捕获环境,这些环境互不影响。

2、一致性:修改变量时所有引用该变量的闭包都能看到更新。

示例 1:变量共享存储

func makeClosures() -> [() -> Void] {
    var sharedVar = 0
    let closure1 = { sharedVar += 1; print("Closure 1: \(sharedVar)") }
    let closure2 = { sharedVar += 10; print("Closure 2: \(sharedVar)") }
    return [closure1, closure2]
}

代码解析

捕获环境中 sharedVar 只存储一次。

closure1 和 closure2 都引用这同一个变量实例。

修改 sharedVar 后,两个闭包即时共享变量的新值。

独立捕获环境

当一个变量只被某个闭包捕获时,或者多个闭包捕获了不同的变量,Swift 为每个闭包单独创建捕获环境,这些环境互不影响。独立捕获的意义在于:

1、作用域隔离:每个捕获环境只负责管理自己捕获的变量,互不干扰。

2、生命周期独立:独立捕获环境的生命周期仅与对应闭包绑定。

示例 2:变量单独存储

func makeIndependentClosures() -> [() -> Void] {
    var var1 = 0
    var var2 = 0
    let closure1 = { var1 += 1; print("Closure 1: \(var1)") }
    let closure2 = { var2 += 10; print("Closure 2: \(var2)") }
    return [closure1, closure2]
}

代码解析

捕获环境中 var1 和 var2 分开存储。

closure1 引用 var1 的独立实例,closure2 引用 var2 的独立实例。

两个闭包互不干扰。

捕获环境图解

图片中变量1、变量2、变量3和变量4,被闭包捕获,并在捕获环境中开辟单独的内存区域。

闭包A、闭包B、闭包C、闭包D访问捕获环境中的变量。

其中,变量2和变量3被闭包A、闭包B、闭包C共享访问,所以这里的变量2和变量3属于共享变量,所以变量2和变量3的修改,都会对闭包A、闭包B和闭包C产生影响。

变量1和变量4因为只有单独的闭包访问,所以在捕获环境中属于独立变量,修改变量1只会影响闭包A,变量4同理。

捕获环境为变量分配一段内存,用于存储值。

捕获环境的生命周期与闭包的引用计数绑定。

变量是否从共享到独立?

1、如果变量最初被多个闭包捕获,捕获环境为该变量创建共享存储。

2、如果变量只有一个闭包引用,捕获环境将依然存在,但内存优化机制不会动态地将共享存储转为独立存储。捕获环境的结构不会改变。

示例:捕获变量的共享和独立混合情况

func makeClosures() -> [() -> Void] {
    var a = 0
    var b = 0

    let closure1 = { a += 1; b += 1; print("Closure 1: a = \(a), b = \(b)") }
    let closure2 = { a += 10; print("Closure 2: a = \(a)") }

    return [closure1, closure2]
}

let closures = makeClosures()
closures[0]() // Closure 1: a = 1, b = 1
closures[1]() // Closure 2: a = 11

closure1 捕获了 a 和 b。

closure2 只捕获了 a。

捕获环境结构

变量 a 存储在共享捕获环境中。

变量 b 只存储在 closure1 的独立捕获环境中。

销毁过程

当 closure1 和 closure2 都被销毁时,a 的共享存储区域才会释放。

b 的存储与 closure1 生命周期绑定,closure1 销毁时释放。

共享与独立捕获环境的总结

当变量被复制到捕获环境时,只有被多个闭包同时访问,才叫做共享捕获环境,否则就是独立捕获环境,共享和独立的捕获环境在实质上没有区别,共享捕获环境不会为相同变量开辟多个存储空间,而是让多个闭包共享访问一个存储实例。

所以共享和独立捕获空间的区分更像是书面用语。

对比普通函数

普通函数的变量只在函数执行期间有效。闭包之所以能“突破”作用域限制,是因为它保存了被捕获变量的环境。

例如

func multiplier() -> Int {
    let value = 3
    return value * 2
}
// value 生命周期结束后就会销毁,无法在外部继续使用。

在这个普通函数中,value 是局部变量,函数调用完毕后,value 就被销毁了。而闭包会在需要时延长捕获变量的生命周期。

总结

1、闭包捕获机制

闭包捕获了外部变量,将其保存到一个环境对象中,这个环境随着闭包一起被返回。

2、为什么可以使用 value

因为闭包持有 value 的值或引用,闭包的生命周期超出了函数作用域,允许闭包在函数外访问变量。

3、具体实现

Swift 将捕获的变量从栈转移到堆中,确保它能在闭包中持续有效。

4、捕获环境的共享与独立

捕获环境是一段内存区域,为闭包提供对变量的访问机制。

如果多个闭包捕获相同变量,这些闭包共享一个捕获环境。

捕获环境中只为被捕获的变量分配存储,不捕获的变量不会加入。

5、变量的共享存储机制

如果一个变量被多个闭包捕获,捕获环境为它开辟一个共享存储区域,所有闭包访问同一内存。

如果变量只被一个闭包捕获,变量的存储与该闭包绑定。

6、销毁逻辑

捕获环境的生命周期与捕获它的闭包引用计数绑定。

变量的存储只有在捕获环境被销毁时才会释放。

7、不会动态调整存储类型

如果变量最初被共享捕获,则存储方式固定为共享。

捕获环境不会动态调整共享存储为独立存储,即使闭包数量减少。

更多扩展

简单闭包的捕获环境

let sayHello = {
    print("你好")
}

这个代码并不会创建捕获环境,因为它并未引用任何外部变量。

因为捕获环境是用于存储闭包中引用的外部变量(捕获的上下文)的一个隐式容器。

在 sayHello 中,闭包的代码只是一个打印操作,并没有使用外部的变量或值,因此编译器不会为这个闭包分配捕获环境。

在这种情况下,闭包是一个简单的函数对象,只包含代码逻辑本身,没有附加的捕获环境。

简单闭包的生命周期

即使没有捕获环境,这个闭包的生命周期仍然由引用计数(ARC)管理。

1、初始化

当定义 let sayHello 时,闭包作为一个对象被创建并存储在内存中(可能在堆中,具体取决于优化)。

由于它没有捕获外部变量,不需要额外的捕获环境,内存占用非常小。

2、引用计数管理

sayHello 是一个变量,持有对闭包的引用。当 sayHello 超出作用域时,它会被 ARC 自动销毁。

do {
    let sayHello = {
        print("你好")
    }
    sayHello() // 执行闭包,输出 "你好"
}
// 作用域结束,`sayHello` 不再被引用,闭包被销毁

3、多次引用

如果闭包被多次引用,它会持续存活,直到所有引用被释放。例如:

var closure1: (() -> Void)?
var closure2: (() -> Void)?

do {
    let sayHello = {
        print("你好")
    }
    closure1 = sayHello
    closure2 = sayHello
}
// 即使作用域结束,closure1 和 closure2 仍然持有闭包的引用
closure1?()
closure2?()
// 当 closure1 和 closure2 都被设置为 nil 时,闭包才会被销毁
性能优化:没有捕获环境的闭包

Swift 编译器会对不需要捕获环境的闭包进行优化:

这种闭包被认为是“无状态的”(stateless),它可以作为轻量级的函数指针实现。

编译器可能不会将它存储在堆中,而是直接优化为栈上的代码块或静态内存中的代码段。

无变量赋值的闭包

class Person {
    var name: String
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}

func createClosure() -> () -> Void {
    var person = Person(name: "Ali")
    return { print("Person's name is \(person.name)") }
}

createClosure() // 调用后,返回的闭包立即被丢弃

createClosure() 返回了一个闭包,该闭包捕获了局部变量 person。执行完 createClosure() 后,闭包和 person 的生命周期会受到以下几个因素的影响:

1、闭包的生命周期

闭包是一个引用类型,返回后其生命周期取决于闭包的持有情况。

如果没有保存返回的闭包(例如用变量持有它),那么闭包会在 createClosure() 的调用结束后立即释放。

2、捕获的变量 person 的生命周期

person 是 createClosure() 中的局部变量,但因为闭包捕获了 person,其生命周期被闭包延长。

如果闭包被释放(没有被外部持有),那么闭包内捕获的所有内容(如 person)也会被释放。

在 Swift 中,这种捕获行为由闭包的引用计数(ARC)管理。

闭包存储在哪里?

闭包可以存储在 栈内存 或 堆内存 中,这取决于闭包的使用场景:

1、栈内存

如果闭包是 非逃逸闭包(non-escaping closure),即它的生命周期不会超过定义它的作用域,那么闭包会分配在栈内存中。

这种场景下,闭包的生命周期与调用它的函数同步,性能开销较低。

func performNonEscaping(action: (Int) -> Void) {
    action(42) // 闭包在这里直接执行
}

performNonEscaping { value in
    print(value) // 输出 42
}

在这个例子中,闭包 { value in print(value) } 是非逃逸闭包,生命周期不会超过 performNonEscaping 的作用域,因此会存储在栈中。

2、堆内存

如果闭包是 逃逸闭包(escaping closure),即闭包在函数返回后仍可能被调用,那么闭包会存储在堆内存中。

捕获的变量(捕获环境)也会存储在堆中,以确保它们在闭包的生命周期内持续存在。

var completionHandlers: [(Int) -> Void] = []

func performEscaping(action: @escaping (Int) -> Void) {
    completionHandlers.append(action) // 将闭包存储到数组中,逃逸到函数外
}

performEscaping { value in
    print(value)
}

completionHandlers.first?(42) // 闭包在此处被调用

在这个例子中,闭包 { value in print(value) } 逃逸到了 completionHandlers 数组,生命周期超出了 performEscaping 的作用域,因此会被存储在堆中。

捕获环境存储在哪里?

捕获环境(捕获变量)会存储在 堆内存 中。每个闭包都有一个独立的 捕获上下文,存储它捕获的变量。具体行为如下:

1、捕获值类型变量

如果捕获的是值类型变量(如 Int、Struct 等),闭包会拷贝这些变量的值,并在堆内存中存储它们的副本。

2、捕获引用类型变量

如果捕获的是引用类型变量(如 class 实例),闭包会捕获对这个对象的强引用(或弱引用,取决于闭包的定义)。引用的对象也存储在堆内存中。

捕获的引用会增加对象的引用计数,直到闭包被销毁,引用计数才会减少。

闭包会延长被捕获变量的生命周期

闭包会延长被捕获变量的生命周期,这可以通过对象的 deinit 方法验证。

class Person {
    var name: String
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}

var person: Person? = Person(name: "Ali")
let closure = { print("Name is \(person?.name ?? "")") }

person = nil // 此时对象不会被销毁,因为闭包捕获了 person 的引用
closure() // 输出: "Name is Ali"

输出

Name is Ali

Person 实例未被释放,因为闭包持有对它的引用。

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注