路径依赖类型

上一章介绍了类型类的概念,这种模式使设计出来的程序既拥抱扩展性,又不放弃具体的类型信息。
这一章,我们还将继续探究 Scala 的类型系统,讲讲另一个特性,
这个特性可以将 Scala 与其他主流编程语言区分开:依赖类型,特别是,路径依赖的类型和依赖方法类型。

一个广泛用于反对静态类型的论点是 “the compiler is just in the way”,
最终得到的都是数据,为什么还要建立一个复杂的类型层次结构?

到最后,静态类型的唯一目的就是,让“超级智能”的编译器来定期“羞辱”编程人员,以此来预防程序的 bug,
在事情变得糟糕之前,保证你做出正确的选择。

路径依赖类型是一种强大的工具,它把只有在运行期才知道的逻辑放在了类型里,
编译器可以利用这一点减少甚至防止 bug 的引入。

有时候,意外的引入路径依赖类型可能会导致难堪的局面,尤其是当你从来没有听说过它。
因此,了解和熟悉它绝对是个好主意,不管以后要不要用。

问题

先从一个问题开始,这个问题可以由路径依赖类型帮我们解决:
在同人小说中,经常会发生一些骇人听闻的事情。
比如说,两个主角去约会,即使这样的情景有多么的不合常理,甚至还有穿越的同人小说,两个来自不同系列的角色互相约会。

不过,好的同人小说写手对此是不屑一顾的。肯定有什么模式来阻止这样的错误做法。
下面是这种领域模型的初版:

  1. object Franchise {
  2. case class Character(name: String)
  3. }
  4. class Franchise(name: String) {
  5. import Franchise.Character
  6. def createFanFiction(
  7. lovestruck: Character,
  8. objectOfDesire: Character): (Character, Character) = (lovestruck, objectOfDesire)
  9. }

角色用 Character 样例类表示, Franchise 类有一个方法,这个方法用来创建有关两个角色的小说。
下面代码创建了两个系列和一些角色:

  1. val starTrek = new Franchise("Star Trek")
  2. val starWars = new Franchise("Star Wars")
  3. val quark = Franchise.Character("Quark")
  4. val jadzia = Franchise.Character("Jadzia Dax")
  5. val luke = Franchise.Character("Luke Skywalker")
  6. val yoda = Franchise.Character("Yoda")

不幸的是,这一刻,我们无法阻止不好的事情发生:

  1. starTrek.createFanFiction(lovestruck = jadzia, objectOfDesire = luke)

多么恐怖的事情!某个人创建了一段同人小说,婕琪戴克斯和天行者卢克竟然在约会!我们不应该容忍这样的事情。

婕琪戴克斯:星际迷航中的角色:http://en.wikipedia.org/wiki/Jadzia_Dax
天行者卢克:星球大战中的角色:http://en.wikipedia.org/wiki/Luke_Skywalker

你的第一直觉可能是,在运行期做一些检查,保证约会的两个角色来自同一个特许商。
比如说:

  1. object Franchise {
  2. case class Character(name: String, franchise: Franchise)
  3. }
  4. class Franchise(name: String) {
  5. import Franchise.Character
  6. def createFanFiction(
  7. lovestruck: Character,
  8. objectOfDesire: Character): (Character, Character) = {
  9. require(lovestruck.franchise == objectOfDesire.franchise)
  10. (lovestruck, objectOfDesire)
  11. }
  12. }

现在,每个角色都有一个指向所属发行商的引用,试图创建包含不同系列角色的小说会引发 IllegalArgumentException 异常。

路径依赖类型

这挺好,不是吗?毕竟这是被灌输多年的行为方式:快速失败。
然而,有了 Scala,我们能做的更好。
有一种可以更快速失败的方法,不是在运行期,而是在编译期。
为了实现它,我们需要将 Character 和它的 Franchise 之间的联系编码在类型层面上。

Scala 嵌套类型 工作的方式允许我们这样做。
一个嵌套类型被绑定在一个外层类型的实例上,而不是外层类型本身。
这意味着,如果将内部类型的一个实例用在包含它的外部类型实例外面,会出现编译错误:

  1. class A {
  2. class B
  3. var b: Option[B] = None
  4. }
  5. val a1 = new A
  6. val a2 = new A
  7. val b1 = new a1.B
  8. val b2 = new a2.B
  9. a1.b = Some(b1)
  10. a2.b = Some(b1) // does not compile

