浅析结构体与类

289 阅读5分钟

Swift中类和结构体是构建代码所用的一种通用且灵活的构造体。可以使用完全相同的语法规则来为类和结构体定义属性(变量,常量)和添加方法,从而扩展类和结构体的功能。

一、结构体

在 Swift 的标准库中,绝大多数的公开类型都是结构体,而枚举和类只占很小一部分。比如 BoolIntDouble、 StringArrayDictionary 等常见类型都是结构体。下面我们先定义一个结构体:

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

let p = HCPerson(name: "Mike", age: 25, sex: true)

所有的结构体都有一个编译器自动生成的孵化器(initializer,初始化方法、构造器、构造方法)。如上所示,可以传入所有成员值,用以初始化所有成员(存储属性,Stored Property)。

1. 结构体的初始化器

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

image.png 下面代码也是可以编译通过的,因为可选项都有一个默认值nil

image.png

2. 自定义初始化器

一但在定义结构体时自定义初始化器,编译器就不会帮自动生成其他初始化器。

image.png 在对结构体进行初始化的时候,必须保证结构体的成员都有值,所以当我们对结构体的某个成员变量设置初始值时,生成的初始化器可以不用传该成员变量的参数赋值。 image.png

3. 探究结构体初始化器的本质

我们来看看,以下两段代码完全是等效的

代码一:

struct HCPerson {
    var name: String
    var age: Int
    init() {
        self.name = "Mike"
        self.age = 25
    }
}
let p1 = HCPerson()

let p1 = HCPerson()打断点,然后lld调试输入si,进去HCPerson.init()中 image.png image.png

代码二:

struct HCPerson {
    var name: String = "Mike"
    var age: Int = 25
}
let p1 = HCPerson()

image.png image.png 经过对比发现,代码一和代码二的init方法完全一样,也就是说自己写与编译器生成初始化器是没区别的,效率是一样的。

4. 结构体的内存结构

我们来看下面这个结构体,HCPerson 有 name, age,sex 三个成员。 image.png 打印出其内存对齐字节数和占用的内存,在 64 位系统下,结构体中,name占16个字节,Int 占8个字节,Bool 占1个字节 ,所以 HCPerson 一共占 25 个字节,但是因为要遵守内存对齐原则(8个字节),所以系统会分配 32 个字节来存储 HCPerson

二、类

类的定义和结构体类似,但编译器并没有为类自动生成可以传入成员值的初始化器。

image.png

1.指定初始化器

当类的成员没有初始值时,必须自定义初始化器,初始化成员值。

class HCPerson {
    var name: String
    var age: Int
    var sex: Bool
    init(name: String, age: Int, sex: Bool) {
        self.name = name
        self.age = age
        self.sex = sex
    }
}
let p = HCPerson(name: "Mike", age: 25, sex: true)

如果类的所有成员都在定义的时候指定了初始值,编译器会为类生成无参的初始化器,成员的初始化是在这个初始化器中完成的。

class HCPerson {
    var name: String = "Mike"
    var age: Int = 25
    var sex: Bool = true
}
let p =  HCPerson()

2.便捷初始化器

我们可以为类提供一个便捷初始化器,便捷初始化器需要在 init 前用 convenience 修饰。 image.png 如上代码所示,便捷初始化器必须从相同的类里调用另一个初始化器,并且最终必须调用一个指定初始化器。

3.可失败初始化器

当初始化的值不满足某个条件时我们需要给初始化方法返回一个nil,那么可以在 init 后面加上一个可选项来修饰。 image.png 如上所示,当 p2 不满 age<18 岁时返回nil。

4.必要初始化器

必要初始化器需要用required来修饰指定初始化器,表明所有子类都必须实现该初始化器,通过继承或者重写实现。如果子类重写了required初始化器,也必须加上required,不用加override。 image.png 如代码所示,required来修饰指定初始化器,该类的子类都必须实现该初始化器,required保证了所有该类的子类都有该初始化器。

5.反初始化器

deinit叫做反初始化器,类似于C++的析构函数、OC中dealloc方法,当类的实例对象被释放内存时,就会调用实例对象的deinit方法。deinit不接受任何参数,不能写小括号,不能自行调用,父类的deinit能被子类继承,子类的deinit实现执行完毕后会调用父类的deinit方法。

