7. 属性

110 阅读7分钟

一、存储属性

存储属性是一个作为特定类和结构体实例一部分的常量变量。存储属性要么是变量存储属性(由 var 关键字引入)要么是常量存储属性(由 let 关键字引入)。存储属性这里没有什么特别要强调的,因为随处可见。

struct Person {
    let name: String
    var age: Int
}

比如这里的 nameage 就是我们所说的存储属性,这里我们需要加以区分的是letvar 两者的区别:

从定义上: let 用来声明常量,常量的值一但设置好便不能再被更改;

var 用来声明变量,变量的值可以在将来再修改。

1. 从汇编的角度分析varlet

image-20220102115754513

image-20220102115933337

断点到汇编可以看到没啥区别,varlet都是存地址,我们再看一下地址:

image-20220102131458192

都是在全局区的地址,只是差了8字节,存储的值不同,其它都一样。

2. 从SIL的角度分析varlet

image-20220102132328382

由此可以发现varlet的本质区别在于编译器有没有生成set方法,如果没有set方法,你就无法给该属性赋值,这和let的定义也是符合的。

二、计算属性

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

struct Square {
    /// 存储属性 内存中占8字节
    var width: Double

    /// 计算属性
    var area: Double {
        get {
            width * width
        }
        set {
            width = sqrt(newValue)
        }
    }
}

var s = Square(width: 5)
print(s.area)
s.area = 36
print(s.width)
1. 汇编分析

image-20220102140534513

很明显,areasettergetter方法都是静态调用。

2. SIL分析

image-20220102134602995

由此可以看到width是存储属性,在Square中占8字节,编译器已经生成了setget

这里我们需要注意的是areawidth的区别:

image-20220102135121767

image-20220102135459128

areasetternewValue是默认参数名,携带的是最新的值,当然也可以自定义参数名。

area本质上是在生成方法,settergetter是直接调用方法并将当前实例Square的地址传入。

三、属性观察者

属性观察者会观察用来观察属性值的变化,一个willSet 当属性将被改变调用,即使这个值与原有的值相同,而 didSet 在属性已经改变之后调用。它们的语法类似于 gettersetter

class Person {
    var age: Int {
        willSet {
            print("Person willSet", newValue)
        }
        didSet {
            print("Person didSet", oldValue)
        }
    }

    init(age: Int) {
        self.age = age
    }
}

class Teacher: Person {
    override var age: Int {
        willSet {
            print("Teacher willSet", newValue)
        }
        didSet {
            print("Teacher didSet", oldValue)
        }
    }
}

let t = Teacher(age: 3)
t.age = 5

执行结果:

Teacher willSet 5
Person willSet 5
Person didSet 3
Teacher didSet 3

这里需要注意的是有继承关系的类的属性观察者的调用顺序:

子类willSet > 父类willSet > 父类didSet > 子类didSet

其次初始化实例时属性观察者是不会回调的,我们可以从SIL分析一下。

1. 分析SIL

image-20220102144549812

image-20220102144703164

由此可以看出子类属性在setter时先调自己的willset然后调父类的setter,而父类setter中先调父类自己的willset再调自己的didset,最后子类再调自己didset。这与代码的执行结果是匹配的。

image-20220102150414998

Teacher在初始化时最后调到Person.init中,由此可以看到age是直接通过内存地址赋值,并不是调setter方法。因为此时Person还没有初始化完成,如果此时访问某些属性的settergetter可能会造成内存访问错误,所以这么设计是合理的。

四、延迟存储属性(懒加载)

  • 延迟存储属性的初始值在其第一次使用时才进行计算。

  • 用关键字lazy 来标识一个延迟存储属性。

image-20220102151431319

此时查看Person实例:

image-20220102151605656

注意前16字节为metadata,这里我们忽略,主要看后面age的值,很明第一次断点时age还没有值,我们接着看下一个断点:

image-20220102151841926

当我们访问age时,age中才有值。

1. SIL分析

image-20220102152344480

