第 3 章 要点详解

第 3 章 要点详解

本章将讲解必要的 Scala 基础知识。

3.1 操作符重载?

在 Scala 中,几乎所有的“操作符”其实都是方法。我们一起看看最基础的一个例子:

1 + 2

数字之间的加号是什么呢?这个操作符是一个方法。

首先,请注意在 Scala 的世界里,Java 中特殊的“基本类型”都变成了正规的对象,这些对象类型为:FloatDoubleIntLongShortByteBoolean 类型。这也意味着它们可以拥有成员方法。

正如你所见,除了某些特殊情况之外,Scala 标示符中允许出现字母和数字之外的字符。我们稍后将会讲到这些特殊情况。

由于使用中缀表示法表示单参数方法时,其中的点号和括号可以省略,因此 1 + 2 等价于 1.+(2)1

1实际上根据操作符优先级规则,包含点号和省去点号的表达式并不总是完全一致的。1 + 2 * 3 = 7,1.+(2)*3 = 9。如果表达式中包含点号,那么优先执行点号对应的操作,再执行乘法运算。另外如果你使用了 2.11 版本之前的 Scala,请在数字 1 后添加一个空格,否则 1 将会被解析为 Double 类型!

与之相似,调用无参方法时也可以省略点号。这种写法也被称为后缀表示法。不过有时候使用后缀表示方式时会产生歧义,因此 Scala 2.10 将这种表示法修改为可选特性。我们将搭建 SBT 的实验环境,假如你在没有告诉编译器的情况下使用了该特性,那么将触发一条警告信息。可以通过一条 import 语句开启此特性。请查看下面的示例了解通过 scala 命令开启 REPL 会话(也可以通过 SBT console 命令开启)。

$ scala
...
scala> 1 toString
Warning: there were 1 feature warning(s); re-run with -feature for details
res0: String = 1

直接运行 scala 命令看上去并不能启动此特性,我们加上 -feature 标志重新启动 REPL,以获取更多有意义的警告信息。

$ scala -feature
...
scala> 1.toString  // normal invocation
res0: String = 1

scala> 1 toString  // postfix inovocation
<console>:8: warning: postfix operator toString should be enabled
by making the implicit value scala.language.postfixOps visible.
This can ... adding the import clause 'import scala.language.postfixOps'
or by setting the compiler option -language:postfixOps.
See the Scala docs for value scala.language.postfixOps for a discussion
why the feature should be explicitly enabled.
              1 toString
                ^
res1: String = 1

scala> import scala.language.postfixOps
import scala.language.postfixOps

scala> 1 toString
res2: String = 1

我希望任何时候出现了这样的问题时,都能看到这个详细的错误信息,所以我进行了 SBT 项目的配置,启用了 -feature 标志。这样一来,在 SBT 中使用 console 任务运行 REPL 时,这个标志便已开启了。

可以通过两种方式消除这个警告。我们已经演示了第一种方法:执行 import scala.language.postfixOps 命令。我们也可以向编译器传递另一个标志 -language:postfixOps,通过这一方式在全局范围内开启该特性。我个人青睐于每次都调用 import 语句,因为这种方式能提醒读者了解到我们现在启用了哪个可选特性。(我们会在 21.1.1 节列出所有可选特性。)

在不造成编译器歧义的前提下,省略点号能够使代码变得更加整洁,也有助于创建更优雅、更自然易懂的程序。

那么,哪些字符允许出现在标示符中呢?下面总结了方法名、类型名、变量名等各种标示符需要遵循的规则。

  • 可用的字符

    除了括号类字符、分隔符之外,其他所有的可打印的 ASCII 字符如字母、数字、下划线(_)和美元符号($)均可出现在 Scala 标示符中,插入字符包括了 (,)[,]{,and};而分隔符则包括了'".; 以及 ,。Scala 还允许在标示符中使用编码在 \u0020 到 \u007F 之间的字符,如数学符号、像/和 < 这样的操作符字符以及其他的一些符号。

  • 不能使用保留字

    和绝大多数语言一样,Scala 中也不允许使用保留字作为标示符。我们将在 2.7 节列举所有的保留字。我们回忆一下,某些操作符和标点符号也属于保留字,例如,下划线(_)便是一个保留字。

  • 普通标示符——字母、数字、$_ 和操作符的组合

    常见的标示符往往由字母或下划线开头,后面跟着一些字母、数字、下划线或美元符。Scala 允许使用 Unicode 格式的字符。由于美元符在 Scala 内部会作为特定作用,因此尽管编译器不会阻止你,你仍不应将美元符当作标示符使用。在下划线后可以输入字母、数字,也可以输入一些操作符。下划线就一个重要的字符,编译器会将下划线之后空格之前的所有字符视为标示符的一部分。举例而言,val xyz_++= = 1 会将变量 xyz_++ 赋值为 1,而表达式 val xyz++= =1 则无法通过编译。这是因为标示符 xyz++= 也可以被解释为 xyz ++=,这看上去像是要为 xyz 赋值。出于同样的原因,假如在下划线后输入了操作符,那么不允许在操作符后输入字母或数字。这一限制确保了不会产生具有歧义的表达式,例如:abc_-123。该语句到底是表示标示符 abc_-123,还是变量 abc_ 减去 123 呢?

  • 普通标示符——操作符

    假如某一标示符以操作符开头,那么后面的字符也必须是操作符字符。

  • 使用反引号定义标示符

    我们可以通过反引号定义标示符,两个反引号内的任意长度的字符串便是定义的标示符,def`test that addition works`= assert(1 + 1 == 2) 便是其中一例(这句代码应用反引号定义测试名称的方法,这种方式使用了一种“如果不满足某些条件,便存在问题”的命名技巧。而你将来也许能在产品代码中看到这类命名方式)。我们之前也曾看到过反引号命名的例子。如果需要访问的 Java 类方法或变量的名与 Scala 类的保留字相同,我们需要使用反引号命名。如:java.net.Proxy.`type`()

  • 模式匹配标示符

    在模式匹配表达式中(请回顾 1.4 节中 actor 的相关示例),以小写字母开头的标记会被解析为变量标示符,而大写字母开头的标记则会被解析为常量标示符(如类名)。模式匹配使用了非常简洁的变量表示法,例如无需使用 val 关键字,增加该限定能够避免产生某些歧义。

语法糖

所有的操作符都是方法。假如你知道该知识点,便能够更容易的理解 Scala 代码。有时候你会看到一些新的操作符,不过你无需担心那些特殊的情况(这些新的操作符本质都是方法,所以无需担心)。1.4 节中出现的 actor 会互相发送异步消息,发送消息时使用了感叹号!操作符,这一操作符只是一个普通方法罢了。

这种灵活的命名方式让编写出的类库非常自然,就像 Scala 自身的一种延伸。利用这种命名方式,你可以编写一个新的数学库,其中的数值类型支持所有的数学操作。你也可以编写一个与 actor 行为类似的全新的并发消息处理层。除了少数的一些命名规则限制之外,使用这些命名方式能创造出无限的可能。

 能够创建操作符符号并不意味着你应该这样做。当你定义 API 时,要提醒自己用户很难读懂这些隐晦的标点符号式的操作符,更别提学会和记住了。滥用这些操作符只会使你的代码变得晦涩。因此,如果你沉迷于创建新的操作符,一旦该操作符无法带来便利,那就意味着你凭空牺牲了方法命名的可读性。

3.2 无参数方法

