Files
knowledge-kit/Chapter1 - iOS/1.114.md
2025-06-23 01:18:55 +08:00

19 KiB
Raw Blame History

Swift 闭包研究

方法占用对象内存吗?

实验一:

class Point {
   var test = true
   var age = 29
   var height = 175
}
var p = Point()
print(Mems.size(ofRef: p)) // 48

为什么是48而不是40

Point 类前16位的前8位表示类信息后8位表示引用计算信息2个 Int 占2*8 = 16 ByteBool 占用1 Byte。所以实际占用 8 + 8 + 2 * 8 + 1 = 33 Byte。但由于存在内存对齐内存对齐以8为 base都是8的整数倍但 malloc 函数分配的内存都是 16的倍数所以占用48 Byte。

Demo:

class Person {
    var age = 29
    func sayHi () {
        var height = 175
        print("局部变量", Mems.ptr(ofVal: &height))
        print("I am \(age) old")
    }
}
func sayOuterHi () {
    print("Hello world")
}

var p = Person()
p.sayHi()
sayOuterHi()

print("全局变量", Mems.ptr(ofVal: &p))
print("堆空间", Mems.ptr(ofRef: p))

代码段Person.sayHi 0x1000034d0

代码段sayOuterHi 0x1000038e0

全局变量: 0x000000010000c388

堆空间: 0x20c820

局部变量(栈): 0x00007ff7bfeff2f8

可以看到写在类里面的方法地址和写在 Class 外部的方法地址差不多,都在代码段。

结论:方法不占用对象的内存。方法、函数存放于代码段。

闭包

定义

什么是闭包?一个函数和它所捕获的变量/常量环境组合起来,称为闭包。

  • 一般指定义在函数内部的函数
  • 一般捕获的是外层函数的局部变量、常量

原理窥探

Demo

func exec(a: Int, b: Int, fn: (Int, Int) -> Int) {
    print(fn(a, b))
}
// 写法1
func sum(a: Int, b: Int) -> Int { return a + b }
exec(a: 1, b: 2, fn: sum)

// 写法2:闭包
exec(a: 1, b: 2, fn: {
    (a: Int, b: Int) -> Int in
    return a + b
})
// 写法3:闭包简写
exec(a: 1, b: 2, fn: {
    a,b in return a + b
})
// 写法4:闭包简写
exec(a: 1, b: 2, fn: {
    a,b in a + b
})
// 写法5:闭包简写。用$0、$1来获取参数。
exec(a: 1, b: 2, fn: { $0 + $1 })
// 写法5:闭包简写。用 + 来代表操作,让编译器进行推断
exec(a: 1, b: 2, fn: + )

如果将一个很长的闭包表达式作为函数的最后一个实参,使用尾随闭包可以增强函数的可读性。尾随闭包是一个被书写在函数调用括号外面(后面)的闭包表达式

上面的写法等价于

// 写法6:尾随闭包
exec(a: 1, b: 2) {
    $0 + $1
}

如果闭包表达式是函数的唯一实参,而且使用饿尾随闭包的语法,那就不需要在函数名后面写圆括号

func exec(fn: (Int, Int) -> Int) {
    print(fn(1, 2))
}

exec(fn: { $0 + $1 }) // 3
exec() { $0 + $1 }		// 3
exec{ $0 + $1 }				// 3

来个 Demo 看看系统数组的排序

var array = [1, 8, 9, 12, 32, 2]
//array.sort()
func compare(a: Int, b: Int) -> Bool {
    return a < b
}
// 写法1
//array.sort(by: compare)

// 写法2
// array.sort { $0 < $1 }

// 写法3
//array.sort { a, b in
//    return a < b
//}

// 写法4
//array.sort(by: {
//    (a: Int, b: Int) -> Bool in
//    return a < b
//})

// 写法5
//array.sort(by: <)

// 写法6
array.sort() { $0 < $1 }

print(array)	// [1, 2, 8, 9, 12, 32]

Demo2

闭包的变量捕获

typealias Fn = (Int) -> Int
func getFn() -> Fn {
    var num = 0
    func plus(_ i: Int) -> Int {
        return i
    }
    return plus
}
var fn = getFn()
print(fn(1))		// 1
print(fn(2))		// 2
print(fn(3))		// 3

简单修改下代码

typealias Fn = (Int) -> Int
func getFn() -> Fn {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}
var fn = getFn()
print(fn(1))	// 1
print(fn(2))	// 3
print(fn(3))	// 6

可以看到上面有 allocObject 方法调用,说明产生了堆空间对象,用于存放 num 。是由 var fn = getFn() 造成的调用1次 getFn 则产生1次堆空间分配用于保存 num。

