Февраль 05, 2017
Идиомы и механизмы абстракции функционального программирования. Функторы

Введение

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

Если для объектно-ориентированных языков это классы, наследование, полиморфизм, инкапсуляция, делегирование и шаблоны проектирования, где все это используется, то для функциональных языков это шаблоны высшего порядка (generics of higher kind), типы, зависимые от пути (path-dependent types), классы типов (type classes) и идиомы, которые их используют: ковариантный функтор, аппликативный функтор, монада, зависимая функция, моноид, группа и т.п.

Абстракции были перенесены в языки функционального программирования из разных разделов математики. В частности, из возникшего в середине 20-го века нового раздела - "теории категорий". Классификация идиом (приведена только часть из всех существующих) по названиям разделов математики с примерами механизмов Scala:

Идиомы FP Механизмы Scala Библиотеки Scala Разделы математики
Covariant functor, applicative functor, monad, arrow Type classes, generics of higher kind Scalaz, Cats Теория Категорий
Dependent pair, dependent function Path-dependent types Shapeless Математическая логика
Моноид, группа, поле, кольцо Type classes, generics of higher kind Algebird, Spire Алгебра
  • Generics of higher king применяют для построения повторно используемой абстракции (например, ковариантный функтор). В ООП таком случае создают родительский тип.
  • Type classes используют для того, чтобы применить абстракцию к коду. Например, пользовательский класс становится ковариантным функтором. В ООП в таком случае наследуются от абстрактного родительского класса.
  • Path-dependent types позволяют объявлять вложенные типы и ссылаться на них через "пути". В ООП для этого используются модули.

Есть несколько подходов к определению идиом функционального программирования. В данной статье будет использоваться "синтаксический" подход, т.е., подход, основанный на том, что некая идиома это тип с определенным методом (или методами). Этот подход удобен еще и тем, что позволяет свести в общую схему многие категориальные конструкции. Примеры кода будут приводиться для языка Scala. В статье рассмотрены понятия ковариантного, контравариантного и инвариантного функтора.

Методы различных абстракций (единственные требуемые или одни из требуемых):


trait X[T] {
  // ковариантный функтор
  def map[R](f: T => R): X[R]

  // контравариантный функтор
  def contramap[R](f: R => T): X[R]

  // инвариантный функтор
  def xmap[R](f: (T => R, R => T)): X[R]

  // аппликативный функтор
  def apply[R](f: X[T => R]): X[R]

  // монада
  def flatMap[R](f: T => X[R]): X[R]

  // комонада
  def coflatMap[R](f: X[T] => R): X[R]
}

Ковариантный функтор

Ковариантным функтором является тип X, имеющий type parameter T, с методом map, имеющим следующую сигнатуру:


trait X[T] {
  def map(f: T => R): X[R]
}

Примеры ковариантного функтора:


// Option

import java.lang.Integer.toHexString

val i: Option[Int] = Map("a" -> 1, "b" -> 2).get("c")
val s: Option[String] = i map toHexString


// Future

import java.lang.Integer.toHexString
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future

def mult: Int = 11 * 21

val i: Future[Int] = Future(mult)
val s: Future[String] = i map toHexString


// List

import java.lang.Integer.toHexString

val i: List[Int] = List(1, 2, 3)
val s: List[String] = i map toHexString

Ковариантный функтор - это как бы контейнер. Например, Option - это контейнер, где что-то может лежать (Some), а может и нет (None). List - это контейнер, в котором может быть от 0 до n элементов. Но точнее будет сказать, что ковариантный функтор - половина контейнера. Та половина, которая отвечает за извлечение данных, так как нету явного метода "put" для того, чтобы положить данные в контейнер.

Для того, чтобы являться ковариантным функтором, кроме наличия метода с определенной сигнатурой, тип должен соответствовать двум правилам: Identity Law и Composition Law.

Identity Law: для любого ковариантного функтора "func" должно выполняться правило, что IdentityLaw.case0(func) - это то же самое, что и IdentityLaw.case1(func) в следующем примере кода:


object IdentityLaw {
  def case0[T](func: Functor[T]): Functor[T] = identity(func)
  def case1[T](func: Functor[T]): Functor[T] = func.map(identity)
}

identity — это единичная функция из scala.Predef (полиморфная тождественная функция):


def identity[A](x: A): A = x

Таким образом, вызов func.map(identity) ничего не должен менять внутри функтора. Например, если контейнер хранит счетчик и увеличивает его при каждом вызове метода map, то этот контейнер не является ковариантным функтором.

Composition Law: для любого ковариантного функтора "func[T]" и любых функций "f" и "g" должно выполняться правило, что CompositionLaw.case0(func) - это то же самое, что и CompositionLaw.case1(funс):


