20.1 Scala简介

本节会简要地介绍Scala的一些基本特性,让你有一个比较直观的感受:到底简单的Scala程序怎么编写。我们从一个略微改动的Hello World示例入手,该程序会以两种方式编写,一种是命令式的风格,另一种是函数式的风格。接着,我们会看看Scala都支持哪些数据结构——ListSetMapStreamTuple以及Option——并将它们与Java中对应的数据结构一一进行比较。最后,我们会介绍trait,它是Scala中接口的替代品,支持在对象实例化时对方法进行继承。

20.1.1 你好,啤酒

我们对经典的Hello World示例进行了微调,让我们来点儿啤酒。你希望在屏幕上打印输出下面这些内容:

  1. Hello 2 bottles of beer
  2. Hello 3 bottles of beer
  3. Hello 4 bottles of beer
  4. Hello 5 bottles of beer
  5. Hello 6 bottles of beer
  • 命令式Scala

下面这段代码中,Scala以命令式的风格打印输出这段内容:

  1. object Beer {
  2. def main(args: Array[String]){
  3. var n : Int = 2
  4. while( n <= 6 ){
  5. println(s"Hello ${n} bottles of beer") ←---- 在字符串中插值
  6. n += 1
  7. }
  8. }
  9. }

如何运行这段代码的指导信息可以在Scala的官方网站找到。这段代码看起来和你用Java编写的程序相当类似。它的结构和Java程序几乎一样:包含了一个名为main的方法,该方法接受一个由参数构成的数组(类型注释遵循s : String这样的语法,不像Java那样用String s)。由于main方法不返回值,因此使用Scala不需要像Java那样声明一个类型为void的返回值。

注意 通常而言,在Scala中声明非递归的方法时,不需要显式地返回类型,因为Scala会自动地替你推断生成一个。

转入main的方法体之前,我们想先讨论下对象的声明。不管怎样,Java中的main方法都需要在某个类中声明。对象的声明产生了一个单例的对象:它声明了一个对象,比如Beer,与此同时又对其进行了实例化。整个过程中只有一个实例被创建。这是第一个以经典的设计模式(即单例模式)实现语言特性的例子——尽量不拘一格地使用它!此外,你可以将对象声明中的方法看成静态的,这也是main方法的方法签名中并未显式地声明为静态的原因。

现在来看看main的方法体。它看起来和Java非常类似,但是语句不需要再以分号结尾了(它成了一种可选项)。方法体中包含了一个while循环,它会递增一个可修改变量n。通过预定义的方法println,你可以打印输出n的每一个新值。println这一行还展示了Scala的另一个特性:字符串插值。字符串插值在字符串的字面量中内嵌变量和表达式。前面的这段代码中,你在字符串字面量s"Hello ${n} bottles of beer"中直接使用了变量n。字符串前附加的插值操作符s,神奇地完成了这一转变。而在Java中,你通常需要使用显式的连接操作,比如"Hello " + n + " bottles of beer",才能达到同样的效果。

  • 函数式Scala

那么,Scala到底能带来哪些好处呢?毕竟本书主要讨论的还是函数式。前面的这段代码利用Java的新特性能以更加函数式的方式实现,如下所示:

  1. public class Foo {
  2. public static void main(String[] args) {
  3. IntStream.rangeClosed(2, 6)
  4. .forEach(n -> System.out.println("Hello " + n +
  5. " bottles of beer"));
  6. }
  7. }

如果以Scala来实现,它是下面这样的:

  1. object Beer {
  2. def main(args: Array[String]){
  3. 2 to 6 foreach { n => println(s"Hello ${n} bottles of beer") }
  4. }
  5. }

