☰
Scala List与Java集合的差异:不可变链表、模式匹配与高阶函数详解
2026/10/13 9:03:05 网站建设 项目流程

那是我第一次真正上手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开发者习惯的写法是:

List<Integer> 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、foreach

4.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/filter

Scala允许用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) // 10

foldLeft(0)里的0是初始值。后续每一步,把当前元素累加到已经累计的结果上。拆解一下,实际计算过程是:

0 + 1 = 1 1 + 2 = 3 3 + 3 = 6 6 + 4 = 10

foldRight方向相反,从右边开始处理。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、模式匹配那些操作,很多东西会瞬间从“语法怪癖”变成“本该如此”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询