Swift类与结构体(上)

225 阅读17分钟

前言

自苹果从2014年推出Swift以来,经过多年的发展,相关生态愈加成熟,其本身相比OC具有更加容易阅读、更加易于维护、更加安全、代码量更少、速度更快等特点,所以以后必然是Swift的天下,所以本文开始,将来探索一下Swift的相关知识。

一、初识类与结构体

1.1、类与结构体的异同点

众所周知,Swift中的ClassStruct非常相似,具有很多相同的特性,我们先来看一段代码。

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{

    } 
}

从上面代码可以看出ClassStruct高度相似,下面就来总结下它们的异同点。

ClassStruct的主要相同点:

  • 都可以定义存储值的属性。
  • 都可以定义方法。
  • 都可以定义下标,以使用下标语法提供对其值的访问。
  • 都可以定义初始化器。
  • 都可以使用extension来拓展功能。
  • 都可以遵循协议来提供某种功能。

主要的不同点:

  • Class有继承的特性,而Struct没有。
  • Struct默认会有成员初始化方法,而Class没有。
  • 类型转换使我们能够在运行时检查和解释Class实例的类型。
  • Class有析构函数用来释放其分配的资源。
  • Class有引用计数,引用计数允许对一个Class实例有多个引用。

1.2、引用类型和值类型

对于ClassStruct我们需要区分的第一件事就是:

  • Class是引用类型,而Struct是值类型。

Class是引用类型,也就意味着一个Class类型的变量并不直接存储具体的实例对象,而是对当前存储具体实例内存地址的引用。

var p = XJPerson(name: "xj", hobby: "swimming")

reference type des-2.jpg

// 此处 XJPerson 类型为 Class
var p = XJPerson(name: "xj", hobby: "swimming")
var p1 = p

reference type des-3.jpg

下面我们借助两个lldb指令来查看当前变量的内存结构:

po / pppo的区别在于使用po只会输出对应的值,而p则会返回值的类型以及命令结果的引用名。

x/8g:读取内存中的值(8g8字节格式输出)。

image.png

x/8g输出的实例对象的内容,后续将在类的声明周期和类的结构探索中类分析。

通过指针我们也可以查看上面两个Class实例对象的指针对应的值。

image.png

从上面可以看出pp1刚好相差8字节,这8字节刚好存储的就是实例对象的内存地址。

StructSwift中是典型的值类型,相比较Class类型的实例对象中存储的是地址,那么值类型存储的就是具体的实例(或者说具体的值)。

// 此处 XJPerson 类型为 Struct
var p = XJPerson(name: "xj", hobby: "swimming")
var p1 = p

Struct object value.jpg

借助两个lldb指令来查看当前变量的内存结构:

image.png

从上面可以看出两个Struct实例对象里面存储的就是具体的值。

其实引用类型就相当于在线的Excel,当我们把这个链接共享给别人的时候,别人修改后,我们能够看到;值类型就相当于本地的Excel,当我们把本地的Excel传递给别人的时候,就相当于重新复制了一份给别人,至于别人的修改,我们是无法感知的。

另外引用类型和值类型还有一个最直观的区别就是存储存储的位置不同:一般情况下,值类型存储在栈上,引用类型存储在堆上。

首先,我们对内存区域来一个基本概念的认知,请看下图:

Memory area division.jpg

Stack(栈区):局部变量和函数运行过程中的上下文。

func test() {
    // 函数内部声明的age变量就是一个局部变量,位于栈区
    var age:Int = 10
    print(age)
}

image.png

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:没有初始化过的符号声明。

image.png

接下来我们接着看StructClass在内存中的分配。

struct XJBoy {
    var name = "xj"
    var hobby = "swimming"
}

func test() {
    var b = XJBoy()
    print("end")
}

test()

使用下面的lldb指令来查看Struct栈帧中的位置。

frame varibale -L xxx

image.png

从输出结果可以看出值类型Struct存储在栈区,首地址存储的就是第一个成员变量。

当前Struct实例在内存中的分布示意图:

Struct stack memory.jpg

如果我们在值类型Struct里面添加一个引用类型Class,会是什么情况呢?

image.png