这里我们需要注意几个点:

  • final标记

  • __lazy_storage_标记

  • Int?可选类型

image-20220102152557129

进一步查看可以发现,age的默认值就是可选类型的空(可选类型本质是enum)。那么当我们访问age时发生了什么?

image-20220102154903149

bb0中我们可以看到访问age时其实是从age地址中将值取出来放到寄存器%4中,让后通过switch_enum来匹配,有值走bb1,没值走bb2。第一次访问时因为没值所以走bb2来进行赋值操作,bb2中是创建一个Int类型的值3,然后将该值给到了枚举变量,最后将枚举变量赋值给age。第二次访问时因为有值所以走bb1bb1调了bb3其实就是将age已有的值返回(并没有走bb2重新赋值)。

此时我们可以思考一个问题,延迟存储属性时线程安全的吗?答案是否定的。如果该属性是直接返一个常量(参考age),那么影响并不大,但是如果换一种方式,比如通过闭包表达式(函数调用)创建值,就像这样:

import UIKit

class Person {
    lazy var age: Int = {
        print("init age")
        return 3
    }()
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let t = Person()

        DispatchQueue.global().async {
            print(t.age)
        }

        DispatchQueue.global().async {
            print(t.age)
        }
    }
}

执行结果:

init age
3
init age
3

很明显,age被创建了2次。所以对于非线程安全的延迟存储属性,我们需要合理使用。

五、类型属性

  • 类型属性只会被初始化一次。

  • 用关键字static 来标识一个类型属性。

    class Person {
        static var age = 3
    }
    
    print(Person.age)
    Person.age = 5
    print(Person.age)
    
1. SIL分析

image-20220102162847099

由此可以发现age被声明为全局变量,在Person中可以看到age属性只是为了让我们通过Person来访问它。那么给age赋值发生了什么,我们继续看:

image-20220102164112324

在这里我们发现调用一个方法s4main6PersonC3ageSivau,我们搜索该方法:

image-20220102164429917

在这里我们可以看到,首先获取了一个全局声明的token的全局地址放到%0,然后将%0做了指针转化放到%1,接着获取了一个globalinit方法的地址放到%2,再进行builtin once并携带两个参数%1%2,最后将全局变量(age)的内存地址返回回去。

那么globalinit方法做了什么,我们搜索globalinit_029_12232F587A4C5CD8B1EEDF696793B2FC_func0继续往下看:

image-20220102165457178

在这里我们看到,首先创建了一个全局变量s4main6PersonC3ageSivpZ,那么这个全局变量是什么?通过xcrun swift-demangle s4main6PersonC3ageSivpZ我们可以发现这个全局变量就是Person.age;然后将全局变量地址给%1,接下就是创建一个Int类型的值3并将该值存到%1,简单来说这个方法就是给age赋值。

回到该方法调用的地方,我们继续看builtin once,从字面来看这个builtin once就是执行一次的意思,它其实是编译器字段,我们可以通过swiftc main.swift -emit-ir将当前代码降级到IR来看:

image-20220102172321486

我们可以找到这个方法s4main6PersonC3ageSivau,通过xcrun swift-demangle s4main6PersonC3ageSivau我们可以发现这个方法就是SIL中的Person.age.unsafeMutableAddressore,也就是说这个方法会调builtin once,也就是上图中的swift_once

,此时可以推测出这个swift_once方法2个入参分别是全局的tokenglobalinit方法。

我们继续往下,在Swift源码中搜索swift_once,最终在Once.cpp中可以看到:

image-20220102173203255

这是啥?这不就是我们熟悉gcdpredicate是我们传入的方法,context是上下文,fn是我们传入的globalinit方法,

由此我们可以看出,swift_once本质上就是dispatch_once

2. 总结

类型属性其实就是一个全局变量,它在初始化过程中只会被初始化一次。这也是Swift中单例可以这样写的原因:

class Manager {
    static let shared = Manager()
    private init() {}
}

因为底层在帮我们用dispatch_once来初始化,从而保证了它的唯一性。