将类型与数据构造函数相关联的 ADT 编码有啥问题? (例如斯卡拉。)

Posted

技术标签:

【中文标题】将类型与数据构造函数相关联的 ADT 编码有啥问题? (例如斯卡拉。)【英文标题】:What are the problems with an ADT encoding that associates types with data constructors? (Such as Scala.)将类型与数据构造函数相关联的 ADT 编码有什么问题? (例如斯卡拉。) 【发布时间】:2014-10-09 10:07:19 【问题描述】:

在 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,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 -&gt; Set (String, JsValue) 类型。这意味着它将在运行时失败 JsArray 等。这个问题也以众所周知的部分记录访问器的形式表现出来。

Scala 对数据构造函数的处理是错误的观点已被多次表达 – 在 Twitter、邮件列表、IRC、SO 等上。不幸的是,我没有任何链接,除了一对夫妇 - Travis Brown 的 this answer 和 Argonaut,一个用于 Scala 的纯函数式 JSON 库。

Argonaut consciously 采用 Haskell 方法(通过 privateing 案例类,并手动提供数据构造函数)。您可以看到我提到的有关 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 的编码错误。当然,它有时会妨碍类型推断,但这似乎不是一个足够有力的理由来判定它是错误的。我错过了什么?

【问题讨论】:

@missingfaktor 哦。好吧,你可以在 Haskell 中使用 GADT 和幻像类型来做到这一点,所以你知道。 +1,好问题。我不确定我对代表“因为 Haskell”方面的感受如何,因为我经常确实在 Scala 中使用构造函数类型。对我来说,反对的偏好主要是简约问题,类型推断问题实际上可能相当烦人,但我绝对不主张在这个问题上成为原教旨主义者。 您在猜测 Haskell 将如何处理 json 示例。两个流行的 json 库是 json 和 aeson。两者都将对象和数组视为包装成 sum 类型的单独类型。可能处理各种 json 值的函数将 sum 类型作为参数,并应用模式匹配。 语法导向性是仅查看代码片段的语法就足以知道涉及哪种类型判断的属性。因此,如果您看到语法(a, b),您就知道您正在处理一对...直到您添加子类型,因为现在您可以处理任何超类型的类型判断。第 23.1 节:cs.cmu.edu/~rwh/plbook/book.pdf 请注意,Haskell 确实有子类型......但它的形式非常有限——它只出现在与可用的类型类字典、活动约束相关的量化变量上。普遍量化的类型总是可以添加更多的类型约束,而存在量化的类型总是可以添加更少的约束。所以——真的很受限制! 【参考方案1】:

据我所知,Scala 对 case 类的惯用编码之所以不好有两个原因:类型推断和类型特异性。前者是句法方便的问题,而后者是增加推理范围的问题。

子类型问题比较容易说明:

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 类型和几个monad 转换器上妥协,尽管这样做并不可靠)。

因此,以这种方式编码 ADT 会对您的代码产生一定程度的病毒式影响。您不仅需要处理 ADT 本身中的子类型,而且您编写的每个容器都需要考虑到您在不合时宜的时刻登陆 ADT 的子类型这一事实。 p>

不使用公共案例类对 ADT 进行编码的第二个原因是避免使用“非类型”来混淆类型空间。从某种角度来看,ADT 案例并不是真正的类型:它们是数据。如果您以这种方式推理 ADT(这没有错!),那么为每个 ADT 案例使用一流的类型会增加您在推理代码时需要记住的一组内容。

例如,考虑上面的ADT 代数。如果你想推理使用这个 ADT 的代码,你需要不断思考“好吧,如果这个类型是 Case1 怎么办?”这不是任何人真正需要问的问题,因为Case1 是数据。它是特定副产品案例的标签。就是这样。

就个人而言,我不太关心上述任何内容。我的意思是,协方差的不健全问题是真实存在的,但我通常更喜欢让我的容器保持不变并指示我的用户“接受它并注释你的类型”。这很不方便而且很愚蠢,但我发现它比替代方案更可取,后者有很多样板折叠和“小写”数据构造函数。

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

【讨论】:

我同意第一点,但第二点不是很有说服力。根据我的经验(类似于@missingfaktor 的示例),我发现情况正好相反。知道副产品案例的类型可以让我忽略其他案例。还要考虑像 1.type 这样的单例类型的情况,它们在 shapeless 等库中是需要的,因为它们提供了额外的保证。 我想无论如何都会发生这种情况,即使它是否代表一种类型。你最终还是要处理那个案子。 第三点怎么不是,基本上,“OOP 不好”?混合 ADT 和 OOP 的最佳特性的多范式编程有什么问题? @RexKerr 我认为即使您删除“OOP 很糟糕”,您仍然会有“隐喻混合很尴尬”这一点。 好吧,让我们这样说吧。我什么时候会希望我的数据不知道如何对其自身执行最自然的计算?我的数据可以打包一次,为什么还要打包两次?

以上是关于将类型与数据构造函数相关联的 ADT 编码有啥问题? (例如斯卡拉。)的主要内容,如果未能解决你的问题,请参考以下文章

软件构造 第三章第三节 抽象数据型(ADT)

在 VueJS 中如何将输入类型复选框与输入类型文本相关联

将 ADT / 密封特征层次结构编码到 Spark DataSet 列中

如何将文件类型与 iPhone 应用程序相关联?

将文件类型与 Chrome 打包应用程序相关联

使用[测试类]而不是[编码UI测试]时将测试用例与测试方法相关联