从输出结果可以看出:

  • g只是存储了引用类型class对象的引用,class对象本事还是存储在堆区。
  • g在栈上的地址刚好在hobby后面16字节。

当前Struct实例在内存中的分布示意图:

Struct stack memory.jpg

此处引用类型对象g的生命周期为:

初始化:

  • 首先在栈上分配8字节的内存大小,用于存储实例对象的引用。
  • 然后初始化的时候,在堆上寻找合适的内存区域返回回来,并将成员变量的值拷贝到对应堆区的内存当中。
  • 最后将栈上的内存地址指向上一步中的堆区。

销毁:

  • 首先查找并且把内存块重新插入到堆空间。
  • 然后销毁栈上的指针。

由此可见,在分配内存的时候堆区始终比栈区多了查找等过程,也就意味着时间更长,速度更慢。也就是StructClass性能更高。

下面通过github上的StructVsClassPerformance案例来直观的测试StructClass的性能(相关代码此处不展示,有兴趣可以自己下载下来查看)。

image.png

从输出结果可知,不管是一个成员,还是十个成员,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(气泡),需要传入colororientationtail三个参数作为Dictionarykey。那么这样是否有问题呢?肯定是有的,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
}

我们声明一个包含colororientationtail三个成员的Struct替代String来作为Dictionarykey,这样就避免了堆内存的分配和销毁,从而达到提升性能的目的。

案例二:

未优化代码:

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包含fileURLuuidmineType三个成员,这三个成员是URLString类型,都涉及到堆内存的分配和销毁。下面我们改为使用值类型来进行优化。

优化后代码:

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的类型,这样就尽量的减少了堆内存的分配和销毁,从而达到提升性能的目的。

二、类的初始化器

前面ClassStruct的不同点之处已经说明过编译器默认不会给Class自动提供成员初始化方法,但是会给Struct默认提供成员初始化方法(前提是我们自己没有指定初始化方法)!

image.png

2.1、指定初始化方法 & 便捷初始化方法

Swift中创建ClassStruct的实例时必须为所有的存储属性设置一个合适的初始值,所以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修饰符来表明所有该类的子类都必须实现该初始化方法。

image.png

三、类的生命周期

前面简单介绍了StructClass的相同点和异同点,并通过简单案例演示了为什么和在什么情况下更多的选择值类型Struct,同时在通过lldb指令查看StructClass内存情况时,发现Class第一个属性会有16字节的偏移,那么现在就来探索下这16字节里面究竟存储了什么。

3.1、Swift编译过程简介

了解这16字节里面存储了什么之前,我们先来了解下Swift的编译过程,iOS开发语言,不管是OC还是Swift,后端都是通过LLVM进行编译的,如下图所示:

OC & Swift program.jpg

OC通过clang编译器编译成IR,然后再生成可执行文件.o(也就是机器码),具体编译过程以前的文章有分析过,这里就不再赘述了。

Swift则是通过Swift编译器编译成IR,然后再生产可执行文件.o,具体编译流程图如下:

Swift code compiler diagram.jpg

相关编译命令如下:

// 分析输出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来说,这里出现了一个很显眼的中间产物SILSwift 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

image.png

Aggregate类型的TargetBuild Phases中新建一个运行脚本。

image.png

在脚本中添加生成优化后的SIL文件并打开的命令。

image.png

选择脚本对应的Scheme并编译,就会生成并打开.sil文件了(如果未指定.sil文件打开的软件,可能会报错,到工程主目录下使用XcodeVS Code打开一次之后就不会了)。

image.png

.sil文件中,我们能看到Class XJPerson的声明和main函数的实现(同时还包括Class XJPerson属性的初始化、settergettermodify、构造函数、析构函数等,这里不做分析),相关代码我都进行了注释,大家可以仔细看看。

image.png

@main:程序入口函数,即OCmain函数,@在SIL中作为标识符。

%x(0、1、2...n):虚拟寄存器,可以理解为我们日常开发中的常量,一旦赋值,不可修改,所以数字会不断累加。最终运行到具体设备上时,会使用真的寄存器。

xcrun swift-demangle:Swift类名等会使用特殊的混写规则进行混写,可以使用此命令进行还原。