object CompositionLaw extends App {
  def case0[T, R, Q](func: Functor[T], f: T => R, g: R => Q): Functor[Q] =
    (func map f) map g
  def case1[T, R, Q](func: Functor[T], f: T => R, g: R => Q): Functor[Q] =
    func map (f andThen g)
}

То есть, вызов "map" сначала с функцией "f", потом с функцией "g" должен быть эквивалентен тому, что мы вызываем "map" с функцией-композицией функций "f" и "g".

Рассмотрим, как мог бы выглядеть метод "map" у связного списка:


sealed trait LinkedList[+T] {
  def map[R](f: T => R): LinkedList[R]
}

case object Empty extends LinkedList[Nothing] {
  def map[R](f: Nothing => R): LinkedList[R] = this
}

case class MyList[+T](head: T, tail: LinkedList[T]) extends LinkedList[T] {
  def map[R](f: T => R): LinkedList[R] = MyList(f(head), tail.map(f))
}

val sampleList = MyList(5, MyList(10, Empty))

// Identity Law

println(identity(sampleList) == sampleList.map(identity)) // true

// Composition Law

def f(i: Int) = i + 2
def g(i: Int) = i * 3
val composition = f _ andThen g
val result1 = sampleList.map(f).map(g)
val result2 = sampleList.map(composition)

println(result1 == result2) // true

Контравариантный функтор

Контравариантным функтором является тип X, имеющий type parameter T, с методом, условно называемым contramap, имеющим сигнатуру:


trait X[T] {
  def contramap[R](f: R => T): X[R]
}

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


// Ordering

import scala.math.Ordering._

val strOrd: Ordering[String] = String // scala.math.Ordering.String
val f: (Int => String) = _.toString
val intOrd: Ordering[Int] = strOrd on f // "on" в качестве метода "contramap"

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


// Writes

import play.api.libs.json._
import play.api.libs.functional.syntax._

case class User(id: Int, name: String)

implicit val userWrites: Writes[User] = 
  (JsPath \ "name").write[String].contramap(_.name)

Зная, как представить строку в json и имея функцию преобразования объекта пользователя в строку, мы получаем способ представления объекта пользователя в json.

Контравариантный функтор должен соответствовать двум правилам: Identity Law и Composition Law.

Identity Law: для любого контравариантного функтора "cofunc" должно выполняться правило, что IdentityLaw.case0(cofunc) - это то же самое, что и IdentityLaw.case1(cofunc) в следующем примере кода:


object IdentityLaw {
  def case0[T](cofunc: Contravariant[T]): Contravariant[T] = identity(cofunc)  
  def case1[T](cofunc: Contravariant[T]): Contravariant[T] = cofunc.contramap(identity)
}

То есть, вызов cofunc.contramap(identity) ничего не меняет внутри контравариантного функтора.

Composition Law: для любого контравариантного функтора "cofunc[T]" и произвольной пары функций f: Q => R и g: R => T должно выполняться правило, что CompositionLaw.case0(cofunc) - это то же самое, что и CompositionLaw.case1(cofunс):


object CompositionLaw {
  def case0[Q, R, T](cofunc: Contravariant[T], f: Q => R, g: R => T): Contravariant[Q] =
    (cofunc contramap g) contramap f
  def case1[Q, R, T](cofunc: Contravariant[T], f: Q => R, g: R => T): Contravariant[Q] =
    cofunc contramap (f andThen g)
}

То есть, последовательный вызов "contramap" с каждой функцией должен быть эквивалентен вызову "contramap" с инвертированной композицией этих функций.

Для примера рассмотрим простой encryptor, который получает из объектов различных типов строку-хэш:


import org.apache.commons.codec.digest.DigestUtils

trait Encryptor[-T] {
  outer =>

  def encrypt(x: T): String

  def contramap[R](f: R => T): Encryptor[R] = new Encryptor[R] {
    def encrypt(x: R): String = outer.encrypt(f(x))
  }
}

object StringEncryptor extends Encryptor[String] {
  def encrypt(x: String): String = DigestUtils.sha1Hex(x)
}

val int2str: (Int => String) = _.toString
val intEnc = StringEncryptor.contramap(int2str)

// Identity Law

val result01 = identity(intEnc).encrypt(2017)
val result02 = intEnc.contramap[Int](identity).encrypt(2017)

println(result01 == result02) // true

// Composition Law

case class Triangle(a: Int, h: Int)

def f(t: Triangle) = 0.5f * t.a * t.h // Triangle => Float
def g(f: Float) = math.round(f) // Float => Int

val composition = f _ andThen g
val result11 = intEnc.contramap(g).contramap(f).encrypt(Triangle(5, 7))
val result12 = intEnc.contramap(composition).encrypt(Triangle(5, 7))

println(result11 == result12) // true

Инвариантный функтор

