Files
knowledge-kit/Chapter1 - iOS/1.115.md
2025-06-23 01:18:55 +08:00

19 KiB
Raw Blame History

属性

实例相关属性分类

存储属性

英文叫 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 可以看到,在调用 enumrawvalue 的时候本质是通过计算属性调用 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 不占用枚举的内存空间(是方法,存储在代码段)

属性观察器

  • 可以为非 lazyvar 存储属性设置属性观察期

  • 计算属性由于有 set 和 get因此不能有属性观察器 willSet 和 didSet

  • 在初始化器中设置属性值不会触发 willSetdidSet

  • 属性观察器、计算属性的功能,同样可以应用在全局变量、局部变量上

    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 计算属性的 gettergetter 的返回值存放在寄存器 rax

  • 20行将 movq %rax, -0x28(%rbp) 函数返回值 rax 存放在 main 函数的栈空间上(虽然没有写调用函数,但是代码主入口就是 main 函数),且 rbprsp 之间都是函数的栈空间。也就是一个局部变量

  • 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 类型,系统真正实现,不能直接将对应的地址传进去,因为传进去很多简单,满足了修改值的需求。但是属性观察器的 setget 就没办法触发了。所以为了触发属性观察器系统的设计是:

  • 第一步:先将传递进去的属性调用 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), %rdimain 函数的栈空间上的局部变量 -0x28(%rbp) 的值,赋值给寄存器 rdi。也就是修改后的20

  • 23行 callq 0x100002db0 ; SwiftDemo.Shape.side.setter : Swift.Int at main.swift:3 调用 setter

  • LLDB 输入 si,可以看到 setter 方法内部,调用了 willSetdidSet

总结:带有属性观察器的存储属性,如果调用的方法参数是 inout 类型,系统真正的实现,并不是直接将 inout 参数的地址传进去,因为传进去很简单,满足了修改值的需求,但属性观察器的 willSetdidSet 就没办法触发了。

问题症结是:直接传递 inout 参数的地址,可以满足直接修改值的需求,但直接修改没办法触发属性观察器的 willSet 和 didSet

所以为了触发属性观察器系统的设计是:

  • 第一步:先将传递进去的地址,保存在函数的栈地址空间内的某个内存上
  • 第二步:然后调用带有 inout 属性的方法,将步骤一得到的内存传递当作参数传进去。修改该内存对应的值
  • 第三步:将步骤二得到的值后,调用一个 setter 方法,setter 方法内,先调用 willSet,再修改真正的带有观察属性的存储属性的值,最后调用 didSet 方法。

这个流程下来,满足了修改值,且触发了原始属性观察器的需求。

总结

  1. 如果实参有内存地址,且没有设置属性观察器和计算属性,实现是直接将实参的内存地址传入带 inout 函数
  2. 如果实参是计算属性或者设置了属性观察器:系统采用了 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_fdispatch_once_t 会传递一个函数地址进去执行,类型属性的初始化代码将会被包装成一个函数。 由 dispatch_once_t 保证线程安全和只初始化1次。

所以类型存储属性底层通过 dispatch_once 保证了只会初始化1次线程安全。