本文主要介绍为什么结构体是值类型,类是引用类型。
值类型
前提:需要了解内存五大区,内存五大区可以参考这篇文章iOS底层原理:内存五大区,如下所示
- 栈区的地址 比 堆区的地址 大
-
栈
是从高地址->低地址
,向下延伸,由系统
自动管理,是一片连续
的内存空间 -
堆
是从低地址->高地址
,向上延伸,由程序员
管理,堆空间结构类似于链表
,是不连续
的 - 日常开发中的溢出是指
堆栈溢出
,可以理解为栈区与堆区边界碰撞
的情况 -
全局区、常量区
都存储在Mach-O
中的_ _TEXT cString
段
我们通过一个例子来引入什么是值类型
func test(){
// 栈区声明一个地址,用来存储age变量
var age = 18
// 传递的值
var age2 = age
// age、age2是修改独立内存中的值
age = 30
age2 = 45
print("age=\(age),age2=\(age2)")
}
test()
从例子中可以得出,age存储在栈区
- 查看
age
的内存情况,从图中可以看出,栈区直接存储
的是值
- 获取age的栈区地址:
po withUnsafePointer(to: &age){print($0)}
- 查看age内存情况:
x/8g 0x00007ffeefbff3e0
- 获取age的栈区地址:
- 查看
age2
的情况,从下图中可以看出,age2
的赋值相当于将age
中的值拿出来,赋值给了age2
。其中age
与age2
的地址相差了8字节
,从这里可以说明栈空间是连续的
、且是从高到低的
所以,从上面可以说明,age就是值类型
值类型 特点
- 1、地址中存储的是
值
- 2、值类型的传递过程中,相当于
传递
了一个副本
,也就是所谓的深拷贝
- 3、值传递过程中,并
不共享
状态
结构体
结构体的常用写法
//***** 写法一 *****
struct SunriseTeacher {
var age: Int = 18
func teach(){
print("teach")
}
}
let t = SunriseTeacher()
//***** 写法二 *****
struct SunriseTeacher {
var age: Int
func teach(){
print("teach")
}
}
let t = SunriseTeacher(age: 18)
- 在结构体中,如果不给属性默认值,编译是不会报错的。即在结构体中属性可以赋值,也可以不赋值
-
init
方法可以重写,也可以使用系统默认的
结构体的SIL分析
- 如果
没有init
,系统会提供不同的默认初始化方法
- 如果
提供了自定义的init
,就只有自定义的
为什么结构体是值类型?
定义一个结构体,并进行分析
- 获取t的内存地址,并查看其内存情况
- 获取地址:
po withUnsafePointer(to: &t){print($0)}
- 查看内存情况:
x/8g 0x0000000100008028
- 获取地址:
问题:此时将t赋值给t1,如果修改了t1,t会发生改变吗?
- 直接打印t及t1,可以发现t并没有因为t1的改变而改变,主要是因为
t1
和t
之间是值传递
,即t1和t是不同内存空间,是直接将t
中的值拷贝至t1
中。t1
修改的内存空间,是不会影响t
的内存空间的
SIL验证
同样的,我们也可以通过分析SIL来验证结构体是值类型
- 在SIL文件中,我们查看结构体的初始化方法,可以发现
只有init
,而没有malloc
,在其中看不到任何关于堆区的分配
总结
-
结构体是值类型
,且结构体的地址就是第一个成员的内存地址 - 值类型
- 在内存中直接
存储值
- 值类型的赋值,是一个
值传递
的过程,即相当于拷贝了一个副本,存入不同的内存空间,两个空间彼此间并不共享状态
-
值传递
其实就是深拷贝
- 在内存中直接
引用类型
类
类的常用写法
//****** 写法一 *******
class SunriseTeacher {
var age: Int = 18
func teach(){
print("teach")
}
init(_ age: Int) {
self.age = age
}
}
var t = SunriseTeacher.init(20)
//****** 写法二 *******
class SunriseTeacher {
var age: Int?
func teach(){
print("teach")
}
init(_ age: Int) {
self.age = age
}
}
var t = SunriseTeacher.init(20)
- 在类中,如果属性没有赋值,也不是可选项,编译会报错
- 需要自己实现
init
方法
为什么类是引用类型?
定义一个类,通过一个例子来说明
class SunriseTeacher1 {
var age: Int = 18
var age2: Int = 20
}
var t1 = SunriseTeacher1()
类初始化的对象t1,存储在全局区
- 打印t1、t:
po t1
,从图中可以看出,t1
内存空间中存放的是地址
,t中存储的是值
- 获取t1变量的地址,并查看其内存情况
- 获取
t1
指针地址:po withUnsafePointer(to: &t1){print($0)}
- 查看
t1
全局区地址内存情况:x/8g 0x0000000100008218
- 查看
t1
地址中存储的堆区地址内存情况:x/8g 0x0000000100504ad0
- 获取
引用类型 特点
- 1、地址中存储的是
堆区地址
- 2、
堆区地址
中存储的是值
问题1:此时将t1赋值给t2,如果修改了t2,会导致t1修改吗?
- 通过
lldb
调试得知,修改了t2
,会导致t1改变
,主要是因为t2、t1
地址中都存储的是同一个堆区地址
,如果修改,修改是同一个堆区地址,所以修改t2会导致t1一起修改,即浅拷贝
问题2:如果结构体中包含类对象,此时如果修改t1中的实例对象属性,t会改变吗?
class SunriseTeacher1 {
var age: Int = 18
var age2: Int = 20
}
struct SunriseTeacher {
var age: Int = 18
var age2: Int = 20
var teacher: SunriseTeacher1 = SunriseTeacher1()
}
var t = SunriseTeacher()
var t1 = t
t1.teacher.age = 30
print("t.teacher.age = \(t.teacher.age) \nt1.teacher.age = \(t1.teacher.age)")
//打印结果如下
t.teacher.age = 30
t1.teacher.age = 30
从打印结果中可以看出,如果修改t1中的实例对象属性,会导致t中实例对象属性的改变。虽然在结构体中是值传递
,但是对于teacher,由于是引用类型
,所以传递
的依然是地址
同样可以通过lldb调试验证
- 打印t的地址:
po withUnsafePointer(to: &t){print($0)}
- 打印t的内存情况:
x/8g 0x0000000100008238
- 打印t中teacher地址的内存情况:
x/8g 0x0000000101805440
注意:在编写代码过程中,应该尽量
避免值类型包含引用类型
查看当前的SIL
文件,尽管SunriseTeacher1
是放在值类型中的,在传递的过程中,不管是传递还是赋值,teacher
都是按照引用计数
进行管理的
可以通过打印teacher的引用计数来验证我们的说法,其中teacher的引用计数为3
主要是是因为:
-
main
中retain
一次 -
teacher.getter
方法中retain
一次 -
teacher.setter
方法中retain
一次
mutating
通过结构体
定义一个栈
,主要有push、pop方法,此时我们需要动态修改栈中的数组
- 如果是以下这种写法,会直接报错,原因是
值类型本身是不允许修改属性
的
- 将push方法改成下面的方式,查看
SIL
文件中的push
函数
struct SunriseStack {
var items: [Int] = []
func push(_ item: Int) {
items.append(item)
}
}
从图中可以看出,push
函数除了item
,还有一个默认
参数self
,self
是let
类型,表示不允许
修改
struct SunriseStack {
var items: [Int] = []
func push(_ item: Int) {
var s = self
s.items.append(item)
}
}
var s = SunriseStack()
s.push(1)
print(s.items)
// ****打印结果如下****
[]
可以得出上面的代码并不能
将item添加进去,因为s是另一个结构体对象,相当于值拷贝
,此时调用push
是将item添加到s的数组中了
- 根据前文中的错误提示,给push添加
mutating
,发现可以添加到数组了
struct SunriseStack {
var items: [Int] = []
mutating func push(_ item: Int) {
items.append(item)
}
}
var s = SunriseStack()
s.push(1)
print(s.items)
// ****打印结果如下****
[1]
查看其SIL文件,找到push函数,发现与之前有所不同,push
添加mutating
(只用于值类型)后,本质上是给值类型
函数添加了inout
关键字,相当于在值传递的过程中,传递
的是引用
(即地址)
inout关键字
一般情况下,在函数的声明中,默认的参数都是不可变的
,如果想要直接修改,需要给参数加上inout
关键字
- 未加
inout
关键字,给参数赋值,编译报错
- 添加
inout
关键字,可以给参数赋值
总结
- 1、结构体中的函数如果想修改其中的属性,需要在函数前加上
mutating
,而类则不用 - 2、
mutating
本质也是加一个inout修饰的self
- 3、
Inout
相当于取地址
,可以理解为地址传递
,即引用
- 4、
mutating
修饰方法
,而inout
修饰参数
总结
通过上述LLDB
查看结构体 & 类的内存模型,有以下总结:
-
值类型
,相当于一个本地excel
,当我们通过QQ传给你一个excel时,就相当于一个值类型,你修改了什么我们这边是不知道的 -
引用类型
,相当于一个在线表格
,当我们和你共同编辑一个在先表格时,就相当于一个引用类型,两边都会看到修改的内容 -
结构体
中函数修改属性
, 需要在函数前添加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
确定地址的(这个在最新版的12.2中通过断点调试并未找到,后续待继续验证)
函数符号命名规则
- 对于
C函数
来说,命名的重整规则就是在函数名之前加_
(注意:C中不允许函数重载,因为没有办法区分)
#include <stdio.h>
void test(){ }
- 对于OC来说,也不支持函数重载,其符号命名规则是
-[类名 函数名]
- 对于Swift来说,是云溪函数重载,主要是因为swift中的重整命名规则比较复杂,可以确保函数符号的唯一性
ASLR
- 通过运行发现,Mach-O中的地址与调试时直接获取的地址是有一定偏差的,其主要原因是实际调用时地址多了一个
ASLR
(地址空间布局随机化 address space layout randomizes)
- 可以通过
image list
查看,其中0x0000000100000000
是程序运行的首地址,后8位是随机偏移00000000
(即ASLR)
- 将Mach-O中的文件地址
0x0000000100003D80 + 0x00000000 = 0x100003D80
,正好对应上面调用的地址
动态派发
汇编指令补充
-
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中包含 关键字、标识(即类名)、所有的方法
2 sil-vtable ::= 'sil_vtable' identifier '{' sil-vtable-entry* '}'
// 方法中包含了声明以及函数名称
3 sil-vtable-entry ::= sil-decl-ref ':' sil-linkage? sil-function-na
me
例如,以SunriseTacher为例,其SIL中的v-table如下所示
class SunriseTeacher{
func teach(){}
func teach2(){}
func teach3(){}
func teach4(){}
@objc deinit{}
init(){}
}
-
sil_vtable
:关键字 -
SunriseTeacher
:表示是SunriseTeacher类的函数表 - 其次就是当前方法的声明对应着方法的名称
- 函数表 可以理解为
数组
,声明在 class内部的方法在不加任何关键字修饰的过程中,是连续存放
在我们当前的地址空间中的。这一点,可以通过断点来印证
-
register read x0
,此时的地址和实例对象的地址是相同
的,其中x8
实例对象地址,即首地址
观察这几个方法的偏移地址,可以发现方法是连续存放的,正好对应V-Table
函数表中的排放顺序
,即是按照定义顺序排放在函数表中
函数表源码探索
下面来进行函数表底层
的源码探索
- 源码中搜索
initClassVTable
,并加上断点,然后写上源码进行调试
其内部是通过for循环
编码,然后offset+index
偏移,然后获取method
,将其存入到偏移后的内存中,从这里可以印证函数是连续存放的
对于class中函数来说,类的方法调度是通过V-Table
,其本质就是一个连续的内存空间(数组结构)。
问题:如果更改方法声明的位置呢?例如extension中的函数,此时的函数调度方式还是函数表调度吗
通过以下代码验证
- 定义一个SunriseTeacher的extension
extension SunriseTeacher{
func teach5(){ print("teach5") }
}
- 在定义一个子类
SunriseStudent
继承自SunriseTeacher
,查看SIL
中的V-Table
class SunriseStudent: SunriseTeacher{}
其原因是因为子类将父类的函数表全部继承
了,如果此时子类增加函数,会继续在连续的地址中插入,假设extension函数也是在函数表中
,则意味着子类也有,但是子类并没有相关的指针记录函数 是父类方法
还是子类方法
,所以不知道方法该从哪里插入
,导致extension中的函数无法安全的放入子类中。所以在这里可以侧面证明extension中的方法是直接调用的,且只属于类,子类是无法继承的
开发注意点:
- 继承方法和属性,不能写extension中。
- 而extension中创建的函数,一定是只属于自己类,但是其
子类也有其访问权限
,只是不能继承和重写
,如下所示
class SunriseTeacher{}
extension SunriseTeacher{
var age: Int{
get{
return 18
}
}
func teach(){
print("teach")
}
}
class SunriseMiddleTeacher: SunriseTeacher{
func study() {
print("SunriseMiddleTeacher study")
}
}
var t = SunriseMiddleTeacher()
//子类有父类extension中方法的访问权限,只是不能继承和重写
t.teach()
t.study()
print(t.age)
// <!--运行结果-->
teach
SunriseMiddleTeacher study
18
final、@objc、dynamic修饰函数
final 修饰
-
final
修饰的方法是直接调度
的,可以通过SIL验证 + 断点验证
class CJLTeacher {
final func teach(){ print("teach") }
func teach2(){ print("teach2") }
func teach3(){ print("teach3") }
func teach4(){ print("teach4") }
@objc deinit{}
init(){}
}
@objc 修饰
使用@objc
关键字是将swift
中的方法暴露给OC
class CJLTeacher{
@objc func teach(){ print("teach") }
func teach2(){ print("teach2") }
func teach3(){ print("teach3") }
func teach4(){ print("teach4") }
@objc deinit{}
init(){}
}
通过SIL+断点调试,发现@objc
修饰的方法是 函数表调度
【小技巧】:混编
头文件查看方式:查看项目名-Swift.h
头文件
- 如果只是通过@objc修饰函数,OC还是无法调用swift方法的,因此如果想要
OC访问swift
,class需要继承NSObject
<!--swift类-->
class CJLTeacher: NSObject {
@objc func teach(){ print("teach") }
func teach2(){ print("teach2") }
func teach3(){ print("teach3") }
func teach4(){ print("teach4") }
@objc deinit{}
override init(){}
}
<!--桥接文件中的声明-->
SWIFT_CLASS("_TtC9_3_指针10CJLTeacher")
@interface CJLTeacher : NSObject
- (void)teach;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end
<!--OC调用-->
//1、导入swift头文件
#import "CJLOCTest-Swift.h"
//2、调用
CJLTeacher *t = [[CJLTeacher alloc] init];
[t teach];
查看SIL
文件发现被@objc
修饰的函数声明有两个:swift + OC(内部调用的swift中的teach函数)
即在SIL文件中生成了两个方法
- swift原有的函数
- @objc标记暴露给OC来使用的函数: 内部调用swift的
dynamic 修饰
以下面代码为例,查看dynamic修饰的函数的调度方式
class WRSTeacher: NSObject {
dynamic func teach(){ print("teach") }
func teach2(){ print("teach2") }
func teach3(){ print("teach3") }
func teach4(){ print("teach4") }
@objc deinit{}
override init(){}
}
其中teach函数的调度还是 函数表调度
,可以通过断点调试验证,使用dynamic
的意思是可以动态修改
,意味着当类继承自NSObject时,可以使用method-swizzling
场景:swift中实现方法交换
在swift中的需要交换的函数前,使用dynamic修饰,然后通过:@_dynamicReplacement(for: 函数符号)
进行交换,如下所示
class WRSTeacher: NSObject {
dynamic func teach(){ print("teach") }
func teach2(){ print("teach2") }
func teach3(){ print("teach3") }
func teach4(){ print("teach4") }
@objc deinit{}
override init(){}
}
extension WRSTeacher{
@_dynamicReplacement(for: teach)
func teach5(){
print("teach5")
}
}
let t = WRSTeacher()
t.teach()
t.teach2()
t.teach3()
t.teach4()
print("end")
将teach方法替换成了teach5
- 如果teach没有实现 / 如果去掉
dynamic
修饰符,会报错
总结
-
struct
是值
类型,其中函数的调度属于直接调用地址
,即静态调度
-
class
是引用
类型,其中函数的调度是通过V-Table函数表
来进行调度的,即动态调度
-
extension
中的函数调度方式是直接调度
-
final
修饰的函数调度方式是直接调度
-
@objc
修饰的函数调度方式是函数表调度
,如果OC中需要使用,class还必须继承NSObject
-
dynamic
修饰的函数的调度方式是函数表调度
,使函数具有动态性
-
@objc + dynamic
组合修饰的函数调度,执行的是objc_msgSend
流程,即动态消息转发
补充:内存插件
主要补充内存插件libfooplugin.dylib
安装及使用
- 在跟目下创建.lldbinit文件
vim /.lldbinit
- 然后输入
plugin load libfooplugin.dylib路径
- 使用:在lldb 调试中输入 --
cat address 地址
可以在这里下载插件文件,密码: ssge
内存分区实践
堆区
有以下代码,通过cat查看t属于哪个区
class SunriseTeacher{
func teach(){
}
}
let t = SunriseTeacher()
print("end")
从结果中可以看出,是在堆区
,即heap pointer
栈区
查看以下代码的内存地址位于哪个区?
func test(){
var age: Int = 10
print(age)
}
test()
从结果来看,位于栈区
,即stack pointer
全局区
对于C的分析
下面是C语言的部分代码,查看其变量的内存地址
// 全局已初始化变量
int a = 10;
// 全局未初始化变量
int age;
// 全局静态变量
static int age2 = 30;
int main(int argc, const char * argv[]) {
char *p = "SunriseTeacher";
printf("%d\n", a);
printf("%d\n", age2);
return 0;
}
- 查看a(全局已初始化变量)的内存地址
其中__DATA.__data
表示segment.section
,这里的位置和全局区并不冲突,因为一个是人为的内存分配(内存布局分区)
,一个是Mach-O的segment.section
段中,是文件的格式划分
- 查看age(全局未初始化变量)的内存地址
age在Mach-O文件中,放在了__DATA.__common
段,主要放的就是未初始化的符号声明(mach-o相比内存划分更细,主要是为了更好的定位符号),当然此时的age
在内存中依然在全局区
- 查看
age2(全局已初始化静态变量
)的内存地址(其中需要注意:age2必须使用才能找到,否则会报错)
- 观察3个变量的地址,其地址都是
相邻的
,因为在内存中都放在了全局区
,观察其内存地址,可以发现,在全局区
中,未初始化变量地址
比已初始化变量地址
高
- 如果定义了一个
char *p = "SunriseTeacher"
,查看*p
,存储在__TEXT.cstring
段,内存中存储在常量区
- 如果是
const
修饰的变量呢?存放在Mach-O文件中的__TEXT.__const
段
- 如果使用
static + const
修饰变量,此时变量在哪?
static const int age3 = 40;
- 查看
age3
的内存地址,地址特别大,而且使用ca
t查看不了,因为Mach-O
没有记录,age3
就是30,即使用static+const
修饰的变量就相当于直接替换
对于swift的分析
let age = 10
由于是不可变,所以不能通过po+cat
查看内存,通过汇编 首地址+偏移
来获取age的内存,发现是在Mach-O的__DATA.__common
段
从这里可以发现,这与C中是有所区别的。swift的不同之处
:已经初始化
的全局变量放在__DATA.__common
段,猜测是因为 age
开始是被标记为未初始化
的,当我们执行代码之后才将10存储到对应的内存地址
中
- 如果是
var
修饰的变量呢?可以发现与let
是一致的,还是__DATA.__common
段
var age2 = 10
总结
对于C语言中全局变量,根据是否已经初始化,存储在Mach-O中存储位置是不同的
-
已初始化
的全局变量:__DATA.__data
-
未初始化
的全局变量:__DATA.__common
-
已初始化
的全局静态
变量,即static
修饰:__DATA.__data
- 对于
char *p
类型的字符:__TEXT.cstring
-
const
修饰的全局变量:__TEXT.__const
-
static+const
修饰的全局变量:Mach-O中没有记录