HLists是否只是写元组的复杂方式?

150

我很感兴趣了解HLists和一般列表相比的差异,更普遍地说,要确定哪些情况下不能使用HLists(或者说,相对于普通列表没有任何优势)。

(我知道Scala中有22个TupleN,而只需要一个HList,但这不是我感兴趣的概念差异。)

下面的文本中我标记了一些问题。实际上回答它们可能并不必要,它们更多是指出我不清楚的事情,并引导讨论朝某些方向进行。

动机

我最近在SO上看到一些人建议使用HLists(例如由Shapeless提供的),包括对该问题的删除答案。这引发了这次讨论,进而引发了这个问题。

简介

我认为,只有当你静态地知道元素数量和精确类型时,HLists才有用。数量实际上并不关键,但我认为你很少需要生成具有静态已知但类型不同的元素列表,而且你又不知道它们的数量。 问题1:你甚至能否编写这样的示例,例如在一个循环中?我的直觉是,拥有具有静态未知数量的任意元素(相对于给定类层次结构)的静态精确HList根本不兼容。

HLists vs. Tuples

如果这是真的,也就是说,您静态地知道数量和类型 - 问题2:为什么不只使用n元组?当然,您可以类型安全地映射和折叠HList(您也可以使用productIterator在元组上进行映射和折叠,但是不是类型安全的),但是由于元素的数量和类型是静态已知的,您可能可以直接访问元组元素并执行操作。
另一方面,如果您映射到hlist的函数f非常通用,可以接受所有元素 - 问题3:为什么不通过productIterator.map使用它?好吧,一个有趣的区别可能来自方法重载:如果我们有几个重载的f,那么hlist提供的更强的类型信息(与productIterator相比)可以使编译器选择更具体的f。但是,我不确定在Scala中是否会实际起作用,因为方法和函数不是相同的。

HLists和用户输入

基于同样的假设,即您需要静态知道元素的数量和类型 - 问题4: hlist能否在元素取决于任何类型的用户交互的情况下使用?例如,在循环内部填充带有元素的hlist;元素从某个地方读取(UI、配置文件、actor交互、网络),直到满足某些条件。hlist的类型将是什么?对于接口规范getElements:HList[...],它应该与静态未知长度的列表一起工作,并允许系统中的组件A从组件B获取任意元素的这样一个列表。
4个回答

147

回答问题一至三: HLists 的主要应用之一是抽象化参数数量。在任何给定的抽象使用点,参数数量通常是静态已知的,但在不同的使用点会有所变化。例如,来自shapeless的examples

def flatten[T <: Product, L <: HList](t : T)
  (implicit hl : HListerAux[T, L], flatten : Flatten[L]) : flatten.Out =
    flatten(hl(t))

val t1 = (1, ((2, 3), 4))
val f1 = flatten(t1)     // Inferred type is Int :: Int :: Int :: Int :: HNil
val l1 = f1.toList       // Inferred type is List[Int]

val t2 = (23, ((true, 2.0, "foo"), "bar"), (13, false))
val f2 = flatten(t2)
val t2b = f2.tupled
// Inferred type of t2b is (Int, Boolean, Double, String, String, Int, Boolean)

如果不使用HLists(或等效物)来抽象化元组参数的数量,那么将无法有一个单一的实现可以接受这两种非常不同形状的参数并以类型安全的方式进行转换。

在涉及固定元数的任何地方,抽象化元数的能力可能会引起兴趣:除了上面的元组之外,还包括方法/函数参数列表和案例类。请参见此处,了解如何抽象化任意案例类的元数以几乎自动地获得类型类实例的示例。

// A pair of arbitrary case classes
case class Foo(i : Int, s : String)
case class Bar(b : Boolean, s : String, d : Double)

// Publish their `HListIso`'s
implicit def fooIso = Iso.hlist(Foo.apply _, Foo.unapply _)
implicit def barIso = Iso.hlist(Bar.apply _, Bar.unapply _)

// And now they're monoids ...

implicitly[Monoid[Foo]]
val f = Foo(13, "foo") |+| Foo(23, "bar")
assert(f == Foo(36, "foobar"))

implicitly[Monoid[Bar]]
val b = Bar(true, "foo", 1.0) |+| Bar(false, "bar", 3.0)
assert(b == Bar(true, "foobar", 4.0))

这里没有运行时的迭代,但是有重复的代码,使用HLists(或类似结构)可以消除重复。当然,如果你对重复的样板代码的容忍度很高,你也可以为每个你关心的形状编写多个实现,从而达到相同的结果。

在第三个问题中,你问:“......如果你在一个hlist上映射的函数f如此通用,以至于它接受所有元素......为什么不通过productIterator.map使用它呢?” 如果你在一个HList上映射的函数确实是Any => T的形式,那么在productIterator上进行映射就可以完美地为你服务。但是,Any => T形式的函数通常并不那么有趣(至少在内部它们不会进行类型转换)。shapeless提供了一种多态函数值的形式,允许编译器按照你怀疑的方式选择特定于类型的情况。例如,

// size is a function from values of arbitrary type to a 'size' which is
// defined via type specific cases
object size extends Poly1 {
  implicit def default[T] = at[T](t => 1)
  implicit def caseString = at[String](_.length)
  implicit def caseList[T] = at[List[T]](_.length)
}

scala> val l = 23 :: "foo" :: List('a', 'b') :: true :: HNil
l: Int :: String :: List[Char] :: Boolean :: HNil =
  23 :: foo :: List(a, b) :: true :: HNil

