撇开语法方便不谈,单例类型、路径依赖类型和隐式值的组合意味着 Scala 对依赖类型的支持出奇的好,正如我在 shapeless 中尝试演示的那样。
Scala 对依赖类型的内在支持是通过path-dependent types。这些允许类型依赖于通过对象(即值)图的选择器路径,就像这样,
scala> class Foo { class Bar }
defined class Foo
scala> val foo1 = new Foo
foo1: Foo = Foo@24bc0658
scala> val foo2 = new Foo
foo2: Foo = Foo@6f7f757
scala> implicitly[foo1.Bar =:= foo1.Bar] // OK: equal types
res0: =:=[foo1.Bar,foo1.Bar] = <function1>
scala> implicitly[foo1.Bar =:= foo2.Bar] // Not OK: unequal types
<console>:11: error: Cannot prove that foo1.Bar =:= foo2.Bar.
implicitly[foo1.Bar =:= foo2.Bar]
在我看来,以上内容应该足以回答“Scala 是一种依赖类型语言吗?”这个问题。积极的一面:很明显,这里我们有类型,这些类型通过作为它们的前缀的值来区分。
然而,人们经常反对 Scala 不是一种“完全”依赖类型的语言,因为它没有像 Agda、Coq 或 Idris 中那样具有 dependent sum and product types 作为内在函数。我认为这在一定程度上反映了对形式而非基础的执着,不过,我将尝试证明 Scala 与这些其他语言的关系比通常公认的更接近。
尽管有术语,但依赖求和类型(也称为 Sigma 类型)只是一对值,其中第二个值的类型取决于第一个值。这在 Scala 中可以直接表示,
scala> trait Sigma {
| val foo: Foo
| val bar: foo.Bar
| }
defined trait Sigma
scala> val sigma = new Sigma {
| val foo = foo1
| val bar = new foo.Bar
| }
sigma: java.lang.Object with Sigma{val bar: this.foo.Bar} = $anon$1@e3fabd8
事实上,这是 Scala 2.10 之前的 encoding of dependent method types which is needed to escape from the 'Bakery of Doom' 的关键部分(或更早通过实验性的 -Ydependent-method types Scala 编译器选项)。
从属产品类型(又名 Pi 类型)本质上是从值到类型的函数。它们是表示statically sized vectors 和其他依赖类型编程语言的典型代表的关键。我们可以使用路径相关类型、单例类型和隐式参数的组合在 Scala 中对 Pi 类型进行编码。首先,我们定义一个特征,它将表示一个从 T 类型的值到 U 类型的函数,
scala> trait Pi[T] { type U }
defined trait Pi
我们可以定义一个使用这种类型的多态方法,
scala> def depList[T](t: T)(implicit pi: Pi[T]): List[pi.U] = Nil
depList: [T](t: T)(implicit pi: Pi[T])List[pi.U]
(注意在结果类型List[pi.U] 中使用了路径相关类型pi.U)。给定一个 T 类型的值,此函数将返回一个(n 个空)与该特定 T 值对应的类型的值列表。
现在让我们为我们想要保持的功能关系定义一些合适的值和隐式见证,
scala> object Foo
defined module Foo
scala> object Bar
defined module Bar
scala> implicit val fooInt = new Pi[Foo.type] { type U = Int }
fooInt: java.lang.Object with Pi[Foo.type]{type U = Int} = $anon$1@60681a11
scala> implicit val barString = new Pi[Bar.type] { type U = String }
barString: java.lang.Object with Pi[Bar.type]{type U = String} = $anon$1@187602ae
现在这是我们使用 Pi 类型的函数,
scala> depList(Foo)
res2: List[fooInt.U] = List()
scala> depList(Bar)
res3: List[barString.U] = List()
scala> implicitly[res2.type <:< List[Int]]
res4: <:<[res2.type,List[Int]] = <function1>
scala> implicitly[res2.type <:< List[String]]
<console>:19: error: Cannot prove that res2.type <:< List[String].
implicitly[res2.type <:< List[String]]
^
scala> implicitly[res3.type <:< List[String]]
res6: <:<[res3.type,List[String]] = <function1>
scala> implicitly[res3.type <:< List[Int]]
<console>:19: error: Cannot prove that res3.type <:< List[Int].
implicitly[res3.type <:< List[Int]]
(请注意,这里我们使用 Scala 的 <:< 子类型见证运算符而不是 =:=,因为 res2.type 和 res3.type 是单例类型,因此比我们在 RHS 上验证的类型更精确)。
然而,在实践中,在 Scala 中,我们不会从编码 Sigma 和 Pi 类型开始,然后像在 Agda 或 Idris 中那样从那里开始。相反,我们将直接使用路径相关类型、单例类型和隐式。你可以找到很多关于这在 shapeless 中如何发挥作用的例子:sized types、extensible records、comprehensive HLists、scrap your boilerplate、generic Zippers 等等。
我能看到的唯一反对意见是,在上述 Pi 类型的编码中,我们要求依赖值的单例类型是可表达的。不幸的是,在 Scala 中,这仅适用于引用类型的值,而不适用于非引用类型的值(尤其是 Int)。这是一个耻辱,但不是一个内在的困难:Scala 的类型检查器在内部表示非引用值的单例类型,并且有一个 couple 或 experiments 使它们可以直接表达。在实践中,我们可以通过fairly standard type-level encoding of the natural numbers 来解决这个问题。
无论如何,我不认为这种轻微的域限制可以用来反对 Scala 作为依赖类型语言的地位。如果是,那么对于 Dependent ML(它只允许依赖于自然数值)也可以这样说,这将是一个奇怪的结论。