为什么类型级计算需要 Aux 技术?

Why is the Aux technique required for type-level computations?

我很确定我在这里遗漏了一些东西,因为我是 Shapeless 的新手并且我正在学习,但是什么时候 Aux 技术实际上需要?我看到它用于通过将 type 语句提升到另一个 "companion" type 定义的签名来公开它。

trait F[A] { type R; def value: R }
object F { type Aux[A,RR] = F[A] { type R = RR } }

但这不是几乎等同于将 R 放入 F 的类型签名中吗?

trait F[A,R] { def value: R }
implicit def fint = new F[Int,Long] { val value = 1L }
implicit def ffloat = new F[Float,Double] { val value = 2.0D }
def f[T,R](t:T)(implicit f: F[T,R]): R = f.value
f(100)    // res4: Long = 1L
f(100.0f) // res5: Double = 2.0

我看到路径相关类型会带来好处 如果 可以在参数列表中使用它们,但我们知道我们不能这样做

def g[T](t:T)(implicit f: F[T], r: Blah[f.R]) ...

因此,我们仍然被迫在 g 的签名中添加一个额外的类型参数。通过使用 Aux 技术,我们 需要花费额外的时间编写同伴 object。从使用的角度来看,对于像我这样的天真的用户来说,使用依赖于路径的类型根本没有任何好处。

我能想到的只有一种情况,就是对于一个给定的类型级计算,返回了不止一个类型级的结果,你可能只想使用其中一个。

我想这一切都归结为我在我的简单示例中忽略了一些东西。

这里有两个不同的问题:

  1. 为什么 Shapeless 在某些类型的某些情况下使用类型成员而不是类型参数 classes?
  2. 为什么 Shapeless 在这些 class 类型的伴生对象中包含 Aux 类型别名?

我将从第二个问题开始,因为答案更直接:Aux 类型别名完全是一种语法上的便利。您永远不会 使用它们。例如,假设我们要编写一个仅在使用两个长度相同的 hlist 调用时才能编译的方法:

import shapeless._, ops.hlist.Length

def sameLength[A <: HList, B <: HList, N <: Nat](a: A, b: B)(implicit
  al: Length.Aux[A, N],
  bl: Length.Aux[B, N]
) = ()

Length类型class有一个类型参数(对于HList类型)和一个类型成员(对于Nat)。 Length.Aux 语法使得在隐式参数列表中引用 Nat 类型成员相对容易,但这只是为了方便——下面是完全等价的:

def sameLength[A <: HList, B <: HList, N <: Nat](a: A, b: B)(implicit
  al: Length[A] { type Out = N },
  bl: Length[B] { type Out = N }
) = ()

Aux 版本与以这种方式写出类型细化相比有几个优点:噪音较小,并且不需要我们记住类型成员的名称。不过,这些纯粹是人体工程学问题 — Aux 别名使我们的代码更易于阅读和编写,但它们不会以任何有意义的方式改变我们对代码可以做什么或不能做什么。

第一个问题的答案有点复杂。在许多情况下,包括我的 sameLengthOut 作为类型成员而不是类型参数没有任何优势。因为 Scala doesn't allow multiple implicit parameter sections,如果我们想验证两个 Length 实例具有相同的 Out 类型,我们需要 N 作为我们方法的类型参数。在这一点上,Length 上的 Out 也可能是一个类型参数(至少从我们作为 sameLength 作者的角度来看)。

不过,在其他情况下,我们可以利用 Shapeless 有时(稍后我将具体讨论 where)使用类型成员而不是类型参数这一事实.例如,假设我们要编写一个方法,该方法将 return 一个函数,该函数将指定大小写 class 类型转换为 HList:

def converter[A](implicit gen: Generic[A]): A => gen.Repr = a => gen.to(a)

现在我们可以这样使用了:

case class Foo(i: Int, s: String)

val fooToHList = converter[Foo]

我们会得到一个不错的 Foo => Int :: String :: HNil。如果 GenericRepr 是一个类型参数而不是类型成员,我们就必须这样写:

// Doesn't compile
def converter[A, R](implicit gen: Generic[A, R]): A => R = a => gen.to(a)

Scala 不支持类型参数的部分应用,所以每次我们调用这个(假设的)方法时,我们都必须指定两个类型参数,因为我们想指定 A:

val fooToHList = converter[Foo, Int :: String :: HNil]

这使得它基本上一文不值,因为重点是让通用机器找出表示形式。

一般来说,只要一个类型由一个类型 class 的其他参数唯一确定,Shapeless 就会使它成为一个类型成员而不是类型参数。每个 case class 都有一个通用表示,因此 Generic 有一个类型参数(对于 case class 类型)和一个类型成员(对于表示类型);每个 HList 都有一个长度,所以 Length 有一个类型参数和一个类型成员,等等

制作 uniquely-determined 类型类型成员而不是类型参数意味着如果我们只想将它们用作 path-dependent 类型(如上面的第一个 converter),我们可以,但是如果我们想像使用类型参数一样使用它们,我们总是可以写出类型细化(或者语法上更好的 Aux 版本)。如果 Shapeless 一开始就把这些类型做成类型参数,就不可能走反方向了。

作为旁注,类型 class 的类型 "parameters" 之间的这种关系(我使用引号,因为它们可能不是 参数 在字面上的 Scala 意义上)在 Haskell 等语言中被称为 "functional dependency",但你不应该觉得你需要了解 Haskell 中关于函数依赖的任何内容才能了解正在发生的事情在无形中。