scala> (l map size).toList
res1: List[Int] = List(1, 3, 2, 1)

关于您的第四个问题,涉及用户输入,有两种情况需要考虑。第一种是我们可以动态建立一个上下文,以确保已知的静态条件得以满足的情况。在这些场景中,完全可以应用形状库技术,但是需要注意的是,如果静态条件在运行时不满足,那么我们必须采取替代路径。毫不奇怪的是,敏感于动态条件的方法必须产生可选结果。以下是使用 HList 的示例。
trait Fruit
case class Apple() extends Fruit
case class Pear() extends Fruit

type FFFF = Fruit :: Fruit :: Fruit :: Fruit :: HNil
type APAP = Apple :: Pear :: Apple :: Pear :: HNil

val a : Apple = Apple()
val p : Pear = Pear()

val l = List(a, p, a, p) // Inferred type is List[Fruit]
< p > l 的类型并不能确定列表的长度或其元素的精确类型。但是,如果我们期望它具有特定的形式(即如果它应该符合某个已知的固定模式),那么我们可以尝试确定这个事实并相应地采取行动。

scala> import Traversables._
import Traversables._

scala> val apap = l.toHList[Apple :: Pear :: Apple :: Pear :: HNil]
res0: Option[Apple :: Pear :: Apple :: Pear :: HNil] =
  Some(Apple() :: Pear() :: Apple() :: Pear() :: HNil)

scala> apap.map(_.tail.head)
res1: Option[Pear] = Some(Pear())

有时候,我们可能并不关心给定列表的实际长度,只需要它与其他列表的长度相同。同样,这是shapeless支持的一种情况,既可以完全静态地支持,也可以在上述混合静态/动态环境中支持。请参见此处以获取扩展示例。

正如您所观察到的那样,所有这些机制都需要可用的静态类型信息,至少有条件地,这似乎排除了这些技术在完全由外部提供的未经过类型标记的数据驱动的完全动态环境中可用。但随着Scala 2.10中反射支持运行时编译的到来,即使这个问题也不再是一个难以克服的障碍...我们可以使用运行时编译提供一种轻量级分期的形式,并在响应动态数据时在运行时执行静态类型化:摘自以下内容...请点击链接查看完整示例。

val t1 : (Any, Any) = (23, "foo") // Specific element types erased
val t2 : (Any, Any) = (true, 2.0) // Specific element types erased

// Type class instances selected on static type at runtime!
val c1 = stagedConsumeTuple(t1) // Uses intString instance
assert(c1 == "23foo")

val c2 = stagedConsumeTuple(t2) // Uses booleanDouble instance
assert(c2 == "+2.0")

我相信@PLT_Borat会对此发表评论,考虑到他关于依赖类型编程语言的睿智评论;-)

2
我对你回答的最后一部分有些困惑,但也非常感兴趣!谢谢你的出色回答和众多参考资料,看起来我有很多阅读要做 :-) - Malte Schwerhoff
1
抽象化度数非常有用。不幸的是,ScalaMock存在重复性问题,因为各种“FunctionN”特征不知道如何抽象化度数:https://github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/scala/org/scalamock/MockFunction.scala https://github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/scala/org/scalamock/Mock.scala不幸的是,我不知道是否可以使用Shapeless来避免这个问题,因为我需要处理“真实”的“FunctionN”。 - Paul Butcher
1
我编写了一个(相当人为的)示例-http://ideone.com/sxIw1-,与问题一类似。这个示例可能会从hlists中受益,也许结合“根据动态数据在运行时执行的静态类型检查”?(我仍然不确定后者具体是什么) - Malte Schwerhoff

18

为了明确,HList 本质上就是一堆带有略微不同语法糖的 Tuple2 栈。

def hcons[A,B](head : A, tail : B) = (a,b)
def hnil = Unit

hcons("foo", hcons(3, hnil)) : (String, (Int, Unit))

因此,你的问题实质上是关于使用嵌套元组与平铺元组之间的区别,但两者同构,因此最终实际上没有任何区别,除了可以使用哪些库函数和可以使用哪些符号之外,还要考虑方便性。


元组可以映射到HList,反之亦然,因此存在明确的同构关系。 - Erik Kaplun

11

有很多事情你不能(很好地)用元组做到:

  • 写一个通用的prepend / append函数
  • 写一个反转函数
  • 写一个连接函数
  • ...

当然,你可以使用元组做所有这些事情,但在一般情况下是不行的。因此,使用HLists可以使你的代码更加DRY。


9
我可以用非常简单的语言解释这个问题:
元组(tuple)和列表(list)的命名并不重要。HList也可以被命名为HTuple。区别在于,在Scala和Haskell中,你可以使用元组(使用Scala语法)来完成以下操作:
def append2[A,B,C](in: (A,B), v: C) : (A,B,C) = (in._1, in._2, v)

将任意类型的输入元组精确地添加第三个元素,并返回一个具有精确三个元素的完全类型化元组。虽然这对于所有类型都是通用的,但必须明确指定输入/输出长度。
Haskell风格的HList让您可以使其通用于长度,因此您可以附加到任何长度的元组/列表并返回一个完全静态类型的元组/列表。这个好处也适用于同类型集合,在那里您可以将int附加到恰好n个int的列表中,并获得一个静态类型为恰好(n+1)个int的列表,而无需明确指定n。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接