也就是说如果一个内层函数访问了外层函数的局部变量,也就会发生变量捕获,做法就是延长局部变量的生命周期,在堆空间申请一段内存,用户保存局部变量。

对代码进行修改

typealias Fn = (Int) -> Int
func getFn() -> Fn {
    var num = 1
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}
var fn1 = getFn()
var fn2 = getFn()
var fn3 = getFn()
print(fn1(1))	// 2
print(fn2(2))	// 3
print(fn3(3)) // 4

我们在汇编 swift_allocObject 下面下个断点

第一次:可以看到 alloc 在堆空间申请后的内存被存放到寄存器 rax 中,此时只是申请内存,没有赋值的。利用 Debug -> Debug Workflow -> View Memory 查看内存信息0x0000600000210000。此时还没值。

敏感点查看到字面量1给汇编代码15行加断点执行完15行继续查看内存信息

可以看到内存数据发生了改变。绿色框内有了值1。

第二次:可以看到 alloc 在堆空间申请后的内存被存放到寄存器 rax 中,此时只是申请内存,没有赋值的。利用 Debug -> Debug Workflow -> View Memory 查看内存信息0x00006000002042c0。此时还没值。

敏感点查看到字面量1给汇编代码15行加断点执行完15行继续查看内存信息

第三次:可以看到 alloc 在堆空间申请后的内存被存放到寄存器 rax 中,此时只是申请内存,没有赋值的。利用 Debug -> Debug Workflow -> View Memory 查看内存信息0x000060000020d460。此时还没值。

敏感点查看到字面量1给汇编代码15行加断点执行完15行继续查看内存信息

打印结果也说明了问题因为调用3次 getFn() 所以会在堆上 alloc 3块内存用于保存捕获的变量。所以调用 fn1 得到 2调用 fn2 得到 3调用 fn3 得到 4。

BTW堆空间分配的内存如果没有 init 或者赋特定的值,数据会是随机的。因为有可能是之前别的地方使用过的内存。

闭包内存结构

先来个简单的函数,看看指针内存结构

func sum(_ a: Int, _ b: Int) -> Int { return a + b }
var fn = sum
print(fn(1, 2))	// 3

var fn = sum 处下断点,可以看到下面汇编

我们知道 leaq 是从 %rip + 0x10f 算出来的地址,赋值给 %rax。所以大概可以推测 %rip + 0x10f 就是 sum 函数地址。LLDM p 打印 sum 地址为 0x0000000100003a30

%rip 是下一条指令的地址 0x100003921%rip + 0x10f 也就是 0x0000000100003a30。和猜想一致。

第六行汇编的意思就是将 sum 函数的地址赋值给 %rax

第七行汇编的意思是将保存在 %rax 内的地址,从 %rip + 0x88d8取8个字节用来保存 %rax 的地址。0x100003928 + 0x88d8 = 0x10000C200

第八行汇编的意思是将保存在 %rax 内的地址,从 %rip + 0x88d85取8个字节用来保存 $0x00x100003933 + 0x88d5 = 0x10000C208

0x10000C2000x10000C208 差8位也是连续的。说明分配了一个函数指针长度为16位。通过MemoryLayout.stride(ofValue: sum) 看到也是16位。符合猜想。

直奔主题,研究闭包内存

可以看到在调用完第六行的函数后将寄存器 raxrdx 里的值取出来使用了。进入函数内部看看发生了什么LLDB 输入 si

可以看到 rax 里面存放了 plus 函数地址。第7行汇编是异或运算2个 ecx 异或结果为0写入到 ecx 里。然后第8行汇编将 ecx 里的0写入到 edxedx 也就是 rdx。走完第6行的汇编继续看第7、8行

将第6行函数的返回结果 rax 里的函数地址赋值给 rip +0x89e7 = 0x100003819 + 0x89e7 = 0x10000C200 第7行的rdx 的值赋值个给 rip +0x89e8 = 0x100003820 + 0x89e8 = 0x10000C208

也就是 fn1 前8个字节存放 plus 的函数地址后8个字节存放0.

继续对比实验,查看闭包放了什么东西(注意和上面的实验不同,下面存在闭包)

基本可以断定函数会返回一个长度为16 Byte 的内存。分别保存在 raxrdx上。所以针对性的研究 rdxrax

汇编第10行经过在堆上为捕获的变量 alloc 内存后,将内存保存到 rax 中,然后赋值给 rdi第11行rdi 再赋值给 rbp - 0x10 的地址。所以汇编19行的 rbp - 0x10 保存的也就是堆内存,赋值给 rdx 了。

