将types与数据构造函数关联的ADT编码有什么问题? (如Scala)

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

 -- 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 classcase object ,Scala会生成一堆类似equalshashCodeunapply (被模式匹配使用)等等,它们带给我们许多传统ADT的关键属性和特性。

但是有一个关键的区别 – 在Scala中,“数据构造函数”有它们自己的types 。 比较以下两个例子(复制从各自的REPLs)。

 // 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 

我一直认为斯卡拉变化是有利的一面。

毕竟, 没有types信息的损失AppendIf[Int]例如是Positioning[Int]的子types。

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

事实上, 你会得到额外的编译时间不变的价值 。 (我们可以称这是依赖打字的一个有限版本吗?)

这可以被很好地利用 – 一旦你知道什么数据构造函数被用来创build一个值,相应的types可以通过其余的stream传播来增加更多的types安全。 例如,使用Scala编码的Play JSON将只允许您从JsObject提取fields ,而不是从任意的JsValue提取fields

 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可能有JsValue -> Set (String, JsValue)typesJsValue -> Set (String, JsValue) 。 这意味着它将在运行时为JsArray等失败。这个问题也体现在众所周知的部分logging访问器的forms。

Scala对数据构造器的处理是错误的观点已经被无数次地expression过了 – 在Twitter,邮件列表,IRC,SO等等。不幸的是,除了一对夫妇,我没有任何链接 – 这个答案由Travis Brown,和Argonaut ,一个纯粹的ScalafunctionJSON库。

Argonaut 有意识地采用了Haskell方法(通过private case类,手动提供数据构造函数)。 你可以看到我用Haskell编码提到的问题也存在于Argonaut中。 (除了使用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的编码错了。 当然这有时会妨碍types推断,但是这似乎不是一个足够强的理由来判定它是错误的。 我错过了什么?

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

子types问题相对容易说明:

 val x = Some(42) 

x的types原来是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)) 

xs的types是List[Case1] 。 这基本上保证不是你想要的。 为了解决这个问题,像List这样的容器需要在它们的types参数中是协变的。 不幸的是,协变引入了一大堆问题,实际上降低了某些构造的稳健性(例如,尽pipe事实上这样做是不合理的,Scalaz在它的Monadtypes和几个单子变换器上妥协了)。

所以,用这种方式编码ADT对你的代码有一定的病毒作用。 您不仅需要在ADT本身中处理子types,而且您所写的每个容器都需要考虑到在不恰当的时刻您正在ADT的子types上着陆的事实。

不使用公共案例类编码ADT的第二个原因是为了避免使用“非types”混淆types空间。 从某种angular度来看,ADT案件并不是真正的types:它们是数据。 如果以这种方式推理ADT(这是没有错的!),那么为每个ADT案例分配一stream的types会增加你想要推理代码的一些东西。

例如,考虑从上面的ADT代数。 如果你想对使用这个ADT的代码进行推理,你需要不断思考“好,如果这种types是Case1 ? 这不是一个真正需要问的问题,因为Case1是数据。 这是一个特定的coproduct案件的标签。 就这样。

就个人而言,我不太在乎上述任何一点。 我的意思是,协方差的不确定性问题是真实的,但我通常只是倾向于使我的容器不变,并指示我的用户“吸取它并注释你的types”。 这是不方便的,它是愚蠢的,但我发现它是更好的select,这是很多样板折叠和“小写”数据构造函数。

作为通配符,这种types特殊性的第三个潜在的缺点是它鼓励(或者说,允许)一个更“面向对象”的风格,在这个风格上,你在每个ADTtypes上放置了特定于案例的function。 我认为,以这种方式混合你的隐喻(case class vs subtype polymorphism)是一个不好的方法。 但是,这个结果是否是types案件的错误是一个悬而未决的问题。