Scala 隐式参数限定可用实例案例分析

摘要:在阅读 《Scala程序设计》第二版的 5.2.3 章节时,介绍了隐式参数的使用场景:限定可用实例。读了很多遍,终于把案例看明白了,记录一下理解过程,留作备忘。

问题引入

熟悉 Java 的应该都知道在定义泛型的时候,我们可以指定上界,比如 <T extend UpperClass> 或者是 <T implement SomeInterface>。通过该限定能够让我们在使用泛型的时候,使用某些上界或是接口的方法,提升代码的抽象能力。例如 SomeInterface 定义方法 hello() 那么我们在使用泛型定义的变量就可以使用这个方法,比如 T t; t.hello();

虽然已经极大的提升了灵活度,但是如果我们需要处理的泛型类型并没有一个统一的公共的超类或接口,这时候我们又能怎么办呢?

显然我们没办法指定任何上界了,这时候 Java 可能需要通过传入 Class<T> 变量加反射来解决这个问题。但是这样代码是否脆弱,无法做的类型安全。

这里使用隐式参数就可以优雅的解决这个问题。

书中使用的 Scala 集合 API 作为案例首先简要说明。我们这里跳过这个过程,因为 Scala 集合十分复杂,有一套机制需要深入学习理解。我们直接介绍后面完整的案例。

下面我们跳过中间部分 (从 应用Scala API 到 117页下半页)

案例介绍

案例本身十分简单,就是一个模拟的数据库 API,而且只模拟了一行的操作。也就是一行中不同的列的操作。

一行有多列,每一列可以通过列名字获取其值,每一列的值可能是 IntDoubleString

现在我们需要提供一组 API,用户可以通过 API 根据列名字获取列值,并且得到正确数据类型。

书中首先给出了一个实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/main/scala/progscala2/implicits/java-database-api.scala

// A Java-like Database API, written in Scala for convenience.
package progscala2.implicits {
package database_api {

case class InvalidColumnName(name: String)
extends RuntimeException(s"Invalid column name $name")

trait Row {
def getInt (colName: String): Int
def getDouble(colName: String): Double
def getText (colName: String): String
}
}

package javadb {
import database_api._

case class JRow(representation: Map[String,Any]) extends Row {
private def get(colName: String): Any =
representation.getOrElse(colName, throw InvalidColumnName(colName))

def getInt (colName: String): Int = get(colName).asInstanceOf[Int]
def getDouble(colName: String): Double = get(colName).asInstanceOf[Double]
def getText (colName: String): String = get(colName).asInstanceOf[String]
}

object JRow {
def apply(pairs: (String,Any)*) = new JRow(Map(pairs :_*))
}
}
}b

该实现定义了三个接口,分别获取不同的数据类型。我们可以思考一下,这种方法肯定不好,首先客户端使用接口需要为不同数据使用不同接口。如果增加一个接口,客户端的就需要修改。

所以书中提到:

如果我们只定义一个 get[T] 方法,其中 T 代表某一允许的列值类型,会不会更好一些呢?
这有助于提供更加统一的调用接口,因为调用这一方法时我们不再需要 case 语句选择正确的调用方法,而且有时候我们还能在这些接口中使用类型推导。
在 Java 中,原始类型与引用类型的区别之一在于我们无法在像 get[T] 这样的参数化方法中使用原始类型。我们必须使用装箱后的类型,比如使用 java.lang.Integer 类型来替代int 类型。但是在高性能数据应用程序中我们往往不希望出现装箱操作的性能损耗!

即使我们使用对象类型,我们只能用 Object 来作为通用的返回,又失去了类型信息。

这时候隐式参数就可以优雅的解决这个问题。

隐式参数解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/main/scala/progscala2/implicits/scala-database-api.scala

// A Scala wrapper for the Java-like Database API.
package progscala2.implicits {
package scaladb {
object implicits {
import javadb.JRow

implicit class SRow(jrow: JRow) {
def get[T](colName: String)(implicit toT: (JRow,String) => T): T =
toT(jrow, colName)
}

implicit val jrowToInt: (JRow,String) => Int =
(jrow: JRow, colName: String) => jrow.getInt(colName)
implicit val jrowToDouble: (JRow,String) => Double =
(jrow: JRow, colName: String) => jrow.getDouble(colName)
implicit val jrowToString: (JRow,String) => String =
(jrow: JRow, colName: String) => jrow.getText(colName)
}

object DB {
import implicits._

def main(args: Array[String]) = {
val row = javadb.JRow("one" -> 1, "two" -> 2.2, "three" -> "THREE!")

val oneValue1: Int = row.get("one")
val twoValue1: Double = row.get("two")
val threeValue1: String = row.get("three")
// val fourValue1: Byte = row.get("four") // won't compile

println(s"one1 -> $oneValue1")
println(s"two1 -> $twoValue1")
println(s"three1 -> $threeValue1")

val oneValue2 = row.get[Int]("one")
val twoValue2 = row.get[Double]("two")
val threeValue2 = row.get[String]("three")
// val fourValue2 = row.get[Byte]("four") // won't compile

println(s"one2 -> $oneValue2")
println(s"two2 -> $twoValue2")
println(s"three2 -> $threeValue2")
}
}
}
}

首先我们可以看到 SRow (scala row)中只有一个方法:get[T]。但这个方法不仅仅包含参数列名,还有一个隐性参数,这个参数是一个方法:方法的输入有两个参数,分别是 JRow 数据源和一个字符串,这个字符串就是列名,输出是参数类型 T。这个方法其实就是说我们该如何从数据源中取出这个参数类型为 T 的数据。

如果第二个参数不是隐式参数,用户使用这个接口的时候,还是需要将 Java 的每种数据类型的方法传入。对于客户端来说没有任何改变,只不过将没有类型特有的方法换一种方式使用。

但是声明了隐式参数就不一样,我们能为不同的数据类型预定义一个方法,这样客户端在使用的时候不需要显示的传入方法,而是可以根据用户希望获取何种数据类型自动加载预定义的方法。

1
2
3
4
implicit val jrowToInt: (JRow,String) => Int =
(jrow: JRow, colName: String) => jrow.getInt(colName)
implicit val jrowToDouble: (JRow,String) => Double =
(jrow: JRow, colName: String) => jrow.getDouble(colName)

这个隐式参数的类型是 (JRow,String),这个类型和我们定义的 get[T] 方法的第二个参数类型是一致的,这样表示这个参数可以根据 T 的类型来隐形推导。

所以在客户端代码

1
2
3
val oneValue1: Int = row.get("one")
// or
val oneValue2 = row.get[Int]("one")

通过指定返回值类型,或者方法调用的 T 值,就可以让编译器决定使用哪个隐式参数了。

当然如果使用的不支持的类型,就会报错,用户需要自己指定自定义的类型的获取方法。这一点是可以接受的,因为灵活性的相对的。

1
2
val fourValue1: Byte = row.get("four") // 编译错误
val fourValue2 = row.get[Byte]("four") // 编译错误