Swift深入理解闭包捕获机制

我们知道:闭包的本质是结构体,有isa指针,即能够管理生命周期,因此可以当作对象处理,闭包可以捕获外界的变量到结构体中,并且闭包在堆上开辟空间保存的。

那么,闭包捕获的过程是什么呢?是怎么实现的呢?接下来就明白了。

闭包是如何捕获的?

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

当一个闭包在定义时引用了外部变量,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的时候,编译器会识别name为外部变量,此时编译器就会生成一个内存空间「捕获环境」存储外部变量name的引用。

二、name 是如何传递给闭包的?

结合下图理解。

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

1、编译器捕获变量

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

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

2、闭包与捕获环境绑定

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

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

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

当 sayName() 被调用时,闭包的代码会访问捕获的上下文,从上下文中取出 name 的引用,并根据它的当前值返回结果。(上下文指的是在访问闭包创建时所处的词法作用域内的变量和常量的引用)

也就是说闭包捕获的变量是可以动态改变的,即改变外部变量的值,那么闭包捕获的值也会随之改变。

闭包的捕获类型:

分为引用捕获(动态)和捕获列表(静态)。

引用捕获

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

  • 闭包不会直接存储变量的值,而是捕获外部变量的引用。
  • 引用可以理解为变量在内存中的地址(或指针),通过这个引用,闭包能够读取或更新变量的值。
  • 捕获引用会延长被捕获变量的生命周期,直到闭包被销毁。

demo:

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

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

除了上述的引用捕获之外,闭包也可以捕获静态值,这就需要使用「捕获列表」了。

捕获静态值

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

demo:

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 实例),闭包捕获的引用实际上是指向该对象的内存地址

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

闭包可以延长变量的生命周期

看如下demo:

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。

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

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

qs:捕获是如何实现的?

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

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

demo中的场景

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

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

闭包的生命周期

在 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、释放闭包捕获的变量(如果这些变量的引用计数也归零)。

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

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

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 实例都无法释放,造成内存泄漏。

闭包对比普通函数

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

例如

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

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

额外补充:

闭包存储在哪里?

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

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 的作用域,因此会被存储在堆中。

参考:

Swift深入理解闭包捕获机制 – 方君宇

Swift闭包在类中的引用问题 – 方君宇

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值