70

在 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 生成了一堆东西,比如equals, hashCode, unapply(用于模式匹配)等,它们为我们带来了传统 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 的额外编译时不变量。(我们可以称之为依赖类型的受限版本吗?)

这可以很好地利用——一旦您知道使用什么数据构造函数来创建一个值,相应的类型就可以通过流程的其余部分传播,以增加更多的类型安全性。例如,使用这种 Scala 编码的 Play 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案例类,并手动提供数据构造函数)。您可以看到我提到的有关 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 的编码错误。当然,它有时会妨碍类型推断,但这似乎不是一个足够有力的理由来判定它是错误的。我错过了什么?

4

1 回答 1

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 类型上。我认为以这种方式混合你的隐喻(案例类与子类型多态性)是一个坏的秘诀,这一点几乎没有问题。然而,这种结果是否是打字案件的错是一个悬而未决的问题。

于 2014-08-15T21:18:44.467 回答