ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Scala从安装到高阶函数:JDK1.8、IDEA插件与版本避坑

Scala从安装到高阶函数:JDK1.8、IDEA插件与版本避坑

简介:这份 docx 文档面向准备进入大数据开发、尤其是想上手 Spark 的初学者,围绕运行在 JVM 上、兼具面向对象与函数式特性的 Scala 语言展开,帮助读者跨过环境搭建与语法入门两道门槛。内容先说明 Spark 1.6 依赖 Scala 2.10、Spark 2.0 之后转向 2.11 的版本关系,归纳 Java 混编、类型推测、并发与分布式、特质、模式匹配、高阶函数六项特征;安装部分覆盖 Windows 下 msi 安装与环境变量配置、Linux 解压即用,以及 IDEA 中 Scala 插件安装与 JDK、Scala SDK 路径设置。语法部分讲解数据类型、var 与 val 的区别、if 表达式赋值,并用完整示例演示 class 与 object 的写法、apply 方法的调用规则及伴生类与伴生对象。资源为单个 docx 文件,约 2.04MB,已有 2066 人学习下载,适合作为边看边敲的入门练习手册。

1. 一份把 Scala 从安装到高阶函数串起来的 docx,值不值得照着敲

很多人学 Scala 的路径很怪:先被 Spark 推着走,回头补语言基础,结果val和var还没分清,就被隐式转换和类型系统劝退。这份资料的路子相反,它从 JDK 1.8、Scala 2.11.8 和 IDEA 插件开始,把环境、变量、类与对象、方法与函数按顺序铺开,中间夹着可以直接运行的例子。它适合两类人:刚接触 Scala、需要一套能跑通最小闭环的新手;以及从 Java 转过来、想快速摸清函数式写法边界的熟手。资料是 docx,不是视频,阅读节奏由自己控制,里面的例子必须手动敲,这一点在后面会反复提到。

2. 环境先落地:JDK 1.8、Scala 2.11.8 与 IDEA 插件的版本对齐

2.1 为什么这套资料把版本钉在 JDK 1.8 + Scala 2.11

Scala 跑在 JVM 上,装 Scala 之前先有 JDK,这是硬前提。这份资料推荐 JDK 1.8,配套的 Scala 安装包是 2.11.8,不是随手选的:Spark 1.6 用的是 Scala 2.10,Spark 2.0 以上切到 Scala 2.11,而 2.11.8 正落在这条线上。版本错配不会在编译期立刻炸,常见表现是运行到某个算子时抛NoSuchMethodError或ClassNotFoundException,排查起来很费时间。

把对应关系先摆清楚,后面装环境就不用猜:

使用场景JDKScala说明
纯语言练习,跟资料敲例子1.82.11.8与文档示例一致,坑最少
Spark 1.6 老项目1.82.10.x老项目不要轻易升 Scala
Spark 2.0 以上1.82.11.x依赖包后缀通常是_2.11

这张表不是让你背,而是让你在拉依赖时多看一眼后缀。Maven 仓库里同一个库常有xxx_2.10和xxx_2.11两个版本,混着引,编译能过,跑起来才翻车。

2.2 Windows 与 Linux 两条安装路径

Windows 下装的是scala-2.11.8.msi,双击按提示走完即可。msi 一般会帮你建好SCALA_HOME,但 Path 需要自己确认,追加下面两项,注意分号是 Windows 的分隔符:

;%SCALA_HOME%\bin;%SCALA_HOME%\jre\bin

改完环境变量必须新开一个 cmd 窗口,老窗口读不到新值。验证命令只有一条,输出里能看到Scala code runner version 2.11.8才算真正装好:

scala -version java -version

Linux 下更简单,解压后配环境变量即可,不需要编译:

tar -zxvf scala-2.11.8.tgz -C /opt # 写入当前用户的 shell 配置 export SCALA_HOME=/opt/scala-2.11.8 export PATH=$SCALA_HOME/bin:$PATH source ~/.bashrc scala -version

SCALA_HOME指向解压后的根目录,不是bin目录;PATH里加的是$SCALA_HOME/bin。这两行写错任意一行,命令都会提示找不到。装完后scala能进交互式 REPL,scalac能编译.scala文件,两个命令都在bin下。

2.3 IDEA 里配 Scala SDK 与第一个项目

