Неразмеченные Конечные Интепретаторы (Tagless Final interpreters — прим. пер.) — это альтернативный подход традиционным Алгебраическим Типам Данных (и обобщённым ADT), основанный на реализации паттерна "интерпретатор". Этот текст представляет "неразмеченный конечный" подход в Scala, и демонстрирует каким образом Dotty с его недавно добавленными типами неявных функций делает этот подход ещё более привлекательным. Все примеры кода — это прямое переложение их Haskell версий, представленных в Typed Tagless Final Interpreters: Lecture Notes (раздел 2).


Паттерн "интерпретатор" в последнее время привлекает всё больше внимания в сообществе Scala. Множество усилий было затрачено на борьбу с наиболее ярким недостатком решений, основанных на ADT/GADT: расширяемость. Для начала можно взглянуть на typeclass Inject из cats как на реализацию идей Data Type a la Carte. Библиотека Freek предоставляет средства для комбинирования более двух алгебр, используя украшения с задействованием аппарата операций на уровне типов. Решение, предложенное в работе Freer Monads, More Extensible Effects также ставит акцент на расширяемости, и вдохновлено набором небольших Scala-библиотек, таких как eff, emm и paperdoll. Неразмеченные конечные интерпретаторы подходят в некотором смысле с противоположной стороны, используя типы классов в своём непосредственном основании вместо более традиционных ADT/GADT. Они также поставляются с большим превосходством в расширяемости "из коробки" без каких-то явных опасностей.



Конспект углубляется в детали представления и сравнения подходов с использованием ADT/GADT и типов классов, обозначая первый как начальный и второй как конечный. С целью краткости этот текст посвящён преимущественно конечным интерпретаторам.


Введение


Мы будем работать с простыми математическими выражениями, похожими на те, что используются в калькуляторах. Наша задача состоит не только в вычислении подобных выражений, но также в их сериализации, десериализации и упрощении. С точки зрения ленивого инженера совершенно разумно представить предметную область с использованием (встроенного) предметно-ориентированного языка (domain specific language — прим. пер.): помимо всего прочего, это спасает нас от необходимости отслеживать возможно некорректные представления нашей предметной области — базовый язык программирования позаботится об этом за нас. Наша предметная область будет состоять только из целочисленных литералов, сложения и взятия с обратным знаком. Кодировка, которая возникла в Вашем сознании, возможно, выглядит так:


sealed trait IExp
final case class Lit(i: Int) extends IExp
final case class Neg(e: IExp) extends IExp
final case class Add(r: IExp, l: IExp) extends IExp

Математическое выражение 8 - (1 + 2), к примеру, может быть закодировано как значение типа IExp:


val fe: IExp = Add(Lit(8), Neg(Add(Lit(1), Lit(2))))

Пока что всё выглядит просто, так? Интерпретация fe как целого числа будет использовать рекурсивную функцию с типом IExp => Int, сериализация происходит с функцией IExp => Json, десериализация идёт в обратном направлении с Json => Option[IExp], и трансформации происходят через функции IExp => IExp.


В терминах конспекта, тип данных IExp соотносится с начальной кодировкой нашей предметной области. Пока что Вы можете забыть об этом, так как вместо этого мы будем использовать конечную кодировку:


trait Exp[T] {
  def lit(i: Int): T
  def neg(t: T): T
  def add(l: T, r: T): T
}

Как нам представить 8 - (1 + 2) с помощью Exp? Каким-то образом вроде:


def tf0[T](implicit e: Exp[T]): T =
  e.add(e.lit(8), e.neg(e.add(e.lit(1), e.lit(2))))

В Haskell (с соответсвующим расширением языка) tf0 может быть полиморфным значением. В Scala мы используем функцию с параметром типа T и ограничением (constraint — прим. пер.) implicit Exp[T]. Синтаксис можно упростить (к примеру) избавившись от e. используя вспомогательные функции:


object ExpSyntax {
  def lit[T](i: Int)    (implicit e: Exp[T]): T = e.lit(i)
  def neg[T](t: T)      (implicit e: Exp[T]): T = e.neg(t)
  def add[T](l: T, r: T)(implicit e: Exp[T]): T = e.add(l, r)
}
import ExpSyntax._ // Безопасно всегда иметь её в области видимости

