5

Scala特性


5.1 Case Class和Sealed Trait81
5.2 模式匹配83
5.3 传名参数88
5.4 隐式参数91
5.5 Typeclass推断93

@ def getDayMonthYear(s: String) = s match {
    case s"$day-$month-$year" => println(s"found day: $day, month: $month, year: $year")
    case _ => println("not a date")
  }

@ getDayMonthYear("9-8-1965")
found day: 9, month: 8, year: 1965

@ getDayMonthYear("9-8")
not a date
</> 5.1.scala

Snippet 5.1: 使用Scala模式匹配解析简单的字符串

本章涉及Scala一些更有趣,且不同寻常的特性。我们不仅介绍特性本身,也用一些例子让你感受特性是如何发挥作用的。

本章的某些特性并不会在你日常使用中频繁出现。然而,当你最终在实际中碰到它们时,这能帮助你从一个较高的角度理解它们。

5.1 Case Class和Sealed Trait

5.1.1 Case Class

case class 有点像普通的 class,但它代表这个类只是一份数据:其中所有数据都是不可变的,可公开访问的,不携带任何可变状态或封装。它类似于C/C++的“structs”,Java的“POJOs”,Python或Kotlin的“Data Classes”。它的名字来源于可搭配 case 关键字做 模式匹配 (5.2) 的功能。

Case class使用 case 关键字定义,不用 new 也能实例化,其构造器参数默认都是公开访问字段。

@ case class Point(x: Int, y: Int)

@ val p = Point(1, 2)
p: Point = Point(1, 2)
</> 5.2.scala
@ p.x
res2: Int = 1

@ p.y
res3: Int = 2
</> 5.3.scala

case class 已经实现了下列方法:

  • .toString 展示构造器参数值

  • == 检查构造器参数值是否相等

  • .copy 方便地创建实例副本

@ p.toString
res4: String = "Point(1,2)"

@ val p2 = Point(1, 2)

@ p == p2
res6: Boolean = true
</> 5.4.scala
@ val p = Point(1, 2)

@ val p3 = p.copy(y = 10)
p3: Point = Point(1, 10)

@ val p4 = p3.copy(x = 20)
p4: Point = Point(20, 10)
</> 5.5.scala

如同普通类,你可以在 case class 内部定义实例方法或属性:

@ case class Point(x: Int, y: Int) {
    def z = x + y
  }
</> 5.6.scala
@ val p = Point(1, 2)

@ p.z
res12: Int = 3
</> 5.7.scala

case class 是大型元组的良好替代物,因为你不用 ._1 ._2 ._7 抽取字段,而是使用字段名称,比如 .x.y。这比记住元组的字段 ._7 所代表的含义要容易的多!

See example 5.1 - CaseClass

5.1.2 Sealed Trait

trait 可以用 sealed 修饰,只被固定数量的 case class 继承。下列例子中,我们定义了 sealed trait Point,它被 Point2DPoint3D 继承:

@ {
  sealed trait Point
  case class Point2D(x: Double, y: Double) extends Point
  case class Point3D(x: Double, y: Double, z: Double) extends Point
  }

@ def hypotenuse(p: Point) = p match {
    case Point2D(x, y) => math.sqrt(x * x + y * y)
    case Point3D(x, y, z) => math.sqrt(x * x + y * y + z * z)
  }

@ val points: Array[Point] = Array(Point2D(1, 2), Point3D(4, 5, 6))

@ for (p <- points) println(hypotenuse(p))
2.23606797749979
8.774964387392123
</> 5.8.scala

普通 traitsealed trait 之间的核心区别如下:

  • 普通 trait开放的:继承类的数量没有限制,只要它们实现所有要求的方法,在 trait 指定接口调用的地方,那些类的实例可互换。

  • sealed trait封闭的:仅允许固定数量类继承它,所有继承类必须和trait自身在同一个文件中定义,或者在REPL命令中一同定义(因此要求上述 Point/Point2D/Point3D 的定义要用花括号 {} 包裹)。

由于 sealed trait Point 继承类数量是固定的,因此我们可以使用上述函数 def hypotenuse 进行模式匹配,定义每一种 Point 的处理逻辑。

5.1.3 普通Trait对比Sealed Trait

普通 traitsealed trait 在Scala应用中都很常见:普通 trait 可以有任意多子类,而 sealed trait 子类数量是固定的。

普通 traitsealed trait 让许多事情变得简单:

  • 普通 trait 层次结构使得添加额外子类是容易的:只需要定义你的子类并实现必要方法。但是这让 trait 添加接口变得困难:新接口需要添加到所有已存在的子类,而子类数量可能很多。

  • sealed trait 层次结构是相反的:为 trait 添加接口很容易,因为新接口可以简单地模式匹配每一个子类,并确定每个子类的处理逻辑。但是添加新子类变得困难,因为你需要为每个已存在的模式匹配接口添加 case 分支来处理你的新子类,而接口数量可能很多。

通常,当你预计子类数量几乎不变时,就选择 sealed trait。一个能用 sealed trait 建模的好例子是JSON:

@ {
  sealed trait Json
  case class Null() extends Json
  case class Bool(value: Boolean) extends Json
  case class Str(value: String) extends Json
  case class Num(value: Double) extends Json
  case class Arr(value: Seq[Json]) extends Json
  case class Dict(value: Map[String, Json]) extends Json
  }
</> 5.9.scala
  • 一个JSON值只可能是null,boolean,number,string,array,或者dictionary。

  • JSON在20年来没有发生变化,所以不太可能有人将来会继承我们的JSON trait 去添加新子类。

  • 尽管子类集合是固定的,但是我们在JSON数据上可以进行的操作是没有边界的:解析、序列化、友好打印、最小化、净化等等。

因此用封闭的 sealed trait,而不是普通开放的 trait 去建模JSON才是合理的。

See example 5.2 - SealedTrait

5.2 模式匹配

5.2.1 匹配

Scala使用 match 关键字做模式匹配。这和其它编程语言中的 switch 语句相似但更灵活:除了匹配原始类型的integer和string,你还可以从组合数据类型诸如元组和 case class 中解构内容。注意下列许多例子中,有一个 case _ => 从句定义了默认case,如果排在前面的case都没有匹配上,将使用该case进行处理。

5.2.1.1 匹配 Int

@ def dayOfWeek(x: Int) = x match {
    case 1 => "Mon"; case 2 => "Tue"
    case 3 => "Wed"; case 4 => "Thu"
    case 5 => "Fri"; case 6 => "Sat"
    case 7 => "Sun"; case _ => "Unknown"
  }

@ dayOfWeek(5)
res19: String = "Fri"

@ dayOfWeek(-1)
res20: String = "Unknown"
</> 5.10.scala

5.2.1.2 匹配 String

@ def indexOfDay(d: String) = d match {
    case "Mon" => 1; case "Tue" => 2
    case "Wed" => 3; case "Thu" => 4
    case "Fri" => 5; case "Sat" => 6
    case "Sun" => 7; case _ => -1
  }

@ indexOfDay("Fri")
res22: Int = 5

@ indexOfDay("???")
res23: Int = -1
</> 5.11.scala

5.2.1.3 匹配元组 (Int, Int)

@ for (i <- Range.inclusive(1, 100)) {
    val s =  (i % 3, i % 5) match {
      case (0, 0) => "FizzBuzz"
      case (0, _) => "Fizz"
      case (_, 0) => "Buzz"
      case _ => i
    }
    println(s)
  }
1
2
Fizz
4
Buzz
...
</> 5.12.scala

5.2.1.4 匹配元组 (Boolean, Boolean)

@ for (i <- Range.inclusive(1, 100)) {
    val s = (i % 3 == 0, i % 5 == 0) match {
      case (true, true) => "FizzBuzz"
      case (true, false) => "Fizz"
      case (false, true) => "Buzz"
      case (false, false) => i
    }
    println(s)
  }
1
2
Fizz
4
Buzz
...
</> 5.13.scala

5.2.1.5 匹配Case Class:

@ case class Point(x: Int, y: Int)

@ def direction(p: Point) = p match {
    case Point(0, 0) => "origin"
    case Point(_, 0) => "horizontal"
    case Point(0, _) => "vertical"
    case _ => "diagonal"
  }

@ direction(Point(0, 0))
res28: String = "origin"

@ direction(Point(1, 1))
res29: String = "diagonal"

@ direction(Point(10, 0))
res30: String = "horizontal"
</> 5.14.scala

5.2.1.6 解构字符串模式:

@ def splitDate(s: String) = s match {
    case s"$day-$month-$year" =>
      s"day: $day, mon: $month, yr: $year"
    case _ => "not a date"
  }

@ splitDate("9-8-1965")
res32: String = "day: 9, mon: 8, yr: 1965"

@ splitDate("9-8")
res33: String = "not a date"
</> 5.15.scala

(注意字符串模式匹配只支持简单的通配符,不支持正则表达式。你可以使用 scala.util.matching.Regex 应对正则表达式的情况)

5.2.2 嵌套匹配

模式可以嵌套,下面例子展示了 case class 嵌套字符串的模式匹配:

@ case class Person(name: String, title: String)

@ def greet(p: Person) = p match {
    case Person(s"$firstName $lastName", title) => println(s"Hello $title $lastName")
    case Person(name, title) => println(s"Hello $title $name")
  }

@ greet(Person("Haoyi Li", "Mr"))
Hello Mr Li

@ greet(Person("Who?", "Dr"))
Hello Dr Who?
</> 5.16.scala

模式可以嵌套任意层。下面例子展示了元组嵌套 case class,里面又嵌套字符串的模式匹配:

@ def greet2(husband: Person, wife: Person) = (husband, wife) match {
    case (Person(s"$first1 $last1", _), Person(s"$first2 $last2", _)) if last1 == last2 =>
      println(s"Hello Mr and Ms $last1")

    case (Person(name1, _), Person(name2, _)) => println(s"Hello $name1 and $name2")
  }

@ greet2(Person("James Bond", "Mr"), Person("Jane Bond", "Ms"))
Hello Mr and Ms Bond