资料里给了 Eclipse 插件、Scala IDE 和 IDEA 三条路,实际用下来,IDEA 最省事。步骤不复杂,但顺序不能反:先装插件,再配 JDK,最后给项目挂 Scala SDK。

  1. 打开 IDEA,关掉当前项目,在欢迎页点Configure -> Plugins,搜索Scala,点Install,装完重启。
  2. 进Project Structure -> SDKs,点+添加本地 JDK 1.8 的安装路径。
  3. 新建项目时选 Scala,项目建好后进Project Structure -> Global Libraries,点+选Scala SDK,再点Browse指向本地 Scala 2.11.8 目录。

如果走 sbt 管理项目,常见做法是在build.sbt里把版本钉死,避免 IDEA 自动拉一个不匹配的 SDK:

name := "scala-basic" version := "1.0" // 与本地安装的 Scala 保持一致,避免编译器和运行时不匹配 scalaVersion := "2.11.8"

sbt第一次跑会下载依赖,网络慢的时候容易卡在Resolving,这不是代码问题。判断环境是否真的可用,不要看 IDEA 右下角有没有绿色对勾,直接在项目里新建一个object,写一行println("ok")跑起来,能输出再往下走。

2.4 Eclipse 插件与 Scala IDE 的取舍

资料里提到 Eclipse 装 Scala 插件的办法:下载对应版本的 zip,把features和plugins拷到 Eclipse 安装目录的dropins/scala下。这条路能走通,但对版本极其敏感,Eclipse 小版本升级一次,插件可能就加载不出来,现象是 IDE 启动卡住或者 Scala 项目没有语法高亮。Scala IDE 独立版也是同样的问题,维护节奏慢。

我的建议很直接:纯学习阶段用 IDEA 社区版足够,不要把时间花在给 Eclipse 找对应版本上。资料保留这部分是历史原因,不是必选项。真要用 Eclipse,先把 Eclipse 版本号记下来,再去插件下载页找严格对应的包,不要下“最新版”碰运气。

3. 变量、表达式与循环:把 Scala 的“极简”落到可运行代码

3.1 val 与 var:不可变不是口号,是编译期约束

Scala 定义变量只有两个关键字:var可重新赋值,val不可。资料里那句话很关键——Scala 有个极简原则,不用写的东西一概不写,类型能推断就省略。但省略类型不等于没有类型,编译器在编译期就把类型定死了。