Если ковариантный функтор - это источник данных, а контравариантный функтор - это приемник данных, то инвариантный функтор совмещает в себе оба этих качества. Используя синтаксический подход, можно сказать, что инвариантный (экспоненциальный) функтор - это тип X, имеющий type parameter T, с методом, условно называемым xmap, имеющим сигнатуру:


trait X[T] {
  def xmap[R](f: T => R, g: R => T): X[R]
}

То есть, для получения функтора X[R] нужно предоставить отображения "f" и "g", которые будут применяться к данным на входе и на выходе.

В качестве примера инвариантного функтора можно привести Format из play.api.libs.json:


// Format

import play.api.libs.json._
import play.api.libs.functional.syntax._

case class User(name: String)

implicit val userFormat: Format[User] =
  (JsPath \ "name").format[String].inmap(User, _.name)

Передав функции для создания объекта пользователя из строки и обратно, мы получаем экземпляр класса Format[User], который объединяет в себе и сериализатор, и десериализатор. В качестве метода "xmap" здесь выступает "inmap".

Инвариантный функтор также должен соответствовать правилам Identity Law и Composition Law.

Identity Law: для любого инвариантного функтора "infunc" должно выполняться правило, что IdentityLaw.case0(infunc) - это то же самое, что и IdentityLaw.case1(infunc) в следующем примере кода:


object IdentityLaw {
  def case0[T](infunc: Invariant[T]): Invariant[T] = infunc
  def case1[T](infunc: Invariant[T]): Invariant[T] = infunc.xmap(x => x, x => x)
}

Смысл правила уже известен: infunc.xmap(identity) никак не меняет функтор.

Composition Law: для любого инвариантного функтора "infunc[T]" и произвольных функций f1: T => R, g1: R => T, f2: R => Q, g2: Q => R должно выполняться правило, что CompositionLaw.case0(fun, f1, g1, f2, g2) - это то же самое, что и CompositionLaw.case1(fun, f1, g1, f2, g2):


object CompositionLaw {
  def case0[T, R, Q](infunc: Invariant[T], f1: T => R, g1: R => T, f2: R => Q, g2: Q => R): Invariant[Q] =
    infunc.xmap(f1, g1).xmap(f2, g2)
  def case1[T, R, Q](infunc: Invariant[T], f1: T => R, g1: R => T, f2: R => Q, g2: Q => R): Invariant[Q] =
    infunc.xmap(f2 compose f1, g1 compose g2)
}

Последовательный вызов "xmap" сначала с функциями "f1" и "g1", затем с функциями "f2" и "g2" должен быть эквивалентен вызову "xmap" с композицией функций "f1" и "f2" и с инвертированной композицией функций "g1" и "g2".

В качестве примера возьмем простейший из контейнеров — value holder:


trait Holder[T] { self =>
  def put(arg: T): Unit
  def get: T
  def xmap[R](f: T => R, g: R => T): Holder[R] = new Holder[R] {
    override def put(arg: R): Unit = self.put(g(arg))
    override def get: R = f(self.get)
  }
}

class StringHolder(var value: Option[String] = None)
  extends Holder[Option[String]] {
  override def put(arg: Option[String]): Unit = {value = arg}
  override def get: Option[String] = value
}

val f: Option[String] => Option[Int] =
  x => x.map(Integer.parseInt(_, 16))
val g: Option[Int] => Option[String] =
  x => x.map(Integer.toHexString)

val strHolder = new StringHolder
val intHolder = strHolder.xmap(f, g)

// Identity Law

intHolder.put(Some(2017))
val result1 = intHolder.get
val result2 = intHolder.xmap[Option[Int]](x => x, x => x).get

println(result1 == result2) // true

// Composition Law

val f2: Option[Int] => Option[BigDecimal] =
  x => x.map(BigDecimal(_) / 2) // из Int в BigDecimal / 2
val g2: Option[BigDecimal] => Option[Int] =
  x => x.map(d => (d * 2).intValue) // из BigDecimal в Int * 2

val composition1 = f2 compose f
val composition2 = g compose g2

val holder1 = strHolder.xmap(f, g).xmap(f2, g2)
val holder2 = strHolder.xmap(composition1, composition2)

holder1.put(Some(3.5))
holder2.put(Some(3.5))

println(holder1.get == holder2.get) // true

Правила, которым должен следовать инвариантный функтор, не разрешают менять состояние функтора внутри операции "xmap", но это можно делать, например, в "put" и "get" операциях. Поэтому, мы можем добавить логирование, либо другой дополнительный функционал в данных методах.

Описанные в статье понятия являются базой для понимания остальных абстракций из теории категорий, которые являются композициями или более специализированными конструкциями на основе описанных функторов. Например, полугруппа - это инвариантный функтор, а монада - это ковариантный функтор.

На момент написания статьи, версия Scala 2.12.1.

Полезные ссылки:
https://habrahabr.ru/company/golovachcourses/blog/266905/

Добавить комментарий:
Комментариев: (0)
Опубликовать