
那是我第一次真正上手Scala接数据处理任务本来在Java里早把List用得轻车熟路——声明、add、迭代、排序闭着眼都能写完。结果到了Scala我下意识写出val list List(1,2,3)之后开始找add方法编译器直接报错。那一刻我才意识到Scala的List和Java集合根本不是一回事它从根上就是另一种东西。这篇笔记就是想把这根上的差异讲透给准备学Scala、尤其是从Java转过来的朋友省掉我摸索时走过的弯路。1. 先把概念对齐Scala的List是“不可变链表”不是集合接口1.1 在Java里用的那一套到了Scala大多要重学Java里的List是一个接口下面是ArrayList、LinkedList这些实现。你可以new一个对象往里加东西、删东西、改东西这些都是就地操作。所以Java开发者习惯的写法是ListInteger nums new ArrayList(); nums.add(1); nums.add(2); nums.add(3);但Scala里给我们的默认List不是接口也不是可以随便改的容器它是一个不可变的链表。第一次看到List(1,2,3)这样的构造方式我第一反应是它可能只是个静态工厂方法类似Java里的Arrays.asList。后来才发现这个理解虽然不完整但方向没错——Scala里的List确实不鼓励你做“改”的操作而是鼓励你做“构造新列表”的操作。为什么这么设计核心原因在于函数式编程的理念数据一旦创建就不应该被修改所有变换都通过生成新数据来完成。这样带来的直接好处是你不需要担心某个方法内部把列表改了一半导致程序状态混乱更不用担心多线程环境下共享同一个列表会互相踩踏。1.2 不可变到底意味着什么旧列表不会变新列表会被创建我打个比方。你在纸上写了一份名单现在要给名单加一个人。Java的做法是拿橡皮擦在纸的末尾补一个新名字这张纸还是原来那张纸。Scala的做法是重新抄一份名单把新名字放在最后然后把这份新名单交给你原来的纸原封不动。所以Scala里你干不出“往列表末尾追加”这种就地修改的事。你能做的是val oldList List(1, 2, 3) val newList oldList : 4 // 返回一个新列表oldList仍然是(1,2,3)这个:符号表示“在右侧追加元素”老列表完全不受影响。相比之下Scala更推荐的是用::在头部添加元素因为链表在头部插入是O(1)的这个后面专门讲。不可变带来的第二个好处是共享结构。因为旧列表永远不变多个新列表可以放心地引用同一个旧列表作为自己的尾部不会出现谁改了数据影响谁的问题。这就是函数式数据结构里常说的“持久化数据结构”。1.3 递归结构每个列表都可以拆成“头元素 尾列表”要理解Scala的List为什么一堆操作都跟递归有关必须知道它的内部结构。一个非空列表本质上由两部分组成head第一个元素tail去掉第一个元素后剩下的部分而剩下的这部分依然是一个List比如List(1, 2, 3)它的结构就是1和List(2, 3)的组合而List(2, 3)又是2和List(3)的组合List(3)是3和Nil的组合。Nil就是空列表是整个链的终点。这种“一个元素接一个列表”的结构就是经典的链表。你在Java里用LinkedList时也是这个结构但Java的LinkedList暴露出来的操作方式太像数组了导致很多人根本不会从链表的角度去思考它。Scala则不同它的操作风格、模式匹配、递归写法全都是基于这种“头尾拆分”的结构设计的。提示在Scala里List默认指的是scala.collection.immutable.List也就是不可变列表。Scala也提供可变版本但名字都不同你不会误用。理解了这层结构后面所有看起来“怪”的语法和模式就都有了底层逻辑。2. 创建列表的方式与cons操作符的细节2.1 三种常见的构造方式Scala创建列表的方式有好几种刚入门时建议把这几种都记住因为不同场景下你会看到不同的写法。第一种是最直观的val nums List(1, 2, 3) val fruits List(apple, banana, cherry)这其实是调用了List伴生对象的apply方法传入一个可变参数帮你构造好了一个列表。写起来和Java的Arrays.asList有点像但类型推断更聪明。第二种是用::操作符也就是cons操作val nums 1 :: 2 :: 3 :: Nil这个写法看起来像把元素一个个“穿”起来以Nil收尾。注意别漏了Nil如果写成1 :: 2 :: 3编译器会立刻报错因为3不是列表而::右侧要求必须是列表。第三种是空列表val empty List.empty[Int] val empty2 Nil两种都表示空的不可变列表。多写几个项目之后你会发现遇到需要返回“空”作为默认值的情况直接用Nil是最省事的。2.2 右结合性为什么1 :: 2 :: 3 :: Nil从右往左算这个点是新手最容易懵的。看到1 :: 2 :: 3 :: Nil直觉上会以为是从左到右依次往一个列表里塞元素。但对编译器来说这个表达式是从右往左计算的因为::是一个以冒号结尾的操作符Scala里有条规则以冒号结尾的方法在调用时是右结合的。所以1 :: 2 :: 3 :: Nil实际等价于1 :: (2 :: (3 :: Nil))也就是先算出3 :: Nil得到一个List(3)再用2去cons得到List(2, 3)最后再用1去cons得到List(1, 2, 3)。这个过程就像在链表头上不断插入元素最终结果看起来“反着”构建但因为每一步都是O(1)的头插所以非常高效。这也是为什么Scala设计成推荐用::构建列表而不是用:去尾部追加——尾部追加对链表来说是O(n)的每次都要从头遍历到尾列表一长性能会很尴尬。2.3 基本查询操作与几个容易踩的坑构造完列表接下来是基础操作。以下这几个方法最常用也最该先记住val nums List(10, 20, 30) nums.head // 10返回第一个元素 nums.tail // List(20, 30)返回去掉头之后的部分 nums.isEmpty // false判断是否为空 nums.length // 3元素个数 nums.last // 30最后一个元素 nums.init // List(10, 20)去掉最后一个元素后的部分看着简单这几个方法恰恰暗藏不少坑。head和last在空列表上直接抛NoSuchElementException。我刚开始写代码时习惯直接myList.head遇到空列表时程序直接崩。后来养成习惯要么先判空要么用更安全的headOption、lastOption返回Option类型就不会动不动崩了val maybeHead: Option[Int] nums.headOption另外一个常见错误是用length去判断列表是否为空。其实对链表来说length需要遍历整个链表才能算出来复杂度是O(n)而isEmpty是O(1)的直接判断头节点是否存在。判断空列表的首选永远是isEmpty而不是length 0。3. 用模式匹配拆列表递归处理是函数式默认姿势3.1case head :: tail是怎么把列表拆开的把列表拆成“头 尾”不仅在理论上重要在实际代码里更是核心操作。Scala提供了模式匹配可以直接把这种结构拆出来def describe(list: List[Int]): String list match { case first :: rest s第一个元素是$first剩下的还有${rest.length}个 case Nil 空列表 }这里first :: rest就是一个模式如果列表非空它会把头元素绑定到first把尾列表绑定到rest。如果列表是Nil就走第二个分支。这种写法直观、安全、可读性强比Java里写if (list.isEmpty)然后list.get(0)再手动截取子列表干净太多。模式匹配的妙处在于它把“判断是否为空”和“取出头尾”这两件事合在了一起分支结构本身就是数据结构的一种映射。很多函数式代码的写法逻辑都是从这里长出来的。3.2 用递归实现length和sum理解了模式匹配就可以写递归了。比如自己实现一个列表长度计算def myLength(list: List[Int]): Int list match { case Nil 0 case _ :: rest 1 myLength(rest) }逻辑就是空列表长度为0非空列表长度等于1加上尾列表的长度。每一步都把问题规模缩小一截直到触底。求和也是类似的思路def mySum(list: List[Int]): Int list match { case Nil 0 case head :: rest head mySum(rest) }这种“先处理头元素然后递归处理尾列表”的套路是处理Scala列表最常见的入门姿势。刚开始会觉得绕但写几次就不难了。它比命令式for循环更贴近数据本身的形状既然列表天然是“头 尾”那么处理逻辑也按照“头 尾”来设计代码和结构是对应的。3.3 尾递归与累加器防止递归爆栈递归虽优雅但直接写1 mySum(rest)这种递归有一个隐患它会一直占用调用栈列表很长时可能栈溢出。Scala编译器的优化可以解决一部分问题前提是你写成尾递归——也就是递归调用是函数的最后一个操作不需要在递归返回后继续算什么东西。上面那个1 mySum(rest)就不是尾递归因为返回后还要加1。可以引入一个累加器把中间结果带着往下传def sumTail(list: List[Int]): Int { def loop(remain: List[Int], acc: Int): Int remain match { case Nil acc case head :: rest loop(rest, acc head) } loop(list, 0) }这里loop每一步都把当前元素加到acc上再带着累积结果继续处理剩余部分。递归调用在函数末尾Scala会把它优化成一个循环不会占用额外栈空间。annotation.tailrec注解可以帮你检查函数到底是不是尾递归如果不是编译器会报错。我习惯写上这个注解相当于让编译器帮我把关。4. 常用高阶函数map、flatMap、filter、foreach4.1 map一对一变换模式匹配和递归固然重要但实际写工程代码时天天手写递归也不现实。Scala提供了一批高阶函数也就是“接收函数作为参数的函数”它们把遍历、分支、递归的细节都封装掉了你要做的只是提供每一步的变换逻辑。map是最基础的它对列表中的每个元素应用同一个函数返回一个新列表val nums List(1, 2, 3, 4) val doubled nums.map(_ * 2) // List(2, 4, 6, 8)_ * 2是Scala的占位符语法表示“把传入的元素乘以2”。如果要更明确也可以写全val doubled nums.map(x x * 2)map完成的是一对一的变换列表长度不变每个元素都被映射成另一个元素。4.2 flatMap一对多变换并拍平flatMap比map进一步函数返回的是一个列表最终结果会把这些列表全部“拍平”到一个列表里。这个函数一开始不太好理解我当初也是某个实际场景才彻底明白的。假设每个数字要拆成它自己和它的两倍val nums List(1, 2, 3) nums.flatMap(x List(x, x * 2)) // 结果List(1, 2, 2, 4, 3, 6)map版本会得到List(List(1,2), List(2,4), List(3,6))是个嵌套列表flatMap直接把这些嵌套列表的元素展开到了同一层。字符串处理里这个操作很常用。比如把一个句子按空格拆成单词再把每个单词拆成字母val words List(hello, world) val letters words.flatMap(_.toList) // List(h,e,l,l,o,w,o,r,l,d)4.3 用filter按条件筛选筛出及格线和边界值filter接收一个返回布尔值的函数保留满足条件的元素val scores List(88, 45, 92, 61, 30) val passed scores.filter(_ 60) // List(88, 92, 61)实际项目里filter和map经常连着用。比如先筛选出符合条件的记录再对记录做转换case class Student(name: String, score: Int) val students List( Student(A同学, 88), Student(B同学, 45), Student(C同学, 92) ) val honorList students.filter(_.score 90).map(_.name) // List(C同学)这种链式写法在Scala里非常自然从左到右读起来就是一套完整的数据处理流水线先过滤再提取干净利落。4.4 for推导只是语法糖本质还是map/flatMap/filterScala允许用for关键字写类似命令式循环的代码但它并不是Java那种for循环而是一层语法糖val nums List(1, 2, 3) val result for { x - nums y - nums if x y } yield (x, y)这段代码等价于nums.flatMap(x nums.filter(y x y).map(y (x, y)))for推导的底层逻辑就是flatMap、map和filter的组合。理解这一点很重要因为一旦你意识到for只是糖你在读别人代码时就不会雾里看花写起来也知道它的执行顺序和嵌套关系。5. fold家族把列表压成单一值的常规武器5.1 foldLeft、foldRight与reduce的区别处理列表时除了变换、筛选还经常需要把整个列表“压”成一个值比如求和、求最大值、统计总数。Scala里干这件事的主力是fold家族。foldLeft从左往右遍历同时维护一个累加结果val nums List(1, 2, 3, 4) val sum nums.foldLeft(0)((acc, x) acc x) // 10foldLeft(0)里的0是初始值。后续每一步把当前元素累加到已经累计的结果上。拆解一下实际计算过程是0 1 1 1 2 3 3 3 6 6 4 10foldRight方向相反从右边开始处理。reduce则不允许提供初始值它直接用列表头两个元素起算所以空列表上调用reduce会直接崩要用就得保证列表非空。实际项目中我更常用foldLeft因为它初始值可控空列表也能安全处理val sum nums.foldLeft(0)(_ _) // 简洁写法5.2 用一个表达式完成词频统计foldLeft最常见的实战场景之一就是构建“列表到映射表”的累积过程。比如统计单词出现次数val words List(apple, banana, apple, cherry, banana, apple) val count words.foldLeft(Map.empty[String, Int]) { (acc, word) acc (word - (acc.getOrElse(word, 0) 1)) } // Map(apple - 3, banana - 2, cherry - 1)这里初始值是一个空Map每遍历一个单词就取它当前次数加1放到新Map里。因为Map也是不可变的所以每次acc (...)都是生成了新Map。写几次这种代码之后你对不可变集合的“累积”就自然适应了不是修改老数据而是不断产生新数据传递下去。5.3 初始值和运算顺序的陷阱使用foldLeft时有两个坑值得专门提醒。第一个是初始值类型会影响结果推导。比如求长度如果写成nums.foldLeft(0)((acc, _) acc 1)初始值是Int没问题但如果你想让结果是Double就得把初始值写成0.0否则类型推导会出问题。第二个是foldLeft和foldRight的运算顺序不同。两者处理的结果通常一样但在某些非交换运算里会出现差异。比如字符串拼接val chars List(a, b, c) chars.foldLeft()(_ _) // abc chars.foldRight()(_ _) // abc这俩在拼接顺序上看不出区别但换成(acc, x) acc - x之类的运算就完全不同了。设计算法时要想清楚方向。6. 关于性能与集合选择的几句实话6.1 尾部追加的O(n)陷阱很多从Java转过来的人习惯性想把数据“追加”到列表尾部。Scala里可以写成var result List[Int]() for (i - 1 to 10000) { result result : i }这段代码能跑但性能极其难看。因为:在不可变链表上是O(n)的操作每追加一个元素都要遍历整个列表到尾部再复制出个新列表循环1万次复杂度就高达O(n²)。我头一回在数据量稍大时跑这种代码直接卡到怀疑人生。正确做法是用::在头部追加最后再反转var result List[Int]() for (i - 1 to 10000) { result i :: result } val finalResult result.reverse因为头插是O(1)的整体复杂度降到O(n)。这是不可变链表的“正确打开方式”但说实话每次都要记着“先头插再反转”也挺反直觉。6.2 用ListBuffer在可变阶段收集最后转成List如果你需要频繁追加数据比起自己折腾头插再反转更推荐用ListBuffer。它就是为“临时累积”设计的可变版本追加元素是高效的最后一句切换到List就行import scala.collection.mutable.ListBuffer val buffer ListBuffer[Int]() for (i - 1 to 10000) { buffer i } val result buffer.toList这个模式非常适合在循环里收集结果、最后需要一个不可变List交付给下游的场景。我自己现在写代码凡是要大量动态追加的一律ListBuffer起步不会再去想“头插然后反转”。提示ListBuffer是可变的它只应该出现在“正在构建列表”的局部代码里完成后立刻转成不可变List交付给别人这样既兼顾性能又把可变状态的污染面控制在最小范围。6.3 List、Vector、ArrayBuffer到底怎么选Scala集合库里还有不少和List容易混淆的类型。我建议入门阶段就把区别理清类型可变性头部操作尾部操作随机访问适用场景List不可变O(1)O(n)O(n)函数式处理、模式匹配、头插Vector不可变近似O(1)近似O(1)近似O(1)需要两端操作或随机访问的不可变数据ArrayBuffer可变O(n)O(1)摊还O(1)动态数组、随机访问、可变累积我的选择习惯是默认用List因为Scala生态里大量高阶函数、模式匹配的例子都是围绕它展开的学习阶段别给自己加戏。如果发现需要频繁取尾部、按索引访问或者构建过程反复在尾部追加就换Vector或ArrayBuffer按表格里的复杂度做判断即可。另外列表转其他集合也只是一个方法的事val list List(1, 2, 3) list.toSet // Set(1, 2, 3) list.toMap // 如果列表元素是二元组可以这样转Map list.toArray // Array(1, 2, 3)实际做数据清洗时这几个转换非常常用比如把列表去重转成Set最直接把键值对列表转成Map一行就搞定。最后说一点个人体会。Scala的列表看起来只是一个集合类但它背后牵扯的是整个函数式编程的思维方式不可变、表达式优先、用组合代替修改。刚学时我总忍不住拿它跟Java的ArrayList去比比多了才发现坐标系都错了。如果你也在入门阶段建议别急着背API先把不可变链表这个底层模型吃透再去看map、fold、模式匹配那些操作很多东西会瞬间从“语法怪癖”变成“本该如此”。