如果您已经具备一些 Java 经验,那么此页面应该可以很好地概述差异,以及您开始使用 Scala 编程时应该期待什么。为了获得最佳效果,我们建议您在计算机上设置 Scala 工具链,或者尝试在浏览器中使用 Scastie 编译 Scala 代码片段。
概览:为什么选择 Scala?
没有分号的 Java:有一种说法是 Scala 是没有分号的 Java。这句话很有道理:Scala 简化了 Java 中的大部分噪音和样板代码,同时建立在相同的基石之上,共享相同的底层类型和运行时。
无缝互操作性:Scala 可以开箱即用地使用任何 Java 库,包括 Java 标准库!而且几乎所有 Java 程序在 Scala 中都能正常运行,只需转换语法即可。
可扩展的语言:Scala 的名字来源于 Scalable Language(可扩展语言)。Scala 不仅可以随着硬件资源和负载要求的增加而扩展,还可以随着程序员技能水平的提高而扩展。如果你愿意,Scala 会用富有表现力的额外功能来奖励你,与 Java 相比,这些功能可以提高开发人员的生产力和代码的可读性。
与你一起成长:学习这些额外功能是可选的步骤,你可以根据自己的节奏进行学习。我们认为,最有趣和最有效的方法是,首先确保你能够利用从 Java 中获得的知识高效地工作。然后,按照Scala 书籍的步骤,一次学习一项内容。选择适合你的学习节奏,并确保你学习的内容是有趣的。
TL;DR:你可以像使用 Java 一样开始编写 Scala 代码,使用新的语法,然后根据需要进行探索。
下一步
比较 Java 和 Scala
本教程的其余部分将进一步解释 Java 和 Scala 之间的一些关键区别。如果你只想快速了解两者之间的区别,请阅读面向 Java 开发人员的 Scala,它包含许多代码片段,你可以在你选择的 Scala 环境中尝试。
进一步探索
完成这些指南后,我们建议你通过阅读Scala 书籍或参加一些在线 MOOC来继续你的 Scala 之旅。
你的第一个程序
编写 Hello World
作为第一个示例,我们将使用标准的Hello World程序。它不是很有趣,但它可以很容易地演示 Scala 工具的使用,而无需了解太多关于该语言的知识。以下是它的样子
object HelloWorld {
def main(args: Array[String]): Unit = {
println("Hello, World!")
}
}
此程序的结构对于 Java 程序员来说应该很熟悉:它的入口点包含一个名为 main
的方法,该方法接受命令行参数(一个字符串数组)作为参数;此方法的主体包含对预定义方法 println
的单次调用,并以友好的问候语作为参数。 main
方法不返回值。因此,它的返回类型声明为 Unit
(等效于 Java 中的 void
)。
对于 Java 程序员来说,不太熟悉的是包含 main
方法的 object
声明。此类声明引入了通常称为单例对象的内容,即只有一个实例的类。上面的声明因此声明了一个名为 HelloWorld
的类和该类的实例,也称为 HelloWorld
。此实例按需创建,在第一次使用时创建。
与 Java 的另一个区别是 main
方法在这里没有声明为 static
。这是因为 Scala 中不存在静态成员(方法或字段)。Scala 程序员不是定义静态成员,而是在单例对象中声明这些成员。
@main def HelloWorld(args: String*): Unit =
println("Hello, World!")
此程序的结构可能对 Java 程序员来说并不熟悉:没有名为 main
的方法,而是通过添加 @main
注释将 HelloWorld
方法标记为入口点。
程序入口点可以选择接受参数,这些参数由命令行参数填充。这里 HelloWorld
将所有参数捕获在一个名为 args
的可变长度字符串序列中。
该方法的主体包含对预定义方法 println
的单次调用,并以友好的问候语作为参数。 HelloWorld
方法不返回值。因此,它的返回类型声明为 Unit
(等效于 Java 中的 void
)。
对于 Java 程序员来说,更不熟悉的是 HelloWorld
不需要包装在类定义中。Scala 3 支持顶层方法定义,这非常适合程序入口点。
该方法也不需要声明为 static
。这是因为 Scala 中不存在静态成员(方法或字段)。相反,顶层方法和字段是其封闭包的成员,因此可以从程序中的任何地方访问。
实现细节:为了让 JVM 能够执行程序,
@main
注解会生成一个名为HelloWorld
的类,其中包含一个静态main
方法,该方法会使用命令行参数调用HelloWorld
方法。此类仅在运行时可见。
运行 Hello World
注意:以下内容假设您在命令行上使用 Scala
从命令行编译
要编译示例,我们使用 scalac
,即 Scala 编译器。 scalac
的工作原理与大多数编译器类似:它将源文件作为参数,可能还有一些选项,并生成一个或多个输出文件。它生成的输出是标准的 Java 类文件。
如果我们将上面的程序保存在名为 HelloWorld.scala
的文件中,我们可以通过发出以下命令来编译它(大于号 >
代表 shell 提示符,不应键入)
> scalac HelloWorld.scala
这将在当前目录中生成一些类文件。其中一个将被称为 HelloWorld.class
,它包含一个类,可以使用 scala
命令直接执行,如下节所示。
从命令行运行
编译完成后,可以使用 scala
命令运行 Scala 程序。它的用法与用于运行 Java 程序的 java
命令非常相似,并接受相同的选项。上面的示例可以使用以下命令执行,它会产生预期的输出
> scala -classpath . HelloWorld
Hello, World!
使用 Java 库
Scala 的优势之一是它可以非常轻松地与 Java 代码交互。默认情况下会导入 java.lang
包中的所有类,而其他类需要显式导入。
让我们看一个演示这一点的示例。我们想要获取并格式化当前日期,以符合特定国家/地区(例如法国)使用的约定。(瑞士的法语区等其他地区使用相同的约定。)
Java 的类库定义了强大的实用程序类,例如 LocalDate
和 DateTimeFormatter
。由于 Scala 与 Java 无缝互操作,因此无需在 Scala 类库中实现等效类;相反,我们可以导入相应 Java 包的类
import java.time.format.{DateTimeFormatter, FormatStyle}
import java.time.LocalDate
import java.util.Locale._
object FrenchDate {
def main(args: Array[String]): Unit = {
val now = LocalDate.now
val df = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(FRANCE)
println(df.format(now))
}
}
Scala 的导入语句看起来与 Java 的等效语句非常相似,但是它更强大。可以从同一个包中导入多个类,方法是在第一行中将它们括在花括号中。另一个区别是,在导入包或类的所有名称时,在 Scala 2 中,我们使用下划线字符 (_
) 而不是星号 (*
)。
import java.time.format.{DateTimeFormatter, FormatStyle}
import java.time.LocalDate
import java.util.Locale.*
@main def FrenchDate: Unit =
val now = LocalDate.now
val df = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(FRANCE)
println(df.format(now))
Scala 的导入语句看起来与 Java 的等效语句非常相似,但是它更强大。可以从同一个包中导入多个类,方法是在第一行中将它们括在花括号中。与 Java 一样,在 Scala 3 中,我们使用星号 (*
) 来导入包或类的所有名称。
因此,第三行的导入语句会导入 Locale
枚举的所有成员。这使得静态字段 FRANCE
直接可见。
在入口点方法中,我们首先创建一个 Java DateTime
类的实例,其中包含今天的日期。接下来,我们使用 DateTimeFormatter.ofLocalizedDate
方法定义日期格式,传递 LONG
格式样式,然后进一步传递我们之前导入的 FRANCE
本地化。最后,我们根据本地化的 DateTimeFormatter
实例打印当前日期。
为了结束关于与 Java 集成的这一部分,应该注意的是,也可以直接在 Scala 中从 Java 类继承并实现 Java 接口。
旁注:第三方库
通常标准库是不够的。作为一名 Java 程序员,你可能已经了解很多你想在 Scala 中使用的 Java 库。好消息是,与 Java 一样,Scala 的库生态系统建立在 Maven 坐标之上。
大多数 Scala 项目都是用 sbt 构建的:添加第三方库通常由构建工具管理。来自 Java 的你可能熟悉 Maven、Gradle 和其他此类工具。你仍然可以使用它们来构建 Scala 项目,但通常使用 sbt。请参阅 使用这些 来构建 Scala 项目,但通常使用 sbt。请参阅 使用 sbt 设置 Scala 项目 以获取有关如何使用 sbt 构建项目并添加一些依赖项的指南。
一切皆对象
Scala 是一种纯粹的面向对象语言,因为一切都是对象,包括数字或函数。它在这方面与 Java 不同,因为 Java 区分基本类型(如 boolean
和 int
)和引用类型。
数字是对象
由于数字是对象,它们也具有方法。事实上,以下算术表达式
1 + 2 * 3 / x
完全由方法调用组成,因为它等效于上一节中看到的以下表达式
1.+(2.*(3)./(x))
这也意味着 +
、*
等在 Scala 中是字段/方法/等的有效标识符。
函数是对象
忠实于一切都是对象,在 Scala 中,即使函数也是对象,超越了 Java 对 lambda 表达式的支持。
与 Java 相比,函数对象和方法之间几乎没有区别:你可以将方法作为参数传递,将它们存储在变量中,并从其他函数中返回它们,所有这些都不需要特殊语法。这种将函数作为值操作的能力是称为函数式编程的非常有趣的编程范式的基石之一。
为了演示,考虑一个每秒执行某些操作的计时器函数。要执行的操作由调用者作为函数值提供。
在以下程序中,计时器函数被称为 oncePerSecond
,它获取一个回调函数作为参数。此函数的类型写为 () => Unit
,是所有不接受参数且不返回值的函数的类型(如前所述,类型 Unit
类似于 Java 中的 void
)。
此程序的入口点通过直接传递 timeFlies
方法来调用 oncePerSecond
。
最终,这个程序将每秒无限次地打印句子 time flies like an arrow
。
object Timer {
def oncePerSecond(callback: () => Unit): Unit = {
while (true) { callback(); Thread.sleep(1000) }
}
def timeFlies(): Unit = {
println("time flies like an arrow...")
}
def main(args: Array[String]): Unit = {
oncePerSecond(timeFlies)
}
}
def oncePerSecond(callback: () => Unit): Unit =
while true do { callback(); Thread.sleep(1000) }
def timeFlies(): Unit =
println("time flies like an arrow...")
@main def Timer: Unit =
oncePerSecond(timeFlies)
请注意,为了打印字符串,我们使用了预定义的方法 println
,而不是使用来自 System.out
的方法。
匿名函数
在 Scala 中,lambda 表达式被称为匿名函数。当函数非常短,以至于给它们命名可能不必要时,它们很有用。
这是一个修改后的计时器程序版本,它将匿名函数传递给 oncePerSecond
,而不是 timeFlies
object TimerAnonymous {
def oncePerSecond(callback: () => Unit): Unit = {
while (true) { callback(); Thread.sleep(1000) }
}
def main(args: Array[String]): Unit = {
oncePerSecond(() =>
println("time flies like an arrow..."))
}
}
def oncePerSecond(callback: () => Unit): Unit =
while true do { callback(); Thread.sleep(1000) }
@main def TimerAnonymous: Unit =
oncePerSecond(() =>
println("time flies like an arrow..."))
本例中匿名函数的存在由右箭头 (=>
) 表示,不同于 Java 的细箭头 (->
),它将函数的参数列表与其主体分开。在本例中,参数列表为空,因此我们在箭头的左侧放置空括号。函数的主体与上面 timeFlies
的主体相同。
类
如上所述,Scala 是一种面向对象的语言,因此它具有类的概念。(为了完整起见,应该注意的是,一些面向对象的语言没有类的概念,但 Scala 不是其中之一。)Scala 中的类使用与 Java 语法相似的语法声明。一个重要的区别是 Scala 中的类可以有参数。这在以下复数定义中得到了说明。
class Complex(real: Double, imaginary: Double) {
def re() = real
def im() = imaginary
}
这个 Complex
类接受两个参数,它们是复数的实部和虚部。在创建 Complex
类实例时,必须传递这些参数,如下所示
new Complex(1.5, 2.3)
该类包含两个方法,称为 re
和 im
,它们提供对这两个部分的访问。
class Complex(real: Double, imaginary: Double):
def re() = real
def im() = imaginary
这个 Complex
类接受两个参数,它们是复数的实部和虚部。在创建 Complex
类实例时,必须传递这些参数,如下所示
new Complex(1.5, 2.3)
其中 new
是可选的。该类包含两个方法,称为 re
和 im
,它们提供对这两个部分的访问。
应该注意的是,这两个方法的返回类型没有明确给出。它将由编译器自动推断,编译器会查看这些方法的右侧并推断两者都返回类型为 Double
的值。
重要:如果实现发生变化,方法的推断结果类型可能会以微妙的方式发生变化,这可能会产生连锁反应。因此,最好为类的公共成员设置显式结果类型。
对于方法中的局部变量,建议推断结果类型。尝试在类型声明似乎很容易从上下文中推断出来时省略它们,看看编译器是否同意。一段时间后,程序员应该对何时省略类型以及何时显式指定类型有一个很好的感觉。
无参数方法
方法 re
和 im
的一个小问题是,为了调用它们,必须在它们的名字后面加上一对空括号,如下面的例子所示
object ComplexNumbers {
def main(args: Array[String]): Unit = {
val c = new Complex(1.2, 3.4)
println("imaginary part: " + c.im())
}
}
@main def ComplexNumbers: Unit =
val c = Complex(1.2, 3.4)
println("imaginary part: " + c.im())
如果能够像访问字段一样访问实部和虚部,而不用加空括号,那就更好了。这在 Scala 中完全可行,只需将它们定义为无参数方法。这种方法与零参数方法的区别在于,它们在定义和使用时都没有括号。我们的 Complex
类可以改写如下
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
}
class Complex(real: Double, imaginary: Double):
def re = real
def im = imaginary
继承和重写
Scala 中的所有类都继承自一个超类。当没有指定超类时,例如在上一节的 Complex
示例中,scala.AnyRef
被隐式使用。
在 Scala 中,可以重写从超类继承的方法。但是,必须使用 override
修饰符显式指定一个方法重写了另一个方法,以避免意外重写。例如,我们的 Complex
类可以扩展为重新定义从 Object
继承的 toString
方法。
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
override def toString() =
"" + re + (if (im >= 0) "+" else "") + im + "i"
}
class Complex(real: Double, imaginary: Double):
def re = real
def im = imaginary
override def toString() =
"" + re + (if im >= 0 then "+" else "") + im + "i"
我们可以像下面这样调用重写的 toString
方法
object ComplexNumbers {
def main(args: Array[String]): Unit = {
val c = new Complex(1.2, 3.4)
println("Overridden toString(): " + c.toString)
}
}
@main def ComplexNumbers: Unit =
val c = Complex(1.2, 3.4)
println("Overridden toString(): " + c.toString)
代数数据类型和模式匹配
程序中经常出现的一种数据结构是树。例如,解释器和编译器通常将程序内部表示为树;JSON 负载是树;许多类型的容器都是基于树的,例如红黑树。
我们现在将通过一个小型计算器程序来考察如何在 Scala 中表示和操作这种树。该程序的目的是操作非常简单的算术表达式,这些表达式由加法、整数常量和变量组成。这种表达式的两个例子是 1+2
和 (x+x)+(7+y)
。
我们首先要决定这种表达式的表示方法。最自然的方法是树,其中节点是操作(这里为加法),叶子是值(这里为常量或变量)。
在 Java 中,在引入记录之前,这样的树将使用一个抽象的超类来表示树,每个节点或叶子使用一个具体的子类。在函数式编程语言中,人们会使用代数数据类型 (ADT) 来达到同样的目的。
Scala 2 提供了案例类的概念,它介于两者之间。以下是如何使用它们来定义我们示例中树的类型
abstract class Tree
object Tree {
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree
}
类 Sum
、Var
和 Const
被声明为案例类,这意味着它们在几个方面与标准类不同
- 创建这些类的实例时,
new
关键字不是必需的(即,可以写Tree.Const(5)
而不是new Tree.Const(5)
), - 为构造函数参数自动定义 getter 函数(即,可以通过编写
c.v
来获取类Tree.Const
的某个实例c
的v
构造函数参数的值), - 提供
equals
和hashCode
方法的默认定义,它们作用于实例的结构,而不是它们的标识, - 提供
toString
方法的默认定义,并以“源代码形式”打印值(例如,表达式x+1
的树打印为Sum(Var(x),Const(1))
), - 这些类的实例可以通过模式匹配进行分解,我们将在下面看到。
Scala 3 提供了枚举的概念,它可以像 Java 的枚举一样使用,也可以用来实现 ADT。以下是如何使用它们来定义我们示例中树的类型
enum Tree:
case Sum(l: Tree, r: Tree)
case Var(n: String)
case Const(v: Int)
枚举 Sum
、Var
和 Const
的情况类似于标准类,但在几个方面有所不同
- 为构造函数参数自动定义 getter 函数(即,可以通过编写
c.v
来获取案例Tree.Const
的某个实例c
的v
构造函数参数的值), - 提供
equals
和hashCode
方法的默认定义,它们作用于实例的结构,而不是它们的标识, - 提供
toString
方法的默认定义,并以“源代码形式”打印值(例如,表达式x+1
的树打印为Sum(Var(x),Const(1))
), - 这些枚举案例的实例可以通过模式匹配进行分解,我们将在下面看到。
现在我们已经定义了表示算术表达式的數據类型,我们可以开始定义操作来操作它们。我们将从一个函数开始,该函数在某个环境中评估表达式。环境的目的是为变量赋予值。例如,表达式 x+1
在将值 5
与变量 x
关联的环境中进行评估,写成 { x -> 5 }
,结果为 6
。
因此,我们必须找到一种表示环境的方法。当然,我们可以使用一些关联数据结构,比如哈希表,但我们也可以直接使用函数!环境实际上不过是一个将值与(变量)名称关联的函数。上面给出的环境 { x -> 5 }
可以用 Scala 如下写出
type Environment = String => Int
val ev: Environment = { case "x" => 5 }
此表示法定义了一个函数,当给定字符串 "x"
作为参数时,它返回整数 5
,否则抛出异常。
在上面,我们定义了一个名为 Environment
的类型别名,它比普通的函数类型 String => Int
更易读,并且使将来的更改更容易。
现在我们可以给出评估函数的定义。这是一个简短的规范:Sum
的值是其两个内部表达式的评估之和;Var
的值是通过在环境中查找其内部名称获得的;Const
的值是其内部值本身。此规范使用对树值 t
的模式匹配,完全转换为 Scala,如下所示
import Tree._
def eval(t: Tree, ev: Environment): Int = t match {
case Sum(l, r) => eval(l, ev) + eval(r, ev)
case Var(n) => ev(n)
case Const(v) => v
}
import Tree.*
def eval(t: Tree, ev: Environment): Int = t match
case Sum(l, r) => eval(l, ev) + eval(r, ev)
case Var(n) => ev(n)
case Const(v) => v
您可以理解模式匹配的精确含义,如下所示
- 它首先检查树
t
是否为Sum
,如果是,则将左子树绑定到一个名为l
的新变量,将右子树绑定到一个名为r
的变量,然后继续评估箭头后面的表达式;此表达式可以(并且确实)使用模式左侧绑定的变量,即l
和r
, - 如果第一次检查不成功,也就是说,如果树不是一个
Sum
,它会继续检查t
是否是一个Var
;如果是,它将Var
节点中包含的名称绑定到一个变量n
,并继续处理右侧表达式。 - 如果第二次检查也失败,也就是说,如果
t
既不是Sum
也不是Var
,它会检查它是否是一个Const
,如果是,它将Const
节点中包含的值绑定到一个变量v
,并继续处理右侧。 - 最后,如果所有检查都失败,则会抛出一个异常来表示模式匹配表达式的失败;这种情况只会发生在声明了更多
Tree
的子类时。
我们可以看到,模式匹配的基本思想是尝试将一个值与一系列模式匹配,一旦一个模式匹配成功,就提取并命名值的各个部分,最后评估一些通常使用这些命名部分的代码。
与 OOP 的比较
熟悉面向对象范式的程序员可能会想知道为什么在 Tree
范围之外定义一个用于 eval
的单一函数,而不是将 eval
设为 Tree
中的抽象方法,并在每个 Tree
子类中提供覆盖。
实际上我们可以这样做,这是一个需要做出的选择,它对可扩展性有重要影响。
- 当使用方法覆盖时,向树添加一个新的操作意味着对代码进行深远的变化,因为它需要在
Tree
的所有子类中添加该方法,然而,添加一个新的子类只需要在一个地方实现操作。这种设计有利于少数核心操作和许多不断增长的子类。 - 当使用模式匹配时,情况正好相反:添加一种新的节点类型需要修改所有对树进行模式匹配的函数,以考虑新的节点;另一方面,添加一个新的操作只需要在一个地方定义函数。如果你的数据结构有一组稳定的节点,它有利于 ADT 和模式匹配设计。
添加一个新的操作
为了进一步探索模式匹配,让我们定义另一个对算术表达式的操作:符号求导。读者可能还记得关于此操作的以下规则
- 求和的导数等于导数的和。
- 某个变量
v
的导数为 1,如果v
是求导的变量,否则为 0。 - 常数的导数为零。
这些规则几乎可以逐字翻译成 Scala 代码,得到以下定义
import Tree._
def derive(t: Tree, v: String): Tree = t match {
case Sum(l, r) => Sum(derive(l, v), derive(r, v))
case Var(n) if v == n => Const(1)
case _ => Const(0)
}
import Tree.*
def derive(t: Tree, v: String): Tree = t match
case Sum(l, r) => Sum(derive(l, v), derive(r, v))
case Var(n) if v == n => Const(1)
case _ => Const(0)
此函数引入了与模式匹配相关的两个新概念。首先,变量的 case
表达式有一个守卫,即 if
关键字后的表达式。此守卫防止模式匹配成功,除非其表达式为真。这里它用于确保我们仅当被求导的变量名称与求导变量 v
相同的情况下才返回常数 1
。这里使用的模式匹配的第二个新特性是通配符,写成 _
,它是一个匹配任何值的模式,而不给它命名。
我们还没有探索模式匹配的全部功能,但为了使本文简短,我们将在此停止。我们仍然想看看上面两个函数在实际示例中的表现。为此,让我们编写一个简单的 main
函数,它对表达式 (x+x)+(7+y)
执行几个操作:它首先在环境 { x -> 5, y -> 7 }
中计算其值,然后计算其相对于 x
的导数,然后计算其相对于 y
的导数。
import Tree._
object Calc {
type Environment = String => Int
def eval(t: Tree, ev: Environment): Int = ...
def derive(t: Tree, v: String): Tree = ...
def main(args: Array[String]): Unit = {
val exp: Tree = Sum(Sum(Var("x"),Var("x")),Sum(Const(7),Var("y")))
val env: Environment = { case "x" => 5 case "y" => 7 }
println("Expression: " + exp)
println("Evaluation with x=5, y=7: " + eval(exp, env))
println("Derivative relative to x:\n " + derive(exp, "x"))
println("Derivative relative to y:\n " + derive(exp, "y"))
}
}
import Tree.*
@main def Calc: Unit =
val exp: Tree = Sum(Sum(Var("x"),Var("x")),Sum(Const(7),Var("y")))
val env: Environment = { case "x" => 5 case "y" => 7 }
println("Expression: " + exp)
println("Evaluation with x=5, y=7: " + eval(exp, env))
println("Derivative relative to x:\n " + derive(exp, "x"))
println("Derivative relative to y:\n " + derive(exp, "y"))
执行此程序,我们应该得到以下输出
Expression: Sum(Sum(Var(x),Var(x)),Sum(Const(7),Var(y)))
Evaluation with x=5, y=7: 24
Derivative relative to x:
Sum(Sum(Const(1),Const(1)),Sum(Const(0),Const(0)))
Derivative relative to y:
Sum(Sum(Const(0),Const(0)),Sum(Const(0),Const(1)))
通过检查输出,我们看到导数的结果应该在呈现给用户之前进行简化。使用模式匹配定义一个基本的简化函数是一个有趣(但令人惊讶地棘手)的问题,留给读者作为练习。
特征
除了从超类继承代码外,Scala 类还可以从一个或多个特征导入代码。
对于 Java 程序员来说,理解特征的最简单方法可能是将它们视为也可以包含代码的接口。在 Scala 中,当一个类从一个特征继承时,它实现该特征的接口,并继承特征中包含的所有代码。
(请注意,从 Java 8 开始,Java 接口也可以包含代码,可以使用 default
关键字或作为静态方法。)
为了了解特质的用处,让我们看一个经典的例子:有序对象。能够比较给定类中的对象通常很有用,例如对它们进行排序。在 Java 中,可比较的对象实现了 Comparable
接口。在 Scala 中,我们可以比 Java 做得更好,通过将我们的 Comparable
等效物定义为一个特质,我们将它称为 Ord
。
在比较对象时,六个不同的谓词可能有用:小于、小于或等于、等于、不等于、大于或等于、大于。但是,定义所有这些谓词很繁琐,尤其是因为这六个中的四个可以用剩下的两个来表达。也就是说,给定相等和更小的谓词(例如),可以表达其他谓词。在 Scala 中,所有这些观察结果都可以通过以下特质声明很好地捕捉到
trait Ord {
def < (that: Any): Boolean
def <=(that: Any): Boolean = (this < that) || (this == that)
def > (that: Any): Boolean = !(this <= that)
def >=(that: Any): Boolean = !(this < that)
}
trait Ord:
def < (that: Any): Boolean
def <=(that: Any): Boolean = (this < that) || (this == that)
def > (that: Any): Boolean = !(this <= that)
def >=(that: Any): Boolean = !(this < that)
此定义既创建了一个名为 Ord
的新类型,它与 Java 的 Comparable
接口的作用相同,还创建了三个谓词的默认实现,这些实现基于第四个抽象谓词。相等和不等的谓词没有出现在这里,因为它们默认存在于所有对象中。
上面使用的类型 Any
是 Scala 中所有其他类型的超类型。它可以被视为 Java 的 Object
类型的更通用版本,因为它也是基本类型(如 Int
、Float
等)的超类型。
因此,要使某个类的对象可比较,只需定义测试相等和劣等的谓词,并将上面的 Ord
类混合进来。例如,让我们定义一个 Date
类,表示公历中的日期。这样的日期由日、月和年组成,我们都将它们表示为整数。因此,我们从 Date
类的定义开始,如下所示
class Date(y: Int, m: Int, d: Int) extends Ord {
def year = y
def month = m
def day = d
override def toString(): String = s"$year-$month-$day"
// rest of implementation will go here
}
class Date(y: Int, m: Int, d: Int) extends Ord:
def year = y
def month = m
def day = d
override def toString(): String = s"$year-$month-$day"
// rest of implementation will go here
end Date
这里最重要的部分是类名和参数后面的 extends Ord
声明。它声明 Date
类继承自 Ord
特征。
然后,我们重新定义从 Object
继承的 equals
方法,以便它通过比较各个字段来正确比较日期。 equals
的默认实现不可用,因为与 Java 一样,它通过标识来比较对象。我们得到了以下定义
class Date(y: Int, m: Int, d: Int) extends Ord {
// previous decls here
override def equals(that: Any): Boolean = that match {
case d: Date => d.day == day && d.month == month && d.year == year
case _ => false
}
// rest of implementation will go here
}
class Date(y: Int, m: Int, d: Int) extends Ord:
// previous decls here
override def equals(that: Any): Boolean = that match
case d: Date => d.day == day && d.month == month && d.year == year
case _ => false
// rest of implementation will go here
end Date
虽然在 Java(16 之前)中,你可能会使用 instanceof
运算符,然后进行强制转换(相当于在 Scala 中调用 that.isInstanceOf[Date]
和 that.asInstanceOf[Date]
);在 Scala 中,使用类型模式更惯用,如上例所示,它检查 that
是否是 Date
的实例,并将其绑定到一个新的变量 d
,然后在 case
的右侧使用它。
最后,要定义的最后一个方法是 <
测试,如下所示。它使用另一个方法 error
,该方法来自包对象 scala.sys
,它使用给定的错误消息抛出异常。
class Date(y: Int, m: Int, d: Int) extends Ord {
// previous decls here
def <(that: Any): Boolean = that match {
case d: Date =>
(year < d.year) ||
(year == d.year && (month < d.month ||
(month == d.month && day < d.day)))
case _ => sys.error("cannot compare " + that + " and a Date")
}
}
class Date(y: Int, m: Int, d: Int) extends Ord:
// previous decls here
def <(that: Any): Boolean = that match
case d: Date =>
(year < d.year) ||
(year == d.year && (month < d.month ||
(month == d.month && day < d.day)))
case _ => sys.error("cannot compare " + that + " and a Date")
end <
end Date
这完成了 Date
类的定义。此类的实例可以看作是日期或可比较对象。此外,它们都定义了上面提到的六个比较谓词:equals
和 <
,因为它们直接出现在 Date
类的定义中,而其他谓词则是因为它们从 Ord
特征继承而来。
当然,特征在其他情况下也很有用,但详细讨论它们的应用超出了本文档的范围。
泛型
在本教程中,我们将探讨的 Scala 的最后一个特性是泛型。Java 程序员应该很清楚他们的语言中缺乏泛型带来的问题,Java 1.5 中解决了这一缺陷。
泛型是指编写以类型为参数的代码的能力。例如,编写链表库的程序员面临着决定链表元素类型的难题。由于此链表旨在用于许多不同的上下文中,因此无法决定元素类型必须是 Int
。这将是完全任意的,并且过于限制。
Java 程序员会使用 Object
,它是所有对象的超类型。然而,这种解决方案远非理想,因为它不适用于基本类型(int
、long
、float
等),并且意味着程序员必须插入大量的动态类型转换。
Scala 使得定义泛型类(和方法)成为可能,以解决这个问题。让我们以最简单的容器类为例来考察一下:一个引用,它可以为空或指向某个类型的对象。
class Reference[T] {
private var contents: T = _
def set(value: T): Unit = { contents = value }
def get: T = contents
}
类 Reference
由一个类型参数化,称为 T
,它是其元素的类型。此类型在类的主体中用作 contents
变量的类型、set
方法的参数以及 get
方法的返回值类型。
上面的代码示例介绍了 Scala 中的变量,这应该不需要进一步解释。然而,有趣的是,赋予该变量的初始值为 _
,它代表一个默认值。此默认值为数值类型的 0
、Boolean
类型的 false
、Unit
类型的 ()
以及所有对象类型的 null
。
import compiletime.uninitialized
class Reference[T]:
private var contents: T = uninitialized
def set(value: T): Unit = contents = value
def get: T = contents
类 Reference
由一个类型参数化,称为 T
,它是其元素的类型。此类型在类的主体中用作 contents
变量的类型、set
方法的参数以及 get
方法的返回值类型。
上面的代码示例介绍了 Scala 中的变量,这应该不需要进一步解释。然而,有趣的是,赋予该变量的初始值为 uninitialized
,它代表一个默认值。此默认值为数值类型的 0
、Boolean
类型的 false
、Unit
类型的 ()
以及所有对象类型的 null
。
要使用此 Reference
类,需要指定要用于类型参数 T
的类型,即单元格中包含的元素的类型。例如,要创建和使用一个保存整数的单元格,可以编写以下代码
object IntegerReference {
def main(args: Array[String]): Unit = {
val cell = new Reference[Int]
cell.set(13)
println("Reference contains the half of " + (cell.get * 2))
}
}
@main def IntegerReference: Unit =
val cell = new Reference[Int]
cell.set(13)
println("Reference contains the half of " + (cell.get * 2))
正如该示例所示,在将 get
方法返回的值用作整数之前,无需对其进行类型转换。此外,由于该特定单元格被声明为保存整数,因此无法存储除整数以外的任何内容。
结论
本文简要概述了 Scala 语言,并提供了一些基本示例。有兴趣的读者可以继续学习,例如阅读Scala 语言之旅,其中包含更多解释和示例,并在需要时查阅Scala 语言规范。