@ greet2(Person("James Bond", "Mr"), Person("Jane", "Ms"))
Hello James Bond and Jane
</> 5.17.scala

5.2.3 循环和 val 变量

你还会在两个地方用到模式匹配: for 循环内部和 val 变量定义。当你迭代元组集合时,for 循环中的模式匹配很有用:

@ val a = Array[(Int, String)]((1, "one"), (2, "two"), (3, "three"))

@ for ((i, s) <- a) println(s + i)
one1
two2
three3
</> 5.18.scala

当你确信变量值会匹配上给定的模式,并且你唯一想做的就是抽取内容,那就用 val 语句的模式匹配。如果变量值匹配失败,将会抛出异常:

@ case class Point(x: Int, y: Int)

@ val p = Point(123, 456)

@ val Point(x, y) = p
x: Int = 123
y: Int = 456
</> 5.19.scala
@ val s"$first $second" = "Hello World"
first: String = "Hello"
second: String = "World"

@ val flipped = s"$second $first"
flipped: String = "World Hello"

@ val s"$first $second" = "Hello"
scala.MatchError: Hello
</> 5.20.scala

5.2.4 Sealed Trait和Case Class的模式匹配

模式匹配让你优雅地处理包含case class和sealed trait的结构化数据。让我们看一个用sealed trait建模的算术表达式:

@ {
  sealed trait Expr
  case class BinOp(left: Expr, op: String, right: Expr) extends Expr
  case class Literal(value: Int) extends Expr
  case class Variable(name: String) extends Expr
  }
</> 5.21.scala

其中 BinOp 代表二目运算,它可以表达算术计算,如下所示

x + 1
BinOp(Variable("x"), "+", Literal(1))
x * (y - 1)
BinOp(
  Variable("x"),
  "*",
  BinOp(Variable("y"), "-", Literal(1))
)
</> 5.22.scala
(x + 1) * (y - 1)
BinOp(
  BinOp(Variable("x"), "+", Literal(1)),
  "*",
  BinOp(Variable("y"), "-", Literal(1))
)
</> 5.23.scala

我们暂时忽略解析过程(即把左边的字符串转变成右边的 case class),我们将在 Chapter 19: Parsing Structured Text 介绍这个过程。算术表达式解析为 case class 后,让我们来考虑你可能做的两件事情:以人类可读的字符串打印它;或者给定变量值的情况下,求表达式的值。

5.2.4.1 表达式字符串化

可以使用下列算法把表达式转换成字符串:

  • 如果 Expr 是一个 Literal,字符串就是其字面量值
  • 如果 Expr 是一个 Variable,字符串就是该变量的名称
  • 如果 Expr 是一个 BinOp,字符串就是左表达式字符串化的结果,加上运算符,再加上右表达式字符串化的结果

转换成模式匹配的代码可以这样写:

@ def stringify(expr: Expr): String = expr match {
    case BinOp(left, op, right) => s"(${stringify(left)} $op ${stringify(right)})"
    case Literal(value) => value.toString
    case Variable(name) => name
  }
</> 5.24.scala

我们可以构造一些之前看到的 Expr,把它们传递给函数 stringify 来看看输出:

@ val smallExpr = BinOp(
    Variable("x"),
    "+",
    Literal(1)
  )

@ stringify(smallExpr)
res52: String = "(x + 1)"
</> 5.25.scala
@ val largeExpr = BinOp(
    BinOp(Variable("x"), "+", Literal(1)),
    "*",
    BinOp(Variable("y"), "-", Literal(1))
  )

@ stringify(largeExpr)
res54: String = "((x + 1) * (y - 1))"
</> 5.26.scala

5.2.4.2 表达式估值

估值比表达式字符串化稍微复杂一些。我们需要传递一个保存所有变量值的映射 values,然后我们需要区别对待 +-,以及 * 运算符:

@ def evaluate(expr: Expr, values: Map[String, Int]): Int = expr match {
    case BinOp(left, "+", right) => evaluate(left, values) + evaluate(right, values)
    case BinOp(left, "-", right) => evaluate(left, values) - evaluate(right, values)
    case BinOp(left, "*", right) => evaluate(left, values) * evaluate(right, values)
    case Literal(value) => value
    case Variable(name) => values(name)
  }

@ evaluate(smallExpr, Map("x" -> 10))
res56: Int = 11

@ evaluate(largeExpr, Map("x" -> 10, "y" -> 20))
res57: Int = 209
</> 5.27.scala

总的来说,这看起来比较类似我们之前编写的函数 stringify:递归地匹配 expr: Expr 参数,处理 Expr 每一种 case class 子类。处理无子节点的 LiteralVariable 比较简单,处理 BinOp 时,需要递归估值左孩子和右孩子,最后合并结果。这是任何语言处理递归数据结构的通用方式,Scala用 sealed traitcase class,和模式匹配实现得简洁而轻松。

我们简化了 Expr 结构,以及编写的字符串化函数和估值函数,只是为了看看模式匹配辅以 case classsealed trait,如何轻松地处理结构化的数据。我们会在 Chapter 20: Implementing a Programming Language 更深入地探索这些技术。

5.3 传名参数

@ def func(arg: => String) = ...

