После изучения нескольких техник функционального программирования, а именно композиция частично определённых функций, частичное применение функций, каррирование, мы собираемся продолжить в том же духе, мы будем учиться делать код настолько гибким насколько это возможно.
Но на этот раз мы поговорим не о функциях, а о типах. Мы научимся работать с системой типов так, чтобы она не мешала нам, а помогала, чтобы наш код был расширяемым. Мы собираемся узнать о классах типов (type class).
Вам может показаться, что это не более чем экзoтичная идея, привнесённая в Scala фанатами Haskell, которая лишена практического применения. Но это явно не так. Классы типов стали важной частью стандартной библиотеки Scala, очень часто они встречаются и в сторонних свободных библиотеках. Поэтому было бы хорошо с ними разобраться.
Я расскажу об основной идее классов типов, чем почему они так полезны, какие преимущества они дают пользователям нашего кода и как реализовать и применять наши собственные классы типов.
Вместо того чтобы начать с абстрактного определения, давайте попробуем разобраться что к чему на практическом примере, пусть и весьма упрощённом.
Предположим, что мы хотим написать классную библиотеку для статистики. Это означает, что
мы собираемся написать кучу функций, который будут принимать коллекции значений и возвращать
какие-нибудь собирательные показатели. Предположим, что мы ограничены в операциях над коллекциями.
Мы можем лишь обращаться по индексу и пользоваться методом reduce
из стандартной библиотеки для
коллекций. Мы накладываем эти ограничения просто потому, что так мы избавимся от лишних
деталей, и пример станет доступным для изложения в блоге. Наконец, мы предполагаем,
что значения поступают к нам отсортированными.
Мы начнём с очень грубой реализации поиска медианы, квартилей и межквартильный интервал для
чисел типа Double
:
object Statistics {
def median(xs: Vector[Double]): Double = xs(xs.size / 2)
def quartiles(xs: Vector[Double]): (Double, Double, Double) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr(xs: Vector[Double]): Double = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile - lowerQuartile
}
def mean(xs: Vector[Double]): Double = {
xs.reduce(_ + _) / xs.size
}
}
Медиана разделяет данные посередине, в то время как нижний и верхний квартили (первый и третий элементы кортежа,
который возвращает функция quartiles
) разделяют данные на нижние и верхние 25%. Метод iqr
возвращает
величину межквартильного интервала, которая представляет собой разницу между верхним и нижним квартилями.
Вдруг нам понадобилось вычисление этих параметров не только для Double
. Неужели мы будем снова определять
эти методы для Int
?
Конечно нет! Во-первых мы не можем перегрузить объявленные методы для Vector[Int]
без некоторых трюков,
потому что тип параметр страдает от стирания типов (type erasure). При этом в нашем коде появятся повторы, не так ли?
Если бы Int
и Double
наследовали от одного общего класса вроде Number
! Тогда мы могли
бы написать наши методы в более общем виде:
object Statistics {
def median(xs: Vector[Number]): Number = ???
def quartiles(xs: Vector[Number]): (Number, Number, Number) = ???
def iqr(xs: Vector[Number]): Number = ???
def mean(xs: Vector[Number]): Number = ???
}
К счастью, такого трэйта нет, и мы не сможем выбрать это неверное решение. Но в других ситуациях
такая возможность может представиться, несмотря на то что это по-прежнему останется плохим решением.
Мы не только ослабляем наши ограничения по типам, мы закрываем интерфейс для расширения, с помощью
тех типов, которые нам пока неизвестны, мы не можем взять численный тип из сторонней библиотеки и унаследовать
его от трэйта Number
.
В Ruby мы могли бы воспользоваться обезьяньим патчем (monkey patch), засоряя глобальное пространство имён
расширением к новому типу, так мы сможем заставить его вести себя как Number
. Разработчики Java, знакомые
с шаблонами проектирования, могут предложить воспользоваться шаблоном адаптер:
object Statistics {
trait NumberLike[A] {
def get: A
def plus(y: NumberLike[A]): NumberLike[A]
def minus(y: NumberLike[A]): NumberLike[A]
def divide(y: Int): NumberLike[A]
}
case class NumberLikeDouble(x: Double) extends NumberLike[Double] {
def get: Double = x
def minus(y: NumberLike[Double]) = NumberLikeDouble(x - y.get)
def plus(y: NumberLike[Double]) = NumberLikeDouble(x + y.get)
def divide(y: Int) = NumberLikeDouble(x / y)
}
type Quartile[A] = (NumberLike[A], NumberLike[A], NumberLike[A])
def median[A](xs: Vector[NumberLike[A]]): NumberLike[A] = xs(xs.size / 2)
def quartiles[A](xs: Vector[NumberLike[A]]): Quartile[A] =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[A](xs: Vector[NumberLike[A]]): NumberLike[A] = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile.minus(lowerQuartile)
}
def mean[A](xs: Vector[NumberLike[A]]): NumberLike[A] =
xs.reduce(_.plus(_)).divide(xs.size)
}
Мы решили проблему расширяемости. Пользователи библиотеки могут передать адаптер NumberLike
для Int
(который мы скорее всего сами же и напишем) или для любого другого типа, который
может выступать в роли числа, без необходимости перекомпиляции, модуля в котором определены
методы вычисления статистических параметров.
Но постоянное заворачивание и разворачивание чисел в адаптеры — не только утомительно для написания и чтения, но и ведёт к тому, что нам приходится создавать много значений для адаптеров.
Классы типов предлагают наилучшее решение этой проблемы. Классы типов — одна из основных особенностей языка Haskell. Несмотря на название, они не имеют ничего общего с понятием класс из объектно ориентированного программирования.
Класс типов C
определяет некоторое поведение в виде набора операций, которые должны
быть определена на типе T
, для того чтобы тот был членом класса типов.
Является ли некоторый тип T
членом класса C
не указывается в определении класса.
Вместо этого любой пользователь может объявить свой тип членом C
, определив на нём
все необходимые операции. Как только T
стал членом класса C
, функции их класса C
могут вызываться на значениях типа T
:
Классы типов реализуют ситуативный полиморфизм (ad-hoc polymorphism). Код, зависящий от классов типов, открыт для расширений, без необходимости создания объектов-адаптеров.
В Scala классы типов реализуются с помощью комбинации нескольких техник. Это более окольный путь чем в Haskell, но предлагает большую возможность для контроля.
Создание класса типов в Scala происходит в несколько шагов. Во-первых, давайте определим трэйт. Он и будет нашим классом типов:
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
Мы создали класс типов с названием NumberLike
. Классы типов всегда принимают один или несколько типов-параметров.
Обычно они не содержат состояния, то есть методы определённые в таком трэйте оперируют только данными, переданными
в метод. В то время как метод из адаптера был привязан к значению и имел один аргумент, наш
новые метод имеет два аргумента типа T
. Значение адаптера превратилось в первый аргумент метода,
определённого в NumberLike
.
Второй шаг реализации класса типа заключается в определении в объекте-компаньоне значений по умолчанию, принадлежащих
нашему классу типов. Скоро мы узнаем почему хорошо так делать. Но сначала давайте сделаем это, давайте сделаем
Double
и Int
членами класса типов NumberLike
:
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
object NumberLike {
implicit object NumberLikeDouble extends NumberLike[Double] {
def plus(x: Double, y: Double): Double = x + y
def divide(x: Double, y: Int): Double = x / y
def minus(x: Double, y: Double): Double = x - y
}
implicit object NumberLikeInt extends NumberLike[Int] {
def plus(x: Int, y: Int): Int = x + y
def divide(x: Int, y: Int): Int = x / y
def minus(x: Int, y: Int): Int = x - y
}
}
}
Отметим два момента. Во-первых, видно, что реализации практически одинаковы. Это не всегда так с классами типов.
Наш класс NumberLike
очень специфичен. Скоро мы встретимся с примерами, в которых не так много дублирования
в реализации методов. Во-вторых, пожалуйста не заостряйте внимание на том, что мы теряем в точности при
выполнении целочисленного деления в NumberLikeInt
. Это просто учебный пример.
Как видно из примера, члены класса являются объектами-синглтонами. Также обратите внимание на ключевое
слово implicit
перед каждой из реализаций. Как раз за счёт этого мы и можем реализовать классы типов в Scala.
Это ключевое слово делает методы, определённые в синглтоне, неявно доступными при определённых условиях.
Подробнее мы разберёмся в этом в следующем разделе.
Теперь, когда у нас есть класс типов и две реализации, мы бы хотели воспользоваться этим в определении
методов для вычисления статистический показателей. Пока давайте сконцентрируемся на методе mean
:
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
}
Это определение не такое страшное, каким кажется на первый взгляд. Наш метод
параметризован типом T
. Он принимает один аргумент типа Vector[T]
.
Основная идея заключается в том, чтобы ограничить тип параметр специфическим классом
с помощью второго неявного списка аргументов. Что это означает? Второй параметр говорит о том,
что где-то в текущей области видимости имён должно быть объявлено значение типа NumberLike[T]
.
Очень часто такие значения предоставляются импортированием объекта, в котором они объявлены
с ключевым словом implicit
.
В том и только в том случае если компилятору не удалось найти имплицинтого значения, он попробует найти его в объекте-компаньоне для класса неявного аргумента. Это стоит учитывать при создании библиотек, так мы можем предоставить возможность пользователям нашей библиотеки легко переопределять наши определения пользовательскими. Что нам и требовалось изначально. Также пользователи могут передать значение в явном виде для того, чтобы переопределить поведение по умолчанию.
Давайте убедимся в том, что неявно определённые значения по умолчанию могут быть найдены компилятором:
val numbers = Vector[Double](13, 23.0, 42, 45, 61, 73, 96, 100, 199, 420, 900, 3839)
println(Statistics.mean(numbers))
Чудесно! Если мы попробуем вызвать функцию с Vector[String]
, то мы получим ошибку на этапе компиляции,
которая укажет на то, что неявное значение не может быть найдено для NumberLike[String]
.
Если такое сообщение об ошибке нас не устраивает, мы можем задать определить собственные
сообщения для ошибок этого типа. Для этого воспользуемся аннотацией @implicitNotFound
:
object Math {
import annotation.implicitNotFound
@implicitNotFound("No member of type class NumberLike in scope for ${T}")
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
Второй список аргументов с неявными аргументами можно переписать в более коротком виде, если у нас есть лишь один тип-параметр. В Scala есть специальный синтаксис, называемый ограничением контекста (context bounds). Посмотрим как это делается на примере оставшихся статистических методов:
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
def median[T : NumberLike](xs: Vector[T]): T = xs(xs.size / 2)
def quartiles[T: NumberLike](xs: Vector[T]): (T, T, T) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[T: NumberLike](xs: Vector[T]): T = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) =>
implicitly[NumberLike[T]].minus(upperQuartile, lowerQuartile)
}
}
Ограничение контекста T : NumberLike
означает, что должно быть определено
неявное значение типа NumberLike[T]
. Эта запись эквивалентна указанию второго
списка аргументов с неявным параметром. Для того, чтобы получить доступ к этому параметру,
мы можем воспользоваться методом implicitly
, как в методе iqr
. Если у нас несколько типов-параметров
в классе типов, то мы не можем воспользоваться ограничением контекста.
Будучи пользователями библиотеки с классами типов, нам рано или поздно захочется определить свой член для уже созданного класса типов. К примеру нам может понадобиться воспользоваться статистическими методами на типе интервалов времени из библиотеки Joda Time. Разумеется, для начала нам нужно добавить библиотеку Joda Time в classpath:
libraryDependencies += "joda-time" % "joda-time" % "2.1"
libraryDependencies += "org.joda" % "joda-convert" % "1.3"
Теперь нам просто нужно определить неявно заданную реализацию NumberLike
(пожалуйста,
убедитесь в том, что Joda Time доступно в вашем classpath при запуске этого примера):
object JodaImplicits {
import Math.NumberLike
import org.joda.time.Duration
implicit object NumberLikeDuration extends NumberLike[Duration] {
def plus(x: Duration, y: Duration): Duration = x.plus(y)
def divide(x: Duration, y: Int): Duration = Duration.millis(x.getMillis / y)
def minus(x: Duration, y: Duration): Duration = x.minus(y)
}
}
Если мы импортируем пакет с объектом, который содержит эту реализацию NumberLike
, то
мы сможем вычислять среднее значение на коллекции временных интервалов:
import Statistics._
import JodaImplicits._
import org.joda.time.Duration._
val durations = Vector(standardSeconds(20), standardSeconds(57), standardMinutes(2),
standardMinutes(17), standardMinutes(30), standardMinutes(58), standardHours(2),
standardHours(5), standardHours(8), standardHours(17), standardDays(1),
standardDays(4))
println(mean(durations).getStandardHours)
Мы посмотрели на то, как устроены классы типов на примере NumberLike
, но в стандартной библиотеке уже
определён класс типов Numeric[T]
, позволяющий выполнять методы вроде sum
и product
на всех типах
T
для которых доступны Numeric[T]
. Также мы часто будем пользоваться ещё одним стандартным классом типов Ordering
.
С его помощью происходит сравнение величин на больше-меньше. Мы пользуемся и м в методах вроде sort
.
Также в стандартной библиотеке определено много разных классов типов, но на практике они встречаются не так часто.
Примером использования классов типов в сторонних библиотеках, может быть преобразование данных из различных протоколов. Особенно стоит отметить JSON. Сделав наш тип членом некоторого класса типов, что отвечает за преобразование, мы можем указать на то как значения нашего типа будут сериализовываться в JSON, XML или любой другой новомодный формат.
Часто по такому же принципу организовано и преобразование типов между форматами, принятыми в используемой нами базе данных.
На практике вы очень быстро столкнётесь с классами типов. Надеюсь, что после прочтения этой статьи, Вы будете готовы к тому, чтобы воспользоваться возможностями, предлагаемыми классами типов, на все 100 процентов.
Классы типов в Scala позволяют проводить последующее расширение класса типов, с максимальным сохранением информации об ограничениях, обусловленных типами. В отличие от других языков в Scala мы полностью контролируем ситуацию, ведь мы можем без труда переопределить реализацию по умолчанию или определить собственную, если она не доступна.
Вы убедитесь в том, что эта техника особенно полезна при написании обобщённых библиотек, предназначенных для использования в других библиотеках. Но классы типов могут использоваться и в конкретных приложениях для уменьшения связанности модулей в приложении.