Swift 与 Objective-C

[Swift 基础 #3] Swift 属性总览:存储、计算、lazy、didSet 的 4 个选择标准

这是 Swift 基础系列第 3 篇。本文不罗列语法,而是围绕选择标准整理五种属性。核心问题只有一个:这个值会被存储、被计算,以及何时创建?

7 分钟阅读
[Swift 基础 #3] Swift 属性总览:存储、计算、lazy、didSet 的 4 个选择标准 封面图

var name = ""。这是 Swift 中最常见的一行代码,但可以放在这里的选项比想象中更多:存储属性、计算属性、lazy、willSet 和 didSet,以及类型属性。即使学过所有语法,真正问到“这个值该用 lazy 还是计算属性?”时,也常常没有清晰标准。

这是 Swift 基础系列第 3 篇。本文不罗列语法,而是围绕选择标准整理五种属性。核心问题只有一个:这个值会被存储、被计算,以及何时创建?

在因为需要共享状态而选择类型属性之前,也请确认 Swift 单例的测试成本,这样能更清楚地区分 static letstatic var 的边界。

存储 vs. 计算——内存中的值与按需创建的值

属性的第一个分岔是存储还是计算。

存储属性(stored property)是实例中实际占用空间的值。写成 var name = "Kim" 后,实例内存会为 name 分配空间。struct 的大小由其存储属性之和决定。

计算属性(computed property)不占用存储空间。每次访问时执行代码生成值,本质上就是一个函数。

struct Rectangle {
    var width: Double   // 存储
    var height: Double  // 存储

    var area: Double {  // 计算——没有存储空间
        width * height
    }
}

你也可以把 area 做成存储属性。但这样每次 width 改变都要更新 area,两者不一致就会产生 bug。由此得到第一个标准:由其他值推导出的值不要存储,而应计算。保持唯一的事实来源,就能从根本上消除同步 bug。

给计算属性添加 set 后就可以写入。存储 celsius、计算 fahrenheit;给 fahrenheit 赋值时,可以反向计算并更新 celsius。此时存储的事实仍然只有一个,其他都是观察这个事实的窗口。

也说明一下何时用计算属性代替函数。Apple API 设计指南的惯例是:计算成本低(通常接近 O(1))、没有副作用,并且概念上属于“对象的属性”时使用属性;计算昂贵或有副作用时使用方法。这就是 array.count 是属性而 array.sorted() 是方法的原因。

lazy——首次使用时创建的存储属性

lazy 是第三种选择。它是存储属性,但不是在创建实例时初始化,而是在首次访问时初始化。

class ImageProcessor {
    lazy var filters: [Filter] = loadExpensiveFilters()
}

常见用途有两种。第一,初始化成本高但可能不会使用的值;每个实例都准备重量级资源会造成浪费。第二,需要引用 self 的属性。普通存储属性的默认值会在 init 结束前计算,因此不能使用 self;lazy 会把初始化推迟到首次访问,所以允许引用 self。这也是立即执行闭包(= { ... }())经常与 lazy 搭配的原因。

有两点需要注意。lazy 必须是 var。由于它的值会从类似 nil 的未初始化状态变成实际值,所以不能是 let。此外它不是线程安全的。多个线程同时首次访问时,初始化可能执行两次。如果多线程环境中的值必须只创建一次,就需要其他机制。

它与计算属性的区别也很明确:lazy 只计算一次并保存结果,之后返回该值;计算属性每次都会重新计算。“昂贵但不变的值”用 lazy,“便宜但必须始终最新的值”用计算属性。

存储是货架上的箱子,计算是接单后制作的工厂
存储是货架上的箱子,计算是接单后制作的工厂

willSet 和 didSet——响应值的变化

存储属性可以添加观察器。它们会在值变化前(willSet)和变化后(didSet)执行代码。

class ProgressBar {
    var progress: Double = 0 {
        didSet {
            // oldValue会自动提供
            guard progress != oldValue else { return }
            updateUI()
        }
    }
}

典型用途是在值变化时自动处理后续工作,例如更新 UI、记录日志,以及验证值(超出范围时回退)。无需手动创建 setter,就能保留赋值语法并加入附加行为。

实际工作中有两个容易混淆的规则。第一,在 init 中赋值时不会调用观察器。初始化是“设置”而不是“变化”,所以 didSet 中的 UI 更新不会作用于初始值。第二,修改值类型的属性时,包含它的属性的 didSet 也会被调用。如 person.name = "Lee",即使只修改 struct 内部,也会调用 person 的 didSet。值类型章节中的“修改就是替换成新值”这一语义在这里同样成立。

要警惕滥用 didSet。如果其中堆积了逻辑,就很难追踪一行赋值究竟做了什么。改一个值却发出网络请求,就违反了最小意外原则。didSet 中只保留轻量同步,把繁重逻辑移到显式方法中更安全。

类型属性——附加在类型而非实例上的值

添加 static 后,属性会附加到类型本身,而不是实例。

struct APIConfig {
    static let baseURL = URL(string: "https://api.example.com")!
    static var requestCount = 0
}

无论创建多少实例,类型属性都只有一个。它适合常量集合(配置值、共享格式化器),标准库也常在 Int.maxDouble.pi 这样的地方使用它。

有一个值得了解的事实:static let 保证线程安全的延迟初始化。它在首次访问时只初始化一次,并且能安全处理并发访问。lazy var 没有这项保证,而 static 有。这也是单例中的 static let shared 无需额外锁机制即可成立的依据。不过可变的 static var 状态实际上就是全局状态,会破坏测试隔离并成为数据竞争候选。单例为何被称为反模式会在另一篇文章中讨论;这里记住“static var 是最后手段”即可。

只需四个问题即可完成选择
只需四个问题即可完成选择

选择流程图——用四个问题总结

把五种类型压缩成一句话问题,就是下面这样。

  1. 是否由其他值推导而来? → 计算属性。只存储一个事实来源。
  2. 初始化成本高,或需要 self 吗? → lazy var。但要注意多线程首次访问。
  3. 是否有跟随值变化的行为? → 存储属性 + didSet。但只做轻量工作。
  4. 是否不依赖实例、只需要一个值? → static。如果是 let,还能获得安全的延迟初始化。
  5. 如果都不是 → 普通存储属性。大多数情况下,这就是答案。

SwiftUI 的 @State、@Published 等属性包装器(property wrapper)最终也是建立在这套属性系统上的语法。理解存储、计算和观察器的原理后,就能看出包装器其实是“包裹存储属性并自动化 didSet 类行为”。直接创建属性包装器将在中级系列中介绍。

直接运行确认的结果

我在 Apple Swift 6.3.3 中,于同一个对象记录了 lazy 初始化次数、didSet 的旧值与新值,以及计算属性结果。输出显示读取 lazy 两次,并将存储属性从 0 改为 7。

properties=lazy-builds:1,didSet:0->7,computed:14

正因为这个小型运行结果,如果缓存确实必须只创建一次,我不会只依赖 lazy 这个语法。只要存在并发访问的可能,就测试初始化次数并确认是否需要额外同步。相反,在 didSet 中只保留像上述输出那样观察状态变化的轻量行为,把可能失败的 I/O 移到显式方法中。

总结

  • 属性的第一个标准是存储还是计算。由其他值推导出的值使用计算,以保持唯一的事实来源。
  • lazy 是在首次访问时初始化的存储属性,可解决昂贵初始化和 self 引用问题。代价是必须使用 var,且不具备线程安全性。
  • willSet/didSet 用于响应值变化,但在 init 中不会调用;如果加入繁重逻辑,也会难以追踪。
  • static let 是保证线程安全延迟初始化的类型属性;static var 是全局状态,应作为最后手段。

下一篇是基础第 4 篇:guard。我们将深入解析在 Optional 文章中短暂介绍的提前退出,并整理 Swift 社区为何要与缩进展开战争。

推荐阅读

来源与验证