Scala函数基础全解析:从一等公民到高阶函数与闭包实战
1. 为什么Scala的函数基础值得专门写一篇先问个问题你写过Java也写过Python现在打算认真学Scala那我猜你大概率有个直觉——函数嘛不就是def加个括号加个花括号这有什么好学的。说实话我第一次接触Scala也是这个心态。当时在搞一个基于Spark的数据处理任务同事扔给我一段代码里面全是map(x x._2)、reduceByKey(_ _)这种写法。我盯着看了半天语法上大概能猜出意思但总觉得隔了一层。后来逼着自己把Scala的函数语法体系捋了一遍再回头看那段代码突然就通透了——原来那些符号不是Scala故意写得玄乎而是函数式风格的必然产物每一条都有明确的语法规则在支撑。这就是我想写这篇函数基础的原因Scala的函数语法是整个语言所有高级特性的大门。你看RDD操作本质是函数传进函数你看Akka Actor的消息处理本质是偏函数你看各种框架的DSL本质是隐式转换加高阶函数。函数这块地基不行后面全是空中楼阁。这篇面向的读者我默认有两种一是从Java或Python转过来的已经有编程经验但第一次系统性接触Scala函数式写法二是纯新手连def都还没写过几行。考虑到两种人混在一起我会把语法拆得细一点每个点都讲清语法长什么样和为什么要这样设计配合代码直接跑你在本地照着敲一遍就能复现。顺便说一句如果你后续要玩Spark、Flink、Kafka Streams这套大数据生态Scala函数基础更是绕不开的前置。RDD的创建、转换、行动操作表面上看是一堆集合算子往深了挖全是函数式组合。这一篇把函数基础夯实了后面看那些算子源码都会轻松很多。2. 函数在Scala中的身份为什么说函数是一等公民2.1 值和函数之间的边界被刻意抹掉了一等公民这四个字光看概念很容易飘。我换个说法你感受一下Java里一个int x 5你可以在函数之间传来传去但一个行为一段逻辑你要么写成方法、要么匿名内部类、要么LambdaJava 8以后的妥协产物始终没法像普通值那样干净地传递。Scala不这么玩。在Scala里函数本身就是一个值它有类型可以赋值给变量可以放进List可以当参数传进另一个函数也可以作为返回值从函数里弹出来。也就是说一个行为和一个数字在其底层抽象层面是平权的。为什么会这样因为Scala面向的是函数式编程范式。函数式编程的核心主张就是逻辑就是数据数据就是逻辑组合它们的方式应该完全统一。把你脑子里这是函数、那是变量的二分法暂时卸掉这是理解后面所有内容的前提。举个最直白的例子// 数字可以赋值给变量 val num: Int 42 // 函数也可以赋值给变量 val addOne: Int Int (x: Int) x 1第二行里Int Int是一个函数类型的字面表达读作接收一个Int、返回一个Int的函数。(x: Int) x 1是具体的函数体。这一整行干的事情和第一行几乎一模一样——定义了一个东西给了它一个名字标注了它的类型。我当年第一次看到这行代码时最大的冲击就是原来加一这种操作居然可以被装进一个变量里随身携带。这就好比你在菜市场买了一把葱然后发现这把葱居然还能帮你算账。2.2 Scala函数类型的底层本质这里插一个稍微底层一点的原理。Scala本质上运行在JVM上JVM的方法调用肯定要落到某个类的方法上。那Scala怎么把函数值这个概念塞进JVM的答案是函数类型在编译之后会被展开成FunctionN特质trait的实例。Function0、Function1、Function2……分别对应零参数、单参数、双参数……的函数。以Function1为例它约定了apply方法你调用一个函数值的时候本质上是在调用applyval addOne: Int Int (x: Int) x 1 // 这两种写法是等价的 println(addOne(5)) println(addOne.apply(5))我第一次意识到原来函数值就是对象函数调用就是apply的时候之前很多困惑都解开了。比如为什么有的地方能直接把函数传给map、有的地方却要写_.something本质都在函数值和对象方法之间来回切换。当然这些底层细节你暂时不需要全记住但建议先有个印象Scala把函数提升为值不是靠魔法而是靠一套统一的类型模型。这个模型就是函数类型。语法上你只需要掌握两个动作创建函数函数字面量、调用函数括号传参。2.3 方法与函数的区别不说清楚早晚被坑def定义的是方法方法归属于对象或类val加函数字面量定义的是函数值。两者在语法上长得像行动起来会有微妙差异。def methodAddOne(x: Int): Int x 1 val funcAddOne: Int Int (x: Int) x 1 // 方法可以转成函数用 _ val funcFromMethod methodAddOne _日常写代码时你会发现集合API大多接受函数值而你自己写业务逻辑时习惯性写def。看起来没差但一旦你开始把方法当作参数传给高阶函数_的使用场景就来了。后面第4章会重点详解这里先记住一个结论方法不是值函数才是值方法需要提升/转换之后才能当值用。3. def定义函数的核心语法拆解3.1 一个完整函数的每个零件直接上一个典型的例子def calculateDiscount(price: Double, rate: Double 0.1): Double { val discount price * rate if (discount 100) 100 else discount }逐块拆开看def关键字表示定义一个方法。calculateDiscount方法名驼峰命名语义化。(price: Double, rate: Double 0.1)参数列表。注意每个参数都需要显式标注类型这是Scala和Java一致的地方但和Python不同——Scala不是一个纯靠运行时推断的语言它在编译期就需要知道类型。: Double返回值类型。这里标注了Double表示方法返回一个浮点数。赋值号。Scala的def语法里这个等号很重要它连接定义和函数体。{ ... }函数体。最后一行表达式的值就是整个函数的返回值。有几个细节值得单独拎出来讲第一函数体的最后一行就是返回值不需要写return。如果你以前写惯了Java脑子里的return反射弧需要掰一下。当然Scala也支持return但显式return在Scala里会被视为代码坏味道因为它破坏了表达式风格的纯粹性。你在函数式代码库里几乎看不到显式return。第二返回值类型在某些情况下可以省略让编译器帮你推断。但我要补一句方法定义的推荐做法是尽量标注返回值类型尤其是公开方法。原因很实际编译器推断有时候会给出你意料之外的类型比如Any提前标注可以强制自己把逻辑想清楚也让其他人读代码时一眼看到这个函数会给你什么。第三参数列表的每个参数都要标注类型。有人觉得啰嗦但这是Scala的一个明确设计取舍类型标注是方法签名的一部分是函数组合时能静态检查的基础。后面你会在case class、泛型、隐式转换里反复感受到类型标注的价值。3.2 参数默认值语法糖但确实好用爬虫项目里经常要写请求函数比如设置超时时间。大部分时候用同一个默认值偶尔才要单独调整这种场景参数默认值非常合适def fetchUrl(url: String, timeout: Int 3000, retries: Int 3): String { // 实际请求逻辑省略 sfetched $url with timeout$timeout } // 只传url其余用默认值 println(fetchUrl(http://example.com)) // 只改timeoutretries保持默认 println(fetchUrl(http://example.com, timeout 5000))第三行用了命名参数named argument的写法timeout 5000。既然参数有默认值你就可以只传自己关心的那几个而且不怕顺序错乱。Java没有这个能力通常只能写一堆重载方法——Scala用默认参数直接消掉了那一大坨重复代码。3.3 过程语法Scala 3以前的老写法读代码要认识在Scala 2里还有一种特殊情况如果函数体没有返回值即返回Unit类似Java的void可以省略把方法体直接写在花括号里// 这种写法叫过程procedure def logMessage(msg: String) { println(s[LOG] $msg) }这本质上是个历史遗留语法。Scala 2里这么写是合法的但Scala 3已经移除了这种写法。我的建议是你自己写代码永远不要省略统一写成def logMessage(msg: String): Unit { println(s[LOG] $msg) }为什么要统一因为省略时方法体没有任何返回值类型推断结果是Unit如果有人后来想在这个方法里加一个return某个值会直接编译报错而且报错信息不是特别直观。你平时读老代码时留意一下这种写法就好知道它等价于Unit方法。3.4 可变参数最后一个参数可以接收任意数量入参def sum(numbers: Int*): Int numbers.sum println(sum(1, 2, 3)) // 6 println(sum(1, 2, 3, 4, 5)) // 15注意Int*的写法星号放在类型后面表示零到多个Int。在函数体内部numbers会被当作Seq[Int]来用。一个常见的坑如果你想把一个已有的Seq展开传入可变参数直接传会编译报错因为类型不匹配。你需要用:_*来告诉编译器把这个序列展开成可变参数val list List(1, 2, 3) sum(list) // 编译错误 sum(list: _*) // 这样才对结果是6这个:_*符号我第一次见到时非常懵。后来看多了Scala代码才发现它在集合和可变参数互相转换的场景里到处都是。如果不理解这条语法你会在集合转可变参数时卡住。4. 函数字面量、Lambda写法与占位符语法4.1 从完整匿名函数到极致简写的完整渐变前面提到val addOne (x: Int) x 1。这种匿名函数/函数字面量在Scala代码里无处不在但它有非常多的写法变体缩写的程度不同适用范围也不同。完整写法val triple: Int Int (x: Int) x * 3省略参数类型如果类型可以从上下文推断val triple2: Int Int x x * 3多个参数val multiply: (Int, Int) Int (a, b) a * b展开成多行函数体val process: Int Int { x val y x 1 y * 2 }这个多行写法里函数体用花括号包裹参数列表和箭头放在开头。核心要点函数体的最后一行表达式是返回值。日常写集合操作时这些写法会在各种组合里出现。我的建议是一开始不要追求最短只要你能把完整版写对、读懂了缩写只是时间问题。但如果遇到实在看不懂的代码就回到完整版去套。4.2 占位符_和它的各种用法这个必须搞透Scala的_是出了名的多功能通配符在不同位置代表不同含义。它在函数字面量缩写里表示参数占位。先看一个最常见的场景。假如你是这么写的val doubled list.map(x x * 2)如果参数在函数体里只出现一次可以简写为val doubled list.map(_ * 2)这里_自动代表传入的那个参数_ * 2就是x x * 2的简写。换到两次使用就不行val sumPairs list.map((a, b) a b) // 正确 val sumPairs list.map(_ _) // 正确两个_分别代表第一个和第二个参数看明白了吗_ _实际上等价于(a, b) a b第一个下划线代表第一个参数第二个下划线代表第二个参数。每个下划线代表参数只用于那个位置。4.3 两个必须避开的占位符坑坑一参数使用两次时不能缩写// 想写一个x x * x的平方函数 val square list.map(x x * x) // 不能改成 _ * _ 以外的写法因为 _ 在同一个表达式里出现两次是不同参数 // 但 _ * _ 又是两个不同参数相乘不是平方所以同一个参数出现多次的lambda不要用_缩写老老实实写x x * x。这个错误编译器不会报错但结果完全不对非常隐蔽。坑二嵌套函数中_的解析陷阱val result list.map(_ 42)这个_ 42表示忽略参数直接返回42。下划线在这里是占位但不使用的意思相当于x 42。这种写法也合法但你不能写成list.map(42)因为没有参数位置的占位符编译器会认为42是一个Int而不是一个函数。我的个人经验是_非常适合单参数、单次使用、逻辑极简的场景一旦逻辑复杂立刻恢复成具名参数x ...的写法不要为了省几个字符牺牲可读性。Scala写多了你会发现代码美观与否很大程度上就取决于这几个缩写和不缩写之间的取舍。5. 高阶函数把函数当参数和返回值5.1 为什么需要高阶函数高阶函数Higher-Order Function的定义其实很直白入口参数里包含函数或者返回值是一个函数再或者两者都有。我记得有个朋友看完定义后问我我知道这个定义但我不理解为什么要这么设计直接把逻辑写在循环里不行吗这个问题问得好。我那天正好在写一段批处理代码就给他举了例子。假设你现在有一串订单价格需要对每个价格做调整普通写法定义三个方法每个方法里写一遍循环。def addTax(orders: List[Double]): List[Double] { for (price - orders) yield price * 1.1 } def addShipping(orders: List[Double]): List[Double] { for (price - orders) yield price 5 } def applyDiscount(orders: List[Double]): List[Double] { for (price - orders) yield price * 0.8 }三份几乎一样的循环模板只是循环体里的计算不同。这叫重复代码。高阶函数写法循环被抽象成一次变化的部分作为函数参数传入。def transform(orders: List[Double], f: Double Double): List[Double] { for (price - orders) yield f(price) } val taxed transform(prices, price price * 1.1) val shipped transform(prices, price price 5) val discounted transform(prices, price price * 0.8)“变化的那一行”从一份份复制粘贴变成入参传进来的函数。这就是高阶函数设计价值的核心——消除重复把控制结构抽象出来。5.2 返回函数的函数让函数生产函数一个函数返回另一个函数在Scala里也极其常见。最经典的场景是配置生成器。def buildGreeting(prefix: String): String String { (name: String) s$prefix, $name! } val casualGreeting buildGreeting(Hey) val formalGreeting buildGreeting(Dear) println(casualGreeting(Alice)) // Hey, Alice! println(formalGreeting(Bob)) // Dear, Bob!buildGreeting接收一个prefix返回一个接收名字、返回完整问候语的函数。casualGreeting和formalGreeting可以当作普通值保存到处传递。你用Python写闭包、用Java写Factory的时候都干过类似的事Scala只是把这个模式变成了语法内的常态操作。5.3 集合API是理解高阶函数的最好训练场Scala标准库的集合类List、Seq、Map等提供了大量现成的高阶函数。最常用的三个我单独列一下map对每个元素应用函数返回同长度新集合。filter保留满足条件函数返回true的元素。fold/reduce把元素两两组合累积成一个值。val nums List(1, 2, 3, 4, 5) val doubled nums.map(_ * 2) // List(2, 4, 6, 8, 10) val evens nums.filter(_ % 2 0) // List(2, 4) val sum nums.reduce(_ _) // 15 val productWithInitial nums.fold(1)(_ * _) // 120注意fold的语法fold(初始值)(二元函数)两个括号是分开的。这背后其实是下一章要讲的柯里化currying你只要先熟悉这个调用形式即可。这些集合API在Spark RDD里几乎是等价的映射关系rdd.map(...)、rdd.filter(...)、rdd.reduceByKey(...)。这也是为什么大数据框架偏爱Scala——集合算子的心智模型高度统一从本地集合切到分布式RDD思维的切换成本很低。6. 闭包与柯里化6.1 闭包函数能把外部变量带进去先看代码def makeCounter(step: Int): () Int { var count 0 () { count step count } } val counter1 makeCounter(1) val counter2 makeCounter(2) println(counter1()) // 1 println(counter1()) // 2 println(counter2()) // 2 println(counter2()) // 4这里有几个关键点第一函数() { count step; count }使用了外部变量count和step。其中step是makeCounter的参数count是makeCounter内部的局部变量。照常规理解方法返回后这些变量都应该销毁了但闭包把它们捕获并保留了下来。第二每次调用makeCounter(1)都会创建一份独立的count所以counter1和counter2互不干扰。第三count是var变量闭包捕获的是变量的引用不是生成时的值。这意味着闭包可以修改它并且多次调用之间状态会累积。你可以这样理解闭包函数本身携带了一个背包背包里装着它创建时所在环境作用域里的变量。哪怕函数被传递到任何地方这个背包都不会丢。6.2 柯里化把多个参数拆成多个参数列表普通函数def add(x: Int, y: Int): Int x y柯里化写法def curriedAdd(x: Int)(y: Int): Int x y调用方式的变化才是重点普通调用add(1, 2)柯里化调用curriedAdd(1)(2)部分应用val addOne curriedAdd(1) _先只传第一个参数得到一个余下参数的新函数为什么有这个设计直接说一个最有用的场景像fold那样让配置参数和数据参数分开。比如一个HTTP请求函数def request(method: String)(url: String): Unit { println(s[$method] $url) } // 先固定method生成两个专用函数 val getRequest request(GET) _ val postRequest request(POST) _ getRequest(http://example.com/api) postRequest(http://example.com/api)你还可以把这个模式用在接收上下文的代码里——第一个参数列表放配置/上下文第二个参数列表放具体业务数据。这样一段业务逻辑可以很容易地被复用、降级、替换。6.3 偏应用函数不用一次性传完所有参数和柯里化紧密相关的一个概念是偏应用函数Partially Applied Function。它可以不改变原函数定义在调用时只提供部分参数得到一个新函数。def power(base: Double, exponent: Double): Double Math.pow(base, exponent) val cube power(_: Double, 3) // 固定exponent3base留空 println(cube(2.0)) // 8.0注意这里的_: Double表示留一个坑等后面填。这个写法其实用到了占位符语法——它把power变成一个接收基数的单参函数。它和柯里化有点像但不要求原函数以多参数列表定义。6.4 柯里化和普通多参函数的取舍我个人的建议是不要刻意到处用柯里化很多场景普通参数列表就够了。柯里化的价值集中在两类场景一是构造先固定某组配置、再接收业务参数的模板函数二是设计类型系统相关的隐式参数那个在后续内容里会单独讲。函数基础阶段你只需要做到看得懂、会用不需要每段代码都写成柯里化风格。7. 递归与尾递归优化7.1 函数式编程的循环就是递归如果你是从命令式编程Java、C转过来的可能习惯性地用while或者for循环来写重复计算。Scala当然支持这些但在函数式风格里循环往往被递归替代因为纯函数风格强调不修改外部状态、每次调用产生新值递归天然契合这种模式。举一个最经典的阶乘递归def factorial(n: Int): Int { if (n 1) 1 else n * factorial(n - 1) }这个函数逻辑上是正确的但对于大n存在性能隐患每次递归调用都会占用一个新的栈帧n很大的时候很容易栈溢出。7.2 尾递归优化让递归像循环一样省栈尾递归tail recursion是一种特殊的递归形态递归调用必须发生在函数的最后一环即返回值的位置并且调用后不再有任何额外的运算。编译器可以把这种递归优化成循环不消耗额外栈空间。拿阶乘改写成尾递归def factorialTailRec(n: Int): Int { annotation.tailrec def loop(acc: Int, remaining: Int): Int { if (remaining 1) acc else loop(acc * remaining, remaining - 1) } loop(1, n) }重点分析内部定义了一个辅助递归函数loop参数acc累积结果和remaining待计算值。loop(acc * remaining, remaining - 1)是递归调用它处于函数的尾部位置——调用完直接返回没有额外乘一个n之类的操作。annotation.tailrec注解是推荐写法。它的作用是让编译器帮你检查这到底是不是尾递归如果是编译成循环如果不是直接编译报错。别小看这个注解它把自己会不会栈溢出这个问题从运行时提前到了编译期。7.3 如何识别并改写尾递归识别思路很简单递归调用之后还有没有其他运算// 非尾递归 def sum(list: List[Int]): Int list match { case Nil 0 case head :: tail head sum(tail) // 递归之后还要再执行head ... } // 尾递归写法 def sumTail(list: List[Int]): Int { annotation.tailrec def loop(acc: Int, remaining: List[Int]): Int remaining match { case Nil acc case head :: tail loop(acc head, tail) // 递归调用本身就是整个返回值 } loop(0, list) }非尾递归版本的调用形式是head sum(tail)——编译器需要先算出sum(tail)再回到当前栈帧做加法所以每个栈帧都还活着。尾递归版本的loop(acc head, tail)把加法往里传当前栈帧可以直接退场新调用复用它的位置。这就是为什么尾递归可以做到和while循环一样省资源。7.4 别忘了Scala集合API里现成的递归很多递归场景不用你手写。比如求和、取最大值、折叠集合库已经把递归尾递归封装好了val nums List(1, 2, 3, 4, 5) println(nums.sum) // 15 println(nums.max) // 5 println(nums.foldLeft(0)(_ _)) // 15foldLeft就是典型的尾递归实现的折叠操作。你先别急着什么都自己写先把集合API的底牌摸清楚能少写很多代码。8. 函数基础在真实项目里的常见踩坑与经验8.1 Scala版本差异导致的语法变化Scala 2和Scala 3在函数语法上存在差异读老代码时特别容易疑惑。Scala 3里过程语法省略的写法已移除。字符串插值s...在Scala 2和3里都可正常用。Scala 3里引入了新的given/using机制替代Scala 2的implicit参数后续学隐式转换时注意辨别。部分集合方法的命名和参数顺序有调整比如.toList、.toSeq的兼容写法。我的建议新项目用Scala 3读老项目代码时先确认它的Scala版本。很多网上资料尤其是五六年以前、以Spark为主的文章默认是Scala 2语法细节会略有不同。8.2 显式类型标注与编译推断的边界Scala的类型推断能力很强写val x List(1,2,3)时不需要手动标注类型。但函数参数的类型必须标注除非有明确的期望类型上下文返回值尽量标注。这个边界在团队协作中尤其重要。有个反面案例我见过有人写def getData() { // 很长的逻辑 }没有标注返回类型。后来代码加了异常处理逻辑返回类型从List[String]变成了Any编译器没有报错但因为类型变模糊了下游代码直接调用.map时报错。排查了大半天。所以说公开方法的返回类型不要偷懒不写。8.3_使用过度带来的可维护性问题很多Scala新手从_的甜头里获得了我写得好短的快感于是到处缩写list.map(_.split(,)).filter(_.length 3).map(_(0))这段代码编译没问题但读起来极其费劲每个_都不一样你得逐个推断它到底代表什么。更麻烦的是合并之后的语义很难一眼看出来。我的经验是同一行代码里_最多出现两次再多就老老实实写具名lambda。你写在博客里没问题写进团队代码库就要考虑别人维护时的血压。8.4 高阶函数和Spark RDD的联动搜索热词里频繁出现rdd的创建scala语言这类关键词说明很多人学Scala就是为了上手Spark。这里我提前打个底RDD的转换算子和集合API几乎完全同构// 本地集合 val localResult List(1, 2, 3).map(_ * 2).filter(_ 2) // RDD import org.apache.spark.rdd.RDD val rddResult: RDD[Int] rdd.map(_ * 2).filter(_ 2)如果你把本地集合的map、filter、foldLeft这些掌握扎实切到RDD时除了要知道分布式计算和懒执行这些新概念代码写作范式是平滑迁移的。反过来如果你直接跳到RDD却连Scala函数基础都没有大概率会被各种高阶函数的组合拳劝退。8.5 闭包在并行和分布式环境下的注意点Scala里闭包很方便但在Spark这种分布式计算框架中闭包捕获的外部变量会被序列化后分发到各个executor上。这个过程中需要保证闭包引用的变量是可以序列化的否则运行时直接报NotSerializableException。函数基础阶段建议先建立这个意识闭包不是万能的捕获外部状态时要想清楚这个状态会跟着函数跑去哪里。后面写Spark作业时会反复面对这个问题。9. 复盘函数基础学习路线建议文章讲到这里我把完整脉络帮你收拢一下方便对照查漏补缺。先建立函数即值的认知值可以存变量、传参、返回函数也行。掌握def完整语法参数类型标注、返回类型标注、函数体最后一行作为返回值。搞定匿名函数/函数字面量完整写法、类型推导写法、多行写法。吃透_占位符的各种用法和限制这是Scala代码阅读速度的分水岭。熟练使用高阶函数自定义一个接收函数参数的函数再用好集合API里现成的map、filter、fold。理解闭包函数捕获外部变量的机制以及它带来的内存和状态语义。掌握尾递归会写、会用tailrec验证、能和非尾递归做区分。时刻记得你在为后续读源码打基础。无论是Spark源码、集合库源码还是各种开源框架函数式风格都贯穿始终。最后一句话心得我见过太多人学Scala时急于上手大数据直接用Scala写Spark一旦遇到复杂逻辑就卡在这语法什么鬼上。如果真心想在这条路上走远函数基础这一课值得花两周砸实。老话说磨刀不误砍柴工放在这里完全成立——这把刀磨好了后面的一切都锋利。