第 2 章 更简洁,更强大

第 2 章 更简洁,更强大

在第 1 章的结尾我们介绍了一个关于 Akka actor 的应用,这个例子可能稍显复杂。本章我们将继续探究 Scala 的特性,重点关注它如何为我们提供简洁且灵活的语法代码。我们将探讨如何组织文件与包,如何导入其他类型、变量、方法声明,以及一些非常有用的数据类型和各种约定俗成的语法习惯。

2.1 分号

分号是表达式之间的分隔符,可以推断得出。当一行结束时,Scala 就认为表达式结束了,除非它可以推断出该表达式尚未结束,应该延续到下一行,如下面这个例子:

// src/main/scala/progscala2/typelessdomore/semicolon-example.sc

// 末尾的等号表明下一行还有未结束的代码。
def equalsign(s: String) =
  println("equalsign: " + s)

// 末尾的花括号表明下一行还有未结束的代码。
def equalsign2(s: String) = {
  println("equalsign2: " + s)
}

// 末尾的逗号、句号和操作符都可以表明,下一行还有未结束的代码。
def commas(s1: String,
           s2: String) = Console.
  println("comma: " + s1 +
          ", " + s2)

与编译器相比,REPL 更容易将每行视为单独的表达式。因此,在 REPL 中输入跨越多行的表达式时,最安全的做法是每行(除最后一行外)都以上述脚本中出现过的符号结尾。

反过来,你可以将多个表达式放在同一行中,表达式之间用分号隔开。

 如果你需要将多行代码解释为同一表达式,却被系统视为多个表达式,可以使用 REPL 的 :paste 模式。输入 :paste,然后输入你的代码,最后用 Ctrl-D 结束。

2.2 变量声明

在声明变量时,Scala 允许你决定该变量是不可变(只读)的,还是可变的(读写)。如下所示,不可变的“变量”用 val 关键字声明:

val array: Array[String] = new Array(5)

Scala 的大部分变量事实上是指向堆内存对象的引用,这一点与 Java 一致。所以,以上代码中的 array 也是一个引用,它不能指向其他 Array,但所指向的 Array 中的元素是可变的,如下所示:

scala> val array: Array[String] = new Array(5)
array: Array[String] = Array(null, null, null, null, null)

scala> array = new Array(2)
<console>:8: error: reassignment to val
       array = new Array(2)

scala> array(0) = "Hello"

scala> array
res1: Array[String] = Array(Hello, null, null, null, null)

一个 val 变量在声明时必须被初始化。

类似地,一个可变变量用关键字 var 来声明。尽管由于该变量是可变变量,声明后可以再次对其赋值,也必须在声明的同时立即初始化:

scala> var stockPrice: Double = 100.0
stockPrice: Double = 100.0

scala> stockPrice = 200.0
stockPrice: Double = 200.0

这里要区分一下:这一次我们修改了 stockPrice 本身,然而,stockPrice 所引用的“对象”没有被修改,因为在 Scala 中 Double 类型是不可变的。

在 Java 中,所谓的原生类型,即 charbyteshortintlongfloatdoubleboolean,与其他引用类型有着本质的不同。这些类型确实既不是对象,也没有引用,是“原始”值。Scala 尽力使其面向对象特性更加一致,因此这些类型在 Scala 中是包含有方法的对象,就像引用类型一样(参见 8.2 节)。然而,Scala 编译时将这些类型尽可能地转为原生类型,使你可以得到原生类型的运行效率(在 12.4 节中我们将深入讨论这一点)。

valvar 声明变量时必须初始化这一规则,但存在少数例外情况。例如,这两个关键字均可以用在构造函数的参数中,这时候变量是该类的一个属性,因此显然不必在声明时进行初始化。此时如果用 val 声明,该属性是不可变的;如果用 var 声明,则该属性是可变的。

考虑如下 REPL 会话,在这里我们定义了 Person 类,其中包含表示姓和名的不可变变量,而年龄则是可变的(因为人的年龄会随时间变大的缘故):

// src/main/scala/progscala2/typelessdomore/person.sc
scala> class Person(val name: String, var age: Int)
defined class Person

scala> val p = new Person("Dean Wampler", 29)
p: Person = Person@165a128d

scala> p.name
res0: String = Dean Wampler     // 显示firstName的值

scala> p.age
res2: Int = 29                  // 显示age的值

scala> p.name = "Buck Trends"
<console>:9: error: reassignment to val   // 这是不允许的!
       p.name = "Buck Trends"
              ^

scala> p.age = 30
p.age: Int = 30                 // 允许!

 varval 关键字只标识引用本身是否可以指向另一个不同的对象,它们并未表明其所引用的对象是否可变。

为了减少可变性引起的 bug,应该尽可能地使用不可变变量。

例如,在散列映射中,可变对象是非常危险的。如果对象发生改变,hashCode 方法的输出就会发生变化,在散列映射表中原来的位置就无法找到对应的值了。

更为常见的是,当你正在使用的对象被其他人修改时,将引起对象产生不可预见的行为。借用量子力学的名词,这是一种“幽灵般的超距作用”,本地的所有操作都无法解释这种不可预见的行为,因为这是由其他某处的操作引起的。

这在多线程程序中是最致命的 bug。在多线程程序中,对共享的可变状态进行读写之前要使用同步操作,但实践中往往很难实现正确的同步。

这个时候,如果你使用的是不可变的值,就可以减少这类问题。

2.3 Range

