scala教程

介绍

本文档是 Scala 语言和编译器的快速入门介绍,适合已经有一定编程经验,且希望了解Scala 可以做什么的读者。我们假定本文的读者具有面向对象编程(尤其是 java 相关)的基础知识。

第一个例子

我们使用最经典的“Hello world”作为第一个例子,这个例子虽然并不是特别炫,但它可以很好的展示 Scala 的用法,且无须涉及太多的语言特性。示例代码如下:

1
2
3
4
5
object HelloWorld {
def main(args: Array[String]) {
println("Hello, world!")
}
}

对于包含 main 方法的 object 声明,Java 程序员可能要相对陌生一些。这种声明方式引入了一个通常被称为单例对象(singleton object)的概念,也就是有且仅有一个实例的类。因此,上例中的声明,在定义了一个名为的 HelloWorld 类的同时,还声明了该类的一个实例,实例的名字也叫 HelloWorld 。该实例在第一次被用到的时候即时(on demand)创建。

细心的读者可能会注意到, main 方法并没有声明为 static 。这是因为 Scala 中不存在静态成员(无论方法还是属性)这一概念,Scala 使用前述的单例对象中的成员来代替静态成员。

编译该示例

要编译上面写的例子,要 scalac 命令,这就是 Scala 的编译器。 scalac 的工作流程和多数编译器类似:从命令行上接收待编译的源文件名以及编译参数,生成一个或者多个目标文件。Scala 生成的目标文件是标准的 java class 文件。
假如我们将 HelloWorld 示例程序存放到 HelloWorld.scala 文件中,则可以用以下指令进行编译:

scalac HelloWorld.scala

该指令执行后,会在当前目录下生成几个 class 文件,其中一个是 HelloWorld.class,该文件中包含一个可以直接被 scala 指令执行的类(class)。

运行该示例

代码编译通过以后,可以使用 scala 指令运行程序, scala 指令和 java 指令的用法非常相似,甚至它们接受的命令行参数都是一样的。前面编译好的例子,可以用如下指令运行,并输出预期的问候语:

和 Java 进行交互

和 Java 代码的交互能力,是 Scala 语言的强项之一。在 Scala 程序中, java.lang包下的类是默认全部引入的,其它包下的类则需要显式引入。

我们可以通过一个例子来展示Scala与Java的交互能力。假如,我们想获取系统当前时间,并按照某个国家(比如法国)的显示习惯进行格式化。

我们知道,在 Java 的类库中已经实现了 Date、DateFormat 等功能强大的工具类,且Scala 可以和 Java 进行无缝的互操作,所以,想在 Scala 程序中使用这些功能,只需要引入这些 Java 类即可,无须从头重复实现相同的功能。

1
2
3
4
5
6
7
8
9
10
import java.util.{Date, Locale}
import java.text.DateFormat
import java.text.DateFormat._
object FrenchDate {
def main(args: Array[String]) {
val now = new Date
val df = getDateInstance(LONG, Locale.FRANCE)
println(df format now)
}
}

Scala 的 import 语句和 Java 中的 import 很想象,但 Scala 的语法更强大一些。比如,要想引入一个包中的多个类,在 Scala 中可以写在一行上,只需要把多个类名放到一个大括号中即可。此外,如果要引入一个包或者类中的所有名字,Scala 使用下划线而不是星号,这是因为,在 Scala中,星号是一个合法的标识符(比如:方法名),后面我们会遇到这种情况。

在 main 方法中,我们首先创建一个 Java 的 Date 实例,该实例默认取得系统当前时间;接下来,我们使用从 DateFormat 类中引入的静态方法 getDateInstance 创建一个 负 责 日 期 格 式 化 的 对 象 df , 创 建 过 程 中 , 通 过 参 数 指 定 了 本 地 化 区 域( Locale.FRANCE );最后,使用 df 将当前时间进行格式化并打印输出到控制台。这个方法的最后一行,体现了 Scala 语法中一种很有意思的特性:如果一个方法只接受一个参数,那么可以使用 infix 语法,也就是说,下面的表达式:

df format now

df.format(now) 的语义完全相同,只是前者更加简洁。

一切皆对象

Scala 中的一切都是对象,从这个意义上说,Scala 是纯粹的面向对象的语言。在这一点上,Scala 与 Java 不同,因为 Java 中,原子类型和引用类型是有区别的,而且 Java 中不能把函数当做值来对待。

数字是对象

因为数字是对象,所以数字也拥有自己的方法,如下的算术表达式:

1+2*3/x

实际上完全是由方法调用构成的。前面章节已经提到过“单参数方法”的简化写法,所以,上述表达式实际上是下面这个表达式的等价简化写法:

(1).+(((2).*(3))./(x))

由此我们还可以看到: + , * 等符号在 Scala 中是合法的标识符(和前面进行印证)。

