Swift构造函数(init)与属性观察器(didSet)

| /

最近工程里 SwiftObjective-C 混编的场景越来越多了,大家在编写 Swift 代码时,一般都会用 didSet 来作为 Objective-Csetter 的替代。但最近遇到好几起(自己和其他同事)因为 Swiftinit 里对属性赋值没有触发 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
2
3
4
5
6
7
8
// Swift
class MyClass {
var myProperty: String {
didSet {
print("Property has been set.")
}
}
}

但是 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.

当您为存储的属性分配默认值或在构造器中设置其初始值时,将直接设置该属性的值,而不调用任何属性观察器。

总结下来,几个原则:

  1. 默认值,不触发
  2. 构造器中赋值,不触发(自身属性,非父类属性)
  3. 构造器中赋值,在子类调用 super.init 之后,如果对父类属性赋值,则触发(对第2点的补充)

针对苹果的第1段描述,上述第3点写的简略了,原因:

  1. 在子类调用 super.init 之前,其实本来就不能对父类属性赋值,会报编译错误 'self' used in property access 'xxx' before 'super.init' call
  2. 而如果是对自己的属性赋值,其实无论在 super.init 之前还是之后,其实都不会触发。

但其实单纯从苹果给的描述,并不能覆盖所有场景,比如:

  1. 构造器调用另一个方法对属性赋值,是否触发?
  2. 构造器使用 kvc 对属性赋值,是否触发?
  3. 构造器内部用 block 对属性赋值,是否触发?

    其实 2 和 3 本质还是第 1 个问题。

Swift的实现

在补充所有原则之前,先针对最小场景,对 Swift 的具体实现进行确认。我们通过生成 Swift 的中间语言(SIL/Swift Intermediate Language),来进行确认。

先写一个最小的 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Swift
class AA {
var test = 1 {
didSet {
}
}

init() {
self.updateTest(2)
test = 3
}

func updateTest(_ input: Int) {
test = input
}
}

执行命令,其中 -Onone 告诉编译器不做任何优化,方便对比。

1
swiftc -emit-silgen -Onone test.swift > test.sil

生成的内容有点多,直接看我们关心的部分,重点是对比 init 内部两行代码的差异。

