Каковы проблемы с кодировкой 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 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>

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

Это можно найти с пользой - как только вы узнаете, какой конструктор данных использовался для создания значения, соответствующий тип можно распространить через остальную часть потока, чтобы повысить безопасность типов. Например, 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.

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


person missingfaktor    schedule 15.08.2014    source источник
comment
Вы ищете пример Haskell, в котором разные конструкторы данных одного типа приводят к значениям разных типов?   -  person David Young    schedule 15.08.2014
comment
@DavidYoung, не совсем. Только хочу знать недостатки подхода Scala.   -  person missingfaktor    schedule 15.08.2014
comment
@missingfaktor О. Что ж, вы можете сделать это в Haskell с помощью GADT и фантомных типов, так что вы знаете.   -  person David Young    schedule 15.08.2014
comment
@DavidYoung, я так и думал. :)   -  person missingfaktor    schedule 15.08.2014
comment
+1, отличный вопрос. Я не уверен, как я отношусь к представлению стороны, потому что Haskell, поскольку я часто действительно использую типы конструкторов в Scala. Для меня предпочтение против - это в значительной степени вопрос экономии, и проблемы с выводом типов могут на самом деле довольно раздражать, но я определенно не буду защищать фундаменталистские взгляды на этот вопрос.   -  person Travis Brown    schedule 15.08.2014
comment
Вы размышляли о том, как Haskell будет обрабатывать пример json. Две популярные библиотеки json: json и aeson. Оба обрабатывают объекты и массивы как отдельные типы, которые объединяются в тип суммы. Функции, которые могут обрабатывать различные значения json, принимают тип суммы в качестве аргумента и применяют сопоставление с образцом.   -  person Michael Steele    schedule 15.08.2014
comment
Можно предположить, что для достижения такой функциональности вам необходимо создание подтипов, и поскольку подтипирование нарушает направленность синтаксиса, вы теряете логический вывод - что, насколько мне известно, верно. Так что, возможно, Haskell сможет вывести больше программ в свете этого различия. Имеет ли Argonaut лучшие свойства вывода, чем другие библиотеки JSON в Scala? (Я недостаточно знаю о Scala, чтобы отважиться на большее, чем это предположение)   -  person J. Abrahamson    schedule 15.08.2014
comment
На чисто педантичном уровне, я думаю, будет справедливо сказать, что ADT работают сами по себе и хорошо сочетаются с определенными видами подтипов ... тогда как Scala, кажется, смешивает их таким образом, который кажется более запутанным (для меня).   -  person J. Abrahamson    schedule 15.08.2014
comment
@MichaelSteele, мое замечание остается в силе. С подходом Scala у вас есть не тип суммы, а один из этих типов.   -  person missingfaktor    schedule 15.08.2014
comment
@ J.Abrahamson, я не использовал Argonaut в гневе, но я использовал Play JSON, и я не могу припомнить никаких проблем с выводом типов, вызванных его дизайном.   -  person missingfaktor    schedule 15.08.2014
comment
@ J.Abrahamson, ваше предположение о необходимости выделения подтипов верно. Что вы имеете в виду под направленностью синтаксиса?   -  person missingfaktor    schedule 15.08.2014
comment
Направленность синтаксиса - это свойство, при котором достаточно одного взгляда на синтаксис фрагмента кода, чтобы знать, какое определение типа используется. Итак, если вы видите синтаксис (a, b), вы знаете, что имеете дело с парой ... пока вы не добавите подтип, поскольку теперь вы можете иметь дело с типизацией суждений любого супертипа. Раздел 23.1 здесь: cs.cmu.edu/~rwh/plbook/book. pdf   -  person J. Abrahamson    schedule 15.08.2014
comment
Обратите внимание, что в Haskell есть подтипы ... но это действительно ограниченная форма - это происходит только с количественными переменными по отношению к доступным словарям классов типов, активным ограничениям. Универсальные количественные типы всегда могут добавлять больше ограничений типа, а экзистенциально количественные типы всегда могут добавлять меньше ограничений. Итак, действительно ограничено!   -  person J. Abrahamson    schedule 15.08.2014


