Swift构造函数(init)与属性观察器(didSet)
最近工程里 Swift
和 Objective-C
混编的场景越来越多了,大家在编写 Swift
代码时,一般都会用 didSet
来作为 Objective-C
里 setter
的替代。但最近遇到好几起(自己和其他同事)因为 Swift
中 init
里对属性赋值没有触发 didSet
,进而导致结果不符合预期的问题,看来还是对 Swift
使用不熟练,这里稍微汇总下。(另外,末尾会附上 ChatGPT3.5
的回答情况)
环境
Xcode 15.2
Swift 5.9
有关属性赋值
大家都很熟悉 Objective-C
里对属性赋值,一般有2种,一种是点操作符
(即 a.x = 1
),一种是直接对成员变量 ivar
赋值(即 _x = 1
)。
大家都很熟悉 @property = ivar + setter + getter
,而点操作符
实际是setter/getter
的语法糖。
进而有常见的初级面试题:
在构造函数里,对属性赋值,对使用点操作符有什么建议?
答案并不固定,基本言之成理即可,无外乎「尽量少用,因为可能会在初始化完成之前,触发不必要的setter
,而其中可能有不确定的逻辑,带来风险」,「如果能明确setter
的逻辑,并且期望将部分逻辑收敛至setter
内,也是可以使用的,但要留意风险」,基本言之有理即可。
那进入 Swift
时代,在做代码迁移,或者开发过程中,会使用 willSet/didSet
来作为 Objective-C
时代 setter
的替代。
比如:
1 | // Swift |
但是 Swift
里并没有区分 点操作符
和 成员变量直接赋值
2种逻辑,即使不显示标注 self
也是相同的逻辑,所以大家倾向于统一当做都是类似 Objective-C
的 点操作符
,即 setter
方法。
然而事实上在默认值
、构造函数(init
)等场景下,其实是 不会触发 didSet
等方法的,有些同学是完全不知道,有些同学是知道,但经常想不起来。
苹果的解释
先看看苹果官方的描述,有2段来源,一段是关于属性/(properties)
的描述,另一段是关于构造函数
的描述。
The Swift Programming Language (5.9.2)/Properties
Note
The willSet and didSet observers of superclass properties are called when a property is set in a subclass initializer, after the superclass initializer has been called. They aren’t called while a class is setting its own properties, before the superclass initializer has been called.
在调用超类初始值设定项之后,在子类构造器设置属性时,将调用超类属性的 willSet 和 didSet 观察者。
在调用超类初始值设定项之前,类在设置自己的属性时不会调用它们。
The Swift Programming Language (5.9.2)/Initialization
Note
When you assign a default value to a stored property, or set its initial value within an initializer, the value of that property is set directly, without calling any property observers.
当您为存储的属性分配默认值或在构造器中设置其初始值时,将直接设置该属性的值,而不调用任何属性观察器。
总结下来,几个原则:
- 默认值,不触发
- 构造器中赋值,不触发(自身属性,非父类属性)
- 构造器中赋值,在子类调用
super.init
之后,如果对父类属性赋值,则触发(对第2点的补充)
针对苹果的第1段描述,上述第3点写的简略了,原因:
- 在子类调用
super.init
之前,其实本来就不能对父类属性赋值,会报编译错误'self' used in property access 'xxx' before 'super.init' call
。 - 而如果是对自己的属性赋值,其实无论在
super.init
之前还是之后,其实都不会触发。
但其实单纯从苹果给的描述,并不能覆盖所有场景,比如:
- 构造器调用另一个方法对属性赋值,是否触发?
- 构造器使用
kvc
对属性赋值,是否触发? - 构造器内部用
block
对属性赋值,是否触发?其实 2 和 3 本质还是第 1 个问题。
Swift的实现
在补充所有原则之前,先针对最小场景,对 Swift
的具体实现进行确认。我们通过生成 Swift
的中间语言(SIL/Swift Intermediate Language
),来进行确认。
先写一个最小的 demo:
1 | // Swift |
执行命令,其中 -Onone
告诉编译器不做任何优化,方便对比。
1 | swiftc -emit-silgen -Onone test.swift > test.sil |
生成的内容有点多,直接看我们关心的部分,重点是对比 init
内部两行代码的差异。
先检查 setter
做了什么,会发现是先对存储的数据赋值,再调用 didSet
的实际逻辑。(代码和附加注释见下方)
1 | // AA.test.setter |
然后检查非构造方法,可以看出,本质就是调用了 setter
,并且没有任何逻辑。所以说明一个重要的事实:无论从何处(包括构造函数)调用这个方法,赋值逻辑都会触发didSet。(代码和附加注释见下方)
1 | // AA.updateTest(_:) |
最后检查 init
方法里,那两行的逻辑,尤其是赋值逻辑。首先调用 updateTest
很直接,就是方法调用。属性赋值看着很眼熟,对比下会发现和 setter
里调用 didSet
前的代码几乎完全一样。所以说明一个重要的事实:构造器里的属性赋值,是直接对存储进行赋值,并不是调用的setter。 (代码和附加注释见下方)
1 | // AA.init() |
这里仅对最简单场景下中间代码做了分析,感兴趣的同学们,可以对其他复杂场景(比如继承、block
)也进行分析确认。
通过以上对中间代码的分析,可以得出2个重要结论,
- 非构造函数里的属性赋值,会使用
setter
方法,进而触发didSet
,如果构造函数调用了该方法,也会触发,没有逻辑判断。 - 构造函数里对自身的属性的赋值,是直接对存储赋值,不使用
setter
方法,所以不会触发didSet
,和苹果官方文档描述一致。
各场景验证
在了解了 Swift
的实现之后,其实我们不难猜测出对于 kvc
、block
等场景下,是否会触发,实际验证如下:
1 | import Foundation |
执行结果
1 | AA test didSet4 |
最后稍微完善下 didSet
触发/不触发的场景原则(重点留意前3条)
- 默认值,不触发
- 构造函数中赋值,不触发(仅限自身属性,非父类属性)
- 构造函数中赋值,在子类调用
super.init
之后,如果对父类属性赋值,则触发(对第2点的补充) - 构造函数中赋值,在子类调用
super.init
之前,不能对父类属性赋值,编译报错 - 构造函数中,调用其他方法对属性赋值,触发
- 构造函数中,使用
kvc
对属性赋值,触发 - 构造函数中,使用立即执行的
block
对属性赋值,触发 - 非构造函数,触发,即使被构造函数调用
简单总结,对于观察器(如didSet
):默认值不触发;构造函数内对自身属性(非父类属性),直接赋值不触发,间接赋值(调用其他函数、block
、kvc
)依然触发;继承场景, super.init
后对父类属性复制,会触发父类属性的 didSet
。
简化记忆,不触发 didSet
的场景,其他例外场景均触发,最好再特殊记一下继承场景对父类属性赋值反而会触发的情况:
- 默认值
- 构造函数内 直接 对 自有 属性赋值
如果我们的代码比较多的使用 didSet/willSet
,那对于构造函数init
内部的触发场景,一定要多加小心,避免踩坑。
ChatGPT 的回答
因为手头只有 ChatGPT3.5,没有 ChatGPT4,这里只检查了 ChatGPT3.5 的回答。
针对 didSet
初次提问:
- 构造器内直接赋值是否触发,回答正确。
- 构造器内,对父类属性赋值是否触发,回答正确。
- 构造函数内调用外部函数赋值是否触发,回答错误。
- 构造函数内用
kvc
赋值是否触发,回答错误。 - 构造函数内用
block
赋值是否触发,回答正确。但是,描述说明里,却说无论直接赋值还是使用闭包赋值,都会触发,则不正确。
当然,如果你恐吓他(只是单纯让他再确认下),他会立马给出完全相反的答案,正确变错误、错误变正确😂。所以,慎用 ChatGPT(至少慎用3.5)来解答一些比较复杂的语言特性的问题,一定要做好验证。