学 Swift 的第4天,我给自己定的主线是集合类型。前三天已经摸过了变量、常量、条件语句、循环和函数,所以今天特意避开界面和动画,把所有精力砸在数组、字典、Set 上。如果你问我为什么这样安排,我的回答很简单:Swift 开发里很少有人只跟一个孤零零的变量打交道,数据基本都是一批一批出现的,你后面写列表页、做搜索、解析 JSON,干的都是“把一堆数据塞进去、取出来、改一改”的活儿。第4天把这块地基夯实,后面看 SwiftUI 的数据绑定都会轻松很多。这篇文章写给两类人:一类是刚把 Swift 语法基础过完、想找一条清晰进阶路线的初学者;另一类是已经写过一点 iOS 代码、但对集合类型细节不太清楚的人。我会把我当天实际操作的代码、踩过的坑、以及“为什么这么写”的理由都放出来,虽然不是教科书,但保证能直接照着练。1. 第4天的内容规划:为什么先死磕集合,而不是急着写界面1.1 第4天在 Swift 学习路线里的位置我习惯把前7天的学习路线分成三个阶段:第1天到第3天:语法地基。变量、类型、条件、循环、函数。目标不是背语法,而是能读懂简单代码。第4天到第5天:数据的组织和处理。数组、字典、Set、枚举、可选类型、闭包。第6天到第7天:开始接触 SwiftUI 或者 UIKit 的界面搭建,用真实的小界面把前面的数据串起来。第4天卡在“语法”和“界面”之间,是最容易被跳过的章节。很多人学了几天语法就急着拖控件,结果一遇到列表刷新、多选筛选这些场景就卡壳。原因不是逻辑不行,而是不会用集合把数据整理成界面需要的样子。所以我宁可把第4天完全留给集合,也不急着去看什么导航栏、按钮点击事件。从广度上看,数组、字典、Set 是 Swift 标准库里最常用的三种容器。我后面做搜索框的时候,发现不管输入什么,第一步几乎都是把候选数组过滤一遍;做标签选择的时候,又需要把选中的 id 放到 Set 里,再和全量 id 做差集。这些东西你在第4天不练熟,到了写功能的阶段就会一遍遍地翻旧文档。1.2 用“三个收纳箱”理解数组、字典、集合把集合类型想象成家里不同用途的收纳箱,Swift 里三种容器对应三种完全不同的生活方式:数组像带编号的抽屉。东西按 0、1、2 的顺序排好,你想找第几个就按编号取。它的特点是“有序、可重复”,适合列表、队列、历史记录。字典像贴了标签的盒子。每个盒子上有一张唯一的标签,你不需要知道东西排在第几,只需要说出标签就能找到内容。它的特点是“键唯一”,适合配置项、字典表、ID 到对象的映射。Set 像储物间里的一堆同款球。它们不排顺序,而且每个球都不允许有两份。你要的只是“在不在里面”这件事,比如检查数组里有没有重复项、两个集合重叠多少个。这三者之间最大的区别,一句话就能记住:数组管顺序,字典管映射,Set 管唯一性。初学的人最容易犯的错是用数组去实现“按名字找东西”,结果每次都要遍历一遍,又慢又啰嗦。第4天练的就是“拿到问题先判断该用哪个容器”。比如实现一个通讯录,按姓名查电话,第一选择是字典,而不是数组套数组;再比如聊天记录要按时间倒序排列,这才是数组的主场。2. 数组实操:创建、取值、遍历的避坑写法2.1 创建数组的几种方式,以及 let/var 带来的差异数组最常见的是直接字面量创建,这点基本谁都会:var numbers: [Int] = [1, 2, 3] let fruits = ["apple", "banana"]但实操起来有几个细节特别值得注意。第一个是let和var的区别。let创建的数组是常量,这意味着你不能append、不能删除元素、也不能用下标fruits[0] = "orange"修改元素。我第4天第一个报错就是因为写了个let list = ["a"],然后想往里面append("b"),编译器直接不给过。有人可能会说:“那我全用 var 不就行了?”实际上苹果的编程规范里更推荐优先用let,因为常量数组能防止后续代码无意间改动数据,出问题的时候更容易排查。你需要做的是在创建时就问自己:“这个数组之后还要不要改?”如果只是存一份固定数据,let就够了;如果后面要加要删,再换var。第二个细节是当你需要一批相同初始值的时候,可以用Array(repeating:count:):var zeroes = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0]这个用法在游戏开发里做棋盘、在统计里做计数表很常用。还有一种初始化是空数组,var items = [String]()。这里有个隐藏知识点:[String]()其实是调用类型Array<String>的初始化方法,后面加个()就是创建实例。如果第一次接触 Swift 的泛型,看到[String]()可能有点懵,其实理解成“一个装着字符串的空数组”就行。第三个细节是被很多人忽略的“值类型”属性。Swift 的数组是结构体,不是类。这意味着当你把一个数组赋值给另一个变量时,它复制的是值,而不是引用。比如:var a = [1, 2, 3] var b = a b.append(4) print(a) // [1, 2, 3] print(b) // [1, 2, 3, 4]a不会因为b的改变而受影响。表面上看是“复制”,其实 Swift 底层用了写时复制优化,只有真正修改b的时候才发生拷贝,所以性能上也不用太担心。这一点和NSArray、NSMutableArray那种引用类型有本质区别。初学阶段不理解没关系,但你得知道:以后写多线程或者把数组传来传去的时候,这个特性会帮你避免很多“别处改了这里数据也跟着变”的诡异问题。2.2 索引越界和安全取值,为什么 Swift 选择直接崩溃数组取值最简单的是下标,比如numbers[0]。但下标访问有一个很坑的地方:越界就是崩溃。let list = [10, 20, 30] let value = list[3] // fatal error: Index out of range我第一次看到 “Index out of range” 的时候觉得很冤:只是取个值,怎么程序直接挂了?后来查资料才明白,这是 Swift 的一种设计取舍:与其让程序带着错误数据继续跑,不如立刻崩溃,让你第一时间发现代码里有 bug。这跟某些语言返回nil或者抛异常的策略不一样。你可以觉得它“不温柔”,但实际项目里,越界往往意味着逻辑上有漏洞,早炸早修。所以真正要解决的是“怎么避免越界”。我第4天的做法是养成三个习惯:取值前用indices.contains(i)判断索引是否存在。想拿首个/最后一个元素时,用first和last属性,它们是可选值,数组为空时不会崩。想取开头几个或末尾几个元素,用prefix(_:)和suffix(_:),即使传的数量大于数组长度,它也只会返回能取到的部分。let list = [10, 20, 30] if list.indices.contains(2) { print(list[2]) // 30 } let head = list.prefix(2) // [10, 20],类型是 ArraySlice这里有个小坑:prefix的返回类型不是Array,而是ArraySlice。ArraySlice可以继续遍历、下标访问,但如果你想把它完整地赋给[Int]类型的变量,还得再做一次Array(head)。我一开始直接let head: [Int] = list.prefix(2)就报错了。不过顺手记一下:ArraySlice底层引用的是切片那一小段内存,开销小,比复制一份更轻量。2.3 遍历和修改:enumerated、removeAll、sort 与 sorted 的区别遍历数组最简单的办法是for item in list,但很多时候你还需要同时拿到下标和值。笨办法是for i in 0..<list.count,然后再用list[i],这样既啰嗦又可能踩越界的坑。Swift 给了更好的选择:let fruits = ["apple", "banana", "orange"] for (index, fruit) in fruits.enumerated() { print("\(index): \(fruit)") }enumerated()返回的是“下标 + 元素”的元组,拆开直接就能用。同样,如果需要一边遍历一边做条件修改,我会尽量用高阶函数,而不是自己去维护索引。比如想删除数组中所有能被 2 整除的数,最直接的是这样:var numbers = [1, 2, 3, 4, 5, 6] numbers.removeAll { $0 % 2 == 0 } // numbers 变成 [1, 3, 5]removeAll(where:)会在内部遍历,把满足条件的元素一次性删掉。这比我曾经用for循环边遍历边删除的方案稳多了。如果你曾经试过在遍历数组时直接remove(at:),多半会撞上索引错乱的问题,因为删除元素会改变后续元素的编号。排序也是数组的高频操作。有两个长得极像的方法:sort()和sorted()。区别只有一点:sort()直接修改原数组,而sorted()返回一个新数组,原数组一丝不动。这是个容易踩但又特别实用的区别:var scores = [88, 72, 95, 60] scores.sort() // scores 变成 [60, 72, 88, 95] let names = ["Tom", "Amy", "Bob"] let sortedNames = names.sorted() // names 还是原来的顺序平时写代码,如果原数组后面还要用原始顺序,就老老实实用sorted()。如果这段数据用完之后没人关心旧顺序,用sort()会更省内存。另外,字符串默认排序是按字母顺序,中文是按拼音顺序吗?不是。String 的<比较是按 Unicode 码点来的,所以中文排序可能会和你想的不太一样,第4天先不用深究,知道有这回事就行。3. 字典和 Set:键值对、唯一性、以及你没注意的细节3.1 字典的增删改查,Optional 那个大坑字典的创建和增删改查,一句话就能说清基础:var scores = ["Math": 90, "English": 85] scores["Science"] = 95 // 新增 scores["Math"] = 95 // 修改 scores["English"] = nil // 删除 let mathScore = scores["Math"] // Optional(95)这里最需要警惕的是:字典通过下标取出来的值,永远是可选类型。因为“键不存在”这件事是合法的,Swift 没法保证你每次取值都成功。所以像下面这种写法,就是最容易翻车的一行代码:let score = scores["Physics"]! // 如果 Physics 不存在,直接崩溃我第4天为了图方便写过一次强制解包,当时心里还想“我肯定存过这个 key”,结果就是因为某个分支漏了,debug 了半天。后来我的习惯是:能不确定就用if let解包,或者提供默认值scores["Physics", default: 0]。用default写起来很爽,而且语义清楚:查不到就给我一个兜底值。实际开发里,字典还有一个高频操作:更新某个 key 的值,并且想知道旧值是什么。此时用updateValue(_:forKey:)比直接用下标赋值更合适:let oldValue = scores.updateValue(100, forKey: "Math") // oldValue 是 Optional(95),如果是新增 key,它是 nil这个方法的好处是能同时拿到“旧状态”。比如记账软件里统计这个月工资和上个月工资的差额,旧值就有用了。小组节补充:字典是否保持插入顺序?不保证。Swift 的字典是无序的,它通过哈希表存储键值对,遍历顺序不一定是插入顺序,也不稳定。如果你非常需要“记住插入顺序”,可以考虑用数组存 key,再用字典存映射;或者直接用结构体包一层。第4天只要知道“顺序不稳定”就够了。3.2 字典的无序遍历,和“有序字典”的妥协方案如果你想让字典的 key 按某种规则排序输出,一个常见套路是:let dict = ["B": 2, "A": 1, "C": 3] let sortedKeys = dict.keys.sorted() for key in sortedKeys { print(key, dict[key]!) }这里我特意没写dict.sorted()。因为dict.sorted()返回的是[(key: String, value: Int)]数组,需要按元组里的属性排序。想保留字典的格式却又要排序,一般做法是先把 key 排好,再取 value。按 value 排序则用dict.sorted { $0.value > $1.value },你得到的是一个有序的元组数组,不再是字典。说到“有序字典”,很多时候是需求逼出来的。比如商家列表要按“上线时间早的排前面”,同时又要把每个 id 对应的名称存起来。我会直接用两个容器:一个数组存顺序,一个字典存映射。这样虽然麻烦点,但逻辑透明。或者用一个包含struct Item的数组,每个Item里同时有 key 和 value。第4天不要求你用这些方案,但是遇到“字典顺序不对”时别懵,项目里更常用的其实是“数组 + 模型”。3.3 Set 集合的核心操作:交集、并集、差集Set 创建时语法有点反直觉,它不能直接写成[1, 2, 3],因为那会被推断成数组。要显式声明类型或者接一个初始化:var tags: Set<String> = ["Swift", "iOS", "Swift"] print(tags) // 只有 ["Swift", "iOS"],重复项被去掉 let numbers: Set = [1, 2, 3]集合最迷人的能力是集合运算。第4天如果你只是把 Set 当“去重数组”用,那就浪费了它一半的价值。它天生擅长处理“分组比较”:let groupA: Set = ["Alice", "Bob", "Charlie"] let groupB: Set = ["Bob", "Charlie", "David"] let union = groupA.union(groupB) // 并集:所有人 let intersection = groupA.intersection(groupB) // 交集:两边都在的人 let onlyA = groupA.subtracting(groupB) // 差集:只在 A 的人 let symmetricDiff = groupA.symmetricDifference(groupB) // 对称差:不是两边都有的我当时看这个还有点懵,后来用“报名的两个兴趣班”来理解:并集是至少报了一个班的人,交集是两个班都报的人,差集是只报了第一个班的人。搞懂之后,很多业务逻辑就成了三行代码。举一个实战场景:你手上有一个全量用户 ID 列表和一个已勾选用户 ID 列表,想拿到未勾选的用户,直接allUserIds.subtracting(selectedUserIds)就行了。用数组实现的话,还得双层循环加 contains,复杂度直接上去一个等级。3.4 从底层聊聊哈希,Set 查找为什么快初学 Set 时常听人说“它查找快”,但到底快在哪?我给一个不完全严谨但很好理解的解释:Set 内部像一个分了很多格的仓库,每次要找一个元素,先通过哈希函数算出它应该在哪一格,直接跳过去看,而不是把整个仓库翻一遍。数组查找某个元素是否存在,最坏情况要挨个比一遍,数据一多就慢。这也解释了为什么 Set 的元素类型必须满足Hashable协议。Swift 里常见的基本类型,比如Int、String、Double、Bool都满足,所以你不用额外操作。但如果你自定义了一个struct,第4天先别急着放进 Set。后面学到Hashable怎么实现,再回来把它塞进 Set 会更顺手。哈希和唯一性是绑定的:只要两个元素哈希不相等,Set 就认为它们不同;如果相等,还会继续用==做二次确认。所以你往 Set 里插重复字符串,只会被忽略。我实测过,insert一个已存在的元素会返回(inserted: false, memberAfterInsert: 已存在元素),这个返回结果有时候能帮你判断“这到底是不是新插入的”,比干巴巴的contains好用。4. 控制流与函数的小进阶:撑起第4天代码骨架4.1 switch 的区间匹配、where 过滤和 fallthrough第4天我开始把数组、字典和之前学的流程控制揉在一起。先补了几个之前没细看的 switch 用法。Swift 的 switch 不像某些语言,它天然不落空:匹配到分支执行完就退出,不会自动往下一个 case 掉。想故意继续往下走,要显式写fallthrough。这个设计挺安全,防止你忘写 break 导致逻辑串台。一个很实用的写法是区间匹配:let score = 85 switch score { case 0..<60: print("不及格") case 60..<80: print("合格") case 80...100: print("优秀") default: break }0..<60是半开区间,包含 0 不含 60;80...100是闭区间,含 80 和 100。这种写法比一串if score >= 0 && score < 60优雅太多。如果你需要的判断不是纯区间,而是带条件的范围,可以用where:switch score { case let x where x % 2 == 0: print("偶数的分数:\(x)") default: print("奇数") }这里case let x会先绑定score到x,再用where过滤。我刚接触时觉得 switch 还能这么用有点神奇,后来才反应过来,它本质上就是一个模式匹配语法,能做的比 C 系语言的 switch 多很多。4.2 函数默认参数、inout 和元组返回值函数在第三天已经学过,我第4天主要补了三个实用细节。第一个是默认参数:func greet(name: String, punctuation: String = "!") -> String { return "Hello, \(name)\(punctuation)" } print(greet(name: "Alice")) // Hello, Alice! print(greet(name: "Bob", punctuation: "?"))默认参数能让我在不传参的情况下也保持调用简洁,同时又保留灵活性。第二个是inout参数。Swift 的函数参数默认是常量,你在函数内部改它们,编译器会直接报错。如果你真的想修改传入的变量,有两种选择:返回新值再赋值,或者用inout:func increase(_ number: inout Int) { number += 1 } var count = 10 increase(&count) print(count) // 11注意调用时要在参数前加&。这个写法在初学阶段用得不算多,但理解它背后的原因是必要的:Swift 默认传值安全,inout相当于“先把值送进去,算完再送出来”,是一种显式的拷贝修改。第三个是元组返回值。函数可以返回多个值,这在很多场景下比“输出参数”更方便:func findMaxAndMin(_ array: [Int]) -> (max: Int, min: Int)? { guard let first = array.first else { return nil } var max = first var min = first for value in array { if value > max { max = value } if value < min { min = value } } return (max, min) }返回一个可选元组后,调用方可以一次拿到两个值,而且不担心空数组崩溃。第4天写这种组合函数,对理解“函数出口”很有帮助:一个函数只做一件事,但可以返回一个结构化的结果。4.3 闭包初体验:把函数当成数据传来传去闭包是第4天我唯一允许自己“先会用、不完全懂原理”的东西。你可以把闭包理解成“没有名字的函数”,它可以被存到变量里,也可以作为参数传给另一个函数,还能在其他函数里返回。Swift 里最经典的闭包使用场景就是数组排序和过滤:let names = ["Tom", "Amy", "Bob"] let sorted = names.sorted { $0.count < $1.count }这行代码的意思是:按字符串长度升序排列。{ $0.count < $1.count }就是闭包,$0和$1分别是第一个和第二个比较元素。第一次看到这种语法我确实劝退过,因为太“魔法”。后来我把它展开成完整写法:let sortedLong = names.sorted(by: { (name1: String, name2: String) -> Bool in return name1.count < name2.count })这才看清楚结构。闭包的核心语法就是:{ (参数列表) -> 返回类型 in 语句 }。如果省略参数名和类型、用$0通配,就成了上面那行短写法。第4天练到这里,你会突然发现 map、filter、sorted 这些高阶函数都能读懂一点了。比如:let uppercased = names.map { $0.uppercased() } // ["TOM", "AMY", "BOB"] let longNames = names.filter { $0.count > 2 } // ["Tom", "Amy"]map把数组里的每一个元素按闭包规则映射成新元素,返回一个新数组;filter则是根据闭包的布尔结果保留或剔除元素。这两个函数正好能用第4天学的数组和闭包串起来,刷熟练度的时候特别快乐。5. 第4天小项目:做一个英文词频统计 Demo5.1 功能拆解和输出设计理论再多,不动手等于没学。我第4天下午给自己布置了一个小项目:输入一段英文文本,输出出现次数最多的前几个单词。这个项目难度刚好,能把数组、字典、Set、排序、闭包全用上,而且结果一眼能验证对不对。功能拆成四步:把字符串拆成单词数组。忽略大小写,统一转成小写,避免 “Hello” 和 “hello” 被当成两个词。用字典统计每个单词出现次数。对字典按次数排序,取前 N 个单词打印。输出格式我设计成:hello: 3 swift: 2 world: 1为什么要做这种小工具?因为第4天你还没到做完整 App 的能力,但你得能独立完成一个“输入数据,处理,输出结果”的闭环。这个闭环比花哨界面重要得多,后面所有列表页、搜索页、详情页,底层干的都是类似的事。5.2 核心代码实现与逐段解读下面是我当天跑通的完整代码,直接在 Playground 就能运行:import Foundation let text = """ Hello Swift! Hello world. Swift is great, swift is fast, Swift is powerful. """ // 1. 拆分:把标点都视为分隔符 let words = text .lowercased() .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } // 2. 用字典统计词频 var frequency: [String: Int] = [:] for word in words { frequency[word, default: 0] += 1 } // 3. 按词频降序排序,取前 5 个 let topWords = frequency.sorted { $0.value > $1.value }.prefix(5) // 4. 输出结果 for (word, count) in topWords { print("\(word): \(count)") }我逐段讲几个关键点。第一段,CharacterSet.alphanumerics.inverted的意思是“除了字母和数字以外的所有字符”。把它当成分隔符传给components(separatedBy:),文本里的空格、逗号、句号、感叹号全都会变成拆分点。如果你只用split(separator: " "),文本里的英文逗号和句号会残留在单词后面,统计结果就全是带标点的“脏词”。比如原来的fast,会带个逗号,和干净单词完全不一样。这不是小事,我用这个方法之前写过一版丑代码,光清洗标点就绕了一圈。第二段,frequency[word, default: 0] += 1是把字典的“查不到就返回默认值”能力用到了极致:如果单词不存在,默认值就是 0,加 1 之后变成 1;如果已经存在,就把旧次数取出来加 1 再存回去。这段看起来简单,但如果你用的是frequency[word] = frequency[word]! + 1,一旦碰到字典里没有的 key 就会崩溃。我当时反复提醒自己:字典取值返回 Optional,能带 default 就别强解包。第三段,frequency.sorted { $0.value > $1.value }把字典转成一个排序后的元组数组,然后.prefix(5)取前 5 个。这里需要注意,字典本身无序,但sorted之后你已经拿到一个有序数组,所以后面迭代输出是按次数从高到低来的。如果你写代码时想到“能不能不用 for 循环统计词频”,Swift 还提供了更函数式的写法:let frequency2 = words.reduce(into: [:]) { $0[$1, default: 0] += 1 }reduce(into:)的意思是:从一个空字典开始,把数组里的每一个单词依次“合并”进去,合并规则就是闭包里的那行。这个写法会更 Swift 一点,但我建议初学者先看懂 for 循环版本,再尝试简化为 reduce,不要为了炫技丢掉可读性。5.3 边界情况与结果验证跑完正常测试后,我故意喂了几种特殊情况给程序,看看它会不会崩:空字符串:words会是空数组,frequency是空字典,排序后prefix(5)是空数组,循环体不执行,安静结束。只有标点,没有字母:lowercased()后按字符集分割,得到一堆空字符串,再被filter { !$0.isEmpty }筛掉,结果依然安静。文本里有大写、小写混合:统一lowercased()后,Hello和hello被合并成一个单词。这点很关键,因为我第一次写的时候没小写,统计结果里同时出现Hello和hello,我还以为逻辑出 bug 了。后来才意识到是大小写没归一化。单词间有多个连续空格:components(separatedBy:)会把连续空格拆成多个空字符串元素。如果不加filter,字典里就会混入空字符串,导致后面输出一行空 key。这个坑几乎每个人都踩过一次。验证结果的时候,我建议用一段你自己能数清楚的短文本,比如上面那串文本,手算一遍“hello: 3, swift: 2, is: 2, world: 1, fast: 1”,再对程序输出。如果匹配,说明这条链路没问题。第4天做这种小项目,最大的意义不是“做出来”,而是“验证明白”,最后你会有一种“这些东西确实都能为我所用”的感觉。6. 我第4天踩过的坑,和一份排查速查表6.1 高频报错与解决方案第4天集中写集合代码,报错率明显比前三天高。我把最常见的几类整理成一张速查表,留着以后报错的时候翻:场景报错或异常现象正确处理数组越界取值Index out of range崩溃取值前用indices.contains(_:),或改用first/last字典 key 不存在强制解包时崩溃用if let或dict[key, default: 0]数组边遍历边删除索引错乱、元素删漏用removeAll(where:),或先记下要删的索引再统一删字符串拆分后有空串统计结果里出现空单词分割后加filter { !$0.isEmpty }字典遍历顺序不稳定输出顺序和预期不一致用sorted { $0.key < $1.key },或先排序 key 再遍历let数组想增删编译报错 “Cannot use mutating member”改成var,或重新思考是否真的需要改动除了这张表,我自己还有一个很耗时间的调试经历:在 Playground 里统计词频,打印出来发现多了很多空 key。我查了半天才发现是标点分隔符的问题,components(separatedBy:)比单纯split(separator:)多产生了很多“空片段”,必须过滤。这类问题在正常项目里也很常见,以后解析 CSV、日志、命令行参数都会遇到,所以第4天就建立“拆分后必过滤空串”的肌肉记忆,能省很多时间。6.2 给同样学第4天的人的建议如果你今天刚开始学集合类型,我最后分享几个切身体会的建议。不要急着把标准库里所有集合方法都背下来。第4天的目标是用得熟练,不是背得全面。数组的map、filter、sorted,字典的默认值取值,Set 的intersection和subtracting,这几个优先级最高。其他的方法,等用到再去文档里查,效果反而更好。用 Playground 而不是新建一个完整 App 工程去练习。第4天还在折腾数据,App 工程多出来的生命周期文件只会干扰你。直接在 Playground 里写,右侧能实时看结果,变量状态一目了然。我后来发现,调试集合逻辑最快的方式就是在 Playground 里把中间过程逐步打印出来,这里多一点那里多一点,很快就能定位。学会主动画数据流向。遇到稍微复杂一点的数组字典操作,我会在纸上或者注释里写“输入是什么 -> 第一步变成什么 -> 第二步变成什么”。如果你不习惯画,至少可以在每个步骤后面print一下中间结果。第4天你写不出复杂算法不是问题,但你必须对“一条数据是怎么一步步改变的”有清晰感知。最后,给自己留一道变体题。我当天写完词频统计,又试着改成“用 Set 检查这篇文章里有没有重复单词”,顺手验证了words.count != Set(words).count这个判断。这种小延伸特别值,等于把今天学的东西又拧了一圈。写这篇文章的时候,我已经在第5天了,回看第4天的学习过程,最大的收获不是记住了多少个 API,而是形成了“先选容器、再做操作、再验证结果”的思考习惯。你在第4天完全可以不用管界面、不用管框架,只需要专心把一堆数据玩弄于股掌之中。等哪天你突然发现,自己拿到需求第一反应是“这适合用字典还是 Set”,那一刻你就知道自己已经上道了。
学 Swift 的第4天,我给自己定的主线是集合类型。前三天已经摸过了变量、常量、条件语句、循环和函数,所以今天特意避开界面和动画,把所有精力砸在数组、字典、Set 上。如果你问我为什么这样安排,我的回答很简单:Swift 开发里很少有人只跟一个孤零零的变量打交道,数据基本都是一批一批出现的,你后面写列表页、做搜索、解析 JSON,干的都是“把一堆数据塞进去、取出来、改一改”的活儿。第4天把这块地基夯实,后面看 SwiftUI 的数据绑定都会轻松很多。这篇文章写给两类人:一类是刚把 Swift 语法基础过完、想找一条清晰进阶路线的初学者;另一类是已经写过一点 iOS 代码、但对集合类型细节不太清楚的人。我会把我当天实际操作的代码、踩过的坑、以及“为什么这么写”的理由都放出来,虽然不是教科书,但保证能直接照着练。1. 第4天的内容规划:为什么先死磕集合,而不是急着写界面1.1 第4天在 Swift 学习路线里的位置我习惯把前7天的学习路线分成三个阶段:第1天到第3天:语法地基。变量、类型、条件、循环、函数。目标不是背语法,而是能读懂简单代码。第4天到第5天:数据的组织和处理。数组、字典、Set、枚举、可选类型、闭包。第6天到第7天:开始接触 SwiftUI 或者 UIKit 的界面搭建,用真实的小界面把前面的数据串起来。第4天卡在“语法”和“界面”之间,是最容易被跳过的章节。很多人学了几天语法就急着拖控件,结果一遇到列表刷新、多选筛选这些场景就卡壳。原因不是逻辑不行,而是不会用集合把数据整理成界面需要的样子。所以我宁可把第4天完全留给集合,也不急着去看什么导航栏、按钮点击事件。从广度上看,数组、字典、Set 是 Swift 标准库里最常用的三种容器。我后面做搜索框的时候,发现不管输入什么,第一步几乎都是把候选数组过滤一遍;做标签选择的时候,又需要把选中的 id 放到 Set 里,再和全量 id 做差集。这些东西你在第4天不练熟,到了写功能的阶段就会一遍遍地翻旧文档。1.2 用“三个收纳箱”理解数组、字典、集合把集合类型想象成家里不同用途的收纳箱,Swift 里三种容器对应三种完全不同的生活方式:数组像带编号的抽屉。东西按 0、1、2 的顺序排好,你想找第几个就按编号取。它的特点是“有序、可重复”,适合列表、队列、历史记录。字典像贴了标签的盒子。每个盒子上有一张唯一的标签,你不需要知道东西排在第几,只需要说出标签就能找到内容。它的特点是“键唯一”,适合配置项、字典表、ID 到对象的映射。Set 像储物间里的一堆同款球。它们不排顺序,而且每个球都不允许有两份。你要的只是“在不在里面”这件事,比如检查数组里有没有重复项、两个集合重叠多少个。这三者之间最大的区别,一句话就能记住:数组管顺序,字典管映射,Set 管唯一性。初学的人最容易犯的错是用数组去实现“按名字找东西”,结果每次都要遍历一遍,又慢又啰嗦。第4天练的就是“拿到问题先判断该用哪个容器”。比如实现一个通讯录,按姓名查电话,第一选择是字典,而不是数组套数组;再比如聊天记录要按时间倒序排列,这才是数组的主场。2. 数组实操:创建、取值、遍历的避坑写法2.1 创建数组的几种方式,以及 let/var 带来的差异数组最常见的是直接字面量创建,这点基本谁都会:var numbers: [Int] = [1, 2, 3] let fruits = ["apple", "banana"]但实操起来有几个细节特别值得注意。第一个是let和var的区别。let创建的数组是常量,这意味着你不能append、不能删除元素、也不能用下标fruits[0] = "orange"修改元素。我第4天第一个报错就是因为写了个let list = ["a"],然后想往里面append("b"),编译器直接不给过。有人可能会说:“那我全用 var 不就行了?”实际上苹果的编程规范里更推荐优先用let,因为常量数组能防止后续代码无意间改动数据,出问题的时候更容易排查。你需要做的是在创建时就问自己:“这个数组之后还要不要改?”如果只是存一份固定数据,let就够了;如果后面要加要删,再换var。第二个细节是当你需要一批相同初始值的时候,可以用Array(repeating:count:):var zeroes = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0]这个用法在游戏开发里做棋盘、在统计里做计数表很常用。还有一种初始化是空数组,var items = [String]()。这里有个隐藏知识点:[String]()其实是调用类型Array<String>的初始化方法,后面加个()就是创建实例。如果第一次接触 Swift 的泛型,看到[String]()可能有点懵,其实理解成“一个装着字符串的空数组”就行。第三个细节是被很多人忽略的“值类型”属性。Swift 的数组是结构体,不是类。这意味着当你把一个数组赋值给另一个变量时,它复制的是值,而不是引用。比如:var a = [1, 2, 3] var b = a b.append(4) print(a) // [1, 2, 3] print(b) // [1, 2, 3, 4]a不会因为b的改变而受影响。表面上看是“复制”,其实 Swift 底层用了写时复制优化,只有真正修改b的时候才发生拷贝,所以性能上也不用太担心。这一点和NSArray、NSMutableArray那种引用类型有本质区别。初学阶段不理解没关系,但你得知道:以后写多线程或者把数组传来传去的时候,这个特性会帮你避免很多“别处改了这里数据也跟着变”的诡异问题。2.2 索引越界和安全取值,为什么 Swift 选择直接崩溃数组取值最简单的是下标,比如numbers[0]。但下标访问有一个很坑的地方:越界就是崩溃。let list = [10, 20, 30] let value = list[3] // fatal error: Index out of range我第一次看到 “Index out of range” 的时候觉得很冤:只是取个值,怎么程序直接挂了?后来查资料才明白,这是 Swift 的一种设计取舍:与其让程序带着错误数据继续跑,不如立刻崩溃,让你第一时间发现代码里有 bug。这跟某些语言返回nil或者抛异常的策略不一样。你可以觉得它“不温柔”,但实际项目里,越界往往意味着逻辑上有漏洞,早炸早修。所以真正要解决的是“怎么避免越界”。我第4天的做法是养成三个习惯:取值前用indices.contains(i)判断索引是否存在。想拿首个/最后一个元素时,用first和last属性,它们是可选值,数组为空时不会崩。想取开头几个或末尾几个元素,用prefix(_:)和suffix(_:),即使传的数量大于数组长度,它也只会返回能取到的部分。let list = [10, 20, 30] if list.indices.contains(2) { print(list[2]) // 30 } let head = list.prefix(2) // [10, 20],类型是 ArraySlice这里有个小坑:prefix的返回类型不是Array,而是ArraySlice。ArraySlice可以继续遍历、下标访问,但如果你想把它完整地赋给[Int]类型的变量,还得再做一次Array(head)。我一开始直接let head: [Int] = list.prefix(2)就报错了。不过顺手记一下:ArraySlice底层引用的是切片那一小段内存,开销小,比复制一份更轻量。2.3 遍历和修改:enumerated、removeAll、sort 与 sorted 的区别遍历数组最简单的办法是for item in list,但很多时候你还需要同时拿到下标和值。笨办法是for i in 0..<list.count,然后再用list[i],这样既啰嗦又可能踩越界的坑。Swift 给了更好的选择:let fruits = ["apple", "banana", "orange"] for (index, fruit) in fruits.enumerated() { print("\(index): \(fruit)") }enumerated()返回的是“下标 + 元素”的元组,拆开直接就能用。同样,如果需要一边遍历一边做条件修改,我会尽量用高阶函数,而不是自己去维护索引。比如想删除数组中所有能被 2 整除的数,最直接的是这样:var numbers = [1, 2, 3, 4, 5, 6] numbers.removeAll { $0 % 2 == 0 } // numbers 变成 [1, 3, 5]removeAll(where:)会在内部遍历,把满足条件的元素一次性删掉。这比我曾经用for循环边遍历边删除的方案稳多了。如果你曾经试过在遍历数组时直接remove(at:),多半会撞上索引错乱的问题,因为删除元素会改变后续元素的编号。排序也是数组的高频操作。有两个长得极像的方法:sort()和sorted()。区别只有一点:sort()直接修改原数组,而sorted()返回一个新数组,原数组一丝不动。这是个容易踩但又特别实用的区别:var scores = [88, 72, 95, 60] scores.sort() // scores 变成 [60, 72, 88, 95] let names = ["Tom", "Amy", "Bob"] let sortedNames = names.sorted() // names 还是原来的顺序平时写代码,如果原数组后面还要用原始顺序,就老老实实用sorted()。如果这段数据用完之后没人关心旧顺序,用sort()会更省内存。另外,字符串默认排序是按字母顺序,中文是按拼音顺序吗?不是。String 的<比较是按 Unicode 码点来的,所以中文排序可能会和你想的不太一样,第4天先不用深究,知道有这回事就行。3. 字典和 Set:键值对、唯一性、以及你没注意的细节3.1 字典的增删改查,Optional 那个大坑字典的创建和增删改查,一句话就能说清基础:var scores = ["Math": 90, "English": 85] scores["Science"] = 95 // 新增 scores["Math"] = 95 // 修改 scores["English"] = nil // 删除 let mathScore = scores["Math"] // Optional(95)这里最需要警惕的是:字典通过下标取出来的值,永远是可选类型。因为“键不存在”这件事是合法的,Swift 没法保证你每次取值都成功。所以像下面这种写法,就是最容易翻车的一行代码:let score = scores["Physics"]! // 如果 Physics 不存在,直接崩溃我第4天为了图方便写过一次强制解包,当时心里还想“我肯定存过这个 key”,结果就是因为某个分支漏了,debug 了半天。后来我的习惯是:能不确定就用if let解包,或者提供默认值scores["Physics", default: 0]。用default写起来很爽,而且语义清楚:查不到就给我一个兜底值。实际开发里,字典还有一个高频操作:更新某个 key 的值,并且想知道旧值是什么。此时用updateValue(_:forKey:)比直接用下标赋值更合适:let oldValue = scores.updateValue(100, forKey: "Math") // oldValue 是 Optional(95),如果是新增 key,它是 nil这个方法的好处是能同时拿到“旧状态”。比如记账软件里统计这个月工资和上个月工资的差额,旧值就有用了。小组节补充:字典是否保持插入顺序?不保证。Swift 的字典是无序的,它通过哈希表存储键值对,遍历顺序不一定是插入顺序,也不稳定。如果你非常需要“记住插入顺序”,可以考虑用数组存 key,再用字典存映射;或者直接用结构体包一层。第4天只要知道“顺序不稳定”就够了。3.2 字典的无序遍历,和“有序字典”的妥协方案如果你想让字典的 key 按某种规则排序输出,一个常见套路是:let dict = ["B": 2, "A": 1, "C": 3] let sortedKeys = dict.keys.sorted() for key in sortedKeys { print(key, dict[key]!) }这里我特意没写dict.sorted()。因为dict.sorted()返回的是[(key: String, value: Int)]数组,需要按元组里的属性排序。想保留字典的格式却又要排序,一般做法是先把 key 排好,再取 value。按 value 排序则用dict.sorted { $0.value > $1.value },你得到的是一个有序的元组数组,不再是字典。说到“有序字典”,很多时候是需求逼出来的。比如商家列表要按“上线时间早的排前面”,同时又要把每个 id 对应的名称存起来。我会直接用两个容器:一个数组存顺序,一个字典存映射。这样虽然麻烦点,但逻辑透明。或者用一个包含struct Item的数组,每个Item里同时有 key 和 value。第4天不要求你用这些方案,但是遇到“字典顺序不对”时别懵,项目里更常用的其实是“数组 + 模型”。3.3 Set 集合的核心操作:交集、并集、差集Set 创建时语法有点反直觉,它不能直接写成[1, 2, 3],因为那会被推断成数组。要显式声明类型或者接一个初始化:var tags: Set<String> = ["Swift", "iOS", "Swift"] print(tags) // 只有 ["Swift", "iOS"],重复项被去掉 let numbers: Set = [1, 2, 3]集合最迷人的能力是集合运算。第4天如果你只是把 Set 当“去重数组”用,那就浪费了它一半的价值。它天生擅长处理“分组比较”:let groupA: Set = ["Alice", "Bob", "Charlie"] let groupB: Set = ["Bob", "Charlie", "David"] let union = groupA.union(groupB) // 并集:所有人 let intersection = groupA.intersection(groupB) // 交集:两边都在的人 let onlyA = groupA.subtracting(groupB) // 差集:只在 A 的人 let symmetricDiff = groupA.symmetricDifference(groupB) // 对称差:不是两边都有的我当时看这个还有点懵,后来用“报名的两个兴趣班”来理解:并集是至少报了一个班的人,交集是两个班都报的人,差集是只报了第一个班的人。搞懂之后,很多业务逻辑就成了三行代码。举一个实战场景:你手上有一个全量用户 ID 列表和一个已勾选用户 ID 列表,想拿到未勾选的用户,直接allUserIds.subtracting(selectedUserIds)就行了。用数组实现的话,还得双层循环加 contains,复杂度直接上去一个等级。3.4 从底层聊聊哈希,Set 查找为什么快初学 Set 时常听人说“它查找快”,但到底快在哪?我给一个不完全严谨但很好理解的解释:Set 内部像一个分了很多格的仓库,每次要找一个元素,先通过哈希函数算出它应该在哪一格,直接跳过去看,而不是把整个仓库翻一遍。数组查找某个元素是否存在,最坏情况要挨个比一遍,数据一多就慢。这也解释了为什么 Set 的元素类型必须满足Hashable协议。Swift 里常见的基本类型,比如Int、String、Double、Bool都满足,所以你不用额外操作。但如果你自定义了一个struct,第4天先别急着放进 Set。后面学到Hashable怎么实现,再回来把它塞进 Set 会更顺手。哈希和唯一性是绑定的:只要两个元素哈希不相等,Set 就认为它们不同;如果相等,还会继续用==做二次确认。所以你往 Set 里插重复字符串,只会被忽略。我实测过,insert一个已存在的元素会返回(inserted: false, memberAfterInsert: 已存在元素),这个返回结果有时候能帮你判断“这到底是不是新插入的”,比干巴巴的contains好用。4. 控制流与函数的小进阶:撑起第4天代码骨架4.1 switch 的区间匹配、where 过滤和 fallthrough第4天我开始把数组、字典和之前学的流程控制揉在一起。先补了几个之前没细看的 switch 用法。Swift 的 switch 不像某些语言,它天然不落空:匹配到分支执行完就退出,不会自动往下一个 case 掉。想故意继续往下走,要显式写fallthrough。这个设计挺安全,防止你忘写 break 导致逻辑串台。一个很实用的写法是区间匹配:let score = 85 switch score { case 0..<60: print("不及格") case 60..<80: print("合格") case 80...100: print("优秀") default: break }0..<60是半开区间,包含 0 不含 60;80...100是闭区间,含 80 和 100。这种写法比一串if score >= 0 && score < 60优雅太多。如果你需要的判断不是纯区间,而是带条件的范围,可以用where:switch score { case let x where x % 2 == 0: print("偶数的分数:\(x)") default: print("奇数") }这里case let x会先绑定score到x,再用where过滤。我刚接触时觉得 switch 还能这么用有点神奇,后来才反应过来,它本质上就是一个模式匹配语法,能做的比 C 系语言的 switch 多很多。4.2 函数默认参数、inout 和元组返回值函数在第三天已经学过,我第4天主要补了三个实用细节。第一个是默认参数:func greet(name: String, punctuation: String = "!") -> String { return "Hello, \(name)\(punctuation)" } print(greet(name: "Alice")) // Hello, Alice! print(greet(name: "Bob", punctuation: "?"))默认参数能让我在不传参的情况下也保持调用简洁,同时又保留灵活性。第二个是inout参数。Swift 的函数参数默认是常量,你在函数内部改它们,编译器会直接报错。如果你真的想修改传入的变量,有两种选择:返回新值再赋值,或者用inout:func increase(_ number: inout Int) { number += 1 } var count = 10 increase(&count) print(count) // 11注意调用时要在参数前加&。这个写法在初学阶段用得不算多,但理解它背后的原因是必要的:Swift 默认传值安全,inout相当于“先把值送进去,算完再送出来”,是一种显式的拷贝修改。第三个是元组返回值。函数可以返回多个值,这在很多场景下比“输出参数”更方便:func findMaxAndMin(_ array: [Int]) -> (max: Int, min: Int)? { guard let first = array.first else { return nil } var max = first var min = first for value in array { if value > max { max = value } if value < min { min = value } } return (max, min) }返回一个可选元组后,调用方可以一次拿到两个值,而且不担心空数组崩溃。第4天写这种组合函数,对理解“函数出口”很有帮助:一个函数只做一件事,但可以返回一个结构化的结果。4.3 闭包初体验:把函数当成数据传来传去闭包是第4天我唯一允许自己“先会用、不完全懂原理”的东西。你可以把闭包理解成“没有名字的函数”,它可以被存到变量里,也可以作为参数传给另一个函数,还能在其他函数里返回。Swift 里最经典的闭包使用场景就是数组排序和过滤:let names = ["Tom", "Amy", "Bob"] let sorted = names.sorted { $0.count < $1.count }这行代码的意思是:按字符串长度升序排列。{ $0.count < $1.count }就是闭包,$0和$1分别是第一个和第二个比较元素。第一次看到这种语法我确实劝退过,因为太“魔法”。后来我把它展开成完整写法:let sortedLong = names.sorted(by: { (name1: String, name2: String) -> Bool in return name1.count < name2.count })这才看清楚结构。闭包的核心语法就是:{ (参数列表) -> 返回类型 in 语句 }。如果省略参数名和类型、用$0通配,就成了上面那行短写法。第4天练到这里,你会突然发现 map、filter、sorted 这些高阶函数都能读懂一点了。比如:let uppercased = names.map { $0.uppercased() } // ["TOM", "AMY", "BOB"] let longNames = names.filter { $0.count > 2 } // ["Tom", "Amy"]map把数组里的每一个元素按闭包规则映射成新元素,返回一个新数组;filter则是根据闭包的布尔结果保留或剔除元素。这两个函数正好能用第4天学的数组和闭包串起来,刷熟练度的时候特别快乐。5. 第4天小项目:做一个英文词频统计 Demo5.1 功能拆解和输出设计理论再多,不动手等于没学。我第4天下午给自己布置了一个小项目:输入一段英文文本,输出出现次数最多的前几个单词。这个项目难度刚好,能把数组、字典、Set、排序、闭包全用上,而且结果一眼能验证对不对。功能拆成四步:把字符串拆成单词数组。忽略大小写,统一转成小写,避免 “Hello” 和 “hello” 被当成两个词。用字典统计每个单词出现次数。对字典按次数排序,取前 N 个单词打印。输出格式我设计成:hello: 3 swift: 2 world: 1为什么要做这种小工具?因为第4天你还没到做完整 App 的能力,但你得能独立完成一个“输入数据,处理,输出结果”的闭环。这个闭环比花哨界面重要得多,后面所有列表页、搜索页、详情页,底层干的都是类似的事。5.2 核心代码实现与逐段解读下面是我当天跑通的完整代码,直接在 Playground 就能运行:import Foundation let text = """ Hello Swift! Hello world. Swift is great, swift is fast, Swift is powerful. """ // 1. 拆分:把标点都视为分隔符 let words = text .lowercased() .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } // 2. 用字典统计词频 var frequency: [String: Int] = [:] for word in words { frequency[word, default: 0] += 1 } // 3. 按词频降序排序,取前 5 个 let topWords = frequency.sorted { $0.value > $1.value }.prefix(5) // 4. 输出结果 for (word, count) in topWords { print("\(word): \(count)") }我逐段讲几个关键点。第一段,CharacterSet.alphanumerics.inverted的意思是“除了字母和数字以外的所有字符”。把它当成分隔符传给components(separatedBy:),文本里的空格、逗号、句号、感叹号全都会变成拆分点。如果你只用split(separator: " "),文本里的英文逗号和句号会残留在单词后面,统计结果就全是带标点的“脏词”。比如原来的fast,会带个逗号,和干净单词完全不一样。这不是小事,我用这个方法之前写过一版丑代码,光清洗标点就绕了一圈。第二段,frequency[word, default: 0] += 1是把字典的“查不到就返回默认值”能力用到了极致:如果单词不存在,默认值就是 0,加 1 之后变成 1;如果已经存在,就把旧次数取出来加 1 再存回去。这段看起来简单,但如果你用的是frequency[word] = frequency[word]! + 1,一旦碰到字典里没有的 key 就会崩溃。我当时反复提醒自己:字典取值返回 Optional,能带 default 就别强解包。第三段,frequency.sorted { $0.value > $1.value }把字典转成一个排序后的元组数组,然后.prefix(5)取前 5 个。这里需要注意,字典本身无序,但sorted之后你已经拿到一个有序数组,所以后面迭代输出是按次数从高到低来的。如果你写代码时想到“能不能不用 for 循环统计词频”,Swift 还提供了更函数式的写法:let frequency2 = words.reduce(into: [:]) { $0[$1, default: 0] += 1 }reduce(into:)的意思是:从一个空字典开始,把数组里的每一个单词依次“合并”进去,合并规则就是闭包里的那行。这个写法会更 Swift 一点,但我建议初学者先看懂 for 循环版本,再尝试简化为 reduce,不要为了炫技丢掉可读性。5.3 边界情况与结果验证跑完正常测试后,我故意喂了几种特殊情况给程序,看看它会不会崩:空字符串:words会是空数组,frequency是空字典,排序后prefix(5)是空数组,循环体不执行,安静结束。只有标点,没有字母:lowercased()后按字符集分割,得到一堆空字符串,再被filter { !$0.isEmpty }筛掉,结果依然安静。文本里有大写、小写混合:统一lowercased()后,Hello和hello被合并成一个单词。这点很关键,因为我第一次写的时候没小写,统计结果里同时出现Hello和hello,我还以为逻辑出 bug 了。后来才意识到是大小写没归一化。单词间有多个连续空格:components(separatedBy:)会把连续空格拆成多个空字符串元素。如果不加filter,字典里就会混入空字符串,导致后面输出一行空 key。这个坑几乎每个人都踩过一次。验证结果的时候,我建议用一段你自己能数清楚的短文本,比如上面那串文本,手算一遍“hello: 3, swift: 2, is: 2, world: 1, fast: 1”,再对程序输出。如果匹配,说明这条链路没问题。第4天做这种小项目,最大的意义不是“做出来”,而是“验证明白”,最后你会有一种“这些东西确实都能为我所用”的感觉。6. 我第4天踩过的坑,和一份排查速查表6.1 高频报错与解决方案第4天集中写集合代码,报错率明显比前三天高。我把最常见的几类整理成一张速查表,留着以后报错的时候翻:场景报错或异常现象正确处理数组越界取值Index out of range崩溃取值前用indices.contains(_:),或改用first/last字典 key 不存在强制解包时崩溃用if let或dict[key, default: 0]数组边遍历边删除索引错乱、元素删漏用removeAll(where:),或先记下要删的索引再统一删字符串拆分后有空串统计结果里出现空单词分割后加filter { !$0.isEmpty }字典遍历顺序不稳定输出顺序和预期不一致用sorted { $0.key < $1.key },或先排序 key 再遍历let数组想增删编译报错 “Cannot use mutating member”改成var,或重新思考是否真的需要改动除了这张表,我自己还有一个很耗时间的调试经历:在 Playground 里统计词频,打印出来发现多了很多空 key。我查了半天才发现是标点分隔符的问题,components(separatedBy:)比单纯split(separator:)多产生了很多“空片段”,必须过滤。这类问题在正常项目里也很常见,以后解析 CSV、日志、命令行参数都会遇到,所以第4天就建立“拆分后必过滤空串”的肌肉记忆,能省很多时间。6.2 给同样学第4天的人的建议如果你今天刚开始学集合类型,我最后分享几个切身体会的建议。不要急着把标准库里所有集合方法都背下来。第4天的目标是用得熟练,不是背得全面。数组的map、filter、sorted,字典的默认值取值,Set 的intersection和subtracting,这几个优先级最高。其他的方法,等用到再去文档里查,效果反而更好。用 Playground 而不是新建一个完整 App 工程去练习。第4天还在折腾数据,App 工程多出来的生命周期文件只会干扰你。直接在 Playground 里写,右侧能实时看结果,变量状态一目了然。我后来发现,调试集合逻辑最快的方式就是在 Playground 里把中间过程逐步打印出来,这里多一点那里多一点,很快就能定位。学会主动画数据流向。遇到稍微复杂一点的数组字典操作,我会在纸上或者注释里写“输入是什么 -> 第一步变成什么 -> 第二步变成什么”。如果你不习惯画,至少可以在每个步骤后面print一下中间结果。第4天你写不出复杂算法不是问题,但你必须对“一条数据是怎么一步步改变的”有清晰感知。最后,给自己留一道变体题。我当天写完词频统计,又试着改成“用 Set 检查这篇文章里有没有重复单词”,顺手验证了words.count != Set(words).count这个判断。这种小延伸特别值,等于把今天学的东西又拧了一圈。写这篇文章的时候,我已经在第5天了,回看第4天的学习过程,最大的收获不是记住了多少个 API,而是形成了“先选容器、再做操作、再验证结果”的思考习惯。你在第4天完全可以不用管界面、不用管框架,只需要专心把一堆数据玩弄于股掌之中。等哪天你突然发现,自己拿到需求第一反应是“这适合用字典还是 Set”,那一刻你就知道自己已经上道了。