
1. 环境准备与一个能跑起来的函数1.1 Scala版本与构建工具选择学Scala函数基础之前我建议先把环境弄顺不然写出来的代码没法第一时间跑学起来很别扭。目前主流的Scala版本是2.13.x社区里大量开源项目、Spark、Kafka相关生态都还在2.12/2.13这条线上Scala 3虽然已经很成熟但如果你是为了工作项目、大数据生态直接学2.13更稳妥网上搜“scala安装教程”能搜到的大多数案例也是基于2.13。我的建议是本地装一个JDK 8或JDK 11再装Scala 2.13.14配合sbt作为构建工具基本够用。不想折腾命令行的话直接装IntelliJ IDEA社区版插件里搜Scala新建项目时选择sbt或IDEA自带的Scala工程几十秒就能跑起来。这里有个容易忽略的坑如果电脑上同时有多个JDK注意在IDEA的Project Structure里把Project SDK指到你要用的JDK不然sbt经常报UnsupportedClassVersionError很烦。1.2 最小可运行函数写法与入口先看一个最简单的函数例子object Main { def main(args: Array[String]): Unit { println(add(2, 3)) } def add(a: Int, b: Int): Int { a b } }这里的add就是一个函数。Scala里函数可以定义在object里也可以定义在class里还可以像普通变量一样直接定义在顶层Scala 3里可以2.13通常要包在object里。运行main方法时add(2, 3)会被调用结果是5。很多初学者会困惑为什么a b没有写return因为Scala是表达式导向的语言函数体最后一个表达式的值就是函数的返回值。return在Scala里其实不推荐用尤其在高阶函数和闭包场景中显式return会影响类型推断和函数式语义我见过不少人从Java转过来后到处写return代码反而更容易出bug。1.3 从Java视角理解Scala方法如果你是从Java转过来的可以把Scala的def想象成Java的方法声明只是把public static之类的修饰符换成了更简洁的写法。Java的public int add(int a, int b) { return a b; }在Scala里可以写成def add(a: Int, b: Int): Int a b差别看起来只是语法糖但背后的思维模式不一样。Java强调“方法属于类”Scala更强调“函数本身就是值”。后面我会详细讲函数字面量一旦你理解“函数也能赋值给变量”Scala的函数式风格才算真正打开。2. 函数定义与调用背后的设计逻辑2.1 方法与函数字面量的区别Scala里有两类东西长得很像方法和函数。方法用def定义函数字面量用val 函数名 (参数) 函数体定义。看个例子def multiplyMethod(x: Int, y: Int): Int x * y val multiplyFunc: (Int, Int) Int (x, y) x * ymultiplyMethod是方法multiplyFunc是函数。方法不能脱离对象或类单独传递而函数本身就是一个对象可以像Int、String一样被赋值、被传递。当你写val f multiplyMethod _时实际上是把方法“提成”了一个函数值。这个区别在写高阶函数时特别明显。比如List(1,2,3).map(multiplyMethod _)如果你直接写map(multiplyMethod)很多场景Scala也能自动转换成函数但初学阶段最好先理解“方法需要被提升为函数才能当值传递”这个过程后面看代码就不会觉得莫名其妙。2.2 表达式风格与返回值推断Scala函数的返回值类型其实可以省略。比如def add2(a: Int, b: Int) a b编译器能推断出返回值是Int。但如果函数体有递归调用或者你故意想让API更清晰最好还是写明返回值类型。有个很常见的坑如果函数体里既有Int又有String编译器推断出来的返回值类型可能是Any你自己不察觉后面调用时才发现类型不对。我个人建议公开方法一定写返回类型私有方法可以省略。这不是强迫症而是写库或者团队协作时显式的类型签名本身就是文档。Scala的隐式推断确实省事但隐式推断出的Any或者Nothing有时候会掩盖代码问题。实际写的时候还要区分“表达式块”和“过程”。比如def sayHello(): Unit { println(hello) }返回类型是Unit相当于Java的void。如果一个函数的最后一行的值是Unit那么它返回的就是Unit。这个设计让Scala的每个函数都像表达式统一了“有值”和“无值”两种情况写泛型或高阶函数时不用特别区分。2.3 参数的各种姿势默认参数、命名参数、可变参数Scala的函数参数设计挺贴心的。默认参数省去了Java里一堆重载方法。比如def connect(host: String localhost, port: Int 3306): Unit { println(sconnecting $host:$port) } connect() connect(10.0.0.1) connect(10.0.0.1, 5432) connect(port 3307)命名参数调用connect(port 3307)在参数很多时非常好用尤其那些配置类函数动辄五六个参数Java只能靠BuilderScala直接命名参数搞定。但它也有坑默认参数和命名参数混用时如果你调整了参数顺序很容易传错。比如connect(port 3307, host abc)没问题但如果漏掉参数名编译器按位置匹配可能报类型不匹配才发现。可变参数用*表示def sum(nums: Int*): Int nums.sum调用时sum(1,2,3,4)直接得到10。可变参数在拼接路径、聚合统计场景很实用。注意nums在函数内部其实是一个Seq[Int]所以可以调用sum、map等集合方法这一点和Java的数组不太一样。3. 函数作为一等公民传函数、高阶函数与闭包3.1 把函数当参数传函数作为一等公民意味着你可以把“行为”作为参数传递。以前Java只能用策略模式、匿名内部类绕一大圈Scala直接传递一个函数。看最常见的mapval prices List(100.0, 200.0, 300.0) val discounted prices.map(price price * 0.8)这里price price * 0.8就是一个函数字面量它作为参数传给了map。map接收一个A B的函数对列表里的每个元素执行这个函数返回新列表。这种写法的优势是把“如何变化”从“遍历逻辑”里抽离出来代码更短、意图更清晰。再比如filterval adults users.filter(user user.age 18)你不用写for循环不用管索引甚至不用创建一个空的ListBuffer再append。函数式集合操作让数据处理变得像流水线filter筛、map转、reduce聚合每一段都是独立的行为函数。3.2 匿名函数与占位符语法匿名函数也叫Lambda表达式基本写法是(参数) 表达式。类型可以省略编译器会根据上下文推断val nums List(1, 2, 3, 4, 5) nums.map(x x * 2) nums.map(_ * 2)_ * 2是占位符语法表示“把参数放到这个位置”。_用起来很爽但前提是参数只出现一次而且编译器能推断出类型。如果一个函数有两个参数比如nums.reduce((a, b) a b)可以写成nums.reduce(_ _)这里两个_分别代表第一个和第二个参数。这个写法简洁但可读性不一定好尤其是参数顺序容易搞混。我自己的建议是简单表达式可以用_复杂函数体还是写全参数名不然过两周你自己都看不懂那个_代表什么。这里还有个小技巧如果函数体只有一句话可以把大括号嵌套写成nums.map { x val y x * 2 y 1 }这种写法在DSL风格的代码里很常见本质上是把函数体变成了一个代码块Last Expression作为返回值。3.3 闭包的实现直觉闭包是指一个函数引用了定义在函数外部的变量。比如val factor 3 val multiplyByFactor (x: Int) x * factor println(multiplyByFactor(4)) // 12这里multiplyByFactor捕获了外部的factor即使factor之后重新赋值函数内看到的可能是当前值或最终值取决于具体版本和捕获语义。在2.13里如果一个var被闭包捕获实际是封装到一个IntRef对象里所以闭包能“感知”变量的最新值如果是val直接捕获值的副本。理解闭包的实际用途在多线程编程或者Spark算子中你经常需要在某个函数里引用外部变量。如果你捕获的是一个可变集合或一个类对象要特别小心线程安全因为闭包对这个对象的修改会跨越任务边界。比如在Spark的map里用了外部数组这个数组会在Driver端序列化后发到Executor端闭包捕获的是序列化后的副本而不是共享引用——这点不搞清楚写分布式任务时会出现“为什么Driver改了Executor没变”的诡异问题。3.4 实操案例用高阶函数替代循环很多Scala新手会习惯性写for循环其实大部分循环都能用高阶函数替代。比如我要把一组成绩做处理过滤掉小于60的然后每个加5分平时分最后求平均。用命令式写法val scores List(55, 70, 88, 43, 92) var sum 0.0 var count 0 for (score - scores) { if (score 60) { val adjusted score 5 sum adjusted count 1 } } val avg if (count 0) 0 else sum / count用函数式写法val avg scores .filter(_ 60) .map(_ 5) .foldLeft(0.0)((acc, x) acc x) / scores.count(_ 60)甚至更优雅一点val adjusted scores.filter(_ 60).map(_ 5) val avg2 adjusted.sum.toDouble / adjusted.length对比之下命令式关注“怎么一步一步做”函数式关注“要对数据做什么转变”。这不只是代码长短的问题而是工程上的可维护性函数式代码更容易测试因为每个函数块都可以单独验证也更容易并行化因为map、filter天然没有共享可变状态。4. 递归、尾递归与Stack安全4.1 递归怎么写递归是函数式编程里替代循环的重要工具。一个经典的阶乘def factorial(n: Int): Int { if (n 1) 1 else n * factorial(n - 1) }这个写法很直观但有个问题如果n很大递归调用会一直压栈最终抛StackOverflowError。我在本机测试时默认栈大小下递归几万次基本就崩了。所以实际项目中直接使用无优化的递归要非常谨慎通常只用于树形结构、目录遍历等天然有层级、深度可控的场景。4.2 tailrec与尾递归优化原理解决办法是尾递归优化。所谓“尾递归”指递归调用是函数体的最后一个动作并且递归调用结果直接返回不再参与额外运算。上面的阶乘不是尾递归因为n * factorial(n - 1)在递归返回后还要做乘法。改成尾递归import scala.annotation.tailrec def factorialTail(n: Int): Int { tailrec def loop(acc: Int, n: Int): Int { if (n 1) acc else loop(acc * n, n - 1) } loop(1, n) }这里loop的递归调用直接返回不依赖外层结果。编译器检测到这是尾递归后会把它编译成等价于while循环的字节码复用同一栈帧栈不会再增长。加上tailrec注解后如果函数不是尾递归编译器会直接报错等于提前发现隐患。这背后其实是编译器的一个相对简单的优化把“递归调用”替换成“跳转到函数的入口”参数换成新一轮的值。所以你也别把它想得多玄本质就是帮你在安全前提下用递归写出循环的效率。4.3 用递归实现电影推荐里的相关性累加热搜里反复出现“电影推荐系统scala”我就拿一个推荐场景的简化例子来讲。假设你要计算用户对某部电影的“综合评分”其中用户画像相似度需要考虑多层相似用户的扩散评分。用递归可以这样抽象case class User(id: Int, rating: Double) def propagateScore(users: List[User], trustMap: Map[Int, List[(User, Double)]], targetId: Int, depth: Int, visited: Set[Int]): Double { if (depth 0 || visited.contains(targetId)) 0.0 else { val directScore users.find(_.id targetId).map(_.rating).getOrElse(0.0) val neighbors trustMap.getOrElse(targetId, Nil) val neighborScore neighbors.collect { case (user, weight) if !visited.contains(user.id) weight * propagateScore(users, trustMap, user.id, depth - 1, visited targetId) }.sum directScore neighborScore } }这里用了递归做相似用户的加权传播深度加深时风险就是栈溢出。如果评分网络的直径很大建议改成尾递归加显式栈或者用foldLeft方式维护一个待处理队列避免深递归。这个案例不是让你直接用而是理解递归在真实系统里的应用边界慢、深、可控就行但别拿它处理百万级节点那是图计算引擎的事。5. 柯里化、部分应用与SAM5.1 柯里化柯里化是指把多个参数的函数转换成一系列单参数函数。Scala里定义柯里化函数有两种常见姿势def addCurried(a: Int)(b: Int): Int a b // 或者 def addCurried2(a: Int): Int Int b a b调用时addCurried(2)(3)结果是5。你还可以只传第一个参数得到一个Int Int的函数然后后续复用。比如val addTwo addCurried(2) println(addTwo(10)) // 12柯里化的价值主要在于把“配置参数”和“业务参数”分开。比如一个数据库查询函数可以先传递连接配置返回一个只接收查询条件的函数用起来非常顺手。5.2 部分应用函数和柯里化相近的一个概念是部分应用函数。它指的是固定住一个多参数函数的部分参数得到一个新函数。比如def log(level: String, message: String): Unit { println(s[$level] $message) } val infoLog log(INFO, _: String) infoLog(user logged in)log(INFO, _: String)把第一个参数固定为INFO第二个参数留空生成一个新函数。部分应用和柯里化的区别柯里化是人为把多参函数拆成一串单参函数部分应用是对已有函数选定部分实参。实际项目里部分应用常用来从通用函数派生出特定场景函数减少重复传参。需要注意的是使用占位符语法时如果函数参数特别多_: String这种写容易漏类型。建议在上下文足够清晰时用否则还是写成(msg: String) log(INFO, msg)更保险。5.3 SAM与Java互操作Scala可以和Java无缝互调。Java里常见的函数式接口比如Runnable、Callable、ComparatorScala 2.12以上支持SAM转换。所谓SAM就是Single Abstract Method只有一个抽象方法的接口。比如Java的RunnableRunnable task () - System.out.println(run);在Scala里你可以直接val task: Runnable () println(run) new Thread(task).start()编译器能自动把Scala函数字面量转成Java的SAM接口。这个特性对写Spark、Kafka代码很重要因为很多API会接收Java函数式接口或Scala函数类型不了解SAM转换的话你会经常纠结“这里该传函数还是该传对象”。边写边报错最烦的是IDE提示不明确明明代码看起来没问题就是编译不过。遇到这类情况优先检查是不是需要显式声明SAM接口类型比如写成new Runnable { def run(): Unit ... }的匿名类写法虽然啰嗦但绝对安全。6. 函数式思维落地一个电影推荐场景的小案例6.1 case class定义数据结合热搜里的“电影推荐系统scala”我们用函数基础的知识快速构建一个极简推荐打分的函数式数据流。先定义数据结构case class Movie(id: Int, title: String, genres: List[String], rating: Double) case class UserPref(genre: String, weight: Double) val movies List( Movie(1, 星际穿越, List(科幻, 冒险), 8.6), Movie(2, 盗梦空间, List(科幻, 悬疑), 9.0), Movie(3, 疯狂动物城, List(动画, 冒险), 9.2), Movie(4, 爱乐之城, List(爱情, 歌舞), 8.4) ) val prefs List( UserPref(科幻, 0.9), UserPref(冒险, 0.4) )case class和普通class的区别之一是自动生成equals、hashCode、toString和copy方法非常适合做不可变数据模型。在函数式代码里我们倾向于把数据和行为分离数据类尽量只承载字段行为由函数去操作。6.2 用函数组合实现评分预测现在给用户推荐电影按用户偏好类型权重为每部电影打一个推荐分。传统做法是循环嵌套。函数式做法是数据流管道def matchScore(movie: Movie, prefs: List[UserPref]): Double { movie.genres.map { genre prefs.find(_.genre genre).map(_.weight).getOrElse(0.0) }.sum * movie.rating } val ranked movies .map(movie (movie, matchScore(movie, prefs))) .sortBy(-_._2) ranked.foreach { case (movie, score) println(s${movie.title}: $score) }这段代码里用到了map、find、sortBy也涉及模式匹配case (movie, score)。这里没有用一个var没有写循环完全靠函数组合完成。这个例子看起来简单但思路可以延伸到真实推荐系统的打分链路召回、过滤、得分、排序每步都可以用一个纯函数表达阶段之间传递不可变集合。6.3 我在实际项目里踩过的几个坑讲几个我实际遇到的坑可能对初学的人很有用。第一不可变集合不等于不可变数据。List本身不可变但你map时返回的结果如果被放到一个var里本质上还是在做可变操作。函数式风格不是禁止var而是尽量缩小可变状态范围。我自己写代码的原则是方法内部可以用局部var跨方法传递时尽量用不可变集合。第二占位符语法在复杂表达式中容易翻车。比如movies.map(_.genres.map(_.length).sum * _.rating)这种写法编译器根本不知道每个_属于哪层直接报错。解决办法是至少给最外层的参数命名movies.map(m m.genres.map(g g.length).sum * m.rating)第三类型推断在getOrElse里有时会推断出Any。比如prefs.find(_.genre 科幻).map(_.weight).getOrElse(0)如果prefs是空列表或者找不到对应类型返回值可能和预期不一致。虽然这里没问题但如果你写getOrElse(null)就会把Null引入类型系统非常不值得。宁可写getOrElse(0.0)也别图方便写null。第四把Java的习惯带到Scala里到处写return。这在函数式管道里特别危险因为return在lambda中会抛出非局部返回异常虽然后来Scala支持用scala.util.control.NonLocalReturns来实现类似效果但日常代码里我强烈建议丢掉显式return。记住最后一个表达式就是返回值强制用return打断容易让高阶函数的类型推断混乱。6.4 函数基础的下一步建议函数基础掌握到能写高阶函数、闭包、递归、柯里化、部分应用以后建议立刻进入集合操作实战List、Map、Option、Either把这些函数式操作铺开去处理真实数据。再往后就是for推导式、Typeclass、隐式转换、Akka或Spark的编程模型。函数基础相当于Scala世界的语法肌肉肌肉不练扎实后面跑框架会很吃力。如果你正打算用Scala做电影推荐系统或者别的数据处理应用先花一个星期写纯函数式的小作业比如把CSV读取、过滤、聚合、排序全部用map/flatMap/foldLeft实现坚持不写for不是否定for而是刻意练习函数组合等回头再写业务代码思路会完全不一样。最后说一个小技巧如果你被某个Scala函数式类型绕晕了直接在IDEA里按住CtrlShiftP查询表达式类型或者用:type命令在REPL里测试。类型就是Scala的地图看懂类型函数基础基本就稳了一半。我自己当年就是从反复查类型开始才逐渐把A B这种抽象落到实处的。