这种实现看起来和基于Java的版本有几分相似,不过Scala的实现更加简洁。首先,你使用表达式2 to 6创建了一个区间。这看起来相当特别: 2在这里并非原始数据类型,在Scala中它是一个类型为Int的对象。在Scala语言中,任何事物都是对象;不像Java那样,Scala没有原始数据类型一说了。通过这种方式,Scala被转变成为了纯粹的面向对象语言。Scala语言中Int对象支持名为to的方法,它接受另一个Int对象,返回一个区间。所以,你还可以通过另一种方式实现这一语句,即2.to(6)。由于接受一个参数的方法可以采用中缀式表达,因此你可以用开头的方式实现这一语句。紧接着,我们看到了foreach(这里的e采用的是小写),它和Java中的forEach(使用了大写的E)也很类似。它是对一个区间进行操作的函数(这里你可以再次使用中缀表达式),它可以接受Lambda表达式做参数,对区间的每一个元素顺次执行操作。这里Lambda表达式的语法和Java也非常类似,区别是箭头的表示用=>替换了-> 1。前面的这段代码是函数式的:因为就像早期使用while循环时示例的那样,你并未修改任何变量。

1注意,在Scala语言中,我们使用匿名函数或者闭包(可以互相替换)来指代Java中的Lambda表达式。

20.1.2 基础数据结构:ListSetMapTupleStream以及Option

几杯啤酒之后,你一定已经止住口渴,精神一振了吧?大多数的程序都需要操纵和存储数据,那么,就让我们一起看看如何在Scala中操作集合,以及它与Java中操作的不同。

  • 创建集合

在Scala中创建集合非常简单,这主要归功于它对简洁性的一贯坚持。比如,你可以通过下面这种方式创建一个Map

  1. val authorsToAge = Map("Raoul" -> 23, "Mario" -> 40, "Alan" -> 53)

这行代码中,有几件事情是我们首次碰到的。首先,你使用->语法轻而易举地创建了一个Map,并完成了键到值的映射,整个过程令人吃惊地简单。你不再需要像Java中那样手工添加每一个元素:

  1. Map<String, Integer> authorsToAge = new HashMap<>();
  2. authorsToAge.put("Raoul", 23);
  3. authorsToAge.put("Mario", 40);
  4. authorsToAge.put("Alan", 53);

不过,第8章中介绍过,受Scala的影响,Java 9也提供了一系列的工厂方法,可以帮助程序员以更简洁的方式书写代码:

  1. Map<String, Integer> authorsToAge
  2. = Map.ofEntries(entry("Raoul", 23),
  3. entry("Mario", 40),
  4. entry("Alan", 53));

第二件让人耳目一新的事是你可以选择不对变量authorsToAge的类型进行注解。实际上,你可以编写val authorsToAge : Map[String, Int]这样的代码,显式地声明变量类型,不过Scala可以替你推断变量的类型(请注意,即便如此,代码依旧会执行静态检查!所有变量在编译时都具有确定的类型)。第21章会继续讨论这一特性。第三,你可以使用val关键字替换var。二者之间存在什么差别吗?关键字val表明变量是只读的,并由此不能被赋值(就像Java中声明为final的变量一样)。而关键字var表明变量是可以读写的。

听起来不错,那么其他的集合类型呢?你可以用同样的方式轻松地创建List(一种单向链表)或者Set(不带冗余数据的集合),如下所示:

  1. val authors = List("Raoul", "Mario", "Alan")
  2. val numbers = Set(1, 1, 2, 3, 5, 8)

这里的变量authors包含三个元素,而变量numbers包含五个元素。

  • 不可变与可变的比较

Scala的集合有一个重要的特质我们应该牢记在心,那就是我们之前创建的集合在默认情况下都是不可变的。这意味着它们从创建开始就不能修改。这是一种非常有用的特性,因为有了它,你知道任何时候访问程序中的集合都会返回包含相同元素的集合。

那么,你怎样才能更新Scala语言中不可变的集合呢?回到上一章中介绍的术语,Scala中的这些集合都是持久化的:更新一个Scala集合会生成一个新的集合,这个新的集合和之前版本的集合共享大部分的内容,最终的结果是数据尽可能地实现了持久化,避免了图19-3和图19-4中那样由于改变所引起的问题。由于具备这一属性,你代码的隐式数据依赖更少: 对你代码中集合变更的困惑(比如在何处更新了集合,什么时候做的更新)也会更少。