函数是对象

scala 函数教程

如果函数没有返回值,则可以省略函数定义中的等号,返回值类型为Unit(相当于Java中的void)

在 Scala 中,函数也是对象,所以,函数可以当做参数进行传递,可以把函数存储在变量中,也可以把函数作为其他函数的返回值,Java 程序员可能会觉得这是一项非常神奇的特性。这种将函数当做值进行操作的能力,是函数式编程最重要的特性之一。

匿名函数

对于只使用一次的函数,可以定义为匿名函数,可以省去定义和命名的麻烦。下面给出示例代码:

1
2
3
4
5
6
7
8
9
object TimerAnonymous {
def oncePerSecond(callback: () => Unit) {
while (true) { callback(); Thread sleep 1000 }
}
def main(args: Array[String]) {
oncePerSecond(() =>
println("time flies like an arrow..."))
}
}

代码中的右箭头‘=>’表明程序中存在一个匿名函数,箭头左边是匿名函数的参数列表,右边是函数体。在本例中,参数列表为空(箭头左边是一对空括号)。

前面已经说过,Scala是面向对象的语言,所以它有类(class)的概念 。Scala中声明类的语法和Java类似,但有一点重要的差异,那就是Scala中的类定义可以带参数下面定义的复数类可以很清晰的展示这一特性:

1
2
3
4
class Complex(real: Double, imaginary: Double) {
def re() = real
def im() = imaginary
}

该复数类可以接受两个参数,分别代表复数的实部和虚部,如果要创建 Complex 类的实例,则必须提供这两个参数,比如: new Complex(1.5, 2.3) 。该类有两个方法: re和 im ,分别用于访问复数的实部和虚部。

需要注意的是,这两个方法的返回值都没有显式定义。在编译过程中,编译器可以根据函数定义的右部,推断出两个函数的返回值都是 Double类型。

无参方法

Complex 类中的 re 和 im 方法有个小问题,那就是调用这两个方法时,需要在方法名后面跟上一对空括号,就像下面的例子一样:

1
2
3
4
5
6
object ComplexNumbers {
def main(args: Array[String]) {
val c= new Complex(1.2, 3.4)
println("imaginary part: " + c.im())
}
}

如果能够省掉这些方法后面的空括号,就像访问类属性(fields)一样访问类的方法,则程序会更加简洁。这在 Scala 中是可行的,只需将方法显式定义为没有参数即可。无参方法和零参方法的差异在于:无参方法在声明和调用时,均无须在方法名后面加括号。所以,前面的 Complex 类可以重写如下:

1
2
3
4
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
}

继承和方法重写

Scala 中的所有类都继承自某一个父类(或者说超类),若没有显式指定
父类(比如前面的 Complex 类),则默认继承自 scala.AnyRef

在 Scala 中可以重写(overriding)从父类继承的方法,但必须使用 override 修饰符来显式声明,这样可以避免无意间的方法覆盖。例如,前面定义的 Complex 类中,我们可以重写从 Object 类中继承的 toString 方法,代码如下:

1
2
3
4
5
6
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
override def toString() =
"" +re + (if (im < 0) "" else "+") + im + "i"
}

条件类和模式匹配(Case classes and pattern matching)

树是软件开发中使用频率很高的一种数据结构,例如:解释器和编译器内部使用树来表示代码结构;XML 文档是树形结构;还有一些容器(集合)也是基于树的,比如:红黑树。

接下来,我们通过一个示例程序,了解在 Scala 中如何表示和操作树形结构,这个示例将实现非常简单的计算器功能,该计算器可以处理包含加法、变量和整数常量的算术表达式,比如:1 + 2、(x + x) + (7 + y)等。

首先,我们要决定如何表示这样的表达式。最自然的选择是树形结构,用非叶子节点表示操作符(具体到这个例子,只有 加法操作),用叶子节点表示操作数(具体到这个例子是常量和变量)。

如果是在 Java 中,建立树形结构最常见的做法是:创建一个表示树的抽象类,然后每种类型的节点用一个继承自抽象类的子类来表示。而在函数式编程语言中,则可以使用代数数据类型(algebraic data-type)来达到同样的目的。Scala 则提供了一种介于两者之间(类继承和代数数据类型),被称为条件类(case classes)的概念,下面就是用条件类定义树的示例代码:

1
2
3
4
abstract class 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 关键字(比如,可以直接用 Const(5)代替 newConst(5) 来创建实例)。
  • 自动为构造函数所带的参数创建对应的 getter 方法(也就是说,如果 c 是Const 的实例,通过 c.v 即可访问构造函数中的同名参数v 的值)
  • 条件类都默认实现 equals 和 hashCode 两个方法,不过这两个方法都是基于实例的结构本身,而不是基于实例中可用于区分的值,这一点和 java 中 Object提供的同名方法的默认实现是基本一致的。
  • 条件类还提供了一个默认的 toString 方法,能够以源码形式打印实例的值(比如,表达式 x+1 会被打印成Sum(Var(x),Const(1)) ,这个打印结果,和源代码中创建表达式结构树的那段代码完全一致)。
  • 条件类的实例可以通过模式匹配(pattern matching)进行分解(decompose),接下来会详细介绍。