不能简单的将绑定在 a2 上的类型 B 的实例赋值给 a1 上的字段:前者的类型是 a2.B ,后者的类型是 a1.B
中间的点语法代表类型的路径,这个路径通往其他类型的具体实例。
因此命名为路径依赖类型。

下面的代码运用了这一技术:

  1. class Franchise(name: String) {
  2. case class Character(name: String)
  3. def createFanFictionWith(
  4. lovestruck: Character,
  5. objectOfDesire: Character): (Character, Character) = (lovestruck, objectOfDesire)
  6. }

这样,类型 Character 嵌套在 Franchise 里,它依赖于一个特定的 Franchise 实例。

重新创建几个角色和发行商:

  1. val starTrek = new Franchise("Star Trek")
  2. val starWars = new Franchise("Star Wars")
  3. val quark = starTrek.Character("Quark")
  4. val jadzia = starTrek.Character("Jadzia Dax")
  5. val luke = starWars.Character("Luke Skywalker")
  6. val yoda = starWars.Character("Yoda")

把角色放在一起构成小说:

  1. starTrek.createFanFictionWith(lovestruck = quark, objectOfDesire = jadzia)
  2. starWars.createFanFictionWith(lovestruck = luke, objectOfDesire = yoda)

顺利编译!接下来,试着去把 jadzialuke 放在一起:

  1. starTrek.createFanFictionWith(lovestruck = jadzia, objectOfDesire = luke)

不应该的事情就会编译失败!编译器抱怨类型不匹配:

  1. found : starWars.Character
  2. required: starTrek.Character
  3. starTrek.createFanFictionWith(lovestruck = jadzia, objectOfDesire = luke)

即使这个方法不是在 Franchise 中定义的,这项技术同样可用。
这种情况下,可以使用依赖方法类型,一个参数的类型信息依赖于前面的参数。

  1. def createFanFiction(f: Franchise)(lovestruck: f.Character, objectOfDesire: f.Character) =
  2. (lovestruck, objectOfDesire)

可以看到, lovestruckobjectOfDesire 参数的类型依赖于传递给该方法的 Franchise 实例。
不过请注意:被依赖的实例只能在一个单独的参数列表里。

抽象类型成员

依赖方法类型通常和抽象类型成员一起使用。
假设我们在开发一个键值存储,只支持读取和存放操作,但是类型安全的。
下面是一个简化的实现:

  1. object AwesomeDB {
  2. abstract class Key(name: String) {
  3. type Value
  4. }
  5. }
  6. import AwesomeDB.Key
  7. class AwesomeDB {
  8. import collection.mutable.Map
  9. val data = Map.empty[Key, Any]
  10. def get(key: Key): Option[key.Value] = data.get(key).asInstanceOf[Option[key.Value]]
  11. def set(key: Key)(value: key.Value): Unit = data.update(key, value)
  12. }

我们定义了一个含有抽象类型成员 Value 的类 Key
AwesomeDB 中的方法可以引用这个抽象类型,即使不知道也不关心它到底是个什么表现形式。

定义一些想使用的具体的键:

  1. trait IntValued extends Key {
  2. type Value = Int
  3. }
  4. trait StringValued extends Key {
  5. type Value = String
  6. }
  7. object Keys {
  8. val foo = new Key("foo") with IntValued
  9. val bar = new Key("bar") with StringValued
  10. }

之后,就可以存放键值对了:

  1. val dataStore = new AwesomeDB
  2. dataStore.set(Keys.foo)(23)
  3. val i: Option[Int] = dataStore.get(Keys.foo)
  4. dataStore.set(Keys.foo)("23") // does not compile

实践中的路径依赖类型

在典型的 Scala 代码中,路径依赖类型并不是那么无处不在,但它确实是有很大的实践价值的,除了给同人小说建模之外。

最普遍的用法是和 cake pattern 一起使用,cake pattern 是一种组件组合和依赖管理的技术。
冠以这一点,可以参考 Debasish Ghosh 的 文章

把一些只有在运行期才知道的信息编码到类型里,比如说:异构列表、自然数的类型级别表示,以及在类型中携带大小的集合,
路径依赖类型和依赖方法类型有着至关重要的角色。
Miles Sabin 正在 Shapeless 中探索 Scala 类型系统的极限。