对于那些不包含参数的方法而言,除了可以选择使用中缀调用或后缀调用方式之外,Scala 还允许用户灵活决定是否使用括号。

我们在定义无参方法时可以省略括号。一旦定义无参方法时省略了括号,那么在调用这些方法时必须省略括号。与之相反,假如在定义无参方法时添加了空括号,那么调用方可以选择省略或是保留括号。

例如,List.size 的方法定义体中省略了括号,因此你应该编写 List(1,2,3).size 这样的代码。假如你尝试输入 List(1,2,3).size(),系统将会返回错误。

java.lang.Stringlength 方法定义体中则包含了括号(这是为了能在 Java 中运行),而 Scala 同时支持 "hello".length()"hello".length 这两种写法。这同样适用于 Scala 定义的一些定义体中包含空括号的那些无参方法。

为了实现与 Java 语言的互操作,无参方法定义体中出现了是否包含空括号这两种情况的处理规则之间的不一致性。尽管 Scala 也希望定义和使用保持一致(如果定义体中包含括号,那么调用时必须添加括号,反之,调用时必须省略括号),不过由于包含了空括号的定义会更灵活些 ,这确保了调用 Java 无参方法时可以与调用 Scala 无参方法保持一致。

Scala 社区已经养成了这样一个习惯:定义那些无副作用的无参方法时省略括号,例如:集合的 size 方法。定义具有副作用的方法时则添加括号,这样便能提醒读者某些对象可能会发生变化,需要额外小心。假如运行 scalascalac 时添加了 -Xlint 选项,那么在定义那些会产生副作用(例如,方法中会有 I/O 操作)的无参方法时,省略括号将会出现一条警告信息。我在 SBT 编译环境中已经添加了这个标志。

为什么我们会优先讲解是否选择括号的问题呢?这是因为合理考虑是否使用括号有助于构建更具表现力的方法调用链,如下所示,示例中的代码看上去就像是一目了然的“句子”:

// src/main/scala/progscala2/rounding/no-dot-better.sc

def isEven(n: Int) = (n % s) == 0

List(1, 2, 3, 4) filter isEven foreach println

程序输出如下:

2
4

尽管上面的语句非常“整齐”,但如果你现在还不熟悉该语句的语法,你就需要花些时间才能理解它的作用。下面四行代码中,每一行都比上一行要少一些,而最后一行便是之前展示的代码。

List(1, 2, 3, 4).filter((i: Int) => isEven(i)).foreach((i: Int) => println(i))
List(1, 2, 3, 4).filter(i => isEven(i)).foreach(i => println(i))
List(1, 2, 3, 4).filter(isEven).foreach(println)
List(1, 2, 3, 4) filter isEven foreach println

前三行代码更为清晰,因此也更容易为初学者理解。过滤器不过是作用于集合之上的单参数方法,foreach 方法默默地对集合执行了一次循环操作,一旦你熟悉了这些,你会发现最后一行代码,这种“斯巴达式”(斯巴达式,指的是非常简洁的方式)的实现体更易阅读和理解。等你变得更有经验之后,你会认为中间的两行代码就像是妨碍阅读的一种噪音。希望你阅读 Scala 代码的时候能将此牢记于心。

需要澄清的是,由于上面的每个方法都接收单一参数,因此该表达式能正常运行。假如方法链中某一方法接收 0 个或大于 1 个的参数,编译器会困惑。如果出现了这种情况,请为部分或全部方法补上点号。

3.3 优先级规则

对于像 2.0 * 4.0 / 3.0 * 5.0 这种包含了一系列 Double 操作的表达式,将遵守何种操作符优先级规则呢?下面将按从低到高的顺序列出优先级规则:

1. 所有字母
2. |
3. ^
4. &
5. < >
6. = !
7. :
8. + -
9. * / %
10. 其他特殊字符

同一行的字符具有相同的优先级。不过有一个例外,当 = 用于赋值操作时,该符号的优先级最低。

因为 */ 的优先级相同,因此下面的两段 scala 会话将返回相同值。

scala> 2.0 * 4.0 / 3.0 * 5.0
res0: Double = 13.333333333333332

scala> (((2.0 * 4.0) / 3.0) * 5.0)
res1: Double = 13.333333333333332

执行由左结合方法组成的方法序列时,只要简单地按照从左到右的顺序执行就行了。那是不是所有的方法都是“左结合”呢? 答案是否定的。在 Scala 中,任何名字以冒号 (:) 结尾的方法都与右边的对象所绑定,其他方法则是左绑定的。例如,你可以通过 :: 方法将某一元素放置到列表前面,这一操作成为 cons 操作,cons 是 constructor 的缩写,这也是 Lisp 所引入的概念。

scala> val list = List('b', 'c', 'd')
list: List[Char] = List(b, c, d)

scala> 'a' :: list
res4: List[Char] = List(a, b, c, d)

第二句表达式等价于 list.::('a')

 任何名字以 : 结尾的方法均与其右边的对象绑定,它们并不与左侧对象绑定。

3.4 领域特定语言

领域特定语言,也称为 DSL,指的是为某一专门问题域编写的语言,引入 DSL 是为了方便用简洁直观的方式表达该领域的概念。例如,SQL 便可以被视为一门 DSL,因为它是一门专门用于解释关系模型的编程语言。

不过通常 DSL 只用于即席查询语言,它们要么被嵌入到某一宿主语言内,要么会专门有一个定制的解析器负责解析。嵌入意味着你需要在宿主语言中通过一种方言来实现 DSL。嵌入式 DSL 通常也被称为内部 DSL,而需要特制解析器的 DSL 则被称为外部 DSL。

使用内部 DSL 时,开发者能利用宿主语言的所有特性处理 DSL 未能覆盖的一些临界情况(悲观地说,DSL 是一类存在漏洞的抽象)。内部 DSL 同样免去了编写此法解析器、解析器和其他编写特定语言所需工具的工作。

Scala 为这两类 DSL 均提供了完美的支持。Scala 提供了灵活的标志符规则,如允许使用操作符命名,支持中缀和后缀方法调用语法,这为编写嵌入式 DSL 提供了构建 DSL 所需的组成元素。

行为驱动开发

