Swift进阶(三)—— 属性

7,250 阅读11分钟

在Swift中,属性将值与特定的类、结构体或枚举关联。其中分为存储属性和计算属性。属性也可以直接与类型本身关联,这种属性称为类型属性。

存储属性

存储属性是一个作为特定类和结构体实例一部分的常量或变量。存储属性要么是变量存储属性(由var关键字引入)要么是常量存储属性(由let关键字引入)。存储属性可以说是类或结构体的成员变量,因为它和类或者结构体的内存结构相关。

  • let用来声明常量,常量的值一旦设置好便不能再被更改。
  • var用来声明变量,变量的值可以在将来设置为不同的值。 我们开通过下面的代码进行一个对比: 截屏2022-01-13 下午2.45.56.png
  • 从汇编角度去分析这两个关键字
let age = 18
var y = 12

截屏2022-01-13 下午2.53.49.png 从汇编的角度看,这两个没什么区别,都是把值存储到寄存器中。

使用lldb调试内存 截屏2022-01-13 下午3.15.38.png 截屏2022-01-13 下午3.18.58.png 截屏2022-01-13 下午3.16.05.png 从内存地址来看,这两个变量存储的内存地址是连续的,而且都存储在DATA中。

  • 从sil文件去分析 截屏2022-01-13 下午3.22.45.png 通过sil文件可以看出来,var关键字修饰的变量age里面有 getset方法,而let关键字修饰的变量x里面没有set方法,因此变量x无法被修改。

计算属性

存储的属性是最常见的,除了存储属性,类、结构体和枚举也能够定义计算属性,计算属性并不存储值,他们提供gettersetter来修改和获取值。对于存储属性来说可以是常量或变量,但计算属性必须定义为量。于此同时我们书写计算属性时候必须包含类型,因为编译器需要知道期望返回值是什么。

截屏2022-01-13 下午5.38.26.png

我们查看一下这个结构体的sil文件: 截屏2022-01-13 下午5.43.56.png 我们发现这个计算属性area没有@_hasStorage,也就是说这个属性可能是个方法,没有存储在内存中,我们通过lldb调试验证一下。 截屏2022-01-13 下午5.48.34.png 调试结果显示,square实例里面确实没有存储area属性。因此计算属性area是方法,不会占用实例内存。 我们使用sil文件查看areagettersetter方法 截屏2022-01-14 上午10.29.09.png 截屏2022-01-14 上午10.29.39.pnggetter方法中,直接是调用了square实例对象的width存储属性的值进行相关操作。而在setter方法中,如果没有手动新增输入参数,那么会自动生成一个默认参数newValue,外部的值传进来的时候是直接赋值给newValue。所以计算属性根本不会有存储在实例的成员变量,那也就意味着计算属性不占内存,并且在计算属性中直接修改存储属性的值,也是直接修改了,并没有调用存储属性的setter方法。

属性观察者

截屏2022-02-06 下午9.54.55.png 截屏2022-02-06 下午9.58.24.png 属性观察者用来观察属性的值的变化,当属性值将要改变时,会调用willSet,即使这个值与原有的值相同。当属性值已经改变之后就会调用didSet

我们查看一下sil文件,查看subjectNamesetter方法 截屏2022-02-06 下午10.12.03.png 我们可以看到,在对属性值进行修改前,会先去调用willSet。然后就行属性值修改。在修改完成后,调用didSet

这里就会引出一个问题,既然属性值改变就会调用属性观察者,那么对这个属性进行初始化时,属性值发生变化,是否会调用属性观察者? 截屏2022-02-06 下午10.20.13.png 截屏2022-02-06 下午10.20.17.png 代码运行后,我们可以看到,当进行初始化时,并没有调用willSetdidSet,这是什么原因呢?我们去查看sil文件,找到文件里面的init方法。 截屏2022-02-06 下午10.37.48.png 我们可以看到,首先会使用ref_element_addr去获取subjectName的内存地址。然后在初始化的时候,是把字符串直接拷贝到subjectName的内存地址里面,没有调用setter方法。当初始化还未完成的时候,某些属性还未初始化,去访问存储属性可能会造成一些内存上的问题。

继承属性观察者

