FP на Scala: Что такое функтор?
Специалист, приступающий к изучению функционального программирования, сталкивается как с неоднозначностью и запутанностью терминологии, так и с постоянными ссылками на «серьезную математику».
В этой статье, не используя теорию категорий с одной стороны и эзотерические языковые механизмы Scala с другой стороны, рассмотрены два важнейших понятия
- ко-вариантный функтор
- контра-вариантный функтор
которые являются стартовой точкой для понимания всего множества категориальных конструкций, куда можно включить
- Exponential (Invariant) Functor, BiFunctor, ProFunctor
- Applicative Functor, Arrow, Monad / Co-Monad
- Monad Transformers, Kleisli, Natural Transformations
Объяснено происхождение категориальной терминологии, указана роль языковых механизмов в реализации категориальных абстракций и рассмотрено несколько ковариантных (Option, Try, Future, List, Parser) и контравариантных (Ordering, Equiv) функторов из стандартной библиотеки Scala.
Если Вы желаете сильнее погрузиться в мир Scala, математики и функционального программирования — попробуйте онлайн-курс «Scala for Java Developers» (видео + тесты, всего за 25% цены!).
Про языковые механизмы абстракции
Погружение во всякий принципиально новый язык программирования включает изучение:
- Языковых механизмов для новых типов абстрагирования.
- Типичных идиом/шаблонов, в которых эти типы абстрагирования используется.
Пример: в ООП изучаются понятия класса, экземпляра, наследования, полиморфизма, инкапсуляции, делегирования,… и шаблоны проектирования GoF, в которых все это разнообразие механизмов абстрагирования используется.
На мой взгляд, основная проблема с переходом Java => Scala заключается в том, что программисты не учат новые механизмы абстракции (generics of higher kind, path dependent types, type classes, macroses, ...) так как не понимают к чему их применить.
А не могут начать применять потому, что как только начинает идти речь про «предметы абстрагирования» (функтор, монада, моноид, зависимые типы, ...) — появляются теоретики из современной математики (теория категорий, абстрактная алгебра, математическая логика) и одним махом «опрокидывают все фигуры с доски». С точки зрения программистов математики зачастую ведут себя как фанатики секты Свидетелей Пришествия Категорий/ГомотопическойТеорииТипов/ИсчисленияКонструкций: вместо того, что бы говорить «на нашем языке» и привести конкретные примеры из области программирования, они сыпят терминами из абстрактной математики и отсылают к своим же Священным Текстам.
В этой статье разобраны функторы (ковариантный и контравариантный) без обращения к теории категорий и на основе только базовые возможности Scala. Не используются type classes и generics of higher kind (как это делают, например, авторы библиотек Scalaz: scala.scalaz.Functor + scala.scalaz.Contravariant, Cats: scala.cats.Functor + cats.functor.Contravariant, Algebird: com.twitter.algebird.Functor). Обратите внимание, часто в названиях типов, соответствующих идиомам covariant functor и contravariant functor, используют сокращенные названия — Functor и Contravariant.
Вообще говоря, функциональное программирование на Scala (уровня L2-L3) — это смещение относительно Java в нескольких направлениях (я вижу три). При этом смещение характеризуется одновременно четырьмя «компонентами»:
- Новыми шаблонами/идиомами/перлами программирования.
- Новыми языковыми механизмами Scala для реализации этих идиом.
- Новыми библиотеками Scala с реализацией идиом на основе языковых механизмов.
- Новыми разделами математики, которые послужили источником для ключевых идей идиомы.
Надо отметить, что
- Изучение идиом — обязательно (это и есть «ядро FP»).
- Изучение языковых механизмов абстракции — требуется в production mode для реализации идиом, приспособленных к повторному использованию.
- Изучение типичных функциональных библиотек Scala — желательно в production mode для повторного использования уже написанных и отлаженных идиом.
- Изучение соответствующего раздела математики не является обязательным для понимания или использования идиом.
Как минимум можно выделить «три смещения»: категориальное, алгебраическое, логическое (по названиям разделов математики)
Идиомы 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 служат для «применения абстракции» к Вашему коду (класс пользователя «становится» ковариантным функтором). В ООП, в таком случае, обычно наследуются от предка-абстракции.
Наши примеры не будут использовать generics of higher king + type classes и потому не будут приспособлены к повторному использованию (а «старые добрые трюки ООП» тут не особенно подходят). Но даже не будучи готовыми к повторному использованию наши примеры хорошо продемонстрируют суть идиом.
Про теорию категорий и Haskell
В середине 20-го века возник новый раздел математики — теория категорий (отмечу, что сами математики часто называют его «абстрактная чепуха»). Теория категорий произошла из общих идей/конструкций, которые широко используются во многих фундаментальных разделах математики (теория множеств, топология, функциональный анализ, ...) и в данный момент претендует на место основания/базы всей математики (вытесняя теорию множеств, на которой строили математику с начала 20-го века).
Но если теория множеств акцентирует внимание на самих множествах (элементы, операции над множествами, мощность множества, множества со структурой (упорядоченные множества, частично упорядоченные множества), ...) и отображения множеств (функции из множества в множество) находятся на втором плане, то в теории категорий основой являются категории, а, упрощенно, категория = множество + отображения. Отображение — это синоним функции (точнее, отображение = соответствие элементам области определения элементов области значений без указания непосредственно процедуры «перехода» от аргументов к значениям, отображение = функция заданная таблично, отображение = «внешний интерфейс» функции в смысле f: A => B без указания «внутренней реализации» (тела функции)), и вот тут оказывается, что такой акцент на функциях как таковых очень важен для функционального программирования.
Концентрация на отображениях породила богатые функциональные абстракции (функторы, монады, ...) и эти абстракции были перенесены в языки функционального программирования (наиболее известны реализации в Haskell). Рассвет Scala (2005-2010) смещен на 15 лет относительно рассвета Haskell (1990-1995) и многие вещи просто перенесены уже готовыми из Haskell в Scala. Таким образом, для Scala-программиста важнее разбираться с реализациями в Haskell, как основным источником категориальных абстракций, а не в самой теории категорий. Это связано с тем, что при переносе теория категорий => Haskell были видоизменены, утрачены или добавлены множество важных деталей. Важных для программистов, но второстепенных для математиков.
Вот пример переноса:
- Теория категорий:
- Haskell:
- Scala (библиотека Scalaz)
Что такое ковариантный функтор
Одни авторы рекомендуют считать, что Covariant Functor — это контейнер (если быть более точным, то ковариантный функтор — это скорее «половина контейнера»). Предлагаю запомнить эту метафору, но относится к ней именно как к метафоре, а не определению.
Другие, что Covariant Functor — это «computational context». Это продуктивный подход, но он полезен, когда мы уже в полной мере освоили понятие и стараемся «выжать из него максимум». Пока игнорируем.
Третьи предлагают «более синтаксический подход». Covariant Functor — это некий тип с определенным методом. Метод должен соответствовать определенным правилам (двум).
Я предлагаю использовать «синтаксический подход» и использовать метафору контейнера/хранилища.
С точки зрения «синтаксического подхода», ковариантным функтором является всякий тип (назовем его X) имеющий type parameter (назовем его T) с методом, который имеет следующую сигнатуру (назовем его map)
trait X[T] {
def map(f: T => R): X[R]
}
Важно: мы не будем наследоваться от этого trait-а, мы будем искать похожие типы.
«Синтаксический подход» хорош тем, что он позволяет свести в общую схему многие категориальные конструкции
trait X[T] {
// covariant functor (functor)
def map[R](f: T => R): X[R]
// contravariant functor (contravariant)
def contramap[R](f: R => T): X[R]
// exponential functor (invariant functor)
def xmap[R](f: (T => R, R => T)): X[R]
// applicative functor
def apply[R](f: X[T => R]): X[R]
// monad
def flatMap[R](f: T => X[R]): X[R]
// comonad
def coflatMap[R](f: X[T] => R): X[R]
}
Важно: указанные тут методы для некоторых абстракций являются единственными требуемыми (covariant / contravariant / exponential functors), а для других (applicative functor, monad, comonad) — одним из нескольких требуемых методов.
Примеры ковариантных функторов
Те, кто уже начали программировать на Scala (или на Java 8), тут же могут назвать множество «типов-контейнеров», которые являются ковариантными функторами:
Option
import java.lang.Integer.toHexString
object Demo extends App {
val k: Option[Int] = Option(100500)
val s: Option[String] = k map toHexString
}
или чуть ближе к жизни
import java.lang.Integer.toHexString
object Demo extends App {
val k: Option[Int] = Map("A" -> 0, "B" -> 1).get("C")
val s: Option[String] = s map toHexString
}
Try
import java.lang.Integer.toHexString
import scala.util.Try
object Demo App {
val k: Try[Int] = Try(100500)
val s: Try[String] = k map toHexString
}
или чуть ближе к жизни
import java.lang.Integer.toHexString
import scala.util.Try
object Demo extends App {
def f(x: Int, y: Int): Try[Int] = Try(x / y)
val s: Try[String] = f(1, 0) map toHexString
}
Future
import java.lang.Integer.toHexString
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
object Demo extends App {
val k: Future[Int] = Future(100500)
val s: Future[String] = k map toHexString
}
или чуть ближе к жизни
import java.lang.Integer.toHexString
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
object Demo extends App {
def calc: Int = (0 to 1000000000).sum
val k: Future[Int] = Future(calc)
val s: Future[String] = k map toHexString
}
List
import java.lang.Integer.toHexString
object Demo extends App {
val k: List[Int] = List(0, 42, 100500)
val s: List[String] = k map toHexString
}
Parser
import java.lang.Integer.toHexString
import scala.util.parsing.combinator._
object Demo extends RegexParsers with App {
val k: Parser[Int] = """(0|[1-9]\d*)""".r ^^ { _.toInt }
val s: Parser[String] = k map toHexString
println(parseAll(k, "255"))
println(parseAll(s, "255"))
}
>> [1.4] parsed: 255
>> [1.4] parsed: FF
В целом метафора ковариантного функтора как контейнера, судя по примерам, работает. Действительно
- Option — «как бы контейнер» на один элемент, где может что-то лежит (Some), а может и нет (None).
- Try — «как бы контейнер» на один элемент, где может что-то лежит (Success), а может и нет (Failure, точнее вместо элемента лежит исключение).
- Future — «как бы контейнер» на один элемент, где может что-то уже лежит, может будет лежать, или уже лежит исключение, или будет лежать исключение или никогда ничего лежать не будет.
- List — контейнер, в котором может быть 0...N элементов
- Parser — тут чуть сложнее, в нем ничего не «хранится», Parser — это способ извлекать данные из строки. Тем не менее, Parser — это источник данных и в этом он похож на контейнер.
Ковариантный функтор — это не просто наличие метода с определенной сигнатурой, это также выполнение двух правил. Математики тут обычно отсылают к теории категорий, и говорят, что эти правила — следствие того, что функтор — это гомоморфизм категорий, то есть отображение категории в категорию, сохраняющее их структуру (а частью структуры категории являются единичный элемент-стрелка (правило Identity Law) и правила композиции стрелок (правило Composition law)).
Такой подход, в целом, непродуктивен в программировании. Считайте, что в функциональном программировании эти два правила необходимы для возможности трансформировать программы с сохранением функциональности (обычно с целью оптимизации).
Ковариантный функтор: Identity Law
Для любого ковариантного функтора 'fun' должно тождественно выполняться следующее правило IdentityLaw.case0(fun) — то же самое что и IdentityLaw.case1(fun).
object IdentityLaw {
def case0[T](fun: Functor[T]): Functor[T] = identity(fun)
def case1[T](fun: Functor[T]): Functor[T] = fun.map(identity)
}
где identity — это полиморфная тождественная функция (единичная функция) из Predef.scala
object Predef ... {
def identity[A](x: A): A = x
...
}
Совсем кратко — fun.map(identity) ничего не должно менять внутри функтора.
Значит контейнер, который хранит версию и увеличивает ее при каждом отображении — не соответствует высокому званию ковариантного функтора
// Это - НЕ ковариантный функтор
class Holder[T](value: T, ver: Int = 0) {
def map[R](f: T => R): Holder[R] = new Holder[R](f(value), ver + 1)
}
так как он «подсчитывает» количество операций отображения (даже отображения функцией identity).
А вот такой код — соответствует первому правилу функтора (и второму тоже).
// Это - ковариантный функтор
class Holder[T](value: T, ver: Int = 0) {
def map[R](f: T => R): Holder[R] = new Holder[R](f(value), ver)
}
Тут версия просто прикреплена к данным и неизменно сопровождает их при отображении.
Ковариантный функтор: Composition Law
Для любого ковариантного функтора 'fun[T]' и любых функций 'f: ' и 'g' должно тождественно выполняться следующее правило CompositionLaw.case0(fun) — то же самое что и CompositionLaw.case1(fun).
object LawСompose extends App {
def case0[T, R, Q](fun: Functor[T], f: T => R, g: R => Q): Functor[Q] =
(fun map f) map g
def case1[T, R, Q](fun: Functor[T], f: T => R, g: R => Q): Functor[Q] =
fun map (f andThen g)
}
То есть произвольный функтор-контейнер, который последовательно отображают функцией 'f' и потом функцией 'g' эквивалентен тому, что мы строим новую функцию-композицию функций f и g (f andThen g) и отображаем один раз.
Рассмотрим пример. Поскольку в качестве функтора часто рассматривают контейнеры, то давайте сделаем свой функтор в виде рекурсивного типа данных — контейнера типа бинарное дерево.
sealed trait Tree[T] {
def map[R](f: T => R): Tree[R]
}
case class Node[T](value: T, fst: Tree[T], snd: Tree[T]) extends Tree[T] {
def map[R](f: T => R) = Node(f(value), fst map f, snd map f)
}
case class Leaf[T](value: T) extends Tree[T] {
def map[R](f: T => R) = Leaf(f(value))
}
Здесь метод map(f: T=>R) применяет функцию 'f' к элементу типа 'T' в каждом листе или узле (Leaf, Node) и рекурсивно распространяет на потомков узла (Node). Значит у нас
- сохраняется структура дерева
- меняются значения данных, «висящих на дереве»
При попытке менять структуру дерева при отображениях нарушаются оба правила (и Identity Law и Composition Law).
НЕ функтор: меняю местами при отображении потомков каждого узла
case class Node[T](value: T, fst: Tree[T], snd: Tree[T]) extends Tree[T] {
def map[R](f: T => R) = Node(f(value), snd map f, fst map f)
}
НЕ функтор: с каждым отображением дерево подрастает, листья превращаются в ветки
case class Leaf[T](value: T) extends Tree[T] {
def map[R](f: T => R) = Node(f(value), Leaf(f(value)), Leaf(f(value)))
}
Если посмотреть на такие (неправильные) реализации бинарного дерева как функтора, то видно, что они вместе с отображением данных, так же подсчитывают количество применений map в виде изменения структуры дерева. Значит И реагируют на identity и пара применений f и g отличается от одного применения f andThen g.
Ковариантный функтор: используем для оптимизации
Давайте рассмотрим пример, демонстрирующий пользу от аксиом ковариантного функтора.
В качестве отображений рассмотрим линейные функции над целыми числами
case class LinFun(a: Int, b: Int) {
def apply(k: Int): Int = a * k + b
def andThen[A](that: LinFun): LinFun = LinFun(this.a * that.a, that.a * this.b + that.b)
}
Вместо самых общих функций вида T=>R я буду использовать их подмножество — линейные функции над Int, так как в отличии от общего вида я умею строить композиции линейных функций в явном виде.
В качестве функтора рассмотрю рекурсивный контейнер типа односвязный список целых чисел (Int)
sealed trait IntSeq {
def map(f: LinFun): IntSeq
}
case class Node(value: Int, tail: IntSeq) extends IntSeq {
override def map(f: LinFun): IntSeq = Node(f(value), tail.map(f))
}
case object Last extends IntSeq {
override def map(f: LinFun): IntSeq = Last
}
А теперь — демонстрация
object Demo extends App {
val seq = Node(0, Node(1, Node(2, Node(3, Last))))
val f = LinFun(2, 3) // k => 2 * k + 3
val g = LinFun(4, 5) // k => 4 * k + 5
val res0 = (seq map f) map g // slow version
val res1 = seq map (f andThen g) // fast version
println(res0)
println(res1)
}
>> Node(17,Node(25,Node(33,Node(41,Last))))
>> Node(17,Node(25,Node(33,Node(41,Last))))
Мы можем либо
- ДВА раза перебрать все элементы списка (ДВА раза пройтись по памяти)
- и ДВА раза выполнить арифметические операции (* и +)
либо построить композицию f andThen g и
- ОДИН раз перебирать все элементы списка
- и ОДИН раз выполнить арифметические операции
Что такое контравариантный функтор
Напомню, что ковариантным функтором называется всякий класс X, который имеет метод с определенной сигнатурой (условно называемый map) и подчиняющийся определенным правилам (Identity Law, Composition Law)
trait X[T] {
def map[R](f: T => R): X[R]
}
В свою очередь контравариантным функтором называется всякий класс X, который имеет метод (условно называемый contramap) с определенной сигнатурой и подчиняющийся определенным правилам (они тоже называются Identity Law, Composition Law)
trait X[T] {
def contramap[R](f: R => T): X[R]
}
В этом месте недоуменный читатель может остановиться. Постойте, но если у нас есть контейнер, содержащий T и мы получаем функцию f: T => R, то понятно каким образом мы получаем контейнер с R. Передаем функцию контейнеру, тот погружает функцию внутрь себя и не извлекая элемент применяет к нему функцию. Однако совершенно непонятно, как, имея контейнер с T и получив функцию f: R => T, применить ее в «обратном порядке»?!
В математике в общем виде не у всякой функции есть обратная и нет общего способа найти обратную даже когда она существует. В программировании же нам необходимо действовать конструктивно (не просто работать с существованием, единственность,… но строить и исполнять конструкции) — надо каким-то образом по функции f: R => T построить функцию g: T => R что бы применить ее к содержимому контейнера!
И вот тут оказывается, что наша метафора (ковариантный функтор ~ контейнер) не работает. Давайте разберем почему.
Всякий контейнер предполагает две операции
- put — поместить элемент в контейнер
- get — извлечь элемент из контейнера
однако у рассмотренных примеров (Option, Try, Future, List, Parser) в той или иной мере есть метод get, но нет метода put! В Option/Try/Future элемент попадает в конструкторе (или в методе apply от companion object) или же в результате какого-то действия. В Parser вообще нельзя попасть, так как Parser[T] — «перерабатывает» строки в T. Parser[T] — это источник T, а не хранилище!
И вот тут кроется секрет ошибки в метафоре.
Ковариантный функтор — это половина контейнера. Та часть, которая отвечает за извлечение данных.
Давайте изобразим это в виде схемы
+-------------------------+ | +------+ T | R | | X[T] -----> f: T=>R ----> | +------+ | +-------------------------+
То есть «на выходе из» ковариантного функтора элемент данных типа T подстерегает функция f: T=>R и вот эта композиция является, в свою очередь, ковариантным функтором типизированным R.
В таком случае становится понятным, почему не контейнеры-хранилища, но типичные источники данных Iterator и Stream тоже являются ковариантными функторами.
???
???
Схематично ковариантный функтор выглядит следующим образом, мы «прикручиваем» преобразование f: R => T «на входе», а не «на выходе».
+-------------------------+ R | T +------+ | -----> f: R=>T -----> X[T] | | | +------+ | +-------------------------+
Примеры контравариантных функторов
Для поиска примеров контравариантных функторов в стандартной библиотеке Scala нам надо забыть про метафору контейнера и искать тип с одним type parameter, который только принимает данные в виде аргументов, но не возвращает в виде результата функции.
В качестве примера можно взять Ordering и Equiv
Пример: Ordering
import scala.math.Ordering._
object Demo extends App {
val strX: Ordering[String] = String
val f: (Int => String) = _.toString
val intX: Ordering[Int] = strX on f
}
Имея способ сравнивать между собой строки и имея функцию преобразования целого числа в строку, я могу построить способ сравнивать числа как строки.
Небольшое замечание относительно строки
val strX: Ordering[String] = String
в данном случае берется не java.lang.String, а scala.math.Ordering.String
package scala.math
trait Ordering[T] extends ... {
trait StringOrdering extends Ordering[String] {
def compare(x: String, y: String) = x.compareTo(y)
}
implicit object String extends StringOrdering
...
}
а в качестве метода contramap выступает метод on
package scala.math
trait Ordering[T] extends ... {
def on[R](f: R => T): Ordering[R] = new Ordering[R] {
def compare(x: R, y: R) = outer.compare(f(x), f(y))
}
...
}
Пример: Equiv
import java.lang.String.CASE_INSENSITIVE_ORDER
import scala.math.Equiv
import scala.math.Equiv.{fromFunction, fromComparator}
object Demo extends App {
val strX: Equiv[String] = fromComparator(CASE_INSENSITIVE_ORDER)
val f: (Int => String) = _.toString
val intX: Equiv[Int] = fromFunction((x, y) => strX.equiv(f(x), f(y)))
}
Мы строим метод сравнения строк (отношение эквивалентности scala.math.Equiz) на основе метода компаратора java.lang.String.CASE_INSENSITIVE_ORDER
package java.lang;
public final class String implements ... {
public static final Comparator<String> CASE_INSENSITIVE_ORDER
= new CaseInsensitiveComparator();
private static class CaseInsensitiveComparator
implements Comparator<String>, java.io.Serializable {
public int compare(String s1, String s2) {...}
...
}
...
}
используя метод fromComparator
object Equiv extends ... {
def fromComparator[T](cmp: Comparator[T]): Equiv[T] = new Equiv[T] {
def equiv(x: T, y: T) = cmp.compare(x, y) == 0
}
...
}
а вместо метода contramap использует громоздкую конструкцию на основе метода fromFunction
object Equiv extends ... {
def fromFunction[T](cmp: (T, T) => Boolean): Equiv[T] = new Equiv[T] {
def equiv(x: T, y: T) = cmp(x, y)
}
...
}
Контравариантный функтор: Identity Law
Как и в случае с ковариантным функтором, контравариантному функтору кроме метода с сигнатурой необходимо следовать двум правилам.
Первое правило (Identity Law) гласит: для всякого контравариантного функтора fun должно выполняться IdentityLaw.case0(fun) тождественно равно IdentityLaw.case1(fun)
object IdentityLaw {
def case0[T](fun: Contravariant[T]): Contravariant[T] = identity(fun)
def case1[T](fun: Contravariant[T]): Contravariant[T] = fun.contramap(identity)
}
То есть отображение контравариантного функтора единичной функцией не меняет его.
Контравариантный функтор: Composition Law
Второе правило (Composition Law) гласит: для всякого контравариантного функтора fun[T] и произвольной пары функций f: Q => R и g: R => T пары должно быть IdentityLaw.case0(fun) тождественно равно IdentityLaw.case1(fun)
object CompositionLaw {
def case0[Q, R, T](fun: Contravariant[T], f: Q => R, g: R => T): Contravariant[Q] =
(fun contramap g) contramap f
def case1[Q, R, T](fun: Contravariant[T], f: Q => R, g: R => T): Contravariant[Q] =
fun contramap (f andThen g)
}
То есть отображение контравариантного функтора последовательно парой функций эквивалентно единичному отображению композицией функций (инвертированной).
Что дальше?
Понятия ко- и контра-вариантного функтора являются только стартовой точкой для серьезного изучения применения абстракций из теории категорий в функциональном программировании (в терминах Scala — переход к использованию библиотек Scalaz, Cats).
Дальнейшие шаги включают:
- Изучение композиций ко- и контра- вариантных функторов (BiFunctor, ProFunctor, Exponential (Invariant) Functor)
- Изучение более специализированных конструкций (Applicative Functor, Arrow, Monad), которые уже действительно составляют новую парадигму работы с вычислениями, вводом-выводом, обработкой ошибок, мутирующим состоянием. Укажу хотя бы на то, что всякая монада является ковариантным функтором.
К сожалению, размер статьи не позволяет рассказать это все за один раз.
P.S. Для тех, кто прочитал статью до самого конца предлагаю мой курс «Scala for Java Developers» всего за 25% цены (просто идите по ссылке или воспользуйтесь купоном HABR-COVARIANT-FUNCTOR). Количество скидочных купонов ограничено!