8-15.【内存管理机制】闭包捕获 struct/enum/value 的行为与 class/value 的差异?

29 阅读5分钟

在 Swift 中,闭包捕获值类型struct/enum)和引用类型class)的行为有着本质的区别。这种差异主要体现在内存地址的指向生命周期的提升以及捕获列表的作用上。


1. 默认捕获行为:引用捕获 (Reference Capture)

无论是值类型还是引用类型,Swift 闭包默认捕获的都是变量的引用(或地址) ,而不是它在捕获那一刻的值。

对于 Class(引用类型)

闭包捕获的是指向堆对象的指针

  • 行为:闭包会让该对象的引用计数(Strong Count)加 1。
  • 后果:闭包内部对该对象的修改(修改属性)会直接反映在原始对象上。

对于 Struct/Enum(值类型)

闭包捕获的是变量在内存中的逻辑地址

  • 行为:为了保证变量在闭包执行时依然有效,Swift 会将该值类型从栈(Stack)**提升(Promote)**到堆(Heap)上的一个 Box 容器中。
  • 后果:闭包内部和外部函数共享这个堆上的 Box。如果你在闭包外修改了变量,闭包执行时会看到最新的值;反之亦然。

2. 使用捕获列表:值捕获 (Value Capture)

如果你在闭包中使用捕获列表 [x],情况会发生剧变。

对于 Struct/Enum

  • 行为:闭包在定义那一刻会对该值进行一次完整的拷贝。
  • 效果:这个拷贝被存储在闭包的内部常量中。即便外部变量后来被修改,闭包内部依然保留的是“那一刻的快照”。

对于 Class

  • 行为:闭包拷贝的是指针本身
  • 效果:虽然指针被拷贝了,但它指向的依然是同一个堆对象。因此,即便用了捕获列表,闭包内部修改 self.name 依然会改变外部对象(除非你在捕获列表中使用了 weakunowned 来改变持有强度)。

3. 核心差异对比表

特性捕获 Struct / Enum (默认)捕获 Class (默认)使用捕获列表 [x] (Struct)
存储位置从栈提升到堆 (Boxed)存储对象指针存储一份本地拷贝 (Const)
共享状态。内外共享最新值。指向同一实例。闭包内是独立快照
修改行为外部改,内部变外部改,内部变外部改,内部不变
内存成本需要分配堆内存 Box增加 ARC 计数结构体拷贝的内存成本

4. 深度原理:Box 容器

当闭包捕获一个 struct 变量时,底层编译器会将该变量转换为一个 ProjectBox

Swift

func example() {
    var count = 0 // 原本在栈上
    let closure = {
        count += 1 // 此时 count 被提升到堆上的 Box
    }
}
  • 如果这个闭包是非逃逸的(@noescape),编译器可以优化掉堆分配,直接在栈上处理。
  • 如果闭包是逃逸的(@escaping),这个 Box 会一直存在,直到闭包销毁。

5. 常见陷阱:逃逸闭包中的 Mutating 方法

你不能在逃逸闭包中直接捕获并修改 mutating 结构体的方法,因为 mutating 本质上是 inout self

Swift

struct Counter {
    var value = 0
    mutating func increment() {
        // 错误:逃逸闭包捕获 mutating self 是不安全的
        // 因为闭包执行时,原始的 struct 可能已经因为是值类型而被销毁或拷贝了
        DispatchQueue.main.async {
            self.value += 1 
        }
    }
}

总结:

  • 捕获 Class:关注的是对象的生命周期(避开循环引用)。
  • 捕获 Struct:关注的是数据的时态(是需要最新值,还是定义时的快照)。

英文版

8-15. [Memory Management] Differences in Closure Capture Behavior: struct/enum vs. class

In Swift, there is a fundamental difference in how closures capture value types (struct/enum) versus reference types (class). This distinction is primarily reflected in memory address mapping, lifecycle promotion, and the impact of capture lists.


1. Default Capture Behavior: Reference Capture

By default, regardless of whether a type is a value type or a reference type, a Swift closure captures a reference (or address) to the variable, rather than its value at the moment of capture.

For Classes (Reference Types)

The closure captures the pointer to the heap object.

  • Behavior: The closure increments the object's reference count (Strong Count) by 1.
  • Consequence: Modifications made to the object's properties inside the closure directly affect the original object.

For Structs/Enums (Value Types)

The closure captures the logical address of the variable in memory.

  • Behavior: To ensure the variable remains valid when the closure executes, Swift promotes the value type from the stack to a Box container on the heap.
  • Consequence: The closure and the outer function share this heap-allocated Box. If you modify the variable outside the closure, the closure will see the updated value upon execution, and vice versa.

2. Using Capture Lists: Value Capture

If you use a capture list [x] in a closure, the behavior changes dramatically.

For Structs/Enums

  • Behavior: The closure performs a full copy of the value at the moment of definition.
  • Effect: This copy is stored as an internal constant within the closure. Even if the external variable is modified later, the closure retains the "snapshot" from that specific point in time.

For Classes

  • Behavior: The closure copies the pointer itself.
  • Effect: Although the pointer is copied, it still points to the same heap object. Therefore, even with a capture list, modifying self.name inside the closure will still affect the external object (unless you use weak or unowned in the capture list to change the holding strength).

3. Core Comparison Table

FeatureCapture Struct / Enum (Default)Capture Class (Default)Capture List [x] (Struct)
Storage LocationPromoted from stack to heap (Boxed)Stores object pointerStores a local copy (Constant)
Shared StateYes. Inner/Outer share latest valueYes. Points to same instanceNo. Inner is an independent snapshot
ModificationOutside change affects insideOutside change affects insideOutside change does not affect inside
Memory CostRequires heap Box allocationIncrements ARC countMemory cost of copying the struct

4. Deep Principle: The Box Container

When a closure captures a struct variable, the underlying compiler converts that variable into a ProjectBox.

Swift

func example() {
    var count = 0 // Originally on the stack
    let closure = {
        count += 1 // count is promoted to a Box on the heap
    }
}
  • If the closure is non-escaping (@noescape), the compiler can optimize away the heap allocation and handle it directly on the stack.
  • If the closure is escaping (@escaping), this Box will persist until the closure itself is deallocated.

5. Common Trap: Mutating Methods in Escaping Closures

You cannot directly capture and modify a struct within an escaping closure via a mutating method, because mutating is essentially inout self.

Swift

struct Counter {
    var value = 0
    mutating func increment() {
        // ERROR: Escaping closure capturing 'self' in a mutating method is unsafe.
        // Because the original struct might be destroyed or copied by the time the closure runs.
        DispatchQueue.main.async {
            self.value += 1 
        }
    }
}

Summary:

  • Capturing Classes: Focus on object lifecycle (avoiding retain cycles).
  • Capturing Structs: Focus on data state (do you need the live, latest value, or a snapshot from the time of definition?).