@ 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(代码片段:使用Scala的模式匹配来解析简单的字符串模式)
本章将会覆盖一些有趣且特殊的Scala特性,这些特性很难在其它主流的编程语言中找到。对于每个本章中的特性,我们不仅会介绍该特性本身,还会介绍与此相关的一些常见用例,以便您直观地了解其功能。
我们并不会在日常的编码中频繁地使用每一个本章的特性:你会经常使用Case Class和模式匹配,但是对传名参数的使用就不会这么频繁了,而隐式参数基本只在库和框架中使用。不过,即使是这些很少使用的特性也已经被使用得足够多了,因此在更高的层面去了解它们,可以让你在真的遇到它们的时候将它们识别出来。
case class就像普通的类那样,但是更多的是为了表达一个类“仅仅就是数据”:这些数据是不可变且公开的,没有任何的可变状态和封装。它们的使用场景与C/C++中的“struct”,Java中的“POJO”,Python或者Kotlin中的“Data Class”有些像。它的名字的由来正是它可以通过case关键字来支持模式匹配。
case class可以通过下列的方式定义:
@ case class Point(x: Int, y: Int)实例化这个类的时候,你可以不用加上new:
@ val p = Point(1, 2)
p: Point = Point(1, 2)它们的构造器参数默认是public字段:
@ p.x
res149: Int = 1
@ p.y
res150: Int = 2toString方法有默认的实现来展示你的构造器的参数,并且==也有默认的实现用来检查两个实例间每个的构造器参数的值是否相等:
@ p.toString
res151: String = "Point(1,2)"
@ val p2 = Point(1, 2)
@ p == p2
res153: Boolean = true你会得到一个.copy函数来方便构建case class一个实例的副本并修改一些字段:
@ val p = Point(1, 2)
@ val p2 = p.copy(y = 10)
p2: Point = Point(1, 10)
@ val p3 = p2.copy(x = 20)
p3: Point = Point(20, 10)与普通类一样,你可以在case class的主体中定义实例方法或属性:
@ case class Point(x: Int, y: Int) {
def z = x + y
}
@ val p = Point(1, 2)
@ p.z
res156: Int = 3
case class是具有多个参数的元组的一个很好的替代品,相比于需要用._1,._2,._7这样的函数从元组中获取值,你可以通过字段的名字来从case class中获取值,在这个例子中,这些名字就是.x和.y。相比与需要准确地记住._7这样的字段表示的是什么意思,使用名字显然更加方便。
特质可以被定义成封闭的,并且只被一组固定的case class继承。在下面的例子里,我们定义一个sealed trait Point并且只被两个case class继承:Point2D和Point3D:
@ {
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通常,特质是开放的,所以任意数量的类可以继承自这个特质,只要它们可以提供所有需要的方法。sealed trait则是封闭的,它们只允许一组固定的类去继承,并且所有继承类都必须和这个特质定义在同一个文件或者REPL命令中(所以上面Point/Point2D/Point3D的定义被{}包围)。
因为只有固定数量的类继承自封闭特质Point,所以我们现在就可以在def hypotenuse这个函数中使用模式匹配来处理每一种Point。
在Scala应用中,普通特质和封闭特质的都是十分常见的: 普通的特质表示一个可以拥有任意数量子类的接口,而封闭特质的子类数目则是固定的。
普通特质和封闭特质都可以简化不同的事情:
- 一个普通特质层级结构使得添加子类更加容易:只要定义你的类并实现必要的方法。然而这会使得添加新的方法变得困难:一个新的方法需要被加入到所有已存在的子类中,这可能是非常多的。
- 封闭特质的层级结构刚好相反:添加新的方法对它来说非常简单,一个新的方法可以简单的通过对每一个子类进行模式匹配来决定哪一个类需要如何处理。然而,新加一个类将会非常困难。你需要做的是在所有已经存在的模式匹配中加入一个新的
case来处理你的新的子类。(译者注:封闭特质不意味着不能添加新的子类,只要这个类定义在与特质相同的文件或者{}中)
通常,当你预期到一个特质的子类会很少变化甚至完全不会变化的时候,使用封闭特质来建模是一个非常好的办法。
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
}一个Json值可以是null、boolean、number、string、array、dictionary。Json在过去20年内都没有改变过,所以我们也不需要考虑有人让额外的类继承我们Json这个特质的可能性。当一组子类被固定下来,能在JSON上的操作是没有限制的。因此,使用封闭特质而非普通特质表示JSON数据是非常合理的。
Scala允许使用match关键字对值进行模式匹配。它非常像其它编程语言中的switch表达式,但是却更加的灵活:除了在想string,integer这样的基础类型上匹配,你还可以使用match关键字从一些组合的数据类型,比如元组和case class中提取值("destructure"-去结构化)。
@ 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)
res162: String = "Fri"
@ dayOfWeek(-1)
res163: String = "Unknown"@ 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")
res166: Int = 5
@ indexOfDay("???")
res167: Int = -1@ for (i <- Range.inclusive(1, 100)) {
println(
(i % 3, i % 5) match {
case (0, 0) => "FizzBuzz"
case (0, _) => "Fizz"
case (_, 0) => "Buzz"
case _ => i
}
)
}
1
2
Fizz
4
Buzz
...@ for (i <- Range.inclusive(1, 100)) {
println(
(i % 3 == 0, i % 5 == 0) match {
case (true, true) => "FizzBuzz"
case (true, false) => "Fizz"
case (false, true) => "Buzz"
case (false, false) => i
}
)
}
1
2
Fizz
4
Buzz
...@ 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))
res171: String = "origin"
@ direction(Point(1, 1))
res172: String = "diagonal"
@ direction(Point(10, 0))
res173: String = "horizontal"@ 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")
res174: String = "day: 9, mon: 8, yr: 1965"
@ splitDate("9-8")
res175: String = "not a date"(注:在字符串模式上的匹配只支持简单的glob-like模式,它不支持像正则表达式这样的丰富的模式。对于这样的字符串,你可以使用scala.util.mathcing.Regex类)。
模式也可以是嵌套的,比如在这个例子中一个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?模式可以被任意深地嵌套,下面的例子匹配一个元组中包含一个case class,case class中又包含了一个字符串:
@ def greet(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")
}
@ greet(Person("Barack Obama", "Pres"), Person("Michelle Obama", "Ms"))
Hello Mr and Ms Obama
@ greet(Person("Barack Obama", "Pres"), Person("Michelle", "Ms"))
Hello Barack Obama and Michelle最后,你可以在for循环中使用模式匹配
@ val a = Array[(Int, String)]((1, "one"), (2, "two"), (3, "three"))
@ for ((i, s) <- a) println(s + i)
one1
two2
three3并且,当你在使用val声明的时候,如果你确定你定义的值能够匹配给定的模式,你只需要抽取出你想要的部分:
@ case class Point(x: Int, y: Int)
@ val p = Point(123, 456)
@ val Point(x, y) = p
x: Int = 123
y: Int = 456@ 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 (of class java.lang.String)
ammonite.$sess.cmd10$.<clinit>(cmd10.sc:1)模式匹配让你可以优雅地处理由case class和封闭特质组成的结构。比如,让我们来考虑一个简单的问题,用封闭特质来表示算术表达式:
@ {
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
}在这里,BinOp表示一个“二元操作符”,它可以用来表示一个类似于下列的算术表达式:
| 算术表达式 | Expr |
|---|---|
x + 1 |
BinOp(Variable("x"), "+", Literal(1) |
x * (y - 1) |
BinOp( Variable("x"), "*", BinOp(Variable("y"), "-", Literal(1))) |
(x + 1) * (y - 1) |
BinOp( BinOp(Variable("x"), "+", Literal(1)), "*", BinOp(Variable("y"), "-", Literal(1))) |
现在,我们先忽略将左边的字符串转化为右边结构化的case class的解析过程,对于这部分内容,将在第19章:结构化文本解析中具体地进行介绍。取而代之的是,让我们去思考两件一旦你已经将算术表达式解析成case class之后立刻会想要做的事情:我们想要将它以可读的字符串打印出来,以及去计算一些变量的值。
我们可以用以下几种情况去思考将表达式转化成字符串这个过程:
- 如果一个
Expr是一个Literal,那么目标字符串就是这个literal的值 - 如果一个
Expr是一个Variable,那么目标字符串就是这个variable的name - 如果一个
Expr是一个BinOp,那么目标字符串式是先将其left值字符串化,然后跟着op,之后再将right值字符串化
将此转化成模式匹配的代码,可以写成:
@ 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
}我们可以分别构建一个小的和大的表达式,并将其作为stringfy函数的输入来观察一些它们的输出:
@ val smallExpr = BinOp(Variable("x"), "+", Literal(1))
@ val largeExpr = BinOp(
BinOp(Variable("x"), "+", Literal(1)),
"*",
BinOp(Variable("y"), "-", Literal(1))
)
@ stringify(smallExpr)
res7: String = "(x + 1)"
@ stringify(largeExpr)
res8: String = "((x + 1) * (y - 1))"相比字符串化一个表达式,计算显得更加的复杂,但是也仅仅是复杂一点而已。我们需要传入一个values的map来持有每个变量的值,然后,我们需要各自的处理+,-和*这些操作符:
@ 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))
res10: Int = 11
@ evaluate(largeExpr, Map("x" -> 10, "y" -> 20))
res11: Int = 209大致上,这个函数看起来与我们之前写的stringfy函数是比较相像的:用一个递归的函数对expr: Expr模式匹配以处理继承自Expr的每一个case class. 处理没有子类的Literal和Variable的情况是非常简单的,而当处理BinOp这个情况的时候,我们同时在左子表达式和右子表达式上进行递归,之后再去组合它们的值。这在任何语言中,都是处理递归数据结构的一种常见做法,但是Scala的封闭特质,case class和模式匹配使它们变得更加的简明和容易。
我们写的这个Expr结构以及打印和计算过程是有意简化的,只是让我们有机会了解如何使用模式匹配轻松地处理以case class和sealed trait建模的结构化数据。我们将会在第20章:实现一门编程语言中更加深入地探讨这一技术。
@ def func(arg: => String) = ???Scala同样支持使用: => T语法使用传名方法参数,它只会在有需要的时候才会进行求值:这意味着方法的参数不是在方法调用的地方立即被计算的,取而代之的是,在方法内部每次被调用的地方,都会进行求值。它主要有下面三个用例:
- 如果参数最终都未被使用,可以避免计算参数的值
- 包装计算的过程,允许函数在计算参数之前和之后运行设置和清理的代码
- 对该参数超过一次的重复计算
下面的log方法使用一个传名参数msg: => String,如果msg不是真的要被打印的话,可以避免对它进行计算。在日志功能未被开启时时,使用传名参数可以避免花费过多cpu时间来创建日志信息(这里是计算"hello " + 123 + " world" ):
@ var logLevel = 1
@ def log(level: Int, msg: => String) = {
if (level > logLevel) println(msg)
}
@ log(2, "Hello " + 123 + " World")
Hello 123 World
@ logLevel = 3
@ log(2, "Hello " + 123 + " World")
<no output was printed>一个方法并不总是在它结束的时候使用了它的所有参数。通过在不需要的时候,不去计算日志信息,我们可以显著地节约cpu时间和对象分配的数量,后者对一些性能敏感的程序可能会造成影响。
例如,我们在第4章:Scala集合中看到的getOrElse和getOrElseUpdate方法在我们要的值已经存在的情况下,就不会去使用表示默认值的参数。getOrElse和getOrElseUpdate通过使用传名参数去获取它们的默认值,当默认值不需要的时候,可以将我们从耗时的默认值计算中拯救出来。
使用传名参数将你的计算方法封装在一些设置和清理代码之内是另外一种常见的模式。下面的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(new Array[String](100 * 1000 * 1000).hashCode())
Evaluation took 287 milliseconds对于这种封装,有许多其它的用例:
- 当参数被求值时设置一些thread-local上下文
- 在
try-catch内部求值以进行异常处理 - 在Future内求值使得逻辑可以在另一个线程异步地运行
这些例子都可以使用传名函数来获得好处。
关于传名参数的最后一个需要介绍的是在函数参数的重复计算。下面的代码定义了一个通用的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("call failed, retrying #" + tries)
}
}
result.get
}我们上面定义的retry是一个范型函数,其接受一个类型参数[T]和一个传名参数用来计算T类型的一个值,之后计算一旦成功就返回一个T的值(如果它永远不会成功,就抛出一个异常)。因此,你可以使用retry封装任何类型的的计算,它会重试这个计算并且返回第一个成功的T的值。
我们可以用一个可能会失败的调用去测试一下这个函数,并观察打印在控制台上的重试消息:
@ retry(max = 5) {
// Only succeeds with a 200 response code 1/3 of the time
requests.get("https://httpbin.org/status/200,400,500")
}
call failed, retrying #1
call failed, retrying #2
res40: requests.Response = Response(
"https://httpbin.org/status/200,400,500",
200,
...让retry函数接收一个传名参数允许我们在必要时对requests.get块进行重复计算。关于重复计算的另外一个用例是运行性能benchmark和load test.
一般来说,你不会经常使用传名函数,但是在必要时,它可以让你的代码有很多方法去操作对函数参数的计算过程:计算前后插装代码,重试计算过程,省略不必要的计算等。
我们将在第12章:使用HTTP API中了解以上片段中使用的requests库的更多信息。
隐式参数是一种当你调用函数时可以自动为你填入的参数。例如,给定下面的类Foo和接收一个参数为implicit foo:Foo的函数bar:
@ class Foo(val value: Int)
@ def bar(implicit foo: Foo) = foo.value + 10如果你在作用域中没有一个隐式的Foo时尝试调用bar,你会得到一个编译错误:
@ bar
cmd4.sc:1: could not find implicit value for parameter foo: ammonite.$sess.cmd1.Foo
val res4 = bar
^
Compilation Failed要调用bar,你需要定义一个Foo类型的隐式值,这样对bar的调用就会自动的从封闭的作用域内获取这个值:
@ implicit val foo: Foo = new Foo(1)
foo: Foo = ammonite.$sess.cmd1$Foo@451882b2
@ bar // `foo` is resolved implicitly
res5: Int = 11
@ bar(foo) // You can also pass in `foo` explicitly
res6: Int = 11隐式参数与我们在第三章:Scala基础中见到的默认值很像。它们都允许你显式地传入一个值或者使用默认的值。最主要的不同是默认值是在函数定义时期的一种硬编码,而隐式参数在调用时期从它们的作用域中获取默认值。
更高级的用例是使用这个特性进行Typeclass推导,在介绍它之前,让我们来看下一个更加具体的用例,使用隐式参数来使你的代码更加简洁和可读。
使用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.id, e.name, r))
)
}getEmployee和getRole都是异步的操作,当它完成时,我们将会用map和flatMap进行之后的工作。Future确切的工作方式超出了本节的范畴,我们将会在第13章:使用Future进行Fork-Join并行中回顾这些API。到现在为止,值得注意的是Future每个操作都需要传递一个executionContext来进行。
如果没有隐式参数,我们将有以下几种选择:
- 显式地传递
executionContext,这是十分冗长的,并且会让我们的代码难以阅读,因为我们关心的逻辑将会被淹没在executionContext的海洋中。 - 将
executionContext全局化,这非常的简洁,但是当你想要在你程序的不同部分传递不同的值的时候缺少灵活性。 - 我们可以将
executionContext放在一个thread-local的变量中来保持灵活性和简洁性,但是这很容易出错,因为在运行需要它的代码之前很容易忘记设置thread-local。
上述的方案都进行了取舍,迫使我们牺牲简洁性,灵活性或安全性。Scala的隐式参数提供了我们第四种选择:隐式地传入executionContext,这将给我们上述3种选择无法满足的简洁性,灵活性和安全性。
为了解决上述的这些问题,我们可以让所有的这些函数都接收一个隐式参数executionContext。标准库中Future的操作比如map和flatMap已经采用这种做法,我们可以按照这个方式修改我们的getEmployee和getRole函数。通过使用implicit val定义executionContext,这个变量会在函数调用的时候,自动的被选择:
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.id, e.name, r)
)
)
}
使用隐式参数可以当我们在整个应用程序中传递相同的共享上下文或配置对象帮助我们清理代码。通过隐式传递“无趣”参数,它可以将读者的注意力集中在应用程序的核心逻辑上。此外,缺少隐式变量是编译时错误,这使它们比local-thread变量更不易出错:当缺少隐式变量时,会在编译代码并将其部署到生产环境之前就被发现。
隐式参数第二种非常有用的地方是,使用隐式参数将值与类型相关联。尽管与Scala中的类型和类无关,但是这种模式经常被称为typeclass,该术语源自Haskell编程语言。尽管typeclass是一种基于上节描述的隐式参数这个语言特性,但它是一种有趣且重要的技术,值得在本章中单独介绍。
让我们考虑一个解析命令行参数的任务,输入若干个String,将它们转化成多种类型:Int、Boolean、Double等。这是几乎每个程序都会直接或通过使用库来处理的常见任务。
第一版的草图可能是编写一种通用方法来解析值。签名可能看起来像这样:
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))从表面上看,这似乎是不可能实现的:
parseCliArgument如何知道怎样将给定的String转换为任意T?- 如何知道命令行参数可以解析为哪些类型T,以及其它不能解析的类型?例如,我应该不能从输入字符串中解析java.net.DatagramSocket。
解决方案的第二个草图可能是,对每种我们需要能够解析的类型,定义单独的解析器对象。例如:
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 }之后,我们可以按照下面的方式调用它:
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))这是可行的。但是,这又导致了另一个问题:如果我想编写一个不直接解析String而是解析一个来自控制台的值的方法,我该怎么做?
我们有两个选择。第一个选项是编写专门用于从控制台进行解析的全新对象集:
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()
第二个选项是定义一个辅助方法,该方法接收一个StrParser[T]作为参数的以告诉它如何解析类型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)这两种解决方案都很笨拙:
- 第一个方案是因为我们需要复制所有
Int/Boolean/Double等解析器。如果我们需要解析来自网络的输入怎么办?来自文件?对于每种情况,我们将需要复制每个解析器。 - 第二个方案是因为我们需要将这些ParseFoo对象传递到任何地方。通常只有一个
StrParser[Int]可以传递给parseFromConsole[Int]。编译器为什么不能为我们推断呢?
解决上述问题的方法是隐式地定义StrParser的实例:
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
}
}
我们将隐式对象ParseInt,ParseBoolean等放置在一个与特质StrParser名字相同,但位置在这个特质旁边的对象StrParser中。定义在一个类旁边且与之名字相同的对象称为伴随对象。伴随对象通常用于将隐式,静态方法,工厂方法组合在一起,以及与特质或类相关但不属于任何特定实例的其它功能。伴随对象中的隐式对象也得到了特殊处理,不需要将其导入作用域即可用作隐式参数。
请注意,如果要将其输入到Ammonite Scala REPL中,则需要在两个声明之间加上一对大括号{...},以便在同一REPL命令中定义特征和对象。
现在,尽管我们仍然可以像以前一样显式调用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))这看起来与我们的初始草图类似,不同之处在于,通过使用参数((implicit parser:StrParser[T]),该函数现在可以针对要解析的每种类型自动推断正确的StrParser。
使用隐式的StrParser [T]意味着我们可以重复使用它们,而无需复制解析器或手动传递它们。例如,我们可以编写一个从控制台解析字符串的函数:
def parseFromConsole[T](implicit parser: StrParser[T]) = {
parser.parse(scala.Console.in.readLine())
}
val myInt = parseFromConsole[Int]
对parseFromConsole[Int]的调用会自动使用StrParser伴随对象中的隐式StrParser.ParseInt,而无需复制它或繁琐地传递它。只要T具有合适的StrParser,就可以轻松编写能够适用通用类型T的代码。
这种采用泛型类型的隐式参数的技术非常普遍,以至于Scala语言为其提供了专用的语法。下面这个函数的签名:
def parseFromString[T](s: String)(implicit parser: StrParser[T])可以更简洁地写作:
def parseFromString[T: StrParser](s: String)该语法被称为上下文绑定(context bound),并且在语义上等同于上面的implicit parser:StrParser[T]语法。
使用上下文绑定语法时,没有为隐式参数指定名称,因此我们不能像以前那样调用parser.parse。对此,我们可以通过implicitly函数解析隐式值,比如:implicitly[StrParser[T]].parse。
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同样,如果您尝试从另一个没有可用的隐式值的方法中调用一个(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我们使用Typeclass推导完成的大多数事情也可以使用运行时反射来实现。但是,依赖运行时反射非常脆弱,在程序发生错误之前,使用反射会使错误,bug以及一些配置问题部署到生产上。相反,Scala的隐式功能使您以安全的方式实现相同的结果:在编译时就可以尽早发现错误,并且可以在闲暇时解决这些错误,而不必承受持续的生产中断的压力。
我们已经知道了如何使用typeclass技术,根据要解析的类型自动选择要使用的StrParser。这也可以用于更复杂的类型,在这种情况下,我们告诉编译器我们需要Seq[Int],(Int, Boolean),甚至是嵌套类型,例如Seq[(Int, Boolean)],然后编译器会自动地将必要的逻辑组装以解析我们相要的类型。
例如,以下ParseSeq函数为作用域内具有隐式StrParser [T]的任何T提供一个StrParser[Seq[T]]:
implicit def ParseSeq[T](implicit p: StrParser[T]) = new StrParser[Seq[T]]{
def parse(s: String) = s.split(',').toSeq.map(p.parse)
}
请注意,与我们之前定义的单例的implicit object不同,这里有一个implicit def。根据类型T,我们需要一个不同的StrParser[T],因此需要一个不同的StrParser[Seq[T]]。因此,每次使用不同的类型T调用时,implicit def ParseSeq都会返回不同的StrParser。
根据这一定义,我们现在可以解析Seq[Boolean],Seq[Int]等。
@ parseFromString[Seq[Boolean]]("true,false,true")
res30: Seq[Boolean] = ArraySeq(true, false, true)
@ parseFromString[Seq[Int]]("1,2,3,4")
res31: Seq[Int] = ArraySeq(1, 2, 3, 4)
我们正在做的事情是有效地教编译器如何为任何类型T生成StrParser[Seq[T]],只要它具有隐式StrParser[T]即可。由于我们已经有了StrParser[Int],StrParser [Boolean]和StrParser[Double],因此ParseSeq方法自动且有效地提供了StrParser[Seq[Int]],StrParser[Seq[Boolean]]和StrParser[Seq[Int]]。
我们要实例化的StrParser[Seq[T]]有一个parse方法,该方法接收参数s:String并返回Seq[T]:我们只需要实现执行该转换所需的逻辑即可,这已在上面的代码中实现。
就像我们定义implicit def来解析Seq[T]一样,我们也可以执行同样的操作来解析元组。下面我们通过假设元组由格式为key=value的输入字符串表示:
mplicit 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))
}
}此定义构造了一个StrParser[(T, V)],但仅适用于类型T和类型V且都具有可用的StrParsers的类型。现在我们可以来解析=分割的元组了:
@ parseFromString[(Int, Boolean)]("123=true")
res34: (Int, Boolean) = (123, true)上面的两个定义,implicit def ParseSeq和implicit def ParseTuple,足以让我们解析元组的序列或序列的元组:
@ parseFromString[Seq[(Int, Boolean)]]("1=true,2=false,3=true,4=false")
res36: 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")
res37: (Seq[Int], Seq[Boolean]) = (ArraySeq(1, 2, 3, 4, 5), ArraySeq(true, false, true))请注意,在这种情况下,由于我们只是单纯地分割输入字符串,因此无法处理嵌套的Seq[Seq[T]]或嵌套的元组。要正确地处理此类情况,我们需要更加结构化的解析器,从而使我们可以指定任意复杂的输出类型并自动派生必要的解析器。我们将在第8章:JSON和二进制数据序列化中使用使用此技术的序列化库。
大多数静态类型的编程语言都可以在某种程度上推断类型:即使不是程序中的每个表达式都使用显式类型进行注释,编译器仍可以根据程序的结构来确定类型。类型类派生实际上是相反的:通过提供显式类型,编译器可以推断出必要的程序结构来提供我们正在寻找的类型的值。
在上面的示例中,我们只需要定义如何处理基本类型:如何生成StrParser[Boolean],StrParser[Int],StrParser[Seq[T]],StrParser[(T, V)]。当我们需要时,编译器将能够自行弄清如何生成StrParser[Seq[(Int, Boolean)]]。
在本章中,我们探讨了Scala的一些更加独特的功能。你将每天使用case class和模式匹配,而传名参数,隐式参数或typeclass推断是更高级的工具,只有在框架或库中才会使用。但是,这些功能使Scala语言成为了现在的样子,从而允许你以一种与大多数主流语言截然不同的方法组织你的代码。
在本章中,我们已经介绍了这些功能的基本动机和用例,并且在本书的其余部分中,我们将会看到更多关于在实战中使用这些特性的用例。
本章将是我们最后一次单独讨论Scala编程语言:后续章节将向您介绍更为复杂的主题,例如使用操作系统,查询远程服务以及利用第三方库和工具。到目前为止,学习Scala语言本身以及使用Scala语言解决实际问题,都会让你很好地开阔眼见。