Scala用语法 : => T 代表传名参数 (by-name),在方法内部引用参数时会对其估值。它主要有三个应用场景:

  1. 如果参数最终没被使用可以避免估值
  2. 在参数估值前后可以运行包裹在估值代码中的前置和后置操作
  3. 对参数多次重复估值

5.3.1 避免估值

如下的 log 方法使用了传名参数,参数被真正打印时才对其估值。当logging被禁用时不会对其估值,这能节省构造日志消息 ("Hello " + 123 + "World") 所花费的CPU时间:

@ var logLevel = 1

@ def log(level: Int, msg: => String) = {
    if (level > logLevel) println(msg)
  }
</> 5.28.scala
@ log(2, "Hello " + 123 + " World")
Hello 123 World

@ logLevel = 3

@ log(2, "Hello " + 123 + " World")
<no output>
</> 5.29.scala

方法一般不会把所有入参都用上,正如上述例子中,只在需要时才构造日志消息,我们能显著节省CPU时间和对象分配,这在性能敏感的应用中大有帮助。

我们在 Chapter 4: Scala集合 见到的 getOrElsegetOrElseUpdate 也使用了这个技术:如果我们查询的值已经存在,那么这两个方法就不会使用默认参数。把一个传名参数作为默认参数,在没有用到它时,我们无需对其估值。

5.3.2 包裹估值

传名参数把估值包裹在前置、后置代码中是另一个常见模式。下述函数 measureTime 推迟 f: => Unit 的估值,在参数估值前后运行 System.currentTimeMillis() 来打印耗时:

@ def measureTime(f: => Unit) = {
    val start = System.currentTimeMillis()
    f
    val end = System.currentTimeMillis()
    println("Evaluation took " + (end - start) + " milliseconds")
  }

@ measureTime(new Array[String](10 * 1000 * 1000).hashCode())
Evaluation took 24 milliseconds

@ measureTime { // methods taking a single arg can also be called with curly brackets
    new Array[String](100 * 1000 * 1000).hashCode()
  }
Evaluation took 287 milliseconds
</> 5.30.scala

这种包裹方法还有许多用途:

  • 当参数被估值时,设置一些thread-local上下文
  • try-catch 代码块中估值参数,以便处理异常
  • Future 中估值参数,代码逻辑会在另一个线程中异步运行

传名参数在这些案例中都能发挥作用。

5.3.3 重复估值

传名参数的另一个用例是对方法入参重复估值。下面的代码片段定义了通用的 retry 方法:这个方法接收一个输入参数,在 try-catch 代码块中估值,如果执行失败了且还在最大尝试次数内,就重新执行它。我们用它包裹一个可能失败的函数调用,检查打印到控制台的重试日志。

@ def retry[T](max: Int)(f: => T): T = {
    var tries = 0
    var result: Option[T] = None
    while (result == None) {
      try { result = Some(f) }
      catch {case e: Throwable =>
        tries += 1
        if (tries > max) throw e
        else {
          println(s"failed, retry #$tries")
        }
      }
    }
    result.get
  }
</> 5.31.scala
@ val httpbin = "https://httpbin.org"

@ retry(max = 5) {
    // Only succeeds with a 200 response
    // code 1/3 of the time
    requests.get(
      s"$httpbin/status/200,400,500"
    )
  }
