Swift底层探索之类、对象、属性

2,246 阅读16分钟

系列文章: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位于在SemaLLVM IR之间

注意:这里需要说明一下,SwiftOC区别在于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
  • requiredAlignmentMaskswift中的字节对齐方式,这个和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方法获取类的内存大小 这点与在源码调试时左边localrequiredSize值是相等的,从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是Class
  • po metadata->getClassObject()x/8g 0x0000000110efdc70,这个地址中存储的是元数据信息 所以,TargetMetadataTargetClassMetadata本质上是一样的,因为在内存结构中,可以直接进行指针的转换,可以说,我们认为的结构体,其实就是TargetClassMetadata
  • 进入TargetClassMetadata定义,继承自TargetAnyClassMetadata,有以下这些属性,这也是类结构的部分
  • 进入TargetAnyClassMetadata定义,继承自TargetHeapMetadata

总结

综上所述,当metadata的kind为Class时,有如下继承链

  • 当前类返回的实际类型是TargetClassMetadata,而TargetMetaData中只有一个属性kindTargetAnyClassMetaData中有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属性

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代码如下

  • 类+mainlazy修饰的存储属性在底层是一个optional类型
  • setter+getter:从getter方法中可以验证,在第一次访问时,就从没值变成了有值的操作 通过sil,有以下两点说明:
  • 1.lazy修饰的属性,在底层默认是optional,在没有被访问时,默认是nil,在内存中的表现就是0x0。在第一次访问过程中,调用的是属性的getter方法,其内部实现是通过当前enum的分支,来进行一个赋值操作
  • 2.可选类型是16字节吗?可以通过MemoryLayout打印
    • size:实际大小
    • stride:分配大小(主要是由于内存对齐)

为什么实际大小是9Optional本质是一个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,其内部是通过GCDdispatch_once_f单例实现。从这里可以验证上面的第3点:类型属性只会被初始化一次

类型属性必须有一个默认的初始值

如下图所示,如果没有给默认的初始值,会报错 所以对于类型属性来说,一是全局变量,只初始化一次,二是线程安全

单例的创建

总结

  • 存储属性会占用实例变量内存空间
  • 计算属性不会占用内存空间,其本质set/get方法
  • 属性观察者
    • willset:新值存储之前调用,先通知子类,再通知父类(因为父类中可能需要做一些额外的操作),即子父
    • didSet:新值存储完成后,先告诉父类,再通知子类(父类的操作优先于子类),即父子
    • 类中的init方法赋值不会触发属性观察
    • 属性可以添加在类定义的存储属性、继承的存储属性、继承的计算属性
    • 子类调用父类的init方法,会触发观察属性
  • 延迟存储属性
    • 使用lazy修饰存储属性,且必须有一个默认值
    • 只有在第一次访问时才会被赋值,且是线程不安全
    • 使用lazy不使用lazy,会对实例对象内存大小有影响,主要是因为lazy底层optional类型optional的本质是enum,除了存储属性本身内存大小,还需要一个字节用于存储case
  • 类型属性
    • 使用static修饰,且必须有一个默认初始值
    • 是一个全局变量,只会被初始化一次,是线程安全
    • 用于创建单例对象:
      • 使用static + let创建实例变量
      • init方法的访问权限为private

写到最后

写的内容比较多,由于本人能力有限,有些地方可能解释的有问题,请各位能够指出,同时对Swift类、对象、属性有疑问,欢迎大家留言,也希望大家点赞多多支持。希望大家能够相互交流、探索,一起进步!