来看一个实际的例子,具体分析下这一思想是如何影响你的程序设计的。下面这段代码中,我们会为Set添加一个元素:

  1. val numbers = Set(2, 5, 3);
  2. val newNumbers = numbers + 8 ←---- 这里的操作符+会将8添加到Set中,创建并返回一个新的Set对象
  3. println(newNumbers) ←---- (2, 5, 3, 8)
  4. println(numbers) ←---- (2, 5, 3)

这个例子中,原始Set对象中的数字没有发生变更。实际的效果是该操作创建了一个新的Set,并向其中加入了一个新的元素。

注意,Scala语言并未强制你使用不可变集合,它只是让你能更轻松地在你的代码中应用不可变原则。scala.collection.mutable包中也包含了集合的可变版本。

不可修改与不可变的比较

Java中提供了多种方法以创建不可修改的(unmodifiable)集合。下面的代码中,变量newNumbers是集合Set对象numbers的一个只读视图:

  1. Set<Integer> numbers = new HashSet<>();
  2. Set<Integer> newNumbers = Collections.unmodifiableSet(numbers);

这意味着你无法通过操作变量newNumbers向其中加入新的元素。不过,不可修改集合仅仅是对可变集合进行了一层封装。通过直接访问numbers变量,你还是能向其中加入元素。

与此相反,不可变(immutable)集合确保了该集合在任何时候都不会发生变化,无论有多少个变量同时指向它。

第19章介绍过如何创建一个持久化的数据结构:你需要创建一个不可变数据结构,该数据结构会保存它自身修改之前的版本。任何的修改都会创建一个更新的数据结构。

  • 使用集合

现在你已经了解了如何创建集合,还需要了解如何使用这些集合开展工作。我们很快会看到Scala支持的集合操作和Stream API提供的操作极其类似。比如,在下面的代码片段中,你会发现熟悉的filtermap,图20-1对这段代码逻辑进行了阐释。

  1. val fileLines = Source.fromFile("data.txt").getLines.toList()
  2. val linesLongUpper
  3. = fileLines.filter(l => l.length() > 10)
  4. .map(l => l.toUpperCase())

20.1 Scala简介 - 图1

图 20-1 使用Scala的List实现类Stream操作

不用担心第一行的内容,它实现的基本功能是将文件中的所有行转换为一个字符串列表(类似Java中提供的Files.readAllLines)。第二行创建了一个由两个操作构成的流水线:

  • filter操作会过滤出所有长度超过10的行;
  • map操作会将这些长的字符串统一转换为大写字符。
    这段代码也可以用下面的方式实现:
  1. val linesLongUpper
  2. = fileLines filter (_.length() > 10) map(_.toUpperCase())

这段代码使用了中缀表达式和下划线(_),下划线是一种占位符,它按照位置匹配对应的参数。这个例子中,你可以将_.length()解读为l =>l.length()。在传递给filtermap的函数中,下划线会被绑定到待处理的line参数。

Scala的Collection API提供了很多非常有用的操作。强烈建议你抽空浏览一下Scala的文档,对这些API有一个大致的了解。注意,Scala的集合类提供的功能比Stream API提供的功能还丰富很多,比如,Scala的集合类支持压缩操作,你可以将两个列表中的元素整合到一个列表中。通过学习,一定能大大增强你的功力。这些编程技巧在将来的Java版本中也可能会被Stream API所引入。

最后,还记得吗?在Java中你可以对Stream调用parallel方法,将流水线转化为并行执行。Scala提供了类似的技巧。你只需要使用方法par就能实现同样的效果:

  1. val linesLongUpper
  2. = fileLines.par filter (_.length() > 10) map(_.toUpperCase())
  • 元组

现在,让我们看看另一个特性,该特性使用起来通常异常烦琐,它就是元组。你可能希望使用元组将人的名字和电话号码组合起来,同时又不希望额外声明新的类,并对其进行实例化。你希望元组的结构就像:("Raoul", "+44 7700 700042")("Alan", "+44 7700 700314"),诸如此类。

非常不幸,Java目前还不支持元组,所以你只能创建自己的数据结构。下面是一个简单的Pair类定义:

  1. public class Pair<X, Y> {
  2. public final X x;
  3. public final Y y;
  4. public Pair(X x, Y y){
  5. this.x = x;
  6. this.y = y;
  7. }
  8. }

