将类型与数据构造函数相关联的ADT编码有什么问题?(例如Scala。)


69

在Scala中,代数数据类型被编码为sealed一级类型层次结构。例:

-- Haskell
data Positioning a = Append
                   | AppendIf (a -> Bool)
                   | Explicit ([a] -> [a]) 
// Scala
sealed trait Positioning[A]
case object Append extends Positioning[Nothing]
case class AppendIf[A](condition: A => Boolean) extends Positioning[A]
case class Explicit[A](f: Seq[A] => Seq[A]) extends Positioning[A]

随着case classES和case objectS,斯卡拉产生了一堆东西一样equalshashCodeunapply(通过模式匹配使用)等这使我们许多关键特性和传统的ADT功能。

但是,有一个关键的区别–在Scala中,“数据构造函数”具有自己的类型。比较以下两个示例(从相应的REPL复制)。

// Scala

scala> :t Append
Append.type

scala> :t AppendIf[Int](Function const true)
AppendIf[Int]

-- Haskell

haskell> :t Append
Append :: Positioning a

haskell> :t AppendIf (const True)
AppendIf (const True) :: Positioning a

我一直认为Scala版本具有优势。

毕竟,不会丢失类型信息AppendIf[Int]例如是的子类型Positioning[Int]

scala> val subtypeProof = implicitly[AppendIf[Int] <:< Positioning[Int]]
subtypeProof: <:<[AppendIf[Int],Positioning[Int]] = <function1>

实际上,您会获得有关value的其他编译时不变的信息。(我们可以称其为受限类型的依赖类型吗?)

可以很好地利用这一点–一旦知道了使用什么数据构造函数创建值,就可以在其余流程中传播相应的类型,以增加类型安全性。例如,播放JSON,使用这个斯卡拉编码,将只允许你提取fieldsJsObject,而不是任意的JsValue

scala> import play.api.libs.json._
import play.api.libs.json._

scala> val obj = Json.obj("key" -> 3)
obj: play.api.libs.json.JsObject = {"key":3}

scala> obj.fields
res0: Seq[(String, play.api.libs.json.JsValue)] = ArrayBuffer((key,3))

scala> val arr = Json.arr(3, 4)
arr: play.api.libs.json.JsArray = [3,4]

scala> arr.fields
<console>:15: error: value fields is not a member of play.api.libs.json.JsArray
              arr.fields
                  ^

scala> val jsons = Set(obj, arr)
jsons: scala.collection.immutable.Set[Product with Serializable with play.api.libs.json.JsValue] = Set({"key":3}, [3,4])

在Haskell中,fields可能有type JsValue -> Set (String, JsValue)。这意味着它将在运行时因某个原因而失败JsArray。此问题还以众所周知的部分记录访问器的形式表现出来。

关于Scala对待数据构造函数的看法是错误的,这种观点已被无数次表达-在Twitter,邮件列表,IRC,SO等上。不幸的是,除了一对夫妇之外,我没有其他任何链接-Travis Brown的这个回答,和Argonaut,一个纯功能的Scala JSON库。

Argonaut有意识地采用Haskell方法(通过private案例类,并手动提供数据构造器)。您可以看到Argonaut也存在我提到的Haskell编码问题。(除了Option用来表示偏爱。)

scala> import argonaut._, Argonaut._
import argonaut._
import Argonaut._

scala> val obj = Json.obj("k" := 3)
obj: argonaut.Json = {"k":3}

scala> obj.obj.map(_.toList)
res6: Option[List[(argonaut.Json.JsonField, argonaut.Json)]] = Some(List((k,3)))

scala> val arr = Json.array(jNumber(3), jNumber(4))
arr: argonaut.Json = [3,4]

scala> arr.obj.map(_.toList)
res7: Option[List[(argonaut.Json.JsonField, argonaut.Json)]] = None

我已经思考了很长时间,但是仍然不明白是什么使Scala的编码错误。当然,它有时会阻碍类型推断,但是这似乎并不是一个足以使它错误的强大理由。我想念什么?


11
@missingfaktor哦。好吧,您可以在Haskell中使用GADT和幻像类型来做到这一点,所以您知道。
大卫

4
+1,好问题。我不知道我是怎么想代表“因为哈斯克尔”的一面,因为我经常使用构造类型Scala中。对我来说,对它的偏爱在很大程度上是关于简约性的问题,类型推断问题实际上可能很烦人,但是我绝对不主张在这个问题上成为原教旨主义者。
特拉维斯·布朗

