系列文章: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字节
内存大小(metadata
8字节 +refCounts
8字节),与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_class
Swift中的实例对象
本质也是结构体
,类型是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 t
x/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类、对象、属性有疑问,欢迎大家留言,也希望大家点赞多多支持。希望大家能够相互交流、探索,一起进步!