20行的 rax 保存了一个看似是 plus 的函数地址。具体是:rip + 0x167 = 0x100003969 + 0x167= 0x100003C37

继续走,走到真正的 plus 方法内,可以看到函数地址是 0x100003970 。所以返回的 rax 里面可能是间接调用真正的 plus 函数的。

问题变得微妙起来了,getFn 方法返回一个地址占用16个字节但是前8个字节存储 plus 方法地址后8个字节存储闭包捕获后在堆上申请内存存放的数据地址。那如何根据一个地址的前8位去真正调用方法

Tips由于地址是动态生成的所以真正去调用 plus 的时候一定不是写死的地址,一定是动态生成的。这种属于间接调用。

  • call 后面直接加一个函数地址,属于直接调用。类似 callq 0x100003970
  • call 后面的函数地址不是固定的,而是动态生成的叫间接调用。在 at&t 汇编里面,间接调用前面要加 * 。类似 callq *%rax,意思是从 %rax 里面取出一个地址值出来,当成函数地址,去调用

顺着思路,分析下汇编:

我们看到25行 callq *%rax 存在动态调用,所以需要找到 %rax 里的值是哪里来的。然后顺着向上找找到23行 movq -0x30(%rbp), %rax。然后继续向上看到16行 movq %rax, -0x30(%rbp)。继续向上看到15行 movq 0x88db(%rip), %rax。相当于就是在 rip + 0x88db 处取出8个字节出来当作函数地址调用汇编代码的右边写了fn1 ),地址为:0x88db(%rip) = rip + 0x88db = 0x10000392d + 0x88db = 0x10000C208

断点继续放开在汇编25行处加断点 callq *%rax

可以看到在方法内部第6行汇编处直接调用一个代码段的函数地址 jmp 0x1000039f0

指令 jmpcall 的区别在于:

  • jmp 指令控制程序直接跳转到目标地址执行程序,程序总是顺序执行,指令本身无堆栈操作过程。
  • call 指令跳转到指定目标地址执行子程序,执行完子程序后,会返回 call 指令的下一条指令处执行程序,执行 call 指令有堆栈操作过程

LLDB 输入 si,可以看到是 plus 函数真正的地址

fn1 函数调用的时候,参数如何传递?

汇编17行 movq 0x88d8(%rip), %r13 ; SwiftDemo.fn1 : (Swift.Int) -> Swift.Int + 8可以看到将返回的 fn1 本身16字节的后8个字节也就是堆地址空间值保存到寄存器 edi 也就是寄存器 rdi 上了。

汇编24行 movl $0x1, %edi 将参数1传给寄存器 rdi 了。

然后 LLDB 输入 si 去分析 callq 内部

可以看到 movq %r13, %rsi 将寄存器 r13 的值写入到 rsi 了。目前为止:rdi 保存参数1rsi 保存堆地址值。

继续输入 si

可以看到汇编的第5行 movq %rsi, -0x50(%rbp)rsi 里的1保存到 rbp - 0x50 处。第6行汇编 movq %rdi, -0x58(%rbp)rdi 堆地址值保存到 rbp - 0x58 处。

汇编26行 movq -0x58(%rbp), %rdirbp - 0x58 的值写入到 rdi也就是堆地址值。第27行 movq -0x50(%rbp), %rsirbp - 0x50 的值写入到 rsi也就是参数值1。

然后真正做 plus 加法运算的就是28行的 addq 0x10(%rsi), %rdirsi 堆地址值的第16个字节的地方取出8个字节的值也就是捕获的外部变量 num 再和参数1相加。

可以看到第6行堆地址空间的值写入到 rbp -0x58 第26行又将 rbp -0x58 写入到 rdi29行将 rdi 的值,写入到 rbp - 0x4834行将 rbp - 0x48 写入到 rcx35行将 rcx 的地址值,写入到 rax 对应的存储空间。也就是堆上的 num 值已经修改,被覆盖了。

总结:当 getFn 内部没有发生闭包的时候fn1 的地址就是16 Byte前8 Byte就是 plus 的函数地址。当发生函数闭包的时候,fn1 的16 Byte前8 Byte 存储间接调用 plus 函数的中转函数后8 Byte 存储着捕获的且在堆上分配内存的地址值。真正调用 plus 的时候会通过寄存器传递2个参数1个是 fn1 函数的参数1个是堆空间的地址值。

var fn1 = getFn()
fn1(1) // 2
fn1(3) //4