下面的示例应用 ScalaTest 类库(http://www.scalatest.org/),向我们展现了一种被称为行为驱动开发(Behavior-Driven Development)的测试编写方式。Specs2 类库(http://etorreborre.github.io/specs2/)也提供了同样的功能。

// src/main/scala/progscala2/rounding/scalatest.scX
// Example fragment of a ScalaTest. Doesn't run standalone.

import org.scalatest.{ FunSpec, ShouldMatchers }
class NerdFinderSpec extends FunSpec with ShouldMatchers {

  describe ("nerd finder") {
    it ("identify nerds from a List") {
      var actors = List("Rick Moranis", "James Deam", "Woody Allen")
      var finder = new NerdFinder(actors)
      finder.findNerds shouldEqual List("Rick Moranis", "Woody Allen")
    }
  }
}

上述示例讲解了如何利用 Scala 编写 DSL,在第 20 章我们会看到更多这样的例子并学会如何动手编写 DSL。

3.5 Scala中的if语句

从表面上看,Scala 的 if 语句看起来很像 Java 中的 if 语句。执行 if 语句时先对 if 条件表达式进行估值。假如表达式结果为 true,那么将执行对应的代码块。反之,将测试下一条件分支,以此类推。下面列举了一个简单示例:

// src/main/scala/progscala2/rounding/if.sc

if (2 + 2 == 5) {
  println("Hello from 1984.")
} else if (2 + 2 == 3) {
    println("Hello from Remedial Math class?")
} else {
  println("Hello from a non-Orwellian future.")
}

Scala 与 Java 语言不同,Scala 中的 if 语句和几乎所有的其他语句都是具有返回值的表达式。因此我们能像下面展示的代码那样,将 if 表达式的结果值赋给其他变量。

// src/main/scala/progscala2/rounding/assigned-if.sc

val configFile = new java.io.File("somefile.txt")

val configFilePath = if (configFile.exists()) {
  configFile.getAbsolutePath()
} else {
  configFile.createNewFile()
  configFile.getAbsolutePath()
}

if 语句返回值的类型也被称为所有条件分支的最小上界类型,也就是与每条 each 子句可能返回值类型最接近的父类型。在上面这个例子中,configFilePathif 表达式的结果值,该 if 表达式将执行文件不存在的条件分支,并返回新创建文件的绝对路径。将 if 语句的返回值赋予变量 configFilePath 之后,整个应用程序都可以使用该值,其类型为 String 类型。

Scala 中的 if 语句是一类表示式,像 predicate ? trueHandler() : falseHandler() 这种三元表达式对于 Scala 来说是多余的,因此 Scala 并不支持三元表达式。

3.6 Scala中的for推导式

除了 if 语句之外,Scala 也为 for 循环这一常见的控制结构提供了非常多的特性,这些 for 循环的特性被称为 for 推导式(for comprehension)或 for 表达式(for expression)。

事实上,推导式一词起源于函数式编程。它表达了这样一个理念:我们遍历一个或多个集合,对集合中的元素进行“推导”,并从中计算出新的事物,新推导出的事物往往是另一个集合。

3.6.1 for循环

让我们从一个基本的 for 表达式开始:

// src/main/scala/progscala2/rounding/basic-for.sc

val dogBreeds = List("Doberman", "Yorkshire Terrier", "Dachshund",
                     "Scottish Terrier", "Great Dane", "Portuguese Water Dog")

for (breed <- dogBreeds)
  println(breed)

你可能已经猜到了,这段代码的意思是:“基于列表 dogBreeds 中的每一个元素,创建临时变量 breedbreed 的值与元素值相同,之后打印 breed。”代码输出如下:

Doberman
Yorkshire Terrier
Dachshund
Scottish Terrier
Great Dane
Portuguese Water Dog

这种形式不返回任何值,因此它只会执行一些会带来副作用的操作。这类 for 推导式有时候也被称为 for 循环,这与 Java 中的 for 循环较为类似。

3.6.2 生成器表达式

breed <- dogBreeds 这样的表达式也被称为生成器表达式(generator expression),生成器表达式之所以叫这个名字,是因为该表达式会基于集合生成单独的数值。左箭头操作符 (<-) 用于对像列表这样的集合进行遍历。

我们还可以使用生成器表达式对某些区间进行访问,以这种方式编写出的 for 循环更加自然。

// src/main/scala/progscala2/rounding/generator.sc

for (i <- 1 to 10) println(i)

3.6.3 保护式:筛选元素

怎样才能获得更细的操作粒度呢? 我们可以加入 if 表达式,来筛选出我们希望保留的元素。这些表达式也被称为保护式(guard)。 为了能够从犬种列表中挑选中犬,我们对之前的代码进行了修改,具体如下:

// src/main/scala/progscala2/rounding/guard-for.sc

val dogBreeds = List("Doberman", "Yorkshire Terrier", "Dachshund",
                     "Scottish Terrier", "Great Dane", "Portuguese Water Dog")
for (breed <- dogBreeds
  if breed.contains("Terrier")
) println(breed)

输出如下:

Yorkshire Terrier
Scottish Terrier

你还可以在 for 循环中添加多个保护式:

// src/main/scala/progscala2/rounding/double-guard-for.sc

val dogBreeds = List("Doberman", "Yorkshire Terrier", "Dachshund",
                     "Scottish Terrier", "Great Dane", "Portuguese Water Dog")

for (breed <- dogBreeds
  if breed.contains("Terrier")
  if !breed.startsWith("Yorkshire")
) println(breed)

for (breed <- dogBreeds
  if breed.contains("Terrier") && !breed.startsWith("Yorkshire")
) println(breed)

在第二个 for 推导式中,两个 if 语句被合并为一个语句。这两个 for 推导式的输出如下所示:

Scottish Terrier
Scottish Terrier

3.6.4 Yielding

假如你并不需要打印过滤后的集合,你需要编写代码对过滤后的集合进行处理,那么该怎么办呢?使用 yield 关键字便能在 for 表达式中生成新的集合。

另外,我们将转而使用大括号代替圆括号,以相似的方法把参数列表封装在大括号中时可以使得块结构的格式看起来更为直观:

// src/main/scala/progscala2/rounding/yielding-for.sc

val dogBreeds = List("Doberman", "Yorkshire Terrier", "Dachshund",
                     "Scottish Terrier", "Great Dane", "Portuguese Water Dog")
val filteredBreeds = for {
  breed <- dogBreeds
  if breed.contains("Terrier") && !breed.startsWith("Yorkshire")
} yield breed

每次执行 for 表达式时,过滤后的结果将生成 breed 值。 随着代码的执行,这些结果值逐渐积累起来,累计而成的结果值集合被赋给了 filteredBreeds 对象。for-yield 表达式所生成的集合类型将根据被遍历的集合类型推导而出。在上面的例子中,由于 filteredBreeds 源于 dogBreeds 列表,而 dogBreeds 类型为 List[String],因此 filteredBreeds 的类型为 List[String]

 for 推导式有一个不成文的约定:当 for 推导式仅包含单一表达式时使用原括号,当其包含多个表达式时使用大括号。值得注意的是,使用原括号时,早前版本的 Scala 要求表达式之间必须使用分号。

假如一个 for 推导式并未使用 yield,而是执行像打印这样的具有副作用的操作,那么我们将其称为 for 循环。这是因为它的行为更像是你所熟悉的 Java 和其他语言中的 for 循环。

3.6.5 扩展作用域与值定义

Scala 的 for 推导式还有一个有用的特征:你能够在 for 表达式中的最初部分定义值,并可以在后面的表达式中使用该值。如下所示:

// src/main/scala/progscala2/rounding/scoped-for.sc

val dogBreeds = List("Doberman", "Yorkshire Terrier", "Dachshund",
                     "Scottish Terrier", "Great Dane", "Portuguese Water Dog")
for {
  breed <- dogBreeds
  upcasedBreed = breed.toUpperCase()
} println(upcasedBreed)

需要注意的是,尽管 upcasedBreed 的值不可变,但并不需要使用 val 关键字进行限定 2 执行结果如下:

2在 Scala 早先的版本中,val 关键字是可选的,而现在则不推荐使用该关键字。

DOBERMAN
YORKSHIRE TERRIER
DACHSHUND
SCOTTISH TERRIER
GREAT DANE
PORTUGUESE WATER DOG

如果你想到了 Option,那就可以用在这个示例中。正如我们之前讨论的那样,Optionnull 更好的替代方案,Option 是一类特殊形式的集合,它只包含 0 个或 1 个元素,意识到这一点对你会有帮助。我们也可以理解下面代码:

// src/main/scala/progscala2/patternmatching/scoped-option-for.sc

val dogBreeds = List(Some("Doberman"), None, Some("Yorkshire Terrier"),
                     Some("Dachshund"), None, Some("Scottish Terrier"),
                     None, Some("Great Dane"), Some("Portuguese Water Dog"))

println("first pass:")
for {
  breedOption <- dogBreeds
  breed <- breedOption
  upcasedBreed = breed.toUpperCase()
} println(upcasedBreed)

println("second pass:")
for {
  Some(breed) <- dogBreeds
  upcasedBreed = breed.toUpperCase()
} println(upcasedBreed)

想象我们会调用返回各种犬种名称的一些服务。这些服务会返回 Option 类型,而由于一些服务无法返回任何值,这些服务将返回 None。在第一个 for 推导式的第一个表达式中,每一个被提取的元素均为 Option 对象。而后续的代码行中将使用箭头符提取 option 中的值。

稍等!当你尝试从 None 对象中提取对象时难道不会抛出异常吗?的确如此,不过由于此时推导式会进行有效地检查并忽略 None,因此不会有异常抛出。这正如我们在第二行代码之前增加了显式的 if breedOption != None

第二个 for 推导式使用了模式匹配,这使得代码更为清新。只有当 BreedOptionSome 类型时,表达式 Some(breed) <- dogBreeds 才会成功执行并提取出 breed,所有操作一步完成。None 元素不再被处理。

什么时候使用左箭头(<-),什么时候该使用等于号(=)呢?当你遍历某一集合或其他像 Option 这样的容器并试图提取值时,你应该使用箭头。当你执行并不需要迭代的赋值操作 时,你应使用等于号。for 推导式的第一句表达式必须使用箭头符执行抽取 / 迭代操作。

在大多数语言的循环体中,你可以使用跳出循环、也可以继续进行迭代。Scala 并未提供 breakcontinue 语句,不过编写地道的 Scala 代码时,你几乎不需要使用这些语句。你可以使用条件表达式或者使用递归判断循环是否应该继续。如果你能在一开始便对集合进行过滤以消除循环中的复杂条件,那就更好了 3

3不过,考虑到存在对 break 功能的需求,Scala 提供了 scala.util.control.Breaks 对象(http://www.scala-lang.org/api/current/#scala.util.control.Breaks$),该对象可用于实现 break 功能,不过我从未用过该功能,你最好也不用它。

 Scala 的 for 推导式并不提供 breakcontinue 功能。Scala 提供的其他特性使得这两个功能没有存在的必要。

3.7 其他循环结构

Scala 提供了许多其他的循环结构,由于 for 推导式是如此的灵活和强大,这些结果并未得到广泛的应用。另外,有时候你需要的仅仅是一个 while 循环。

3.7.1 Scala的while循环

只要判断条件成立,while 循环将一直运行对应代码块。例如,在下一个 13 号的周五到来之前,下面的代码将按照一天一次的频率打印抱怨的信息:

// src/main/scala/progscala2/rounding/while.sc
// 警告:这个脚本会运行非常非常长时间!
import java.util.Calendar

def isFridayThirteen(cal: Calendar): Boolean = {
  val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
  val dayOfMonth = cal.get(Calendar.DAY_OF_MONTH)

  // Scala将最后一个表达式的结果值作为该方法的返回结果
  (dayOfWeek == Calendar.FRIDAY) && (dayOfMonth == 13)
}

while (!isFridayThirteen(Calendar.getInstance())) {
  println("Today isn't Friday the 13th. Lame.")
  // sleep for a day
  Thread.sleep(86400000)
}

3.7.2 Scala中的do-while循环

while 循环相似,只要条件表达式返回 true,do-while 循环语句就会执行代码。也就是说,执行完代码块后,do-while 语句便会检查条件是否为真。为了能计数十次,我们可以编写如下代码:

// src/main/scala/progscala2/rounding/do-while.sc

var count = 0

do {
  count += 1
  println(count)
} while (count < 10)

3.8 条件操作符

Scala 从 Java 及其祖先处继承了大多数的条件操作符。你能在 if 语句、while 循环以及每个应用了 else 条件的地方看到表 3-1 所列的那些条件操作符。

表 3-1:条件操作符

操作符

操作

描述

&&

和操作

操作符左边和右边的值都为 true。只有当操作符左边的值为真值时才会评估右边值是否为真

||

或操作

操作符左边和右边值至少有一个为 true。只有当操作符左边值为 false 时才会评估右边值是否为真

>

大于

操作符左边的值应大于右边的值

>=

大于或等于

操作符左边的值大于或等于右边的值

<

小于

操作符左边的值应小于右边的值

<=

小于或等于

操作符左边的值小于或等于右边的值

==

等于

操作符左边的值等于右边的值

!=

不等于

操作符左边的值不等于右边的值

需要注意的是 &&||短路(short-circuiting)操作符,一旦得知结果,这些操作便会停止对表达式估值。

绝大多数操作符的行为与它们在 Java 和其他语言中的表现一致,但操作符 == 和它的逆操作符 !== 例外。在 Java 中,== 只会对对象引用进行比较,它并不会执行一次逻辑上的相等检查,即比较字段值。而你调用 equals 方法便是出于这个目的。因此假如有两个类型相同且具有相同字段值(也就是说,这两个对象的状态相同)的不同对象,在 Java 中执行 == 操作将返回 false。

与之相反,Scala 使用 == 符执行逻辑意义上的相等检查,不过该操作符也调用了 equals 方法。假如你并不希望进行逻辑相等检查(我们会在 10.6 节详细讨论对象相等的相关内容),而只想比较引用,你可以使用 Scala 提供的新的方法 eq

3.9 使用trycatchfinal子句

Scala 推崇通过使用函数式结构和强类型以减少对异常和异常处理的依赖的编码范式。尽管如此,异常仍然有用,而当 Scala 需要与普遍使用异常的 Java 代码交互时,异常尤为重要。

 与 Java 不同,Scala 并不支持已被视为失败设计的检查型异常(checked exception)。Scala 将 Java 中的检查型异常视为非检查型,而且方法声明中也不包含 throw 子句。不过 Scala 提供了有助于 Java 互操作的 @throws 注解(http://www.scala-lang.org/api/current/index.html#scala.throws),具体内容请参考 23.2 节。

Scala 将异常处理作为另一类模式匹配来进行处理,因此我们可以简洁地对各种不同类型的异常进行处理。

让我们看看 Scala 在资源管理这样一个常见的应用场景中是如何处理异常的。我们希望通过某种方式打开并处理一些文件。在这个示例中我们仅仅会统计行数。不过,我们仍然必须对一些错误场景进行处理。比如说,文件也许并不存在,这个错误尤其是当我们需要让用户指定文件名时尤为明显。除此之外,处理文件时可能也会有某些错误(为了测试错误发生的场景,我们将随意地触发一个错误)。无论是否成功地对文件进行了处理,我们都需要确保关闭了所有的文件句柄。

// src/main/scala/progscala2/rounding/TryCatch.scala
package progscala2.rounding

object TryCatch {
  /** Usage: scala rounding.TryCatch filename1 filename2 ... */
  def main(args: Array[String]) = {
    args foreach (arg => countLines(arg))                            // ➊
  }

  import scala.io.Source                                             // ➋
  import scala.util.control.NonFatal

  def countLines(fileName: String) = {                               // ➌
    println() // Add a blank line for legibility
    var source: Option[Source] = None                                // ➍
    try {                                                            // ➎
      source = Some(Source.fromFile(fileName))                       // ➏
      val size = source.get.getLines.size
      println(s"file $fileName has $size lines")
    } catch {
      case NonFatal(ex) => println(s"Non fatal exception! $ex")      // ➐
    } finally {
      for (s <- source) {                                            // ➑
        println(s"Closing $fileName...")
        s.close
      }
    }
  }
}

❶ 使用 foreach 循环遍历参数列表并对各个参数进行处理。该循环每遍历一次便返回一个 Unit 对象,而 foreach 执行完毕后所返回的最终结果也是 Unit 对象。

❷ 导入用于读取输入的 scala.io.Source 类(http://www.scala-lang.org/api/current/#scala.io.Source)以及用于匹配 nonfatal 异常的 scala.util.control.NonFatal 类(http://www.scala-lang.org/api/current/#scala.util.control.NonFatal$)。

❸ 统计每个文件名所对应文件的行数。

❹ 由于我们将变量 source 声明为 Option 类型,因此我们在 finally 子句中能分辨出 source 对象是否是真正的实例。

❺ 开始执行 try 子句。

❻ 假如文件不存在,source.fromFile 方法将抛出 java.io.FileNotFoundExceptionhttp://docs.oracle.com/javase/8/docs/api/java/io/FileNotFoundException.html)类型的异常。否则的话,我们将该方法的返回值封装到 Some 对象中。下一行中我们将调用 source 变量的 get 方法,由于目前我们已经能确认 source 属于 Some 类型,因此这一操作是安全的。

❼ 捕获那些非致命的错误。例如,内存不足是一个致命错误。

❽ 使用 for 推导式从 Some 类型的对象中提取 Source 实例,之后将关闭文件。假如 source 对象为 None,将不会发生任何事。

请留意 catch 子句。Scala 会使用模式匹配来捕捉你所希望捕获的异常,而 Java 则使用单独的 catch 子句来捕获各个异常。与 Java 相比,Scala 捕获异常的方式更紧凑、更灵活。在这段示例代码中,case NonFatal(ex) => …子句使用 scala.util.control.NonFatal 匹配了所有的非致命性异常。

应用 finally 子句可以确保资源会在一处得到合理的清理。如果不使用 finally,我们将不得不分别在 try 子句和 catch 子句中重复清理逻辑,这样才能确保文件句柄会被关闭。由于我们使用了 for 推导式从 option 对象中抽取出 Source 对象,因此即使 option 对象实际上是一个 None 实例,什么也不会发生,包含了文件 close 方法的代码块并不会被调用。

 假如你需要对 Option 对象进行检测,当它是 Some 对象时执行一些操作,而当它是 None 对象时则不进行任何操作,那么你可以使用 for 推导式,这也是 Scala 的一个广泛应用的常见用法。

由于该程序已经经过 sbt 编译,因此我们可以在 sbt 提示符后运行 run-main 任务来启动该程序,启动 run-main 任务时允许输入参数。为了方便阅读,我将输入参数折成多行,使用 \ 表示行符 ,并删除了一些文本。

> run-main progscala2.rounding.TryCatch foo/bar \
  src/main/scala/progscala2/rounding/TryCatch.scala
[info] Running rounding.TryCatch foo/bar .../rounding/TryCatch.scala

... java.io.FileNotFoundException: foo/bar (No such file or directory)

file src/main/scala/progscala2/rounding/TryCatch.scala has 30 lines
Closing src/main/scala/progscala2/rounding/TryCatch.scala...
[success] ...

第一个文件 foo/bar 并不存在,而第二个文件才是该程序的源文件。使用 scala.io.Source API 能够方便地对来自文件或其他源的数据流进行处理。与一些这类的 API 相似,文件不存在时 Source 将抛出异常。因此读取 foo/bar 文件时抛出异常的行为是可预期的行为。

 假如无论是否成功地使用了资源,资源都需要被清理,请将资源清理的相关逻辑放到 finally 子句中执行。

除了使用模式匹配定位异常之外,Scala 异常处理的其他设定与大多数语言相似。与 Java 一样,使用 Scala 时我们通过编写 throw new MyBadExceptoin(...) 抛出异常。假如你自定义的异常是一个 case 类,那么抛出异常时可以省略 new 关键字。这就是 Scala 与其他语言在异常处理上的差别。

自动资源管理是一类常见的 Scala 设计模式。为了实现自动资源管理,Joshua Suereth 编写了一个名为 ScalaARM(http://jsuereth.com/scala-arm/)的独立项目。下面让我们尝试编写自动资源管理程序。

3.10 名字调用和值调用

我们曾动手实现了可重用资源管理器,其具体实现如下:

// src/main/scala/progscala2/rounding/TryCatchArm.scala
package progscala2.rounding
import scala.language.reflectiveCalls
import scala.util.control.NonFatal

object manage {
  def apply[R <: { def close():Unit }, T](resource: => R)(f: R => T) = {
    var res: Option[R] = None
    try {
      res = Some(resource)         // 只会引用"resource"一次!!
      f(res.get)
    } catch {
      case NonFatal(ex) => println(s"Non fatal exception! $ex")
    } finally {
      if (res != None) {
        println(s"Closing resource...")
        res.get.close
      }
    }
  }
}

object TryCatchARM {
  /** Usage: scala rounding.TryCatch filename1 filename2 ... */
  def main(args: Array[String]) = {
    args foreach (arg => countLines(arg))
  }

  import scala.io.Source

  def countLines(fileName: String) = {
    println()  // 打印空白行,以增加可读性
    manage(Source.fromFile(fileName)) { source =>
      val size = source.getLines.size
      println(s"file $fileName has $size lines")
      if (size > 20) throw new RuntimeException("Big file!")
    }
  }
}

你只要将命令行中的 TryCatch 替换为 TryCatchArm,便可以像运行之前的示例那样运行该程序并得到相似的输出。

能够将关注点分离(separation of concern,SOC)固然很好,不过为了实现这点,我们需要运用一些强大的新工具。

首先,我们将对象命名为 manage,而不是 Manage。通常我们都遵循类型名称首字母大写的规范,不过由于该示例使用 manage 的方式与使用函数的方式相似,因此未遵循该规范。我们希望 manage 在用户代码中看上去像一个内置的操作符,整个代码看起来就像是一个 while 循环。我们可以在 countLines 方法中查看 manage 对象的使用方式。这个示例也演示了如何使用 Scala 工具构建小型领域特定语言(DSL)。

manage.apply方法

manage.apply 方法声明看上去非常奇怪,为了能够理解该声明,我们将对其进行分解。下面再次列出了该方法的签名,我们将分行显示方法签名,而每一行也都提供了对应的注释。

def apply[
  R <: { def close():Unit },   ➊
  T ]                          ➋
  (resource: => R)             ➌
  (f: R => T) = {...}          ➍

❶ 这行出现了两个新的事物。R 表示我们将要管理的资源类型。而 <: 则意味着 R 属于某其他类型的子类。在本例中 R 的父类型是一个包含 close():Unit 方法的结构类型。为了能帮助你更直观的理解 R 类型,尤其是当你之前没接触过结构化类型时,你可以认为 R <: Closable 表示 Closable 接口中定义了 close():Unit 方法并且 R 实现了 Closable 接口。不过结构化类型允许我们使用反射机制嵌入包含 close():Unit 方法的任意类型(如 Source 类型)。反射机制会造成许多系统开销,而结构化类型代价也较为昂贵,因此就像后缀表达式那样,Scala 将反射列为可选特性,为了能够让编译器相信我们知道我们在做什么,需要在代码中添加 import 语句。

❷ 我们传入了用于处理资源的匿名函数,而 T 表示该匿名函数的返回值。

❸ 尽管看上去像是一个声明体较为奇特的函数,但 resource 事实上是一个传名参数(by-name parameter)。我们暂且将其视为一个在调用时应省略括号的函数。

❹ 最后我们将传入第二个参数列表,其中包含了一个输入为 resource、返回值类型为 T 的匿名函数,该匿名函数将负责处理 resource

我们再回到注释 1,假设所有资源均实现了 Closable 抽象,那么 apply 方法声明看起来会是下面这个样子:

object manage {
  def apply[ R <: Closable, T](resource: => R)(f: R => T) = {...}
  ...
}

resource 只会在 val res = Some(resource) 这行代码中被求值,这行代码必不可少的。由于 resource 的表现与函数相似,因此就像是一个会被重复调用的函数,每次引用该变量时便会对其求值。但我们并不希望每次引用 resource 时都会执行一次 Source.fromFile(fileName),因为这意味着我们每次都会重新打开一个新的 Source 实例。

之后,我们将 res 值传入工作函数 f 中。

TryCatchARM.countLines 又是如何使用 manage 对象的呢? manage 对象在这段代码中看上去就像是一个 Scala 自带的控制结构,该控制结构包含了两个参数列表:一个用于创建 Source 对象,而另一个则是处理 Source 对象的代码块。这样一来,manage 对象看起来就像是一个普通 while 语句了。

我们再回顾一下之前的代码,创建 Source 对象的第一条表达式其实并没有立刻执行,在进入 manage 对象之前,该表达式都没有被执行。直到执行 manage 对象内的代码 val res = Some(resource) 时,该表达式才会执行。这便是传名参数 resource 能提供的功能。我们编写的 manage.apply 方法可以接受任意表达式输入,但这些表达式将延后执行。

与大多数语言相似,Scala 通常使用按值调用(call-by-value)的语法。如果 manage(Source.fromFile(fileName)) 所处上下文遵循按值调用的方式的话,那么 Scala 将执行 Source.fromFile 方法,并将返回值传递给 manage 对象。

通过将 Source.fromFile 推迟到 apply 方法中的代码行 val res = Some(resource),该行代码等效于下列代码:

val res = Some(Source.fromFile(fileName))

正是因为要支持像延迟计算这样的语法,Scala 才提供了传名参数。

假如 Scala 未提供传名参数,该怎么办呢?我们可以使用匿名函数实现延迟计算,不过这种实现方式看起来略显丑陋。

manage 对象的调用代码看上去像是下列代码:

manage(() => Source.fromFile(fileName)) { Source =>

而在 apply 方法体中,将以函数调用的方式来引用 resource

val res = Some(resource())

尽管这种函数调用并不会给我们造成可怕的后果,不过像 manage 对象那样,我们也可以通过按名调用(call by name)构建自己的控制结构。

请记住,传名参数的行为与函数相似;每次使用该参数时便会执行表达式。在我们的 ARM 示例里,我们希望该表达式只会被执行一次,但这并不能反映通常的情况。

下面的示例中通过定义 continue 结构,实现了一个简单的类似于 while 循环的结构体:

// src/main/scala/progscala2/rounding/call-by-name.sc

@annotation.tailrec                                               // ➊
def continue(conditional: => Boolean)(body: => Unit) {            // ➋
  if (conditional) {                                              // ➌
    body                                                          // ➍
    continue(conditional)(body)
  }
}

var count = 0                                                     // ➎
continue(count < 5) {
  println(s"at $count")
  count += 1
}

❶ 确保了调用实现体时将采用尾递归的方式。

❷ 定义 continue 函数,该函数接受两个参数列表:第一个列表中仅包含了一个传值参数 conditional,而第二个列表则包含了传值参数 body。body 代表了每次迭代都会执行的代码体。

❸ 检查当前是否满足条件。

❹ 假如满足条件,将执行 body 参数,并递归调用 continue 函数。

❺ 调用 continue 方法!

读者需谨记一点:传名参数会在每次被引用时估值。(顺便提一下,上述实现描述了如何使用递归取代循环结构。)由于传名参数的求值会被推迟,并可能会一再地被重复调用,因此此类参数具有惰性。除此之外,Scala 也提供了惰性值(lazy value)。

3.11 惰性赋值

惰性赋值是与传名参数相关的技术,假如你希望以延迟的方式初始化某值,并且表达式不会被重复计算,则需要使用惰性赋值。下面列举了一些需要用到该技术的常见场景。

  • 由于表达式执行代价昂贵(例如:打开一个数据库连接),因此我们希望能推迟该操作,直到我们确实需要表达式结果值时才执行它。

  • 为了缩短模块的启动时间,可以将当前不需要的某些工作推迟执行。

  • 为了确保对象中其他的字段的初始化过程能优先执行,需要将某些字段惰性化。我们会在 11.4 节讨论 Scala 对象模型时深入探讨这些场景。

下面的示例中便应用了惰性赋值:

// src/main/scala/progscala2/rounding/lazy-init-val.sc

object ExpensiveResource {
  lazy val resource: Int = init()
  def init(): Int = {
    // 执行某些代价高昂的操作
    0
  }
}

lazy 关键字意味着求值过程将会被推迟,只有需要时才会执行计算。

那么惰性赋值与方法调用有那些差别呢?对于方法调用而言,每次调用方法时方法体都会被执行;而惰性赋值则不然,首次使用该值时,用于初始化的“代码体”才会被执行一次。这种只能执行一次的计算对于可变字段而言几乎没有任何意义。因此,lazy 关键字并不能用于修饰 var 变量。

我们通过保护式(guard)来实现惰性值。当客户代码引用了惰性值时,保护式会拦截引用并检查此时是否需要初始化惰性。由于保护式能确保惰性值在第一次访问之前便已初始化,因此增加保护式检查只有当第一次引用惰性值时才是必要的。但不幸的是,很难解除之后的惰性值保护式检查。所以,与“立刻”值相比,惰性值具有额外的开销。因此只有当保护式带来的额外开销小于初始化带来的开销时,或者将某些值惰性化(请参考 11.4 节)能简化系统初始化过程并确保执行顺序满足依赖条件时,你才应该使用惰性值。

3.12 枚举

还记得之前那个列出各类犬种的示例吗?我们也许期望能有一个顶级的 Breed 类型来纪录各类犬种。像 Breed 这样的类型被统称为枚举类型,而该类型包含的值被称为枚举值。

虽然许多编程语言都内置了枚举值,但是 Scala 却使用了另外一种实现方式:在标准库中专门定义 Enumeration 类(http://www.scala-lang.org/api/current/scala/Enumeration.html)。这意味着 Scala 并未提供任何特殊语法来支持枚举,而 Java 则不然。所以 Scala 语言中的枚举与 Java 中的 enum 结构在字节码层面上没有任何联系。

请看示例:

// src/main/scala/progscala2/rounding/enumeration.sc

object Breed extends Enumeration {
  type Breed = Value
  val doberman = Value("Doberman Pinscher")
  val yorkie   = Value("Yorkshire Terrier")
  val scottie  = Value("Scottish Terrier")
  val dane     = Value("Great Dane")
  val portie   = Value("Portuguese Water Dog")
}
import Breed._

// 打印所有犬种及其ID列表
println("ID\tBreed")
for (breed <- Breed.values) println(s"${breed.id}\t$breed")

// 打印犬列表
println("\nJust Terriers:")
Breed.values filter (_.toString.endsWith("Terrier")) foreach println

def isTerrier(b: Breed) = b.toString.endsWith("Terrier")

println("\nTerriers Again??")
Breed.values filter isTerrier foreach println

该程序会打印以下信息 :

ID      Breed
0       Doberman Pinscher
1       Yorkshire Terrier
2       Scottish Terrier
3       Great Dane
4       Portuguese Water Dog

Just Terriers:
Yorkshire Terrier
Scottish Terrier

Terriers Again??
Yorkshire Terrier
Scottish Terrier

我们会发现犬种枚举类型中包含了许多 Value 类型值,如下所示:

val doberman = Value("Doberman Pinscher")

实际上,每个犬种声明均调用了接收单一字符串参数的 Value 方法。我们使用该方法为每个枚举值指定了较长的犬种名称。调用 Value.toString 方法将返回该字符串。

Breed 类型是一个别名,无需使用各个 Value 值,仅用 Breed 枚举便能定位到具体犬种。只有在输入 isTerrie 方法参数时我们才真正需要使用 Value 值。假如我们注释 Breed 的类型定义,那么该函数就无法通过编译。

尽管类型名和方法名均为 Value,但它们之间并不存在命名空间冲突。因为编译器为值和方法分别维护了各自独立的命名空间。

Scala 还提供了一些其他的重载版 Value 方法。我们之前使用的 Value 方法接收单一字符串输入,而另一个 Value 方法则不接受任何输入参数。无参的 Value 方法将对象名作为输入字符串,例如:变量 doberman 对应的字符串是 doberman。第三个 Value 方法的输入参数是一个整型 ID 值,该方法在使用默认字符串(即变量名)的同时会将我们显式指定的整数值作为 ID 值。最后一个 Value 方法同时接收整数和字符串输入。

由于我们调用的方法并未显式指定 ID 值,Value 对象的 ID 将会自动从 0 开始分配,并按照声明的顺序逐一递增。这些 Value 方法都会生成 Value 对象,而这些新创建的 Value 对象也会被添加到枚举的 Value 集合中。

通过调用了 value 方法,我们能像处理集合那样处理这些枚举值。这样一来,我们便能使用 for 推导式遍历所有的犬种,对这些犬种按名称进行过滤,也能查看系统为那些未指定 ID 的犬种自动分配的 ID 值。

就像这个示例表现的那样,我们通常希望能给枚举值取一个可读性强的名字。但是有时候你也许又不需要对枚举值命名,下面这一示例改编自 Scaladoc 文档中枚举类型(http://www.scala-lang.org/api/current/index.html#scala.Enumeration)的入口页的示例。

// src/main/scala/progscala2/rounding/days-enumeration.sc

object WeekDay extends Enumeration {
  type WeekDay = Value
  val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value
}
import WeekDay._

def isWorkingDay(d: WeekDay) = ! (d == Sat || d == Sun)

WeekDay.values filter isWorkingDay foreach println

运行上述脚本将输出下列信息(v2.7):

Mon
Tue
Wed
Thu
Fri

请注意,我们引入了 WeekDay._,这使得每一个枚举值(如 MonTues)都在代码的作用域内。否则的话,你需要编写像 WeekDay.MonWeekDay.Tus 这样的代码。我们同样可以通过调用 values 方法遍历枚举值。在这个示例中,我们就过滤出“工作日”对应的枚举值。

尤其与 Java 相比,枚举在 Scala 代码中出现的次数并不多。尽管 Scala 中的枚举使用便利,但是需要预先知道集合中应包含哪些枚举值。而客户是无法增加其他的枚举值的。

作为枚举的一种替代品,case 类常常应用于那些需要使用“枚举值”的场景中。尽管 case 类更重量级一些,但是却具有两大优势。首先,case 类允许添加方法和字段,而且我们也能够对枚举值应用模式匹配,这便为用户提供了更好的灵活性。其次 case 类能适用于包含未知枚举值的场景。只要有需要,用户代码便可以将更多的 case 类添加到基本集合中。

3.13 可插入字符串

我们在 1.4 节已经介绍了什么是可插入字符串(interpolated string),这里将对其进行更深层次地分析。

假如某一字符串的形式为 s"foo ${bar}",那么表达式 bar 将会被转化为字符串并被插入到原字符串中的 $"{bar}" 的位置中。假如表达式 bar 返回的不是字符串,而是某一类型的实例,那么只要该实例中定义了 toString 方法,系统便会调用该方法将该实例转化成字符串。如果 bar 表达式返回值无法转换成字符串,那么程序将会报错。

如果 bar 是一个变量引用,那么可以省略字符串中的大括号。如下所示:

val name = "Buck Trends"
println(s"Hello, $name")

如果想在可插入字符串中输入美元符,那么请连续输入两个美元符 $$

Scala 中存在两类可插入字符串。第一类采用 printf 格式,这类可插入字符串以 f 为前缀。第二类也被称为“原生的”可插入字符串,这类字符串并不会对像 \n 这样的逃逸字符串进行扩展。

假设我们正在生成财务报表,希望浮点数只显示到小数点后两位 4。那么可以编写如下代码:

4通常并不使用原生的浮点数和双精度浮点数表示货币,这是因为表示货币金额时需要满足各种记账规则(如舍入规则)。不过为了简化起见,我们在示例中使用了浮点数。

val gross   = 100000F
val net     = 64000F
val percent = (net / gross) * 100
println(f"$$${gross}%.2f vs. $$${net}%.2f or ${percent}%.1f%%")

最后一行代码将输出下列结果:

$100000.00 vs. $64000.00 or 64.0%

采用 printf 格式时,Scala 会调用 Java 的 Formatter 类。这类字符串中嵌入的表达式与之前代码使用的语法相同,均为 $"{...}",不过在编写时 printf 格式指令与 ${...} 之间不应有空格。

在该示例中,为了打印出一个美元符号,我们使用了两个美元符 $$;同时使用了两个百分号 %% 以打印出一个百分号。表达式 ${gross}%.2f 会格式化 gross 的变量值,保留其小数点后两位数字。

可插入字符串中的变量类型必须与其格式吻合,为此 Scala 会执行一些隐式转化。下面列出的代码试图在浮点数语境中使用 Int 表达式,Scala 允许这种操作并会在整数的小数点后添加两个 0。而第二个表达式则尝试将 Double 类型值展现为 Int 类型,不过这次尝试会导致编译错误:

scala> val i = 200
i: Int = 200

scala> f"${i}%.2f"
res4: String = 200.00

scala> val d = 100.22
d: Double = 100.22

scala> f"${d}%2d"
<console>:9: error: type mismatch;
 found   : Double
 required: Int
              f"${d}%2d"
                  ^

顺便提一下,调用 Java 静态方法 String.formathttp://docs.oracle.com/javase/8/docs/api/java/lang/String.html)将按照 printf 的格式对字符串格式化。该方法的输入参数包含了格式字符串以及一组用于替代格式字符串的变量列表。String.format 方法还有另外一个版本,该版本中的第一个参数代表字符串的区域设置(该参数为 Locale 类型)。

Scala 编译器会在某些语境中对 Java 字符串进行封装并提供一些额外的方法,这些定义在 scala.collection.immutable.StringLikehttp://www.scala-lang.org/api/current/scala/collection/immutable/StringLike.html)中。这些额外提供的方法中包含了一个叫作 format 的实例方法。你可以对一个格式化字符串调用该方法并传入需要插入到该字符串中的参数列表。具体如下:

scala> val s = "%02d: name = %s".format(5, "Dean Wampler")
s: String = "05: name = Dean Wampler"

在该示例中,我们希望能输出两位数整数并在小数点添加两个零。最后一类内置的字符串插入器被称为“原生”(raw)插入器,该插入器不会对控制字符进行扩展,具体请参阅下面两个示例。

scala> val name = "Dean Wampler"
name: String = "Dean Wampler"

scala> s"123\n$name\n456"
res0: String =
123

Dean Wampler
456

scala> raw"123\n$name\n456"
res1: String = 123\nDean Wampler\n456

最后提一下,其实我们可以自定义字符串插入器,不过在此之前,我们需要掌握更多的关于隐式转换(implicit)的知识。如果想了解具体细节,请参阅 5.3.1 节。

3.14 Trait:Scala语言的接口和“混入”

尽管我们已经学习了快 100 页的内容,但我们至今仍未讨论到面向对象语言的一个最基础的特性:在 Scala 中如何定义抽象? Java 语言中的接口是抽象的同义词,那 Scala 呢?Scala 又该如何实现类继承呢?

Scala 从函数式编程思想中汲取了 trait,为了突出它的强大能力,我有意将讲解相关内容的篇幅推后,不过现在是时候对这一重要主题进行概览了。

我曾使用过一些模糊的术语,比如说抽象。一些示例也用抽象类来表示父类。但我之前并未思索过这些用词,只是认为曾经在其他语言中见过类似的结构体。

Java 提供了接口,你可以在接口中声明方法,但却不能定义方法。至少在 Java 8 诞生之前是这样的。同样,你也可以在接口中声明和定义静态变量或者嵌套类型。

Scala 使用 trait 来替代接口。在第 9 章我们会详细地讲述 trait,目前你可以将其视为允许将声明方法实现的接口。使用 trait 时,你可以声明示例字段(与 Java 接口一样,你在 trait 中并非只局限于声明静态字段)并选择是否定义这些字段,你也可以参照之前的枚举示例,在 trait 中声明或定义类型。

trait 提供的这些扩展被证实确实能够打破 Java 对象模型的一些局限。Java 对象模型只允许在类中定义方法和字段,而 trait 则允许真正意义上的组合行为(“混入”模式)。这在 Java 8 诞生之前是很难实现的。

下面示例解决了每一个企业级 Java 开发者都曾面对的问题:在应用中混入日志。我们首先编写了下列服务:

// src/main/scala/progscala2/rounding/traits.sc

class ServiceImportante(name: String) {
  def work(i: Int): Int = {
    println(s"ServiceImportante: Doing important work! $i")
    i + 1
  }
}

val service1 = new ServiceImportante("uno")
(1 to 3) foreach (i => println(s"Result: ${service1.work(i)}"))

该服务执行了某些工作并返回下列输出:

ServiceImportante: Doing important work! 1
Result: 2
ServiceImportante: Doing important work! 2
Result: 3
ServiceImportante: Doing important work! 3
Result: 4

现在我们在该服务中混入一个标准日志库,为了简单起见,我们将使用 println 输出日志。

示例中包含了两个 trait,其中的一个定义了日志抽象(不包含任何具体的成员),而另一个则实现了日志抽象,将日志信息输出到标准输出。

trait Logging {
  def info   (message: String): Unit
  def warning(message: String): Unit
  def error  (message: String): Unit
}

trait StdoutLogging extends Logging {
  def info   (message: String) = println(s"INFO:    $message")
  def warning(message: String) = println(s"WARNING: $message")
  def error  (message: String) = println(s"ERROR:   $message")
}

请注意,这里编写的日志与 Java 中的接口非常相似。它们的 JVM 字节码甚至采用了相同的实现方式。

最后,我们声明了一个“混入”了日志功能的服务:

val service2 = new ServiceImportante("dos") with StdoutLogging {
  override def work(i: Int): Int = {
    info(s"Starting work: i = $i")
    val result = super.work(i)
    info(s"Ending work: i = $i, result = $result")
    result
  }
}
(1 to 3) foreach (i => println(s"Result: ${service2.work(i)}"))

INFO:    Starting work: i = 1
ServiceImportante: Doing important work! 1
INFO:    Ending work: i = 1, result = 2
Result: 2
INFO:    Starting work: i = 2
ServiceImportante: Doing important work! 2
INFO:    Ending work: i = 2, result = 3
Result: 3
INFO:    Starting work: i = 3
ServiceImportante: Doing important work! 3
INFO:    Ending work: i = 3, result = 4
Result: 4

现在服务开启或结束工作时都会输出日志信息。

为了能混入 trait,我们需要使用 with 关键字。根据自身需要,我们可以混入任意多个 trait。一些 trait 也许不会对现有行为做任何修改,它们只会添加一些新的有用方法,但这些方法之间是相互独立的。

实际上,为了能注入日志,在该示例中我们修改了服务的行为,但并未修改服务与客户之间的“契约”,也就是说,我们并未修改服务的对外行为。5

5严格意义上说,这种说法并不正确。这个附加的 I/O 操作已经对代码与外界之间的交互产生了影响。

假如我们希望能在 ServiceImportante 的多个实例中混入 StdoutLogging 特征,我们可以声明一个新类:

class LoggedServiceImportante(name: String)
  extends ServiceImportante(name) with stdoutLogging {...}

请留意我们是如何将参数 name 传递给父类 ServiceImportante 的。在创建实例时,new LoggedServiceImportante("tres") 能够实现你想要的功能。

不过,假如我们仅需将日志特征混入一个实例,我们可以在定义变量时混入特征。

为了使用日志扩展,我们不得不对 work 方法进行覆写。假如你希望覆盖父类中某一具体方法,那么 Scala 要求必须输入 override 关键字。请留意我们是如何访问父类的 work 方法的。与 Java 和一些其他语言一样,我们通过 super.work 调用该方法。

trait 和对象组合还有很多值得讨论的地方,本书会在后续章节讲述相关的内容。

3.15 本章回顾与下一章提要

前三章讲述了许多基础知识,从中我们了解到 Scala 代码可以如此的简洁灵活。经过本章的学习,我们掌握了一些强大的可用于定制 DSL 或操作数据的结构,比如说 for 推导式。最后学习了如何使用枚举封装值以及 trait 的一些基本知识。

现在你应该已经具备了阅读一些 Scala 代码的能力,不过 Scala 语言还有很多需要学习的地方。现在我们将开启 Scala 特征的深度之旅。

目录

  • 版权声明
  • O'Reilly Media, Inc. 介绍
  • 前言
  • 第 1 章 零到六十:Scala 简介
  • 第 2 章 更简洁,更强大
  • 第 3 章 要点详解
  • 第 4 章 模式匹配
  • 第 5 章 隐式详解
  • 第 6 章 Scala 函数式编程
  • 第 7 章 深入学习 for 推导式
  • 第 8 章 Scala 面向对象编程
  • 第 9 章 特征
  • 第 10 章 Scala 对象系统(I)
  • 第 11 章 Scala 对象系统(II)
  • 第 12 章 Scala 集合库
  • 第 13 章 可见性规则
  • 第 14 章 Scala 类型系统(I)
  • 第 15 章 Scala 类型系统(II)
  • 第 16 章 高级函数式编程
  • 第 17 章 并发工具
  • 第 18 章 Scala 与大数据
  • 第 19 章 Scala 动态调用
  • 第 20 章 Scala 的领域特定语言
  • 第 21 章 Scala 工具和库
  • 第 22 章 与 Java 的互操作
  • 第 23 章 应用程序设计
  • 第 24 章 元编程:宏与反射
  • 附录 A 参考文献
  • 作者简介
  • 关于封面