def tf1[T: Exp]: T =
  add(lit(8), neg(add(lit(1), lit(2))))

В этот момент Вы, вероятно, задаётесь вопросом, "как нам написать интерпретатор для этой tf1?". Ответ прост: определяя реализацииExp!


implicit val evalExp: Exp[Int] = new Exp[Int] {
  def lit(i: Int): Int = i
  def neg(t: Int): Int = -t
  def add(l: Int, r: Int): Int = l + r
}

implicit val printExp: Exp[String] = new Exp[String] {
  def lit(i: Int): String = i.toString
  def neg(t: String): String = s"(-$t)"
  def add(l: String, r: String): String = s"($l + $r)"
}

Интерпретация производится через определение типов. Давайте посмотрим на tf1 как на Int:


scala> tf1[Int]
res0: Int = 5

А что насчёт tf1 как String?


scala> tf1[String]
res1: String = (8 + (-(1 + 2)))

Расширяемость


Что если мы решим дополнить наши математические выражения операцией произведения? В начальной (основанной на ADT) кодировке IExp, мы бы столкнулись с двумя обременительными альтернативами: обновить определения типа данных IExp в купе со всеми интерпретаторами, что мы до сих пор написали, или положиться на возведение значений IExp в абстрактные суммы типов (coproducts — прим. пер.) в стиле Data Type a la Carte. И имено в этом отношении, где конечный неразмеченный подход по настоящему проявляет себя, т.к. он может быть расширен ествественным образом без изменений (или даже повторной компиляции) написанного кода. Мы введём новый, совершенно независимый класс типов для операции произведения:


trait Mult[T] {
  def mul(l: T, r: T): T
}

object MultSyntax {
  def mul[T](l: T, r: T)(implicit e: Mult[T]): T = e.mul(l, r)
}
import MultSyntax._

Выражения, использующие умножение чисел требуют дополнительного ограничения Mult (дополнительного неявного (implicit — прим. пер.) аргумента Mult[T], вот и всё). Вот так мы определим tfm1 = 7 - 1 * 2 и tfm2 = 7 * (8 - (1 + 2)):


def tfm1[T: Exp : Mult] = add(lit(7), neg(mul(lit(1), lit(2))))
def tfm2[T: Exp : Mult] = mul(lit(7), tf1)

