iOS-Swift类与结构体

2,768 阅读13分钟

这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战

Swift 中,类是引用类型,结构体是值类型。值类型在传递和赋值时将进行复制,而引用类型则只会使用引用对象的一个指向。那么下面就分别介绍类和结构体以及它们的区别:

结构体

在Swift标准库中,绝大多数的公开类型都是结构体,而枚举和类只占了很小一部分,比如 Bool、Int、Double、 String、Arrary、Dictionary等都是结构体。

结构体的构造方法

所有的结构体都有一个编译器自动生成的初始化器(initalizer、初始化方法、构造器、构造方法),比如下面这个例子:

截屏2022-01-24 下午5.16.15.png

编译器会根据情况,可能会为结构体生成多个初始化器,宗旨是:保证所有成员都有初始值。

自定义初始化器

struct Person {
    var name: String
    var age: Int = 20
    var sex: Bool?

    init(name: String) {
        self.name = name
    }
}

var person = Person(name: "candy")

如果某个成员是可为nil的,且是var类型则初始化时不必赋值

结构体的内存结构

struct Person {
    var name: String
    var age: Int
    var sex: Bool
}

var person = Person(name: "candy", age: 20, sex: true)

//对齐8个字节
print(MemoryLayout<Person>.alignment)
//实际占用25字节
print(MemoryLayout<Person>.size)
//结构体占用32字节,因为是8字节对齐,所以Bool类型也会分配8个字节的空间
print(MemoryLayout<Person>.stride)

类 

对于类,编译器并不会自动生成成员初始化器,而对于结构体编译器是提供了默认的初始化器的。Swift 中创建类和结构体的实例时必须为所有的存储属性设置一个合适的初始值,不然是会报错的。所以必须为类提供对应的指定初始化器,也可以为类指定便携初始化器。

指定初始化器

调用父类的指定初始化器调用之前,必须初始化完成当前类的所有属性

class Person {
    var age: Int
    var name: String
    
    init(_ age: Int, _ name: String) {
        self.age = age
        self.name = name
    }
}

便捷初始化器

使用convenience 修饰,必须调用同类的其他初始化器,然后再为属性赋值

convenience init(){
    self.init(20, "candy")
}

可失败初始化器

不满足某个条件时,存在初始化失败的情况,那就返回 nil

init?(_ age: Int, _ name: String) {
        if (age < 20) {
            return nil
        }
        self.age = age
        self.name = name
    }

必要初始化器

使用 required 修饰,表示所有该类的子类必须实现的初始化器

required init(_ age: Int, _ name: String) {
        self.age = age
        self.name = name
    }

类和结构体的区别

相同点:

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

主要的不同点有:

  • 类有继承,结构体没有
  • 类有析构函数用来释放其分配的资源
  • 引用计数允许对一个类实例有多个引用
  • 类型转换是你能够在运行时检查和解释类实例的类型

区别1:

  • 类是引用类型:也就是一个类类型的变量,存储的不是类的实例对象,而是实例对象的内存地址的引用
  • 结构体是值类型:相比引用类型存储的是地址,值类型存储的就是具体的实例
class Person {
    var age: Int
    var name: String
    required init(_ age: Int, _ name: String) {
        self.age = age
        self.name = name
    }
}

func test(){
    var person = Person(20, "candy")
    var person1 = person
    print(class_getInstanceSize(Person.self))
}

test()

运行项目,打印信息,输出如下:

截屏2021-12-27 下午2.59.05.png

withUnsafePointer:函数可以输出对应的内存地址

struct Person {
    var age: Int
    var name: String
    init(_ age: Int, _ name: String) {
        self.age = age
        self.name = name
    }
}

func test(){
    var person = Person(20, "candy")
    var person1 = person
    print("")
}
test()

运行项目,打印信息如下:

截屏2021-12-27 下午3.08.58.png

从实际打印信息也可以得出结论,对象实例打印出来的是指向堆上内存的地址,而结构体实例打印出来的直接是内容。也可以看出引用类型相当于浅拷贝,而结构体相当于深拷贝。

区别2:

其次引用类型和值类型在内存中存储的位置也是不同的,引用类型存储在堆上,值类型存储的栈上

  • 在iOS开发中,内存主要分为堆区、栈区、全局区、常量区、代码区五大区域

截屏2021-12-27 上午10.16.16.png

  • 栈区:存放程序临时创建的变量、函数的参数、局部变量,创建和释放由编译器完成;
  • 堆区:是由程序员分配和释放,用于存放运行中被动态分配的内存段,比如存放对象;
  • 全局静态区:全局区是编译时分配的内存空间,用来存放全局变量;
  • 常量区:编译时分配的内存空间,存放的常量;
  • 代码区:存放函数的二进制代码,它是可执行程序在内存中的镜像;