6
您正在猜测Haskell将如何处理json示例。两种流行的json库是jsonaeson。两者都将对象和数组视为单独的类型,并包装为sum类型。可能处理各种json值的函数将sum类型作为参数,并应用模式匹配。
迈克尔·斯蒂尔2014年

7
语法定向性是仅查看代码片段的语法就足以知道涉及哪种类型判断的属性。因此,如果您看到语法(a, b),就知道您正在处理一对...直到添加子类型,因为现在您可以处理任何超类型的类型判断。第23.1节:cs.cmu.edu/~rwh/plbook/book.pdf
J. Abrahamson

4
请注意,Haskell确实具有子类型...但是它的形式非常有限-仅在关于可用类型类字典(即活动约束)的量化变量上发生。通用量化类型总是可以添加更多的类型约束,而现有量化类型总是可以添加更少的约束。所以-真的有限制!
J. Abrahamson 2014年

Answers:


32

据我所知,Scala的案例类惯用编码可能很差的原因有两个:类型推断和类型特异性。前者是句法上的便利性问题,而后者是推理范围扩大的问题。

子类型问题相对容易说明:

val x = Some(42)

该类型的x结果是Some[Int],这可能不是你想要的。您可以在其他问题更大的领域中产生类似的问题:

sealed trait ADT
case class Case1(x: Int) extends ADT
case class Case2(x: String) extends ADT

val xs = List(Case1(42), Case1(12))

的类型xsList[Case1]。基本上可以保证这不是您想要的。为了解决这个问题,类似容器List的类型参数需要协变。不幸的是,协方差会带来很多问题,并且实际上会降低某些构造的可靠性(例如,ScalazMonad通过允许协变容器来折衷其类型和几个monad转换器,尽管事实并非如此)。

因此,以这种方式编码ADT对您的代码会产生一些病毒性影响。您不仅需要处理ADT本身的子类型,而且您曾经编写的每个容器都需要考虑到您在不适当的时刻登陆ADT子类型的事实。

不使用公共案例类对ADT进行编码的第二个原因是,避免使用“非类型”弄乱您的类型空间。从某种角度来看,ADT案例并不是真正的类型:它们是数据。如果您以这种方式推理ADT(这是正确的!),那么为每个ADT案例配备一流的类型会增加您需要考虑的一系列事情来推理代码。

例如,ADT从上面考虑代数。如果您想对使用此ADT的代码进行推理,则需要不断思考“好吧,如果这种类型是Case1?” 这不是任何人真正需要问的问题,因为Case1是数据。这是特定副产品案例的标签。就这样。

就个人而言,我对上述任何内容都不在乎。我的意思是,协方差的不合理性是真实的,但是我通常只喜欢使容器不变,并指示用户“对其进行填充并注释您的类型”。这很不方便,而且很笨,但是我发现它比其他方法更可取,后者有很多样板式折叠和“小写”数据构造函数。

作为通配符,这种类型特异性的第三个潜在缺点是,它鼓励(或更确切地说,允许)一种更加“面向对象”的样式,其中您将特定于案例的函数放在各个ADT类型上。我认为几乎没有问题,以这种方式混合您的隐喻(案例类与子类型多态性)是不好的秘诀。但是,这种结果是否是典型病例的错是一个悬而未决的问题。


3
我同意第一点,但第二点不是很有吸引力。根据我的经验(类似于@missingfaktor的示例),我发现相反的说法是正确的。知道副产品案例的类型可以使我忽略其他案例。还考虑单身类型(如)的情况,1.type在诸如shapeless之类的库中需要它们,以提供它们的额外保证。
Ionuț G. Stan 2014年

1
我想无论如何它都会发生,即使它代表一种类型也是如此。最后,您仍然必须处理这种情况。
Ionuț G. Stan 2014年

5
第三点,基本上,“ OOP不好”怎么办?混合了ADT和OOP的最佳功能的多范式编程有什么问题?
Rex Kerr 2014年

3
@RexKerr我认为,即使您删除了“ OOP不好”,您仍然会有“隐喻混合很尴尬”的位。
J. Abrahamson 2014年

4
好吧,让我们这样说。我什么时候希望我的数据不知道如何对自己执行最自然的计算?当数据可以包装一次时,为什么还要将数据包装两次?
Rex Kerr
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.