Если Вас не устраивает добавлять всюду : Exp : Mult, забегая вперёд, я скажу, что мы увидим в конце статьи: в Dotty это может быть вынесено в тип неявной функции ( (implicit-function-type — прим. пер.).


Чтобы интерпретировать эти новые выражения, мы должны предоставить реализации Mult для Int и String:


implicit val evalMult: Mult[Int] = new Mult[Int] {
  def mul(l: Int, r: Int): Int = l * r
}

implicit val printMult: Mult[String] = new Mult[String] {
  def mul(l: String, r: String): String = s"$l * $r"
}

Без какого либо дополнительного связывания реализации Exp и Mult автоматически комбинируются во время интепретации:


scala> tfm1[String]
res2: String = (7 + (-1 * 2))
scala> tfm1[Int]
res3: Int = 5
scala> tfm2[String]
res4: String = 7 * (8 + (-(1 + 2)))
scala> tfm2[Int]
res4: Int = 35

Десериализация


Продвинемся теперь к более сложной задаче десериализации. Целевой формат — это Json-подобная древовидная структура, определённая следующим образом:


sealed trait Tree
final case class Leaf(s: String) extends Tree
final case class Node(s: String, ts: List[Tree]) extends Tree

Преобразование выражений в этот Json-подобный формат не сложнее сериализации в String. В зависимости от того, где определены реализации Exp и Mult, их можно сгруппировать вместе:


implicit val toTree: Exp[Tree] with Mult[Tree] = new Exp[Tree] with Mult[Tree] {
  def lit(i: Int): Tree = Node("Lit", List(Leaf(i.toString)))
  def neg(t: Tree): Tree = Node("Neg", List(t))
  def add(l: Tree, r: Tree): Tree = Node("Add", List(l , r))
  def mul(l: Tree, r: Tree): Tree = Node("Mult", List(l , r))
}

scala> val tf1Tree = tf1[Tree]
tf1Tree: Tree = Node(Add,List(Node(Lit,List(Leaf(8))), Node(Neg,List(Node(Add,List(Node(Lit,List(Leaf(1))), Node(Lit,List(Leaf(2)))))))))

Для десериализации нам необходимо описать функцию fromTree для преобразования Json-подобных Tree в его конечную кодировку. Учитывая это, наши конечно закодированные значения — это функции типа [T] => Exp[T] => T (в Dotty это синтаксис для лямбда-функции типов ({type L[T] = Exp[T] => T})#L), нашей первой догадкой может быть определение fromTree как def fromTree[T](t: Tree)(implicit e: Exp[T]): Either[ErrMsg, T]:


type ErrMsg = String

def readInt(s: String): Either[ErrMsg, Int] = {
  import scala.util.{Try, Success, Failure}
  Try(s.toInt) match {
    case Success(i) => Right(i)
    case Failure(f) => Left(f.toString)
  }
}

def fromTree[T](t: Tree)(implicit e: Exp[T]): Either[ErrMsg, T] =
  t match {
    case Node("Lit", List(Leaf(n))) =>
      readInt(n).right.map(e.lit)

    case Node("Neg", List(t)) =>
      fromTree(t).right.map(e.neg)

    case Node("Add", List(l , r)) =>
      for(lt <- fromTree(l).right; rt <- fromTree(r).right)
      yield e.add(lt, rt)

    case _ => Left(s"Дерево некорректно $t")
  }

Это сработает, но так как T и Exp[T] должны быть полностью определены при вызове fromTree, полиморфизм утерян и результат fromTree может иметь единственную интерпретацию. Мы обойдём эту проблему, обернув результат, используя следующий подход:


trait Wrapped {
  def value[T](implicit e: Exp[T]): T
}

Конспект далее предлагает переписать fromTree с использованием новой сигнатуры: def fromTree(t: Tree): Either[ErrMsg, Wrapped], но, как я полагаю, они упустили из виду, что мы можем получить тот же результат определив реализацию Exp[Wrapped] и используя наш изначальный код для fromTree:


implicit val wrappingExp: Exp[Wrapped] = new Exp[Wrapped] {
  def lit(i: Int) = new Wrapped {
    def value[T](implicit e: Exp[T]): T = e.lit(i)
  }
  def neg(t: Wrapped) = new Wrapped {
    def value[T](implicit e: Exp[T]): T = e.neg(t.value)
  }
  def add(l: Wrapped, r: Wrapped) = new Wrapped {
    def value[T](implicit e: Exp[T]): T = e.add(l.value, r.value)
  }
}

Этого достаточно, чтобы получить полиморфизм первого класса


scala> fromTree[Wrapped](tf1Tree) match {
     |  case Left(err) =>
     |    println(err)
     |
     |  case Right(t) =>
     |    println(t.value[Int])
     |    println(t.value[String])
     |    println
     |}
5
(8 + (-(1 + 2)))

Но мы справились только с половиной задачи: нашему десериализатору всё ещё не хватает расширяемости. Для того, чтобы разрешить добавлять умножение пост-фактум, fromTree должна быть переписана в открыто-рекурсивном стиле. Это ещё одно из страшных имён для очень простой идеи: мы можем переписать все наши рекурсивные вызовы fromTree через дополнительный параметр recur:


// Заметьте, что `recur` и `fromTree _` одного типа!
def fromTreeExt[T]
  (recur: => (Tree => Either[ErrMsg, T]))
  (implicit e: Exp[T])
  : Tree => Either[ErrMsg, T] = {
    val e = implicitly[Exp[T]]
    tree => tree match {
      case Node("Lit", List(Leaf(n))) =>
        readInt(n).right.map(e.lit)

      case Node("Neg", List(t)) =>
        recur(t).right.map(e.neg)

      case Node("Add", List(l , r)) =>
        for(lt <- recur(l).right; rt <- recur(r).right)
        yield e.add(lt, rt)

      case t => Left(s"Дерево некорректно $t")
    }
  }

Рекурсия затем стягивается, используя оператор неподвижной точки (fix point operator — "прим. пер."):


def fix[A](f: (=> A) => A): A = f(fix(f))

def fromTree2[T: Exp](t: Tree): Either[ErrMsg, T] = fix(fromTreeExt[T] _)(t)

Таким образом, становится возможным определить десериализатор оператора произведения независимо, и "затянуть узел" ещё раз::


def fromTreeExt2[T]
  (recur: => (Tree => Either[ErrMsg, T]))
  (implicit e: Exp[T], m: Mult[T])
  : Tree => Either[ErrMsg, T] = {
    case Node("Mult", List(l , r)) =>
      for(lt <- recur(l).right; rt <- recur(r).right)
      yield m.mul(lt, rt)

    case t => fromTreeExt(recur).apply(t)
  }

def fromTree3[T: Exp : Mult](t: Tree): Either[ErrMsg, T] = fix(fromTreeExt2[T] _)(t)

Теперь мы можем протестировать нашу сериализацию и десериализацию для любого e, к примеру, используя:


assert(fromTreeN[String](e[Tree]) == Right(e[String]))

Заметьте, что в Scala эта реализация стэко-небезопасна. Нам понадобится добавить trampolining, чтобы использовать этот трюк с рекурсией для больших структур данных.


Преобразование


Мы увидели как преобразовывать наши математические выражения в различные другие представления, т.е. интерпретацию; как строить наши математические выражения из другого представления: десериализацию; но что насчёт преобразования математического выражения в другое математическое выражение?


Рассмотрим преобразование, которое опустит унарный минус в самый низ, к литералам, так чтобы 8 - (1 + 2) стало 8 + ((-1) + (-2)). Задача звучит просто для начальной (основанной на ADT) кодировки, сработала бы простая функция IExp => IExp


def pushNeg(e: IExp): IExp = e match {
  case Lit(_) => e
  case Neg(Lit(_)) => e
  case Neg(Neg(n)) => n
  case Neg(Add(l, r)) => Add(pushNeg(Neg(l)), pushNeg(Neg(r)))
  case Add(l, r) => Add(pushNeg(l), pushNeg(r))
}

Это выглядит невозможным в конечной кодировке. Сопоставление с образцом (pattern matching — прим. пер.) очень удобно для преобразований в определённыом контексте, как нам добиться аналогичного удобства внутри реализации Exp? Уловка в том, что вместо обработки Exp[T] как мы поступали прежде, работать с Exp[Ctx => T] в подходящем контексте. В данном случае контекст довольно прост: всё, что нам нужно знать — это нужно или нет изменить знак у текущего узла:


sealed trait NCtx
final case object PosCtx extends NCtx
final case object NegCtx extends NCtx

Преобразование выражается как Exp[NCtx => T]:


implicit def negDownExp[T](implicit e: Exp[T]): Exp[NCtx => T] = new Exp[NCtx => T] {
  def lit(i: Int): NCtx => T = {
    case PosCtx => e.lit(i)
    case NegCtx => e.neg(e.lit(i))
  }

  def neg(x: NCtx => T): NCtx => T = {
    case PosCtx => x(NegCtx)
    case NegCtx => x(PosCtx)
  }

  def add(l: NCtx => T, r: NCtx => T): NCtx => T =
    c => e.add(l(c), r(c))
}

Чтобы применить трансформацию, нужно сначала преобразовать выражение в фунцкию NCtx => T, а затем вызвать её с начальным контекстом:


scala> tf1[NCtx => String].apply(PosCtx)
(8 + ((-1) + (-2)))

Начальный контект также можно вынести в фунцкию:


scala> def pushNeg[T](e: NCtx => T): T = e(PosCtx)
pushNeg: [T](e: NCtx => T)T

scala> pushNeg(tf1[NCtx => String])
(8 + ((-1) + (-2)))

К несчастью, механизм вывода типов в scalac в этом случае требует явно определять внутренний параметр типа, что может выглядеть довольно некрасиво при композиции нескольких трансформаций: pushNeg(pushNeg(pushNeg(tf1[NCtx => NCtx => NCtx => String]))). Улучшения в механизме вывода типов в Dotty делают возможным писать просто pushNeg(pushNeg(pushNeg(tf1))): String, аналогично тому, как бы Вы написали в Haskell. Смотрите Dotty and types: the story so far для введения в механизм вывода типов в Dotty.


Это преобразование может быть естественным образом расширено для операции произведения с помощью определения дополнительной реализации Mult[NCtx => T]:


implicit def negDownMult[T](implicit e: Mult[T]): Mult[NCtx => T] = new Mult[NCtx => T] {
  def mul(l: NCtx => T, r: NCtx => T): NCtx => T = {
    case PosCtx => e.mul(l(PosCtx), r(PosCtx))
    case NegCtx => e.mul(l(PosCtx), r(NegCtx))
  }
}

scala> pushNeg(tfm1[NCtx => String])
(7 + 1 * (-2))

scala> pushNeg(tfm2[NCtx => String])
7 * (8 + ((-1) + (-2)))

Эта лекция продолжается ещё одним примером преобразования с использованием похожего трюка с введением контекста. Преобразования, которые мы видели до сих пор, были довольно изобретательными, и Вы можете задаться вопросом: всё ли, что можно записать с помощью конечной кодировки также может быть выражено в начальной кодировке, и наоборот. Эти два представления в действительности эквивалентны, что может быть продемонстрировано существованием биекции:



// Из кодировки классом типов в кодировку ADT
implicit def initialize: Exp[IExp] = new Exp[IExp] {
  def lit(i: Int): IExp = Lit(i)
  def neg(t: IExp): IExp = Neg(t)
  def add(l: IExp, r: IExp): IExp = Add(l, r)
}

// Из кодировки ADT в кодировку классом типов
def finalize[T](i: IExp)(implicit e: Exp[T]): T = i match {
  case Lit(l) => e.lit(l)
  case Neg(n) => e.neg(finalize[T](n))
  case Add(l, r) => e.add(finalize[T](l), finalize[T](r))
}

Объединение ограничений на классы типов с помощью типов неявных функций


Неявные функции — это недавнее добавление в компилятор Dotty. Идея в том, чтобы расширить синтаксис, доступный в настоящий момент, поддержкой функций с неявными аргументами. Как Вы, наверное, знаете, функции в Scala определены следующим образом:


trait Function1[A, B] {
  def apply(a: A): B
}

В компиляторе реализован синтаксический сахар, преобразующий типы A => B в Function1[A, B] и позволяет пользователям лаконично определять значения-функции. Неявные функции аналогичны: implicit A => B становится корректным типом, раскрывающимся в ImplicitFunction1[A, B]:


trait ImplicitFunction1[A, B] {
  def apply(implicit a: A): B
}

Определение функции, возвращающей тип неявной функции получает дополнительное преимущетство в дополнительном раскрытии, автоматически помещающим неявные параметры в область видимости:


def f: implicit Ctx => Unit = ???

Раскрывается в:


def f: implicit Ctx => Unit = { implicit $e1: Ctx => ???: Unit }

Синтаксический сахар, возможно, и не выглядит очень полезным в этом простом примере, но с таким синонимом типа уже становится более интересным:


type Contextualized[T] = implicit Ctx => T

def f: Contextualized[Unit] = ???

Если задействовано более одного неявного параметра, тип неявной функции позволяет сделать нечто, не вполне возможное ранее: абстрагироваться от неявных параметров.


type Constrained[T] = implicit (TC1[T], TC2[T], TC3[T]) => T

def f: Constrained[Int] = ???

Раскрывается в:


def f: Constrained[Int] = { ($e1: TC1[Int], $e2: TC2[Int], $e3: TC3[Int]) =>
  implicit $e4: TC1[Int] = $e1
  implicit $e5: TC1[Int] = $e2
  implicit $e6: TC1[Int] = $e3
  ???: Int
}

Возвращаясь к нашей конечной кодировке математических выражений, неявные типы функций в Dotty дают возможность расширить кодировку с минимальным синтаксическим оверхедом:


type Ring[T] = implicit (Exp[T], Mult[T]) => T

def tfm1[T]: Ring[T] = add(lit(7), neg(mul(lit(1), lit(2))))
def tfm2[T]: Ring[T] = mul(lit(7), tf1)

Так завершается наше возвращение к Неразмеченным Конечным интерпретаторам (смотри здесь все (Scala 2.11) фрагменты кода из этой статьи)!


Если вам хочется узнать больше о Неразмеченных Конечных интерпретаторах, я настоятельно рекомендую продолжить изучение разделов 3 и 4 конспекта для кодировки просто типизированного лямбда-исчисления.

Поделиться с друзьями
-->

Комментарии (13)


  1. sshikov
    06.04.2017 19:40

    Это перевод?


    1. Odomontois
      06.04.2017 20:40

      Да. Есть способ конвертировать?


      1. sshikov
        06.04.2017 20:47

        Не уверен. Лично меня скорее язык смутил — невооруженным глазов местами видно, что текст был изначально не на русском )))


        1. Odomontois
          06.04.2017 20:52

          Пытался русифицировать, получалось только с потерей смысла.


  1. NaHCO3
    06.04.2017 21:55

    А зачем нужна вот такая штука:

    {type L[T] = Exp[T] => T})#L

    когда есть

    (Exp[T] => T) forSome {type T}?

    Лямбды используются, когда хотят частично применить какой-нибудь тип, а здесь этого не наблюдается.


    1. Googolplex
      06.04.2017 23:33
      +3

      Типы, которые вы написали, не эквивалентны. Первое — это тип второго порядка, т.е. с сигнатурой * -> *. Второе — это конкретный (первого порядка) экзистенциальный тип, т.е. с сигнатурой просто *. Грубо говоря, первый тип вы можете применить к какому-то типу и получить новый тип, второй же применять не к чему, он уже конкретный, хоть и экзистенциальный. Вот этот плагин к компилятору: https://github.com/non/kind-projector позволяет записывать такие типы удобнее.


    1. Odomontois
      07.04.2017 00:08
      +1

      Данный тип говорит о том, что значение полиморфно.
      Т.е. для любого типа T мы можем получить Exp[T] => T.
      Тип (Exp[T] => T) forSome {type T} означает, что существует такой T, для которого мы получили Exp[T] => T. Просто забыли, для какого.
      Разница у этих двух определений — в квантификаторе. для любого vs существует
      Строго говоря, в scala нет понятия типа полиморфной функции, поэтому эта type lambda просто для понимания проблемы, стоящей перед нами в текущий момент


  1. OlegZH
    07.04.2017 11:45

    Чрезвычайно интересно!

    А что нужно знать и уметь, чтобы это всё можно было попробовать в деле?

    Меня самого интересуют подходы к построению типов данных. (Будут интересовать в будущем. Надеюсь, в очень ближайшем.) Я очень хочу овладеть Хаскелом. Могу ли я сконцентрироваться только на нём (пока), без обращения к Скала и т.п. вещам?

    И ещё. Что означает в Вашей статье слово «закодировано»? А то я, от недостатка чего-то (знаний, что ли?) могу что-то в Вашей статье не так понять, и моё воображение рисует мне картину, в которой, вместо железной логики обыкновенных типов данных, «зашитой» в компилятор и непосредственно опирающийся на процессор, имеются какие-то полусимволические представления, которые надо ещё дополнительно интерпретировать во время исполнения (наверное, их можно как-то интерпретировать ещё во время компиляции или ещё на этапе создания кода; я, даже, не знаю, что ещё и думать). Если то, что описываете Вы, как-то похоже на то, что рисуется мне, то, наверное, вполне возможно, будет вести речь о такой штуке. Допустим, мы реализуем систему научных расчётов (типа MATLAB) с участием матриц. Тогда, если мы описываем какой-то матричный алгоритм с участием матричного умножения и операции обращения матрицы, то мы сможем описывать этот алгоритм именно в его обобщённом матричном виде, не опираясь на какую-то реализацию каждой операции, а иметь схему вычислений, в которую можно вставить (в зависимости от контекста) или расширить (в Вашей терминологии?) подходящую реализацию того же умножения и/или обращения.

    Другая сторона этого вопроса — это возможность представить сам программный код в виде текста, где, собственно, код и данные оказываются сильно перемешанными и, по сути, самоинтерпретируемыми. Раз так, то, вообще, можно смешать программный и код и текст (на естественном языке), иметь некую единую «кодовую таблицу», а оперативную память воспринимать как определённым образом структурированную базу данных, которую надо единожды создать при загрузке ОС (без необходимости её перераспределять в процессе работы)… В этом смысле, различным традиционным приложениям будут соответствовать наборы расширений для интерпретаторов системных типов. Получается, что ОС может оказаться единым адресным пространством для типов данных, и тогда слово «операционная» следует понимать буквально: как реализацию некоторой системы алгебр, что делает все выполняемые действия: а) проверяемыми; б) обратимыми; и в) доказательными…

    Вот, чтобы таких фантазий было поменьше, хотелось бы узнать, что нужно в первую очередь изучить чтобы правильно понимать, то, что нужно понимать правильно.


    1. Odomontois
      07.04.2017 11:53

      Статья является фактически переложением лекции Олега Киселёва на Haskell. Поэтому, используя Haskell, вы несомненно сможете опробовать все эти практики и даже с меньшими синтаксическими накладками.


      Ваше воображение рисует вам правильную картину. Основная задача — построить какое-то синтаксическое дерево для предметной области, которое можно по-разному интерпретировать в зависимости от ситуации.


      1. sshikov
        08.04.2017 18:04
        +1

        Тут дело не совсем в хаскеле (хотя изначально идея скорее всего оттуда). Дело в том, что dotty — это вероятное будущее скалы, которое интересно попробовать.


    1. NaHCO3
      07.04.2017 14:03
      +1

      > А что нужно знать и уметь, чтобы это всё можно было попробовать в деле?

      Данная статья исчерпывающая.

      > Я очень хочу овладеть Хаскелом. Могу ли я сконцентрироваться только на нём (пока), без обращения к Скала и т.п. вещам?

      Да, конечно. Я вообще не понимаю людей, которые из скалы делают хаскель. Ведь есть же уже один. Из хаскеля хаскель получается даже лучший, чем из скалы.

      Всё очень просто. Вместо типа данных передаётся полиморфная функция. Тип данных — это стандартное предописанное представление. Функция — это код, который собирает нужную структуру на лету. В функциональных языках вполне себе можно передавать функцию как аргумент. Каждый раз, когда вы обращаетесь к данным, закодированным в функцию, вызывается эта функция и даёт вам ответ.

      Можно задать себе вопрос, а зачем такие сложности, зачем передавать функцию, которую ещё нужно исполнить, вместо готового результата? Фишка в том, что функцию полиморфная. В разных контекстах (разных наследниках одного и того же класса), она строит фактически разные структуры. Ну вроде можно как три кирпича в ряд положить, или коржика для тортика. Всё это актуально потому что, голые структуры никому не интересны. Обычно вычисляется какая-то функция от них. Иногда свёртка в строчку или число: toString, toInt. Иногда на базе одной структуры вычисляется другая. Тогда мы можем склеить вычисления структуры-аргумента и функции от неё. В этом случае оверхед на представление структуры как кода, который её генерирует уже не так высок.

      Впрочем, если структура нас интересует больше как структура, чем источник вычисления, то не обойтись без её канонического «начального» представления. Например, если мы хотим её сериализовать и передать по сети. Потому как начальное представление — это частный случай сериализации. И тогда уже нет никакой разницы, какое представление использовать. Потому что полифорфизм — это не исключительное свойство конечного представления. Полимформизм бывает и для данных. Открытая рекурсия для данных тоже есть, и конструктор Fix тоже.

      Конечное представление — это просто такой фокус, способ переключить внимание. Как паттерн visitor в ООП.

      > Допустим, мы реализуем систему научных расчётов (типа MATLAB) с участием матриц.

      Тогда не нужен оверхед на конечное представление данных. Используйте просто полиморфные типы без извращений в функциональном стиле.

      > Вот, чтобы таких фантазий было поменьше, хотелось бы узнать, что нужно в первую очередь изучить чтобы правильно понимать, то, что нужно понимать правильно.

      Прям даже не знаю. Всё так взаимосвязано, а понимание приходит само, от сравнения разных подходов и методик. Чем больше знаешь разных концепций, тем лучше понимаешь одну конкретную в сравнении с остальными. Могу только посоветовать стандартный университетский курс.


      1. sshikov
        08.04.2017 18:09

        Из хаскеля хаскель получается даже лучший, чем из скалы.

        Ну это как раз просто понять. Scala — это jvm. Это далеко не только язык, и хаскель его не заменяет и вероятно никогда полностью не заменит.


        Хотя в контексте ответа на конкретный вопрос вы несомненно правы, если хочется овладеть хаскелем, нужно овладевать хаскелем. Если хочется овладеть ФП — то вполне можно и скалой. Практических применений возможно будет и больше (с учетом спарка и т.п.).


    1. sshikov
      08.04.2017 18:14

      "Закодировано" это encoding, и у него в английском несколько другой основной смысл, нежели получается в русском. И ваше понимание вполне близко к истине. Речь по сути идет от широко известной expression problem, и варианте ее решения.