想象你是一家现代化食品工厂的厂长。几百米长的流水线上,传送带昼夜不停:原料盒从左端上带,经过清洗、切割、调味、封装一站站往前走,最终变成整齐的成品箱。你从不担心哪个工位把半成品偷偷塞回上一个环节,因为每个工作站都遵守同一条纪律——只接收传入的盒子,按固定配方加工,再把新盒子放回传送带;不翻动别人的材料,不偷偷改写流经的数据。这条流水线之所以可靠,不是因为工人多聪明,而是因为规定足够纯粹。 Scala 函数式编程,就是在软件世界里修建这样一条流水线。数据像密封盒一样在管道中流动:函数是工作站,集合是传送带,模式匹配是质检员,惰性求值是按需放行的联动开关。本文就以“食品工厂流水线”为比喻,带你从零认识 Scala 函数式编程的核心概念,并理解为什么 Apache Spark 这样的大数据引擎会把它选作实现语言。

岗位关联

对 AI 从业者而言,数据管道是模型与业务的骨架。函数式编程帮你把“数据怎么处理”写成可复现、可组合、可并行的代码,让你接手 Spark 生态、构建可靠的特征工程时游刃有余。

目录
  1. 可视化演示
  2. 纯函数力量
  3. 高阶函数
  4. 模式匹配
  5. 不可变集合
  6. 惰性求值
  7. 函数组合
  8. Option安全
  9. 大数据应用
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

交互展示纯函数、高阶函数、模式匹配和不可变集合等函数式编程核心概念,理解如何用纯函数构建可靠数据管道。

本节配有交互动效(进入视口后自动加载,约 3KB)。

纯函数与不可变性 相同输入永远产生相同输出 无副作用,不修改外部状态 f(x) → y 数据管道中每个环节可预测可测试

纯函数力量

流水线最前端是一个标准化操作台。工人放进去一块原料,操作台输出一份固定半成品——放十次、放一百次,结果完全一样。操作台旁边没有外接水管、油箱,不会随手改动车间的温度湿度,更不会偷偷把原料扔进隔壁工位的盒子。它做工时,仿佛与世界完全隔离:只从进料口读取,只把成品送出。

在 Scala 中,这样的操作台就是纯函数:输出只由输入决定,不修改外部状态。所谓“无副作用”,对应操作台“不接外水管”——函数不修改全局变量、不写文件、不打印日志、不悄悄改动传入的集合。有一个更数学化的说法叫引用透明:任何 f(x) 都可以在表达式里直接替换成它算出的值,而程序的含义完全不变。这就像你看到“半成品”三个字,就知道它一定等价于对应的工作站产物,不会因为谁今天心情好而多放一勺盐。

纯函数带来的直接红利是可测试性。测试时无需搭环境、mock 一堆全局状态,给定输入、断言输出即可。下面这个简单的函数,就是一台标准操作台的缩影:

回想一下工厂:正因为每台操作台都如此纯粹,出了质量问题才能快速定位到某一个工位。函数式代码也是这样,bug 无处藏身,心智负担大大降低。标准化操作台,就是可靠管道的第一块基石。

f(x)=x×2+1\displaystyle f(x) = x \times 2 + 1
  1. 函数签名f 表示一个函数,只接收一个参数 x,没有读取任何外部变量。
    f(x)=⋯\displaystyle f(x) = \cdots
  2. 乘 2 运算x \times 2 是函数内的第一步运算,结果只依赖于传入的 x。
    x×2\displaystyle x \times 2
  3. 加 1 运算+ 1 是常量运算,不会因为程序其他地方的状态改变而改变。
    f(x)=x×2+1\displaystyle f(x) = x \times 2 + 1
  4. 引用透明f(3) 可以在程序任意位置被直接替换成数字 7,程序行为完全等价。
    f(3)=7\displaystyle f(3) = 7

高阶函数

流水线的每个工作站头部有一个可快速换装的“机头”。拧下切割头、换上打花头,同一个工作站就能完成完全不同形态的加工。接口是统一的,功能是多样的。如果把“机头”想成一个函数,那么把函数传给另一个函数来使用,就构成了函数式编程里的大杀器——高阶函数。