我们接下来将讨论方法的声明,但其中的示例需要用到 Range 的概念(http://www.scala-lang.org/api/current/scala/collection/immutable/Range.html),因此我们先来讨论 Range

有时我们需要一个数字序列,从某个起点到某个终点。而 Range 能满足这个需要。以下实例将展示如何创建 Range,支持 Range 的类型包括 IntLongFloatDoubleCharBigInt(支持任意大小的整数,http://www.scala-lang.org/api/current/scala/math/BigInt.html)和 BigDecimal(支持任意大小的浮点数,http://www.scala-lang.org/api/current/scala/math/BigDecimal.html)。

你创建的 Range 可以包含区间上限,也可以不包含区间上限;步长默认为 1,也可以指定一个非 1 的步长:

scala> 1 to 10                // Int类型的Range,包括区间上限,步长为1(从1到10)
res0: scala.collection.immutable.Range.Inclusive =
  Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

scala> 1 until 10             // Int类型的Range,不包括区间上限,步长为1(从1到9)
res1: scala.collection.immutable.Range = Range(1, 2, 3, 4, 5, 6, 7, 8, 9)

scala> 1 to 10 by 3           // Int类型的Range,包括区间上限,步长为3
res2: scala.collection.immutable.Range = Range(1, 4, 7, 10)

scala> 10 to 1 by -3          // Int类型的递减Range,包括区间下限,步长为-3
res2: scala.collection.immutable.Range = Range(10, 7, 4, 1)

scala> 1L to 10L by 3         // Long类型
res3: scala.collection.immutable.NumericRange[Long] = NumericRange(1, 4, 7, 10)

scala> 1.1f to 10.3f by 3.1f  // Float类型的Range,步长可以不等于1
res4: scala.collection.immutable.NumericRange[Float] =
  NumericRange(1.1, 4.2, 7.2999997)

scala> 1.1f to 10.3f by 0.5f  // Float类型的Range,步长可以小于1
res5: scala.collection.immutable.NumericRange[Float] =
  NumericRange(1.1, 1.6, 2.1, 2.6, 3.1, 3.6, 4.1, 4.6, 5.1, 5.6, 6.1, 6.6,
    7.1, 7.6, 8.1, 8.6, 9.1, 9.6, 10.1)

scala> 1.1 to 10.3 by 3.1     // Double类型
res6: scala.collection.immutable.NumericRange[Double] =
  NumericRange(1.1, 4.2, 7.300000000000001)

scala> 'a' to 'g' by 3        // Char类型
res7: scala.collection.immutable.NumericRange[Char] = NumericRange(a, d, g)

scala> BigInt(1) to BigInt(10) by 3
res8: scala.collection.immutable.NumericRange[BigInt] =
  NumericRange(1, 4, 7, 10)

scala> BigDecimal(1.1) to BigDecimal(10.3) by 3.1
res9: scala.collection.immutable.NumericRange.Inclusive[scala.math.BigDecimal]
  = NumericRange(1.1, 4.2, 7.3)

部分输出根据页面大小进行了重新排版。

2.4 偏函数

我们来讨论偏函数(PartialFunctionhttp://bit.ly/1yMpzEPs)的性质。偏函数之所以“偏”,原因在于它们并不处理所有可能的输入,而只处理那些能与至少一个 case 语句匹配的输入。

在偏函数中只能使用 case 语句,而整个函数必须用花括号包围。这与普通的函数字面量不同,普通函数字面量可以用花括号,也可以用圆括号包围。

如果偏函数被调用,而函数的输入却与所有语句都不匹配,系统就会抛出一个 MatchErrorhttp://www.scala-lang.org/api/current/#scala.MatchError)运行时错误。

我们可以用 isDefineAt 方法测试特定输入是否与偏函数匹配,这样偏函数就可以避免抛出 MatchError 错误了。

偏函数可以如此“链式”连接:pf1 orElse pf2 orElse pf3…。如果 pf1 不匹配,就会尝试 pf2,接着是 pf3,以此类推。如果以上偏函数都不匹配,才会抛出 MatchError

以下实例可以展示上述规则:

// src/main/scala/progscala2/typelessdomore/partial-functions.sc

val pf1: PartialFunction[Any,String] = { case s:String => "YES" }    // ➊
val pf2: PartialFunction[Any,String] = { case d:Double => "YES" }    // ➋

val pf = pf1 orElse pf2                                              // ➌

def tryPF(x: Any, f: PartialFunction[Any,String]): String =          // ➍
  try { f(x).toString } catch { case _: MatchError => "ERROR!" }

def d(x: Any, f: PartialFunction[Any,String]) =                      // ➎
  f.isDefinedAt(x).toString

println("      |   pf1 - String  |   pf2 - Double  |    pf - All")   // ➏
println("x     | def?  |  pf1(x) | def?  |  pf2(x) | def?  |  pf(x)")
println("++++++++++++++++++++++++++++++++++++++++++++++++++++++++++")
List("str", 3.14, 10) foreach { x =>
  printf("%-5s | %-5s | %-6s  | %-5s | %-6s  | %-5s | %-6s\n", x.toString,
    d(x,pf1), tryPF(x,pf1), d(x,pf2), tryPF(x,pf2), d(x,pf), tryPF(x,pf))
}

❶ 只匹配字符串的偏函数。

❷ 只匹配 Double 数字的偏函数。

❸ 将这两个函数结合,得到一个新的偏函数:既能匹配字符串,又能匹配 Double 数字。

❹ 辅助函数:用于 try 一个偏函数,然后将可能产生的 MatchError 异常捕捉到。无论是否捕获异常,函数均返回一个字符串。

❺ 辅助函数:使用了 isDefineAt,返回值为字符串。

❻ 使用了多个偏函数的链式组合,并将结果以表格的形式打印出来。

其他代码对这 3 个偏函数输入不同的值,先调用 isDefineAt(结果显示在输出表中的 def? 这一列),然后再尝试调用偏函数本身。输出为:

      |   pf1 - String  |   pf2 - Double  |    pf - All
x     | def?  |  pf1(x) | def?  |  pf2(x) | def?  |  pf(x)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
str   | true  | YES     | false | ERROR!  | true  | YES
3.14  | false | ERROR!  | true  | YES     | true  | YES
10    | false | ERROR!  | false | ERROR!  | false | ERROR!

未输入字符串时,pf1 将会失败;未输入 Double 数字时,pf2 会失败;如果给出整数,这两个函数均失败。组合后的函数 pf 对于字符串或者 Double 数字的输入均成功,但输入整数时仍将失败。

2.5 方法声明

下面我们来探讨方法的声明。本节我们会用到前文使用的 Shape 类的继承树并加以修改(为了简单处理,我们去掉了 Triangle 类)。

2.5.1 方法默认值和命名参数列表

以下是修改后的 Point case 类:

// src/main/scala/progscala2/typelessdomore/shapes/Shapes.scala
package progscala2.typelessdomore.shapes

case class Point(x: Double = 0.0, y: Double = 0.0) {              // ➊

  def shift(deltax: Double = 0.0, deltay: Double = 0.0) =         // ➋
    copy (x + deltax, y + deltay)
}

❶ 如同前文,定义 Point 类,并提供默认的初始化值。

❷ 新的 shift 方法,用于从现有的 Point 对象中对“点”进行平移,从而创建一个新的

Point 对象。它使用了 copy 方法,copy 方法也是 case 类自动创建的。

copy 方法允许你在创建 case 类的新实例时,只给出与原对象不同部分的参数,这一点对于大一些的 case 类非常有用:

scala> val p1 = new Point(x = 3.3, y = 4.4)    // 显式使用命名参数列表。
p1: Point = Point(3.3,4.4)

scala> val p2 = p1.copy(y = 6.6)  // 指定新的y值,创建新实例。
p2: Point = Point(3.3,6.6)

命名参数列表让客户端代码更具可读性。当参数列表很长,且有若干参数是同一类型时,bug 容易避免,因为在这种情况下很容易搞错参数传入的顺序。当然,更好的做法是一开始就避免出现过长的参数列表。

2.5.2 方法具有多个参数列表

接下来,我们对 Shape 类进行修改,特别是其中的 draw 方法:

abstract class Shape() {
  /**
   * draw 带两个参数列表,其中一个参数列表带着一个表示绘制偏移量的参数
   * 另一个参数列表是我们之前用过的函数参数。
   */
  def draw(offset: Point = Point(0.0, 0.0))(f: String => Unit): Unit =
    f(s"draw(offset = $offset), ${this.toString}")
}

case class Circle(center: Point, radius: Double) extends Shape

case class Rectangle(lowerLeft: Point, height: Double, width: Double)
  extends Shape

没错,这里的 draw 方法有两个参数列表,每个参数列表都有一个参数,而不是拥有一个具有两个参数的参数列表。第一个参数列表允许你指定 Point 对象的偏移量,供绘制使用。默认值 Point(0.0, 0.0),表示没有偏移。第二个参数列表与之前的 draw 函数相同,其中的参数是用来绘制所用的函数的。

你可以任意指定参数列表的个数,但实际上很少有人使用两个以上的参数列表。

那么,为什么要允许多个参数列表呢?当最后一个参数列表只包含一个表示函数的参数时,多个参数列表的形式拥有整齐的块结构语法。以下是我们调用新的 draw 方法的表达方式:

s.draw(Point(1.0, 2.0))(str => println(s"ShapesDrawingActor: $str"))

Scala 允许我们把参数列表两边的圆括号替换为花括号,因此,这一行代码还可以写为:

s.draw(Point(1.0, 2.0)){str => println(s"ShapesDrawingActor: $str")}

如果函数字面量不能在一行内完成,我们可以重写为以下方式:

s.draw(Point(1.0, 2.0)) { str =>
  println(s"ShapesDrawingActor: $str")
}

或写为等价的形式:

s.draw(Point(1.0, 2.0)) {
  str => println(s"ShapesDrawingActor: $str")
}

这一写法很像我们之前常用来写 iffor 表达式或方法体的代码块。只不过,在这里的 {…} 块所表示的函数是我们要传递给 draw 方法的参数。

当函数字面量很长时,这种用 {…} 代替 (…) 的“语法糖”使得代码看起来美观多了。此时的代码更像我们所熟悉和喜爱的块结构语法。

如果我们使用缺省的偏移量,第一个圆括号就不能省略:

s.draw() {
  str => println(s"ShapesDrawingActor: $str")
}

如同 Java 方法一样,draw 方法也可以只使用一个带两个参数值的参数列表。如果那样,客户端代码就会像这样写:

s.draw(Point(1.0, 2.0),
  str => println(s"ShapesDrawingActor: $str")
)

这份代码并没那么清晰和优雅。使用默认值开启 offset 也没那么便捷,因此我们不得不对参数进行命名:

s.draw(f = str => println(s"ShapesDrawingActor: $str"))

第二个优势是在之后的参数列表中进行类型推断。如以下例子:

scala> def m1[A](a: A, f: A => String) = f(a)
m1: [A](a: A, f: A => String)String

scala> def m2[A](a: A)(f: A => String) = f(a)
m2: [A](a: A)(f: A => String)String

scala> m1(100, i => s"$i + $i")
<console>:12: error: missing parameter type
              m1(100, i => s"$i + $i")
                      ^

scala> m2(100)(i => s"$i + $i")
res0: String = 100 + 100

函数 m1 和函数 m2 看起来几乎一模一样,但我们需要注意用相同的参数调用它们时 m1m2 的表现。我们传入 Int 和一个函数 Int => String,对于 m1,Scala 无法推断该函数的参数 im2 则可以。

使用多个参数列表的第三个优势是,我们可以用最后一个参数列表来推断隐含参数。隐含参数是用 implicit 关键字声明的参数。当相应方法被调用时,我们可以显式指定这个参数,或者也可以不指定,这时编译器会在当前作用域中找到一个合适的值作为参数。隐含参数可以代替参数默认值,而且更加灵活。我们这就来研究一个 Scala 库中使用隐含参数的例子 Future

2.5.3 Future简介

scala.concurrent.Futurehttp://www.scala-lang.org/api/current/scala/concurrent/Future.html)是 Scala 提供的一个并发工具,其中的 API 使用隐含参数来减少冗余代码。Akka 使用了 Future,但如果你并不需要 actor 的所有功能,也可以单独使用 Akka 中的 Future 部分。

当你将任务封装在 Future 中执行时,该任务的执行是异步的。Future API 提供了多种机制去获取执行结果,如提供回调函数。当结果就绪时,回调函数将被调用。我们在这里就使用回调函数作为例子,关于其他 API 的讨论将推迟到第 17 章。

以下示例并发发出 5 个任务,并在任务结束时处理任务返回的结果:

// src/main/scala/progscala2/typelessdomore/futures.sc
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def sleep(millis: Long) = {
  Thread.sleep(millis)
}
// 繁忙的处理工作;)
def doWork(index: Int) = {
  sleep((math.random * 1000).toLong)
  index
}

(1 to 5) foreach { index =>
  val future = Future {
    doWork(index)
  }
  future onSuccess {
    case answer: Int => println(s"Success! returned: $answer")
  }
  future onFailure {
    case th: Throwable => println(s"FAILURE! returned: $th")
  }
}

sleep(1000)  // 等待足够长的时间,以确保工作线程结束。
println("Finito!")

在 import 语句之后的 sleep 函数中,我们调用 Threadsleep 方法来模拟程序进行繁忙的处理工作,参数为睡眠的时长。doWork 方法是对 sleep 的简单封装,传入一个 0 到 1000 范围的随机数,表示睡眠的毫秒数。

接下来的部分就有趣了。我们用 foreach 对一个从 1 到 5 的 Range 进行迭代,调用了 scala.concurrent.Future.applyhttp://www.scala-lang.org/api/current/scala/concurrent/Future.html),这是单例对象 Future 的“工厂”方法。在这个例子中,Future.apply 传入了一个匿名函数,表示需要做的任务。我们用花括号而不是圆括号包围传入的匿名函数:

val future = Future {
  doWork(index)
}

Future.apply 返回一个新的 Future 对象,然后控制权就交还给循环了,该对象将在另一个线程中执行 doWork(index)。接着,我们用 onSuccess 注册一个回调函数,当 future 成功执行完毕后,该回调会被执行。这个回调函数是一个偏函数。

类似地,我们用 onFailure 注册了一个回调函数来处理错误。回调函数将错误封装在一个 Throwablehttp://docs.oracle.com/javase/8/docs/api/java/lang/Throwable.html)的对象中。

最后,我们调用了 sleep,以等待所有的任务执行完毕。

可能的运行结果如下所示。在本例中结果将以随机的顺序出现,这一点你也许有预期到:

$ scala src/main/scala/progscala2/typelessdomore/futures.sc
Success! returned: 1
Success! returned: 3
Success! returned: 4
Success! returned: 5
Success! returned: 2
Finito!

好了,上面说的这些与隐含参数有什么关系呢?从第二条 import 语句中可以发现答案:

import scala.concurrent.ExecutionContext.Implicits.global

我之前说过,每个 future 运行在各自独立的线程中,但这个说法不够严谨。事实上,Future API 允许我们通过 ExecutionContext 来配置并发操作的执行。上述 import 语句导入了默认的 ExecutionContext,使用 Java 的 ForkJoinPoolhttp://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ForkJoinPool.html)设置来管理 Java 线程池。在示例中我们调用了 3 个方法,其中这些方法的第二个参数列表具有隐含的 ExecutionContext 参数。由于我们没有明显地指定第二个参数列表,系统使用了默认的 ExecutionContext

Apply 方法就是上述 3 个方法之一,以下是 applyFuture.apply 的 Scaladoc(http://www.scala-lang.org/api/current/#scala.concurrent.Future$)中的声明:

apply[T](body: => T)(implicit executor: ExecutionContext): Future[T]

注意第二个参数列表中有 implicit 关键字。

以下是其他两个方法在 Fure.onSuccessFuture.onFailure 的 Scaladoc 中的声明:

def onSuccess[U](func: (Try[T]) => U)(
  implicit executor: ExecutionContext): Unit
def onFailure[U](callback: PartialFunction[Throwable, U])(
  implicit executor: ExecutionContext): Unit

Try 结构是一个处理 try {…} catch {…} 语句的工具,我们以后再讨论它。

那么,如何将某个值声明为 implicit 呢?导入的 scala.concurrent.ExecutionContext.Implicits.global 是在 Future 中常用的默认 ExecutionContext。它使用 implicit 关键字声明,因此如果调用时未显式给出 ExecutionContext 参数,编译器就会使用这个默认值,本例就是这种情况。只有由 implicit 关键字声明的,在当前作用域可见的对象才能用作隐含值;只有被声明为 implicit 的函数参数才允许调用时不给出实参,而采用隐含的值。

以下就是 Scala 源代码中的 scala.concurrent.ExecutionContextImplicits.global 的声明。这段代码演示了如何用 implicit 关键字声明一个隐含的值(这里只给出了代码片段,省略了具体细节):

object Implicits {
  implicit val global: ExecutionContextExecutor =
    impl.ExecutionContextImpl.fromExecutor(null: Executor)
}

在后续的示例中我们会创建自己的隐含变量。

2.5.4 嵌套方法的定义与递归

方法的定义还可以嵌套。当你将一个很长的方法重构为几个更小的方法时,如果这些小的辅助方法只在该方法中调用,就可以用嵌套方法。我们将这些辅助函数嵌套定义在原方法中,它们便对其他外层的代码不可见,包括类中的其他方法。

以下代码实现了阶乘的计算,在这个方法中,我们调用了另一个嵌套的方法去完成阶乘的实际计算:

// src/main/scala/progscala2/typelessdomore/factorial.sc

def factorial(i: Int): Long = {
  def fact(i: Int, accumulator: Int): Long = {
    if (i <= 1) accumulator
    else fact(i - 1, i * accumulator)
  }

  fact(i, 1)
}

(0 to 5) foreach ( i => println(factorial(i)) )

以下为代码运行的输出:

1
1
2
6
24
120

辅助函数递归地调用它本身,并传入一个 accumulator 参数,阶乘的计算结果保存在该参数中。注意,当计数器 i 达到 1 时,我们就将阶乘的计算结果返回。(这里我们不考虑负整数参数,负整数的输入是无效的,本函数在 i <= 1 时返回 1。)定义好嵌套的方法后,factorial 调用该方法,传入参数 i,并累计乘法的初始值 1。

 很容易忘记调用嵌套的函数!如果编译器提示,能找到 Unit 但找不到 Long,可能就是因为你忘记调用嵌套函数了。

是否注意到,我们两次用 i 作为参数名?第一次是 factorial 方法的参数,第二次是嵌套的 fact 方法的参数。在 fact 方法中使用的 i 参数“屏蔽”了外部 factorial 方法的 i 参数。这样做是允许的,因为我们在 fact 方法中并不需要外部的 i,我们只在 factorial 结尾调用 fact 的时候才需要它。

类似方法中声明的局部变量,嵌套的方法也只在声明它的方法中可见。

观察这两个方法的返回值。因为阶乘的计算结果增长非常快,我们选择使用 Long 类型,而不使用 Scala 自动推断的 Int 类型。如果使用 Int 类型,factorial 就不需要上述的类型注释了。然而,我们必须要为 fact 声明返回类型。因为这是一个递归方法,Scala 采用的是局部作用域类型推断,无法推断出递归函数的返回类型。

对递归函数你也许会感到一丝不安。我们是否在冒风险? JVM 和许多其他语言环境并不对尾递归做优化,否则尾递归会将递归转为循环,可以避免栈溢出。(尾递归一词,表示调用递归函数是该函数中最后一个表达式,该表达式的返回值就是所调用的递归函数的返回值。)

递归是函数式编程的特点,也是优雅地实现很多算法的强大工具。所以,Scala 编译器对尾递归做了有限的优化。它会对函数调用自身做优化,但不会优化所谓的 trampoline 的情况,也就是“a 调用 b 调用 a 调用 b”的情形。

你可能仍然想知道自己写的尾递归是否正确,编译器是否对自己的尾递归执行了优化。没有人希望在生产环境中出现栈空间崩溃。幸运的是,如果你加一个 tailrec 关键字(http://www.scala-lang.org/api/current/#scala.annotation.tailrec),编译器会告诉你代码是否正确地实现了尾递归,如以下 factorial 的改良版本:

// src/main/scala/progscala2/typelessdomore/factorial-tailrec.sc
import scala.annotation.tailrec

def factorial(i: Int): Long = {
  @tailrec
  def fact(i: Int, accumulator: Int): Long = {
    if (i <= 1) accumulator
    else fact(i - 1, i * accumulator)
  }

  fact(i, 1)
}

(0 to 5) foreach ( i => println(factorial(i)) )

如果 fact 不是尾递归,编译器就会抛出错误。我们用这个特性在 REPL 中写出递归的 Fibonacci 函数:

scala> import scala.annotation.tailrec

scala> @tailrec
     | def fibonacci(i: Int): Long = {
     |   if (i <= 1) 1L
     |   else fibonacci(i - 2) + fibonacci(i - 1)
     | }

<console>:16: error: could not optimize @tailrec annotated method fibonacci:
         it contains a recursive call not in tail position
         else fibonacci(i - 2) + fibonacci(i - 1)
                               ^

我们有两个递归调用,然后又对调用的结果做计算,而不是只在结尾调用一次递归函数,因此这个函数不是尾递归的。

最后要说明的是,外层方法所在作用域中的一切在嵌套方法中都是可见的,包括传递给外层方法的参数。下例 count 方法中的 n 参数就是一个例子:

// src/main/scala/progscala2/typelessdomore/count-to.sc

def countTo(n: Int): Unit = {
  def count(i: Int): Unit = {
    if (i <= n) { println(i); count(i + 1) }
  }
  count(1)
}

2.6 推断类型信息

静态类型语言的代码往往比较繁琐。因此我们可以考虑使用以下 Java 中的类型声明代码(Java 7 之前的版本):

import java.util.HashMap;
...
HashMap<Integer, String> intToStringMap = new HashMap<Integer, String>();

我们不得不两次指定类型参数 <Integer, String>。Scala 使用类型注解一词表示类似 HashMap<Integer, String> 的显式类型声明。

Java 7 引入了尖括号操作符来推断表达式右边的泛型类型,降低了冗余度:

HashMap<Integer, String> intToStringMap = new HashMap<>();

我们之前已经看过一些示例说明 Scala 对类型推断确有支持。没有显式类型注解时,编译器可以根据上下文识别不少信息。利用自动推断类型信息,以上声明可以用 Scala 重写如下:

val intToStringMap: HashMap[Integer, String] = new HashMap

如果我们将 HashMap[Integer, String] 放在等号后边,代码会更简洁:

val intToStringMap2 = new HashMap[Integer, String]

一些函数式编程语言,如 Haskell,可以推断出几乎所有的类型,因为它们可以执行全局类型推断。Scala 则无法做到这一点,部分原因是 Scala 必须支持子类多态(支持继承),这使得类型推断要困难得多。

以下总结了在 Scala 中什么时候需要显式类型注解。

什么时候需要显式类型注解

在实际编程中,你在以下情况中必须提供显式的类型注解。

  • 声明了可变的 var 变量或不可变的 val 变量,没有进行初始化。(例如,在类中的抽象声明,如 val book: String, var count: Int)。

  • 所有的方法参数(如 def deposit(amount: Money) = {…})。

  • 方法的返回值类型,在以下情况中必须显式声明其类型。

    • 在方法中明显地使用了 return(即使在方法末尾也是如此)。

    • 递归方法。

    • 两个或多个方法重载(拥有相同的函数名),其中一个方法调用了另一个重载方法,调用者需要显式类型注解。

    • Scala 推断出的类型比你期望的类型更为宽泛,如 Any

幸运的是,最后一种情况是比较少见的。

 Any 类型是 Scala 类型层次(我们会在 10.2 节介绍 Scala 的类型)中的根类型。如果一块代码意外地返回了 Any 类型的值,很有可能是因为代码比你预期的更为宽泛,于是只有 Any 类型才能覆盖所有可能的值。

我们来看几个之前没见过的例子,在这些例子中需要显式类型注释。如同以下这个示例,重载的函数中,如果其中一个调用了另一个,则需要提供显式类型注解:

// src/main/scala/progscala2/typelessdomore/method-overloaded-return-v1.scX
// StringUtil第一版(有一个编译错误)
// 错误:无法通过编译,右边的joiner需要一个String类型的返回值

object StringUtilV1 {
  def joiner(strings: String*): String = strings.mkString("-")

  def joiner(strings: List[String]) = joiner(strings :_*)   // 编译错误
}

println( StringUtilV1.joiner(List("Programming", "Scala")) )

在这个人为生造的示例中,虽然两个 joiner 方法将字符串组成的 List 连接起来,用“-”分隔,但这段代码不能通过编译。这里虽然也用了一些有用的编程惯用法,不过我们还是先解决编译错误吧。

如果你执行这段脚本,会得到下面的错误信息:

...
<console>:10: error: overloaded method joiner needs result type
         def joiner(strings: List[String]) = joiner(strings :_*)   // 错误
                                                ^
...

由于第二个 joiner 调用了第一个 joiner,第二个 joiner 就需要显式地将返回值声明为 String。应该像这样:

def joiner(strings: List[String]): String = joiner(strings :_*)

现在我们来看第二个 joiner 方法中使用的惯用技巧。Scala 支持方法拥有变量参数列表(有时也叫作“可变方法”),第一个 joiner 方法的签名为:

def joiner(strings: String*): String = strings.mkString("-")

参数列表中,String 后的 * 表示“0 个或多个 String”。方法可以拥有其他参数,但必须位于可变参数之前,且方法只能拥有一个可变参数。

然而,有时用户可能已经有了可以传递给 joiner 的字符串序列。在这种情况下,第二个 joiner 方法是一个便利的方法。它只是简单地转发调用给第一个 joiner,但它使用了特殊的语法告诉编译器将输入的 List 转为第一个 joiner 需要的可变参数列表:

def joiner(strings: List[String]): String = joiner(strings :_*)

我这么理解这段古怪的代码:strings :_* 告诉编译器你希望列表 string 作为可变参数列表,而列表 string 的类型却不是指定的,而是根据输入推断得出的。Scala 不允许你写成 strings :String *,即使你需要为第一个 joiner 方法指定的输入类型就是 String

事实上,并不需要这个 joiner 的便利方法,用户也可以给第一个 joiner 方法传入一个字符串列表。既然第一个 joiner 需要可变的字符串参数列表,我们可以在调用它的时候使用类似第二个 joiner 方法的语法。

对返回值类型的规定十分微妙,特别是 Scala 推断出的类型比你期望的更通用、更宽泛。将函数返回值赋给一个更具体类型的变量时,你可能会遇到这类问题。例如,你期望得到一个 String,但函数推断的返回值类型是 Any。我们再来看一个人为生造的例子,这个例子就存在以上情形所产生的 bug:

// src/main/scala/progscala2/typelessdomore/method-broad-inference-return.scX
// 错误:无法通过编译。方法事实上返回了List[Any],这一返回值的类型太“宽泛”了。

def makeList(strings: String*) = {
  if (strings.length == 0)
    List(0)  // #1
  else
    strings.toList
}

val list: List[String] = makeList()  // 编译错误

执行这段脚本会触发以下错误信息:

...8: error: type mismatch;
 found   : List[Any]
 required: List[String]
val list: List[String] = makeList()   // 错误
                                 ^

我们希望 makeList 返回一个 List[String],但当 strings.Length 等于零时,我们返回了 List(0),错误地“假定”这就是 Scala 中创建空列表的正确方法。实际上,我们返回的是拥有一个元素 0List[Int]

我们应该返回 List.empty[String] 或空列表的专用产生工具 Nil。两种方法都可以得到正确的返回值推断结果。

if 语句返回 List[Int],而 else 语句返回 List[String]strings.toList 的执行结果)时,方法的返回值推断结果只能是 List[Int]List[String] 的最近公共父类型,即 List[Any]

更重要的一点是,以上代码并未产生编译错误。我们在给 list 指定了类型 List[String] 后,才在运行时发现问题。

在这个例子中,加上显式的返回类型注解 List[String] 可以在编译时就捕捉到这个错误。当然,返回类型注解也为代码的读者提供了很好的文档。

还有两个场景需要注意,省略了返回类型注解可能得到意外的推断类型。第一种,在函数中,你调用的其他函数可能会引发意外推断。这是因为该函数近期可能被修改了返回值类型。这样,你的函数在被重新编译后也会改变推断得到的返回值类型。

第二种场景常常出现在项目越来越大、不同模块构建于不同时间段的情形。如果你在集成测试中发现有 java.lang.NoSuchMethodError,或者更糟,你在生产环境实际运行时发现了这个错误,而这个被调用的方法在另一个模块中定义,这很有可能说明该函数显式地或根据推断改变了返回值类型,而调用方没有相应地进行更新,仍然在期待过时的返回类型。

 开发 API 时,如果与客户端分开构建,应该显式地声明返回类型,并尽可能地返回一个你所能返回的最通用的类型。在 API 声明抽象方法(参见第 9 章)时这一点尤其重要。

最后我们用一个常见的拼写错误来结束本节。这个错误很容易犯,尤其对 Scala 新手来说。

scala> def double(i: Int) { 2 * i }
double: (i: Int)Unit

scala> println(double(2))
()

为什么第二个命令打印出了 (),而不是 4 ?仔细看 scala 解释器打印的方法签名 double(Int)Unit。我们认为定义的方法名为 double,带一个 Int 参数并返回一个新的 Int 值,新的值是输入值的两倍。但它实际返回了 Uint 类型,为什么?这种意外行为产生的原因是在函数定义时漏掉了一个等号。以下才是我们想要的函数定义:

scala> def double(i: Int) = { 2 * i }
double: (i: Int)Int

scala> println(double(2))
4

注意 double 方法体之前的等号。现在,输出告诉我们,我们已经定义的 double 方法返回 Int 类型的值,第二条命令的输出也符合我们的期望。

这种现象的出现有它的原因。Scala 将方法体前的声明和等号当作函数定义,而在函数式编程中,函数总要有返回值。另一方面,当 Scala 发现函数体之前没有等号时,就认为程序员希望该方法是一个 procedure,意味着它只返回 Unit。而在实践中,这很可能是因为程序员忘了写等号!

 假如方法的返回值是推断的并且你在方法体的花括号之前没有写等号,Scala 会推断返回类型为 Unit,即使函数体最后一个表达式的值不是 Unit 类型也是如此。

这种规则太微妙,以至于很容易就会犯这种错误。由于很容易就会错误地定义一个返回 Unit 的方法,这种 procedure 的语法在 Scala 2.11 中已经被废除。不要用这种语法!

上文没有告诉我们 bug 修复之前输出的 () 是从哪来的。我们之前曾提到,Unit 的行为类似于其他语言的 void。然而,与 void 不同,Unit 这个类型拥有一个名为 () 的值,而这是函数式编程的一贯做法,我们将在 16.1.1 节解释它的原因。

2.7 保留字

表 2-1 列出了 Scala 的保留字。其中的一些我们之前已经遇到过,还有许多保留字在 Java 中也能找到,并且它们在两种语言中的含义是相同的。对于目前还没遇到的保留字,本书的后续章节会逐步涉及。

表 2-1:保留字

保留字

描述

参见

abstract

做抽象声明

参见 8.1 节

case

match 表达式中的 case 子句;定义一个 case 类

第 4 章

catch

捕捉抛出的异常

参见 3.9 节

class

声明一个类

参见 8.1 节

def

定义一个方法

参见 2.5 节

do

用于 do...while 循环

参见 3.7 节

else

if 配对的 else 语句

参见 3.5 节

extends

表示接下来的 class 或 trait 是所声明的 class 或 trait 的父类型

参见 8.4 节

false

Boolean 的 false 值

参见 2.8.3 节

final

用于 class 或 trait,表示不能派生子类型;用于类型成员,则表示派生的 class 或 trait 不能覆写它

参见 11.2 节

finally

finally 语句跟在相应的 try 语句之后,无论是否抛出异常都会执行

参见 3.9 节

for

for 循环

参见 3.6 节

forSome

用在已存在的类型声明中,限制其能够使用的具体类型

参见 14.9 节

if

if 语句

参见 3.5 节

implicit

使得方法或变量值可以被用于隐含转换;将方法参数标记为可选的,只要在调用该方法时,作用域内有类型匹配的候选对象,就会使用该对象作为参数

参见 5.3 节

import

将一个或多个类型抑或类型的成员导入到当前作用域

参见 2.12 节

lazy

推迟 val 变量的赋值

参见 3.11 节

match

用于类型匹配语句

第 4 章

new

创建类的一个新实例

参见 8.1 节

null

尚未被赋值的引用变量的值

参见 10.2 节

object

用于单例声明,单例是只有一个实例的类

参见 1.3 节

override

当原始成员未被声明为 final 时,用 override 覆写类型中的一个具体成员

参见 11.1 节

package

声明包的作用域

参见 2.11 节

private

限制某个声明的可见性

第 13 章

protected

限制某个声明的可见性

第 13 章

requires

停用,以前用于自类型

参见 14.6 节

return

从函数返回

参见 1.3 节

sealed

用于父类型,要求所有派生的子类型必须在同一个源文件中声明

参见 2.10 节

super

类似 this,但表示父类型

参见 11.3 节

this

对象指向自身的引用;辅助构造函数的方法名

参见 8.1 节

throw

抛出异常

参见 3.9 节

trait

这是一个混入模块,对类的实例添加额外的状态和行为;也可以用于声明而不实现方法,类似 Java 的 interface

第 9 章

try

将可能抛出异常的代码块包围起来

参见 3.9 节

true

Boolean 的 true 值

参见 2.8.3 节

type

声明类型

参见 2.13 节

val

声明一个“只读”变量

参见 2.2 节

var

声明一个可读可写的变量

参见 2.2 节

while

用于 while 循环

参见 3.7 节

with

表示所声明的类或实例化的对象包括后面的 trait

第 9 章

yield

for 循环中返回元素,这些元素会构成一个序列

参见 3.6.4 节

_

占位符,使用在 import、函数字面量中

很多章节均涉及

:

分隔标识符和类型注解

参见 1.3 节

=

赋值

参见 1.3 节

=>

在函数字面量中分隔参数列表与函数体

参见 6.2.1 节

<-

for 循环中的生成表达式

参见 3.6 节

<:

在参数化类型和抽象类型声明中,用于限制允许的类型

参见 14.2 节

<%

在参数化类型和抽象类型的 view bound 声明中

参见 14.4 节

>:

在参数化类型和抽象类型声明中,用于限制允许的类型

参见 14.2 节

#

在类型注入中使用

参见 15.3 节

@

注解

参见 23.2 节

(Unicode \u21D2),与 => 相同

参见 6.2.1 节

(Unicode \u2192),与 -> 相同

参见 5.3 节

(Unicode \u2190),与 <- 相同

参见 3.6 节

注意,表中没有列出 breakcontinue。 这两个流程控制的关键字在 Scala 中不存在。Scala 鼓励使用函数式编程的惯用法来实现相同的 breakcontinue 功能。函数式编程通常会更加简洁,不容易出现 bug。

一些 Java 中的方法名在 Scala 中是保留字。如 java.util.Scanner.match。为了避免编译错误,引用该方法名时,在名字两边加上反引号,如 java.util.Scanner.`match`

2.8 字面量

我们在前面已经遇到过一些字面量,如 val book = "Programming Scala"。在这行代码中,我们用一个 String 字面量初始化了一个 val 变量 book。还有 (s: String) => s.toUpperCase,这也是一个函数字面量的例子。我们现在来讨论 Scala 支持的所有字面量。

2.8.1 整数字面量

整数字面量可以以十进制、十六进制或八进制的形式出现。表 2-2 给出了整数字面量的详细信息。

表 2-2:整数字面量

类型

格式

例子

十进制

0 或一个非零值,后面跟上 0 个或多个数字(0-9)

0,1,321

十六进制

0x 后面跟上一个或多个十六进制数字(0-9,A-F,a-f)

0xFF,0x1a3b

八进制

0 后面跟上一个多个八进制数字(0-7)a

013,077

a 八进制数字字面量在 Scala 2.10 中已经废弃。

在数字字面量之前加上 - 符号,可以表示负数。

对于 Long 类型的字面量,除非你将该字面量赋值给一个 Long 类型的变量,否则需要在数字字面量后面加上 Ll。如若不加,字面量的类型将默认推断为 Int。整数字面量的有效值范围是根据被赋值的变量类型决定的。表 2-3 列出了各类型的上下限(包含上下限本身)。

表 2-3:整数字面量的取值范围(包含边界)

目标类型

下限(包含)

上限(包含)

Long

-263

263-1

Int

-231

231-1

Short

-215

215-1

Char

0

216-1

Byte

-27

27-1

如果整数字面量的值超出了以上表格中所示的范围,将会引发一个编译错误。如下面这个例子:

scala> val i = 1234567890123
<console>:1: error: integer number too large
       val i = 12345678901234567890

scala> val i = 1234567890123L
i: Long = 1234567890123

scala> val b: Byte = 128
<console>:19: error: type mismatch;
 found   : Int(128)
 required: Byte
       val b: Byte = 128
                     ^

scala> val b: Byte = 127
b: Byte = 127

2.8.2 浮点数字面量

浮点数字面量的形式为:首先是一个可省略的负号,然后是 0 个或多个数字后面跟上一个点号(.),后面再跟上一个或多个数字。对于 Float 类型的字面量,还要在后面加上 Ff,否则会被认为是 Double 类型。你也可以在后面加上 Dd,明确表示字面量为 Double 字面量。

浮点数字面量可以用指数表示。指数部分的形式为 Ee 后面跟上可选的 +-,然后是一个或多个数字。

以下是浮点数字面量的一些例子。例子中除非被赋值的变量是 Float 类型,或者在字面量后面加了 Ff,否则字面量都被推断为 Double 类型:

.14
3.14
3.14f
3.14F
3.14d
3.14D
3e5
3E5
3.14e+5
3.14e-5
3.14e-5
3.14e-5f
3.14e-5F
3.14e-5d
3.14e-5D

Float 遵循 IEEE 754 32 位单精度浮点数规范。

Double 遵循 IEEE 754 64 位双精度浮点数规范。

在 Scala 2.10 之前,小数点后没有数字是允许的,如 3.3.e5。但是由于这种语法会导致歧义:小数点可能被解释为方法名前的句号,因此 1.toString 应该如何解析?是 Int 类型的 1,还是 Double 类型的 1.0 ?所以,小数点后不带数字的浮点数字面量在 Scala 2.10 中就被废弃,而在 Scala 2.11 中则不被允许。

2.8.3 布尔型字面量

布尔型字面量可以为 truefalse。被这两个字面量赋值的变量,其类型将被推断为 Boolean

scala> val b1 = true
b1: Boolean = true

scala> val b2 = false
b2: Boolean = false

2.8.4 字符字面量

字符字面量要么是单引号内的一个可打印的 Unicode 字符,要么是一个转义序列。值在 0~255 的 Unicode 字符也可以用八进制数字的转义形式表示,即一个反斜杠(\)后面跟上最多 3 个八进制数字字符。如果反斜杠后面不是有效的转义序列,会引发编译时的错误。

以下是字符字面量的例子:

'A'
'\u0041'  // Unicode中的'A'
'\n'
'\012'    // 八进制的'\n'
'\t'

表 2-4 给出了有效的转义序列。

表 2-4:字符转义序列

转义序列

含义

\b

退格(BS

\t

水平制表符(HT

\n

换行(LF

\f

表格换行(FF

\r

回车(CR

\"

双引号("

\'

单引号('

\\

反斜杠(\

注意,不可打印的 Unicode 字符,如 \u0009(水平制表符)是不允许的。应该使用等价的转义形式 \t。回顾一下表 2-1 中提到的 3 个 Unicode 字符,可以有效地替换相应的 ASCII 序列:⇒替换 =>,→替换 ->,←替换 <-

2.8.5 字符串字面量

字符串字面量是被双引号或者三重双引号包围的字符串序列,如 """…"""

对于双引号包围的字符串字面量,允许出现的字符与字符字面量相同。但是,如果双引号字符出现在字符串中,则必须使用反斜杠 \ 进行转义。以下是字符串字面量的例子:

"Programming\nScala"
"He exclaimed, \"Scala is great!\""
"First\tSecond"

用三重双引号包含的字符串字面量也被称为多行字符串字面量。这些字符串可以跨越多行,换行符是字符串的一部分。可以包含任意字符,可以是一个双引号也可以是两个连续的双引号,但不允许出现三个连续的双引号。对于包含有反斜杠 \,但反斜杠不用于构成 Unicode 字符,也不用于构成有效转义序列(如表 2-4 中列出的序列)的字符串很适合采用这种字符串字面量。正则表达式就是一个很好的例子,在正则表达式中经常用转义的字符表示特殊含义。即使转义序列出现,三重双引号包含的字符串也不对其进行转义。

以下给出了 3 个示例字符串:

"""Programming\nScala"""
"""He exclaimed, "Scala is great!" """
"""First line\n
Second line\t

Fourth line"""

注意,在第二个例子中,必须在结尾的 """ 前加一个空格,以防止出现解析错误。试图将 "Scala is great!" 的第二个双引号进行转义("Scala is great!\")的行为是无效的。

使用多行字符串时,你可能希望各行子串有良好的缩进以使代码美观,但却不希望多余的空格出现在字符串的输出中。String.stripMargin 可以解决这个问题。它会移除每行字符串开头的空格和第一个遇到的垂直分隔符 |。如果你需要自行添加空格制造缩进,你应该在 | 后添加你要的空格。参照以下示例:

// src/main/scala/progscala2/typelessdomore/multiline-strings.sc

def hello(name: String) = s"""Welcome!
  Hello, $name!
  * (Gratuitous Star!!)
  |We're glad you're here.
  |  Have some extra whitespace.""".stripMargin

hello("Programming Scala")

以上代码输出如下:

Welcome!
  Hello, Programming Scala!
  * (Gratuitous Star!!)
We're glad you're here.
  Have some extra whitespace.

注意哪些行开头的空格被移除,而哪些行开头的空格未被移除。

如果你希望用别的字符代替 |,可以用 stripMargin 的重载版本,该函数可以指定一个 Char 参数代替 |。如果你想要移除整个字符串(而不是字符串的各个行)的前缀和后缀,有相应的 stripPrefixstripSuffix 方法可以完成:

// src/main/scala/progscala2/typelessdomore/multiline-strings2.sc

def goodbye(name: String) =
  s"""xxxGoodbye, ${name}yyy
  xxxCome again!yyy""".stripPrefix("xxx").stripSuffix("yyy")

goodbye("Programming Scala")

上述例子的输出为:

Goodbye, Programming Scalayyy
  xxxCome again!

2.8.6 符号字面量

Scala 支持符号,符号是一些规定的字符串。两个同名的符号会指向内存中的同一对象。相比其他编程语言如 Ruby 和 Lisp,符号在 Scala 中用得比较少。

符号字面量是单引号(')后跟上一个或多个数字、字母或下划线(“_”),但第一个字符不能为数字。所以 '1symbol 是无效的符号。

符号字面量 'id 是表达式 scala.Symbol("id") 的简写形式,如果你需要创建一个包含空格的符号,可以使用 Symbol.apply,如 Symbol(" Programming Scala ") 中的空格均为符号的一部分。

2.8.7 函数字面量

我们之前已经接触过函数字面量,但这里重述一下:(i: Int, s: String) => s+i 是一个类型为 Function2[Int,String,String](返回值类型为 String)的函数字面量。

甚至可以用函数字面量来声明变量,以下两种声明是等价的:

val f1: (Int,String) => String       = (i, s) => s+i
val f2: Function2[Int,String,String] = (i, s) => s+i

2.8.8 元组字面量

你希望从方法中返回多少次值(两个或多个值)?在很多语言中,如 Java,你只有少数几种解决方案,且每一种都不是上选。比如,你可以传入参数,然后在方法中修改该参数,相当于“返回值”,但这样的代码很丑陋,不美观。(有的语言甚至用关键字来表示哪些参数是输入参数,哪些参数是输出参数。)你可以声明一个像“结构体”一样的类,类中有两个或多个值,然后返回该类的实例。

Scala 库中包含 TupleN 类(如 Tuple2),用于组建 N 元素组,它以小括号加上逗号分隔的元素序列的形式来创建元素组。TupleN 表示的多个类各自独立,N 的取值从 1 到 22,包括 22(在 Scala 的未来版本中这个上限可能最终取消)。

例如,val tup = ("Programming Scala", 2014) 定义了一个 Tuple2 的实例,实例中的第一个成员类型为 String,从 "Programming Scala" 中推断得到,第二个成员类型为 Int,从 2014 中推断得到。元组的实例是不可变的、first-class 的值(因为它们是对象,与你定义的其他类的实例没有区别),所以你可以将它们赋值给变量,将它们作为输入参数,或从方法中将其返回。

你也可以用字面量语法来声明 Tuple 类型的变量:

val t1: (Int,String)       = (1, "two")
val t2: Tuple2[Int,String] = (1, "two")

以下例子演示了元组的用法:

// src/main/scala/progscala2/typelessdomore/tuple-example.sc

val t = ("Hello", 1, 2.3)                                         // ➊
println( "Print the whole tuple: " + t )
println( "Print the first item:  " + t._1 )                       // ➋
println( "Print the second item: " + t._2 )
println( "Print the third item:  " + t._3 )

val (t1, t2, t3) = ("World", '!', 0x22)                           // ➌
println( t1 + ", " + t2 + ", " + t3 )

val (t4, t5, t6) = Tuple3("World", '!', 0x22)                     // ➍
println( t4 + ", " + t5 + ", " + t6 )

❶ 用字面量语法构造一个三个参数的元组 Tuple3

❷ 从元组中提取第一个元素(计数从 1 开始,不从 0 开始),接下来的 2 行代码分别提取第二个和第三个元素。

❸ 声明了三个变量,t1t2t3,用元组中三个对应的元素对其赋值。

❹ 用 Tuple3 的“工厂”方法构造一个元组。

运行该脚本,得到以下输出:

Print the whole tuple: (Hello,1,2.3)
Print the first item:  Hello
Print the second item: 1
Print the third item:  2.3
World, !, 34
World, !, 34

表达式 t._n 提取元组 t 中的第 n 个元素。为遵循历史惯例,这里从 1 开始计数,而不是从 0 开始。

一个两元素的元组,有时也被简称为 pair。有很多定义 pair 的方法,除了在圆括号中列出元素值以外,还可以把“箭头操作符”放在两个值之间,也可以用相应类的工厂方法:

(1, "one")
1 -> "one"
1 → "one"        // 用 → 代替 ->
Tuple2(1, "one")

箭头操作符只适用于两元素的元组。

2.9 OptionSomeNone:避免使用null

我们来讨论 3 种类型,即 OptionSomeNone,它们可以表示“有值”或者“没有值”。

大部分语言都有一个特殊的关键字或类的特殊实例,用于在引用变量没有指向任何对象时,表示“无”。在 Java 中,是 null 关键字,但 Java 中没有某个实例或类型。因此,对它调用任何方法都是非法的。但是语言设计者对此感到非常迷惑:为什么要在程序员期望返回对象时返回一个关键字呢?

当然,真正的问题在于,null 是很多 bug 的来源。null 表示的真正含义是在给定的情形下没有任何值。如何变量不等于 null,它是有值的。为什么不在类型系统中显式地将这种情况表达出来,并通过类型检查来避免空指针异常呢?

Optionhttp://www.scala-lang.org/api/current/#scala.Option)允许我们显式表示这种情况,而不需要用 null 这种“骇客”技巧。作为一个抽象类,Option 却有两个具体的子类 Somehttp://www.scala-lang.org/api/current/#scala.Some)和 Nonehttp://www.scala-lang.org/api/current/#scala.None$)。Some 用于表示有值,None 用于表示没有值。

在以下实例中你会看到 OptionSomeNone 的运用。我们创建了一个美国州与州首府的映射表:

// src/main/scala/progscala2/typelessdomore/state-capitals-subset.sc

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  // ...
  "Wyoming" -> "Cheyenne")

println( "Get the capitals wrapped in Options:" )
println( "Alabama: " + stateCapitals.get("Alabama") )
println( "Wyoming: " + stateCapitals.get("Wyoming") )
println( "Unknown: " + stateCapitals.get("Unknown") )

println( "Get the capitals themselves out of the Options:" )
println( "Alabama: " + stateCapitals.get("Alabama").get )
println( "Wyoming: " + stateCapitals.get("Wyoming").getOrElse("Oops!") )
println( "Unknown: " + stateCapitals.get("Unknown").getOrElse("Oops2!") )

注意看执行脚本时发生了什么:

Get the capitals wrapped in Options:
Alabama: Some(Montgomery)
Wyoming: Some(Cheyenne)
Unknown: None
Get the capitals themselves out of the Options:
Alabama: Montgomery
Wyoming: Cheyenne
Unknown: Oops2!

Map.get 方法返回了 Option[T],这里类型 TString。与此不同,在 Java 中,Map.get 返回 TT 可能是 null 或实际的值。通过返回 Option,我们就不会“忘记”去检查是否有实际值返回。换言之,对于给定的 key,“对应的值可能并不存在”这一事实已经包含在方法返回的类型中了。

第一组 println 语句非直接地对 get 返回的实例执行了 toString 方法。事实上,我们是在对 SomeNone 执行 toString 方法,因为当映射表中存在 key 对应的值时,Map.get 的返回值被自动包装在 Some 对象中。相反,当我们请求了一个映射表中不存在的数据时,Map.get 就返回 None,而不是 null,最后一个 println 就是这种情况。

第二组 println 更进一步,调用 Map.get 后,又对 Option 实例调用了 getgetOrElse,以取出其中包含的值。

Option.get 方法有些危险,如果 Option 是一个 SomeSome.get 则会返回其中的值。然而,如果 Option 事实上是一个 NoneNone.get 就会抛出一个 NoSuchElementExceptionhttp://docs.oracle.com/javase/8/docs/api/java/util/NoSuchElementException.html)异常。

在后面两个 println 语句中,我们可以看到 get 的替代选项——一个更安全的方法 getOrElsegetOrElse 方法会在 OptionSome 时返回其中的值,而在 OptionNone 时返回传递给它的参数中的值。换言之,getOrElse 的参数起到了默认值的作用。

所以,getOrElse 是两个方法中更具防御性的,它避免了潜在的异常。

再次申明,由于 Map.get 返回了 Option,这明显告诉读者映射表中有可能找不到指定的 key。映射表在这种情况下会返回 None,而大部分编程语言会返回 null(或其他等价的值)。的确,从经验来看你推断这种情况下这些语言中可能出现 null,但 Option 将这种情况显式地体现在函数签名中,使其更具自解释性。

另外,多亏 Scala 的静态类型性质,你可以避免“忘记”返回的是 Option,从而调用 Option 里的值(如果有值的话)来启动方法。在 Java 中,当方法返回一个值时,在调用该值的方法前很容易忘记检查它是否为 null。但是,Scala 的方法返回 Option,编译器的类型检查便强制要求你先从 Option 中提取值,再对它调用方法。这一机制“提醒”你去检查 Option 是否等于 None。所以,Option 的使用强烈鼓励更具弹性的编程习惯。

Scala 运行于 JVM 的环境,且必须与其他库互操作,因此 Scala 必须支持 null。另外,一些固执的人也可能会给你返回一个 Some(null)。尽管如此,你现在有了比 null 更好的选择,就应该在自己的代码中避免使用 null(除非必须与支持 null 的 Java 库进行交互)。Tony Hoare(http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare),在 1965 年开发一门名为 ALGOL W 的语言时发明了 null,他曾表示 null 的发明是相当于损失“数十亿美元”的错误。你看,还是用 Option 吧。

2.10 封闭类的继承

现在我们来探讨 Option 的一个很有用的特性。Option 的一个关键点在于它只有两个有效的子类。如果我们有值,则对应 Some 子类;如果没有值,则对应 None 子类。没有其他有效的 Option 子类型。所以,我们可以防止用户创建一个他们自己的子类。

为了达到这个目的,Scala 设计了关键字 sealedOption 的声明类似这样(省略部分细节):

sealed abstract class Option[+A] ... { ... }

关键字 sealed 告诉编译器,所有的子类必须在同一个源文件中声明。而在 Scala 库中,SomeNone 就是与 Option 声明在同一源文件中的。这一技术有效防止了 Option 派生其他子类型。

顺便提一下,如果需要防止用户派生任何子类,也可以用 final 关键字进行声明。

2.11 用文件和名空间组织代码

Scala 沿用 Java 用包来表示命名空间的这一做法,但它却更具灵活性。文件名不必与类名一致,包结构不一定要与目录结构一致。所以,你可以定义与文件的“物理”位置独立的包结构。

以下示例用 Java 语法在包 com.example.mypkg 中定义了名为 MyClass 的类,这是 Java 的常规语法:

// src/main/scala/progscala2/typelessdomore/package-example1.scala
package com.example.mypkg

class MyClass {
  // ...
}

Scala 也支持使用嵌套块结构语法来定义包的作用域,与 C# 的命名空间语法和 Ruby 表示命名空间的 modules 用法类似:

// src/main/scala/progscala2/typelessdomore/package-example2.scala
package com {
  package example {
    package pkg1 {
      class Class11 {
        def m = "m11"
      }
      class Class12 {
        def m = "m12"
      }
    }

    package pkg2 {
      class Class21 {
        def m = "m21"
        def makeClass11 = {
          new pkg1.Class11
        }
        def makeClass12 = {
          new pkg1.Class12
        }
      }
    }

    package pkg3.pkg31.pkg311 {
      class Class311 {
        def m = "m21"
      }
    }
  }
}

pkg1pkg2 这两个包定义在包 com.example 中。在这两个包中,共有 3 个类。在包 pkg2 的类 Class21 中,makeClass11makeClass12 方法展示了如何引用“兄弟”包 pkg1 中的类。你也可以分别用这些类的全路径 com.example.pkg1.Class11com.example.pkg1.Class12 来引用它们。

pkg3.pkg31.pkg311 表明你可以在一条语句中将多个包“链接”在一起。不必为每一层包单独使用一条 package 语句。

然而,有一种情况你必须使用单独的 package 语句。我们称之为连续包声明:

// src/main/scala/progscala2/typelessdomore/package-example3.scala
// 导入example中所有包级别的声明
package com.example
// 导入mypkg中所有包级别的声明
package mypkg

class MyPkgClass {
  // ...
}

如果你想导入的包都有包级别的声明,比如类的声明,那么应该为包层次中的每个包使用单独的 package 语句。每个后续的包语句都被解释为上一个包的子包,就像我们使用上文展示的嵌套块结构语法那样。第一个 package 语句的路径为绝对路径。

我们遵循 Java 的惯例,将 Scala 库的 root 包命名为 scala

尽管声明包的语法很灵活,但有一个限制,就是包不能在类或对象中定义,那样做是没有意义的。

 Scala 不允许在脚本中定义包,脚本被隐含包装在一个对象中。在对象中声明包是不允许的。

2.12 导入类型及其成员

就像在 Java 中一样,要使用包中的声明,必须先导入它们。但 Scala 还提供了其他选择,以下例子展示了 Scala 如何导入 Java 类型:

import java.awt._
import java.io.File
import java.io.File._
import java.util.{Map, HashMap}

你可以像第一行那样,用下划线(_)当通配符,导入包中的所有类型。你也可以像第二行那样导入包中单独的 Scala 类型或 Java 类型。

 Java 用星号(*)作为 import 的通配符。在 Scala 中,星号被允许用作函数名,因此 _ 被用作通配符,以避免歧义。例如,如果对象 Foo 定义了其他方法,同时它还定义了 * 方法,import Foo.* 表示什么呢?

第三行导入了 java.io.File 中所有的静态方法和属性。与之等价的 Java import 语句为 import static java.io.File.*。Scala 没有 import static 这样的写法,因为 Scala 将 object 类型与其他类型一视同仁。

如第四行所示,选择性导入的语法非常好用,在第四行中我们导入了 java.util.Mapjava.util.HashMap

import 语句几乎可以放在任何位置上,所以你可以将其可见性限制在需要的作用域中,可以在导入时对类型做重命名,也可以限制不想要的类型的可见性:

def stuffWithBigInteger() = {

  import java.math.BigInteger.{
    ONE => _,
    TEN,
    ZERO => JAVAZERO }

  // println( "ONE: "+ONE )     // ONE未定义
  println( "TEN: "+TEN )
  println( "ZERO: "+JAVAZERO )
}

由于这一 import 语句位于 stuffWithBigInteger 函数中,导入的类型和值在函数外是不可见的。

java.math.BigInteger.ONE 常量重命名为下划线,使得该常量不可见。当你需要导入除少部分以外的所有声明时,可以采用这一技术。

接着,java.math.BigInteger.TEN 导入时未经重命名,所以可以用 TEN 引用它。

最后,java.math.BigInteger.ZERO 常量被赋予了 JAVAZERO 的“别名”。

当你想取一个便利的名字或避免与当前作用域中其他同名声明冲突时,别名非常有用。导入 Java 类型时经常使用别名,以避免其余 Scala 中同名类型的冲突,如 java.util.Listhttp://docs.oracle.com/javase/8/docs/api/java/util/List.html)与 java.util.Maphttp://docs.oracle.com/javase/8/docs/api/java/util/Map.html),在 Scala 库中有相同名称的类。

2.12.1 导入是相对的

Scala 与 Java 导入机制的另一重要区别是:Scala 的导入是相对的。注意下例中的注释:

// src/main/scala/progscala2/typelessdomore/relative-imports.scala
import scala.collection.mutable._
import collection.immutable._              // 由于scala已经导入,不需要给出全路径
import _root_.scala.collection.parallel._  // 从“根”开始的全路径

在相对导入上很少会碰见麻烦,但意外有时也会发生。如果你遇到一个让你迷惑不解的编译错误指出某个包无法找到时,需要检查一下导入语句中的相对路径和绝对路径是否正确。在少数情况下,你还需要添加 _root_ 前缀。通常,使用顶层的包,如 comorgscala 就足够了。但必须保证问题库的所在路径被包含在 CLASSPATH 中。

2.12.2 包对象

对于库的作者,设计上要选择何处作为 API 暴露公共接入点,因为这些 API 是客户端将要导入并使用的。Java 库经常导入包中定义的部分或所有类型。例如 Java 语句 import java.io.* 导入了 io 包中的所有类型。Java 5 增加了“静态导入”,支持单独导入包中的静态成员。尽管相对便利了一些,但它所采用的语法还是不太便利。你可以考虑使用一个虚构的 JSON 解析库,同时会用到顶层的库 json 以及类 JSON 中的一个静态 API 方法:

static import com.example.json.JSON.*;

在 Scala 中,你至少可以省略 static 关键字。但写为如下形式更为简洁,可以用一条 import 语句导入所有 API 使用者需要的类型、方法和值:

import com.example.json._

Scala 支持包对象这一特殊类型的、作用域为包层次的对象。这里的 json 就是包对象。它像普通的对象一样声明,但与普通对象有着如下示例所展示的不同点:

// src/com/example/json/package.scala                        ➊

package com.example                                       // ➋

package object json {                                     // ➌
  class JSONObject {...}                                  // ➍
  def fromString(string: String): JSONObject = {...}
  ...
}

❶ 文件名必须为 package.scala。根据惯例,文件位于与定义的包对象相同的包目录中,在这里为 com/example/json

❷ 上层包的作用域。

❸ 使用 package 关键字给包名之后的对象命名,在这里对象名为 json

❹ 适合暴露给客户端的成员。

这样,客户端可以用 import com.example.json._ 导入所有的定义,或用通常的方法单独导入元素。

2.13 抽象类型与参数化类型

我们在 1.3 节中提到 Scala 支持参数化类型,与 Java 中的泛型十分类似。(我们也可以交换这两个术语,但 Scala 社区中多使用“参数化类型”,Java 社区中常用泛型一词。)在语法上,Java 使用尖括号(<…>),而 Scala 使用方括号([…]),因为在 Scala 中 <> 常用作方法名。

例如,字符串列表可以声明如下:

val strings: List[String] = List("one", "two", "three")

由于我们可以在集合 List[A] 中使用任何类型作为类型 A,这种特性被称为参数多态。在方法 List 的通用实现中,允许使用任何类型的实例作为 List 的元素。

我们来讨论学习参数化类型最为重要的细节,尤其当你在试图理解 Scaladoc 中的类型签名时,这些细节尤为重要。如在 Scaladoc 中的 List 条目(http://www.scala-lang.org/api/current/#scala.collection.immutable.List)中,你会发现其声明被写为 sealed abstract class List[+A]

A 之前的 + 表示:如果 BA 的子类,则 List[B] 也是 List[A] 的子类型,这被称为协类型。协类型很符合直觉,如果我们有一个函数 f(list: List[Any]),那么传递 List[String] 给这个函数,也应该能正常工作。

如果类型参数前有 -,则表示另一种关系:如果 BA 的子类型,且 Foo[A] 被声明为 Foo[-A],则 Foo[B]Foo[A] 的父类型(称为逆类型)。这一机制没那么符合直觉,我们将在参数化类型中与参数化类型的其他细节一起解释这一点。

Scala 还支持另一种被称为“抽象类型”的抽象机制,它可以运用在许多参数化类型中,也能够解决设计上的问题。然而,尽管两种机制有所重合,但并不冗余,两种机制对不同的设计问题各有优势与不足。

参数化类型和抽象类型都被声明为其他类型的成员,就像是该类型的方法与属性一样。以下示例在父类中使用抽象类型,而在子类中将该类型具体化:

// src/main/scala/progscala2/typelessdomore/abstract-types.sc
import java.io._

abstract class BulkReader {
  type In
  val source: In
  def read: String  // 读进source,然后返回一个String
}

class StringBulkReader(val source: String) extends BulkReader {
  type In = String
  def read: String = source
}

class FileBulkReader(val source: File) extends BulkReader {
  type In = File
  def read: String = {
    val in = new BufferedInputStream(new FileInputStream(source))
    val numBytes = in.available()
    val bytes = new Array[Byte](numBytes)
    in.read(bytes, 0, numBytes)
    new String(bytes)
  }
}

println(new StringBulkReader("Hello Scala!").read)
// 假定当前目录为src/main/scala:
println(new FileBulkReader(
  new File("TypeLessDoMore/abstract-types.sc")).read)

产生的输出如下:

Hello Scala!
// src/main/scala/progscala2/typelessdomore/abstract-types.sc

import java.io._

abstract class BulkReader {
...

抽象类 BulkReader 声明了 3 个虚拟成员:一个名为 In,是类型成员;第二个类型为 In,是 val 变量,名为 source;第三个是一个 read 方法。

派生类 StringBulkReaderFileBulkReader 为上述抽象成员提供具体化的定义。

注意 type 成员的工作机制与参数化类型中的类型参数非常类似。事实上,我们可以将该示例重写如下,在这里我们只显示改动的部分:

abstract class BulkReader[In] {
  val source: In
  ...
}

class StringBulkReader(val source: String) extends BulkReader[String] {...}

class FileBulkReader(val source: File) extends BulkReader[File] {...}

就像参数化类型,如果我们定义 In 类型为 String,则 source 属 性也必须被定义为 String。注意 StringBulkReaderread 方法只是将 source 属性返回,而 FileBulkReaderread 方法则需要读取文件的内容。

那么,类型成员与参数化类型相比有什么优势呢?当类型参数与参数化的类型无关时,参数化类型更适用。例如 List[A]A 可能是 IntStringPerson 等。而当类型成员与所封装的类型同步变化时,类型成员最适用。正如 BulkReader 这个例子,类型成员需要与封装的类型行为一致。有时这种特点被称为家族多态,或者协特化。

2.14 本章回顾与下一章提要

本章介绍了 Scala 编程的实践基础,如字面量、关键字、文件的组织以及导入。我们学习了如何声明变量、方法和类,也了解了 Option 是比 null 更好用的工具,以及其他一些有用的技术。在下一章中,我们将结束对 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 参考文献
  • 作者简介
  • 关于封面