如果一个属性观察者被继承,那么调用顺序会是怎么样? 截屏2022-02-07 下午9.20.06.png 截屏2022-02-07 下午9.20.34.png 通过打印log我们可以看到,首先调用的是子类的willSet,然后是父类的willSet,接着是父类的didSet,最后是子类的didSet。为什么是这个顺序呢?我们通过sil文件来分析一下 截屏2022-02-07 下午9.36.25.png 截屏2022-02-07 下午9.35.21.png 通过查看LGPartTimeTeacher.age.setter方法,我们可以看到,在调用完子类的willSet方法后,会去调用父类的setter方法,然后在调用子类的didSet。同时在父类的setter方法中,先调用了父类的 willSet方法,最后调用父类的didSet方法。

延迟属性

延迟属性使用lazy关键字来定义,当你第一次使用到这个属性的时候才会进行初始化。

延迟属性必须使用var关键字来修饰,因为let必须在实例的初始化方法完成之前就拥有值。 截屏2022-02-08 下午6.39.57.png 我们通过lldb打印来看一下延迟属性在使用之后,变量s的内存变化。 截屏2022-02-08 下午6.40.12.png 我们通过x/8g命令查看内存地址,前16字节存储的是类的metaDatareferenceCount。后面8字节则是存储的是age的值,我们可以发现,在未开始使用属性时,没有存储age的值。当打印完s.age属性后,再去查看s的内存地址,发现存储了初始值18

延迟属性时怎么实现的呢?我们使用sil文件来分析一下。 截屏2022-02-08 下午6.49.48.png 截屏2022-02-08 下午9.22.07.png 通过属性分析我们可以看到延迟属性被声明为可选类型,初始化的时候被赋值一个Optional.none,同时存储属性还会被final关键字修饰,也就是说延迟属性不允许被重写。

我们再去看一下lazy属性的getter方法里面发生了什么 截屏2022-02-08 下午9.39.26.png 我们看一下第一个红框里面的代码:

switch_enum %4 : $Optional<Int>, case #Optional.some!enumelt: bb1, case #Optional.none!enumelt: bb2 // id: %6

这里面对延迟属性的可选值进行判断,如果该属性有值,那么执行bb1的代码,然后执行bb3,直接返回这个值。如果这个属性没有值,那么就执行bb2的代码,把初始值赋值给属性,并执行bb3返回值。

使用延迟属性注意点

  • 如果多条线程同时第一次访问延迟属性,无法保证属性只被初始化1次。因此,延迟属性不是线程安全的。
  • 当结构体包含一个延迟属性时,只有var修饰实例 才能访问延迟属性,因为延迟属性初始化时需要改变结构体的内存。

类型属性

属性根据是否使用实例去访问可分为实例属性(Instance Property)和类型属性(Type Property)。类型属性和实例属性一样,也可以分为存储属性和计算属性。类型属性需要使用static关键字来声明。

类型存储属性

和实例存储属性不同,类型存储属性必须要有初始值,因为类型没有像实例那样的 init 初始化器来初始化存储属性。

类型计算属性

在类里面类型计算属性不仅可以用static修饰也可以使用class修饰。

类型属性sil文件分析

类型属性代码如下: 截屏2022-02-09 下午8.19.04.png 类型属性的sil文件如下: 截屏2022-02-09 下午8.36.39.png 类型属性和实例属性定义差不多,只是在属性前会被static修饰。从上面文件中我们看到,Swift会为每一个类型属性声明一个全局变量,以及类型属性初始化的token。

我们可以得到类型属性的2个特点:

  • 类型属性其实就是一个全局变量
  • 类型属性只会被初始化一次

那么类型属性怎么保证只会被初始化一次呢?

截屏2022-02-09 下午8.45.56.png 我们先在main函数里面找到LGTeacher.age,我们发现在函数里面,会调用到LGTeacher.age.unsafeMutableAddressor这个方法,也就是去访问age的内存地址。

然后我们去寻找LGTeacher.age.unsafeMutableAddressor方法。 截屏2022-02-09 下午8.56.28.png 从方法里面我们可以看到,首先去获取age属性对应的全局变量token的内存地址,转成RawPointer指针,然后去初始化一次age属性。最后返回全局变量age属性的内存地址。

接着我们去看一下function_ref one-time initialization function for age方法。 截屏2022-02-09 下午9.50.34.png 我们可以看到,在这个方法里面,先是获取全局变量age的内存地址,然后把Int类型值18存到这个内存地址里面。至此,我们完成了age属性的初始化。