Scala 最常用的三个高阶函数,恰好对应流水线的三种标准机头:

  • map:对集合里每个元素做同一种转换。好比给每个原料盒贴一张统一的新标签。
  • filter:按条件挑选元素。好比只放行合格的盒子,不合格的直接进入回收支线。
  • foldLeft / fold:把整个集合归约为一个值。好比把整批盒子逐一称重、累加成一个总重量。

这三种操作都不会修改原集合,而是返回一个全新的集合——新盒子放回传送带,旧盒子原地不动。这正是函数式风格的标志:用“变换”而不是“修改”来驱动数据流。

换装式机头让同一条流水线能灵活切换产品线;代码里的高阶函数也让复用性大增。一行 map 或 filter,往往就能顶替一整段充满循环和中间变量的命令式代码。

map(f,[x1,x2,…,xn])=[f(x1),f(x2),…,f(xn)]\displaystyle \mathrm{map}(f, [x_1, x_2, \dots, x_n]) = [f(x_1), f(x_2), \dots, f(x_n)]
  1. map 接收函数f 作为第一个参数被传入,说明 map 是高阶函数——它能使用另一个函数。
    map(f,⋅)\displaystyle \mathrm{map}(f, \cdot)
  2. 输入集合[x_1, x_2, \dots, x_n] 表示一个有 n 个元素的列表,它们是待加工的原料盒。
    [x1,x2,…,xn]\displaystyle [x_1, x_2, \dots, x_n]
  3. 逐个应用f(x_1), f(x_2), \dots 表示对每一个元素分别调用 f,互不干扰。
    [f(x1),f(x2),…,f(xn)]\displaystyle [f(x_1), f(x_2), \dots, f(x_n)]
  4. 返回新集合最终输出一个全新的集合,原集合保持不变,就像原料盒在传送带上没有被拆开修改。
    map(f,[x1,…,xn])=[f(x1),…,f(xn)]\displaystyle \mathrm{map}(f, [x_1, \dots, x_n]) = [f(x_1), \dots, f(x_n)]

模式匹配

流水线中段有一个质检分拣工位。质检员手里拿着一本分拣手册:第一页写着“如果是圆形盒子,量半径算面积”;第二页写着“如果是方形盒子,量长宽算面积”。他拿起一个盒子,翻到对应页码直接执行对应操作,完全不需要写一长串层层嵌套的 if-else。这种“看形状、走分支”的能力,在 Scala 中叫模式匹配。

Scala 的 match / case 把分拣逻辑写得如同一本打开的手册:

这里有两个关键机制。第一是样本类(case class):它让数据自带“可拆箱”属性,Circle(r) 的写法能直接从对象里抽出半径 r,这种能力叫解构。第二是守卫条件:case Circle(r) if r > 0 可以进一步细化规则,让分拣更精准。用数学语言描述这个分拣过程,就像分段函数一样清晰:

分拣手册代替了冗长的 if-else 嵌套,让复杂的分支逻辑一目了然。数据管道里无论有多少种数据形态,模式匹配都能让每种形态各得其所、各走其道。

