前言
自苹果从2014
年推出Swift
以来,经过多年的发展,相关生态愈加成熟,其本身相比OC
具有更加容易阅读、更加易于维护、更加安全、代码量更少、速度更快等特点,所以以后必然是Swift
的天下,所以本文开始,将来探索一下Swift
的相关知识。
一、初识类与结构体
1.1、类与结构体的异同点
众所周知,Swift
中的Class
与Struct
非常相似,具有很多相同的特性,我们先来看一段代码。
struct XJPerson{
var name: String
var hobby: String
init(name: String, hobby: String) {
self.name = name
self.hobby = hobby
}
}
class XJPerson{
var name: String
var hobby: String
init(name: String, hobby: String) {
self.name = name
self.hobby = hobby
}
deinit{
}
}
从上面代码可以看出Class
和Struct
高度相似,下面就来总结下它们的异同点。
Class
和Struct
的主要相同点:
- 都可以定义存储值的属性。
- 都可以定义方法。
- 都可以定义下标,以使用下标语法提供对其值的访问。
- 都可以定义初始化器。
- 都可以使用
extension
来拓展功能。 - 都可以遵循协议来提供某种功能。
主要的不同点:
Class
有继承的特性,而Struct
没有。Struct
默认会有成员初始化方法,而Class
没有。- 类型转换使我们能够在运行时检查和解释
Class
实例的类型。 Class
有析构函数用来释放其分配的资源。Class
有引用计数,引用计数允许对一个Class
实例有多个引用。
1.2、引用类型和值类型
对于Class
和Struct
我们需要区分的第一件事就是:
Class
是引用类型,而Struct
是值类型。
Class
是引用类型,也就意味着一个Class
类型的变量并不直接存储具体的实例对象,而是对当前存储具体实例内存地址
的引用。
var p = XJPerson(name: "xj", hobby: "swimming")
// 此处 XJPerson 类型为 Class
var p = XJPerson(name: "xj", hobby: "swimming")
var p1 = p
下面我们借助两个lldb
指令来查看当前变量的内存结构:
po / p
:p
和po
的区别在于使用po
只会输出对应的值,而p
则会返回值的类型以及命令结果的引用名。
x/8g
:读取内存中的值(8g
:8
字节格式输出)。
x/8g
输出的实例对象的内容,后续将在类的声明周期和类的结构探索中类分析。
通过指针我们也可以查看上面两个Class
实例对象的指针对应的值。
从上面可以看出p
和p1
刚好相差8字节,这8
字节刚好存储的就是实例对象的内存地址。
Struct
在Swift
中是典型的值类型,相比较Class
类型的实例对象中存储的是地址,那么值类型存储的就是具体的实例(或者说具体的值)。
// 此处 XJPerson 类型为 Struct
var p = XJPerson(name: "xj", hobby: "swimming")
var p1 = p
借助两个lldb
指令来查看当前变量的内存结构:
从上面可以看出两个Struct
实例对象里面存储的就是具体的值。
其实引用类型就相当于在线的Excel
,当我们把这个链接共享给别人的时候,别人修改后,我们能够看到;值类型就相当于本地的Excel
,当我们把本地的Excel
传递给别人的时候,就相当于重新复制了一份给别人,至于别人的修改,我们是无法感知的。
另外引用类型和值类型还有一个最直观的区别就是存储存储的位置不同:一般情况下,值类型存储在栈上,引用类型存储在堆上。
首先,我们对内存区域来一个基本概念的认知,请看下图:
Stack
(栈区):局部变量和函数运行过程中的上下文。
func test() {
// 函数内部声明的age变量就是一个局部变量,位于栈区
var age:Int = 10
print(age)
}
Heap
(堆区):存储所有对象。
Global
(全局区):存储全局变量、常量、代码区。
其实如果研究APP
的内存结构的话,一般研究的就是Mach-O
文件,而Mach-O
里面又分为很多个Segment
(段),每个Segment
有不同的功能,然后每个Segment
又分为很多小的Section
(区)。
下面就列出一些常见的Section
中存储的内容:
TEXT.text
:机器码。TEXT.cstring
:硬编码的字符串。TEXT.const
:初始化过的常量。DATA.data
:初始化过的可变的(静态/全局)数据。DATA.const
:没有初始化过的常量。DATA.bss
:没有初始化过的(静态/全局)变量。DATA.common
:没有初始化过的符号声明。
接下来我们接着看Struct
和Class
在内存中的分配。
struct XJBoy {
var name = "xj"
var hobby = "swimming"
}
func test() {
var b = XJBoy()
print("end")
}
test()
使用下面的lldb
指令来查看Struct
在栈帧
中的位置。
frame varibale -L xxx
从输出结果可以看出值类型Struct
存储在栈区,首地址存储的就是第一个成员变量。
当前Struct
实例在内存中的分布示意图:
如果我们在值类型Struct
里面添加一个引用类型Class
,会是什么情况呢?
从输出结果可以看出:
g
只是存储了引用类型class
对象的引用,class
对象本事还是存储在堆区。g
在栈上的地址刚好在hobby
后面16
字节。
当前Struct
实例在内存中的分布示意图:
此处引用类型对象g
的生命周期为:
初始化:
- 首先在栈上分配
8
字节的内存大小,用于存储实例对象的引用。 - 然后初始化的时候,在堆上寻找合适的内存区域返回回来,并将成员变量的值拷贝到对应堆区的内存当中。
- 最后将栈上的内存地址指向上一步中的堆区。
销毁:
- 首先查找并且把内存块重新插入到堆空间。
- 然后销毁栈上的指针。
由此可见,在分配内存的时候堆区始终比栈区多了查找等过程,也就意味着时间更长,速度更慢。也就是Struct
比Class
性能更高。
下面通过github
上的StructVsClassPerformance案例来直观的测试Struct
和Class
的性能(相关代码此处不展示,有兴趣可以自己下载下来查看)。
从输出结果可知,不管是一个成员,还是十个成员,Struct
都比Class
内存分配性能更高。
因此,如果只是需要通过数据结构来描述数据类型,不需要继承、类型转换等特性,尽可能优先使用Struct
,因为Struct
在栈上,线程安全,内存分配的性能也比堆区的Class
更高。
单单这样说,感知可能还不够强烈,接下来,我们再来看看两个官方文档的具体案例。
案例一:
未优化代码:
enum Color { case blue, green, gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }
var cache = [String : UIImage]()
func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
let key = "\(color):\(orientation):\(tail)"
if let image = cache[key] {
return image
}
...
}
此代码非常简单,就是创建一个聊天的Balloon
(气泡),需要传入color
、orientation
、tail
三个参数作为Dictionary
的key
。那么这样是否有问题呢?肯定是有的,String
虽然表现的像值类型,但是里面的Character
是存在堆上的,当我们每次调用这个函数的时候,都需要进行堆内存的分配和销毁。如果需要优化这样聊天场景界面的卡顿,这样的执行效率是不能接受的。
下面就通过Struct
来进行优化。
优化后的代码:
enum Color { case blue, green, gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }
var cache = [Balloon : UIImage]()
//
func makeBalloon(_ balloon: Balloon) -> UIImage {
if let image = cache[balloon] {
return image
}
// 没有就创建,此处省略
...
}
// Balloon 需要作为key,需要遵守Hashable协议
struct Balloon: Hashable {
var color: Color
var orientation: Orientation
var tail: Tail
}
我们声明一个包含color
、orientation
、tail
三个成员的Struct
替代String
来作为Dictionary
的key
,这样就避免了堆内存的分配和销毁,从而达到提升性能的目的。
案例二:
未优化代码:
struct Attachment {
let fileURL: URL
let uuid: String
let mimeType: String
init?(fileURL: URL, uuid: String, mimeType: String) {
guard mineType.isMineType
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mimeType = mimeType
}
}
Attachment
包含fileURL
、uuid
、mineType
三个成员,这三个成员是URL
和String
类型,都涉及到堆内存的分配和销毁。下面我们改为使用值类型来进行优化。
优化后代码:
struct Attachment {
let fileURL: URL // 实际需要,不适合更改
let uuid: UUID // 值类型
let mimeType: MimeType // 值类型
init?(fileURL: URL, uuid: UUID, mimeType: MimeType) {
guard mineType.isMineType
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mimeType = mimeType
}
}
enum MimeType: String{
case jpeg = "image/jpeg"
....
}
我们将uuid
的类型从String
更换到值类型UUID
,新建一个值类型enum
作为mimeType
的类型,这样就尽量的减少了堆内存的分配和销毁,从而达到提升性能的目的。
二、类的初始化器
前面Class
和Struct
的不同点之处已经说明过编译器默认不会给Class
自动提供成员初始化方法,但是会给Struct
默认提供成员初始化方法(前提是我们自己没有指定初始化方法)!
2.1、指定初始化方法 & 便捷初始化方法
Swift
中创建Class
和Struct
的实例时必须为所有的存储属性设置一个合适的初始值,所以Class
必须要提供对应的指定初始化方法(Struct
默认提供),同时我们也可以为Class
提供便捷初始化方法(注意:便捷初始化方法必须从相同的Class
里调用另一个初始化方法。)。
class XJPerson {
var name: String
var hobby: String
// 指定初始化方法
init(_ name: String, _ hobby: String) {
self.name = name
self.hobby = hobby
}
// 便捷初始化方法一
convenience init() {
// 便捷初始化方法必须先调用其他初始化方法,
// 以确保 self(实例) 及其属性先初始化,才能进行后面的操作
self.init("xj", "swimming")
}
// 便捷初始化方法二
convenience init(_ name: String) {
// 便捷初始化方法必须先调用其他初始化方法,
// 以确保 self(实例) 及其属性先初始化,才能进行后面的操作
self.init("wn", "swimming") // 此行代码必须保证在第一行,放到后面就会报错
self.name = name
self.hobby = "swimming"
}
}
当我们派生出一个子类,并指定一个指定初始化方法之后,向上委托父类初始化方法之前,本类的所有属性都要先初始化完成,并且指定初始化器必须先向上委托父类初始化方法,然后才能为继承的属性设置新值。
class XJBoy: XJPerson {
var nick: String
init(nick: String) {
// 向上委托父类初始化方法之前,本类的所有属性都要先初始化完成
self.nick = nick
super.init("xj", "swimming")
// 指定初始化器必须先向上委托父类初始化方法,然后才能为继承的属性设置新值
self.name = "hehe"
}
}
指定初始化方法 & 便捷初始化方法注意点:
-
指定初始化方法必须保证在向上委托给父类初始化方法之前,其本类的所有属性都要初始化完成。
-
指定初始化方法必须先向上委托父类初始化方法,然后才能为继承的属性设置新值。
-
便捷初始化方法必须先委托同类中的其它初始化方法,然后再为任意属性赋新值(包括同类里定义的属性)。如果没这么做,便捷初始化方法赋予的新值将被自己类中其它指定初始化方法所覆盖。
-
初始化方法在第一阶段初始化完成之前,不能调用任何实例方法、不能读取任何实例属性的值,也不能引用
self
作为值。
2.2、可失败初始化方法
这个非常好理解,也就意味着当前因为参数的不合法或者外部条件的不满足,存在初始化失败的情况。Swift
中可失败初始化方法写return nil
语句来表明在何种情况下会触发初始化失败。
class XJPerson {
var name: String
var hobby: String
var age: Int
// 可失败初始化方法
init?(_ name: String, _ hobby: String, _ age: Int) {
if age < 18 {return nil} // 比如这里我们定义了如果小于18,就不是一个合法的成年人
self.name = name
self.hobby = hobby
self.age = age
}
// 便捷初始化方法
convenience init?() {
// 便捷初始化方法必须先调用其他初始化方法,
// 以确保 self(实例) 及其属性先初始化,才能进行后面的操作
self.init("xj", "swimming", 18)
}
}
2.3、必要初始化器
在类的初始化方法前添加required
修饰符来表明所有该类的子类都必须实现该初始化方法。
三、类的生命周期
前面简单介绍了Struct
和Class
的相同点和异同点,并通过简单案例演示了为什么和在什么情况下更多的选择值类型Struct
,同时在通过lldb
指令查看Struct
和Class
内存情况时,发现Class
第一个属性会有16
字节的偏移,那么现在就来探索下这16
字节里面究竟存储了什么。
3.1、Swift
编译过程简介
了解这16
字节里面存储了什么之前,我们先来了解下Swift
的编译过程,iOS
开发语言,不管是OC
还是Swift
,后端都是通过LLVM
进行编译的,如下图所示:
OC
通过clang
编译器编译成IR
,然后再生成可执行文件.o
(也就是机器码),具体编译过程以前的文章有分析过,这里就不再赘述了。
Swift
则是通过Swift
编译器编译成IR
,然后再生产可执行文件.o
,具体编译流程图如下:
相关编译命令如下:
// 分析输出AST
swiftc main.swift -dump-parse
// 分析并且检查类型输出AST
swiftc main.swift -dump-ast
// 生成中间体语言(SIL),未优化
swiftc main.swift -emit-silgen
// 生成中间体语言(SIL),优化后的
swiftc main.swift -emit-sil
// 生成LLVM中间体语言 (.ll文件)
swiftc main.swift -emit-ir
// 生成LLVM中间体语言 (.bc文件)
swiftc main.swift -emit-bc
// 生成汇编
swiftc main.swift -emit-assembly
// 编译生成可执行.out文件
swiftc -o main.o main.swift
有兴趣的朋友可以自行尝试执行查看结果。
3.2、SIL
浅析
相对于OC
来说,这里出现了一个很显眼的中间产物SIL
(Swift Intermediate Language
-Swift
中间语言),下面我们就将.swift
文件通过命令编译成.sil
文件来看看具体内容。
在.swift
文件中新建一个Class
。
class XJPerson {
var name: String = "xj"
var hobby: String = "swimming"
var age: Int = 18
}
var p = XJPerson()
在Xcode
中新建一个Aggregate
类型的Target
。
在Aggregate
类型的Target
的Build Phases
中新建一个运行脚本。
在脚本中添加生成优化后的SIL
文件并打开的命令。
选择脚本对应的Scheme
并编译,就会生成并打开.sil
文件了(如果未指定.sil
文件打开的软件,可能会报错,到工程主目录下使用Xcode
或VS Code
打开一次之后就不会了)。
在.sil
文件中,我们能看到Class XJPerson
的声明和main
函数的实现(同时还包括Class XJPerson
属性的初始化、setter
、getter
、modify
、构造函数、析构函数等,这里不做分析),相关代码我都进行了注释,大家可以仔细看看。
@main:程序入口函数,即
OC
的main
函数,@
在SIL中作为标识符。%x(0、1、2...n):虚拟寄存器,可以理解为我们日常开发中的常量,一旦赋值,不可修改,所以数字会不断累加。最终运行到具体设备上时,会使用真的寄存器。
xcrun swift-demangle:
Swift
类名等会使用特殊的混写规则进行混写,可以使用此命令进行还原。
更多
SIL
相关知识可以查看苹果的SIL文档。
3.3、类的初始化流程
从上面的SIL
文件,我们发现XJPerson
类的对象初始化时调用了一个关键函数__allocating_init()
,接下来我们就以此方法为突破口来探索下类的初始化。
在.sil
文件中搜索__allocating_init()
函数,找到具体实现。
可以看到__allocating_init()
函数里调用了alloc_ref
,我们在苹果的SIL
文档中搜索alloc_ref
,查看相关说明可知此符号作用为分配一个引用类型为T
(泛型)的对象。该对象引用计数将被初始化为1
;标识为objc
的类(继承自OC
,比如继承自NSObject
)应该使用OC
的分配方法(+allocWithZone:
)分配对象。
3.3.1、纯Swift
类初始化流程
将Scheme
切换到主Target
,在Class XJPerson
的对象初始化的地方加上断点,运行代码,选择Debug -> Debug Workflow -> Always Show Disassembly
打开汇编调试,可以看到__allocating_init()
函数的调用,按住Control
,点击step into
进入具体汇编可以看到主要调用了swift_allocObject
函数(内存分配)和init()
函数(初始化)。
3.3.2、非纯Swift
类初始化流程
将Class XJPerson
继承自NSObject
,再次运行代码,重复上面步骤进入__allocating_init()
函数,可以看到主要调用了objc_allocWithZone
函数(内存分配)和发送了init
消息(初始化)。
笔者此处工程为
Command Line Tool
类型,所以汇编为X86_64
架构,大家也可以新建arm64
架构的工程运行,具体初始化流程差异不大,所以笔者就偷下懒了
3.4 Swift
类和对象的数据结构
上面了解了Swift
对象的初始化流程,下面我们通过Swift
源码来看看swift_allocObject
函数的具体流程。
3.4.1 Swift
对象的数据结构
下载Swift源码,然后将文件夹拖入VS Code
打开,搜索swift_allocObject
函数,在HeapObject.cpp
文件中可以找到具体实现,通过查看源码可以知道里面主要是调用了_swift_allocObject_
函数。
CALL_IMPL
宏定义:
搜索_swift_allocObject_
函数查看具体实现,发现里面调用了swift_slowAlloc
函数和HeapObject
结构体的初始化方法。
搜索swift_slowAlloc
函数查看具体实现,发现里面就是最大16
字节对齐分配内存。
搜索查看Struct HeapObject
的定义,会发现里面有metadata
和refCounts
两个成员,metadata
为HeapMetadata
类型指针,相当于OC
的isa
,refCounts
为对象的引用计数(类没有OC
属性时才有refCounts
)。
从上面分析我们可以得出调用纯Swift
类的默认初始化方法(XJPerson()
为例)初始化对象的流程为:
-
__allocating_init()
(编译器会为每个类生成)->swift_allocObject
(内存分配) ->init()
(初始化)。内存分配流程:
swift_allocObject
- >_swift_allocObject_
->swift_slowAlloc
->malloc_zone_malloc / malloc
->new HeapObject(相当于OC的isa)
纯Swift
类的实例对象的数据结构为结构体HeapObject
(相当于OC
的结构体objc_object
),里面有两个属性metadata
(相当于OC
的isa
)和refCounts
(64
位的位域信息,以后有机会探索Swift
内存管理时再详细探索),默认占用16
字节大小。
struct HeapObject {
var metadata: UnsafeRawPointer // 原生指针
var refcounts: uint64
}
OC
的objc_object
只有8
字节,因为里面只有isa
。
struct objc_object { void *isa }
3.4.2 Swift
类的数据结构
前面我们已经分析出了Swift
实例对象的数据结构,接下来我们就分析下Swift
类的数据结构。
刚才我们已经确定了Swift
实例对象的数据结构中的metaData
是HeapMetadata
类型,我们就以此为切入点来进行探索。
同时按住Command
+ Shift
点击HeapMetadata
,在HeapObject.h
中找到相应定义,可以看到HeapMetadata
是TargetHeapMetadata
的别名,接收参数InProcess
。
继续点击进入TargetHeapMetadata
里面查看,发现其本质是一个模板类型,里面定义了初始化方法,传入了一个MetadataKind
类型的参数kind
(即InProcess
),没有成员变量,继续查找父类,本结构体没有成员变量,父类可能有。
进入TargetMetadata
定义,发现里面有一个StoredPointer
类型的kind
,即前面传进来的InProcess
,通过查看StoredPointer
类型的定义,发现其本质就是unsigned long
。
从TargetHeapMetadata
和TargetMetaData
定义中,均可以看出初始化方法中参数kind
的类型是MetadataKind
,进入其定义,发现里面有一句#include "MetadataKind.def"
。
点击进入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 |
现在确定了MetadataKind
的类型,但是还没有确定Swift
类的数据结构,回到TargetMetadata
定义中继续寻找有用的信息,终于找到了getClassObject
函数的定义。
点击进入查看实现,如果是Class
,则直接对this
(当前指针,即对象内存中的metadata
)强转为ClassMetadata
。
点击查看ClassMetadata
定义,发现其是TargetClassMetadata
的别名,接收InProcess
参数。
点击查看TargetClassMetadata
定义,发现其是一个模板类,继承自TargetAnyClassMetadata
,里面有如下成员变量。这也就是Class
数据结构的部分内容。
template <typename Runtime>
struct TargetClassMetadata : public TargetAnyClassMetadata<Runtime> {
...
// Swift 特有的类的标识
ClassFlags Flags;
// 实例对象内存大小
uint32_t InstanceSize;
// 实例对象内存对齐方式
uint16_t InstanceAlignMask;
// 保留供运行时使用
uint16_t Reserved;
// 类的内存大小
uint32_t ClassSize;
// 类对象中地址点的偏移量
uint32_t ClassAddressPoint;
...
}
进入TargetAnyClassMetadata
查看其定义,同样是一个模板类,继承自TargetHeapMetadata
(你看,又回来了,继承链也就清楚了),里面有如下成员变量。
template <typename Runtime>
struct TargetAnyClassMetadata : public TargetHeapMetadata<Runtime> {
...
// 父类的 metadata。对于根类,这是null。
TargetSignedPointer<Runtime, const TargetClassMetadata<Runtime> * __ptrauth_swift_objc_superclass> Superclass;
#if SWIFT_OBJC_INTEROP
// 缓存数据用于某些动态查找;它由运行时拥有,通常需要与 OC 的使用进行互操作。
TargetPointer<Runtime, void> CacheData[2];
StoredSize Data;
#endif
...
}
综上所述,当metadata
的kind
为Class
时,继承链如下:
Swift
类的数据结构如下:
struct TargetClassMetadata {
var kind: Int
var superClass: Any.Type
var cacheData: (Int, Int)
var data: Int
var classFlags: Int32
var instanceAddressPoint: UInt32
var instanceSize: UInt32
var instanceAlignmentMask: UInt16
var reserved: UInt16
var classSize: UInt32
var classAddressPoint: UInt32
var typeDescriptor: UnsafeMutableRawPointer
var iVarDestroyer: UnsafeRawPointer
}
接下来,我们就通过指针类型绑定来输出下类和对象的数据结构。
// 对象数据结构
struct HeapObject {
var metadata: UnsafeRawPointer // 原生指针
var refcounts: uint64
}
// 类数据结构
struct TargetClassMetadata {
var kind: Int
var superClass: Any.Type
var cacheData: (Int, Int)
var data: Int
var classFlags: Int32
var instanceAddressPoint: UInt32
var instanceSize: UInt32
var instanceAlignmentMask: UInt16
var reserved: UInt16
var classSize: UInt32
var classAddressPoint: UInt32
var typeDescriptor: UnsafeMutableRawPointer
var iVarDestroyer: UnsafeRawPointer
}
class XJPerson {
var name: String = "xj"
var hobby: String = "swimming"
var age: Int = 18
}
var p = XJPerson()
// 获取对象的原生指针
let objcRawPtr = Unmanaged.passUnretained(p as AnyObject).toOpaque()
// 将原生指针重新绑定为 HeapObject 类型
let objcPtr = objcRawPtr.bindMemory(to: HeapObject.self, capacity: 1)
// .pointee 访问指针
print(objcPtr.pointee)
print("\n")
// 将 metadata 重新绑定为 TargetClassMetadata 类型
let metadata = objcPtr.pointee.metadata.bindMemory(to: TargetClassMetadata.self, capacity: MemoryLayout<TargetClassMetadata>.stride).pointee
print(metadata)
总结
本文主要探索了Class
和Struct
的异、同点,以及为什么和什么情况下使用值类型Struct
比较好,最后通过初始化方法和源码确定了Class
及其实例对象的数据结构。