10 内存与值语义
12 分钟阅读
10 内存与值语义
赋值的时候到底发生了什么
这个问题决定了 Swift 里 90% 的意外行为。
值类型(struct enum 集合 基本类型) | 引用类型(class actor 闭包) | |
|---|---|---|
| 赋值 | 逻辑上复制一份 | 复制一个指针 |
| 传参 | 逻辑上复制一份 | 传指针 |
| 放进数组 | 逻辑上复制一份 | 传指针 |
| 改一个会影响另一个吗 | ❌ | ✅ |
| 内存布局 | 通常内联在容器里 | 堆上一个独立对象 |
| |
两个变量各有一份数据。函数参数同理:
| |
| |
哪怕 a 是 let,a.value 依然能改——let 锁的是指针,不是对象内部。
| |
🔥 一个数组里装引用类型,数组本身的"值语义"就名存实亡了——拷贝数组只拷贝了指针。
写时复制(Copy-on-Write)
“逻辑上复制一份"如果真每次都复制,性能会很糟。所以标准库的 Array、Dictionary、Set、String 用了写时复制:只有在你真的改了,而且还有别人共享时,才复制。
| |
代价是:isKnownUniquelyReferenced 这种判断带一点开销,而且多线程共享同一份底层缓冲时,一旦复制,内存峰值会翻倍。
给自己的类型实现 COW
| |
💭 需要写 COW 的场合其实很少:容器装的是大块数据(大数组、图片缓冲)才值得。普通小结构体直接复制更简单。
独占访问:写的时候别人不许碰
Swift 有一条平时不出声、出事时很难查的规则:同一块内存,写访问必须独占。读可以大家一起读,但只要有人在写,就不许别人同时读或写它。
inout 参数是这条规则最常见的入口:它的写访问从实参求值完开始,一直持续到函数返回。下面几种写法都会炸。
把同一个变量传给两个 inout 参数,编译器当场拦下:
| |
而下面这种"看着挺正常"的写法,编译器有时证明得了、有时证明不了:
| |
⚠️ 把 var oscar 挪到函数外面变成全局变量,同一行代码就换了个死法——编译期放行,运行期崩(本机实测):
| |
| |
元组的两个元素同理(balance(&pair.0, &pair.1) 一样崩)。原因是”编译器能不能静态证明这两个访问互不相干":局部变量它证得了,就按静态诊断放行;全局变量、类实例的属性、元组元素它证不了,于是退回运行期检查——一冲突就打印上面那句 Simultaneous accesses to ...,随后进程带着崩溃栈退出。
⚠️ 判断标准不是"这两块内存有没有关系",而是"编译器能不能证明没有关系"。14 类型转换 里 NSNumber 那种"看起来是 A 其实是 B"的坑,和这里是同一种味道:别问直觉,问编译器。🝖 顺带一提,这条检查在 -Onone 和 -O 下都在;真想在优化构建里关掉,得显式写 -enforce-exclusivity=unchecked(那时上面两段都"能跑"了,代价是冲突变成未定义行为)。
真碰上冲突,解法只有一个:把要读的值先抄到局部变量里,让读访问在写访问开始之前结束。
| |
⚠️ 同一个值既是 mutating 方法的 self、又是它的 inout 参数时也报同样的错:oscar.share(with: &oscar) → inout arguments are not allowed to alias each other。
📘 完整的规则与示意图在官方语言指南的 Memory Safety 一章。
ARC:引用计数
类实例靠自动引用计数(ARC)管理生命周期。每次赋值、传参、存进属性,计数 +1;离开作用域,计数 -1;归零就销毁并调用 deinit。
| 引用种类 | 会 +1 吗 | 为 nil 时访问 | 什么时候用 |
|---|---|---|---|
strong(默认) | ✅ | 对象不会为 nil | 默认,表示"我拥有它" |
weak | ❌ | 自动变成 nil,所以类型必须是 T? | 反向引用、代理、闭包里的 self 🔥 |
unowned | ❌ | 崩溃 | 你能保证生命周期不短于自己 |
| |
把 weak var child 改回 var child(强引用),上面两个对象会互相持有,谁都不会被释放——这就是循环引用。
循环引用的三种典型形态
| 形态 | 症状 | 修法 |
|---|---|---|
| 两个对象互相强引用 | 两个 deinit 都不执行 | 一侧改 weak |
闭包捕获 self,self 又持有闭包 | 控制器不释放 | [weak self] |
| 代理属性写成强引用 | 视图控制器不释放 | weak var delegate |
| |
⚠️ 两个常见的说法都不准确,先纠正掉:
❌「
weak只能写在类里」——能写在任何地方。struct、actor、局部变量里都可以放weak var,它照样是真的弱引用:1 2 3 4 5 6 7 8final class Session { deinit { print("session 没了") } } struct Holder { weak var session: Session? } // ✅ 合法,且确实是弱引用 var s: Session? = Session() let h = Holder(session: s) s = nil // prints: session 没了 print(h.session == nil) // prints: true✅「
weak只能指向类(或类约束协议)类型」——这才是真正的限制。随便拿个值类型去弱引用,报的是:1 2 3struct Point { var x = 0 } struct Holder { weak var p: Point? } // error: 'weak' may only be applied to class and class-bound protocol types, not 'Point'
⚠️ 顺带一提,weak 也不能出现在元组类型和 enum 关联值里(case a(weak C) 报 enum case cannot have keyword arguments)——不是语法没设计好,而是这两处没有"属性"这个概念,弱引用得挂在属性上。真需要的话,把那个值包进一个 struct 再放进去。
💭 所以判断标准不是"这个类型是不是值类型",而是"被指向的那个类型是不是类"。值类型里放 weak,恰恰是打断"类 → 结构体 → 类"这种间接引用环的标准手法。
deinit 与销毁顺序
| |
⚠️ deinit 里不能调用异步代码,也不应该做需要 await 的清理。真正的异步资源释放要另想办法(比如显式 close())。
两个"管生命周期"的工具
ARC 的规则是"最后一次用到之后就可以释放",所以下面这两件事偶尔会需要你插手:
| |
| 工具 | 什么时候用 |
|---|---|
withExtendedLifetime(x) { ... } | 对象的释放时机被优化得过早,而它恰好持有 C 指针、文件描述符一类资源 |
autoreleasepool { ... } | Apple 平台上循环创建大量临时 Objective-C 对象(例如 NSString、UIImage),内存峰值压不下去 |
💭 autoreleasepool 只和 Objective-C 的自动释放池有关——纯 Swift 对象的生命周期由 ARC 在编译期插桩,不进那个池子。所以它是个"边界工具",不是日常清理手段。
闭包捕获的是值还是引用
| |
两者输出的都是 100,但原因不同:闭包捕获的是变量本身(number 被搬到堆上共享),而 counter 捕获的是指针,指向的对象的属性当然也变了。
想在闭包里冻结那一刻的值,用捕获列表:
| |
所有权与不可复制类型 🆕
Swift 5.9 起支持显式的所有权标注,Swift 6 里逐渐可用:
| |
| 写法 | 含义 |
|---|---|
~Copyable | 这个类型不能被复制,只能移动 |
consuming func | 调用后把 self 吃掉 |
borrowing func | 只借用,不取得所有权(默认行为) |
consuming 参数 | 参数被函数吃掉,调用方不能再使用 |
🚧 这套机制的生态还在成形。写库、做零拷贝、封装文件句柄与锁时它很有价值;日常业务代码知道有这回事就够了。
MemoryLayout:内存里到底占多大
| |
| 类型 | size | stride |
|---|---|---|
Int | 8 | 8 |
Bool | 1 | 1 |
(Int8, Int8) 元组或结构体 | 2 | 2 |
{ Bool, Int } 结构体 | 16 | 16 |
String | 16 | 16 |
[Int] | 8 | 8 |
无关联值的 enum(3 个 case 也一样) | 1 | 1 |
只有一个 case、且只带一个 Int 的 enum | 8 | 8 |
带 Int 关联值、但有多个 case 的 enum | 9 | 16 |
Int? | 9 | 16 |
class 的引用本身 | 8 | 8 |
⚠️ 上表里那两个 enum 的行最容易记反,实测数据摆在这里:
| |
所以"带关联值的 enum 就是 size + 1"这条经验只对多个 case成立;只有一个 case 时它退化成那个关联值本身。Int? 走的正是"多个 case"那条路(.none / .some),所以是 9 / 16。
关键区别:size 是实际用到的字节,stride 是数组里每个元素占的间隔。 两者的差额就是编译器塞进去的填充。写跨平台二进制格式时别拿结构体布局当协议:成员顺序、填充位置都不保证,size 也不等于"各字段加起来"。要序列化就老老实实按字节逐个字段写。
指针与 unsafe API
需要读原始字节、和 C 交互、或者做极致优化时,才用这一层:
| |
⚠️ offset(of:) 在哪个类型的 MemoryLayout 上调,key path 的根类型就必须是那个类型:MemoryLayout<Int>.offset(of: \WithPadding.i) 会报 “cannot convert value of type PartialKeyPath<WithPadding> to expected argument type PartialKeyPath<Int>"。上面写成 \.i 能省掉根类型,是因为上下文已经把它推成 WithPadding 了。
| API | 用途 |
|---|---|
withUnsafeBytes(of:) | 看某个值的原始字节 |
withUnsafeBufferPointer(_:) | 只读访问数组底层缓冲 |
withUnsafeMutableBufferPointer(_:) | 可写访问 |
withUnsafePointer(to:) | 取某个变量的地址 |
UnsafeMutableRawPointer.allocate | 手工分配内存,必须配对释放 🛑 |
MemoryLayout<T>.offset(of:) | 某个属性在结构体里的字节偏移 |
ObjectIdentifier(x) | 引用类型的身份标识,可哈希、可比较 |
Unmanaged<T> | 不参与 ARC 的引用,给 C / 老 API 传对象指针时用 🝖 |
x.bitPattern / Double(bitPattern:) | 浮点与整数的位模式互转,安全且无开销 |
unsafeBitCast(_:to:) | 强行按位重解释,编译器多半会建议你换别的写法 🛑 |
| |
⚠️ 三句提醒:
ObjectIdentifier问的是"是不是同一个对象”,不是"两个对象相等吗"。后者要Equatable,一个对象可以"相等但不同一"。Unmanaged的引用计数归你管:passRetained之后你得负责release,takeRetainedValue会吃掉一次引用。用错方向就是过度释放,崩溃现场通常在几百行之外。- 想"看浮点的位"就写
.bitPattern,别上unsafeBitCast——实测编译器会直接给出'unsafeBitCast' from 'Double' to 'UInt64' can be replaced with 'bitPattern' property on 'Double'这条警告。unsafeBitCast留给真的没有安全替代品的时候。
⚠️ 指针不能逃逸出 withUnsafe... 闭包。存下来以后再用,指向的可能是已经被回收或搬走的内存,这类 bug 表现为"偶尔算错一个数",极难定位。
陷阱速查
| 陷阱 | 说明 |
|---|---|
以为 let 类实例不可改 | let 锁的是指针,属性照样能改 |
| 用类做本该用结构体的东西 | 无意间的共享修改,是这类 bug 的头号来源 |
| 数组装引用类型 | 拷贝数组 ≠ 拷贝元素 |
| 代理属性写成强引用 | 典型的循环引用 |
闭包忘记 [weak self] | 控制器、视图永远不释放 |
unowned 用错 | 访问已释放对象直接崩溃,不确定就用 weak |
| COW 与多线程 | 共享缓冲被两侧同时改写会触发多次复制,性能反而更差 |
同一个变量传进两个 inout | 编译不过:inout arguments are not allowed to alias each other,先复制到局部变量 |
全局变量 / 元组的两个属性做 inout | 编译器证明不了独占性,改成局部变量,或先复制再写回 |
size 当 stride 用 | 数组下标计算出错,通常表现为"数据错位一格" |
| 指针逃逸 | 未定义行为,比崩溃更难查 |