开篇
Swift 诞生于 2014 年的 WWDC,目标是替代 Objective-C 成为 Apple 全平台的首选语言。十多年过去,Swift 已经演进到 6.0(Xcode 16 内置),语言形态与最初相比发生了巨大变化:从早期的 AnyObject 泛滥、@objc 依赖,走向了以值类型、协议、泛型为核心的现代风格。
对从 Java、C#、TypeScript 或 Objective-C 转过来的开发者而言,Swift 的学习曲线并不陡峭,但有几个地方特别容易“写得出代码、却写不对代码”:
- 把
struct当class用,无脑复制引用语义的思维; - 以为
struct一定没有堆分配,从而在循环里反复拷贝大数组; - 闭包里
self捕获语义理解不清,导致循环引用或数据竞争; - 面对
Optional只会!强制解包,把崩溃留到线上。
本文不追求语法手册式的穷举,而是聚焦“为什么这样设计”与“什么时候该用哪个”。如果你已经能写出能跑的 Swift,这篇文章帮你把直觉校正到正确的方向。后续可以继续阅读 Swift 可选类型与错误处理 与 Swift 协议与泛型编程 ,这三篇构成语言基础的主干。
一、值类型与引用类型
1.1 两种语义的本质区别
Swift 中的类型按赋值行为分为两类:
- 值类型(value type):
struct、enum、tuple,以及标准库中的Array、Dictionary、Set、String。赋值或传参时产生一份逻辑上独立的副本。 - 引用类型(reference type):
class、actor、闭包。赋值时复制的是指向同一块堆内存的指针,多个变量共享同一实例。
struct Point {
var x: Int
var y: Int
}
final class Counter {
var value: Int = 0
}
var p1 = Point(x: 0, y: 0)
var p2 = p1 // 拷贝
p2.x = 10
print(p1.x) // 0 —— p1 不受影响
let c1 = Counter()
let c2 = c1 // 共享同一实例
c2.value = 99
print(c1.value) // 99 —— 两个变量指向同一对象
这个区别看似基础,但它决定了并发安全、缓存行为、API 副作用等一连串设计问题。Swift 6 的严格并发检查(strict concurrency)之所以能把大量数据竞争在编译期拦下来,根基就在于值类型默认是 Sendable 的,而 class 不是。
1.2 写时复制(Copy on Write)
一个常见的误解是“值类型每次赋值都会深拷贝,所以很慢”。实际上标准库的集合类型采用写时复制:赋值时只增加引用计数,只有当其中一份被修改、且引用计数大于 1 时才真正复制底层缓冲区。
var a = Array(0..<1_000_000) // 底层 buffer 引用计数 = 1
var b = a // 只 +1 引用计数,没有复制 8MB 数据
b.append(999_999) // 此时才真正复制 buffer
print(a.count) // 1000000 —— a 不受影响
写时复制是标准库为集合类型手动实现的优化,不是你写 struct 就自动拥有的。自定义 struct 若持有 class 引用或大块堆内存,需要自己用 isKnownUniquelyReferenced(_:) 实现 COW,否则会退化成“共享引用”或“全量深拷贝”。
| 维度 | 值类型(struct/enum) | 引用类型(class) |
|---|---|---|
| 赋值语义 | 逻辑副本 | 共享实例 |
| 默认存储位置 | 可能栈、也可能堆(取决于逃逸) | 堆 |
| 继承 | 不支持 | 支持 |
| 析构 | 无 deinit | 有 deinit |
| 恒等比较 | 只能比较内容(==) | 可比较引用(===) |
| 并发安全 | 天然 Sendable(成员都 Sendable 时) | 需 @unchecked 或加锁/actor |
| 适合场景 | 数据模型、配置、枚举状态 | 有身份与生命周期的实体、共享可变状态 |
1.3 什么时候值类型反而更慢
值类型并非万能。当结构体体积很大(比如包含 20 个字段)且频繁在函数间传递时,逐字段拷贝的开销会超过一次指针传递。此时有两种应对:把大结构体装箱成 class,或改用 inout 参数避免拷贝。
// 避免:大结构体按值传参
func process(_ config: HugeConfig) { /* 每次都拷贝 */ }
// 更好:inout 传递,语义上仍是一次修改
func process(_ config: inout HugeConfig) { /* 原地修改 */ }
1.4 let 与 var 的语义边界
Swift 的 let 对值类型与引用类型含义不同:对 struct,let 冻结了所有属性,任何修改都编译报错;对 class,let 只冻结引用本身,实例内部的属性仍可改。
struct Config { var timeout: Int }
final class Session { var token: String = "" }
let config = Config(timeout: 30)
// config.timeout = 60 // 编译错误:config 是 let
let session = Session()
session.token = "abc" // 合法:引用不变,内容可变
这解释了为什么 struct 的方法要修改自身属性时必须标 mutating——因为方法可能是通过 let 实例调用的,编译器要区分“这个方法会不会改 self”。在协议里声明 mutating func 也会限制它只能被值类型遵循,这是协议设计时常被忽略的约束。
二、struct、enum、class 的选型
2.1 选型决策表
| 问题 | 选 struct | 选 enum | 选 class |
|---|---|---|---|
| 需要继承或共享可变状态 | 是 | ||
| 需要表达“有限个互斥状态” | 是 | ||
| 需要值语义(拷贝即独立) | 是 | 是 | |
需要 deinit 做资源清理 | 是 | ||
| 需要与 Objective-C 互操作 | 常需 | ||
需要被 === 比较身份 | 是 |
经验法则:默认用 struct,需要有限状态集时用 enum,只有在确实需要引用语义、继承或生命周期管理时才用 class。SwiftUI 的视图、SwiftData 的模型、Codable 的 DTO,绝大多数都应该是 struct。
2.2 enum 被低估的表达力
Swift 的 enum 是带关联值的代数数据类型,能表达“状态机”这类问题而不需要一堆可空字段:
enum LoadState {
case idle
case loading(progress: Double)
case success(data: [String])
case failure(Error)
}
func render(_ state: LoadState) {
switch state {
case .idle:
print("等待开始")
case .loading(let progress):
print("加载中 \(Int(progress * 100))%")
case .success(let data):
print("成功,\(data.count) 条")
case .failure(let error):
print("失败:\(error)")
}
}
switch 对 enum 的穷尽性检查是编译期强制的:新增一个 case 时,所有未处理的 switch 都会报错。这比“用几个 Bool 字段表示状态”安全得多——后者很容易出现 isLoading == true && hasError == true 这种非法组合。
2.3 常见误用
- 用
class定义纯数据模型,然后为了“比较相等”手写一大堆==; - 用
struct表达需要共享的可变状态(比如全局缓存),结果各处的修改互不可见; - 在
struct里塞一个class属性,误以为整个类型都是值语义——实际上外层是值语义、内层仍是引用语义,容易踩坑。
三、Optional 与可选链入门
Optional 是 Swift 类型系统最重要的安全机制之一,本质是一个 enum:
enum Optional<Wrapped> {
case none
case some(Wrapped)
}
String? 只是 Optional<String> 的语法糖。可选链(optional chaining)让“逐层访问可能为 nil 的属性”变得简洁:
struct Address { var city: String }
struct User { var name: String; var address: Address? }
let user = User(name: "Leeting", address: nil)
let city = user.address?.city // String?
let upper = user.address?.city.uppercased() // 链上任一环为 nil,整体返回 nil
let fallback = user.address?.city ?? "未知" // 用 ?? 提供默认值
print(fallback) // 未知
要点:可选链的返回值永远是 Optional,即使最内层属性是非可选的 String,user.address?.city 的类型也是 String?。可选链还支持方法调用与下标,且短路特性保证后续表达式不会被执行。
如果你想把 Optional 用得更扎实(可选绑定、guard let、Result、typed throws),详见 Swift 可选类型与错误处理
。
四、闭包与捕获
4.1 捕获语义
闭包会捕获其引用的外部变量。捕获的是变量的引用(box),而不是快照:
var counter = 0
let increment = { counter += 1 }
increment()
increment()
print(counter) // 2 —— 闭包持有 counter 的引用
如果要捕获当前值的快照,用捕获列表:
var value = 10
let snapshot = { [value] in print(value) }
value = 20
snapshot() // 10 —— 捕获的是当时的值
捕获列表还能用来打破循环引用,通过 [weak self] 或 [unowned self]:
final class ViewModel {
var onUpdate: (() -> Void)?
func bind() {
onUpdate = { [weak self] in
guard let self else { return }
print(self.description)
}
}
var description: String { "ViewModel" }
}
[weak self] 会产生 Optional,必须解包;[unowned self] 不产生 Optional,但如果对象已释放会崩溃。默认选 weak,只有在能严格证明生命周期覆盖时用 unowned。
4.2 逃逸与非逃逸闭包
| 特性 | 非逃逸 (()->Void) | 逃逸 @escaping (()->Void) |
|---|---|---|
| 默认性 | 是 | 需显式标注 |
| 生命周期 | 函数返回前结束 | 可超出函数作用域 |
| 是否可存为属性 | 否 | 是 |
捕获 self 是否需 weak | 通常不需要 | 通常需要 |
| 性能 | 编译器可优化,无堆分配 | 通常需堆分配 |
非逃逸闭包是默认值,编译器可以做更激进的优化(栈上分配、内联)。只有把闭包存起来、或传给异步 API 时才需要 @escaping。
五、属性包装器
属性包装器(property wrapper)把“读写属性时附带的逻辑”抽成一个可复用类型:
@propertyWrapper
struct Clamped<Value: Comparable> {
var wrappedValue: Value {
didSet { wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound) }
}
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
}
struct Player {
@Clamped(0...100) var volume: Int = 50
}
var player = Player()
player.volume = 150
print(player.volume) // 100 —— 被自动钳制
标准库与 SwiftUI 里大量使用属性包装器:@State、@Binding、@Published、@Environment、@AppStorage 都是属性包装器。理解其展开形式(编译器把 @Clamped var volume 展开为一个 _volume: Clamped<Int> 存储属性加一个计算属性)之后,调试 SwiftUI 的状态问题会轻松很多。
六、Result builder 简介
Result builder(旧称 function builder)是让“声明式 DSL”成为可能的编译期转换机制,SwiftUI 的 body、SwiftData 的查询、以及社区里的 HTML/SQL DSL 都建立在它之上:
@resultBuilder
struct StringBuilder {
static func buildBlock(_ parts: String...) -> String {
parts.joined(separator: "\n")
}
static func buildOptional(_ part: String?) -> String {
part ?? ""
}
static func buildEither(first: String) -> String { first }
static func buildEither(second: String) -> String { second }
}
@StringBuilder
func makeReport(includeHeader: Bool) -> String {
"标题"
if includeHeader {
"副标题"
}
"正文"
}
print(makeReport(includeHeader: true))
关键点是 buildBlock、buildOptional、buildEither 这些静态方法把一串语句改写成对它们的嵌套调用。SwiftUI 中“为什么 if 能出现在 body 里、而 for 需要 ForEach”的答案就在这里:buildArray 只支持同构元素,而 if/else 由 buildEither 支持。
七、Swift 5.9 与 Swift 6 的语言特性
7.1 if 与 switch 表达式
从 Swift 5.9 起,if 和 switch 可以作为表达式直接产生值,省掉临时变量:
let score = 82
let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" }
let icon = switch grade {
case "A": "star.fill"
case "B": "star.leadinghalf.filled"
default: "star"
}
注意每个分支必须是单个表达式,且类型需一致。它适合替代“只为算一个值而写的三元表达式套娃”。
7.2 宏 macros
Swift 5.9 引入的宏系统让编译期代码生成成为语言的一等公民。宏分为两类:freestanding(独立调用,如 #Preview、#Predicate)与 attached(附着在声明上,如 @Observable)。
import Observation
@Observable
final class CartModel {
var items: [String] = []
var total: Double = 0
}
#Preview {
let model = CartModel()
model.items = ["键盘"]
return Text("\(model.items.count)")
}
@Observable 是 attached 宏,编译器会为每个存储属性生成观察代码;#Preview 是 freestanding 宏,展开成一段预览注册代码。宏与属性包装器的区别在于:属性包装器是运行时的包装类型,宏是编译期展开的语法树改写,因此没有额外运行时开销,但调试时需要展开宏(Xcode 的 Expand Macro 命令)来看真实代码。
Swift 6 的语言模式(language mode)默认开启严格并发检查,把数据竞争从运行时问题变成编译期错误。这是升级 Xcode 16 时最容易“炸出一片红”的地方,迁移建议逐 target 开启、分批修复。
八、命名与 API 设计规范
Apple 的 Swift API Design Guidelines 是团队协作的底线,几条最常用的:
- 方法名读起来像英语句子:
array.remove(at: 3)而非array.removeIndex(3); - 布尔属性用
is/has/can前缀:isEnabled、hasChanges; - 副作用的方法用动词命令式(
sort()),无副作用的返回新值用-ed/-ing(sorted()); - 参数标签优先用介词短语补全语义:
move(from:to:)、insert(_:at:); - 避免缩写:
ViewController而非VC,configuration而非config(局部变量可放宽)。
命名是“零成本的可维护性投资”,Code Review 里最该被挑剔的就是命名。
常见坑清单
- 在
struct的mutating方法里调用self的闭包属性,导致“逃逸闭包捕获可变 self”的编译错误; - 用
class做模型,然后在 SwiftUI 的@State里持有它,发现界面不刷新(因为引用没变); - 忘记
Array的 COW 特性,在热路径里对同一数组做大量写操作却不自知复制发生; - 闭包中隐式捕获
self却不加捕获列表,在长生命周期回调里造成内存泄漏; - 属性包装器的
init里在wrappedValue完成初始化之前就读取它,导致编译错误或读到默认值; - 以为
enum不能有方法/计算属性,把逻辑散落到外部switch; - 在 Swift 6 语言模式下把非
Sendable的class跨 actor 传递,编译报错后才回头重构; - 用
!强制解包处理“理论上不会 nil”的场景,结果线上崩溃率上升。
FAQ
Q:struct 一定在栈上分配吗?
不一定。Swift 的编译器会在能证明生命周期不逃逸时把 struct 放在栈上,但一旦被闭包捕获、被 Any 装箱、或作为 class 的属性,就会分配在堆上。不要用“栈 vs 堆”作为选型依据,用“语义”来选。
Q:Array 是值类型,那传参时会不会很慢?
不会。标准库为集合实现了写时复制,只读传参只是引用计数 +1。真正产生复制的时机是“引用计数大于 1 时发生写操作”。
Q:什么时候必须用 class?
需要继承(如自定义 UIView 子类)、需要 deinit 管理资源(如持有文件句柄、定时器)、需要引用身份语义(如共享的 URLSession 配置)、或需要与 Objective-C 运行时互操作时。
Q:@escaping 是不是加了就更慢?
它本身不改变闭包体,但逃逸闭包通常需要堆分配和引用计数,无法像非逃逸闭包那样栈分配或内联。所以能用非逃逸就别加 @escaping。
相关阅读
- Swift 可选类型与错误处理
— 把 Optional、可选绑定、
Result与 typed throws 一次讲透,是本文第三节的深入展开。 - Swift 协议与泛型编程
— 承接值类型与协议思维,讲清
some/any、关联类型与面向协议编程。 - Swift ARC 与内存管理
— 闭包捕获与引用计数的底层机制,理解
weak/unowned的取舍依据。
小结
Swift 的语言设计围绕三个核心展开:值语义优先、Optional 显式化、编译期安全。把 struct 作为默认选择、把 enum 当作状态建模的利器、把 Optional 当作必须处理的类型而非障碍、把闭包捕获列表当作常规习惯,你就已经跨过了 Swift 新手期最大的几个坑。属性包装器与 Result builder 是理解 SwiftUI 的前置知识,宏与严格并发则是 Swift 6 时代的必备技能。建议在真实项目里刻意练习“先问该用值类型还是引用类型”,两三周后选型直觉就会稳定下来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。