编程

Scala 介绍及入门教程

441 2024-09-12 01:13:00

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-langscala-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 没有 continuebreak 关键字。也没有 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  是一个生成器表达式,他生成一系列从 ab 的值。

然后对序列中的每个值执行 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)
}

这里,rm 是函数(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))
}

上面,summod 每个都有两个参数列表。

我们传递两个参数列表,如 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 的,除非有  privateprotected 访问修饰符。

要重写超类中的方法必须使用 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)"
}

在这里,我们指定了三个构造函数参数——namesalaryannualIncrement

由于我们使用 valvar 关键字声明 namepay,因此相应的成员是公开的。另一方面,我们没有对 annualIncrement 参数使用 valvar 关键字。因此,相应的成员是私有的。当我们为这个参数指定默认值时,我们可以在调用构造函数时省略它。

除了字段外,我们还定义了方法 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/mainsrc/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 语言,并学习了它的一些关键特性。它为命令式、函数式和面向对象的编程提供了出色的支持。