image.png

三、结构体与类的本质区别

结构体与类的本质区别为结构体是值类型,类是引用类型(指针类型)。那么它们还有一个最直观的区别就是存储的位置不同:一般情况,值类型存储的在栈上,引用类型存储在堆上image.png image.png 可以看到,结构体的数据是直接存到栈空间的,类的实例是用指针指向堆空间的内存,指针在栈空间。

1. 值类型

值类型赋值给 varlet 或者给函数传参,是直接将所有内容拷贝一份。类似于对文件进行 copy、paste操作,产生了全新的文件副本。属于深拷贝(deep copy)。

image.png 我们可以看到在修改 p2 的 x 后,对 p1 并没有影响,这属于深拷贝。 我们来看数组的打印结果。 image.png

在 Swift 标准库中,为了提升性能,StringArrayDictionarySet采取了Copy On Write的技术, 比如仅当有“写”操作时,才会真正执行拷贝操作。对于标准库值类型的赋值操作,Swift 能确保最佳性能,所有没必要为了保证最佳性能来避免赋值。
建议:不需要修改的,尽量定义成 let

2.引用类型

引用赋值给var、let或者给函数传参,是将内存地址拷贝一份。类似于制作一个文件的替身(快捷方式、链接),指向的是同一个文件。属于浅拷贝(shallow copy)。

image.png

在Swift中,创建类的实例对象,要向堆空间申请内存,大概流程如下:

  • Class.__allocating_init()
  • libswiftCore.dylib: swift_allocObject
  • libswiftCore.dylib: swift_slowAlloc
  • libsystem_malloc.dylib: malloc_zone_malloc 在Mac,iOS中的malloc函数分配的内存大小总是16的倍数(为了做内存优化)。如果底层调用了alloc或malloc函数,说明该对象存在堆空间,否则就是在栈空间。
    通过class_getInstanceSize可以得知类的对象真正使用的内存大小。

image.png

  • 通过打印得知,HCPerson 的大小为 40 个字节,系统分配 HCPerson 的内存大小为 48 个字节。
  • HCPerson 中,因为类存储在堆空间中,它前面会有 8 个字节存放类型信息,8个字节存引用计数,weight 占 8 个字节,height 占 8 个字节,sxe 占 1 个字节,加起来一共是 33 个字节,根据内存对齐原则(8 个字节),所以 HCPerson 的大小为 40 个字节。
  • 因为在Mac、iOS中的 malloc 函数分配的内存大小总是16 的倍数,所以最终系统会分配 HCPerson 的内存大小为 48 字节。

3.汇编分析结构体

首先创建结构体,打断点进入汇编: image.png 打开汇编调试: image.png image.png image.png 这里一般有个小规律:

  • 内存地址格式为:0x4bdc(%rip),一般是全局变量,全局区(数据段)
  • 内存地址格式为:-0x78(%rbp),一般是局部变量,栈空间
  • 内存地址格式为:0x10(%rax),一般是堆空间 从上面汇编代码中可以分析出结构体创建的对象是在栈中存储的。

4.汇编分析类

接下来我们通过汇编来查看类的初始化流程,我们打个断点如下: image.png image.pngcallq...__allocating_init()...函数处打断点,lldb输入si进入函数体:

image.png 可以看到,进入到 __allocating_init 的内部实现后,发现它会调用一个 swift_allocObject 函数。 image.pnglibswiftCore.dylib swift_allocObject:中,并且在callq...swift_slowAlloc处打断点进入: image.png malloc_zone_malloc出现了,这时候继续进入函数体 image.png 经过上面分析,可以清晰的看到,对象是在堆空间存储的。

接下来我们来看一下swift源码,源码可以去苹果官网下-swift源码下载地址。用 Xcode 打开下载好的 swift 源码,全局搜索 swift_allocObject 这个函数。在 HeapObject.cpp 文件中找到 swift_allocObject 函数的实现,并且在 swift_allocObject 函数的实现上方,有一个 _swift_allocObject_ 函数的实现。

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

image.png swift_slowAlloc 函数的内部是去进行一些分配内存的操作,如上图代码中的malloc。由此也可以清晰的看到,创建类的实例对象在堆空间申请内存的过程。

四、结构体与类的选择