最后我们看一下 builtin “once” 是什么函数。我们把sil指令代码转成IR代码看一下。调用的方法名 s4main10HWLTeacherC1h_WZ 可以看到这里builtin "once" 指的是 swift_once 截屏2022-02-09 下午9.59.55.png 通过源码查找swift_once的实现 16414764427241.jpeg 我们看到了dispatch_once_f,也就是说和OC一样,都是使用了dispatch_once确保static属性只会被初始化一次。

Swift单例模式

由于Swift不允许使用dispatch_once来使用单例。因此我们可以利用Static属性只初始化一次的特性来设计单例模式。代码如下: 截屏2022-02-09 下午10.09.41.png 通过把init函数私有,保证外部想使用LGTeacher实例只能去调用shareInstance,达到单例模式的效果。

属性在Mahco文件的位置信息

我们通过swift源码的分析,已经知道了swift类的本质是HeapObject,他有两个成员变量metadatarefcount,在metadata中存放了Description

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:TargetClassDescriptor 
    var iVarDestroyer: UnsafeRawPointer
}

而在Swift进阶(二) —— 方法探究中,我们知道了TargetClassDescriptor,也知道了TargetClassDescriptor的结构如下:

class 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
}

我们这次去了解一下fieldDescriptor,里面存储的是属性信息。

我们先去通过源码寻找fieldDescriptor 截屏2022-02-09 下午10.30.27.png 其中,FieldRecordIterator实际上是一个数组,我们查看FieldRecordIterator的结构体,发现里面存储的是一个FieldRecord类。因此,我们可以得出fieldDescriptor的具体结构。

struct FieldDescriptor { 
    MangledTypeName int32 
    Superclass int32 
    Kind uint16 
    FieldRecordSize uint16 
    NumFields uint32 
    FieldRecords [FieldRecord] 
}

其中,NumFields代表了当前有几个属性,而FieldRecord则是记录了每个属性的信息。

接下来我们查看一下FieldRecord截屏2022-02-09 下午10.38.44.png 通过源码,我们可以得到FieldRecord类的结构如下:

struct FieldRecord{
    Flags uint32 
    MangledTypeName int32 
    FieldName int32
}

Swift进阶(二) —— 方法探究中,我们知道了如何查找V-table的地址,这次我们使用同样的方法来查找属性地址。

首先,我们要先找到classDescriptor 截屏2022-02-09 下午10.49.27.png 计算ClassDescriptor在Mach-O 中的偏移地址,然后减去虚拟基地址,找到ClassDescriptor的地址

 0x3EE0 + 0xFFFFFE14 =  0x100003CF4
 0x100003CF4 - 0x100000000  = 0x3CF4

然后找到3CF4在Mach-O 中的位置 截屏2022-02-09 下午10.55.04.png 红框标识的地址就是ClassDescriptor的首地址。

因为fieldDescriptorClassDescriptor的第五个属性,因此我们要向后偏移16个字节 截屏2022-02-09 下午10.59.15.png 红框标识的就是fieldDescriptor的偏移地址。所以fieldDescriptor的实际地址为

3D04 + 01B4 = 0x3EB8

在Mach-O中找到0x3EB8,这就是fieldDescriptor的首地址,后面的地址存储的就是fieldDescriptor的成员变量。我们通过上面fieldDescriptor的成员变量的字节大小可知,往后面偏移16个字节就是FieldRecords的地址。 截屏2022-02-09 下午11.09.36.png 因此0x3EC8就是FieldRecords的首地址。而FieldRecords里面是FieldRecord,因此我们可以按照FieldRecord的结构FlagsMangledTypeNameFieldName顺序寻找这三个变量的偏移地址。我们要获取属性名称,因此我们要去寻找FieldName的偏移地址,FieldName的偏移地址如下: 截屏2022-02-09 下午11.09.36.png 由此我们可以计算出FieldName的地址

3EC8 + 4 + 4 + FFFFFFDD = 0x100003EAD
0x100003EAD - 0x100000000 =  3EAD

找到 3EAD 可以看到属性的名称 截屏2022-02-09 下午11.25.48.png 最终在Macho文件reflstr字段中找到属性名称的地址,其中61 67 65 00是属性age的地址,61 67 65 31是属性age1的信息。