1. 从一次线上崩溃说起:为什么数组遍历值得单独拎出来讲
去年帮一个朋友排查他们App的线上崩溃,问题出在一个很不起眼的地方:一个商品列表页,用户快速滑动时偶发数组越界。代码逻辑看起来没问题,就是最普通的for i in 0..<array.count,但崩溃日志指向的索引却超出了范围。后来定位到原因——这个数组在遍历过程中被另一个线程修改了。这件事让我意识到,Swift里数组遍历这件事,远没有表面上看起来那么简单。
很多人写Swift代码,遍历数组就是顺手一个for in,或者需要索引的时候写个enumerated(),再复杂点用forEach。但真到了需要精细控制、需要性能优化、需要处理边界情况的时候,这几种方式之间的差异就会暴露出来。forEach和for in到底该用哪个?enumerated()返回的到底是什么?Iterator在底层是怎么工作的?indices又适合什么场景?这些问题如果只是停留在"能用就行"的层面,迟早会在某个深夜的崩溃日志里跟你算账。
这篇内容我打算把这四种遍历方式彻底拆开讲清楚。不是那种API文档式的罗列,而是从实际项目出发,讲清楚每种方式的底层机制、适用边界、性能特征,以及我在真实项目里踩过的坑。不管你是刚接触Swift的新手,还是写了几年iOS的老手,应该都能从中找到一些之前没注意到的细节。特别是如果你正在处理大数据量的列表渲染、或者在做性能敏感的算法模块,这些内容会直接影响你的代码质量和运行效率。
2. forEach的本质:它和for in到底差在哪
2.1 forEach的闭包语义与return的陷阱
先看一段代码,这是我在代码审查里见过最多的误用:
let numbers = [1, 2, 3, 4, 5] numbers.forEach { number in if number == 3 { return // 很多人以为这是跳出循环 } print(number) } // 输出:1 2 4 5很多人第一次写forEach的时候,会下意识地把return当成break来用,以为能提前结束遍历。实际上这里的return只是从闭包中返回,相当于continue,循环会继续执行下去。forEach的签名是func forEach(_ body: (Element) throws -> Void) rethrows,它接收一个闭包,对每个元素执行一次。闭包里的return只是结束当前这次闭包的调用,跟循环控制没有任何关系。
那如果想在forEach里实现break怎么办?答案是:做不到。forEach没有提供任何提前终止的机制。你只能用for in配合break,或者用first(where:)、contains(where:)这类高阶函数来间接实现。这是forEach最本质的限制,也是我在选型时最先考虑的一点。
2.2 for in的编译期优化与forEach的运行时开销
从性能角度看,for in和forEach在大多数情况下差异不大,但底层机制不同。for in会被编译器识别为标准的迭代模式,Swift编译器可以对它做循环展开、边界检查消除等优化。而forEach本质上是一次函数调用,闭包可能带来额外的调用开销,虽然现代编译器对闭包的内联优化已经做得很好,但在极端性能敏感的场景下,for in仍然更可控。
我做过一个简单的测试,对一个包含一百万个Int的数组求和:
var sum = 0 // 方式一:for in for num in numbers { sum += num } // 方式二:forEach var sum2 = 0 numbers.forEach { sum2 += $0 }在-O优化级别下,两者差距在5%以内,基本可以忽略。但在-Onone调试模式下,forEach的闭包调用开销会明显一些。所以我的建议是:日常业务代码用哪个看可读性,性能关键路径上优先用for in。
2.3 什么时候必须用forEach而不是for in
forEach也不是没有它的价值。有一种场景它比for in更合适:当你需要把遍历逻辑作为一个值传递的时候。比如:
func processItems(_ action: (Item) -> Void) { items.forEach(action) }这里forEach接收一个函数作为参数,代码更简洁。另外在链式调用中,forEach作为终结操作符,读起来更顺:
items .filter { $0.isActive } .map { $0.name } .forEach { print($0) }如果用for in就得先赋值给一个临时变量,再遍历,多了一步。所以我的经验是:链式调用的末端用forEach,需要流程控制(break、continue、return跳出整个函数)的时候用for in。
注意:
forEach闭包里的return不会终止遍历,只会跳过当前元素。如果你在闭包里写了一个看起来像提前退出的逻辑,一定要反复确认它是不是真的按你预期工作。
3. enumerated的索引真相:它返回的不是你想的那个元组
3.1 offset与index的区别,以及为什么这个区别很重要
enumerated()是日常开发里用得最多的带索引遍历方式,但它的返回值经常被误解。看这个例子:
let array = ["a", "b", "c"] for (index, value) in array.enumerated() { print("\(index): \(value)") } // 0: a // 1: b // 2: c看起来index就是数组下标,对吧?对于Array来说确实如此,因为Array的索引就是从0开始的连续整数。但enumerated()返回的元组第一个元素,官方名称叫offset,不是index。这个区别在Array上体现不出来,但换一个集合类型就露馅了:
let set: Set = ["a", "b", "c"] for (offset, value) in set.enumerated() { print("\(offset): \(value)") } // 0: a // 1: b // 2: cSet是无序的,它的Index类型并不是Int,但enumerated()仍然给你从0开始的连续整数。因为enumerated()的签名是func enumerated() -> EnumeratedSequence<Self>,它内部维护了一个从0开始的计数器,跟集合本身的索引体系完全无关。
这个认知很重要。我见过有人在ArraySlice上用enumerated(),然后拿offset去访问原数组,结果越界。因为ArraySlice的索引可能从中间某个值开始,但enumerated()的offset永远从0开始。
3.2 enumerated在ArraySlice上的坑
ArraySlice是数组切片,它和原数组共享内存,但索引范围可能不是从0开始:
let array = [10, 20, 30, 40, 50] let slice = array[2...4] // [30, 40, 50] for (offset, value) in slice.enumerated() { print("offset: \(offset), value: \(value)") } // offset: 0, value: 30 // offset: 1, value: 40 // offset: 2, value: 50 // 但如果你用offset去访问slice: // slice[0] 会崩溃!因为slice的startIndex是2正确的做法是用slice.indices配合zip,或者直接用for index in slice.indices。这个坑我在处理分页数据的时候踩过一次,当时用enumerated()拿到的offset去操作切片,调试了半天才发现索引对不上。
3.3 需要真实索引时的正确姿势
如果你确实需要集合的真实索引,而不是从0开始的偏移量,有几种方案:
// 方案一:用indices for index in array.indices { let value = array[index] // 使用index和value } // 方案二:用zip配合indices for (index, value) in zip(array.indices, array) { // index是真实索引 } // 方案三:如果只是Array,enumerated就够了 for (index, value) in array.enumerated() { // Array的index就是offset,没问题 }方案二是我个人比较推荐的,因为它同时保留了索引和值的语义,而且对任何集合类型都适用。zip会把两个序列配对,长度以短的为准,这里两个序列长度相同,所以完全安全。
提示:
enumerated()的offset永远从0开始递增,跟集合自身的索引体系无关。在Array上两者恰好一致,但在ArraySlice、Set、Dictionary上就不一定了。需要真实索引时,用indices或zip。
4. Iterator底层机制:for in背后到底发生了什么
4.1 IteratorProtocol与Sequence的关系
Swift的for in循环看起来是语言层面的语法,但它背后依赖的是一套协议体系。理解这套体系,能帮你写出更灵活的遍历代码。
核心是两个协议:Sequence和IteratorProtocol。Sequence表示"可以被遍历的序列",它要求实现一个makeIterator()方法,返回一个迭代器。IteratorProtocol则定义了迭代器的行为,核心方法是mutating func next() -> Element?,每次调用返回下一个元素,返回nil表示遍历结束。
for in循环会被编译器展开成类似这样的逻辑:
var iterator = array.makeIterator() while let element = iterator.next() { // 循环体 }Array的迭代器内部维护了一个当前索引,每次next()返回当前元素并把索引加一,直到索引等于count时返回nil。这个过程是惰性的,每次只取一个元素,不会一次性把所有元素加载到内存里。
4.2 自定义Iterator的实战场景
理解了这个机制,你就可以为自己的类型实现遍历能力。假设你有一个分页加载的数据源,想让它支持for in:
struct PagedDataSource<Element> { let pages: [[Element]] } extension PagedDataSource: Sequence { func makeIterator() -> AnyIterator<Element> { var pageIndex = 0 var itemIndex = 0 return AnyIterator { while pageIndex < self.pages.count { let page = self.pages[pageIndex] if itemIndex < page.count { let item = page[itemIndex] itemIndex += 1 return item } else { pageIndex += 1 itemIndex = 0 } } return nil } } }这样你就可以直接for item in pagedDataSource了,迭代器会自动处理跨页的逻辑。这种模式在处理流式数据、懒加载列表的时候特别有用。
4.3 迭代器失效与集合修改的经典崩溃
回到开头提到的那个崩溃案例。Array的迭代器在遍历过程中,如果数组被修改(增删元素),迭代器持有的索引就可能失效,导致越界或数据错乱。Swift的Array是值类型,但如果你在遍历时通过某个引用间接修改了它,或者在多线程环境下另一个线程修改了它,问题就会出现。
var array = [1, 2, 3, 4, 5] for element in array { if element == 3 { array.removeAll() // 危险操作 } }这段代码在Swift里不会崩溃,因为for in遍历的是数组的一个副本(值语义),但你的逻辑可能不符合预期——你遍历的是修改前的快照。如果换成引用类型或者多线程场景,就可能直接崩溃。
我的经验是:遍历过程中绝对不要修改正在遍历的集合。如果确实需要修改,先收集要操作的元素,遍历结束后再统一处理:
var toRemove: [Int] = [] for element in array { if shouldRemove(element) { toRemove.append(element) } } array.removeAll { toRemove.contains($0) }注意:
for in遍历的是集合在遍历开始时的状态。值类型集合在遍历时修改不会影响当前遍历,但引用类型集合或多线程场景下修改会导致未定义行为。永远不要在遍历中修改集合。
5. indices的适用边界:什么时候它比enumerated更合适
5.1 indices返回的是什么类型
indices是Collection协议的一个属性,返回集合所有有效索引的集合。对于Array,它返回Range<Int>;对于String,它返回一个String.Indices类型;对于Dictionary,它返回Dictionary.Indices。
let array = [10, 20, 30] for index in array.indices { print(array[index]) }indices最大的价值在于:它返回的是集合的真实索引,而不是从0开始的偏移量。这在处理ArraySlice、String这类索引不规则的集合时特别重要。
5.2 用indices安全地做原地修改
indices最常见的实战场景是原地修改数组元素:
var scores = [60, 75, 88, 92, 45] for index in scores.indices { if scores[index] < 60 { scores[index] = 60 } } // scores: [60, 75, 88, 92, 60]这里用indices而不是enumerated(),因为我们需要通过索引写回数组。如果用enumerated(),拿到的offset在Array上虽然等于索引,但语义上不够清晰,而且一旦换成其他集合类型就会出问题。
另一个场景是同时访问相邻元素:
let values = [1, 3, 2, 5, 4] for i in values.indices.dropLast() { if values[i] > values[i + 1] { print("下降点:\(i)") } }dropLast()确保i + 1不会越界。这种相邻元素比较的算法,用indices是最自然的表达方式。
5.3 indices与enumerated的性能对比
从性能角度看,indices遍历和enumerated()遍历在Array上差异很小,因为Array的索引就是连续整数,两者生成的机器码很接近。但在String上差异就大了:
let str = "Hello, Swift" // 方式一:indices for index in str.indices { print(str[index]) } // 方式二:enumerated for (offset, char) in str.enumerated() { print("\(offset): \(char)") }String的索引不是整数,str.indices返回的是String.Index的集合,遍历时需要计算字符边界。而enumerated()内部也是基于索引遍历的,两者性能相当。但如果你需要的是字符的位置信息,indices给出的String.Index可以直接用于字符串操作,而enumerated()的offset只是一个计数器,不能直接用于字符串索引。
我在处理富文本解析的时候,就吃过这个亏。当时用enumerated()拿到offset,想用它去截取字符串,结果发现String根本不接受Int作为下标。后来改用indices,代码才正常。
| 遍历方式 | 返回类型 | 适用场景 | 注意事项 |
|---|---|---|---|
for in | 元素本身 | 通用遍历,需要break/continue | 遍历中不可修改集合 |
forEach | 元素本身 | 链式调用末端,函数式风格 | 无法提前终止,return等于continue |
enumerated | (offset, element) | 需要计数器的场景 | offset不是真实索引,ArraySlice上会错位 |
indices | 真实索引 | 需要索引读写、相邻元素操作 | 索引类型因集合而异,String上不是Int |
6. 四种方式在真实项目中的选型决策树
6.1 按需求场景快速匹配
经过前面几轮拆解,选型逻辑其实已经很清晰了。我把它整理成一个决策流程,你在写代码的时候可以快速对照:
第一问:遍历过程中需要提前退出吗?
- 需要
break或return跳出整个函数 → 用for in - 不需要,就是要全部走一遍 → 进入第二问
第二问:需要索引吗?
- 不需要索引,只要元素 → 用
for in或forEach(看是否在链式调用中) - 需要索引 → 进入第三问
第三问:需要的是计数器还是真实索引?
- 只是想知道"第几个",不用于访问集合 → 用
enumerated() - 需要用索引访问或修改集合元素 → 用
indices
第四问:集合是Array还是其他类型?
- Array →
enumerated()和indices都可以,看语义清晰度 - ArraySlice、String、Dictionary → 优先用
indices,避免enumerated()的offset陷阱
6.2 一个综合案例:列表 diff 计算
假设你在做一个列表刷新功能,需要对比新旧数据,找出新增、删除、移动的项。这个场景下四种遍历方式可能都会用到:
struct Item: Equatable { let id: Int let name: String } func diff(old: [Item], new: [Item]) -> (inserted: [Int], deleted: [Int], moved: [(Int, Int)]) { var inserted: [Int] = [] var deleted: [Int] = [] var moved: [(Int, Int)] = [] // 用indices遍历旧数组,记录每个id的位置 var oldIndexMap: [Int: Int] = [:] for index in old.indices { oldIndexMap[old[index].id] = index } // 用enumerated遍历新数组,对比位置变化 var newIndexMap: [Int: Int] = [:] for (offset, item) in new.enumerated() { newIndexMap[item.id] = offset if let oldIndex = oldIndexMap[item.id] { if oldIndex != offset { moved.append((oldIndex, offset)) } } else { inserted.append(offset) } } // 用forEach找出被删除的项 old.forEach { item in if newIndexMap[item.id] == nil { deleted.append(oldIndexMap[item.id]!) } } return (inserted, deleted, moved) }这个例子里,indices用于建立索引映射,enumerated用于同时获取位置和元素,forEach用于简单的过滤收集。每种方式都用在了它最合适的地方。
6.3 性能敏感场景的实测数据
我在一个数据处理模块里做过对比测试,数组包含10万个结构体,分别用四种方式遍历并做简单计算,在iPhone真机上跑的结果大致如下:
| 遍历方式 | 耗时(相对值) | 内存峰值 |
|---|---|---|
for in | 1.0x | 最低 |
forEach | 1.05x | 略高 |
enumerated | 1.1x | 略高 |
indices | 1.0x | 最低 |
差距不大,但在循环体逻辑复杂、循环次数极多的场景下,for in和indices的优势会累积。enumerated因为要构造元组,会有轻微额外开销。forEach的闭包捕获上下文时可能产生堆分配,这是它内存峰值略高的原因。
我的建议是:日常业务代码不用纠结这点差异,选可读性最好的。但在算法核心、大数据量处理、实时渲染这些场景,优先用for in或indices。
7. 那些年我踩过的遍历坑与排查思路
7.1 在forEach里写break的编译错误
刚写Swift的时候,我在forEach闭包里写break,编译器直接报错'break' is only allowed inside a loop。当时很困惑,明明forEach看起来就是个循环。后来才明白,forEach接收的是闭包,闭包不是循环结构,break和continue都不适用。这个错误其实是个好事,它强制你思考:我到底需不需要提前退出?如果需要,就该换for in。
7.2 enumerated在字典上的顺序问题
有一次我用enumerated()遍历字典,想给每个键值对编号,结果发现每次运行的编号顺序都不一样。这是因为字典是无序集合,enumerated()的offset只是遍历顺序的计数器,而遍历顺序本身是不确定的。如果你需要稳定的编号,必须先把字典转成有序结构:
let dict = ["a": 1, "b": 2, "c": 3] let sortedKeys = dict.keys.sorted() for (offset, key) in sortedKeys.enumerated() { print("\(offset): \(key) = \(dict[key]!)") }7.3 迭代器在嵌套遍历中的陷阱
嵌套遍历时,如果内外层用同一个迭代器变量,会出现意想不到的结果。虽然for in每次都会创建新的迭代器,但如果你手动管理迭代器,就要特别小心:
var iterator = array.makeIterator() while let outer = iterator.next() { // 这里如果再用同一个iterator,内层循环会消耗掉外层剩余的元素 while let inner = iterator.next() { // ... } }这种写法会导致外层循环只执行一次,因为内层循环把迭代器耗尽了。正确的做法是每次内层遍历都创建新的迭代器,或者用嵌套的for in。
7.4 排查数组越界的通用思路
回到开头的崩溃案例,我总结了一套排查数组越界的流程:
- 确认崩溃点的索引值:从崩溃日志里找到越界的索引和数组的count
- 检查索引来源:是
enumerated的offset、indices的索引,还是手动计算的 - 确认集合类型:是Array、ArraySlice还是其他,索引体系是否一致
- 检查遍历中是否有修改:是否有remove、append等操作
- 检查多线程访问:是否有其他线程同时读写这个数组
大多数越界问题都能通过这五步定位。最常见的原因是第三条和第四条——索引体系混用和遍历中修改集合。
提示:如果你的数组可能在多线程环境下被访问,考虑用串行队列保护,或者改用值语义的不可变数组。Swift的Array是值类型,但多线程同时读写同一个变量仍然是不安全的。
8. 写遍历代码时我会遵守的几条个人原则
写了这么多年Swift,遍历数组这件事我已经形成了一些固定的习惯,分享出来供参考。
第一条,能用for in就不用forEach。除非是在链式调用的末端,否则for in的可读性和可控性都更好。forEach的闭包会引入一层额外的作用域,调试时断点也不如for in直观。
第二条,需要索引时先问自己是不是真的需要。很多时候我们习惯性地用enumerated(),但其实循环体里根本没用那个offset。这种情况下直接用for in遍历元素更简洁。只有当索引确实参与逻辑运算或元素访问时,才引入索引。
第三条,ArraySlice和String上永远用indices。这两个类型的索引不是从0开始的连续整数,enumerated()的offset和真实索引是两回事。用indices能避免绝大多数索引错位的问题。
第四条,遍历中不修改集合。这是铁律。需要修改就分两步:先遍历收集,再统一修改。多写几行代码,换来的是一整类崩溃的消失。
第五条,性能关键路径上用for in或indices。forEach的闭包开销在极端场景下不可忽视,enumerated的元组构造也有成本。算法核心、大数据处理、实时渲染这些地方,选最朴素的遍历方式。
最后分享一个我最近才注意到的小细节:Array的indices返回的是Range<Int>,而Range本身也是Collection,所以你可以对indices做map、filter等操作。比如array.indices.filter { array[$0] > 10 }能直接得到所有大于10的元素的索引。这种写法在需要批量处理索引的时候特别顺手,比手动循环简洁得多。