call failed, retry #1
call failed, retry #2
res68: requests.Response = Response(
  "https://httpbin.org/status/200,400,500",
  200,
...
</> 5.32.scala

上面我们定义了一个类型参数 [T] 的泛型函数 retry,接受一个估值结果类型为 T 的传名参数,当代码块执行成功后返回一个类型为 T 的值。我们可以用 retry 包裹任何类型的代码块,在最大可尝试次数内它会不断重试直到成功,然后返回结果。

retry 接收一个传名参数,可以在必要时对 requests.get 代码块重复估值。其它重复估值的用例还包括性能基准测试或者压力测试。你不会经常使用传名参数,但必要时,它能够让你便利地操控参数的估值:比如调校、重复、或跳过。

我们将在 Chapter 12: Working with HTTP APIs 中学习更多上面用到的 requests 库。

See example 5.4 - ByName

5.4 隐式参数

隐式参数 是函数调用时自动为你填充的参数。举个例,有一个类 Foo 和一个接收参数 implicit foo: Foo 的函数 bar

@ class Foo(val value: Int)

@ def bar(implicit foo: Foo) = foo.value + 10
</> 5.33.scala

如果你调用 bar 时的作用域中找不到隐式的 Foo,你将得到一个编译错误。调用 bar 时,你需要定义一个类型为 Foo 的隐式值,这样一来 bar 在调用时,才能自动找到隐式值:

@ bar
cmd4.sc:1: could not find implicit
           value for parameter foo: Foo
val res4 = bar
           ^
Compilation Failed
</> 5.34.scala
@ implicit val foo: Foo = new Foo(1)
foo: Foo = ammonite.$sess.cmd1$Foo@451882b2

@ bar // `foo` is resolved implicitly
res72: Int = 11

@ bar(foo) // passing in `foo` explicitly
res73: Int = 11
</> 5.35.scala

隐式参数和我们在 Chapter 3: Scala基础 遇到的 默认值 很相似。两者都允许你直接传递一个值,或者兜底到某个默认值。主要区别是默认值“硬编码”在方法定义处,而隐式参数在调用处的作用域里寻找 implicit 作为默认值。

我们先看一个更具体的例子,例子中隐式参数让你的代码干净可读。之后我们再学习这个特性在 Typeclass推断 (5.5) 中更高级的用法。

5.4.1 把ExecutionContext传递给Future

下面代码例子中,由于 Future 需要在 ExecutionContext 中执行,于是我们到处传递 ExecutionContext,使得编码过程乏味、繁琐:

def getEmployee(ec: ExecutionContext, id: Int): Future[Employee] = ...
def getRole(ec: ExecutionContext, employee: Employee): Future[Role] = ...

val executionContext: ExecutionContext = ...

val bigEmployee: Future[EmployeeWithRole] = {
  getEmployee(executionContext, 100).flatMap(
    executionContext,
    e =>
      getRole(executionContext, e)
        .map(executionContext, r => EmployeeWithRole(e, r))
  )
}
</> 5.36.scala

getEmployeegetRole 异步执行,其中使用 mapflatMap 做进一步处理。Future 工作原理超出了本节范围,现在只需要注意到每个操作都需要传递 executionContext。我们会在 Chapter 13: Fork-Join Parallelism with Futures 重新审视这些API。

假如不使用隐式参数,我们有下列选择:

  • 不停地手动传递 executionContext:这让你的代码很难阅读,我们真正关心的逻辑被淹没在样板式传递 executionContext 的海洋里

  • executionContext 作为全局变量:这会很简洁,但是在程序不同部分传递不同值的灵活性有所损失

  • executionContext 放进thread-local变量:这可以维持灵活性和简洁性,但是易于出错,你容易忘记为需要它才能运行的代码提前设置好thread-local

所有这些选项都是折中办法,让我们牺牲掉简洁性、灵活性、或者安全性。Scala隐式参数提供了第四种选项:间接地传递 executionContext,同时给我们简洁、灵活、以及安全。

5.4.2 隐式依赖注入

我们可以让所有函数接收隐式参数 executionContext 来解决这个问题。FutureflatMapmap 等标准库操作已经满足要求,我们可以修改 getEmployeegetRole 来依样画葫芦。通过定义 executionContextimplicit,下面所有方法调用会自动选中该隐式变量。

def getEmployee(id: Int)(implicit ec: ExecutionContext): Future[Employee] = ...
def getRole(employee: Employee)(implicit ec: ExecutionContext): Future[Role] = ...

implicit val executionContext: ExecutionContext = ...

val bigEmployee: Future[EmployeeWithRole] = {
  getEmployee(100).flatMap(e =>
    getRole(e).map(r =>
      EmployeeWithRole(e, r)
    )
  )
}
</> 5.37.scala

使用隐式参数可以帮助你清除应用程序中传递共享上下文或者配置对象的冗余代码:

  • 通过间接传递“不感兴趣”的参数,可以把读者的注意力聚焦在应用程序的核心逻辑上。

  • 由于隐式参数可以被直接传递,它们为开发者保留了灵活性,以防他们想要手动指定或者覆盖正被传递的隐式参数。

  • 隐式值缺失会引起编译错误,这使得它比thread-local用起来不那么容易犯错。在部署到生产环境前,缺失隐式值的编译错误就会被捕获。

5.5 Typeclass推断

隐式参数第二个发挥作用的地方是可以为指定类型关联隐式值,常称为 typeclass。这个术语来自Haskell编程语言,尽管它和 type 或者 class 都没有关系。typeclass构建在 implicit 语言特性之上,它很有趣并且足够重要,值得在本章单独占据一节内容。

5.5.1 问题描述:解析命令行参数

让我们考虑一个解析命令行参数的需求,输入是 String,要求转换成Scala各种数据类型:IntBooleanDouble 等等。这是一个几乎所有程序都要面对的通用需求,无论是直接处理,还是借助某个库。

第一种尝试可能是编写一个泛型方法来解析各种类型的值。签名类似这样:

def parseFromString[T](s: String): T = ...

val args = Seq("123", "true", "7.5")
val myInt = parseFromString[Int](args(0))
val myBoolean = parseFromString[Boolean](args(1))
val myDouble = parseFromString[Double](args(2))
</> 5.38.scala

这乍看起来无法实现:

  • parseFromString 如何知道怎样把一个 String 转换成任意类型 T

  • 它如何知道一个命令行参数可以转为哪些类型,不可以转为哪些类型?比如,我们无法支持从字符串解析出 java.net.DatagramSocket

5.5.2 独立的解析器对象

第二种尝试也许是定义独立的解析器对象,每种待解析的类型对应一个解析器对象。举个例:

trait StrParser[T]{ def parse(s: String): T }
object ParseInt extends StrParser[Int]{ def parse(s: String) = s.toInt }
object ParseBoolean extends StrParser[Boolean]{ def parse(s: String) = s.toBoolean }
object ParseDouble extends StrParser[Double]{ def parse(s: String) = s.toDouble }
</> 5.39.scala

然后我们可以如下调用:

val args = Seq("123", "true", "7.5")
val myInt = ParseInt.parse(args(0))
val myBoolean = ParseBoolean.parse(args(1))
val myDouble = ParseDouble.parse(args(2))
</> 5.40.scala

这样是可行的。但这引出了另一个问题:如果我们想要编写一个方法并不是直接解析 String,而是解析控制台输入,我们怎样做到?我们或许有两个选项。

5.5.2.1 重用StrParser

第一种选择是编写全新系列 object,去专门解析控制台输入:

trait ConsoleParser[T]{ def parse(): T }
object ConsoleParseInt extends ConsoleParser[Int]{
  def parse() = scala.Console.in.readLine().toInt
}
object ConsoleParseBoolean extends ConsoleParser[Boolean]{
  def parse() = scala.Console.in.readLine().toBoolean
}
object ConsoleParseDouble extends ConsoleParser[Double]{
  def parse() = scala.Console.in.readLine().toDouble
}

val myInt = ConsoleParseInt.parse()
val myBoolean = ConsoleParseBoolean.parse()
val myDouble = ConsoleParseDouble.parse()
</> 5.41.scala

第二种选择是定义一个帮助类,接收一个 StrParser[T] 作为参数,传进去后让StrParser来解析出类型 T

def parseFromConsole[T](parser: StrParser[T]) = parser.parse(scala.Console.in.readLine())

val myInt = parseFromConsole[Int](ParseInt)
val myBoolean = parseFromConsole[Boolean](ParseBoolean)
val myDouble = parseFromConsole[Double](ParseDouble)
</> 5.42.scala

这两种解决方案都很笨重:

  1. 第一种方案会造成重复编写 Int/Boolean/Double 等等解析器。如果我们希望从网络输入解析,从文件解析时,我们将需要为每种案例重复编写解析器。

  2. 第二种方案需要我们到处传递 ParseFoo 对象。而我们能够传递给 parseFromConsole[Int] 一般仅有 StrParser[Int]。为什么不让编译器帮我们推断呢?

5.5.3 解决方案:隐式StrParser

解决办法是把那些 StrParser 单实例变作 implicit

trait StrParser[T]{ def parse(s: String): T }
object StrParser{
  implicit object ParseInt extends StrParser[Int]{
    def parse(s: String) = s.toInt
  }
  implicit object ParseBoolean extends StrParser[Boolean]{
    def parse(s: String) = s.toBoolean
  }
  implicit object ParseDouble extends StrParser[Double]{
    def parse(s: String) = s.toDouble
  }
}
</> 5.43.scala

我们把 implicit object ParseIntParseBoolean 等等放在 object StrParser 中,object StrParsertrait StrParser 同名,紧挨在一起定义。和 class/trait 紧挨定义的同名 object 称为 伴生对象companion object)。伴生对象一般用于把隐式值、静态方法、工厂方法、以及其它功能聚在一起,它们和一个 trait 或者 class 相关,但是不属于任何特定实例。伴生对象中的隐式值会被自动引入代码当前作用域,提供所需隐式参数。