object VarValDemo { def main(args: Array[String]): Unit = { // var 可修改,类型推断为 String var name = "alpha" name = "beta" // val 不可修改,相当于 Java 里的 final val gender: String = "m" // gender = "f" // 这行编译不过:reassignment to val println(name + "/" + gender) } }

val的不可变是编译期检查,不是运行期提醒,写错直接编译失败,这其实是好事,越早暴露越好。显式写val gender: String是为了说明类型可以手写,但多数时候省略更清爽。真正需要写类型的地方集中在递归方法返回值、函数类型参数,以及你希望用父类型接收子类对象的时候。

3.2 if 是表达式,不是语句

Java 里的if是语句,Scala 里它是表达式,有返回值,所以可以直接赋给变量。这个差别看起来小,写起来影响很大:你不需要先声明一个变量再在分支里赋值。

val x = 1 val y = if (x > 0) 1 else -1 // 两个分支类型不同时,公共父类型是 Any val a = if (x > 0) 1 else "jay" // 缺 else 分支,返回 Unit,值会被丢弃 val p = if (x > 5) 1 println(y + " / " + a + " / " + p)

Any是所有类型的父类,相当于 Java 里的Object,所以1和"jay"能放进同一个表达式。缺else时,Scala 补一个Unit,打印出来是(),这不是错误,但如果你指望它返回具体值,就会拿到一个没用的结果。判断逻辑超过两层时,建议拆成方法,不要把嵌套的if堆在一个表达式里。

3.3 to、until 与 Range:循环的三种写法

Scala 的循环区间底层都是Range,区别在边界开闭和步长。to左右都包含,until左闭右开,带第二个参数就是步长,步长不能为 0,可以为负数做递减。

println(1 to 10) // 1 到 10,包含 10 println(1 until 10) // 1 到 9,不包含 10 println(1 to (10, 2)) // 步长 2:1,3,5,7,9 println(1 until (10, 3)) // 步长 3:1,4,7 // 等价写法,Range 同样是左闭右开 var r = Range(1, 10, 2) println(r)

1 to (10, 2)这种写法新手容易看懵,它等价于1.to(10, 2),第二个参数是步长。Range(1, 10, 2)的第二个参数是结束边界,不包含,所以它和1 until (10, 2)结果一致。写循环时先确认边界是“包含”还是“不包含”,这是最常见的差一错误来源。

3.4 for、yield 与 while:把数组偶数取出来

for在 Scala 里比 Java 灵活,可以一次写多个生成器构成多层循环,也可以直接在括号里加条件过滤,还可以用yield把结果收集成一个集合。

val arr = Array("a", "b", "c") for (i <- arr) println(i) // 分号隔开多个生成器,构成多层循环 var count = 0 for (i <- 1 to 10; j <- 1 until 10) { count += 1 } println(count) // 条件过滤,多个条件用分号或空格隔开 for (i <- 1 to 10 if i % 2 == 0 if i != 4) println(i) // yield 把满足条件的元素收集成集合 val result = for (i <- 1 to 100 if i > 50 if i % 2 == 0) yield i println(result)

这里有个血泪经验:Scala 没有count++和count--,写惯了 Java 的人会条件反射,编译直接报错。只能写count += 1或count = count + 1。另外for里的条件不是“循环体里再判断”,而是生成器的一部分,过滤掉的元素不会进入循环体,yield收集的也是过滤后的结果。

资料里的练习是定义一个数组,把偶数取出来,用yield一行就能写完:

val a1 = Array(1, 2, 3, 4, 5, 6, 7, 8, 9) // 过滤 + 收集,返回 Array[Int] val even = for (e <- a1 if e % 2 == 0) yield e println(even.mkString(","))

yield返回的集合类型尽量与源集合保持一致,数组进数组出,列表进列表出。如果你用println在循环里直接打印,拿不到集合,后续没法继续做map、filter,这是两种不同的写法,别混。

while和do-while在 Scala 里照样能用,但更推荐用for加集合操作,可读性更好。do-while至少执行一次,适合“先跑再判断”的场景:

var index = 0 while (index < 3) { println("while " + index) index += 1 } do { index -= 1 println("do " + index) } while (index > 0)

3.5 lazy:把初始化推迟到第一次使用

lazy val是 Scala 里很实用的一个修饰,被它标记的常量在定义时不会执行右侧的表达式,第一次访问才初始化,而且只初始化一次。

lazy val lazyVal = { println("I am too lazy") 1 } println("before") println(lazyVal) // 到这一行才打印 "I am too lazy" println(lazyVal) // 第二次不会再初始化

这个特性在读配置文件、建数据库连接池时很常见:对象启动时不连,真正用到才连,避免启动阶段被外部依赖卡住。注意lazy只能修饰val,不能修饰var,也不能修饰方法参数。初始化顺序如果涉及多个lazy val互相引用,仍然可能死锁,别把它当万能的延迟加载。

4. 类、对象、伴生与函数:把 OO 和 FP 两套写法装进同一个文件

4.1 class 的构造参数就是默认构造器

Scala 的类声明比 Java 紧凑,类名后面括号里的参数直接就是主构造器的参数,不用单独写构造函数。类属性自带 getter 和 setter,val只有 getter,var两者都有。

class Person(xname: String, xage: Int) { // 类属性,var 自带 getter/setter,val 只有 getter var name = Person.name 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就不是字段,只能在类体里用,外部访问不到。想让外部读,就声明成val;想读写,声明成var。辅助构造器的第一行必须是this(...),这是硬规矩,绕不过去。

4.2 object 是单例,也是 Java 静态成员的替代

object定义的是单例对象,不用new,直接用名字访问。Java 里用static放工具方法,Scala 里就放在object里。main方法也必须写在object里才能作为程序入口。

object Person { val name = "default-name" def main(args: Array[String]): Unit = { val p = new Person("w", 10, "f") println(p.age) println(p.sayName()) println(p.gender) } }

object不能传参数,这是它和class最直接的区别。new一个class时,类体里除了方法,其他语句都会执行;object第一次被访问时整个对象初始化一次,之后复用。把main放在object里,类名和文件名不需要一致,这点和 Java 不同。

4.3 apply 让 object 像函数一样被调用

object不能带参数,但你可以定义apply方法,这样写ObjectName("x")时,编译器会自动转成ObjectName.apply("x")。参数个数不同,就定义多个重载的apply。

object ObjectWithParam { def apply(s: String): Unit = { println("name is " + s) } def apply(s: String, age: Int): Unit = { println("name is " + s + ", age = " + age) } def main(args: Array[String]): Unit = { // 下面两行等价于调用对应的 apply ObjectWithParam("alpha") ObjectWithParam("beta", 18) } }

apply本质是语法糖,省掉方法名,读起来像函数调用。Scala 标准库里的Array(1,2,3)、List(1,2,3)都是靠伴生对象的apply实现的。自己写apply时注意参数个数和类型要能区分开,否则重载解析会报歧义。

4.4 伴生类与伴生对象:互相访问私有成员

在同一个文件里,class和object同名,它们就互为伴生类和伴生对象。伴生对象可以访问类的私有成员,类也可以访问伴生对象的私有成员,这是 Scala 用来替代 Javastatic的常用手法。

class Account { private var balance = 0 def deposit(n: Int): Unit = balance += n } object Account { // 伴生对象可以访问类的 private 成员 def show(a: Account): Int = a.balance def main(args: Array[String]): Unit = { val a = new Account() a.deposit(100) println(Account.show(a)) } }

伴生关系和“同名”绑定,必须写在同一个文件里,跨文件同名不算。资料里还提到object和Trait都不能传参数,class可以,这条在选型时很有用:需要携带状态、需要多实例的用class;纯工具方法、常量、工厂入口用object。

4.5 def 与匿名函数:方法和函数不是一回事

def定义的是方法,不是值;函数是FunctionN的实例,可以赋给变量、作为参数传递。资料里那句“函数是头等公民”说的就是这个:函数可以像普通值一样被传来传去。

// 方法:用 def 定义,带参数类型和返回类型 def method(a: Int, b: Int): Int = a * b // 函数:本质是 Function2 的实例,可以赋给 val val f1 = (x: Int, y: Int) => x + y // 匿名函数,直接作为值使用 val anon: Int => Unit = (a: Int) => println(a) println(method(3, 5)) println(f1(1, 2)) anon(9)

方法定义有几个细节:参数类型必须写,返回类型可以推断,但递归方法和返回函数类型的方法必须显式写;方法体只有一行时可以省略花括号;return可写可不写,不写就把最后一行当返回值,写了就必须声明返回类型。最容易踩的坑是方法体前面少了等号,这时返回类型强制变成Unit,里面的计算结果会被丢弃。

4.6 高阶函数与偏应用:把函数当参数、当返回值

高阶函数有三种形态:参数是函数、返回是函数、两者都是。这是 Scala 函数式编程的核心,也是读 Spark 源码必须跨过去的一道坎。

// 参数是函数 def highFun(f: (Int, Int) => Int, a: Int): Int = f(a, 100) // 返回是函数 def makeAdder(n: Int): Int => Int = (x: Int) => x + n println(highFun(_ + _, 1)) // 101 println(makeAdder(10)(5)) // 15

(Int, Int) => Int是函数类型,_ + _是两个下划线占位符的简写。makeAdder(10)返回一个函数,后面再跟(5)调用,这种柯里化风格的写法在 Scala 里很常见。

偏应用函数是另一种用法:一个函数有多个参数,你固定其中一部分,得到一个新函数。资料里的日志例子很典型,日期固定,只变日志内容:

def log(date: java.util.Date, s: String): Unit = { println("date is " + date + ", log is " + s) } val date = new java.util.Date() // 固定第一个参数,第二个参数用下划线占位 val logWithDate = log(date, _: String) logWithDate("log11") logWithDate("log22")

注意_: String不能省成_,因为编译器需要知道占位参数的类型。偏应用函数和部分应用函数在中文里经常混着叫,实际就是固定部分参数、返回新函数这个动作。

递归、默认参数、可变参数、嵌套方法在资料里都有例子,集中写一遍方便对比:

// 递归:必须显式声明返回类型 def fact(num: Int): Int = if (num == 1) num else num * fact(num - 1) // 默认参数:不想覆盖默认值时,用参数名指定 def fun3(a: Int = 10, b: Int): Int = a + b println(fun3(b = 2)) // 可变参数:参数类型后面加 * def fun4(elements: Int*): Int = { var sum = 0 for (e <- elements) sum += e sum } println(fun4(1, 2, 3, 4)) // 嵌套方法:内部方法只服务于外部方法 def outerFact(num: Int): Int = { def inner(a: Int, b: Int): Int = if (a == 1) b else inner(a - 1, a * b) inner(num, 1) } println(outerFact(5))

默认参数调用时,如果传入的参数个数少于定义,必须用参数名指定,否则编译器按位置匹配会报错。可变参数在方法体里按集合处理,可以直接for遍历。嵌套方法能访问外部方法的参数,适合把递归的辅助逻辑藏起来,不给外部调用者看到。

5. 避坑与排查:五个把我卡住的现场

5.1 环境与 IDE 类问题

现象:cmd 里输入scala -version提示“不是内部或外部命令”。原因:SCALA_HOME建了,但Path里没加%SCALA_HOME%\bin,或者加了但当前 cmd 窗口是改环境变量之前开的。 解决:关掉所有 cmd 重新开一个,执行echo %SCALA_HOME%确认变量存在,再检查Path里那两项有没有拼错,分号别漏。

现象:IDEA 里新建 Scala 项目,右键没有 Scala Class,或者项目结构里找不到 Scala SDK。原因:只装了 Scala 插件,没有给项目挂 SDK;插件负责语法支持,SDK 负责编译和运行,两件事。 解决:Project Structure -> Global Libraries -> + -> Scala SDK -> Browse,指向本地 Scala 2.11.8 根目录。挂完后重新打开项目,等索引跑完再建类。

现象:Eclipse 装完 Scala 插件启动卡死,或者项目没有 Scala 透视图。原因:插件版本和 Eclipse 版本不匹配,dropins方式对版本很敏感。 解决:要么严格按 Eclipse 版本号去插件页找对应包,要么直接换 IDEA。学习阶段不建议在这上面耗时间。

5.2 语法与语义类问题

现象:给val定义的变量重新赋值,编译报reassignment to val。原因:val是不可变常量,编译期就禁止二次赋值,不是运行期才检查。 解决:确实需要改就用var;如果只是想在原值基础上算新值,用新变量接收表达式结果,别改原变量。

现象:for循环里写count++,编译报错。原因:Scala 没有++和--运算符,Java 那套自增语法在这里不存在。 解决:写count += 1或count = count + 1。同样,i--也要改成i -= 1。

现象:方法明明算出了结果,调用方拿到的却是()。原因:方法定义时省略了等号,def f() { ... }这种写法返回类型被强制为Unit,最后一行表达式的值被丢弃。 解决:加上等号,写成def f() = { ... },并显式声明返回类型。返回类型是函数类型或递归方法时,返回类型不能省。

现象:object后面直接带参数调用,编译不过。原因:object是单例,本身不接收构造参数,带参调用需要apply。 解决:在object里定义对应参数个数的apply方法,或者改用class加new。

现象:本地跑得好好的,放到 Spark 环境里抛NoSuchMethodError。原因:Scala 二进制版本不匹配,比如依赖是_2.10编的,运行环境是 2.11,或者反过来。 解决:看依赖坐标后缀,统一成_2.11;确认 Spark 版本对应的 Scala 版本,不要只看scalaVersion一个地方,构建工具和运行环境都要对齐。

6. 进阶用法与验证:用 REPL 和 javap 把“猜”变成“看”

学到方法和高阶函数这一层,最怕的是“看着懂、写不出”。我的习惯是先在 REPL 里把表达式跑通,再落回文件。启动 REPL 只需要在终端敲scala,进去后用:type看推断结果,比在 IDEA 里等编译快得多:

:type 1 to 10 // 输出 Range,确认是左闭右闭还是左闭右开 :type if (true) 1 else "x" // 输出 Any,混合类型的公共父类 val f = (x: Int) => x + 1 :type f // 输出 Int => Int,函数类型的真实面貌

res0: scala.collection.immutable.Range.Inclusive = Range(1, 2, ...)这类输出会直接告诉你边界类型,比翻文档快。:type对判断“这个表达式到底返回什么”特别有用,尤其是if缺else、for加yield之后返回值类型变了的时候。

第二个验证手段是看伴生对象编译后长什么样。Scala 的object在字节码层面会生成一个带$后缀的类,用javap能看到它的静态成员和MODULE$字段:

javac -version javap -p Person$ javap -p Person

Person$是伴生对象对应的类,Person是普通类。看到public static final Person$ MODULE$就说明单例确实生成了。这个办法在排查“为什么object里的方法和 Java 反射拿到的对不上”时很有用。

第三个习惯是用 IDEA 的 Scala Worksheet 做小步验证。新建一个.sc文件,左边写表达式,右边实时出结果,不用写main,适合验证yield返回的集合类型、偏应用函数固定参数后的类型。Worksheet 的结果依赖 IDEA 的 Scala 插件和 SDK 配置,SDK 没挂对时右侧不刷新,先回去检查第 2 章那三步。

最后一个验证点是版本一致性。我一般会同时看三个地方:终端scala -version、java -version、IDEA 项目里的 Scala SDK 版本。三处不一致时,先别怀疑代码,先把版本对齐。曾经有一次本地 REPL 是 2.11.8,IDEA 项目挂的却是 2.10,for推导里的一个集合操作编译不过,查了半天以为是语法问题,最后发现是 SDK 挂错。从那以后我每次新建 Scala 项目,都强制先把这三处版本打印一遍再写第一行代码。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进