Swift 与 Objective-C

[Swift 哲学 #3] Swift 为什么几乎全是 struct?值类型优先原则总结

打开 Swift 标准库,会发现一个显著事实:除了 Int、Double、Bool 等基本类型,String、Array、Dictionary、Set 也全是 struct。在其他语言中理所当然是类的类型,在 Swift 中都是值类型…

6 分钟阅读
[Swift 哲学 #3] Swift 为什么几乎全是 struct?值类型优先原则总结 封面图

Swift 标准库中有一个显著特点:Int、Double、Bool、String、Array、Dictionary、Set 全部都是 struct。在其他语言中属于类的类型,在 Swift 中都是值类型。Java 的 String 是类,而 Python 中一切都是对象引用。

这并非偶然。Swift 在设计阶段就确立了“默认选择值类型”的方针,并在 WWDC 2015 的著名演讲“Protocol-Oriented Programming”和“Building Better Apps with Value Types”中正式宣布。本篇将整理 Swift 为何默认使用值而非引用,以及这一选择如何影响整个语言。

这是 Swift 哲学系列第 3 篇。类与结构体的语法差异已在其他文章中介绍,这里聚焦于“为什么要产生这些差异”的设计意图。

引用优先世界的顽疾

要理解值类型优先,首先要看看以引用类型为默认的世界存在什么问题。

引用类型的本质是共享。复制变量后对象仍然只有一个,两个变量指向同一个对象。有意共享是功能,无意共享则是 bug 的温床。经典模式如下。

// 假设是引用类型(类)
let settings = defaultSettings
settings.fontSize = 20   // 我本来没打算修改默认设置
// defaultSettings.fontSize却 20了

以为复制了,实际上却在共享。此类 bug 的棘手之处在于症状和原因相距甚远。值出错的位置与破坏它的代码隔着好几个文件,调试就变成“追踪所有引用此对象的地方”。

Objective-C 开发者很了解这个问题,因此一直通过惯例防范。他们将 NSString 属性声明为 copy,区分 NSArray 的可变版本(NSMutableArray)和不可变版本,并习惯加入防御性复制。这些都是用开发者纪律弥补“默认引用”问题的补丁。

Swift 团队的观点是:如果每次都要靠惯例防御,那么语言默认值是不是错了?

值类型守护的东西——局部推理

值类型的核心特性是,复制后会真正成为独立的对象。

var a = [1, 2, 3]
var b = a
b.append(4)
// a仍然是 [1, 2, 3]

这保证的不只是便利,而是局部推理能力。把数组传给函数时,如果它是值类型,就不必担心“这个函数可能偷偷修改我的数组”。只要阅读自己的代码块,就能完全掌握变量状态。在引用类型世界中,必须从整个程序考虑“谁持有对象、何时修改”;值类型则把思考范围缩小到眼前的一个函数。

这与第 1 篇讨论的 Safe 哲学完全相连。正如可选类型在编译时捕获“忘记处理 nil”,值类型也在类型层面消除了“意外共享”这一类 bug。用 let 声明的 struct 还是真正不可变的。类实例的 let 只表示引用不会改变,内容仍然可以改变。

这一特性随着时间推移变得更有价值。在多线程环境中,数据竞争发生于多个线程共享同一块内存;值类型默认不共享,因此竞争的前提消失了。Swift Concurrency 将值类型列为可以跨越线程边界的代表类型(Sendable),是自然结果。2014 年的设计决策在 2021 年的并发模型中得到了回报。

引用一起抓住一个气球,值类型各自拥有自己的气球
引用一起抓住一个气球,值类型各自拥有自己的气球

“复制不是很昂贵吗?”——Copy-on-Write 的答案

对值类型优先的第一项质疑总是性能:每次把包含 10 万个元素的数组传给函数都完整复制,真的承受得了吗?

Swift 的答案是 Copy-on-Write(CoW)。Array、Dictionary、Set、String 等标准集合在赋值时共享内部存储,只有一方修改的瞬间才真正复制。从语义上看它们是完整的值,修改互不可见;从成本上看,仅读取时无需复制,接近引用。

var a = hugeArray   // 不发生复制,共享存储
let x = a[0]        // 仍然不复制
a.append(1)         // 此时才首次发生复制

需要注意,CoW 是库的实现技术,不是语言功能。标准集合内置了它,但我们自行创建的 struct 不会自动获得它。如果自定义值类型持有大型数据,就要使用 isKnownUniquelyReferenced 自行实现。这个实现细节已在 CoW 专文中整理。

反方向也有性能优势。小型 struct 无需堆分配和引用计数即可放在栈上,因此反而比类更便宜。这就是 CGPoint 使用 struct 的原因。所以“值类型很慢”的直觉在 Swift 中通常正好相反。

没有继承也能生存——协议与组合

值类型优先有一个代价:struct 不能继承。没有引用时,实现部分多态更困难。那么代码复用和多态该怎么办?

Swift 的答案是面向协议编程(POP)。用协议声明公共接口,将公共实现放入协议 extension,再让类型采用多个协议来组合能力。继承是从一个父类继承一切的垂直结构;协议采用则是选择所需能力的水平结构。

struct Player: Codable, Equatable, Comparable {
    let name: String
    let score: Int

    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.score < rhs.score
    }
}

这个 struct 没有继承任何东西,却具备 JSON 转换、相等比较和排序能力。Codable 和 Equatable 的实现甚至可以由编译器自动合成。值类型与协议的组合取代了继承的大多数实际用途。

因此,值类型优先和面向协议是一套组合。WWDC 2015 将两个演讲并列发布并非偶然。“用 struct 和协议代替类继承”是 Swift 提出的默认组合,整个标准库都采用了这种方式构建。POP 本身已在其他文章中详细介绍。

Copy-on-Write 读取时共享,写入时才复制
Copy-on-Write 读取时共享,写入时才复制

那么,什么时候使用类?

值类型优先并不是“不要使用类”。准确的规则是:“默认使用 struct,有充分理由需要引用时才选择类。”大致有三种理由。

**需要身份(identity)时。**数据库连接、界面视图、文件句柄等即使值相同也是不同的实体,使用引用很自然。两个连接配置相同,也不是同一个连接。

**共享本身就是目的时。**多个界面需要观察同一状态的共享模型,或整个应用只能存在一个的管理器,都把引用的共享特性作为功能。

**需要管理生命周期时。**如果需要通过 deinit 清理资源,或要与 UIKit 等 Objective-C 框架交互,就使用类。

Apple 官方文档的指南也指向同一方向:默认使用 struct 和 enum,符合这些条件时再选择类。实际上,在 SwiftUI 时代,应用代码正逐渐收敛为视图使用 struct、状态数据使用 struct,以及少量引用模型使用 @Observable 类。值是默认、引用是例外的哲学已经贯彻到 UI 框架层面。

总结

  • Swift 标准库几乎全是 struct,这是设计方针。默认选择是值类型。
  • 目标是在类型层面消除引用优先语言中由意外共享导致的远距离 bug。
  • 值类型守护局部推理,这一特性又在 Swift Concurrency 的 Sendable 中得到体现。
  • 标准集合的 Copy-on-Write 和小型 struct 的栈分配回答了性能质疑。
  • 面向协议编程填补了继承的空缺,两者被设计成一套组合。
  • 类被重新定义为在需要身份、共享或生命周期管理时使用的工具。

到这里,Swift 哲学系列介绍了安全性(第 1 篇)、学习曲线(第 2 篇)和值类型(第 3 篇)。下一篇将讨论 Swift Evolution,即这些哲学实际反映到语言中的流程:一条语法从获得 SE-XXXX 编号到进入语言的历程。

延伸阅读