先检查 setter 做了什么,会发现是先对存储的数据赋值,再调用 didSet 的实际逻辑。(代码和附加注释见下方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// AA.test.setter
sil hidden [ossa] @$s8Contents2AAC4testSivs : $@convention(method) (Int, @guaranteed AA) -> () {
// %0 "value" // users: %6, %2
// %1 "self" // users: %9, %4, %3
bb0(%0 : $Int, %1 : @guaranteed $AA):
debug_value %0 : $Int, let, name "value", argno 1, implicit // id: %2
debug_value %1 : $AA, let, name "self", argno 2, implicit // id: %3

// 附加注释:以下3行是对存储数据的赋值 assign
%4 = ref_element_addr %1 : $AA, #AA.test // user: %5
%5 = begin_access [modify] [dynamic] %4 : $*Int // users: %7, %6
assign %0 to %5 : $*Int // id: %6
end_access %5 : $*Int // id: %7

// 附加注释:这时候是调用 didset
// function_ref AA.test.didset
%8 = function_ref @$s8Contents2AAC4testSivW : $@convention(method) (@guaranteed AA) -> () // user: %9
%9 = apply %8(%1) : $@convention(method) (@guaranteed AA) -> ()
%10 = tuple () // user: %11
return %10 : $() // id: %11
} // end sil function '$s8Contents2AAC4testSivs'

然后检查非构造方法,可以看出,本质就是调用了 setter,并且没有任何逻辑。所以说明一个重要的事实:无论从何处(包括构造函数)调用这个方法,赋值逻辑都会触发didSet。(代码和附加注释见下方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AA.updateTest(_:)
sil hidden [ossa] @$s8Contents2AAC10updateTestyySiF : $@convention(method) (Int, @guaranteed AA) -> () {
// %0 "input" // users: %5, %2
// %1 "self" // users: %5, %4, %3
bb0(%0 : $Int, %1 : @guaranteed $AA):
debug_value %0 : $Int, let, name "input", argno 1 // id: %2
debug_value %1 : $AA, let, name "self", argno 2, implicit // id: %3

// 附加注释:以下是对 setter 的调用
%4 = class_method %1 : $AA, #AA.test!setter : (AA) -> (Int) -> (), $@convention(method) (Int, @guaranteed AA) -> () // user: %5
%5 = apply %4(%0, %1) : $@convention(method) (Int, @guaranteed AA) -> ()
%6 = tuple () // user: %7
return %6 : $() // id: %7
} // end sil function '$s8Contents2AAC10updateTestyySiF'

最后检查 init 方法里,那两行的逻辑,尤其是赋值逻辑。首先调用 updateTest 很直接,就是方法调用。属性赋值看着很眼熟,对比下会发现和 setter 里调用 didSet 前的代码几乎完全一样。所以说明一个重要的事实:构造器里的属性赋值,是直接对存储进行赋值,并不是调用的setter。 (代码和附加注释见下方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// AA.init()
sil hidden [ossa] @$s8Contents2AACACycfc : $@convention(method) (@owned AA) -> @owned AA {
// %0 "self" // users: %2, %1
bb0(%0 : @owned $AA):
debug_value %0 : $AA, let, name "self", argno 1, implicit // id: %1
%2 = mark_uninitialized [rootself] %0 : $AA // users: %28, %27, %17, %9, %3
%3 = begin_borrow %2 : $AA // users: %8, %4
%4 = ref_element_addr %3 : $AA, #AA.test // user: %7
// function_ref variable initialization expression of AA.test
%5 = function_ref @$s8Contents2AAC4testSivpfi : $@convention(thin) () -> Int // user: %6
%6 = apply %5() : $@convention(thin) () -> Int // user: %7
store %6 to [trivial] %4 : $*Int // id: %7
end_borrow %3 : $AA // id: %8

// 附加注释:准备调用 updateTest
%9 = begin_borrow %2 : $AA // users: %16, %15, %14

// 附加注释:对 updateTest 的入参(整型2)做准备
%10 = integer_literal $Builtin.IntLiteral, 2 // user: %13
%11 = metatype $@thin Int.Type // user: %13
// function_ref Int.init(_builtinIntegerLiteral:)
%12 = function_ref @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %13
%13 = apply %12(%10, %11) : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %15
%14 = class_method %9 : $AA, #AA.updateTest : (AA) -> (Int) -> (), $@convention(method) (Int, @guaranteed AA) -> () // user: %15

// 附加注释:调用 updateTest
%15 = apply %14(%13, %9) : $@convention(method) (Int, @guaranteed AA) -> ()

// 附加注释:结束调用 updateTest
end_borrow %9 : $AA // id: %16

// 附加注释:准备属性赋值
%17 = begin_borrow %2 : $AA // users: %26, %22

// 附加注释:对准备属性赋值的右值(整型3)做准备
%18 = integer_literal $Builtin.IntLiteral, 3 // user: %21
%19 = metatype $@thin Int.Type // user: %21
// function_ref Int.init(_builtinIntegerLiteral:)
%20 = function_ref @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %21
%21 = apply %20(%18, %19) : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %24

// 附加注释:获取属性变量地址,准备赋值。留意这里是直接赋值的存储地址,并没有调用 setter 方法。
// 和 setter 里调用 didSet 之前的逻辑完全一样
%22 = ref_element_addr %17 : $AA, #AA.test // user: %23
%23 = begin_access [modify] [dynamic] %22 : $*Int // users: %25, %24
assign %21 to %23 : $*Int // id: %24

// 附加备注,结束赋值
end_access %23 : $*Int // id: %25
end_borrow %17 : $AA // id: %26
%27 = copy_value %2 : $AA // user: %29
destroy_value %2 : $AA // id: %28
return %27 : $AA // id: %29
} // end sil function '$s8Contents2AACACycfc'

这里仅对最简单场景下中间代码做了分析,感兴趣的同学们,可以对其他复杂场景(比如继承、block)也进行分析确认。

通过以上对中间代码的分析,可以得出2个重要结论,

  1. 非构造函数里的属性赋值,会使用 setter 方法,进而触发 didSet,如果构造函数调用了该方法,也会触发,没有逻辑判断。
  2. 构造函数里对自身的属性的赋值,是直接对存储赋值,不使用 setter 方法,所以不会触发 didSet,和苹果官方文档描述一致。

各场景验证

在了解了 Swift 的实现之后,其实我们不难猜测出对于 kvcblock 等场景下,是否会触发,实际验证如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import Foundation
// 为方便验证 KVC 场景,直接继承自 NSObject
class AA: NSObject {
@objc var test = 1 { // ❌not 初始化不触发
didSet {
print("AA test didSet\(test)")
}
}

override init() {
test = 2 // ❌not init 里不触发
super.init()
self.test = 3 // ❌not init 里显式写 self 也不触发
self.updateTest(4) // ✅yes init 里调方法,触发
test = 5 // ❌not 也不触发

{
self.test = 6 // ✅yes 触发
}() // 立即执行

// 以下3个等价,但需要 test 补充 @objc
self.setValue(7, forKey: "test") // ✅yes
// self.setValue(5, forKey: NSExpression(forKeyPath:\AA.test).keyPath) // ✅yes
// self.setValue(5, forKey: #keyPath(AA.test)) // ✅yes 可以触发
}

func updateTest(_ input: Int) {
test = input // ✅yes always
}
}

class AAA: AA {
var myTest: Int = 0 {
didSet {
print("AAA test didSet\(myTest)")
}
}

override init() {
// test = 6 // ❌not 'self' used in property access 'test' before 'super.init' call
myTest = 1 // ❌not
super.init()

myTest = 2 // ❌not
test = 9 // ✅yes for superclass AA
}
}

let aaa = AAA()
aaa.test = 10 // ✅yes 触发
aaa.myTest = 3 // ✅yes 触发

执行结果

1
2
3
4
5
6
AA test didSet4
AA test didSet6
AA test didSet7
AA test didSet9
AA test didSet10
AAA test didSet3

最后稍微完善下 didSet 触发/不触发的场景原则(重点留意前3条)

  1. 默认值,不触发
  2. 构造函数中赋值,不触发(仅限自身属性,非父类属性)
  3. 构造函数中赋值,在子类调用 super.init 之后,如果对父类属性赋值,则触发(对第2点的补充)
  4. 构造函数中赋值,在子类调用 super.init 之前,不能对父类属性赋值,编译报错
  5. 构造函数中,调用其他方法对属性赋值,触发
  6. 构造函数中,使用 kvc 对属性赋值,触发
  7. 构造函数中,使用立即执行的 block 对属性赋值,触发
  8. 非构造函数,触发,即使被构造函数调用

简单总结,对于观察器(如didSet):默认值不触发;构造函数内对自身属性(非父类属性),直接赋值不触发,间接赋值(调用其他函数、blockkvc)依然触发;继承场景, super.init 后对父类属性复制,会触发父类属性的 didSet

简化记忆,不触发 didSet 的场景,其他例外场景均触发,最好再特殊记一下继承场景对父类属性赋值反而会触发的情况:

  1. 默认值
  2. 构造函数内 直接自有 属性赋值

如果我们的代码比较多的使用 didSet/willSet,那对于构造函数init内部的触发场景,一定要多加小心,避免踩坑。

ChatGPT 的回答

因为手头只有 ChatGPT3.5,没有 ChatGPT4,这里只检查了 ChatGPT3.5 的回答。

针对 didSet 初次提问:

  • 构造器内直接赋值是否触发,回答正确。
  • 构造器内,对父类属性赋值是否触发,回答正确。
  • 构造函数内调用外部函数赋值是否触发,回答错误。
  • 构造函数内用kvc赋值是否触发,回答错误。
  • 构造函数内用block赋值是否触发,回答正确。但是,描述说明里,却说无论直接赋值还是使用闭包赋值,都会触发,则不正确。

当然,如果你恐吓他(只是单纯让他再确认下),他会立马给出完全相反的答案,正确变错误、错误变正确😂。所以,慎用 ChatGPT(至少慎用3.5)来解答一些比较复杂的语言特性的问题,一定要做好验证。