既然我们已经定义了用于表示算术表达式的数据结构,接下来我们可以定义作用在这些数据结构上的操作。首先,我们定义一个在特定环境(environment,上下文)中对表达式进行求值的函数,其中环境的作用是为了确定表达式中的变量的取值。例如:有一个环境,对变量 x 的赋值为 5,我们记为:{x → 5},那么,在这个环境上求 x + 1的值,得到的结果为 6。

在程序中,环境也需要一种合理的方式来表示。可以使用哈希表(hash table)之类的数据结构,也可以直接使用函数(functions)!实际上,环境就是一个给变量赋予特定值的函数。上面提到的环境:{x → 5},在 Scala 中可以写成:

{ case "x" => 5 }

上面这一行代码定义了一个函数,如果给该函数传入一个字符串 “x” 作为参数,则函数返回整数 5,否则,将抛出异常。

在写表达式求值函数之前,我们还要对环境的类型(type of the environments)进行命名。虽然在程序中全都使用 String => Int 这种写法也可以的,但给环境起名后,可以简化代码,并使得将来的修改更加方便(这里说的环境命名,简单的理解就是宏,或者说是自定义类型)。在 Scala 中,使用如下代码来完成命名:

type Environment = String => Int

此后,类型名 Environment 可以作为“从 String 转成 Int” 这一类函数的别名。

现在,我们来写求值函数。求值函数的实现思路很直观:两个表达式之和(sum),等于分别对两个表达式求值然后求和;变量的值直接从环境中获取;常量的值等于常量本身。在 Scala 中描述这个概念并不困难:

1
2
3
4
5
def eval(t: Tree, env: Environment): Int = t match {
case Sum(l, r) => eval(l, env) + eval(r, env)
case Var(n) => env(n)
case Const(v) => v
}

求值函数的工作原理是对树 t 上的结点进行模式匹配,下面是对匹配过程的详细描述(实际上是递归):

  1. 求值函数首先检查树 t 是不是一个求和( Sum ),如果是,则把 t 的左子树和右子树分别绑定到两个新的变量 l 和 r 上,然后对箭头右边的表达式进行运算(实际上就是分别求左右子树的值然后相加,这是一个递归)。箭头右边的表达式可以使用箭头左边绑定的变量,也就是 l 和 r 。
  2. 如果第一个检查不满足,也就是说,树 t 不是 Sum ,接下来就要检查 t 是不是一个变量 Var ;如果是,则 Var 中包含的名字被绑定到变量 n 上,然后继续执行箭头右边的逻辑。
  3. 如果第二个检查也不满足,那意味着树t 既不是 Sum ,也不是 Var ,那就进一步检查 t 是不是常量 Const 。如果是,则将常量所包含的值赋给变量 v ,然后继续执行箭头右边的逻辑。
  4. 最后,如果以上所有的检查都不满足,程序会抛出异常,表明对表达式做模式匹配时产生了错误。这种情况,在本例中,只有声明了更多 Tree 的子类,却没有增加对应的模式匹配条件时,才会出现。

通过上例,我们可以看到,模式匹配的过程,实际上就是把一个值和一系列的模式进行比对,如果能够匹配上,则从值中取出有用的部件进行命名,然后用这些命名的部件(作为参数)来驱动另一段代码的执行。

一个有经验的面向对象程序员可能会问:为什么不把 eval 定义成类 Tree 的成员方法?事实上,这么做也行,因为在 Scala 中,条件类和普通类一样,都可以定义方法。不过,“模式匹配”和“类方法”除了编程风格的差异,也各有利弊,决策者需要根据程序的扩展性需求做出权衡和选择:

  • 使用类方法,添加一种新的节点类型比较简单,因为只需要增加一个 Tree的 子类即可。但是,要在树上增加一种新的操作则比较麻烦,因为这需要修改 Tree 的所有子类。
  • 使用模式匹配,情况则刚好相反:增加一种新的节点类型需要修改所有作用在树上的模式匹配函数;而增加新的操作则比较简单,只需要增加一个新的函数即可。

模式匹配的功能非常强大,但限于本文的长度和定位,我们将不再做太多深入的讨论,接下来,我们还是通过一个实例,来看看前面定义的函数如何使用吧。为此,我们编写一个main 函数,在函数中,先创建一个表达式:(x + x) + (7 + y),然后在环境{x → 5, y → 7}上求表达式的值。