因为只调用1次 getFn 所以堆内存分配了1个被捕获的变量地址调用过1次 fn1 后,堆地址所指向的内存上的数被修改了。当第二次调用的时候操作的还是同一块堆内存地址,也就是同一个被捕获的堆地址所指向的变量。

var fn1 = getFn()
fn1(1) // 2
fn1(3) //4
var fn2 = getFn()
fn2(2) // 3
fn2(4) // 5

因为调用了2次 getFn 所以堆内存分配了2个被捕获的变量地址调用过1次 fn1 后,堆地址所指向的内存上的数被修改了。当第二次调用的时候操作的还是同一块堆内存地址,也就是同一个被捕获的堆地址所指向的变量。当调用 fn2 的时候操作的是被捕获的新的一个堆地址空间所指向的变量。

且 fn1 所占用的16个字节fn2 所占用16个字节。2者的前8字节内容相同都是包装了 plus 函数的一个地址。

“闭包就是对象”

捕获了外部变量的闭包类似于一个类,里面存在存储属性和方法

class {
	var num: Int
	func fn(_ i: Int) -> Int {
   	return i + num 
  }
}

自动闭包

自动闭包是一种自动创建的,用来把作为实际参数传递给函数的表达式打包的闭包。它不接受任何实际参数,并且当它被调用时,它会返回内部打包的表达式的值

这个语法的好处在于通过写普通表达式代替显示闭包,而使你省略包围函数的形式参数的括号.

比如系统的断言 assert

public func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line)

// 语法糖。自动闭包
func getPositiveValue(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int {
    return v1 > 0 ? v1 : v2()
}

//print(getPositiveValue(10, {20}))
//print(getPositiveValue(-10) {20})
//print(getPositiveValue(-20) {
//    let a = 10
//    return a + 1
//}
//)

print(getPositiveValue(-10, 22))

@autoclosure 会自动将 22 封装成闭包 { 22 }

@autoclosure 只支持 () -> T 无参数,并且有一个返回值的闭包。

@autoclosure 并非只支持最后1个参数。

?? 函数的本质就是自动闭包

自动闭包允许延迟处理,因此任何闭包内部的代码直到你调用的时候才会运行。对于有副作用或者占用资源的代码来说很有用。因为它可以允许你控制代码何时进行求值

var group = ["zhangsan", "lisi", "wangwu"]
//print(group.count)
//let groupRemover = { group.remove(at: 0) }
//print(group.count)
//
////print("execute remove function \(groupRemover())")
//print(group.count)


func serve(customer customerProvider: @autoclosure () -> String) {
    print("Now serving \(customerProvider())!")
}
serve(customer: group.remove(at: 0)) 
// Now serving zhangsan! 

如果一个闭包作为参数,是可以去掉 {} 的,参数加了 @autoclosure 后,是会自动转换为闭包的。

但如果函数参数没有加 @autoclosure,在调用函数的时候,传参没有加 {} 编译器是会报错的

正确的做法是:要么加 @autoclosure 要么在函数调用的时候加 {}

// 改法1
func collectCustomerProviders(_ customerProvider: @escaping () -> String) {
    customerProoviders.append(customerProvider)
}
collectCustomerProviders( { group.remove(at: 0) })
// 改法2
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
    customerProoviders.append(customerProvider)
}
collectCustomerProviders(group.remove(at: 0))

如果你的自动闭包允许逃逸,就可以同时使用 @autoclosure@escaping

var customerProoviders: [() -> String] = []
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
    customerProoviders.append(customerProvider)
}
collectCustomerProviders(group.remove(at: 0))

闭包和闭包表达式的区别

闭包

定义:闭包是自包含的功能代码块,可以在代码中被传递和使用。它能捕获并存储其所在上下文的常量和变量(即“闭合”环境)

种类:

  • 全局函数(有名称,不捕获任何值)
  • 嵌套函数(有名称,可捕获外曾函数的变量)
  • 闭包表达式(匿名,轻量语法,可以捕获上下文变量)

闭包强调的是:捕获上下文的能力。不管是函数还是闭包表达式

闭包表达式

定义:是 Swift 中一种简洁的语法格式,用于编写匿名闭包。是闭包的一种实现方式之一(函数和闭包表达式都可以实现闭包)

特点:

  • 没有函数名
  • 语法清凉,支持参数类型推断、隐式返回、简写参数名(如 $0、$1等特性
  • 通常用于作为函数的参数传递

总结

  • 闭包是广义概念,包含所有能捕获上下文的代码块(如函数、闭包表达式)。
  • 闭包表达式是闭包的一种具体实现方式,专指用轻量语法编写的匿名闭包。