5-2.【性能分析与优化】哪些“看起来无害”的代码,实际上可能造成严重性能问题?

7 阅读4分钟

一、集合 & 值语义陷阱(最常见)

1️⃣ for x in array { ... array.append(...) }

for x in array {
    array.append(x)
}

为什么危险

  • 每次 append 都可能触发 COW
  • 实际是 O(n²) + 大量内存拷贝

表面假象

“Swift 的 Array 是值类型,应该很安全吧?”

正确姿势

let original = array
array.reserveCapacity(array.count * 2)
for x in original {
    array.append(x)
}

2️⃣ array.filter { ... }.map { ... }

let result = array
    .filter { $0.isValid }
    .map { transform($0) }

为什么危险

  • 产生中间 Array
  • 多次遍历
  • 内存分配翻倍

隐藏成本

  • 每个链式调用都是一次完整遍历

优化

var result = [T]()
result.reserveCapacity(array.count)

for x in array where x.isValid {
    result.append(transform(x))
}

(在热路径里非常值)


3️⃣ contains / first(where:) 在循环中

for x in items {
    if blacklist.contains(x) { ... }
}

看起来

“一行搞定,超清爽”

实际上

  • contains 是 O(n)
  • 外层再循环 → O(n²)

解法

let set = Set(blacklist)
for x in items {
    if set.contains(x) { ... }
}

二、String / Unicode 暗雷(性能杀手)

4️⃣ String.count

if str.count > 10 { ... }

看起来

“count 不就是长度吗?”

实际上

  • Swift 的 String.countO(n)
  • 因为是 grapheme cluster

🔥 在循环里用直接爆炸

替代

if str.utf8.count > 10 { ... }

5️⃣ 用下标访问 String

let c = str[i]

问题

  • 每次都要从头遍历
  • Unicode 变长编码

后果

  • O(n²)

建议

  • [UInt8]
  • 或提前算 index

6️⃣ substring 滥用

let sub = str.prefix(10)
process(String(sub))

危险点

  • 新 String
  • 拷贝 + ARC
  • 在循环中尤其致命

三、ARC & 闭包陷阱(Profiler 里最常见)

7️⃣ 看不见的 retain / release

items.forEach {
    self.process($0)
}

问题

  • 隐式捕获 self
  • 每次循环都有 retain/release

更快

for item in items {
    process(item)
}

8️⃣ 在循环中创建对象

for _ in 0..<100_000 {
    let formatter = DateFormatter()
    formatter.string(from: date)
}

为什么致命

  • ARC 压力巨大
  • DateFormatter 本身超重

修正

let formatter = DateFormatter()
for _ in 0..<100_000 {
    formatter.string(from: date)
}

四、Protocol / 抽象层陷阱

9️⃣ any Protocol

let service: any Service = MyService()
service.run()

看起来

“很现代,很 Swift 5.7”

实际上

  • existential container
  • 动态派发
  • 无法内联

热路径优化

func run<T: Service>(_ s: T) {
    s.run()
}

🔟 protocol + associatedtype 的误用

func process(_ p: any Processor) { ... }

后果

  • witness table 查找
  • 动态类型转换
  • 性能非常不稳定

五、并发“伪优化”

1️⃣1️⃣ 滥用 Task {}

for x in items {
    Task {
        await process(x)
    }
}

问题

  • 创建 Task 本身就有成本
  • 上下文切换比任务还贵

正确心法

  • 并发 ≠ 更快
  • 少量粗粒度任务

1️⃣2️⃣ async let 用在细粒度循环

for x in items {
    async let r = process(x)
    results.append(await r)
}

结果

  • 顺序 await
  • 还多了调度成本

六、Debug 构建下“看起来很慢”

1️⃣3️⃣ 在 Debug 测性能

事实

  • Swift Debug 不内联
  • ARC 不优化
  • 性能和 Release 差一个数量级

⚠️ 永远用 Release + Instruments


七、一句话总结(真·经验)

Swift 的性能灾难
几乎都来自“隐式成本”
而不是你写了多复杂的代码。

最危险的关键词

  • filter / map
  • any
  • String.count
  • forEach
  • Task {}

补充分析:为什么这段代码是正确的?

let original = array
array.reserveCapacity(array.count * 2)
for x in original {
    array.append(x)
}

先看“错误但看起来没问题”的版本

for x in array {
    array.append(x)
}

Swift 在这里实际做了什么?

for x in array 等价于(概念上):

var iterator = array.makeIterator()
while let x = iterator.next() {
    array.append(x)
}

⚠️ 问题来了

  • iterator 持有的是 array 原始 buffer 的视图

  • append修改 array 本身

  • 一旦触发扩容或写入:

    • 破坏了 iterator 的一致性
    • Swift 为了安全,必须复制 buffer(COW)

结果:

  • 每次 append 都可能触发一次 完整数组拷贝
  • 复杂度直接从 O(n) → O(n²)

为什么 let original = array 是关键?

let original = array

这一步并没有立即拷贝数据。

Swift 的真实行为(Copy-on-Write)

  • originalarray
  • 共享同一个 buffer
  • 直到某一方发生“写操作”

这时:

array.reserveCapacity(...)

发生了什么?

  • reserveCapacity写操作
  • Swift 发现 buffer 有两个 owner
  • 👉 只复制一次
  • array 拿到独立 buffer
  • original 继续保持不变

此时关系是:

original  ---> old buffer (只读)
array     ---> new buffer (可写,容量已扩好)

再看 for 循环为什么安全了

for x in original {
    array.append(x)
}

现在:

  • 遍历的是 original(只读、不变)
  • 修改的是 array(独立 buffer)
  • iterator 不会被破坏
  • 没有重复 COW
  • append 是摊还 O(1)

💥 关键点

遍历和修改发生在 两个逻辑上不同的数组实例


reserveCapacity 为什么也很重要?

如果你写成:

let original = array
for x in original {
    array.append(x)
}

仍然是正确的(不会逻辑错误),但:

  • array 每次 append 可能扩容
  • 扩容会反复分配内存 + 拷贝

reserveCapacity 的作用是:

  • 一次性分配足够空间
  • 把 append 变成真正的 O(1)

用时间复杂度总结对比

写法是否安全时间复杂度COW 次数
遍历 + 修改同一个 arrayO(n²)多次
let original = arrayO(n)1 次
+ reserveCapacityO(n)1 次(且更快)

一句话记忆法(非常实用)

Swift 中:
永远不要在遍历一个集合的同时修改它。
要么 copy,要么 split 读写对象。