系列文章:OC底层原理系列,OC基础知识系列,Swift底层探索系列
前言
继续学习Swift,这篇文章主要探究以下内容:
通SIL来理解对象的创建过程Swift类结构分析存储属性 & 计算属性延迟存储属性 & 单例创建
Swift对象的创建过程
Swift编译
在介绍SIL之前,我们先来看下Swift的编译,我们看下图代码:
我们创建了一个LjTeacher的类,并通过
默认的初始化方法,创建了一个实例对象给teacher
我们要研究的就是这个默认的初始化方法到底在底层做了些什么?于是我们就引入了SIL(Swift intermediate language)
SIL
iOS开发的语言不管是OC还是Swift,在底层是通过不同的编译器进行编译,然后再通过LLVM,生成.o可执行文件,如下图所示
OC通过clang编译器,编译成IR,然后再生成可执行文件.O(也就是我们的机器码)Swift则是通过Swift编译器编译成IR,然后再生成可执行文件.O下面是一个Swift文件的编译过程
其中
SIL(Swift Intermediate Language),是Swift编译过程中的中间代码,主要用于进一步分析和优化Swift代码。SIL位于在Sema和LLVM IR之间
注意:这里需要说明一下,Swift与OC的区别在于Swift生成了高级的SIL
我们可以通过swiftc -h终端命令,查看swiftc的所有命令
例如:在main.swift文件定义如下代码
- 使用抽象语法树:
swiftc -dump-ast main.swift - 生成SIL文件:
swiftc -emit-sil main.swift >> ./main.sil && code main.sil,其中main的入口函数如下
// main
//`@main`:标识当前main.swift的`入口函数`,SIL中的标识符名称以`@`作为前缀
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
//`%0、%1` 在SIL中叫做寄存器,可以理解为开发中的常量,一旦赋值就不可修改,如果还想继续使用,就需要不断的累加数字(注意:这里的寄存器,与`register read`中的寄存器是有所区别的,这里是指`虚拟寄存器`,而`register read`中是`真寄存器`)
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
//`alloc_global`:创建一个`全局变量`,即代码中的`t`
alloc_global @$s4main1tAA10LjTeacherCvp // id: %2
//`global_addr`:获取全局变量地址,并赋值给寄存器%3
%3 = global_addr @$s4main1tAA10LjTeacherCvp : $*LjTeacher // user: %7
//`metatype`获取`LjTeacher`的`MetaData`赋值给%4
%4 = metatype $@thick LjTeacher.Type // user: %6
//将`__allocating_init`的函数地址赋值给 %5
// function_ref LjTeacher.__allocating_init()
%5 = function_ref @$s4main10LjTeacherCACycfC : $@convention(method) (@thick LjTeacher.Type) -> @owned LjTeacher // user: %6
//`apply`调用 `__allocating_init` 初始化一个变量,赋值给%6
%6 = apply %5(%4) : $@convention(method) (@thick LjTeacher.Type) -> @owned LjTeacher // user: %7
//将%6的值存储到%3,即全局变量的地址(这里与前面的%3形成一个闭环)
store %6 to %3 : $*LjTeacher // id: %7
//构建`Int`,并`return`
%8 = integer_literal $Builtin.Int32, 0 // user: %9
%9 = struct $Int32 (%8 : $Builtin.Int32) // user: %10
return %9 : $Int32 // id: %10
} // end sil function 'main'
【注意】:code命令是在.zshrc中做了如下配置,可以在终端中指定软件打开相应文件
$ open .zshrc
//****** 添加以下别名
alias subl='/Applications/SublimeText.app/Contents/SharedSupport/bin/subl'
alias code='/Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin/code'
//****** 使用
$ code main.sil
//如果想SIL文件高亮,需要安装插件:VSCode SIL
- 从SIL文件中,可以看出,代码是经过混淆的,可以通过以下命令还原,以
s4main1tAA10LjTeacherCvp为例:xcrun swift-demangle s4main1tAA10LjTeacherCvp - 在SIL文件中搜索
s4main10LjTeacherCACycfC,其内部实现主要是分配内存+初始化变量allocing_ref:创建一个LjTeacher的实例对象,当前实例对象的引用计数为1调用init方法
//********* main入口函数中代码 *********
%5 = function_ref @$s4main10LjTeacherCACycfC : $@convention(method) (@thick LjTeacher.Type) -> @owned LjTeacher
// s4main10LjTeacherCACycfC 实际就是__allocating_init()
// LjTeacher.__allocating_init()
sil hidden [exact_self_class] @$s4main10LjTeacherCACycfC : $@convention(method) (@thick LjTeacher.Type) -> @owned LjTeacher {
// %0 "$metatype"
bb0(%0 : $@thick LjTeacher.Type):
// 堆上分配内存空间
%1 = alloc_ref $LjTeacher // user: %3
// function_ref LjTeacher.init() 初始化当前变量
%2 = function_ref @$s4main10LjTeacherCACycfc : $@convention(method) (@owned LjTeacher) -> @owned LjTeacher // user: %3
// 返回
%3 = apply %2(%1) : $@convention(method) (@owned LjTeacher) -> @owned LjTeacher // user: %4
return %3 : $LjTeacher // id: %4
} // end sil function '$s4main10LjTeacherCACycfC'
SIL语言对于Swift源码的分析是非常重要的,关于其更多的语法信息,可以在此处进行查询
符号断点调试
- 在demo中设置
_allocing_init符号断点发现其内部调用的是
swift_allocObject
源码调试
下面我们就通过swift_allocObject来探索swift中对象的创建过程
- 在
REPL(命令交互行,类似于python的,可以在这里编写代码)中编写如下代码(也可以拷贝),并搜索swift_allocObject函数加一个断点,然后定义一个实例对象t - 断点断住,查看左边local有详细的信息
- 其中
requiredSize是分配的实际内存大小,为40 requiredAlignmentMask是swift中的字节对齐方式,这个和OC中是一样的,必须是8的倍数,不足的会自动补齐,目的是以空间换时间,来提高内存操作效率
swift_allocObject 源码分析
swift_allocObject的源码如下,主要分为一下几部分:
- 通过
swift_slowAlloc分配内存,并进行内存字节对齐 - 通过
new + HeapObject + metadata初始化一个实例对象 - 函数的返回值是
HeapObject类型,所以当前对象的内存结构就是HeapObject的内存结构 - 进入
swift_slowAlloc函数其内部主要是通过malloc在堆中分配size大小的内存空间,并返回内存地址,主要是用于存储实例变量 - 进入HeapObject初始化方法,需要两个参数:metadata、refCounts
- 其中
metadata类型是HeapMetadata,是一个指针类型,占8字节 refCounts(引用计数,类型是InlineRefCounts,而InlineRefCounts是一个类RefCounts的别名,占8个字节),swift采用arc引用计数
总结
- 对于实例对象
t来说,其本质是一个HeapObject结构体,默认16字节内存大小(metadata8字节 +refCounts8字节),与OC的对比如下OC中实例对象的本质是结构体,是以objc_object为模板继承的,其中有一个isa指针,占8字节Swift中实例对象,默认的比OC中多了一个refCounted引用计数大小,默认属性占16字节
- Swift中对象的内存分配流程是:
__allocating_init --> swift_allocObject_ --> _swift_allocObject --> swift_slowAlloc --> malloc init在其中的职责就是初始化变量,这点与OC中是一致的 针对上面的分析,我们还遗留了两个问题:metadata是什么,40是怎么计算的?下面来继续探索
在demo中,我们可以通过Runtime方法获取类的内存大小
这点与在源码调试时左边
local的requiredSize值是相等的,从HeapObject的分析中我们知道了,一个类在没有任何属性的情况下,默认占用16字节大小
对于Int、String类型,进入其底层定义,两个都是结构体类型,那么是否都是8字节呢?可以通过打印其内存大小来验证
//********* Int底层定义 *********
@frozen public struct Int : FixedWidthInteger, SignedInteger {...}
//********* String底层定义 *********
@frozen public struct String {...}
//********* 验证 *********
print(MemoryLayout<Int>.stride)
print(MemoryLayout<String>.stride)
//********* 打印结果 *********
8
16
从打印的结果中可以看出,
Int类型占8字节,String类型占16字节(后面文章会进行详细讲解),这点与OC中是有所区别的
所以这也解释了为什么LjTeacher的内存大小等于40,即40 = metadata(8字节) +refCount(8字节)+ Int(8字节)+ String(16字节)
这里验证了40的来源,但是metadata是什么还不知道,继续往下分析
探索Swift中类的结构
我们知道在OC中类是从objc_class模板继承过来的,可参照文章OC底层原理之-类的结构
但是在Swift中,类的结构在底层是HeapObject,其中有metadata + refCounts
HeapMetadata类型分析
我们下面看下metadata,看看它是什么!
- 进入
HeapMetadata定义,是TargetHeapMetaData类型的别名,接收了一个参数Inprocess - 进入
TargetHeapMetaData定义,其本质是一个模板类型,其中定义了一些所需的数据结构。这个结构体中没有属性,只有初始化方法,传入了一个MetadataKind类型的参数(该结构体没有,那么只有在父类中了)这里的kind就是传入的Inprocess - 进入
TargetMetaData定义,有一个kind属性,kind的类型就是之前传入的Inprocess。从这里可以得出,对于kind,其类型就是unsigned long,主要用于区分是哪种类型的元数据
从
TargetHeapMetadata、TargetMetaData定义中,均可以看出初始化方法中参数kind的类型是MetadataKind
- 进入
MetadataKind定义,里面有一个#include "MetadataKind.def",点击进入,其中记录了所有类型的元数据,所有kind种类总结如下 | name | value | |------------|------------| |Class|0x0| |Struct|0x200| |Enum|0x201| |Optional|0x202| |ForeignClass|0x203| |Opaque|0x300| |Tuple|0x301| |Function|0x302| |Existential|0x303| |Metatype|0x304| |ObjCClassWrapper|0x305| |ExistentialMetatype|0x306| |HeapLocalVariable|0x400| |HeapGenericLocalVariable|0x500| |ErrorObject|0x501| |LastEnumerated|0x7FF| - 回到
TargetMetaData结构体定义中,找方法getClassObject,在该方法中去匹配kind返回值是TargetClassMetadata类型- 如果是
Class,则直接对this(当前指针,即metadata)强转为ClassMetadata这一点,我们可以通过
lldb来验证
- 如果是
po metadata->getKind(),得到其kind是Classpo metadata->getClassObject()、x/8g 0x0000000110efdc70,这个地址中存储的是元数据信息所以,
TargetMetadata和TargetClassMetadata本质上是一样的,因为在内存结构中,可以直接进行指针的转换,可以说,我们认为的结构体,其实就是TargetClassMetadata- 进入
TargetClassMetadata定义,继承自TargetAnyClassMetadata,有以下这些属性,这也是类结构的部分 - 进入
TargetAnyClassMetadata定义,继承自TargetHeapMetadata
总结
综上所述,当metadata的kind为Class时,有如下继承链:
- 当前类返回的实际类型是
TargetClassMetadata,而TargetMetaData中只有一个属性kind,TargetAnyClassMetaData中有4个属性,分别是kind,uperclass,cacheData、data(图中未标出) - 当前
Class在内存中所存放的属性由TargetClassMetadata属性 +TargetAnyClassMetaData属性 +TargetMetaData属性构成,所以得出的metadata的数据结构体如下所示
struct swift_class_t: NSObject{
void *kind;//相当于OC中的isa,kind的实际类型是unsigned long
void *superClass;
void *cacheData;
void *data;
uint32_t flags; //4字节
uint32_t instanceAddressOffset;//4字节
uint32_t instanceSize;//4字节
uint16_t instanceAlignMask;//2字节
uint16_t reserved;//2字节
uint32_t classSize;//4字节
uint32_t classAddressOffset;//4字节
void *description;
...
}
与OC对比
实例对象&类OC中的实例对象本质是结构体,是通过底层的objc_object模板创建,类是继承自objc_classSwift中的实例对象本质也是结构体,类型是HeapObject,比OC多了一个refCounts
- 方法列表
OC中的方法存储在objc_class结构体class_rw_t的methodList中swift中的方法存储在metadata元数据中
- 引用计数
- OC中的ARC维护的是
散列表 - Swift中的ARC是对象内部有一个
refCounts属性
- OC中的ARC维护的是
Swift属性
在swift中,属性主要分为以下几种
- 1.
存储属性 - 2.
计算属性 - 3.
延迟存储属性 - 4.
类型属性
存储属性
存储属性,又分两种
- 1.要么是
常量存储属性,即let修饰 - 2.要么是
变量存储属性,即var修饰定义如下代码:其中代码中的
age、name来说,都是变量存储属性,这一点可以在SIL中体现
class LjTeacher {
@_hasStorage @_hasInitialValue var age: Int { get set }
@_hasStorage @_hasInitialValue var name: String { get set }
init(age: Int, name: String)
@objc deinit
}
【存储属性特征】:会占用实例对象的内存空间
下面我们通过断点调试来验证
po tx/8gx内存地址,即HeapObject存储的地址可以画一张类的属性图:
计算属性
【计算属性】:是指不占用内存空间,本质是set/get方法的属性 我们通过一个demo来说明,以下写法可行吗?
class LjTeacher {
var age: Int{
get{
return 18
}
set{
age = newValue
}
}
}
在实际编程中,编译器会报以下警告,其意思是在age的set方法中又调用了age.set
运行后发现
崩溃,原因就是age的set方法中调用age.set导致循环引用,出现递归
验证:不占用内存
对于其不占用内存空间这一特征,我们还是通过LjTeacher案例来验证,打印以下类的内存大小
从结果可以看出类LjTeacher的内存大小是
24,等于(metadata + refCounts)类自带16字节+height(8字节)= 24,是没有加上weight的。从这里可以证明weight属性没有占有内存空间。
验证:本质是set/get方法
- 将
main.swift转换为SIL文件:swiftc -emit-sil main.swift >> ./main.sil - 查看
SIL文件,对于存储属性,有_hasStorage的标识符
class Square {
@_hasStorage @_hasInitialValue var height: Double { get set }
var weight: Double { get set }
init(height: Double, weight: Double)
@objc deinit
}
对于计算属性,SIL中只有setter、getter方法
属性观察者(didSet、willSet)
- willSet:
新值存储之前调用newValue - didSet:
新值存储之后调用oldValue
验证
- 可以通过demo验证
- 也可以通过编译来验证,将
main.swift编译成mail.sil,在sil文件中找name的set方法
相关问题
init方法中是否会触发属性观察者?
以下代码中,init方法中设置name,是否会触发属性观察者?
运行结果发现,并
没有走willSet、didSet中的打印方法,所以有以下结论:
- 在
init方法中,如果调用属性,是不会触发属性观察者的 init中主要是初始化当前变量,除了默认的前16个字节,其他属性会调用memset清理内存空间(因为有可能是脏数据,即被别人用过),然后才会赋值【总结】:初始化器(即init方法设置)和定义时设置默认值(即在didSet中调用其他属性值)都不会触发
哪里可以添加属性观察者?
主要有以下三个地方可以添加:
- 1.
类中定义的存储属性 - 2.
通过类继承的存储属性 - 3.
通过类继承的计算属性
子类和父类的计算属性同时存在didset、willset时,其调用顺序是什么?
我们通过以下代码来验证
结论:对于
同一个属性,子类和父类都有属性观察者,其顺序是:先子类willset,后父类willset,再父类didset,后子类的didset,即:子父,父子
子类调用了父类的init,是否会触发观察属性?
在上面问题的基础上,修改LjMediumTeacher类为下图
通过打印我们看到会
触发观察者,主要是因为子类调用了父类的init,已经初始化过了,而初始化流程保证了所有属性都有值(即super.init确保变量初始化完成了),所以可以观察属性了
延迟属性
延迟属性主要有以下几点说明:
- 1.
使用lazy修饰的存储属性 - 2.
延迟属性必须有一个默认的初始值 - 3.
延迟存储在第一次访问的时候才被赋值 - 4.
延迟存储属性并不能保证线程安全 - 5.
延迟存储属性对实例对象大小的影响下面逐条分析
使用lazy修饰的存储属性
延迟属性必须有一个默认的初始值
如果定义为可选类型,则会报错,如下所示
延迟存储在第一次访问的时候才被赋值
可以通过调试,来查看实例变量的内存变化
- age
第一次访问前的内存情况:此时的age是没值的,为0x0 - age
第一次访问后的内存情况:此时age是有值的,为24从上面可以验证,懒加载存储属性只有在第一次访问时才会被赋值
我们也可以通过sil文件来查看,这里可以在生成sil文件时,加上还原swift中混淆名称的命令(即xcrun swift-demangle):swiftc -emit-sil main.swift | xcrun swift-demangle >> ./main.sil && code main.sil,demo代码如下
类+main:lazy修饰的存储属性在底层是一个optional类型setter+getter:从getter方法中可以验证,在第一次访问时,就从没值变成了有值的操作通过sil,有以下两点说明:
- 1.
lazy修饰的属性,在底层默认是optional,在没有被访问时,默认是nil,在内存中的表现就是0x0。在第一次访问过程中,调用的是属性的getter方法,其内部实现是通过当前enum的分支,来进行一个赋值操作 - 2.可选类型是16字节吗?可以通过
MemoryLayout打印- size:
实际大小 - stride:
分配大小(主要是由于内存对齐)
- size:
为什么实际大小是
9?Optional其本质是一个enum,其中Int占8字节,另一个字节主要用于存储case值(这个后续会详细讲解)
延迟存储属性并不能保证线程安全
继续分析上面的sil文件,主要是查看age的getter方法,如果此时有两个线程:
线程1此时访问age,其age是没有值的,进入bb2流程- 然后
时间片将CPU分配给了线程2,对于optional来说,依然是none,同样也会进入bb2流程 - 所以在此时,
线程1会走一遍赋值,线程2也会走一遍赋值,并不能保证属性只初始化了一次
延迟存储属性对实例对象大小的影响
我们先看下不使用lazy的内存与使用lazy的内存是否有变化?
不使用lazy修饰的情况,类的内存大小是24使用lazy修饰的情况下,类的内存大小是32从而可以证明,
使用lazy和不使用lazy,其实例对象的内存大小是不一样的
类型属性
类型属性,主要有以下几点说明:
- 1.
使用关键字static修饰,且是一个全局变量 - 2.
类型属性必须有一个默认的初始值 - 3.
类型属性只会被初始化一次
使用关键字static修饰
生成SIL文件
- 查看定义,发现
多了一个全局变量,所以类型属性是一个全局变量 - 查看
入口函数中age的获取 - 查看
age的getter方法 - 其中
globalinit_33_596AE01B81CE0F4EFFD4B7F23A0D7C04_func0是全局变量初始化函数 - 在static代码处,通过断点调试,发现调用的是
swift_once,表示属性只初始化一次 - 源码中搜索
swift_once,其内部是通过GCD的dispatch_once_f单例实现。从这里可以验证上面的第3点:类型属性只会被初始化一次
类型属性必须有一个默认的初始值
如下图所示,如果没有给默认的初始值,会报错
所以对于
类型属性来说,一是全局变量,只初始化一次,二是线程安全的
单例的创建
总结
- 存储属性
会占用实例变量的内存空间 - 计算属性
不会占用内存空间,其本质是set/get方法 - 属性观察者
willset:新值存储之前调用,先通知子类,再通知父类(因为父类中可能需要做一些额外的操作),即子父didSet:新值存储完成后,先告诉父类,再通知子类(父类的操作优先于子类),即父子- 类中的
init方法赋值不会触发属性观察 - 属性可以添加在
类定义的存储属性、继承的存储属性、继承的计算属性中 - 子类调用父类的
init方法,会触发观察属性
- 延迟存储属性
使用lazy修饰存储属性,且必须有一个默认值- 只有在
第一次被访问时才会被赋值,且是线程不安全的 使用lazy和不使用lazy,会对实例对象的内存大小有影响,主要是因为lazy在底层是optional类型,optional的本质是enum,除了存储属性本身的内存大小,还需要一个字节用于存储case
- 类型属性
- 使用
static修饰,且必须有一个默认初始值 - 是一个全局变量,只会被
初始化一次,是线程安全的 - 用于创建
单例对象:- 使用
static + let创建实例变量 init方法的访问权限为private
- 使用
- 使用
写到最后
写的内容比较多,由于本人能力有限,有些地方可能解释的有问题,请各位能够指出,同时对Swift类、对象、属性有疑问,欢迎大家留言,也希望大家点赞多多支持。希望大家能够相互交流、探索,一起进步!