swift-属性的探究

703 阅读10分钟

前言

我们开发的过程中经常用到的是属性,不管是我们的类,结构体还是枚举都会用到,那么属性到底有什么用法?或者属性的本质是什么?我们一探究竟!

1.存储属性

我们先看一段代码

class SPClass {
  let age: Int
  var height: Double
  init(age: Int, height: Double) {
    self.age = age
    self.height = height
  }
}

struct SPStruct {
  let age: Int
  var height: Double
}

let t1 = SPClass(age:20, height: 1.8)
t1.age = 20
t1.height = 1.0
t1 = SPClass(age:20, height: 1.8)

var t2 = SPClass(age:20, height: 1.8)
t2.age = 20
t2.height = 1.0
t2 = SPClass(age:20, height: 1.8)

let t3 = SPStruct(age:20, height: 1.8)
t3.age = 20
t3.height = 1.0
t3 = SPStruct(age:20, height: 1.8)

var t4 = SPStruct(age:20, height: 1.8)
t4.age = 20
t4.height = 1.0
t4 = SPStruct(age:20, height: 1.8)

image.png 我们看到

  • t1是个let修饰的实例对象,但是对于他的var修饰的属性是可以修改的,但是不能修改t1实例对象本身
  • t2跟t1的区别是可以修改t2实例对象本身
  • t3这种值类型且用了let修饰,修改了var修饰的属性也是会保存的,因为修改属性等于修改结构体本身,跟let修饰矛盾
  • t4用var类型修饰的结构体可以修饰本身以及他用var修饰的属性

那么到底var和let的本质区别是什么呢,我们用汇编和sil的角度分别去看下:

1.1汇编的角度

var age = 18
let height = 10

从汇编角度对上述var和let修饰的变量进行汇编分析,我们看到:

image.png 汇编代码是一样的,都是将一个立即数存放到寄存器中,我们打印他们的内存地址:

image.png 发现他们存的地方是相同的内存空间,都在.__DATA.__common这个segmengt里面,且相差8个字节,唯一不同的是用let修饰的变量不能用&去他地址来访问,因为他是immutable不可变的

1.2sil的角度

@_hasStorage @_hasInitialValue var age: Int { get set }
@_hasStorage @_hasInitialValue let height: Int { get }

我们看到var修饰的变量有get和set方法,但是let修饰的变量只有get方法。我们这么解读:编译器默认会var修饰的变量生成get和set方法,但是let修饰的变量只会生成get方法,所有修改let修饰的变量会方法变量的set方法,但是没有知道到,所以会报错,所以let和var的本质区别是var有get和set方法,let只有get方法,所以我们未来不会修改的变量尽量都声明成let

2.计算属性

