前言
本来打算这篇文章来讲讲动态库的内容,但是由于最近项目比较的忙,所以临时决定写一些关于Swift的内容,有兴趣的同学可以看看。 本文主要介绍为什么结构体是值类型,类是引用类型
值类型
前提:需要了解内存五大区,内存五大区可以参考这篇文章OC基础知识点之-内存管理初识(内存分区),主要如下图所示:
- 栈区的地址比堆区的地址大
- 栈是从
高地址->低地址,向下延伸,由系统自动管理,是一片连续的内存空间 - 堆是从
低地址->高地址,向上延伸,由开发者管理,堆空间结构类似于链表,是不连续的 - 日常开发中的
溢出是指堆栈溢出,可以理解为栈区与堆区边界碰撞的情况 全局区、常量区都存储在Mach-O中的__TEXT cString段 我们通过一个例子来引入什么是值类型从例子中可以得出,age存储在栈区
- 查看
age的内存情况,从图中可以看出,栈区直接存储的是值- 获取age的栈区地址:
po withUnsafePointer(to: &age){print($0)} - 查看age内存情况:
x/8gx 0x00007ffeefbff530
- 获取age的栈区地址:
- 查看
age1的情况,从下图中可以看出,age1的赋值相当于将age中的值拿出来,赋值给了age1。其中age与age1的地址相差了8字节,从这里可以说明栈空间是连续的、且是从高到低的所以从上面就可以看出age就是值类型
值类型特点
- 1.地址中存储的是
值 - 2.值类型的传递过程中,相当于
传递了一个副本,也就是所谓的深拷贝 - 3.
值传递过程中,并不共享状态
结构体
结构体的常用写法
在
结构体中,如果不给属性默认值,编译是不会报错的。即在结构体中属性可以赋值,也可以不赋值
为什么结构体是值类型
定义一个结构体,并进行分析
打印t:
po t,从下图中可以发现,t的打印直接就是值,没有任何与地址有关的信息
- 获取t的内存地址,并查看其内存情况
- 获取地址:
po withUnsafePointer(to: &t){print($0)} - 查看内存情况:
x/8gx 0x0000000100003050
- 获取地址:
问题:此时将t赋值给t1,如果修改了t1,t会发生改变吗?
- 直接打印t及t1,可以发现t并没有因为t1的改变而改变,主要是因为因为
t1和t之间是值传递,即t1和t是不同内存空间,是直接将t中的值拷贝至t1中。t1修改的内存空间,是不会影响t的内存空间的
SIL验证
同样的,我们也可以通过分析SIL来验证结构体是值类型
- 在
SIL文件中,我们查看结构体的初始化方法,可以发现只有init,而没有malloc,在其中看不到任何关于堆区的分配
总结
结构体是值类型,且结构体的地址就是第一个成员的内存地址- 值类型
- 在内存中直接
存储值 - 值类型的赋值,是一个
值传递的过程,即相当于拷贝了一个副本,存入不同的内存空间,两个空间彼此间并不共享状态 值传递其实就是深拷贝
- 在内存中直接
引用类型
类
- 类的常用写法
- 在类中,如果属性没有赋值,也不是可选项,编译会报错
- 需要自己实现
init方法
为什么类是引用类型?
定义一个类,通过一个例子来说明
类初始化的对象t,存储在全局区
- 打印
s、t,从图中可以看出,s内存空间中存放的是地址,t中存储的是值 - 获取s变量的地址,并查看其内存情况
- 获取
s指针地址:po withUnsafePointer(to: &s){print($0)} - 查看s全局区地址内存情况:
x/8gx 0x0000000100003240 - 查看s地址中存储的堆区地址内存情况:
x/8gx 0x0000000100677c70引用类型的特点
- 获取
- 1.地址中存储的是
堆区地址 - 2.
堆区地址中存储的是值
问题一:此时将t1赋值给t2,如果修改了t2,会导致t1修改吗?
通过
lldb调试得知,修改了t2,会导致t1改变,主要是因为t2、t1地址中都存储的是同一个堆区地址,如果修改,修改是同一个堆区地址,所以修改t2会导致t1一起修改,即浅拷贝
问题二:如果结构体中包含类对象,此时如果修改t1中的实例对象属性,t会改变吗?
从打印结果中可以看出,如果
修改t1中的实例对象属性,会导致t中实例对象属性的改变。虽然在结构体中是值传递,但是对于teacher,由于是引用类型,所以传递的依然是地址
同样可以通过lldb调试验证
- 打印t的地址:
po withUnsafePointer(to: &t){print($0)} - 打印t的内存情况:
x/8gx 0x0000000100008380 - 打印t中teacher地址的内存情况:
x/8gx 0x0000000106210be0注意:在编写代码过程中,应该尽量
避免值类型包含引用类型
查看当前的SIL文件,尽管LjTeacher是放在值类型中的,在传递的过程中,不管是传递还是赋值,teacher都是按照引用计数进行管理的
可以通过打印
student的引用计数来验证我们的说法,其中student的引用计数为3
主要是因为:
main中retain一次student.getter方法retain一次student.setter方法中retain一次
mutating
通过结构体定义一个栈,主要有push、pop方法,此时我们需要动态修改栈中的数组
- 如果是以下这种写法,会直接报错,原因是
值类型本身是不允许修改属性的 - 将push方法改成下面的方式,查看
SIL文件中的push函数
从图中可以看出,
push函数除了item,还有一个默认参数self,self是let类型,表示不允许修改
- 尝试1:如果将push函数修改成下面这样,可以添加进去吗?
打印结果如下
可以得出上面的代码并
不能将item添加进去,因为s是另一个结构体对象,相当于值拷贝,此时调用push是将item添加到s的数组中了 - 根据前文中的错误提示,给push添加
mutating,发现可以添加到数组了 - 查看其SIL文件,找到push函数,发现与之前有所不同,
push添加mutating(只用于值类型)后,本质上是给值类型函数添加了inout关键字,相当于在值传递的过程中,传递的是引用(即地址)
inout关键字
一般情况下,在函数的声明中,默认的参数都是不可变的,如果想要直接修改,需要给参数加上inout关键字
- 未加
inout关键字,给参数赋值,编译报错 - 添加
inout关键字,可以给参数赋值
总结
- 1.结构体中的函数如果想修改其中的属性,需要在函数前加上
mutating,而类则不用 - 2.
mutating本质也是加一个inout修饰的self - 3.
Inout相当于取地址,可以理解为地址传递,即引用 - 4.
mutating修饰方法,而inout修饰参数
总结
通过上述LLDB查看结构体 & 类的内存模型,有以下总结:
值类型,相当于一个本地文件,当我们通过网络传给你一个文件时,就相当于一个值类型,你修改了什么这边是不知道的引用类型,相当于一个在线文件,当我们和你共同编辑一个文件时,就相当于一个引用类型,两边都会看到修改的内容结构体中函数修改属性, 需要在函数前添加mutating关键字,本质是给函数的默认参数self添加了inout关键字,将self从let常量改成了var变量
方法调度
通过上面的分析,我们有以下疑问:结构体和类的方法存储在哪里?下面来一一进行分析
静态派发
值类型对象的函数的调用方式是静态调用,即直接地址调用,调用函数指针,这个函数指针在编译、链接完成后就已经确定了,存放在代码段,而结构体内部并不存放方法。因此可以直接通过地址直接调用
- 结构体函数调试如下所示
- 打开打开demo的
Mach-O可执行文件,其中的__text段,就是所谓的代码段,需要执行的汇编指令都在这里 - 对于上面的分析,还有个疑问:直接地址调用后面是
符号,这个符号哪里来的?是从
Mach-O文件中的符号表Symbol Tables,但是符号表中并不存储字符串,字符串存储在String Table(字符串表,存放了所有的变量名和函数名,以字符串形式存储),然后根据符号表中的偏移值到字符串中查找对应的字符,然后进行命名重整:工程名+类名+函数名,如下所示 Symbol Table:存储符号位于字符串表的位置Dynamic Symbol Table:动态库函数位于符号表的偏移信息 还可以通过终端命令nm,获取项目中的符号表- 查看符号表:
nm mach-o文件路径 - 通过命令还原符号名称:
xcrun swift-demangle 符号 - 如果将
edit scheme -> run中的debug改成release,编译后查看,在可执行文件目录下,多一个后缀为dSYM的文件,此时,再去Mach-O文件中查找teach,发现是找不到,其主要原因是因为静态链接的函数,实际上是不需要符号的,一旦编译完成,其地址确定后,当前的符号表就会删除当前函数对应的符号,在release环境下,符号表中存储的只是不能确定地址的符号 - 对于不能确定地址的符号,是在
运行时确定的,即函数第一次调用时(相当于懒加载),例如print,是通过dyld_stub_bind确定地址的
函数符号命名规则
- 对于
C函数来说,命名的重整规则就是在函数名之前加_(注意:C中不允许函数重载,因为没有办法区分) - 对于OC来说,也不支持函数重载,其符号命名规则是
-[类名 函数名] - 对于Swift来说,是
函数重载,主要是因为swift中的重整命名规则比较复杂,可以确保函数符号的唯一性
动态派发
汇编指令补充
blr:带返回的跳转指令,跳转到指令后边跟随寄存器中保存的地址mov:将某一寄存器的值复制到另一寄存器(只能用于寄存器与起存起或者 寄存器与常量之间 传值,不能用于内存地址)- mov x1, x0 将寄存器x0的值复制到寄存器x1中
ldr:将内存中的值读取到寄存器中- ldr x0, [x1, x2] 将寄存器x1和寄存器x2 相加作为地址,取该内存地址的值翻入寄存器x0中
str:将寄存器中的值写入到内存中- str x0, [x0, x8] 将寄存器x0的值保存到内存[x0 + x8]处
bl:跳转到某地址
探索class的调度方式
首先介绍下V_Table在SIL文件中的格式
// 声明sil vtable关键字
decl ::= sil-vtable
// sil vtable中包含 关键字、标识(即类名)、所有的方法
sil-vtable ::= 'sil_vtable' identifier '{' sil-vtable-entry* '}'
// 方法中包含了声明以及函数名称
sil-vtable-entry ::= sil-decl-ref ':' sil-linkage? sil-function-name
例如:以LjTeacher为例,其SIL中的v-tabler如下图所示
sil_vtable:关键字LjTeacher:表示是LjTeacher类的函数表- 其次就是当前方法的声明对应着方法的名称
- 函数表 可以理解为
数组,声明在 class内部的方法在不加任何关键字修饰的过程中,是连续存放在我们当前的地址空间中的。这一点,可以通过断点来印证 register read/x rax,此时的地址和实例对象的地址是相同的,其中rax实例对象地址,即首地址- 观察下面这几个方法的偏移地址,可以发现方法是连续存放的,正好对应
V-Table函数表中的排放顺序,即是按照定义顺序排放在函数表中
函数表源码探索
下面来进行函数表底层的源码探索
- 源码中搜索
initClassVTable,并加上断点,然后写上源码进行调试
其内部是通过
for循环编码,然后offset+index偏移,然后获取method,将其存入到偏移后的内存中,从这里可以印证函数是连续存放的
对于class中函数来说,类的方法调度是通过V-Taable,其本质就是一个连续的内存空间(数组结构)。
问题:如果更改方法声明的位置呢?例如extension中的函数,此时的函数调度方式还是函数表调度吗?
通过以下代码验证
- 定义一个
LjTeacher的extension - 在定义一个子类
LjYoungTeacher继承自LjTeacher,查看SIL中的V-Table - 查看
SIL文件,发现子类只继承了class中定义的函数,即函数表中的函数
其原因是因为
子类将父类的函数表全部继承了,如果此时子类增加函数,会继续在连续的地址中插入,假设extension函数也是在函数表中,则意味着子类也有,但是子类无法并没有相关的指针记录函数 是父类方法 还是 子类方法,所以不知道方法该从哪里插入,导致extension中的函数无法安全的放入子类中。所以在这里可以侧面证明extension中的方法是直接调用的,且只属于类,子类是无法继承的
开发注意点
- 继承方法和属性,
不能写extension中。 - 而
extension中创建的函数,一定是只属于自己类,但是其子类也有其访问权限,只是不能继承和重写,如下所示
final、@objc、dynamic修饰函数
final 修饰
final修饰的方法是直接调度的,可以通过SIL验证 + 断点验证
@objc 修饰
使用@objc关键字是将swift中的方法暴露给OC
通过SIL+断点调试,发现
@objc修饰的方法是函数表调度
【小技巧】:
混编头文件查看方式:查看项目名-Swift.h头文件
- 如果只是通过@objc修饰函数,OC还是无法调用swift方法的,因此如果想要
OC访问swift,class需要继承NSObject
dynamic 修饰
以下面代码为例,查看dynamic修饰的函数的调度方式
其中teach函数的调度还是
函数表调度,可以通过断点调试验证,使用dynamic的意思是可以动态修改,意味着当类继承自NSObject时,可以使用method-swizzling
@objc + dynamic
通过断点调试,走的是
objc_msgSend流程,即动态消息转发
场景:swift中实现方法交换
- 在swift中的需要交换的函数前,使用
dynamic修饰,然后通过:@_dynamicReplacement(for: 函数符号)进行交换,如下所示
将teach方法替换成了teach5
- 如果teach没有实现/如果去掉
dynamic修饰符,会报错
总结
struct是值类型,其中函数的调度属于直接调用地址,即静态调度class是引用类型,其中函数的调度是通过V-Table函数表来进行调度的,即动态调度extension中的函数调度方式是直接调度final修饰的函数调度方式是直接调度@objc修饰的函数调度方式是函数表调度,如果OC中需要使用,class还必须继承NSObjectdynamic修饰的函数的调度方式是函数表调度,使函数具有动态性@objc + dynamic组合修饰的函数调度,是执行的是objc_msgSend流程,即动态消息转发
写到最后
下篇文章继续写高阶进阶系列,介绍动态库的内容,后面会根据项目来讲下动态库合并等内容,Swift文章会作为项目忙的时候的一个补充,因为Swift没有那么多复杂的操作,也不用写shell语言!欢迎大家留言,也希望大家点赞多多支持。希望大家能够相互交流、探索,一起进步