Mach-O是Mac上可执行文件的格式,Mach-O 文件有多个段( Segment ),每个段有不同的功能。然后每个段又分为很多小的 Section

  • TEXT.text : 机器码 
  • TEXT.cstring : 硬编码的字符串 
  • TEXT.const: 初始化过的常量 
  • DATA.data: 初始化过的可变的(静态/全局)数据 
  • DATA.const: 没有初始化过的常量 
  • DATA.bss: 没有初始化的(静态/全局)变量DATA.common: 没有初始化过的符号声明 

可以查看类/结构体在内存中的分布

frame varibale -L xxx

类与结构体的选择

如果定义的数据结构比较简单,结构体能满足的情况下,建议就用结构体。因为结构体的内存分配在栈上,系统自动管理,而对于类系统需要对类的内存大小进行分配以及析构等操作,相对于结构体性能是会差一些。

Swift代码编译流程:

iOS 开发的语言不管是 OC 还是 Swift 后端都是通过LLVM进行编译的,如下图所示:

截屏2021-12-27 下午2.25.01.png

  • OC 通过 clang 编译器编译成 IR, 然后再生成可执行文件.o
  • Swift 则是通过 Swift 编译器编译成 IR,然后再生成可执行文件 .o

截屏2021-12-27 下午2.25.13.png

  • 先是 Swift Code 通过 -dump-parse 进行语义分析解析成 Parse(抽象语法树)
  • Parse 经过 -dump-ast 进行语义分析语法是否正确
  • Sema 之后会把 Swift Code 降级变成 SILGen(Swift 中间代码),对于 SILGen 又分为未优化(-emit-silgen)和经过优化(-emit-sil)
  • 优化完的 SILGen 会有 LLVM 降级 成为 IR,然后 IR 再由后端代码编译成机器码

编译流程的命令:

// 分析输出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

查看编译称SIL后的代码

class Person {
var age = 20
var name = "Candy"
}

let person = Person()

进入到当前代码所在的位置,输入 swiftc main.swift -emit-sil 然后回车

截屏2022-01-25 下午2.22.22.png

关于SIL的语法说明,可以参考官方文档:github.com/apple/swift…

查看汇编代码

通过下面这样,打上代码断点后可以查看汇编代码

截屏2022-01-25 下午2.31.40.png

接下来打开汇编调试

截屏2022-01-25 下午2.30.12.png

截屏2022-01-25 下午2.29.25.png

通过汇编查看,Person 在进行初始化的时候,在底层会调用 __allocating_init 的函数,那么 __allocating_init 做了什么事情呢,跟进去看一下。在圈住的地方打一个断点,让断点走到 __allocating_init 这一行代码,按住 control 键,点击这个向下的按钮。

截屏2022-01-25 下午2.50.55.png

其实进入这个页面也看不出来什么

截屏2022-01-25 下午2.50.32.png

这个时候我们来看一下源码。源码可以去苹果官网下-swift源码下载地址。用 VSCode 打开下载好的 swift 源码,全局搜索 swift_allocObject 这个函数。

在 VSCode 打开 Swift 源码,如果想像 Xcode 一样,点击进行实现跳转,需要安装插件 C/C++ Extension Pack

HeapObject.cpp 文件中找到 swift_allocObject 函数的实现,并且在 swift_allocObject 函数的实现上方,有一个 _swift_allocObject_ 函数的实现。

截屏2022-01-25 下午2.57.25.png

在函数的内部会调用一个 swift_slowAlloc 函数,我们来看下 swift_slowAlloc 函数的内部实现:

截屏2022-01-25 下午3.02.39.png

swift_slowAlloc 函数的内部是去进行一些分配内存的操作,比如 malloc。所以就印证了引用类型->对象申请堆空间的过程。

OC 与 Swift 的区分调用

在调用 _swift_allocObject_ 函数的时候有一个参数,名为 metadata 的 HeapMetadata。以下是 HeapMetadata 跟进的代码过程:

截屏2022-01-25 下午3.06.38.png

截屏2022-01-25 下午3.09.14.png

在这里有对 OC 和 Swift 做兼容。调用的 TargetHeapMetadata 函数的时候,如果是 Swift 类,那么就是MetadataKind 类型参数, 如果是 OC 的类,那么参数为 isa 指针。MetadataKind 是一个 uint32_t 的类型。

截屏2022-01-25 下午3.11.44.png

那么 MetadataKind 的种类如下:

name                       Value

Class                      0x0
Struct                     0x200
Enum                       0x201
Optional                   0x202
ForeignClass               0x203
ForeignClass               0x203
Opaque                     0x300
Tuple                      0x301
Function                   0x302
Existential                0x303
Metatype                   0x304
ObjCClassWrapper           0x305
ExistentialMetatype        0x306
HeapLocalVariable          0x400
HeapGenericLocalVariable   0x500
ErrorObject                0x501
LastEnumerated             0x7FF

Swift 类底层的源码结构

接下来我们找到 TargetHeapMetadata 的继承 TargetMetadata(在 C++ 中结构体是允许继承的)。在 TargetMetadata 结构体中找到了 getTypeContextDescriptor 函数,代码如下:

截屏2022-01-25 下午3.18.38.png

可以看到,当 Metadatakind 是一个 Class 的时候,会拿到一个名为 TargetClassMetadata 的指针,来看看 TargetClassMetadata 的实现:

截屏2022-01-25 下午3.19.54.png

我们看到 TargetClassMetadata 继承于 TargetAnyClassMetadata,在这里看到了superclassisa

截屏2022-01-25 下午3.22.53.png

通过以上的分析,我们可以得出,Swift 类中的 metadata 数据结构大致如下:

struct Metadata {
   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
}

接下来我们做一个测试,通过 lldb 查看 Swift 类的内存结构,那么既然在 Swift 的底层,_swift_allocObject_ 函数返回的是 HeapObject 的指针类型,我们来看一下 HeapObject 的结构:

截屏2022-01-25 下午3.25.45.png

知道了 HeapObject 的源码结构之后,我们也假里假气的模仿源码,自己定义一个 HeapObjectrefCounts 可以先忽略,不管,主要看 metadata,通过上面看到的 Metadata 也还原出对应的源码。 我们将 Person 类转成 HeapObject 结构体,来打印HeapObject和Metadata的内存结构。

struct HeapObject {
    var metadata: UnsafeRawPointer
    var refCounts: UInt32
}

struct Metadata{
    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 Person {
  var age = 20
  var name = "Candy"
}

let person = Person()

// 将 person 转成 HeapObject 指针
let p_raw_ptr = Unmanaged.passRetained(person as AnyObject).toOpaque()

let p_ptr = p_raw_ptr.bindMemory(to: HeapObject.self, capacity: 1)
// 将 p_ptr 指针转成 HeapObject 的指针类型并打印出 HeapObject 的内存结构
print(p_ptr.pointee)

//将HeapObject中的metadata绑定成Metadata类型,并转成Metadata指针类型,数据大小可以使用MemoryLayout进行测量
let matadata = p_ptr.pointee.metadata.bindMemory(to: Metadata.self, capacity: MemoryLayout<Metadata>.stride).pointee

print(matadata)

截屏2022-01-25 下午3.35.12.png

通过打印验证了我们还原出来的底层结构是没有问题的。

这里补一个小知识点:

可以通过以下方法获取对象的指针

withUnsafeBytes(of: &person) { (ptr) -> Void in

 }

withUnsafePointer(to: &person) { (ptr) -> Void in
 }

假如通过这种方法传递对象的指针,就会在将指针转换成class对象的时候遇到问题。所以,这里需要用到Unmanaged。

Unmanaged是一个结构体,可以用来获取一个对象的指针,也可以将一个指针转换成一个对象:

// 获取指针
let ptr = UnsafeMutableRawPointer(Unmanaged<A>.passUnretained(person).toOpaque())
//获取对象
let obj = Unmanaged<A>.fromOpaque(ptr).takeUnretainedValue()

总结

所以就可以得到下面的这些结论

Swift 对象内存分配的流程:

__allocating_init -> swift_allocObject -> _swift_allocObject_ ->
swift_slowAlloc -> Malloc

Swift 对象的内存结构 HeapObject (OC objc_object) ,有两个属性: 一个是 Metadata ,一个是 RefCount ,默认占用 16 字节大小。 而对于 OC 对象,只存在一个 isa,默认占有8字节的大小。

方法

异变方法

Swift 中 class 和 struct 都能定义方法,唯一的区别就是 struct 值类型的属性不能被自身的实例方法修改。

举个例子:

struct Color {
    var red = 0, green = 0, blue = 0
    func buildColor(red paraRed: Int, green paraGreen: Int, blue paraBlue: Int) {
        red = 255
        green = 255
        blue = 255
    }
}

这样写完之后报这个错误

Cannot assign to property: 'self' is immutable

struct 中要想在实例方法中修改值类型的属性,需要在方法前面加载关键字 mutating ,这样相当于是传递的已初始化的地址。下面通过分析 Sil 来看加不加这个关键字的方法有什么区别。

再增加一个常规方法

struct Color {
    var red = 0, green = 0, blue = 0
    