类,结构体,枚举除了可以定义存储属性,也可以定义计算属性,在使用计算属性的过程中我们需要注意的是:

  • 计算属性需要用var来修饰(因为本身返回值是不固定的
  • 定义的时后需要指明类型(因为编译器需要知道期望返回的是什么类型)

image.png 对于如下代码

struct SPStruct {
  var width: Int

  var area: Int {
    get{
      return width * width
    }
    set {
      self.width = newValue
    }
  }
}

var s = SPStruct(width: 1)
s.area = 3
let a = s.area

2.1汇编的角度

image.png 我们看到修改计算属性会调用他的set方法,获取计算属性会调用他的get方法

2.2sil的角度

struct SPStruct {
  @_hasStorage var width: Int { get set }
  var area: Int { get set }
  init(width: Int)
}
// SPStruct.area.setter
sil hidden @$s4main8SPStructV4areaSivs : $@convention(method) (Int, @inout SPStruct) -> () {
// %0 "newValue"                                  // users: %6, %2
// %1 "self"                                      // users: %4, %3
bb0(%0 : $Int, %1 : $*SPStruct):
  debug_value %0 : $Int, let, name "newValue", argno 1, implicit // id: %2
  debug_value %1 : $*SPStruct, var, name "self", argno 2, implicit, expr op_deref // id: %3
  %4 = begin_access [modify] [static] %1 : $*SPStruct // users: %7, %5
  %5 = struct_element_addr %4 : $*SPStruct, #SPStruct.width // user: %6
  store %0 to %5 : $*Int                          // id: %6
  end_access %4 : $*SPStruct                      // id: %7
  %8 = tuple ()                                   // user: %9
  return %8 : $()                                 // id: %9
} // end sil function '$s4main8SPStructV4areaSivs'
// SPStruct.area.getter
sil hidden @$s4main8SPStructV4areaSivg : $@convention(method) (SPStruct) -> Int {
// %0 "self"                                      // users: %3, %2, %1
bb0(%0 : $SPStruct):
  debug_value %0 : $SPStruct, let, name "self", argno 1, implicit // id: %1
  %2 = struct_extract %0 : $SPStruct, #SPStruct.width // user: %4
  %3 = struct_extract %0 : $SPStruct, #SPStruct.width // user: %5
  %4 = struct_extract %2 : $Int, #Int._value      // user: %7
  %5 = struct_extract %3 : $Int, #Int._value      // user: %7
  %6 = integer_literal $Builtin.Int1, -1          // user: %7
  %7 = builtin "smul_with_overflow_Int64"(%4 : $Builtin.Int64, %5 : $Builtin.Int64, %6 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1) // users: %9, %8
  %8 = tuple_extract %7 : $(Builtin.Int64, Builtin.Int1), 0 // user: %11
  %9 = tuple_extract %7 : $(Builtin.Int64, Builtin.Int1), 1 // user: %10
  cond_fail %9 : $Builtin.Int1, "arithmetic overflow" // id: %10
  %11 = struct $Int (%8 : $Builtin.Int64)         // user: %12
  return %11 : $Int                               // id: %12
} // end sil function '$s4main8SPStructV4areaSivg'

我们看到编译器会给我们的计算属性生成setget方法,我们注意seter方法里的debug_value %0 : $Int, let, name "newValue", argno 1,编译器会默认生成一个名字叫newValue的参数

那么let修饰的存储属性和只有get方法的计算属性什么区别呢?我们直接sil一下:

struct SPStruct {
  @_hasStorage let width: Int { get }
  var area: Int { get }
  init(width: Int)
}

他们的相同点是都只有get方法,不通点是let修饰的存储属性具有存储的标记,是占用实际的内存空间的,且在默认的初始化器里面带上默认参数。而计算属性只有对应的get方法

另外值得注意的是我们开发的过程中经常会用private(set)来修饰一个存储属性,那么其实意味着在内部是读写的对外是只读的,比如如下代码:

class SPClass {
  private(set) var width = 0
  func test() {
    self.width = 1
  }
}

var s = SPClass()
s.width = 2

image.png 编译器会抱错,因为width对外只读,不能修改 编译成sil如下:

class SPClass {
  @_hasStorage @_hasInitialValue private(set) var width: Int { get set }
  func test()
  @objc deinit
  init()
}

小结下我们的计算属性其实他的本质是set和get方法

3.属性观察器

class SPClass {
  var subjectName: String = "" {
    willSet {
      print("subjectName will set value \(newValue)")
      
    } 
    didSet {
      print("subjectName has been changed \(oldValue)")
    }
  }
}

var s = SPClass()
s.subjectName = "aaa"

属性修改之前会调用willSet方法,didSet方法,我们sil看一下:

image.png 我们看到确实变量的赋值的前后分别有willSet方法和didSet方法 在使用属性观察器的过程中我们需要注意几点:

  • 初始化不会触发属性观察器
  • 计算属性没有willSet,didSet
  • 有继承关系的属性观察器调用顺序

3.1初始化不会触发属性观察器

class SPClass {
  var subjectName: String = "" {
    willSet {
      print("subjectName will set value \(newValue)")
      
    } didSet {
      print("subjectName has been changed \(oldValue)")
    }
  }
  init(subjectName: String) {
    self.subjectName = subjectName
  }
}

var s = SPClass(subjectName: "aaa")

我们sil看一下:

image.png 我们看到对于字符串aaa的传值过程是赋值的操作,没有触发willSet方法和didSet方法

3.2计算属性没有willSet,didSet

class SPClass {
  var jisuan: String {
    get {
      return ""
    }
    willSet {
      print("jisuan will set value \(newValue)")
      
    } didSet {
      print("jisuan has been changed \(oldValue)")
    }
  }
}

image.png 我们看到编译器会报错,那么怎么对计算属性进行观察呢,其实啊计算属性本身有setget方法,想要观察属性,直接在里面写观察的代码就好,另外计算属性的属性不需要有初始值,所以没必要多次一举再加willSet方法和didSet方法

3.3有继承关系的属性观察器调用顺序

class SPClass {
  var subjectName: String = "" {
    willSet {
      print("subjectName will set value \(newValue)")
      
    } didSet {
      print("subjectName has been changed \(oldValue)")
    }
  }
  
  init(subjectName: String) {
    self.subjectName = subjectName
  }
}

class SPSubClass: SPClass {
  override var subjectName: String {
    willSet {
      print("override subjectName will set value \(newValue)")
      
    } didSet {
      print("override subjectName has been changed \(oldValue)")
    }
  }
  
  override init(subjectName: String) {
    super.init(subjectName: subjectName)
    self.subjectName = "bbb"
  }
}


var s = SPSubClass(subjectName: "aaa")

显然再初始化完成之后调用的self.subjectName = "bbb"会触发属性观察器,我们看到控制台打印:

image.png 我们看到子类的willSet方法和didSet方法直接回调用父类的willSet方法和didSet方法,我们不妨sil一下:

image.png

image.png 确实我们看到子类的setter方法里面会调用父类的willSet方法和didSet方法,这两个方法之间会调用父类的willSet方法和didSet方法,验证了我们的控制台打印结果。

4.延迟存储属性

延迟存储有两点需要注意:

  • 用关键字 lazy 来标识一个延迟存储属性
  • 延迟存储属性的初始值在其第一次使用时才进行计算

比如我们有这样的代码

class SPClass {
  lazy var width: Int = 18
}

var s = SPClass()
print(s.width)

在赋值之前和之后我们控制台观察下变化

image.png 我们看到类初始化完成并没有初始化延迟属性,而是在第一次访问我们的延迟属性的时候对其进行了初始化,我们sil一下:

image.png 我们看到编译器生成的延迟属性是一个可选类型的枚举并且用final关键字修饰

image.png 在类的初始化方法里面我们看到延迟属性会初始化Option.none

image.png 然后在访问属性的时候会取延迟属性的值进行模匹配:

  • 匹配了Option.none,会跳转到bb2,赋值Option.some,再返回
  • 匹配了Option.some, 会跳转到bb1,直接返回

所以我们可以用闭包表达式来初始化延迟属性,在里面会有些申请内存的操作,在不访问延迟属性时候会赋值Option.none,就不会申请内存,可以节省空间

image.png 需要注意的是延迟属性并不是线程安全的,比如两个线程同时访问改延迟属性,第一个线程模式匹配到Option.none然后进行赋值,还没返回的时候第二个线程也访问了该延迟属性,同样匹配到的是Option.none然后进行赋值,所以说延迟属性线程不安全可能会被初始化多次

5.类型属性

对于类型属性我们需要知道两点:

  • 类型属性其实就是一个全局变量
  • 类型属性只会被初始化一次
class SPClass {
  static var width: Int = 10
}

print(SPClass.width)
SPClass.width = 18

我们sil一下:

image.png 编译器给我们的注释也正好说明了上述两点 另外我用在swift使用单例可以使用static关键字,写法如下:

class SPClass {
  static var sharedInstance = SPClass()
  private init(){}
}

对于initprivate处理,这里只能通过全局变量sharedInstance访问实例,而static修饰的变量只会被初始化一次,也就达到了单例的效果

6.属性在MachO文件的位置

之前我们讲到了方法在MachO文件的位置,那么属性是在哪里呢? 我们可以通过源码获得如下结构:

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
}