Ответы (1)


Насколько мне известно, есть две причины, по которым идиоматическое кодирование классов case в 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 в неподходящие моменты.

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

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

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

Как подстановочный знак, третий потенциальный недостаток такого рода специфичности типа заключается в том, что он поощряет (или, скорее, позволяет) более «объектно-ориентированный» стиль, когда вы помещаете зависящие от случая функции на отдельные типы ADT. Я думаю, что нет никаких сомнений в том, что смешивание ваших метафор (классов случаев и полиморфизма подтипов) таким образом - это рецепт плохого. Однако вопрос о том, является ли этот результат ошибкой типизированных случаев, остается открытым.

person Daniel Spiewak    schedule 15.08.2014
comment
Я согласен с первым пунктом, но второй не очень убедителен. По моему опыту (аналогично примерам @ missingfaktor) я обнаружил, что верно обратное. Знание типа кейса с сопутствующим продуктом позволяет мне игнорировать другие случаи. Рассмотрим также случай одноэлементных типов, таких как 1.type, которые желательны в библиотеках, таких как shapeless, из-за дополнительных гарантий, которые они предоставляют. - person Ionuț G. Stan; 16.08.2014
comment
@ IonuțG.Stan Иногда да, более конкретный тип действительно помогает разобраться. Трудно отрицать, что это лишнее имя, которое нужно держать в голове, знать, что оно плавает вокруг и т. Д. - person Daniel Spiewak; 16.08.2014
comment
Я предполагаю, что это все равно происходит, даже если он представляет собой тип или нет. В конце концов, тебе все равно придется разобраться с этим делом. - person Ionuț G. Stan; 16.08.2014
comment
Как третий пункт, в принципе, не ООП - это плохо? Что плохого в многопарадигмальном программировании, сочетающем в себе лучшие возможности ADT и ООП? - person Rex Kerr; 16.08.2014
comment
@RexKerr Я думаю, что даже если вы удалите ООП, это плохо, у вас все еще есть метафора, смешивание - это немного неудобно. - person J. Abrahamson; 16.08.2014
comment
Обычно я не придерживаюсь мнения, что ООП - это плохо, но смешивание метафор определенно очень странно в большинстве случаев. Не всегда, но в большинстве случаев. - person Daniel Spiewak; 17.08.2014
comment
Что ж, давайте сформулируем это так. Когда я когда-нибудь захочу, чтобы мои данные не знали, как выполнять наиболее естественные вычисления над собой? Зачем мне нужно дважды обернуть мои данные, если их можно обернуть один раз? - person Rex Kerr; 17.08.2014
comment
Я никогда не хочу этого, @RexKerr. Я не хочу сказать, что вы не должны, но просто хочу выразить то, что я считаю, что существует вполне работоспособная метафора, которая вообще не включает эту концепцию, что ведет к возможности смешивания. Я не уверен, что концепция упаковки - это необходимая вещь, просто артефакт реализации Scala. - person J. Abrahamson; 18.08.2014
comment
@ J.Abrahamson - Учитывая ограничения реализации, вы все еще можете спросить: почему вы не хотите, чтобы это было так в Scala? (В Haskell есть разные ограничения реализации, которые заставляют ADT работать так же, как они.) - person Rex Kerr; 18.08.2014
comment
Это не ограничения реализации, это просто другая модель набора текста. Я мог бы спорить с этим, но это не моя точка зрения: я просто хочу сказать, что существует другая разумная модель для этого, которая является последовательной и значимой, и, следовательно, стремление Scala охватить как эту модель, так и более объектно ориентированный подход. Вдохновленная модель вызывает странное смешение. Если вы хотите поговорить о том, чем эта модель отличается или лучше или хуже, чем модель более объектно-ориентированной / динамической диспетчеризации, я предлагаю вам задать другой вопрос. - person J. Abrahamson; 18.08.2014