简介:这份Scala升级版学习文档面向具备一定Java基础、准备进入大数据开发方向的初学者与转型开发者,重点解决Scala语法入门、开发环境搭建与面向对象、函数式编程思维转换的问题。文档从学习目标与建议切入,系统梳理Scala与JVM的关系、Scala 2.10与2.11在Spark 1.6及2.0以上版本中的对应关系,以及Java与Scala混编、类型推测、并发与分布式、特质、模式匹配、高阶函数六项语言特征。环境部分覆盖Windows下安装JDK 1.8与scala-2.11.8.msi、配置SCALA_HOME与Path变量、Linux直接解压,以及Eclipse Scala插件、Scala IDE和IntelliJ IDEA插件的配置流程。语法部分讲解数据类型、var与val声明常量的区别、if表达式、类与object单例对象、apply方法、伴生类与伴生对象,并配有可直接运行的示例代码。压缩包内为1个docx文档,约2.04MB,结构按学习目标、安装配置、编程基础递进编排,便于边读边敲、对照实践。目前已有2066人学习下载,适合作为Spark学习前的语言铺垫材料。
1. 从一份 Scala 学习文档说起:先搞清楚这套东西能带你走到哪
接手一个跑在 Spark 上的离线任务,日志里的堆栈层层嵌套着=>、_和模式匹配的 case 分支,Java 那套断点调试经验在这儿基本失效——这是很多人第一次认真考虑学 Scala 的场景。Scala-升级版.docx这份资源针对的正是这个卡点:它从装 JDK、配 IDEA 插件开始,一路把val/var、类型推测、class与object、伴生对象、to/until区间、yield、lazy val,一直讲到方法、匿名函数、偏应用函数和高阶函数,每个点都配了能直接粘进编辑器跑起来的例子。它适合两类人:要读 Spark 源码或写算子、被 Java 到 Scala 的语法断层卡住的工程师,以及刚接触大数据栈、想先把语言地基打牢的学习者。它不是 API 手册,更像一份带注释的手敲清单——下面按我自己复现的顺序拆一遍,重点讲参数怎么设、报错怎么看。
2. 环境搭到能跑:JDK 1.8 与 Scala 2.11.8 的版本对齐和 IDEA 插件配置
2.1 为什么版本对齐比“装最新版”更重要
Scala 的二进制兼容性是以大版本为单位的:2.10 编译出来的 class 文件,2.11 的运行时并不保证能直接吃下去。这条规则直接决定了你该装哪个版本,而不是装最新的那个。资源正文里给了一条很关键的对应关系——Spark 1.6 用的是 Scala 2.10,Spark 2.0 以上用的是 Scala 2.11。所以当你的集群还是老版本 Spark,本地却兴冲冲装了 2.13,编译期可能看不出问题,运行期就会出现NoSuchMethodError或者MissingRequirementError,这类报错信息往往指向 Scala 内部类,看上去完全和你写的业务代码无关,这就是很多人嘴里说的“玄学问题”。
| 运行环境 | 依赖的 Scala 版本 | 资源里的选择 |
|---|---|---|
| Spark 1.6 及更早 | 2.10 | 不推荐作为新环境起点 |
| Spark 2.0 及以上 | 2.11 | 对应 scala-2.11.8 |
| JDK | 1.8 | 与 Scala 2.11 组合最稳 |
资源正文选的是scala-2.11.8,配套 JDK 1.8,这是一个经过大量现网验证的组合。我一般会先确认机器上的 JDK 版本再动手,因为 Scala 编译器和运行时都要在 JVM 上跑,JDK 版本不对,后面所有步骤都白搭。JDK 1.8 在这个组合里属于“不需要动脑”的选项,2.11 时代的生态基本都围绕它构建。
提示:如果你的目标是把这份文档当 Spark 开发入门,装版本前先去确认集群上 Spark 的具体版本,再反推 Scala 版本,顺序反过来会多走很多弯路。
2.2 Windows 与 Linux 两条安装路径的实操步骤
Windows 上的路线是“先 JDK、后 Scala”。装完 JDK 1.8 之后运行scala-2.11.8.msi,安装程序会自动帮你写好SCALA_HOME环境变量,但 Path 往往还需要手动补一下。资源里给的追加内容是;%SCALA_HOME%\bin;%SCALA_HOME%\jre\bin,这两段都要有:bin是scala、scalac这些命令所在目录,jre\bin是 Scala 自带的一小套运行环境。改完环境变量必须重新开一个命令行窗口,老的 cmd 进程不会重新读取环境变量,这是新手最容易忽略的一步。
# 1. 先确认 JDK 装好没 java -version # 期望看到 java version "1.8.0_xxx" # 2. 确认 SCALA_HOME 是否已经被 msi 写入 echo %SCALA_HOME% # 期望输出类似 C:\Program Files (x86)\scala # 3. 校验 scala 命令是否可用 scala -version # 期望输出 Scala code runner version 2.11.8 -- Copyright ...这三条命令的检查顺序不能乱。java -version决定 JVM 这一层是否 OK;echo %SCALA_HOME%用来判断安装器有没有把变量写进去,如果没有就手动新建;scala -version才是最终验收。如果第三条报“不是内部或外部命令”,说明 Path 里没有%SCALA_HOME%\bin,或者当前窗口是改环境变量之前打开的。
Linux 侧就简单得多,解压即用,不需要安装器,但要自己写环境变量。常见做法是把包解压到/opt或用户目录下,然后在/etc/profile或~/.bashrc里追加两行。
# 解压到 /opt 下 tar -zxvf scala-2.11.8.tgz -C /opt/ # 写入环境变量(追加到 /etc/profile) export SCALA_HOME=/opt/scala-2.11.8 export PATH=$SCALA_HOME/bin:$PATH # 让当前 shell 生效 source /etc/profile scala -versionSCALA_HOME指向解压出来的根目录,PATH里把bin放前面,避免和系统里其他同名命令冲突。source这一步只对当前会话生效,换一个终端要重新执行,或者干脆退出登录再进。如果scala -version输出了 2.11.8 但scalac找不到,说明bin目录下确实没有编译器脚本,多半是解压不完整,重新解压一遍比查原因快。
资源正文里还提到 Eclipse 插件和 Scala IDE 两条 IDE 路线,但明确标注了“不建议”。这个判断是有道理的:Eclipse 的 Scala 插件必须严格对应 Eclipse 大版本,版本错配的表现是插件装上了、项目建出来了、编译按钮点下去没反应或者整页报红,排查成本比直接换工具高得多。既然 IDEA 的插件生态已经足够成熟,这条路可以跳过。
2.3 IDEA 里配 Scala SDK 与第一个可运行工程
IDEA 的配置分三步,顺序是“装插件 → 配 JDK → 配 Scala SDK”,任何一步跳过去都会在后面卡住。第一步,关掉当前项目,在欢迎界面的 Configure → Plugins 里搜Scala,点击 Install,装完必须重启 IDE,插件才会注册语言支持。第二步,打开 Project Structure,在 SDKs 里点加号新增一个 JDK,指向本机 JDK 1.8 的安装路径。第三步才是关键:新建 Scala 项目时选择 Scala 模块类型,弹出 SDK 选择框后点 Browse,指向本机 Scala 的安装目录,让 IDEA 读出scala-library.jar和编译器 jar。
object HelloScala { def main(args: Array[String]): Unit = { // 方法体最后一行即返回值,Unit 表示不返回有意义的结果 println("scala version: " + scala.util.Properties.versionNumberString) println("jvm: " + System.getProperty("java.version")) } }这段代码要验证的是两件事:scala.util.Properties.versionNumberString打出来是不是 2.11.8,java.version打出来是不是 1.8.x。两个都对,说明 SDK 配对了、JDK 也接对了。运行配置里 Main class 填HelloScala,注意 Scala 的入口不是类名带$,IDEA 会自动补全。如果代码里scala前缀一片红、没有语法高亮,九成是只装了插件没配 SDK,或者 SDK 指到了 JDK 目录上。这种时候别急着重装,先把 Project Structure 里 Modules 的依赖树展开看一眼,确认scala-library到底挂上没有。
3. 变量与控制流:val/var 的不可变约束、to/until 区间和 yield 收集
3.1 val 与 var:不可变声明和类型推测的边界
资源里反复强调一条原则:Scala 极简,能省的类型声明都省掉。val定义不可重新赋值的绑定,语义上接近 Java 的final;var定义可变的变量。这个区别不是风格问题,而是后续所有集合操作、并发写法的基础——你在一个val上做的任何“修改”,实际都是产生了一个新值。
val name = "a" // 推测为 String var counter = 0 // 推测为 Int,可重新赋值 // name = "b" // 编译错误:reassignment to val val x = 1 val y = if (x > 0) 1 else -1 // 推测为 Int val mixed = if (x > 0) 1 else "jay" // 推测为 Any,公共父类型 val noElse = if (x > 5) 1 // 缺 else,推测为 Anymixed这一行是理解类型推测的入口。Int和String没有比Any更近的共同父类,编译器只能把结果类型定成Any,这时候你拿到的值再做算术就得强转。noElse更隐蔽:if缺else时等价于补一个(),也就是 Unit,和Int的公共父类还是Any。很多人写条件赋值时觉得“反正只有一个分支会走”,结果拿到Any去做+ 1直接编译不过,这就是根因。我一般遇到条件表达式参与后续计算的场景,宁可把else补全,也别让编译器去猜。
var在 Scala 里不是禁忌,但资源里的例子几乎都在用val,这个倾向值得跟。循环计数器确实需要可变状态,但后面用yield收集结果时可以完全避开var。
3.2 to / until / Range:闭开区间与步长
这三个东西是 Scala 循环的地基,也是从 Java 转过来最容易记混的地方。资源里给了很明确的对照:to左右都包含,until左闭右开,Range和until一样是左闭右开,且都能带步长。三者的底层实现都是scala.collection.immutable.Range,区别只是构造函数参数的包装方式。
| 写法 | 生成的区间 | 是否包含终点 | 步长 |
|---|---|---|---|
1 to 10 | 1,2,3,…,10 | 包含 | 1 |
1 to (10, 2) | 1,3,5,7,9 | 包含(未越界时) | 2 |
1 until 10 | 1,2,…,9 | 不包含 | 1 |
1 until (10, 3) | 1,4,7 | 不包含 | 3 |
Range(1, 10, 2) | 1,3,5,7,9 | 不包含 10 | 2 |
Range(10, 0, -2) | 10,8,6,4,2 | 不包含 0 | -2 |
println(1 to 10) // Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) println(1.to(10)) // 与上面等价,中缀写法就是方法调用 println(1 to (10, 2)) // Range(1, 3, 5, 7, 9) println(1 until 10) // Range(1, 2, ..., 9) println(1 until (10, 3)) // Range(1, 4, 7) println(Range(10, 0, -2)) // Range(10, 8, 6, 4, 2)Range的三个参数依次是起点、终点(不含)、步长,步长不能为 0,可以为负值表示递减。资源里有一处注释写“步长为 2,从 1 开始打印,打印 1,4,7”,实际打印结果是对的,但步长写错了,正确说法是步长 3。这类注释笔误在文档里很常见,别照着注释去推结果,一定要自己println一遍。我习惯在第一次用某个区间写法时把它getClass打出来,确认生成的对象类型和自己预期一致,再往下写业务逻辑。
3.3 for、卫语句与 yield:把筛选结果落成集合
Scala 的for和 Java 的for不是一回事,它本质上是foreach、map、withFilter、flatMap这套方法的语法糖。理解这一点,后面看yield的返回类型就不会困惑。基础写法里,多个生成器用分号或空格分隔,效果等价;if条件直接写在生成器列表里,不需要额外的括号。
// 单层遍历 for (i <- 1 to 3) println(i) // 多层 + 卫语句:打印小九九 for (i <- 1 until 10; j <- 1 until 10 if i >= j) { print(s"$i*$j=${i * j}\t") if (i == j) println() } // yield 把满足条件的元素收成集合 val evens = for (i <- 1 to 100 if i > 50 if i % 2 == 0) yield i println(evens.getClass.getName) // 打印实际集合类型 println(evens.take(5))小九九这段里,i和j都从 1 到 9,卫语句i >= j保证每行只输出到对角线,i == j时换行。这种写法比 Java 里的双重循环加if干净得多,但要注意print和println的副作用在这里是必要的,如果你把print换成yield,返回的就是一个二维的集合序列了。
yield那段是练习需求的正解:Array(1,2,3,...,9)里挑偶数。两种写法都行——for (i <- a1 if i % 2 == 0) yield i或者直接a1.filter(_ % 2 == 0)。前者更贴近资源里的教学路径,后者更符合实际工程习惯。yield返回的集合类型由生成器决定,Range经过map之后通常不再是Range而是Vector,所以上面代码里特意打了一次getClass,这是定位“为什么返回类型和我以为的不一样”的最快手段。
注意:Scala 没有
++和--运算符,count++会直接编译失败,只能写count += 1或count = count + 1。从 Java 转过来的人几乎都在这上面翻过一次车。
3.4 lazy val 的求值时机
lazy val解决的问题是“定义时不想算,第一次用到才算,而且只算一次”。资源里给的例子很直白:加lazy修饰之后,初始化表达式里的println不会在定义处执行,而是在第一次访问这个值时执行。
lazy val lazyVal = { println("I am too lazy") 1 } println("before access") println(lazyVal) // 此刻才打印 "I am too lazy" println(lazyVal) // 第二次访问不再执行初始化表达式 val eagerVal = { println("I am eager") // 定义时立刻执行 2 }对比eagerVal的输出顺序就能看懂差别:普通val在定义处就把代码块跑完了,lazy val把这次执行推迟到第一次读取。这个特性在初始化开销大、或者初始化依赖其他尚未就绪的资源时特别有用,比如加载一个大配置文件、建一个连接池对象。
代价在于线程安全。lazy val的首次求值在 Scala 2.11 里带同步保护,多个线程同时第一次访问同一个lazy val,只有一个线程会执行初始化表达式,其他线程等待。单线程场景感受不到,高并发初始化场景里这段同步就可能成为一个不易察觉的阻塞点。我一般的判断标准是:初始化逻辑纯计算、无副作用、开销小,就用普通val;初始化涉及 IO、依赖外部状态、或者可能永远不会被用到,才上lazy。
4. 类、对象与函数:class 主构造器、伴生对象和 def 到高阶函数的谱系
4.1 class 的参数列表就是默认构造器
Scala 的类定义比 Java 紧凑得多,类名后面括号里的参数列表本身就是主构造器,不需要单独写一个和类同名的方法。类体里定义的字段和方法,会在new的时候按顺序执行(方法体除外)。
class Person(xname: String, xage: Int) { // 类体里的字段:new 时按顺序初始化 var name = Person.defaultName val age = xage var gender = "m" // 辅助构造器:第一句必须调用主构造器或其他构造器 def this(name: String, age: Int, g: String) = { this(name, age) gender = g } def sayName(): String = "my name is " + name }这里有两个容易忽略的点。第一,xname和xage作为构造参数,如果没有val/var修饰,它们只是构造器作用域内的参数,不会变成类的字段,类外部访问不到;age = xage这一句才把它落成字段。第二,辅助构造器的第一行必须是this(...),而且最终必须能追溯到主构造器,这是编译器的硬性要求,写成别的顺序直接报错。
主构造器参数默认是val语义,在类体里不能重新赋值。这是 Scala 的一条底层规则,也是方法参数不能重新赋值的原因,后面讲方法时会再遇到。
4.2 object、apply 与伴生对象
object是单例对象,语义上接近 Java 里的工具类加静态成员的组合,但它是真正的对象,可以继承、可以混入特质。使用时不需要new,直接按名字访问。资源里特别点出的一处是apply:object本身不能传参,但当你在对象名后面加括号传参时,编译器会自动去找对应参数个数的apply方法。
object Lesson_ObjectWithParam { def apply(s: String): Unit = println("name is " + s) def apply(s: String, age: Int): Unit = println(s"name is $s, age = $age") def main(args: Array[String]): Unit = { Lesson_ObjectWithParam("a") // 自动调用 apply(String) Lesson_ObjectWithParam("b", 18) // 自动调用 apply(String, Int) } }这套机制的价值在于省略new。Array(1,2,3)之所以能这么写,靠的就是Array伴生对象上的apply。理解之后你就会明白,为什么有些库的构造入口长得像函数调用,本质都是apply在背后接。
伴生对象和伴生类的关系是另一条关键规则:在同一个源文件里,同名的class和object互为伴生,可以互相访问对方的private成员。
object Person { private val defaultName = "default" // 伴生类可以访问 def main(args: Array[String]): Unit = { val p = new Person("a", 10, "f") println(p.age) println(p.sayName()) println(p.gender) } }Person.defaultName在类里被直接引用,尽管它是private,这就是伴生关系的作用。反过来说,如果class Person和object Person写在两个不同的源文件里,它们就只是同名而已,private访问立刻编译失败。这个坑很隐蔽,因为报错信息指向的是访问权限,而不是文件位置。
注意:
object不能带参数列表,trait也不能带参数列表,这两个限制在 Scala 2.11 里是硬约束。想给单例对象传配置,得靠方法参数或者外部配置读取。
4.3 def 与函数字面量:方法和函数不是一回事
资源里反复区分“方法”和“函数”,这个区分在 Scala 里是实打实的类型系统差异,不是术语洁癖。
| 维度 | 方法(def) | 函数字面量(val f = ...) |
|---|---|---|
| 定义方式 | def m(a: Int): Int = ... | val f = (a: Int) => ... |
| 是不是值 | 不是值,是方法类型 | 是Function1/Function2的实例 |
| 能否赋给变量 | 需要_或 eta 展开 | 直接赋值 |
| 能否作为参数传递 | 在期望函数类型的位置自动展开 | 直接传 |
| 能否重载 | 可以 | 不行 |
def m(a: Int, b: Int): Int = a * b // 方法 val f = (a: Int, b: Int) => a + b // 函数 val mAsValue = m _ // 方法转函数,显式下划线 println(mAsValue(2, 3)) // 在高阶函数的位置,方法会自动 eta 展开 def highFun(fun: (Int, Int) => Int, a: Int): Int = fun(a, 100) println(highFun(m, 1))m _这个下划线的含义是“把方法包装成一个函数值”,在 Scala 2.11 里赋给无类型标注的val时必须显式写,否则编译器不知道该不该展开。而highFun(m, 1)这一行不用下划线,因为参数位置的期望类型已经是(Int, Int) => Int,编译器知道要做 eta 展开。这两处的差别是很多人写代码时被 IDE 报“missing argument list”搞懵的原因。
匿名函数是函数字面量的另一种形态,可以带参、不带参、带返回值,本质都一样。
val f1: Int => Unit = (a: Int) => println(a) // 有参无返回 val f2 = () => println("no arg") // 无参 val f3 = (a: Int, b: Int) => a + b // 有返回值 f1(1) f2() println(f3(4, 4))f1那一行显式标了类型Int => Unit,是因为匿名函数的参数类型有时推不出来,标上之后(a: Int)里的Int甚至可以省。这类“类型标注该写在哪”的判断,是 Scala 新手最费时间的地方,原则很简单:编译器推不出来就标,推得出来就别写。
4.4 嵌套方法、偏应用与高阶函数
递归方法必须写返回类型,这是编译器的硬性要求,因为类型推测是单向的,递归调用会在自己还没推完的时候被引用。资源里的阶乘例子同时给了普通递归和嵌套方法两种写法,后者把累乘因子做成参数,避免每层都重新计算。
// 普通递归:必须写返回类型 Int def fact(num: Int): Int = if (num == 1) num else num * fact(num - 1) println(fact(5)) // 嵌套方法:外层只暴露一个参数,内层做累乘 def factNested(num: Int): Int = { def loop(a: Int, acc: Int): Int = if (a == 1) acc else loop(a - 1, a * acc) loop(num, 1) } println(factNested(5))偏应用函数解决的问题是“固定一部分参数,剩下的留空”。资源里log(date, s)的例子很典型:日期不变、日志内容每次变,可以先把日期固定住生成一个新函数。
import java.util.Date def log(date: Date, s: String): Unit = println(s"date is $date, log is $s") val date = new Date() val logWithDate = log(date, _: String) // 下划线处必须标类型 logWithDate("log11") logWithDate("log22")log(date, _: String)里的_: String类型标注不能省,因为编译器无法从上下文推出空缺参数的类型。这个限制在实际写代码时经常出现,遇到“missing parameter type”的报错,第一反应就该是给下划线补类型。
高阶函数的定义是“参数是函数、或者返回是函数、或者两者都是”。资源里给了参数是函数的最小例子,加上可变参数和默认参数,基本覆盖了日常写算子会用到的形态。
// 参数是函数 def highFun(f: (Int, Int) => Int, a: Int): Int = f(a, 100) println(highFun(_ + _, 1)) // 下划线简写,两个参数相加 // 可变参数 def sumAll(elements: Int*): Int = elements.sum println(sumAll(1, 2, 3, 4)) // 默认参数:不想覆盖默认值时必须用命名参数 def withDefault(a: Int = 10, b: Int): Int = a + b println(withDefault(b = 2)) // 结果是 12,不是 2withDefault(b = 2)这行是关键:如果写成withDefault(2),编译器会认为你在给第一个参数a赋值,结果变成2 + ???,直接报参数不足。命名参数在这里不是可选项,是唯一正确写法。_ + _是二元函数字面量的简写,每个下划线占一个参数位,参数类型由highFun的期望类型反推,这也是它能省掉类型标注的原因。
5. 避坑排查:从装不上到编译不过的 5 个具体翻车现场
5.1 环境和 IDE 相关的两个坑
现象一:命令行里敲scala -version,Windows 提示“不是内部或外部命令”,Linux 提示command not found,但安装过程明明一路下一步走完了。
原因:Windows 的 msi 安装器确实会写SCALA_HOME,但 Path 变量不一定被更新,尤其是机器上装过多个版本的 Scala 时,覆盖顺序会乱。另一种情况是环境变量改完了,用的还是修改之前打开的那个终端窗口,进程启动时读的环境变量是快照,不会热更新。
解决:先在命令行里echo %SCALA_HOME%(Linux 用echo $SCALA_HOME)确认变量本身存不存在,不存在就手动新建;存在但命令找不到,就在 Path 里补%SCALA_HOME%\bin和%SCALA_HOME%\jre\bin。改完之后一定要关掉终端重开,再敲scala -version验收。Linux 下如果用的是~/.bashrc,注意source只影响当前 shell,新开的窗口会重新读,但如果是通过图形界面启动的 IDE,它继承的是登录会话的环境变量,可能需要注销重登。
现象二:IDEA 里新建了 Scala 项目,代码没有语法高亮,println都标红,右键也没有 Scala 相关的运行入口。
原因:插件和 SDK 是两件独立的事。装插件只是让 IDE 认识.scala后缀、提供语法解析,真正让代码能编译的是项目里挂上的scala-library。很多人只做了 Plugins 那一步,Project Structure 里的 SDK 还是空的,或者错误地指向了 JDK 目录。
解决:打开 Project Structure,先看 SDKs 列表里有没有 JDK 1.8;再切到 Modules,展开当前模块的依赖,确认有没有scala-library和scala-compiler。没有的话回到新建项目流程,在选择 SDK 的弹窗里点 Browse,指向本机 Scala 安装根目录,让 IDE 自己去读lib下的 jar。配完之后新建一个带main方法的object跑一次,能出结果才算通。
5.2 语法与语义相关的三个坑
现象三:循环里写count++,编译直接报错value ++ is not a member of Int。
原因:Scala 没有自增自减运算符。++在 Scala 里是集合拼接的方法名,Int上没有这个方法,所以报“不是成员”。Java 里i++是语言层面的语法糖,Scala 没有对应的语法糖。
解决:改成count += 1或者i = i + 1。注意+=要求count是var,val声明的绑定都不允许重新赋值,这一层约束会在你在val上做累加的时候再挡你一次,属于正常行为。
现象四:写了val r = for (i <- 1 to 10 if i > 5) yield i,然后用的时候发现类型对不上,或者r的getClass打出来不是Range。
原因:for加yield本质是map调用,Range经过map之后返回的不再是Range,而是更通用的不可变序列类型。生成的集合类型由生成器的map/flatMap实现决定,不是简单的“原来是什么就返回什么”。
解决:如果下游代码强依赖区间特性(比如需要by步进或者contains的 O(1) 语义),就别用yield,改用filter之后重新构造Range;如果只是当序列遍历,用什么类型都无所谓,直接println(r.getClass.getName)看清楚再决定。养成打一次getClass的习惯,比查文档快。
现象五:方法定义写成def f(a: Int, b: Int) = { ... },结果拿到的返回值是(),后续计算全错。
原因:方法体前面有等号时,最后一行表达式的结果就是返回值,类型自动推断;去掉等号之后,方法一律返回Unit,方法体里最后那行表达式的值会被丢弃。这个规则和“有没有return关键字”无关,只和等号有关。
解决:需要返回值的方法,=一定要写;需要递归调用的方法,返回类型必须显式标注;返回类型是函数类型的方法,同样必须标注。资源里提到“递归方法或者返回值是函数类型的时候不能省略返回类型”,这两类情况编译器确实推不出来,其余情况建议还是标上,可读性收益远大于省那几个字符。
提示:伴生类和伴生对象必须写在同一个源文件里,这一条在排查
private访问权限报错时优先确认。IDE 里按住 Ctrl 点类名能跳转,不代表它们真是伴生关系。
6. 把方法变成值:柯里化与偏应用的两个验证技巧
方法怎么变成函数值,是这套内容里最像“心法”的一处,光看不练很容易以为自己会了。我的验证方式是写三行对照代码,看三种写法在编译器和运行时的表现差异。
def add(a: Int)(b: Int): Int = a + b // 柯里化:两个参数列表 val addTen1 = add(10) _ // 显式下划线,把部分应用固化成函数值 val addTen2: Int => Int = add(10) // 期望类型标注,自动 eta 展开 println(addTen1(5) + addTen2(5)) // 15 + 15add定义了两个参数列表,add(10)只填第一个列表,得到的是一个“还缺一个 Int 的函数”。第一行必须写_,因为赋值目标没有类型标注,编译器不知道你要展开;第二行给了Int => Int,编译器就自己完成 eta 展开,_可以省。这两行的输出必须都是 15,如果哪一行编译不过,问题一定出在类型标注是否缺失上。
| 写法 | 是否需要_ | 何时使用 |
|---|---|---|
val f = m _ | 需要 | 把方法存成变量、当值传递 |
val f: T => R = m | 不需要 | 类型能从上下文确定 |
highFun(m, 1) | 不需要 | 参数位置期望类型已确定 |
log(date, _: String) | 需要,且须标类型 | 偏应用函数填补部分参数 |
另一个技巧是遇到missing parameter type时别急着加类型标注,先把调用点拆成两行:一行只做方法引用,一行只做调用。拆开之后报错会精确落在真正缺类型的那一处,比在一长串链式调用里找快得多。我因为这个报错来回改过好几次匿名函数的参数列表,最后发现根因其实是前面一个map返回的集合元素类型被推成了Any,参数类型自然推不出来——错误信息和真实原因隔了两层,这就是 Scala 类型推测比较绕的地方。
从那以后我每次碰高阶函数和偏应用,都会先把这组三行对照写进临时脚本跑一遍,确认 eta 展开和类型标注的行为符合预期,再往业务代码里搬。希望帮到你。
本文还有配套的精品资源,点击获取