注意当你输入到Ammonite Scala REPL时,你需要用额外的花括号 {...} 包裹两者的定义,这样才能在同一个REPL命令中同时定义 traitobject

现在,我们仍然可以像之前一样直接调用 ParseInt.parse(args(0)) 解析字符串字面量,也可以编写泛型函数,根据待解析类型自动选择正确的 StrParser 实例:

def parseFromString[T](s: String)(implicit parser: StrParser[T]) = {
  parser.parse(s)
}

val args = Seq("123", "true", "7.5")
val myInt = parseFromString[Int](args(0))
val myBoolean = parseFromString[Boolean](args(1))
val myDouble = parseFromString[Double](args(2))
</> 5.44.scala

这和我们最初始的尝试类似,区别在于函数接收一个参数 (implicit parser: StrParser[T]),可以为待解析类型自动推断相应的 StrParser

5.5.3.1 重用隐式StrParser

StrParser[T] 变为隐式意味着我们可以重用它们,而不必重复编写解析器或者手动传递它们。比如,我们可以这样编写能够解析控制台字符串的函数:

def parseFromConsole[T](implicit parser: StrParser[T]) = {
  parser.parse(scala.Console.in.readLine())
}

val myInt = parseFromConsole[Int]
</> 5.45.scala

调用 parseFromConsole[Int] 自动推断出伴生对象 StrParser 中的隐式单例 StrParser.ParseInt,你不必复制它,也不必繁琐地传递它。这就使得编写代码处理泛型 T 很容易,只要 T 有对应的 StrParser 即可。

5.5.3.2 上下文界定语法

隐式参数携带泛型的技术很常用,Scala为此提供了专用语法。下面的方法签名:

def parseFromString[T](s: String)(implicit parser: StrParser[T]) = ...

可以简洁地写做:

def parseFromString[T: StrParser](s: String) = ...

