离线下载
PDF版 ePub版

Jiafeng Cao · 更新于 2017-09-25 16:00:26

路径依赖类型

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

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

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

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

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

问题

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

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

    object Franchise {
       case class Character(name: String)
     }
    class Franchise(name: String) {
      import Franchise.Character
      def createFanFiction(
        lovestruck: Character,
        objectOfDesire: Character): (Character, Character) = (lovestruck, objectOfDesire)
    }

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

    val starTrek = new Franchise("Star Trek")
    val starWars = new Franchise("Star Wars")

    val quark = Franchise.Character("Quark")
    val jadzia = Franchise.Character("Jadzia Dax")

    val luke = Franchise.Character("Luke Skywalker")
    val yoda = Franchise.Character("Yoda")

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

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

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

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

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

    object Franchise {
      case class Character(name: String, franchise: Franchise)
    }
    class Franchise(name: String) {
      import Franchise.Character
      def createFanFiction(
          lovestruck: Character,
          objectOfDesire: Character): (Character, Character) = {
        require(lovestruck.franchise == objectOfDesire.franchise)
        (lovestruck, objectOfDesire)
      }
    }

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

路径依赖类型

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

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

    class A {
      class B
      var b: Option[B] = None
    }
    val a1 = new A
    val a2 = new A
    val b1 = new a1.B
    val b2 = new a2.B
    a1.b = Some(b1)
    a2.b = Some(b1) // does not compile

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

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

    class Franchise(name: String) {
      case class Character(name: String)
      def createFanFictionWith(
        lovestruck: Character,
        objectOfDesire: Character): (Character, Character) = (lovestruck, objectOfDesire)
    }

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

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

    val starTrek = new Franchise("Star Trek")
    val starWars = new Franchise("Star Wars")

    val quark = starTrek.Character("Quark")
    val jadzia = starTrek.Character("Jadzia Dax")

    val luke = starWars.Character("Luke Skywalker")
    val yoda = starWars.Character("Yoda")

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

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

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

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

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

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

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

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

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

抽象类型成员

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

    object AwesomeDB {
      abstract class Key(name: String) {
        type Value
      }
    }
    import AwesomeDB.Key
    class AwesomeDB {
      import collection.mutable.Map
      val data = Map.empty[Key, Any]
      def get(key: Key): Option[key.Value] = data.get(key).asInstanceOf[Option[key.Value]]
      def set(key: Key)(value: key.Value): Unit = data.update(key, value)
    }

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

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

    trait IntValued extends Key {
     type Value = Int
    }
    trait StringValued extends Key {
      type Value = String
    }
    object Keys {
      val foo = new Key("foo") with IntValued
      val bar = new Key("bar") with StringValued
    }

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

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

实践中的路径依赖类型

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

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

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

上一篇: 类型类 下一篇: 结语