1.总览

结构体和类是在应用程序中存储数据和模型行为的不错选择,但是结构体和类的相似性使其很难选择。在向你的应用程序添加数据类型时,可以参考以下建议:

  • 默认情况下使用结构体。 Default
  • 需要 Objective-C 互操作性时,请使用类。
  • 需要控制数据模型的同一性时,请使用类。
  • 需要通过协议继承共享行为实现时,请使用结构体。

2.默认选择结构体

使用结构体表示常见数据类型。Swift 中的结构体包含许多功能,这些功能仅限于其他语言中的类:它们可以包含存储属性,计算属性和方法。而且,Swift结构体可以通过遵守协议来获得默认的实现行为。Swift 标准库和 Foundation 中对经常使用的数据类型都是用结构体实现的,例如数字,字符串,数组和字典。

使用结构体可以更轻松地推理代码片段,而无需考虑应用程序的整体状态。由于结构体是值类型(与类不同),因此结构体的局部更改对应用程序的其余部分不可见,除非您有意将这些更改作为应用程序流程的一部分进行交流。结果,您可以查看一段代码,并更有信心对该部分中的实例进行显式更改,而不是通过与切向相关的函数调用进行不可见的更改。

3.需要与Objective-C互操作性时使用类

如果您使用需要处理数据的 Objective-C API,或者需要适应你的数据模型为继承或者定义在 Objective-C 框架中的类,则可能需要使用类和类继承来对数据进行建模。例如,许多 Objective-C 框架都公开了您希望子类化的类。

4.需要控制同一性时使用类

Swift 中的类带有内置的同一性概念,因为它们是引用类型。这意味着当两个不同的类实例为其每个存储的属性具有相同的值时,同一运算符(===)仍将它们视为不同。这也意味着,当您在应用程序中共享一个类实例时,对该实例所做的更改对于包含对该实例的引用的代码的每一部分都是可见的。当您需要实例具有这种 同一性时,请使用类。常见的用例是文件管理,网络连接和共享的硬件中介,如CBCentralManager

例如,如果您有一个表示本地数据库连接的类型,则用于管理对该数据库访问权限的代码需要完全控制从应用程序查看的数据库状态。在这种情况下,使用一个类是适当的,但是一定要限制应用程序的哪些部分可以访问共享数据库对象。

重要

小心对待同一性。在整个应用程序中普遍共享类实例使逻辑错误更有可能发生。您可能不会预期更改大量共享实例的后果,因此,正确编写此类代码需要做更多的工作。

5.当您不控制同一性时使用结构体

当你的数据模型包含的信息不需要 同一性 时,请使用 结构体。

例如,在查询远程数据库的应用中,实例的同一性可以完全由外部实体拥有,并可以通过标识符进行通信。如果应用程序模型的一致性存储在服务器上,则可以将记录建模为带有标识符的结构体。在下面的示例中,jsonResponse 包含来自服务器的编码 PenPalRecord 实例:

struct PenPalRecord {
    let myID: Int
    var myNickname: String
    var recommendedPenPalID: Int
}
var myRecord = try JSONDecoder().decode(PenPalRecord.self, from: jsonResponse)

像PenPalRecord模型类型的本地更改是非常有用的。例如,一个应用可能会响应用户反馈而推荐多个不同的 penpals。因为该PenPalRecord 结构体无法控制基础数据库记录的 同一性,所以不存在对本地 PenPalRecord 实例所做的更改意外更改数据库中值的风险。

如果应用程序的另一部分发生更改 myNickname,并将更改请求提交给服务器,则更改不会错误地接受最近拒绝的penpals建议。因为该 myID 属性被声明为常量,所以不能在本地更改。因此,对数据库的请求不会意外更改错误的记录。

6.使用结构体和协议建模来继承和共享行为

结构体和类都支持一种继承形式。结构体和协议只能采用协议;他们不能从类继承。但是,还可以使用协议继承和结构体对可以使用类继承构建的继承层次结构体进行建模。

如果要从头开始建立继承关系,则最好使用协议继承。协议允许类,结构体和枚举参与继承,而类继承仅与其他类兼容。当您选择如何对数据建模时,请先尝试使用协议继承构建数据类型的层次结构体,然后在结构体中采用这些协议。