一个将类型与数据构造函数相关联的ADT编码(例如Scala)存在哪些问题?

71
在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 classcase object,Scala会生成一些东西,例如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>

事实上,您将获得有关该值的附加编译时不变式。(我们可以称之为依赖类型的有限版本吗?)
这可以很好地利用 - 一旦您知道使用哪个数据构造函数创建了一个值,相应的类型就可以通过其余流程传播以增加更多的类型安全性。例如,使用此Scala编码的Play JSON仅允许您从JsObject中提取fields,而不是从任意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 可能的类型是 JsValue -> Set (String, JsValue)。这意味着对于一个 JsArray 等类型来说,它将在运行时失败。这个问题也表现在众所周知的部分记录访问器中。
关于Scala对数据构造函数的处理方式有误的观点已经多次表达过——在Twitter、邮件列表、IRC、SO等平台上。不幸的是,我没有任何链接可以提供,除了Travis Brown的this answer和Scala的纯函数JSON库ArgonautArgonaut有意地采用了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编码的错误之处。当然,有时会阻碍类型推断,但这似乎不足以宣布它是错误的。我错过了什么?


11
哦,好的。在Haskell中,您可以使用GADTs和phantom类型来实现这一点。 - David Young
4
+1,好问题。我不确定自己对代表“因为 Haskell”这一方的感觉如何,因为我经常在 Scala 中使用构造函数类型。对我来说,反对使用构造函数类型主要是出于简洁性的考虑,类型推断问题实际上可能相当麻烦,但我绝对不会倡导过分坚持这个问题。 - Travis Brown
6
您在猜测Haskell将如何处理JSON示例。两个受欢迎的JSON库是 jsonaeson。它们都将对象和数组视为单独的类型,被包装成一个总和类型。可能处理各种JSON值的函数将总和类型作为参数,并应用模式匹配。 - Michael Steele
7
语法导向性是一种属性,仅查看代码片段的语法就足以知道涉及哪些类型判断。因此,如果你看到 (a, b) 这个语法,你就知道正在处理一对... 直到添加子类型,因为现在可能涉及任何超类型的类型判断。详见此处的23.1节:https://www.cs.cmu.edu/~rwh/plbook/book.pdf - J. Abrahamson
4
请注意,Haskell确实具有子类型,但其形式非常有限——它仅出现在针对可用类型类字典和活动约束的量化变量上。普遍量化类型总是可以添加更多类型约束,存在量化类型总是可以添加更少的约束。因此,它的使用受到极大限制! - J. Abrahamson
显示剩余8条评论
1个回答

33
据我所知,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))
xs 的类型是 List[Case1]。这基本上肯定不是你想要的。为了解决这个问题,像 List 这样的容器需要对其类型参数进行协变。不幸的是,协变引入了许多问题,实际上降低了某些构造的准确性(例如,Scalaz 在允许协变容器的情况下妥协其 Monad 类型和多个单子变换器,尽管这样做是不准确的)。
因此,以这种方式编码 ADT 对您的代码有一种相当 "病毒式" 的影响。您不仅需要处理 ADT 本身的子类型化,而且还需要考虑到在不合适的时刻着陆于 ADT 的子类型。
第二个不使用公共 case 类来编码 ADT 的原因是避免用 "非类型" 来混淆您的类型空间。从某种角度来看,ADT case 实际上并不是类型,而是数据。如果您按照这种方式推理 ADT(这并不是错误的!),那么为每个 ADT case 提供一流类型将增加您需要在脑海中携带的事物集来推理代码的数量。
例如,请考虑上面的 ADT 代数。如果您想要推理使用此 ADT 的代码,则需要不断地思考 "嗯,如果这种类型是 Case1 怎么办?" 这根本不是任何人真正需要问的问题,因为 Case1 是数据。它只是一个特定余产品 case 的标签而已。
个人而言,我并不太关心上述任何一点。我的意思是,协变的不准确性问题是真实存在的,但我通常更喜欢使我的容器不变,并指导我的用户 "忍受并注释你的类型"。虽然这很不方便也很愚蠢,但我觉得这比替代方案——大量的样板折叠和 "小写" 数据构造函数——更可取。
作为一个万能字符,这种类型特异性的第三个潜在缺点是它鼓励(或者说允许)更 "面向对象" 的风格,其中将针对各个 ADT 类型放置特定于 case 的函数。我认为,在这种方式中混合您的隐喻(case 类 vs 子类型多态)会导致糟糕的结果。然而,是否将这种结果归咎于类型化 case 实际上是一个未决问题。

3
我同意第一个观点,但第二个观点并不十分令人信服。根据我的经验(类似于@missingfaktor的例子),我发现相反的情况是正确的。了解余积类型的情况可以使我忽略其他情况。还需要考虑单例类型的情况,例如1.type,这在shapeless等库中是有用的,因为它们提供了额外的保证。 - Ionuț G. Stan
1
我猜无论如何都会发生这种情况,即使它代表一种类型或不代表。最终你仍然必须处理这种情况。 - Ionuț G. Stan
5
第三点不就是“面向对象编程不好”吗?那么将 ADT 和 OOP 的最佳特性混合起来的多范式编程有什么问题呢? - Rex Kerr
3
即使去掉“面向对象编程是不好的”这一说法,仍然存在“隐喻混合很尴尬”的问题。 - J. Abrahamson
4
好的,我们这样来表述。我何时会希望我的数据不知道如何在自身上执行最自然的计算?为什么要将我的数据包装两次,当它可以被包装一次呢? - Rex Kerr
显示剩余5条评论

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