前言
我们开发的过程中经常用到的是属性,不管是我们的类,结构体还是枚举都会用到,那么属性到底有什么用法?或者属性的本质是什么?我们一探究竟!
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)
我们看到
- 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修饰的变量进行汇编分析,我们看到:
汇编代码是一样的,都是将一个立即数存放到寄存器中,我们打印他们的内存地址:
发现他们存的地方是相同的内存空间,都在.__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来修饰(因为本身返回值是不固定的
- 定义的时后需要指明类型(因为编译器需要知道期望返回的是什么类型)
对于如下代码
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汇编的角度
我们看到修改计算属性会调用他的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'
我们看到编译器会给我们的计算属性生成set
和ge
t方法,我们注意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
编译器会抱错,因为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看一下:
我们看到确实变量的赋值的前后分别有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看一下:
我们看到对于字符串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)")
}
}
}
我们看到编译器会报错,那么怎么对计算属性进行观察呢,其实啊计算属性本身有set
和get
方法,想要观察属性,直接在里面写观察的代码就好,另外计算属性的属性不需要有初始值,所以没必要多次一举再加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"
会触发属性观察器,我们看到控制台打印:
我们看到子类的willSet
方法和didSet
方法直接回调用父类的willSet
方法和didSet
方法,我们不妨sil一下:
确实我们看到子类的setter方法里面会调用父类的willSet
方法和didSet
方法,这两个方法之间会调用父类的willSet
方法和didSet
方法,验证了我们的控制台打印结果。
4.延迟存储属性
延迟存储有两点需要注意:
- 用关键字 lazy 来标识一个延迟存储属性
- 延迟存储属性的初始值在其第一次使用时才进行计算
比如我们有这样的代码
class SPClass {
lazy var width: Int = 18
}
var s = SPClass()
print(s.width)
在赋值之前和之后我们控制台观察下变化
我们看到类初始化完成并没有初始化延迟属性,而是在第一次访问我们的延迟属性的时候对其进行了初始化,我们sil一下:
我们看到编译器生成的延迟属性是一个可选类型的枚举并且用final关键字修饰
在类的初始化方法里面我们看到延迟属性会初始化Option.none
然后在访问属性的时候会取延迟属性的值进行模匹配:
- 匹配了
Option.none
,会跳转到bb2
,赋值Option.some
,再返回 - 匹配了
Option.some
, 会跳转到bb1
,直接返回
所以我们可以用闭包表达式来初始化延迟属性,在里面会有些申请内存的操作,在不访问延迟属性时候会赋值Option.none
,就不会申请内存,可以节省空间
需要注意的是延迟属性并不是线程安全的,比如两个线程同时访问改延迟属性,第一个线程模式匹配到Option.none
然后进行赋值,还没返回的时候第二个线程也访问了该延迟属性,同样匹配到的是Option.none
然后进行赋值,所以说延迟属性线程不安全
可能会被初始化多次
5.类型属性
对于类型属性我们需要知道两点:
- 类型属性其实就是一个全局变量
- 类型属性只会被初始化一次
class SPClass {
static var width: Int = 10
}
print(SPClass.width)
SPClass.width = 18
我们sil一下:
编译器给我们的注释也正好说明了上述两点 另外我用在swift使用单例可以使用static关键字,写法如下:
class SPClass {
static var sharedInstance = SPClass()
private init(){}
}
对于init
做private
处理,这里只能通过全局变量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
开始找:
计算出TargetClassDescriptor
的内存在0x3F50
+0xFFFFFF24
= 0x100003E74
去掉虚拟基地址,我们可以定位到0x3E74
的位置
通过偏移我们计算出fieldDescriptor
的内存在0x3E84
+0xA4
=0x3F28
,我们可以定位到0x3F28
的位置
通过结构体数据分析我们计算出FieldRecords
的首地址是
其中FieldName
的地址是0x3F28
+0x8
+FFFFFFDB
=0x100003F0B
可以定位到第一个属性名字的位置是0x3F0B
看后面的备注也验证了我们的思路是对的,到此我们就找到了属性在MachO文件的位置
总结
- var和let在汇编角度没有区别,在sil角度他们的本质区别是var有get和set方法,let只有get方法
- 计算属性的本质是
set和get方法
- 计算属性需要用var来修饰(因为本身返回值是不固定的)
- 定义的时后需要指明类型(因为编译器需要知道期望返回的是什么类型)
- 属性观察器
- 初始化不会触发属性观察器
- 计算属性没有willSet,didSet
- 有继承关系的属性观察器调用顺序
- 延迟存储属性
- 用关键字 lazy 来标识一个延迟存储属性
- 延迟存储属性的初始值在其第一次使用时才进行计算
- 本质是
Option
- 线程不安全
- 类型属性
- 类型属性其实就是一个全局变量
- 类型属性只会被初始化一次
- 最后我们通过
TargetClassDescriptor
,FieldDescriptor
,FieldRecord
结构体的数据结构在MachO文件中找到了属性的位置