这个语法就是 上下文界定 (context bound),它和上述语法 (implicit parser: StrParser[T]) 语义上等价。当使用上下文界定语法时,隐式参数没有名称,我们不能够像之前那样调用 (implicit parser: StrParser[T])。但我们可以使用函数 implicitly 解析隐式值,比如 implicitly[StrParser[T]].parse

5.5.3.3 编译期隐式安全

由于Typeclass推断使用了同样的 implicit 语言特性,当使用了不合法的类型调用 parseFromConsole 时,会产生编译错误:

@ val myDatagramSocket = parseFromConsole[java.net.DatagramSocket]
cmd19.sc:1: could not find implicit value for parameter parser:
            ammonite.$sess.cmd11.StrParser[java.net.DatagramSocket]
val myDatagramSocket = parseFromConsole[java.net.DatagramSocket]
                                       ^
Compilation Failed
</> 5.46.scala

除此之外,在另一个方法中调用需要隐式参数 (implicit parser:StrParser[T]) 的方法时,编译器也会抛出错误:

@ def genericMethodWithoutImplicit[T](s: String) = parseFromString[T](s)
cmd2.sc:1: could not find implicit value for parameter parser:
           ammonite.$sess.cmd0.StrParser[T]
def genericMethodWithoutImplicit[T](s: String) = parseFromString[T](s)
                                                                ^
Compilation Failed
</> 5.47.scala

我们能用Typelcass推断完成的绝大多数事情,也可以用运行时反射实现。然而,依赖运行时反射是脆弱的,非常容易出bug,或者误配置,在发现前便上了生产环境,引发灾难。对比之下,Scala implicit 特性让你安全地完成同样的事情:错误提前在编译期暴露,你可以从容修复它们,而不是顶着生产系统宕机的压力去解决问题。

5.5.4 递归Typeclass推断

我们已经看到了如何使用typeclass技术自动基于待解析类型选择 StrParser。这同样适用于更复杂的类型,比如我们告诉编译器我们想要一个 Seq[Int](Int, Boolean),或者甚至嵌套类型 Seq[(Int, Boolean)],然后编译器自动组装逻辑,解析出我们想要的类型。

5.5.4.1 解析序列

比如,下列函数 ParseSeq 为任意类型 T 提供了 StrParser[Seq[T]],其自身又使用了一个隐式 StrParser[T]

implicit def ParseSeq[T](implicit p: StrParser[T]) = new StrParser[Seq[T]]{
  def parse(s: String) = s.split(',').toSeq.map(p.parse)
}
</> 5.48.scala

不像我们先前定义的单实例 implicit object,这里我们使用 implicit def。取决于类型 T,我们需要不同的 StrParser[T],对应不同的 StrParser[Seq[T]]implicit def ParseSeq 每次被调用时,不同类型 T 会返回不同 StrParser

有了这个定义,我们现在可以解析 Seq[Boolean]Seq[Int]等等。

@ parseFromString[Seq[Boolean]]("true,false,true")
res99: Seq[Boolean] = ArraySeq(true, false, true)

@ parseFromString[Seq[Int]]("1,2,3,4")
res100: Seq[Int] = ArraySeq(1, 2, 3, 4)
</> 5.49.scala

我们教会了编译器为任何类型 T 产生一个 StrParser[Seq[T]],只要它有一个可用的隐式 StrParser[T]。由于我们已经有了 StrParser[Int]StrParser[Boolean],以及 StrParser[Double]ParseSeq 自然支持了 StrParser[Seq[Int]]StrParser[Seq[Boolean]],以及 StrParser[Seq[Double]]

我们正在实例化的 StrParser[Seq[T]] 有一个解析方法,它接收一个参数 s: String,返回一个 Seq[T]。正如我们在上述代码片段中所做的,我们只需要实现变换操作的必要逻辑。

5.5.4.2 解析元组

类似我们定义 implicit def 去解析 Seq[T],我们同样可以解析元组。我们假设以下输入字符串用 key=value 代表元组:

implicit def ParseTuple[T, V](implicit p1: StrParser[T], p2: StrParser[V]) =
  new StrParser[(T, V)]{
    def parse(s: String) = {
      val Array(left, right) = s.split('=')
      (p1.parse(left), p2.parse(right))
    }
  }
</> 5.50.scala

我们定义出了 StrParser[(T, V)],但要求类型 TV 都有对应可用的 StrParser。现在,我们可以把用 = 拼接的对值解析为元组:

@ parseFromString[(Int, Boolean)]("123=true")
res102: (Int, Boolean) = (123, true)

@ parseFromString[(Boolean, Double)]("true=1.5")
res103: (Boolean, Double) = (true, 1.5)
</> 5.51.scala

5.5.4.3 解析递归结构

上述两个定义,implicit def ParseSeqimplicit def ParseTuple,足以让我们解析元组序列和序列元组:

@ parseFromString[Seq[(Int, Boolean)]]("1=true,2=false,3=true,4=false")
res104: Seq[(Int, Boolean)] = ArraySeq((1, true), (2, false), (3, true), (4, false))

@ parseFromString[(Seq[Int], Seq[Boolean])]("1,2,3,4,5=true,false,true")
res105: (Seq[Int], Seq[Boolean]) = (ArraySeq(1, 2, 3, 4, 5), ArraySeq(true, false, true))
</> 5.52.scala