其中fieldDescriptor记录了属性的相关信息,他在源码的结构如下:

struct FieldDescriptor { 
    MangledTypeName int32 
    Superclass int32 
    Kind uint16 
    FieldRecordSize uint16 
    NumFields uint32 // 记录了多少个属性
    FieldRecords [FieldRecord] // 每个属性的信息
}
其中`FieldRecord`在源码的结构如下:
struct FieldRecord{ 
	Flags uint32 
	MangledTypeName int32 
	FieldName int32 
}

我们编译一下代码并查看其对应的MachO文件

class SPClass {
  var width = 10
  var width1 = 20
}

我们从TargetClassDescriptor开始找:

image.png 计算出TargetClassDescriptor的内存在0x3F50+0xFFFFFF24= 0x100003E74去掉虚拟基地址,我们可以定位到0x3E74的位置

image.png 通过偏移我们计算出fieldDescriptor的内存在0x3E84+0xA4=0x3F28,我们可以定位到0x3F28的位置

image.png 通过结构体数据分析我们计算出FieldRecords的首地址是

image.png

其中FieldName的地址是0x3F28+0x8+FFFFFFDB=0x100003F0B可以定位到第一个属性名字的位置是0x3F0B

image.png 看后面的备注也验证了我们的思路是对的,到此我们就找到了属性在MachO文件的位置

总结

  • var和let在汇编角度没有区别,在sil角度他们的本质区别是var有get和set方法,let只有get方法
  • 计算属性的本质是set和get方法
    • 计算属性需要用var来修饰(因为本身返回值是不固定的)
    • 定义的时后需要指明类型(因为编译器需要知道期望返回的是什么类型)
  • 属性观察器
    • 初始化不会触发属性观察器
    • 计算属性没有willSet,didSet
    • 有继承关系的属性观察器调用顺序
  • 延迟存储属性
    • 用关键字 lazy 来标识一个延迟存储属性
    • 延迟存储属性的初始值在其第一次使用时才进行计算
    • 本质是Option
    • 线程不安全
  • 类型属性
    • 类型属性其实就是一个全局变量
    • 类型属性只会被初始化一次
  • 最后我们通过TargetClassDescriptorFieldDescriptorFieldRecord结构体的数据结构在MachO文件中找到了属性的位置