a(m)={πr2m=Circle(r)w×hm=Rect(w,h)\displaystyle a(m) = \begin{cases} \pi r^2 & m = \mathrm{Circle}(r) \\ w \times h & m = \mathrm{Rect}(w, h) \end{cases}
  1. match 分派a(m) 表示对数据 m 按形状分类求值,相当于质检员翻手册。
    a(m)={⋯\displaystyle a(m) = \begin{cases} \cdots \end{cases}
  2. 圆形分支当 m 是 Circle(r) 时,解构出半径 r,计算面积 \pi r^2。
    πr2m=Circle(r)\displaystyle \pi r^2 \quad m = \mathrm{Circle}(r)
  3. 方形分支当 m 是 Rect(w, h) 时,解构出宽 w 和高 h,计算面积 w \times h。
    w×hm=Rect(w,h)\displaystyle w \times h \quad m = \mathrm{Rect}(w, h)
  4. 穷尽性良好的匹配必须覆盖所有可能出现的数据形状,就像手册不能漏页,否则流水线上会出现无人认领的盒子。
    a(m)=⋯\displaystyle a(m) = \cdots
scala8 行
sealed trait Shapecase class Circle(r: Double) extends Shapecase class Rect(w: Double, h: Double) extends Shape def area(shape: Shape): Double = shape match {  case Circle(r)  => math.Pi * r * r  case Rect(w, h) => w * h}

不可变集合

流水线上有一种特别的密封传送盒:质检员看完盒子里的原料,不是拆开盒子修改内容,而是贴上“合格”标签、装进一个全新的盒子放回传送带。旧盒子原封不动。这就是不可变集合:一个 List 创建后,里面的元素永远不变;任何“增删改”操作,都返回一个新集合,原集合保持原样。

Scala 中 val 搭配不可变集合是默认的代码风格。两种最常用的结构是 List 和 Vector:

  • List:链表结构,擅长头尾操作,适合递归和顺序处理。
  • Vector:分支树结构,支持快速随机访问,适合大规模数据处理。

不可变集合还有一个精妙特性——共享结构:新集合和旧集合可以共用同一段底层内存。比如在旧链表前插入一个新元素,新链表只新增一个节点,其余节点全部共用。这就像新盒子与旧盒子共用同一个密封塞,既节省材料又不破坏原盒。正因如此,多个线程同时读取同一个不可变集合时,谁都不用加锁,天然线程安全。

密封盒让传送带上的物料状态永远可预期:没有哪个环节能偷偷改掉别人的输入,出问题时也随时能查看“原始包装”。这正是可靠数据管道最需要的纪律。

b=0 :: a,a=[1,2,3],b=[0,1,2,3]\displaystyle b = 0 \ \mathrm{::}\ a,\qquad a = [1, 2, 3],\quad b = [0, 1, 2, 3]
  1. 原集合a 是一个不可变链表,内容为 [1, 2, 3]。
    a=[1,2,3]\displaystyle a = [1, 2, 3]
  2. 头部插入0 :: a 把元素 0 放到链表头部,构建一个新链。
    0 :: a\displaystyle 0 \ \mathrm{::}\ a
  3. 新集合b 是操作后得到的新链表 [0, 1, 2, 3]。
    b=[0,1,2,3]\displaystyle b = [0, 1, 2, 3]
  4. 原集合不变操作结束后,a 依然是 [1, 2, 3],可以在其他环节继续安全使用。
    a=[1,2,3]\displaystyle a = [1, 2, 3]

惰性求值

大型食品工厂绝不会提前把原料堆满整个仓库,而是根据下游订单按需放货。传送带上有一个联动开关:传送带每转一格,仓库才放出一批原料。惰性求值就是计算世界的联动开关——表达式先不急着算,等结果真正被用到时才动手。

Scala 中 lazy val 声明的变量在第一次被访问时才初始化;LazyList(旧称 Stream)甚至能表示无限序列,比如“所有自然数”。程序不取,它就不产生新元素。看一个例子:

#:: 右侧的 naturals.map(_ + 1) 并没有立刻计算,而是被推迟到需要时。处理超大规模数据时,这意味着巨大的性能优势:只计算需要的那一部分,而不是把全部数据一次性加载进内存。

联动开关让小型工厂也能接下大订单;惰性求值让内存受限的程序也能处理无限或超大规模的数据流。在数据管道中,这是一种非常实用的节能策略。

naturals(n)=n :: naturals(n+1)\displaystyle \mathrm{naturals}(n) = n \ \mathrm{::}\ \mathrm{naturals}(n + 1)
  1. 流的头部n 是当前元素,定义后立刻可以被访问。
    n\displaystyle n
  2. 流的尾部naturals(n + 1) 是递归产生的后续序列,在 LazyList 中被延迟,等 take 等操作真正需要时才展开。
    naturals(n+1)\displaystyle \mathrm{naturals}(n + 1)
  3. 连接操作:: 把已算出的头部和尚未计算的尾部连接成一条流。
    n :: naturals(n+1)\displaystyle n \ \mathrm{::}\ \mathrm{naturals}(n + 1)
  4. 按需取用只有执行 take(3) 这类操作时,才逐个触发尾部计算,得到前三个元素。
    take(naturals(1),3)=[1,2,3]\displaystyle \mathrm{take}(\mathrm{naturals}(1), 3) = [1, 2, 3]
scala2 行
lazy val naturals: LazyList[Int] = 1 #:: naturals.map(_ + 1)naturals.take(3).toList  // 结果 List(1, 2, 3)

函数组合

流水线的工作站之所以能随意排列组合,是因为每个站点的进出口都是统一标准接口:盒子从左边进、右边出,管道一接就通。函数组合就是给函数装上标准接口——把函数 A 的输出,直接接到函数 B 的输入,得到一个新的函数。

Scala 提供两个组合方向:

  • andThen:先执行前一个函数,再执行后一个,方向从左到右。
  • compose:从右往左组合,和数学中的复合记号一致。

更有管道风味的是链式调用:list.filter(f).map(g)。数据从左向右流过一个个“工作站”,每一步只做一件事,整段代码读起来就像流水线的布局图。看这个例子:

组合的意义不仅在于少写几行代码,更在于把大任务拆成小工位、再按顺序拼装。每个函数只管一件事,每条管道都清晰可读。

流水线因统一规格而可以无限拼接;函数组合让你的数据处理逻辑像标准件一样模块化、可复用、可测试。整条数据管道,就是由这些标准接口串联起来的。

(f∘g)(x)=f(g(x))\displaystyle (f \circ g)(x) = f(g(x))
  1. 组合符号f \circ g 读作“f 复合 g”,它表示一个全新的函数。
    f∘g\displaystyle f \circ g
  2. 先算内层(f \circ g)(x) 先执行 g(x),得到中间结果,就像第一站先加工。
    g(x)\displaystyle g(x)
  3. 再算外层把中间结果交给 f,得到最终值。
    f(g(x))\displaystyle f(g(x))
  4. 方向性g \circ f 通常不等于 f \circ g,就像“先切后煮”和“先煮后切”结果完全不同。
    (f∘g)(x)=f(g(x))\displaystyle (f \circ g)(x) = f(g(x))
scala4 行
val step1 = (x: Int) => x + 1val step2 = (x: Int) => x * 2val combo = step1 andThen step2combo(3)  // 先加一得 4,再乘二得 8

Option安全

流水线上经常出现瑕疵品。聪明的工厂不会把瑕疵品直接扔到地上造成混乱,而是给它贴一张“异常”标签,让它继续随传送带流向回收区。Option 就是这张标签,它有两种形态:Some(值) 表示“这是一个合格物品”,None 表示“这里没有值”。

Java 程序员最头疼的空指针,本质上是盒子是空的却硬要打开。Scala 用 Option 从类型层面逼你想清楚“可能没有值”这件事。对管道里的数据,不需要每个环节都写 if (x != null),而是可以对 Option 执行 map、flatMap、getOrElse 这些操作,让标签自动在管道中流动:

getOrElse 就像一个兜底方案:有值就取出来用,没有值就用默认值顶替。更妙的是,None 会安静地穿过整条管道,不在每一个环节都中断程序。

有了“异常标签”,流水线不再需要专门停机检查;有了 Option,数据处理过程就优雅地避开了空指针崩溃。可靠性,就是这样从每一个小盒子中积累起来的。

map(o,f)={Some(f(v))o=Some(v)Noneo=None\displaystyle \mathrm{map}(o, f) = \begin{cases} \mathrm{Some}(f(v)) & o = \mathrm{Some}(v) \\ \mathrm{None} & o = \mathrm{None} \end{cases}
  1. map 输入o 是一个 Option 类型值,可能是 Some(v),也可能是 None;f 是转换函数。
    map(o,f)\displaystyle \mathrm{map}(o, f)
  2. Some 分支当 o 是 Some(v) 时,取出 v 送入 f,再把结果包回 Some。
    Some(f(v))o=Some(v)\displaystyle \mathrm{Some}(f(v)) \quad o = \mathrm{Some}(v)
  3. None 分支当 o 是 None 时,不做任何计算,直接返回 None。
    Noneo=None\displaystyle \mathrm{None} \quad o = \mathrm{None}
  4. 链式传递因为 None 会一路滑过,管道中每个环节都不需要手动判空。
    o.map(f).map(g)\displaystyle o.\mathrm{map}(f).\mathrm{map}(g)
scala2 行
val maybe: Option[Int] = Some(5)val result = maybe.map(_ * 2).getOrElse(0)  // 结果 10

大数据应用

最后,把视角拉远,看看整座工厂。它不是单条流水线,而是成千上万条流水线并行运转,分布在不同的厂房、甚至不同的城市。厂长需要一套统一的规则:每条流水线都遵循同样的接口、共用同样的零件,任何一环出错都能从记录中回溯重放。Scala 函数式编程,正是这套“工厂宪法”。

Spark 的核心数据抽象 RDD(弹性分布式数据集),就是不可变集合在分布式环境的放大版。RDD 本身不可变,每一次 map、filter、reduceByKey 都会生成新的 RDD,绝不会破坏之前的计算结果。这让 Spark 能安全地把计算分发给成百上千台机器并行执行;一旦某台机器宕机,还能根据操作血缘关系(血统图)从源头重新计算。这正是 Spark 选择 Scala 的核心原因:JVM 生态 + 函数式表达能力 = 分布式世界里的黄金组合。

用一句话概括函数式数据管道的样子,就是下面这条 Spark 词频统计链路:

当你也用相同的函数式思维去写数据处理代码——不管是在单机 Scala 还是 Spark 集群——整座“工厂”都会像流水线一样透明、可靠、可扩展。这正是函数式编程在大数据时代最迷人的地方。

counts=lines.filter(isValid).flatMap(splitWords).reduceByKey(+)\displaystyle \mathrm{counts} = \mathrm{lines}.\mathrm{filter}(\mathrm{isValid}).\mathrm{flatMap}(\mathrm{splitWords}).\mathrm{reduceByKey}(+)
  1. 输入数据lines 是原始数据形成的 RDD,比如日志文件中的所有行。
    lines\displaystyle \mathrm{lines}
  2. 过滤无效行filter(isValid) 丢弃格式错误、内容无关的行,得到干净的数据。
    lines.filter(isValid)\displaystyle \mathrm{lines}.\mathrm{filter}(\mathrm{isValid})
  3. 拆词摊平flatMap(splitWords) 把每行拆成单词,并把所有单词摊平成一个大的单词集合。
    flatMap(splitWords)\displaystyle \mathrm{flatMap}(\mathrm{splitWords})
  4. 按键聚合reduceByKey(+) 把所有相同单词的计数累加起来,最终得到词频表。
    reduceByKey(+)\displaystyle \mathrm{reduceByKey}(+)

AI 应用场景

电商日志 ETL 管道

阶段:数据接入与清洗

解决的问题:传统 Java 代码处理每日数十亿条日志时,大量嵌套循环和可变状态让清洗逻辑难以维护;字段缺失、格式错误等问题更是让程序频繁崩溃。用 Scala 的 map / filter / flatMap 构建不可变清洗管道,每一步都是独立纯函数,既可单测又可重放。

具体案例:某电商平台用 Spark 每天处理 50 亿条用户浏览日志。先用 filter 丢弃脏数据,再用 flatMap 解析嵌套 JSON 字段,接着用 map 做单位归一化,最后 reduceByKey 汇总用户行为。管道中任何一环出错,都能从输入数据重新计算,不影响其他环节。

实时流特征工程

阶段:特征工程与在线推理前的实时计算

解决的问题:模型实时打分需要滑动窗口特征(如最近 5 分钟点击率),传统可变状态在分布式场景下极易出错。函数式流处理用不可变数据 + 无状态转换函数建模,天然适配 Kafka Streams / Spark Streaming。

具体案例:推荐系统中,用户点击流进入 Kafka,Scala 程序用 map 构建特征向量,用 Option 安全处理用户画像缺失,用惰性流合并窗口统计,最终产出特征直接送入模型打分 API。缺失字段不会让管道崩溃,只会安静地走 None 分支。

可复现的离线训练数据生成

阶段:离线训练集构建

解决的问题:训练数据生成脚本只要有隐藏全局状态,两次运行结果就会不一致,模型无法复现。用纯函数 + 不可变集合保证:同一份 SQL 或日志输入,永远生成同一份特征文件。

具体案例:广告 CTR 模型每月重训,特征工程代码全部按纯函数规范编写:输入数据批次 ID、日期、用户特征表,输出标准化特征行。任何一次重跑都与首次完全一致,算法团队再也不用为“为什么这次结果不一样”而争吵。

Python 演示

python29 行
from functools import reducefrom collections import Counter DATA = [    "spark is fast spark is scalable",    "scala is functional scala is pure",    "spark runs on scala",] def split_words(line):    return line.lower().split() def keep_long_word(word):    return len(word) > 3 def flatten(rows):    return reduce(lambda a, b: a + b, rows, []) def build_pipeline(data):    rows = map(split_words, data)    flat_words = flatten(list(rows))    long_words = list(filter(keep_long_word, flat_words))    counted = Counter(long_words)    return dict(sorted(counted.items())) if __name__ == "__main__":    result = build_pipeline(DATA)    for word, count in result.items():        print(word + ": " + str(count))
目的:用 Python 的 map / filter / reduce 复刻一条纯函数式词频统计管道,直观演示高阶函数和管道式数据处理。
  1. def split_words(line):纯函数:给一行文本,返回单词列表,不修改入参,可安全复用。
  2. rows = map(split_words, data)等价于 Scala 的 map:把函数应用到每一行上,这是高阶函数在 Python 中的形态。
  3. def flatten(rows):用 reduce 把二维列表“摊平”成一维词表,对应 Scala/Spark 里 flatMap 的逻辑。
  4. long_words = list(filter(keep_long_word, flat_words))filter 高阶函数只保留长度大于 3 的单词,就像质检员只放行合格品。
  5. counted = Counter(long_words)归约聚合:把单词列表变成一个词频字典,对应 reduceByKey 的简化版。
  6. if __name__ == "__main__":程序入口,保证脚本既能被导入也能直接运行。

常见误区

误区我一直以为纯函数就是死板的、什么操作都不能做,连打印日志都不行,那写代码还有什么乐趣?这也太不实用了。

正解纯函数约束的是“对外部世界的影响”,不是“不能做事”。函数内部可以计算、可以构造新值、可以返回结果;只要不修改外部状态、不依赖外部可变状态,它就是纯函数。println 确实是副作用,但工程实践中可以把所有 IO 操作集中到程序边界,核心计算保持纯粹——就像流水线工作台可以认真加工原料,只是不许乱接水管、乱扔废料。

为何常见:教材反复强调“无副作用”,初学者很容易把“无副作用”误解成“无功能”。

误区我写了 `val arr = Array(1,2,3)`,然后执行 `arr(0) = 99` 居然成功了。那 val 还有什么意义?说好的不可变呢?

正解val 表示“引用不可变”,不表示“指向的对象不可变”。val arr = Array(...) 只说明 arr 不能再指向其他数组,但 Array 本身是可变的,元素可以被修改。要真正不可变,应使用 List、Vector 这类不可变集合,或者用 case class 声明只读字段。就像密封盒的标签不能换,但盒子内部如果设计成可打开的,那仍然不是真正的密封。

为何常见:val 的中文翻译“不可变变量”容易让人误以为它保证了指向对象的一切都不可变。

误区既然惰性求值是“按需计算”,那它应该每次都比立即求值快吧?我用它肯定能优化所有程序。

正解惰性求值减少了“算了但没用”的浪费,但它不是免费的:延迟计算需要额外记录状态、首次访问时要执行计算、有时还要缓存结果。在数据量小、访问频繁的场景下,惰性求值反而可能更慢。它真正的价值在于“计算昂贵但只用其中一小部分结果”或“需要表达无限数据流”时。就像联动开关虽然省了仓库空间,但每次开关本身也需要维护。

为何常见:只看到“省计算”的好处,忽略了延迟与缓存本身也有成本。

误区Option 不就是让我用 `map` 代替 `if (x != null)` 吗?本质上没有区别,只是写法更花哨罢了。

正解差别在于“强制”和“组合”。Option 是类型系统里的一等公民:一个方法返回 Option[String],编译器就逼你处理“可能没有值”的情况,而不是运行时才发现空指针。它还能用 map / flatMap 链式组合,就像异常标签可以一路流过多个工位而不断链,这远不是单个 if 判空能做到的。

为何常见:只看到 Option 在单一判空场景的用法,没体会到它在复合管道中的组合能力。

面试题

Q1. 在 Scala 中,纯函数最核心的特征是?

单选题 · easy

  • A.可以自由访问全局变量
  • B.相同输入必得相同输出← 正确答案
  • C.代码执行速度非常快
  • D.返回值必须是整数

答案:B。纯函数的关键是无副作用、引用透明,相同输入必然得到相同输出。访问全局变量会引入外部依赖,执行速度是性能问题而非纯函数特征,返回值类型也没有强制限制。

Q2. 以下哪个用法体现了高阶函数?

单选题 · mid

  • A.对列表调用 map 方法← 正确答案
  • B.把数值赋给 val 变量
  • C.比较两个数字大小
  • D.引入随机数工具包

答案:A。高阶函数指接收函数作为参数或返回函数的函数。map 接收一个函数作为参数,是高阶函数的典型代表;其他选项都只是普通的值操作或导入语句。

Q3. 在模式匹配 case 0 => "zero" 中,0 代表什么?

单选题 · mid

  • A.一个默认的返回值
  • B.一个匹配值的模式← 正确答案
  • C.一个变量的声明
  • D.一个隐式类型转换

答案:B。case 后面的 0 是一个字面量模式,只有当被匹配的值恰好为 0 时才会进入这个分支。它不是返回值、不是变量声明,更不是类型转换。

Q4. 在 Scala 中声明一个不可变引用,应该使用哪个关键字?

单选题 · easy

  • A.使用 var 来声明它
  • B.使用 def 来声明它
  • C.使用 val 来声明它← 正确答案
  • D.使用 new 来声明它

答案:C。val 声明不可变引用,一旦初始化就不能指向其他对象;var 声明可变引用;def 声明方法;new 用于创建对象实例。

Q5. 关于不可变集合 List,下列说法正确的有哪些?

多选题 · mid

  • A.添加元素会返回新集合← 正确答案
  • B.集合内容可被直接修改
  • C.新旧集合可共享尾部← 正确答案
  • D.多线程访问更加安全← 正确答案

答案:ACD。不可变 List 的任何“增删改”操作都返回新集合;新旧集合通过共享尾部节省内存;因为没有写操作,多线程读取天然安全。选项 B 正是不可变集合反对的行为。

Q6. lazy val x = expensive() 中,x 的值何时被真正计算?

单选题 · mid

  • A.声明语句执行的时候
  • B.第一次被访问的时候← 正确答案
  • C.程序彻底结束时才算
  • D.每次访问都重新计算

答案:B。lazy val 的求值时机是第一次被访问时,之后的结果会被缓存,再次访问不会重新计算。这就是“按需放货”的联动开关。

Q7. 执行 Some(5).map(_ * 2) 的输出是什么?

单选题 · hard

  • A.返回整数 5 本身
  • B.返回一个空值 None
  • C.返回 Some(10) 盒子← 正确答案
  • D.返回 Some(5) 原样

答案:C。map 对 Some 内部的值应用函数 _ * 2,并用 Some 重新包装,得到 Some(10)。若原始值是 None,结果才会是 None。

Q8. Spark 选择 Scala 作为实现语言的原因包括哪些?

多选题 · extreme

  • A.JVM 生态与 Java 互通← 正确答案
  • B.函数式 API 适合分布式← 正确答案
  • C.不可变集合利于并发← 正确答案
  • D.Scala 完全没有类型系统

答案:ABC。Scala 运行在 JVM 上,能与 Java 生态无缝互操作;函数式 API(map/filter/reduce)非常适合表达分布式计算;不可变集合天然支持多线程共享。选项 D 明显错误,Scala 拥有强大的类型系统。

Q9. 把元素 0 添加到 List(1,2,3) 的头部,正确写法是?

单选题 · easy

  • A.List(1,2,3).append(0)
  • B.0 :: List(1,2,3)← 正确答案
  • C.List(1,2,3) + 0
  • D.0.addTo(List(1,2,3))

答案:B。:: 是 Scala 中在列表头部添加元素的专用操作符,0 :: List(1,2,3) 返回 List(0,1,2,3)。其他写法都不是 Scala 的标准操作。

Q10. 纯函数式代码带来的工程收益有哪些?

多选题 · mid

  • A.更方便编写单元测试← 正确答案
  • B.并发时无需担心共享状态← 正确答案
  • C.代码一定运行更快
  • D.推理和排错更加清晰← 正确答案

答案:ABD。纯函数无副作用,测试时只需给定输入断言输出;不可变状态让并发无需加锁;引用透明让代码更容易推理和排错。性能方面,函数式代码不一定更快,可读性和可靠性才是主要收益。