当然,你还需要显式地实例化Pair对象:

  1. Pair<String, String> raoul = new Pair<>("Raoul", "+ 44 7700 700042");
  2. Pair<String, String> alan = new Pair<>("Alan", "+44 7700 700314");

好了,看起来一切顺利,不过如果是三元组呢?如果是自定义大小的元组呢?这个问题就变得相当烦琐,最终会影响你代码的可读性和可维护性。

Scala提供了名为元组字面量的特性来解决这一问题,这意味着你可以通过简单的语法糖创建元组,就像普通的数学符号那样:

  1. val raoul = ("Raoul", "+ 44 7700 700042")
  2. val alan = ("Alan", "+44 7700 700314")

Scala支持任意大小2的元组,所以下面的这些声明都是合法的:

  1. val book = (2018 "Modern Java in Action", "Manning") ←---- 元组类型为(Int, String, String)
  2. val numbers = (42, 1337, 0, 3, 14) ←---- 元组类型为(Int, Int, Int, Int, Int)

你可以依据它们的位置,通过存取器(accessor)_1_2(从1开始的一个序列)访问元组中的元素,比如:

  1. println(book._1) ←---- 打印输出2018
  2. println(numbers._4) ←---- 打印输出3

是不是比Java语言中现有的实现方法简单很多?好消息是关于将元组字面量引入到未来Java版本的讨论正在进行中(第21章会围绕这一主题进行更深入的讨论)。

  • Stream

目前为止所讨论的集合,包括ListSetMapTuple都是即时计算的(即在第一时间立刻进行计算)。当然,你也已经了解Java中的Stream是按需计算的(即延迟计算)。通过第5章,你知道由于这一特性,Stream可以表示无限的序列,同时又不消耗太多的内存。

Scala中也提供了一个采用延迟方式计算的数据结构,名称也叫Stream!不过Scala中的Stream提供了更加丰富的功能,这让Java中的Stream有些黯然失色。Scala中的Stream可以记录它曾经计算出的值,所以之前的元素可以随时进行访问。除此之外,Stream还进行了索引,所以Stream中的元素可以像List那样通过索引访问。注意,这种抉择也附带着开销,由于需要存储这些额外的属性,和Java中的Stream比起来,Scala版本的Stream内存的使用效率变低了,因为Scala中的Stream需要能够回溯之前的元素,这意味着之前访问过的元素都需要在内存“记录下来”(即进行缓存)。

  • Option

另一个你熟悉的数据结构是Option。我们在第11章讨论过Java的OptionalOption是Java中Optional类型的Scala版本。建议你在设计API时尽可能地使用Optional,这种方式下,接口用户只需要阅读方法签名就能了解它是否接受一个Optional的值。应该尽量地用它替代null,避免发生空指针异常。

第11章中,你了解了可以使用Optional返回客户的保险公司名称——如果客户的年龄超过设置的最低值,就返回该客户对应的保险公司名称,具体代码如下:

  1. public String getCarInsuranceName(Optional<Person> person, int minAge) {
  2. return person.filter(p -> p.getAge() >= minAge)
  3. .flatMap(Person::getCar)
  4. .flatMap(Car::getInsurance)
  5. .map(Insurance::getName)
  6. .orElse("Unknown");
  7. }

在Scala语言中,你可以通过使用与Optional类似的方法使用Option实现该函数:

  1. def getCarInsuranceName(person: Option[Person], minAge: Int) =
  2. person.filter(_.age >= minAge)
  3. .flatMap(_.car)
  4. .flatMap(_.insurance)
  5. .map(_.name)
  6. .getOrElse("Unknown")

这段代码中除了getOrElse方法,其他的结构和方法你一定都非常熟悉,getOrElse是与Java中orElse等价的方法。你看到了吗?在本书中学习的新概念能直接应用于其他语言!然而,不幸的是,为了保持同Java的兼容性,在Scala中依旧保持了null,不过我们极度不推荐你使用它。

2Scala元组中元素的最大上限为22。