В чем проблемы с кодировкой ADT, которая связывает типы с конструкторами данных? (Например, Scala.)

В 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 class es и case object s, 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>

Фактически, вы получаете дополнительный параметр времени компиляции относительно значения. (Можно ли назвать эту ограниченную версию зависимого набора?)

Это может быть полезно для использования. Как только вы знаете, какой конструктор данных использовался для создания значения, соответствующий тип может распространяться через остальную часть потока, чтобы добавить дополнительную безопасность типов. Например, Play JSON, который использует эту кодировку Scala, позволит вам извлечь fields из JsObject, а не из любого произвольного 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 и т.д. К сожалению, у меня нет ссылок ни на один из этих, за исключением пары - этот ответ от Трэвиса Брауна и Argonaut, чисто функциональную библиотеку JSON для Scala.

Аргонавт сознательно использует подход 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 неправильной. Уверен, что это иногда мешает типу вывода, но это не похоже на достаточно сильную причину, чтобы привести его в заблуждение. Что мне не хватает?

Ответ 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 и несколько монадных трансформаторов, разрешая ковариантные контейнеры, несмотря на то, что это необоснованно).

Таким образом, кодирование ADT таким образом оказывает несколько виброэффект на ваш код. Вам не только нужно иметь дело с подтипами в самом ADT, но каждый контейнер, который вы когда-либо пишете, должен учитывать тот факт, что вы приземляетесь по подтипам вашего ADT в неподходящие моменты.

Вторая причина не кодировать ваши ADT, используя общедоступные классы классов, заключается в том, чтобы избежать загромождения вашего пространства типов "не-типами". С определенной точки зрения, случаи ADT на самом деле не являются типами: это данные. Если вы рассуждаете об ADT таким образом (что не так!), То наличие первоклассных типов для каждого из ваших случаев ADT увеличивает набор вещей, которые вам нужно нести в уме, чтобы рассуждать о вашем коде.

Например, рассмотрим алгебру ADT сверху. Если вы хотите рассуждать о коде, который использует этот ADT, вам нужно постоянно думать о "хорошо, что, если этот тип Case1?" Это просто не вопрос, который действительно нужно задать, так как Case1 - это данные. Это тег для конкретного случая копроизведения. Это все.

Лично меня не волнует ни одно из вышеперечисленных. Я имею в виду, что проблемы беззаботности с ковариацией реальны, но я обычно предпочитаю, чтобы мои контейнеры были инвариантными и инструктировали моих пользователей "сосать его и аннотировать ваши типы". Это неудобно и неловко, но я считаю предпочтительным альтернативу, в которой много шаблонных сгибов и конструкторов данных "нижнего регистра".

В качестве подстановочного знака третий потенциальный недостаток такого рода специфичности типов заключается в том, что он поощряет (или, вернее, позволяет) более "объектно-ориентированный" стиль, где вы ставите специфичные для конкретного случая функции для отдельных типов ADT. Я думаю, что очень мало вопросов, что смешивание ваших метафор (case classes vs subtype polymorphism) таким образом является рецептом плохого. Однако, независимо от того, является ли этот результат ошибкой типизированных случаев, это некий открытый вопрос.