1
2
3
4
5
6
def main(args: Array[String]) {
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))
}

Traits(特征、特性)

Scala 中的类不但可以从父类继承代码,还可以从一个或者多个 traits 引入代码。
对于 Java 程序员来说,理解 traits 最简单的方法,是把它当作可以包含代码的接口。在 Scala 中,如果一个类继承自某个 trait,则该类实现了 trait 的接口,并继承了 trait 的所有代码。

我们用一个经典的例子:有序对象来展示 trait 的作用。有很多应用场景,需要在同类对象之间比较大小,比如排序算法。在 Java 中,可以实现Comparable 接口,而在 Scala 中,有更好的办法,那就是定义一个和Comparable 对等的 trait,名为: Ord 。

对象之间做比较,需要六种断言:小于,小于等于,等于,不等于,大于等于,大于。不过,这六种断言中的四种,可以用另外两种进行表述,比如,只要确定了等于和小于两种断言,其它四种就可以推导出来,所以,并不是每种断言都需要由具体类来实现(实现在 trait 上即可,相当于抽象类)。基于以上的分析,我们用下面的代码定义一个 trait:

1
2
3
4
5
6
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)
}

以上代码,定义了和 java 中 Comparable 接口对等的 trait: Ord ,同时,默认实现了三个断言,这三个断言依赖的第四个是抽象的(留给具体类实现)。等于和不等于默认存在于所有对象上,因此这里不需要显式定义。

代码中用到的类型 Any 是 Scala 中所有类型的超类。它比 java 中的 Object 类型更加通用,因为基本类型如: Int , Float 也是继承自该类的。

要想让一个类的实例可比,只需要继承前面定义的 Ord trait,并实现相等和小于两个断言即可。接下来还是用例子说话,我们定义一个 Date 类,这个类使用三个整数分别表示公历的年、月、日,该类继承自 Ord ,代码如下:

1
2
3
4
5
6
class Date(y: Int, m: Int, d: Int) extends Ord {
def year = y
def month = m
def day = d

override def toString(): String = year + "-" + month + "-" + day

请重点关注代码中紧跟在类名和参数后面的 extends Ord ,这是 Date 类声明继承自 Ord strait 的语法。
接下来,我们要重写(redefine)从 Object 上继承的 equals 方法,该方法的默认实现是比较对象的天然特性(比如内存地址),而 Date 类需要比较年、月、日字段的值才能确定大小。

1
2
3
4
5
override def equals(that: Any): Boolean =
that.isInstanceOf[Date] && {
val o = that.asInstanceOf[Date]
o.day == day && o.month == month && o.year == year
}

以上代码用到了两个预定义的方法: isInstanceOf 和 asInstanceOf 。其中isInstanceOf 方法对应 java 中的 instanceof 操作符,当且仅当一个对象的类型和方法参数所指定类型匹配时,才返回 true; asInstanceOf 方法对应 java 中的 cast 强制类型转换操作:如果当前对象是特定类的实例,转换成功,否则抛出 ClassCastException异常。

最后,还需要定义一个判断小于的函数,该函数又用到了一个预定义方法:error ,作用是抛出异常,并附带指定的错误信息。代码如下:

1
2
3
4
5
6
7
8
9
def <(that: Any): Boolean = {
if(!that.isInstanceOf[Date])
error("cannot compare " + that + "and a Date")

val o = that.asInstanceOf[Date]
(year < o.year) ||
(year == o.year && (month < o.month ||
(month == o.month && day < o.day)))
}

至此, Date 类就写完了,该类的实例既可以被看作是一个日期(dates),也可以被看作一个可比的对象,并且,无论那种看法,他们都有六个用作比较的断言,其中, equals和 < 直接定义在 Date 类上,而其它四个则继承自 Ord trait。

泛型

范型就是定义以类型为参数的类或接口(Scala中为特征)的功能。Java里从JDK5开始就有了范型,想必知道的人应该比较多了,下面就简单举例说明一下。
例如,假设有如下的代码片段。这里java.util.List是范型接口,String就是赋给它的类型参数。

1
java.util.List<String> strs = new java.util.ArrayList<String>();

这样,就可以用如下方法将String类型(或子类型)的对象加入List中了。

1
strs.add("hoge");

Scala的范型与Java是非常相似的,基本上可以同样地使用,只是在标记方法上有些区别。以下是同刚才Java代码基本相同的Scala代码。

1
var strs: java.util.List[String] = new java.util.ArrayList

Scala中用[..]来代替了Java中的< ..>来表现类型参数表。附带提一下,与Java有一点小的不同,Scala在new ArrayList时不需要指定String类型参数,这是编译器的类型推断起了效用(显示指定也是可以的)。


参考资料:

  1. scala 教程
  2. 菜鸟教程