    func test(){
        let r = self.red
    }
    
    mutating func buildColor(red paraRed: Int, green paraGreen: Int, blue paraBlue: Int) {
        red = 255
        green = 255
        blue = 255
    }
}

首先知道如何生成 sil 文件, 点击 File ->New -> Target,选择 other -> Aggregate 创建,创建完成之后,按着这个步骤配置

swiftc -emit-sil ${SRCROOT}/SwiftDemo/main.swift > ./main.sil && open main.sil

  截屏2021-12-29 下午9.39.56.png

然后选择新的建 Target 然后运行项目,就生成了 sil 文件。通过分析 sil 文件可以发现两个方法的区别   截屏2021-12-29 下午9.47.52.png

截屏2021-12-29 下午9.48.39.png

没有使用 mutating 相当于传递的是,而使用了 mutating 相当于传递的是地址。我们都知道,默认方法传递 self 参数, 我们发现两者的区别是 mutating 修饰的方法把 self 标记成了 inout 参数。这个关键字表明,当前参数类型是间接的,传递的是已经初始化过的地址,所以导致我们能修改值了。

异变方法的本质:对于异变方法,传入的 self 被标记成 inout 参数。无论在mutating方法内部发生什么,都会影响外部依赖类型的一切。

输入输出参数: 如果我们想让函数能够修改一个形式参数的值,而且希望这些改变在函数结束后依旧生效,那么就需要将形式参数定义成输入输出参数。在形式参数定义的类型前面加上 inout 关键字即可。

var red = 0
func buildColor(_ red: inout Int) {
    red = 255
}
buildColor(&red)

方法调度

在 OC 中调用方法其实就是发送消息 objc_msgsend, 而在 Swift 中是怎么调度方法的呢?

class Person {
    
    func test(){
        print("test")
    }
}
var p = Person()
p.test()

把metadata的地址值加上偏移量的值作为地址,取改内存地址的值赋值给 X8 寄存器,此时 X8 就是我们 test 函数的地址。

常见的寄存器指令:

mov: 将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于内存地址),如:
mov x1, x0 将寄存器X0的值复制到寄存器X1中Ӿldr: 将内存中的值读取到寄存器中,如:

ldr x0, [x1, x2] 将寄存器 X1 和 X2 的值相加作为地址,取该内存地址的值存储在寄存器 X0 中
bl: (branch)跳转到某地址(无返回)
blr: 跳转到某地址(有返回)

运行查看汇编代码:

截屏2021-12-30 上午11.50.38.png

test1 函数的调用过程:先找到 Metadata,确定函数地址(Metadata + 偏移量),执行函数基于函数表的调度。

这是生成 sil文件,查看文件再次证实了基于函数表的调度

截屏2021-12-29 下午10.51.24.png

Metadata 的数据结构,这个在上篇文章中有还原

struct Metadata{
    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
}

这里我们需要关注 typeDescriptor,不管是 class、 struct、 enum 都有自己的 Descriptor,就是对类的一个详细描述。我们可以通过分析 swift 源码查看这个Descriptor,在生成 classDescriptor 时调用了下面的方法

截屏2021-12-29 下午11.00.46.png

我们可以跟进去这个 addVTable() 方法

截屏2021-12-29 下午11.03.48.png

其实这个 B 就是 TargetClassDescriptor,这个时候我们就可以补全 TargetClassDescriptor 的结构了。

struct TargetClassDescriptor{
    var flags: UInt32
    var parent: UInt32
    var name: Int32
    var accessFunctionPointer: Int32
    var fieldDescriptor: Int32
    var superClassType: Int32
    var metadataNegativeSizeInWords: UInt32
    var metadataPositiveSizeInWords: UInt32
    var numImmediateMembers: UInt32
    var numFields: UInt32
    var fieldOffsetVectorOffset: UInt32
    var Offset: UInt32
    var size: UInt32
    //V-Table
}

Mach-O 是可执行文件的格式。常见的 .o、.a、.dylib、 Framework、dyld、.dsym 都是可执行文件。

这里举个类实例方法调用的例子:

class Person {
    
    func test1(){
        print("test1")
    }
    
    func test2(){
        print("test2")
    }
    
    func test3(){
        print("test3")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var p = Person()
        p.test1()
        p.test2()
        p.test3()
    }
}

截屏2021-12-30 上午8.15.50.png