image.png

更多SIL相关知识可以查看苹果的SIL文档

3.3、类的初始化流程

从上面的SIL文件,我们发现XJPerson类的对象初始化时调用了一个关键函数__allocating_init(),接下来我们就以此方法为突破口来探索下类的初始化。

.sil文件中搜索__allocating_init()函数,找到具体实现。

image.png

可以看到__allocating_init()函数里调用了alloc_ref,我们在苹果的SIL文档中搜索alloc_ref,查看相关说明可知此符号作用为分配一个引用类型为T(泛型)的对象。该对象引用计数将被初始化为1;标识为objc的类(继承自OC,比如继承自NSObject)应该使用OC的分配方法(+allocWithZone:)分配对象。

image.png

3.3.1、纯Swift类初始化流程

Scheme切换到主Target,在Class XJPerson的对象初始化的地方加上断点,运行代码,选择Debug -> Debug Workflow -> Always Show Disassembly打开汇编调试,可以看到__allocating_init()函数的调用,按住Control,点击step into进入具体汇编可以看到主要调用了swift_allocObject函数(内存分配)和init()函数(初始化)。

image.png

3.3.2、非纯Swift类初始化流程

Class XJPerson继承自NSObject,再次运行代码,重复上面步骤进入__allocating_init()函数,可以看到主要调用了objc_allocWithZone函数(内存分配)和发送了init消息(初始化)。

image.png

笔者此处工程为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_函数。

image.png

CALL_IMPL宏定义: image.png

搜索_swift_allocObject_函数查看具体实现,发现里面调用了swift_slowAlloc函数和HeapObject结构体的初始化方法。

image.png

搜索swift_slowAlloc函数查看具体实现,发现里面就是最大16字节对齐分配内存。

image.png

搜索查看Struct HeapObject的定义,会发现里面有metadatarefCounts两个成员,metadataHeapMetadata类型指针,相当于OCisarefCounts为对象的引用计数(类没有OC属性时才有refCounts)。

image.png

从上面分析我们可以得出调用纯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(相当于OCisa)和refCounts64位的位域信息,以后有机会探索Swift内存管理时再详细探索),默认占用16字节大小。

struct HeapObject {
    var metadata: UnsafeRawPointer // 原生指针
    var refcounts: uint64
}

OCobjc_object只有8字节,因为里面只有isa

struct objc_object {
    void *isa
}

3.4.2 Swift类的数据结构

前面我们已经分析出了Swift实例对象的数据结构,接下来我们就分析下Swift类的数据结构。

刚才我们已经确定了Swift实例对象的数据结构中的metaDataHeapMetadata类型,我们就以此为切入点来进行探索。

同时按住Command + Shift点击HeapMetadata,在HeapObject.h中找到相应定义,可以看到HeapMetadataTargetHeapMetadata的别名,接收参数InProcess

image.png

继续点击进入TargetHeapMetadata里面查看,发现其本质是一个模板类型,里面定义了初始化方法,传入了一个MetadataKind类型的参数kind(即InProcess),没有成员变量,继续查找父类,本结构体没有成员变量,父类可能有。

image.png

进入TargetMetadata定义,发现里面有一个StoredPointer类型的kind,即前面传进来的InProcess,通过查看StoredPointer类型的定义,发现其本质就是unsigned long

image.png

image.png

image.png

TargetHeapMetadataTargetMetaData定义中,均可以看出初始化方法中参数kind的类型是MetadataKind,进入其定义,发现里面有一句#include "MetadataKind.def"

image.png

点击进入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函数的定义。

image.png

点击进入查看实现,如果是Class,则直接对this(当前指针,即对象内存中的metadata)强转为ClassMetadata

image.png

点击查看ClassMetadata定义,发现其是TargetClassMetadata的别名,接收InProcess参数。

image.png

点击查看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
    ...
}

综上所述,当metadatakindClass时,继承链如下:

Class metadata inher chain.jpg

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)

image.png

总结

本文主要探索了ClassStruct的异、同点,以及为什么和什么情况下使用值类型Struct比较好,最后通过初始化方法和源码确定了Class及其实例对象的数据结构。