注意本例中,由于我们仅简单地分割输入字符串,所以我们还不能处理嵌套的 Seq[Seq[T]] 或者 嵌套元组。一个更具结构化的解析器可以毫无压力地处理这种案例,允许我们指定任意复杂的输出类型,并自动推断相应的解析器。我们将在 Chapter 8: JSON and Binary Data Serialization 使用支持这种技术的序列化库。

绝大多数静态类型语言可以一定程度推断类型:没有直接注解类型的表达式,编译器也能够基于程序结构弄明白其类型。衍生物Typeclass正好相反:根据提供的类型,编译器推断必要的程序结构来提供我们所需的隐式值。

上述例子中,我们只需要定义如何处理基本类型 - StrParser[Boolean]StrParser[Int]StrParser[Seq[T]]StrParser[(T, V)] - 编译器就能够弄明白当我们需要一个 StrParser[Seq[(Int, Boolean)]] 时,该如何产生它。

5.6 总结

本章,我们探索了更多Scala独一无二的特性。Case Class和模式匹配是我们日常使用到的,而传名参数,隐式参数,及Typeclass推断是更高级的工具,你可能只会在框架或库中使用它们。然而,正是这些特性造就了Scala的样子,提供了比绝大多数主流语言更优雅地解决困难问题的方式。

我们在本章中浏览了这些特性的基本动机和用例。本书剩余部分,你会通过动手完成更多用例来熟悉这些特性。

这是我们单独谈论Scala编程语言的最后一章节:后续章节会给你介绍更多更复杂的主题,比如操作系统的使用,远程服务的开发,以及第三方库。当你拓展视野,从学习Scala语言本身到需要用Scala解决真实世界的问题时,你已掌握的Scala语言基础会很好地帮助到你。

Exercise: Define a function that uses pattern matching on the Exprs we saw earlier to perform simple algebraic simplifications:

(1 + 1)
2
((1 + 1) * x)
(2 * x)
((2 - 1) * x)
x
(((1 + 1) * y) + ((1 - 1) * x))
(2 * y)
See example 5.7 - Simplify

Exercise: Modify the def retry function earlier that takes a by-name parameter and make it perform an exponential backoff, sleeping between retries, with a configurable initial delay in milliseconds:

retry(max = 50, delay = 100 /*milliseconds*/) {
  requests.get(s"$httpbin/status/200,400,500")
}
</> 5.53.scala
See example 5.8 - Backoff

Exercise: Modify the typeclass-based parseFromString method we saw earlier to take a JSON-like format, where lists are demarcated by square brackets with comma-separated elements. This should allow it to parse and construct arbitrarily deep nested data structures automatically via typeclass inference:

@ parseFromString[Seq[Boolean]]("[true,false,true]") // 1 layer of nesting
res1: Seq[Boolean] = List(true, false, true)

@ parseFromString[Seq[(Seq[Int], Seq[Boolean])]]( // 3 layers of nesting
    "[[[1],[true]],[[2,3],[false,true]],[[4,5,6],[false,true,false]]]"
  )
res2: Seq[(Seq[Int], Seq[Boolean])] = List(
  (List(1), List(true)),
  (List(2, 3), List(false, true)),
  (List(4, 5, 6), List(false, true, false))
)

@ parseFromString[Seq[(Seq[Int], Seq[(Boolean, Double)])]]( // 4 layers of nesting
    "[[[1],[[true,0.5]]],[[2,3],[[false,1.5],[true,2.5]]]]"
  )
res3: Seq[(Seq[Int], Seq[(Boolean, Double)])] = List(
  (List(1), List((true, 0.5))),
  (List(2, 3), List((false, 1.5), (true, 2.5)))
)
</> 5.54.scala

A production-ready version of this parseFromString method exists in upickle.default.read, which we will see in Chapter 8: JSON and Binary Data Serialization.

See example 5.9 - Deserialize

Exercise: How about using typeclasses to generate JSON, rather than parse it? Write a writeToString method that uses a StrWriter typeclass to take nested values parsed by parseFromString, and serialize them to the same strings they were parsed from.

@ writeToString[Seq[Boolean]](Seq(true, false, true))
res1: String = "[true,false,true]"

@ writeToString(Seq(true, false, true)) // type can be inferred
res2: String = "[true,false,true]"

@ writeToString[Seq[(Seq[Int], Seq[Boolean])]](
    Seq(
      (Seq(1), Seq(true)),
      (Seq(2, 3), Seq(false, true)),
      (Seq(4, 5, 6), Seq(false, true, false))
    )
  )
res3: String = "[[[1],[true]],[[2,3],[false,true]],[[4,5,6],[false,true,false]]]"

@ writeToString(
    Seq(
      (Seq(1), Seq((true, 0.5))),
      (Seq(2, 3), Seq((false, 1.5), (true, 2.5)))
    )
  )
res4: String = "[[[1],[[true,0.5]]],[[2,3],[[false,1.5],[true,2.5]]]]"
</> 5.55.scala
See example 5.10 - Serialize
Discuss Chapter 5 online at https://www.handsonscala.com/discuss/5