Scala 介绍及入门教程
1. 介绍
本文中,我们将学习 Scala——在 Java 虚拟机上运行的主要语言之一。
我们将从核心语言特性开始,如值、变量、方法和控制结构。然后,我们将探索一些高级功能,如高阶函数、柯里化、类、对象和模式匹配。
2. 项目安装
本文中,我们将使用标准的 Scala 安装:https://www.scala-lang.org/download/.
首先将 scala-library 依赖添加到 pom.xml
。它提供了该语言的标准库:
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>2.13.10</version>
</dependency>
然后添加 scala-maven-plugin 用以编译、测试、运行和文档化代码:
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
<version>3.3.2</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
Maven 有 scala-lang 和 scala-maven-plugin 的最新工件。
最后,我们将使用 JUnit 进行单元测试。
3. 基础特性
本章中,我们将通过示例检查基础的语言特性。我们使用 Scala interpreter。
3.1. 解释器
解释器是一个用于编写程序和表达式的交互式 shell。
让我们用它打印 “hello world”:
C:\>scala
Welcome to Scala 2.13.10 (Java HotSpot(TM)
64-Bit Server VM, Java 1.8.0_92).
Type in expressions for evaluation.
Or try :help.
scala> print("Hello World!")
Hello World!
scala>
上面,我们通过在命令行中键入“scala” 来启动解释器。解释器启动并显示欢迎消息,然后显示提示。
然后,我们在此提示符处键入表达式。解释器读取表达式,对其求值并打印结果。然后,它循环并再次显示提示。
由于它提供即时反馈,解释器是开始学习语言的最简单方法。因此,让我们用它来探索基本的语言特征:表达式和各种定义。
3.2. 表达式
任何可计算语句都是表达式。
让我们写一些表达式,看看它们的结果:
scala> 123 + 321
res0: Int = 444
scala> 7 * 6
res1: Int = 42
scala> "Hello, " + "World"
res2: String = Hello, World
scala> "zipZAP" * 3
res3: String = zipZAPzipZAPzipZAP
scala> if (11 % 2 == 0) "even" else "odd"
res4: String = odd
如上所示,每个表达式都有一个值和一个类型。
如果表达式没有返回任何东西,它将返回 Unit 类型的值。该类型只有一个值:()
。它类似于 Java 中的 void
关键字。
3.3. 值定义
val
关键字用来声明值。
我们用它来给表达式的结果命名:
scala> val pi:Double = 3.14
pi: Double = 3.14
scala> print(pi)
3.14
这样做我们可以多次重用其结果。
值是不可变的。因此,我们不能重新赋值:
scala> pi = 3.1415
<console>:12: error: reassignment to val
pi = 3.1415
^
3.4. 变量定义
如果我们需要重新赋值,则需将其声明为变量。
关键字 var
用于声明变量:
scala> var radius:Int=3
radius: Int = 3
3.5. 方法定义
我们使用 def
关键字来定义方法。紧随着该关键字,我们指定方法名,参数声明,一个分隔符(冒号)以及返回类型。然后,指定一个分隔符(=),其后是方法体。
不同于 Java,我们不使用 return
关键字来返回结果。方法返回最后一个计算的表达式的值。
我们来编写一个方法 avg
来计算两个值的平均数:
scala> def avg(x:Double, y:Double):Double = {
(x + y) / 2
}
avg: (x: Double, y: Double)Double
然后,我们调用该方法:
scala> avg(10,20)
res0: Double = 12.5
如果一个方法不接受任何参数,我们可以在定义和调用过程中省略括号。此外,如果方法体只有一个表达式,我们也可以省略括号。
让我们编写一个无参数方法 coinToss
,它随机返回 “Head” 或 “Tail”:
scala> def coinToss = if (Math.random > 0.5) "Head" else "Tail"
coinToss: String
接下来,调用该方法:
scala> println(coinToss)
Tail
scala> println(coinToss)
Head
4. 控制结果
控制结构允许我们改变程序中的控制流。我们有以下控制结构:
- If-else 表达式
- While 循环和 do while 循环
- For 表达式
- Try 表达式
- Match 表达式
不同于 Java,Scala 没有 continue
或 break
关键字。也没有 return
关键字。但是,我们应该避免使用它。
我们使用 match
表达式进行模式匹配,而不是 switch
语句。此外,我们可以定义自己的控制抽象。
4.1. if-else
if-else 表达式类似于 Java。else
部分是可选,可以嵌套使用多个 if-else 表达式。
由于它是一个表达式,它会返回一个值。因此,我们使用它类似于 Java 中的三元运算符 (?:) 。事实上,该语言没有三元运算符。
我们来使用 if-else 编写一个方法来计算最大公约数:
def gcd(x: Int, y: Int): Int = {
if (y == 0) x else gcd(y, x % y)
}
然后为该方法编写一个单元测试:
@Test
def whenGcdCalledWith15and27_then3 = {
assertEquals(3, gcd(15, 27))
}
4.2. While 循环
while 循环有一个条件和一个循环体。当条件为真时,它在循环中反复评估循环体——在每次迭代开始时评估条件。
由于它没有任何有用的东西可以返回,因此它返回 Unit
。
让我们使用 while 循环编写一个方法来计算最大公约数:
def gcdIter(x: Int, y: Int): Int = {
var a = x
var b = y
while (b > 0) {
a = a % b
val t = a
a = b
b = t
}
a
}
然后,验证结果:
assertEquals(3, gcdIter(15, 27))
4.3. Do While 循环
do-while 循环类似于 while 循环,只是循环条件在循环结束时进行评估。
让我们使用 do-while 循环编写一个计算阶乘的方法:
def factorial(a: Int): Int = {
var result = 1
var i = 1
do {
result *= i
i = i + 1
} while (i <= a)
result
}
接下来,验证其结果:
assertEquals(720, factorial(6))
4.4. For 表达式
for 表达式比 Java 中的 for 循环更通用。
它可以迭代单个或多个集合。此外,它可以过滤掉元素并生成新的集合。
让我们使用 for 表达式编写一个方法来对一系列整数求和:
def rangeSum(a: Int, b: Int) = {
var sum = 0
for (i <- a to b) {
sum += i
}
sum
}
此处,i <- a to b 是一个生成器表达式,他生成一系列从 a
到 b
的值。
然后对序列中的每个值执行 for 循环体。
接下来,让我们验证一下结果:
assertEquals(55, rangeSum(1, 10))
5. 函数
Scala 是一个函数式语言。函数是”一等公民“——我们可以像使用任何其他值类型一样使用它们。
在本节中,我们将研究一些与函数相关的高级概念——局部函数、高阶函数、匿名函数和 currying。
5.1. 局部函数
我们可以在函数内定义函数。它们被称为嵌套函数或局部函数。与局部变量类似,它们仅在定义它们的函数中可见。
现在,我们来使用嵌套函数编写一个方法来计算乘方:
def power(x: Int, y:Int): Int = {
def powNested(i: Int,
accumulator: Int): Int = {
if (i <= 0) accumulator
else powNested(i - 1, x * accumulator)
}
powNested(y, 1)
}
然后,我们来验证其结果:
assertEquals(8, power(2, 3))
5.2. 高阶函数
既然函数是值,我们可以将它们作为参数传递给另一个函数。我们也可以让一个函数返回另一个函数。
我们将对函数进行运算的函数称为高阶函数。它们使我们能够在更抽象的层面上工作。使用它们,我们可以通过编写通用算法来减少代码重复。
现在,让我们编写一个高阶函数,在一系列整数上执行映射并减少操作:
def mapReduce(r: (Int, Int) => Int,
i: Int,
m: Int => Int,
a: Int, b: Int) = {
def iter(a: Int, result: Int): Int = {
if (a > b) {
result
} else {
iter(a + 1, r(m(a), result))
}
}
iter(a, i)
}
这里,r
和 m
是函数(Function
)类型的参数。通过传递不同的函数,我们可以解决一系列问题,例如平方和或立方和阶乘。
接下来,让我们使用此函数编写另一个函数 sumSquares
,对整数的平方求和:
@Test
def whenCalledWithSumAndSquare_thenCorrectValue = {
def square(x: Int) = x * x
def sum(x: Int, y: Int) = x + y
def sumSquares(a: Int, b: Int) =
mapReduce(sum, 0, square, a, b)
assertEquals(385, sumSquares(1, 10))
}
上面,我们可以看到,高阶函数往往会创建许多小型的一次性函数。我们可以通过使用匿名函数来避免命名它们。
5.3. 匿名函数
匿名函数是一个计算结果为函数的表达式。它类似于 Java 中的 lambda 表达式。
让我们使用匿名函数重写前面的示例:
@Test
def whenCalledWithAnonymousFunctions_thenCorrectValue = {
def sumSquares(a: Int, b: Int) =
mapReduce((x, y) => x + y, 0, x => x * x, a, b)
assertEquals(385, sumSquares(1, 10))
}
本例中,mapReduce
接收两个匿名函数:(x, y) => x + y
和 x => x * x
。
Scala 可以从上下文中推断出参数类型。因此,我们省略了这些函数中的参数类型。
与前面的示例相比,这使得代码更加简洁。
5.4. Currying 函数
curried 函数接受多个参数列表,例如 def f(x: Int) (y: Int)
。它通过传递多个参数列表来应用,如 f(5)(6)
。
它被当作函数链的调用。这些中间函数接受一个参数并返回一个函数。
我们还可以部分指定参数列表,例如 f(5)
。
现在,让我们通过一个例子来理解这一点:
@Test
def whenSumModCalledWith6And10_then10 = {
// a curried function
def sum(f : Int => Int)(a : Int, b : Int) : Int =
if (a > b) 0 else f(a) + sum(f)(a + 1, b)
// another curried function
def mod(n : Int)(x : Int) = x % n
// application of a curried function
assertEquals(1, mod(5)(6))
// partial application of curried function
// trailing underscore is required to
// make function type explicit
val sumMod5 = sum(mod(5)) _
assertEquals(10, sumMod5(6, 10))
}
上面,sum
和 mod
每个都有两个参数列表。
我们传递两个参数列表,如 mod(5)(6)
。这被当作为两次函数调用。首先,对 mod(5)
进行求值,返回一个函数。然后又通过参数 6
调用。我们得到结果 1
。
可以使用部分参数,如 mod(5)
。然后,我们得到一个函数作为结果。
同样,在表达式 sum(mod(5)) _
中,我们只将第一个参数传递给 sum
函数。因此,sumMod5
是一个函数。
下划线用作未应用参数的占位符。由于编译器无法推断出预期的函数类型,因此我们使用尾随下划线使函数显式返回类型。
5.5. By-Name 参数
一个函数可以通过两种不同的方式应用参数——传值(by-value)参数和传名(by-name)参数——传值参数只在调用时进行一次求值。而传名参数每次引用时,它都会进行重新求值。如果没有使用传名参数,则不会对其进行求值。
Scala 默认使用 by-value 参数。如果参数类型前面有箭头( =>),它会切换成 by-name 参数。
接下来,我们用它来实现 while 循环:
def whileLoop(condition: => Boolean)(body: => Unit): Unit =
if (condition) {
body
whileLoop(condition)(body)
}
为了使上述函数正常工作,每次引用参数 condition
和 body
都应该对其进行求值。因此,我们将它们定义为传名参数(y-name parameters)。
6. 类定义
我们用 class
关键字后跟随类名定义一个类。
在类名之后,我们可以指定主要构造函数参数。这样做会自动将同名成员添加到类中。
在类体中,我们定义了成员——值、变量、方法等。默认情况下,它们是 public 的,除非有 private
或 protected
访问修饰符。
要重写超类中的方法必须使用 override
关键字。
让我们定义一个类 Employee
:
class Employee(val name : String, var salary : Int, annualIncrement : Int = 20) {
def incrementSalary() : Unit = {
salary += annualIncrement
}
override def toString =
s"Employee(name=$name, salary=$salary)"
}
在这里,我们指定了三个构造函数参数——name
、salary
和 annualIncrement
。
由于我们使用 val
和 var
关键字声明 name
和 pay
,因此相应的成员是公开的。另一方面,我们没有对 annualIncrement
参数使用 val
或 var
关键字。因此,相应的成员是私有的。当我们为这个参数指定默认值时,我们可以在调用构造函数时省略它。
除了字段外,我们还定义了方法 incrementSalary
。此方法是 public 的。
接下来,让我们为这个类编写一个单元测试:
@Test
def whenSalaryIncremented_thenCorrectSalary = {
val employee = new Employee("John Doe", 1000)
employee.incrementSalary()
assertEquals(1020, employee.salary)
}
6.1. 抽象类
我们使用关键字 abstract
定义抽象类。它与 Java 中的类似。它可以拥有普通类可以拥有的所有成员。
此外,它还可以包含抽象成员。这些成员只有声明,没有定义,它们的定义在子类中提供。
与 Java 类似,我们不能创建抽象类的实例。
现在,让我们用一个例子来说明抽象类。
首先,让我们创建一个抽象类 IntSet
来表示整数集:
abstract class IntSet {
// add an element to the set
def incl(x: Int): IntSet
// whether an element belongs to the set
def contains(x: Int): Boolean
}
接下来,我们创建了具体的子类 EmptyIntSet
表示空集:
class EmptyIntSet extends IntSet {
def contains(x : Int) = false
def incl(x : Int) =
new NonEmptyIntSet(x, this)
}
然后,另一个子类 NonEmptyIntSet
表示非空集:
class NonEmptyIntSet(val head : Int, val tail : IntSet)
extends IntSet {
def contains(x : Int) =
head == x || (tail contains x)
def incl(x : Int) =
if (this contains x) {
this
} else {
new NonEmptyIntSet(x, this)
}
}
最后,我们编写 NonEmptySet
单元测试:
@Test
def givenSetOf1To10_whenContains11Called_thenFalse = {
// Set up a set containing integers 1 to 10.
val set1To10 = Range(1, 10)
.foldLeft(new EmptyIntSet() : IntSet) {
(x, y) => x incl y
}
assertFalse(set1To10 contains 11)
}
6.2. Trait
Trait 对应于 Java 接口,它有以下不同:
- 能够从类中继承
- 可以访问超类成员
- 可以有初始化语句
我们像定义类一样定义它们,但使用的是 trait
关键字。此外,除了构造函数参数之外,它们可以具有与抽象类相同的成员。此外,它们旨在作为 mixin 添加到其他类中。
现在,让我们用一个例子来说明 trait
。
首先,让我们定义一个 trait UpperCasePrinter
,以确保 toString
方法返回大写的值:
trait UpperCasePrinter {
override def toString =
super.toString.toUpperCase
}
然后,我们将其添加到 Employee
类测试该 trait:
@Test
def givenEmployeeWithTrait_whenToStringCalled_thenUpper = {
val employee = new Employee("John Doe", 10) with UpperCasePrinter
assertEquals("EMPLOYEE(NAME=JOHN DOE, SALARY=10)", employee.toString)
}
类、对象和 trait 最多可以继承一个类,但可以继承任意数量的 trait。
7. 对象(Object)定义
对象是类的实例。正如我们在前面的例子中看到的,我们使用 new
关键字从类中创建对象。
但是,如果一个类只能有一个实例,我们需要防止创建多个实例。在 Java 中,我们使用 Singleton
模式来实现这一点。
对于这种情况,我们有一个简洁的语法,称为对象(object)定义——类似于类定义,但有一个区别。我们不使用 class
关键字,而是使用 object
关键字。这样做定义了一个类,并懒洋洋地创建了它的唯一实例。
我们使用对象定义来实现实用方法和单例。
让我们定义一个 Utils
对象:
object Utils {
def average(x: Double, y: Double) =
(x + y) / 2
}
此处,我们定义了类 Utils
并同时生成它的唯一实例。
我们使用它的名称 Utils
引用这个单一实例。该实例在第一次访问时创建。
我们无法使用 new
关键字创建另外一个实例。
接下来,我们来编写一个 Utils
对象的单元测试:
assertEquals(15.0, Utils.average(10, 20), 1e-5)
7.1. 伴生对象和伴生类
如果一个类和一个对象定义具有相同的名称,我们将它们分别称为伴生对象和伴生类。我们需要在同一个文件中定义两者。伴生对象可以在其伴生类中访问私有成员,反之亦然。
与 Java 不同,我们没有静态成员。相反,我们使用伴生对象来实现静态成员。
8. 模式匹配
模式匹配将表达式与一系列备选方案相匹配。每个情况使用 case
关键字开始。其后紧随着模式,分隔箭头(=>)和一些表达式。当模式匹配时,计算其中的表达式。
我们可以从下面方式中创建模式:
- case 类构造函数
- 变量模式
- 通配符模式 _
- 字面量
- 常量标识符
Case 类使对对象进行模式匹配变得容易。我们在定义类时添加 case
关键字,使其成为 case
类。
因此,模式匹配比 Java 中的 switch
语句强大得多。因此,它是一种广泛使用的语言特性。
然后,我们使用模式匹配编写一个 Fibonacci 方法:
def fibonacci(n:Int) : Int = n match {
case 0 | 1 => 1
case x if x > 1 =>
fibonacci (x-1) + fibonacci(x-2)
}
接下来,我们为该方法编写了一个单元测试:
assertEquals(13, fibonacci(6))
9. sbt 使用
sbt 是 Scala 项目事实上的构建工具。在本节中,让我们了解它的基本用法和关键概念。
9.1. Setting Up the Project
我们可以使用 sbt 来设置 Scala 项目并管理其生命周期。下载并安装后,让我们设置一个简约的 Scala 项目,Scala-demo
。
首先,我们必须在 build.sbt
文件中添加项目设置,如名称(name)、组织(organization)、版本(version)和 scalaVersion
:
$ cat build.sbt
scalaVersion := "3.3.0"
version := "1.0"
name := "sbt-demo"
organization := "com.baeldung"
然后,当我们在项目根目录中运行 sbt
命令时,它启动了 sbt
shell:
scala-demo $ sbt
[info] Updated file /Users/tavasthi/baeldung/scala-demo/project/build.properties: set sbt.version to 1.8.3
[info] welcome to sbt 1.8.3 (Homebrew Java 20.0.1)
[info] loading global plugins from /Users/tavasthi/.sbt/1.0/plugins
[info] loading project definition from /Users/tavasthi/baeldung/scala-demo/project
[info] loading settings for project scala-demo from build.sbt ...
[info] set current project to sbt-demo (in build file:/Users/tavasthi/baeldung/scala-demo/)
[info] sbt server started at local:///Users/tavasthi/.sbt/1.0/server/de978fbf3c48749b6213/sock
[info] started sbt server
sbt:sbt-demo>
此外,他创建或更新 project/
和 target/
目录,用于管理项目的生命周期:
$ ls -ll
total 8
-rw-r--r-- 1 tavasthi staff 94 Jul 22 22:40 build.sbt
drwxr-xr-x 4 tavasthi staff 128 Jul 22 22:40 project
drwxr-xr-x 5 tavasthi staff 160 Jul 22 22:41 target
最后,默认情况下,sbt 遵循传统的项目结构方法,src/main
和 src/test
目录分别包含主源代码和测试源代码。因此,让我们使用该约定设置项目结构:
$ mkdir -p src/{main,test}/scala/com/baeldung
我们必须注意,我们创建了目录层次结构,以根据我们在 build.sbt
文件中使用的组织名称来保存我们的文件。
9.2. 编译代码
通过 sbt shell,我们可以方便地编译 Scala 项目。
首先,我们添加一个 Main.scala
文件到源代码中用以打印 “Hello, world!” 文本:
$ cat src/main/scala/com/baeldung/Main.scala
package com.baeldung
object Main {
val helloWorldStr = "Hello, world!"
def main(args: Array[String]): Unit = println(helloWorldStr)
}
类似地,我们在 src/test/scala directory
目录下添加了 DemoTest.scala
测试文件:
$ cat src/test/scala/com/baeldung/DemoTest.scala
package com.baeldung
import org.scalatest.funsuite.AnyFunSuite
class DemoTest extends AnyFunSuite {
test("add two numbers") {
assert(2 + 2 == 4)
}
}
现在,我们准备用 sbt compile
命令编译代码:
$ sbt compile
[info] welcome to sbt 1.8.3 (Homebrew Java 20.0.1)
[info] loading global plugins from /Users/tavasthi/.sbt/1.0/plugins
[info] loading project definition from /Users/tavasthi/baeldung/scala-demo/project
[info] loading settings for project scala-demo from build.sbt ...
[info] set current project to sbt-demo (in build file:/Users/tavasthi/baeldung/scala-demo/)
[info] Executing in batch mode. For better performance use sbt's shell
[success] Total time: 0 s, completed Jul 23, 2023, 12:53:39 PM
很好! 看来我们做对了。
9.3. 依赖管理
我们还可以使用 sbt
管理项目中的依赖关系。为了添加包依赖关系,我们需要更新 build.sbt
文件中的 libraryDependencies
属性。
对于我们的用例,让我们添加一个对 scalatest
包的依赖,这是运行我们的测试代码所必需的:
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.13" % Test
)
我们必须注意,我们使用了 ++=
运算符来扩展配置,并使用 %
作为组织、工件和版本信息之间的分隔符。此外,我们使用 %%
运算符注入项目的 Scala 版本,并使用 Test
关键字定义依赖关系的范围
9.4. 运行 main 代码和测试代码
我们也可以使用 sbt
来运行项目中的 main 代码和测试代码。
首先,我们使用 sbt run
命令来运行 main 代码:
$ sbt run
[info] welcome to sbt 1.8.3 (Homebrew Java 20.0.1)
[info] loading global plugins from /Users/tavasthi/.sbt/1.0/plugins
[info] loading project definition from /Users/tavasthi/baeldung/scala-demo/project
[info] loading settings for project scala-demo from build.sbt ...
[info] set current project to sbt-demo (in build file:/Users/tavasthi/baeldung/scala-demo/)
[info] running com.baeldung.Main
Hello, world!
[success] Total time: 0 s, completed Jul 23, 2023, 1:01:09 PM
完美!我们可以看到 main 代码运行成功。
接下来,我们使用 sbt test
命令来运行项目测试:
$ sbt test
[info] welcome to sbt 1.8.3 (Homebrew Java 20.0.1)
[info] loading global plugins from /Users/tavasthi/.sbt/1.0/plugins
[info] loading project definition from /Users/tavasthi/baeldung/scala-demo/project
[info] loading settings for project scala-demo from build.sbt ...
[info] set current project to sbt-demo (in build file:/Users/tavasthi/baeldung/scala-demo/)
[info] compiling 1 Scala source to /Users/tavasthi/baeldung/scala-demo/target/scala-3.3.0/test-classes ...
[info] DemoTest:
[info] - add two numbers
[info] Run completed in 136 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 2 s, completed Jul 23, 2023, 1:03:52 PM
我们可以看到,测试运行良好。
10. 结论
本文中,我们研究了 Scala 语言,并学习了它的一些关键特性。它为命令式、函数式和面向对象的编程提供了出色的支持。