20 KiB
属性
实例相关属性分类
存储属性
英文叫 Stored Property
- 类似于成员变量的概念
- 为什么叫存储属性?属性的内存直接存储在实例的内存中
- 结构体、类,都有存储属性
- 枚举不可以定义存储属性
为什么 enum 不可以定义存储属性?
最基础的枚举,内存占用1个字节,只用来存储哪个 case 的索引值
enum Season {
case spring
case summer
case antumn
case winter
}
带有关联值的枚举
enum Season {
case spring(Int, Int, Int)
case summer(Int, Int)
case antumn(Int)
case winter(Bool)
case unknown
}
var season: Season = Season.spring(1, 2, 3)
print(MemoryLayout<Season>.size) // 3*8 + 1
print(MemoryLayout.stride(ofValue: season)) // 32
.spring 有3个 Int,单个 Int 占8个字节空间,所以红色框代表 spring 的1,蓝色框代表 spring 的2,绿色框代表 spring 的3,黄色框代表枚举的第1个 case,剩余7个字节,为空。后续的7个字节是为了内存对齐而补齐的内存。
1个字节用来表达位置信息。共 3*8 + 1 = 25
内存对齐系数,以8 Byte 为单位,对象分配的内存必须是该值的整数倍,所以实际分配后的内存为32字节
带有原始值的枚举
enum Season : Int {
case spring = 1
case summer = 2
case antumn = 3
case winter = 4
}
print(MemoryLayout<Season>.size) // 1
print(MemoryLayout<Season>.stride) // 1
print(MemoryLayout<Season>.alignment) // 1
只带有原始值的枚举,同样只占用1个字节,该字节的值为枚举的位置索引(比如1、2),而非原始值。原始值不占用枚举的内存
总结:
- Swift 的枚举是一种值类型,核心目的是表示一组互斥的、有限的可能性
- 允许存储属性,当枚举实例处于不同 case 时,这些属性的存在性和内存占用会变得不可预测
计算属性
英文名为:Computed Property
- 计算属性的本质是方法
- 不占用实例的内存
- 计算属性只能用 var,不能用 let
- 有了 setter,必须有 getter
- 可以只有 getter,没有 setter
- 枚举、结构体、 类都可以定义计算属性
struct Circle {
var radius: Int // 存储属性
var diameter: Int { // 计算属性
set {
radius = newValue/2
}
get {
2 * radius
}
}
}
var circle = Circle(radius: 10)
// print(circle.diameter) // 20
circle.diameter = 24
// print(circle.radius) // 12
let diameter = circle.diameter
print(MemoryLayout<Circle>.size) // 8
print(MemoryLayout<Circle>.stride) // 8
print(MemoryLayout<Circle>.alignment) // 8
计算属性 y 等价于下面的代码:
setDiameter (newValue: Int) {
radius = newValue/2
}
getDiameter () {
return 2*radius
}
然后通过汇编来窥探下,在 circle.diameter = 22 处加断点,可以看到本质上调用的就是 setter 方法,setter 内部的实现就不一一窥探了
然后断点继续,在 let diameter = circle.diameter 处加断点,可以看到调用了 getter 方法
计算属性的本质就是方法,看上去是属性,但是不占用结构体的内存。而是独立在代码段中,所以只占用1个 Int 即8个字节的大小。
-
计算属性可以有只读计算属性
也就是只有 getter,没有 setter 方法
struct Circle { var radius: Int // 存储属性 var diameter: Int { radius*2 } }
延迟存储属性
常规写法
class Car {
init () {
print("Car init")
}
func run () {
print("Car is running")
}
}
class Person {
let car:Car = Car()
init () {
print("Person init")
}
func goOut() {
car.run()
}
}
let p = Person()
print("---")
p.goOut()
// console
Car init
Person init
---
Car is running
但是想实现一个需求,就是在 Person 初始化的时候先不初始化 Car,当调用 Person 对象的 goOut 方法的时候再初始化,该怎么办?
使用 lazy 可以定义一个延迟存储属性,在第一次用到属性的时候才会进行初始化
对 Person 改造如下
class Person {
lazy var car:Car = Car()
init () {
print("Person init")
}
func goOut() {
car.run()
}
}
// console
Person init
---
Car init
Car is running
注意:延迟属性 lazy 必须和 var 搭配使用,不能是 let
异同点
存储属性:
-
类似于成员变量
-
存储在实例的内存中
-
结构体、类可以定义存储属性
-
枚举不可以定义存储属性
-
在创建类、结构体的实例时,必须为所有的存储属性设置一个合适的初始值
-
延迟存储属性必须是
var,不能是let。 因为 Swift 规定 let 必须在实例的初始化方法完成之前就拥有值 -
lazy在多线程情况下,无法保证属性只被初始化1次。struct Point { var x:Int lazy var y = 0 init(_ x: Int = 0) { self.x = x } } var p = Point(2) print(p.y) -
当结构体包含一个延迟存储属性时,只有 var 才能访问延迟存储属性(因为延迟属性初始化的时候需要改变结构体内存)。Class 的话,实例可以用 let 修饰,访问延迟存储属性是可以的。
QA:为什么结构体包含一个延迟存储属性时,只有 var 才能访问延迟存储属性?
之前在 Swift 结构体和类的内存布局 探究过
struct的内存布局,struct的成员变量在内存中是连续存储的。由于是延迟存储属性,等真正使用的时候才会执行延迟属性的初始化逻辑,才会更改struct的内存,所以let无法满足更改内存的需求。struct Point { var x = 0 lazy var y = 0 } let p = Point() print(p.y) // Cannot use mutating getter on immutable value: 'p' is a 'let' constant var p2 = Point() print(p2.y) // 0 class Point { var x:Int lazy var y = 0 init(_ x: Int = 0) { self.x = x } } let p = Point(2) print(p.y) // 0
计算属性:
- 本质就是方法
- 不占用实例内存
- 枚举、结构体、类都可以定义计算属性
- 计算属性只能用 var,不能用 let
枚举 rawValue 的原理
枚举原始值 rawValue 的本质:只读的计算属性,不占用实例内存。
enum Season: Int {
case spring = 10
case summer = 20
case autumn = 30
case winter = 40
}
let season = Season.summer
// season.rawValue = 22 // Cannot assign to property: 'rawValue' is immutable
print(season.rawValue)
通过汇编 SwiftDemo.Season.rawValue.getter 可以看到,在调用 enum 的 rawvalue 的时候本质是通过计算属性调用 getter 来实现的。
类似于:
enum Season: Int {
case spring = 10
case summer = 20
case autumn = 30
case winter = 40
var rawValue: Int {
get {
switch self {
case .spring:
return 10
case .summer:
return 20
case .autumn:
return 30
case .winter:
return 40
}
}
}
}
let season = Season.summer
print(season.rawValue)
也侧面证明了 rawValue 不占用枚举的内存空间(是方法,存储在代码段)
属性观察器
-
可以为非
lazy的var存储属性设置属性观察期 -
计算属性由于有 set 和 get,因此不能有属性观察器 willSet 和 didSet
-
在初始化器中设置属性值不会触发
willSet、didSet -
属性观察器、计算属性的功能,同样可以应用在全局变量、局部变量上
var num: Int { get { return 10 } set { print("newValue", newValue) } } num = 11 print(num) // console newValue 11 10 func test () { var age: Int { set { print("new age is ", newValue) } get { 28 } } age = 29 print(age) } test() // console new age is 29 28
Inout 核心原理
普通的存储属性
struct Shape {
var width: Int
var side: Int {
willSet {
print("willset side", newValue)
}
didSet {
print("didset side", oldValue, side)
}
}
var girth: Int {
set {
width = newValue/side
print("set girth ", newValue)
}
get {
print("get girth")
return width * side
}
}
func show() {
print("width is \(width), side is \(side), girth is \(girth)")
}
}
func changeValue(_ value: inout Int) {
value = 20
}
var shape = Shape(width: 10, side: 4)
changeValue(&shape.width)
shape.show()
// console
get girth
width is 20, side is 4, girth is 80
在 changeValue(&shape.width) 处加汇编可以看到断点停在第10行 leaq 0x953c(%rip), %rdi 即将 rip + 0x953c = 0x100002cbc + 0x953c = 0x10000C1F8 赋值给 rdi。
第16行也是一样,leaq 0x9523(%rip), %rdi 即将 rip + 0x9523 = 0x100002cd5 + 0x953c = 0x10000C1F8 赋值给 rdi。
然后看到17行的关键代码,LLDB 输入 si,可以看到在第6行 movq $0x14, (%rdi),将16进制的 0x14 也就是20,移动到指定的内存地址 rdi 上
因为 struct 结构体内存布局中,成员变量在内存中是连续存储的。所以 struct 的地址也就是 struct 中第一个成员变量 width 的地址。
总结:普通的存储属性,在调用方法的时候,如果参数是 inout 传递引用,则直接传递存储属性的地址值即可。在方法内部,对该地址对应的值进行修改即可。
计算属性
对调用的代码进行调整
var shape = Shape(width: 10, side: 4)
changeValue(&shape.girth)
shape.show()
// console
get girth
set girth 20
get girth
width is 5, side is 4, girth is 20
在 changeValue(&shape.girth) 处下断点,查看汇编
核心思路:方法参数用 inout修饰,则传递的是引用(内存地址)。
-
汇编19行
callq 0x1000030e0 ; SwiftDemo.Shape.girth.getter : Swift.Int at main.swift:16调用了girth计算属性的getter,getter的返回值存放在寄存器rax上 -
20行将
movq %rax, -0x28(%rbp)函数返回值rax存放在main函数的栈空间上(虽然没有写调用函数,但是代码主入口就是main函数),且rbp到rsp之间都是函数的栈空间。也就是一个局部变量 -
21行
leaq -0x28(%rbp), %rdi将栈空间上-0x28(%rbp)的地址值赋值给rdi寄存器 -
22行
callq 0x1000037b0 ; SwiftDemo.changeValue(inout Swift.Int) -> () at main.swift:26调用changeValue方法,参数通过寄存器rdi传递,里面是栈空间 getter 值的地址。 -
LLDB 输入
si查看 changeValue 内部。可以看到第6行movq $0x14, (%rdi)直接将20赋值给rdi
-
LLDB 输入
finish结束changeValue细节,查看外部23行汇编movq -0x28(%rbp), %rdi,将main函数栈空间上getter返回值的内存对应的值,保存到寄存器rdi上。 -
25行
callq 0x100003250 ; SwiftDemo.Shape.girth.setter : Swift.Int at main.swift:12调用计算属性的setter,函数参数为rid寄存器里的值(也就是20)
总结:带有计算属性的存储属性,如果调用的方法参数是 inout 类型,系统真正实现,不能直接将对应的地址传进去,因为传进去很多简单,满足了修改值的需求。但是属性观察器的 set、get 就没办法触发了。所以为了触发属性观察器系统的设计是:
- 第一步:先将传递进去的属性调用
getter,保存在函数的栈地址空间内的某个内存上 - 第二步:然后调用带有
inout属性的方法,将步骤一得到的内存传递当作参数传进去。修改该内存对应的值 - 第三步:将步骤二得到的值后,调用
setter方法。
这个流程下来,满足了修改值,且触发了原始属性观察器的需求。
带有属性观察器的存储属性
对调用的代码进行调整
var shape = Shape(width: 10, side: 4)
changeValue(&shape.side)
shape.show()
// console
willset side 20
didset side 4 20
get girth
width is 10, side is 20, girth is 200
在 changeValue(&shape.side) 处添加断点,查看汇编
分析:
-
17行
movq 0x9549(%rip), %rax ; SwiftDemo.shape : SwiftDemo.Shape + 8将地址格式为0x9549(%rip)一个全局变量,也就是shape的地址 + 8 的值,赋值给rax寄存器 -
18行
movq %rax, -0x28(%rbp)将寄存器rax里的值,赋值给main函数的栈空间上的局部变量-0x28(%rbp) -
19行 将
main函数的栈空间上的局部变量-0x28(%rbp)的地址值,赋值给寄存器rdi -
20行
callq 0x1000037b0 ; SwiftDemo.changeValue(inout Swift.Int) -> () at main.swift:26调用changeValue方法。函数参数通过寄存器rdi传递,函数内部修改了该内存上的值 -
21行
movq -0x28(%rbp), %rdi将main函数的栈空间上的局部变量-0x28(%rbp)的值,赋值给寄存器rdi。也就是修改后的20 -
23行
callq 0x100002db0 ; SwiftDemo.Shape.side.setter : Swift.Int at main.swift:3调用 setter -
LLDB 输入
si,可以看到setter方法内部,调用了willSet、didSet
总结:带有属性观察器的存储属性,如果调用的方法参数是 inout 类型,系统真正的实现,并不是直接将 inout 参数的地址传进去,因为传进去很简单,满足了修改值的需求,但属性观察器的 willSet、didSet 就没办法触发了。
问题症结是:直接传递 inout 参数的地址,可以满足直接修改值的需求,但直接修改没办法触发属性观察器的 willSet 和 didSet
所以为了触发属性观察器系统的设计是:
- 第一步:先将传递进去的地址,保存在函数的栈地址空间内的某个内存上
- 第二步:然后调用带有
inout属性的方法,将步骤一得到的内存传递当作参数传进去。修改该内存对应的值 - 第三步:将步骤二得到的值后,调用一个
setter方法,setter方法内,先调用willSet,再修改真正的带有观察属性的存储属性的值,最后调用didSet方法。
这个流程下来,满足了修改值,且触发了原始属性观察器的需求。
总结
- 如果实参有内存地址,且没有设置属性观察器和计算属性,实现是直接将实参的内存地址传入带
inout函数 - 如果实参是计算属性或者设置了属性观察器:系统采用了 Copy In Copy Out 的策略
- 调用带
inout函数时,先复制实参的值,产生副本 (get。栈空间上的局部变量 rbx + offset) - 将副本的内存地址传入带
inout函数(副本进行引用传递),在函数内部修改副本的值 - 函数返回后,再将副本的值覆盖实参的值(set。willSet、didSet)
- 调用带
类型属性
-
不同于存储属性,类型属性必须设置初始值,永伟类型属性不像存储属性那样有
init初始化器来初始化存储属性 -
类型属性就不存储在每个实例的内存里
-
存储属性默认就是
lazy,会在第一次使用的时候才初始化 -
存储属性就算被多个线程同时访问,但系统会保证只初始化1次
-
存储类型属性可以是
let -
存储属性可以用
static修饰 -
枚举 enum 里面不可以实例存储属性,但是可以定义类型存储属性
enum Season { static let age: Int = 0 case spring, summer, antumn, winter } var season = Season.summer -
类型属性的经典场景就是单例模式
class FileManager { private init() {} public static let sharedInstance: FileManager = FileManager() } var manager1 = FileManager.sharedInstance var manager2 = FileManager.sharedInstance var manager3 = FileManager.sharedInstance print(Mems.ptr(ofRef: manager1)) // 0x0000600000008030 print(Mems.ptr(ofRef: manager2)) // 0x0000600000008030 print(Mems.ptr(ofRef: manager3)) // 0x0000600000008030
内存角度分析:类型属性存储在哪
Demo1:
movq $0xa, 0x86d1(%rip) num1 的地址为: rip + 0x86d1 = 0x100003b0f + 0x86d1 = 0x10000C1E0
movq $0xb, 0x86ce(%rip) num2 的地址为: rip + 0x86ce = 0x100003b1a + 0x86ce = 0x10000C1E8
movq $0xc, 0x86cb(%rip) num3 的地址为: rip + 0x86cb = 0x100003b25 + 0x86cb = 0x10000C1F0
可以看到 0x10000C1E0 0x10000C1E8 0x10000C1F0 在内存上是连续的,间隔8Byte。可见分配的3个全局变量内存是连续的
Demo2
movq $0xa, 0x8b65(%rip) num1 的内存为 rip + 0x8b65 = 0x1000037c3 + 0x8b65 = 0x10000C328
可以看到15行将11赋值给 rax,所以直接读取 rax 的地址:0x000000010000c330
movq $0xc, 0x8b38(%rip) num1 的内存为 rip + 0x8b38 = 0x100003800 + 0x8b38 = 0x10000C338
可以看到 0x10000C328 0x10000c330 0x10000C338 也是内存连续的。所以类型属性就是带有访问控制(必须通过类来访问)的全局变量
类型属性是线程安全的
看个 Demo
class Manager {
static var count = Int.random(in: 1...100)
}
Manager.count = 10
Manager.count = 11
下断点可以看到,调用了地址访问器函数。为什么需要地址访问器函数:
- 线程安全初始化首次访问时触发初始化(可能包含
dispatch_once逻辑) - 抽象内存访问隐藏实际存储位置(可能位于不同段或延迟分配)
- 支持属性观察(didSet)通过封装访问点插入回调逻辑
lldb 输入 si 查看具体实现
可以看到底层调用了 swift_once 函数,函数传递了2个参数, rsi 存储 dispatch_once 的 block 参数,rdi 存储了 onceToken
继续敲 si 可以看到底层调用的就是 GCD 的 dispatch_once 函数。
类型属性如何保证线程安全的?如何保证只会初始化一次
底层会调用 swift_once 进而调用 dispatch_once_f,dispatch_once_t 会传递一个函数地址进去执行,类型属性的初始化代码将会被包装成一个函数。 由 dispatch_once_t 保证线程安全和只初始化1次。
所以类型存储属性底层通过 dispatch_once 保证了只会初始化1次,线程安全。