我们 Swift 类的的信息是存储在这里的,前4个字节的地址就是当前类的地址

截屏2021-12-30 上午8.19.32.png

可以通过这个计算当前类在 Mach-O 文件中的内存地址

截屏2021-12-30 上午11.52.06.png

Data 区主要就是负责代码和数据记录的,这里就存储着类的具体信息,我们通过上面计算出类在Mach-O文件中的内存地址,然后再根据Descriptor的数据结构,计算出VTable的首地址   截屏2021-12-30 上午11.51.51.png

image list 打印程序运行的基地址

截屏2021-12-30 下午1.36.25.png

截屏2021-12-30 上午8.37.30.png

起始地址 + Mach-O = 函数在内存的地址 + 程序运行的基地址 = 函数在程序运行中的地址

根据 VTable 的数据结构,找到 Impl,还需要加上 Flags 和 Offset 就能得到 impl 的地址

然后再通过运行查看汇编代码,通过 Register read X8 的内存地址和上面得到的impl地址是否一样来验证。

通过计算得到函数的 impl 的地址

0x00000001040c4000+B7A4 = 0x1040CF7A0+4+FFFFC1FC = 0x2040CB9A4

通过汇编,读取 X8 寄存器的地址:

截屏2021-12-30 下午1.44.02.png

这样就验证了确实和猜想的一样,先找到 Metadata,确定函数地址(Metadata + 偏移量),执行函数基于函数表的调度,找到函数的 impl 然后执行。

截屏2021-12-30 上午8.55.57.png

结构体实例调用方法:

把上面 Person 类改成结构体,然后查看汇编

截屏2021-12-30 下午2.56.12.png   发现直接执行的地址,这就属于静态派发。

继承 NSObject 方法的类实例调用方法:

class Person: NSObject {
    
    func test1(){
        print("test1")
    }
    
    func test2(){
        print("test2")
    }
    
    func test3(){
        print("test3")
    }
    
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var p = Person()
        p.test1()
        p.test2()
        p.test3()
    }
}

截屏2021-12-30 下午3.01.50.png

发现这里的调用和之前没继承 NSObject 时是一样的,也是函数表派发的方式。

方法调度方式总结

截屏2022-01-25 下午1.45.11.png

影响函数派发方式

  • final:添加了 final 关键字的函数不能被重写,不支持继承,使用静态派发,不会在VTable中出现,且对 objc 运行时不可见。
  • dynamic:函数均可添加 dynamic 关键字,为非 objc 类和值类型的函数增加动态性,但派发方式还是函数表派发。
  • @objc:该关键字可以将 Swift 函数暴露给 Objc 运行时,依旧是函数表派发。
  • @objc + dynamic:消息派发的方式,继承自 NSObject 后可以给方法暴露给 OC 调用

通过上面也可以看出,OC 和 Swift 调用函数的方法完全不相同,OC 中的所有方法调用,最终都是转换成runtime中的一个C语言消息分发函数。

函数内联

如果开启了编译器优化,编译器会自动将某些函数变成内联函数(将函数调用展开成函数体)。其实在Release 模式默认开启优化,并且是按照速度去优化。

截屏2022-01-25 下午5.32.44.png

比如我们有一个函数,那么一旦调用 test() 这个函数,系统就会为这个函数分配栈空间,并且在栈空间进行分配局部变量的操作,函数执行完之后会对函数栈空间进行回收。假如 test()函数只是做了一件很简单的事情,这样不是浪费性能吗?比如:

func test(){
    print("end")
}

test()

然而内联函数要做的事情就是,将函数调用展开成函数体代码,这样就减少了函数的调用开销,不必再开辟回收函数的栈空间了。

打断点,运行下代码,查看汇编(Debug -> Debug Workflow -> Always show Disassembly)

截屏2022-01-25 下午5.39.22.png

这个 callq 调用的就是 test 方法

打开编译器优化,在运行看下情况

截屏2022-01-25 下午5.44.27.png

发现断点根本没有断到,然后也有打印。这个时候把断点打到输出函数那里

截屏2022-01-25 下午5.46.00.png

截屏2022-01-25 下午5.48.35.png

我们看汇编可以发现,print("end")代码被直接放到 main 函数当中,也就是编译器帮我们做了内联操作。

函数不会发生内联的情况:

1、函数体比较长的函数不会被内联(如果函数体比较长,函数被调用次数也非常多,进行内联操作生成的汇编代码会非常多,也就是机器码会变多,最终代码体积变大安装包变大);

2、递归调用不会被内联;

3、包含动态派发(类似OC动态绑定)的函数不会被内联;

以上就是类和结构体的区别总结。