Имплиситы и тайпклассы в Scala
Статья, в большей степени, будет интересна для начинающих скалистов и по сути является переработанным конспектом лекции. А еще стоит отметить, что все примеры кода написаны на Scala 2.
План у нас такой
Implicit conversions
И так, давайте же сразу начнем с примера! Допустим, у нас есть такой код. Вопрос - скомпилируется ли он?
1
val x: String = 123
Конечно же нет! Мы получим ошибку, поскольку пытаемся присвоить строковому типу численный тип и поэтому компилятор бьет нас по рукам - говорит, что так делать нельзя:
1
2
3
4
5
6
7
[error] ...: type mismatch;
[error] found : Int(123)
[error] required: String
[error] val x: String = 123
[error] ^
[error] one error found
[error] (Compile / compileIncremental) Compilation failed
Но в Scala есть способ сделать так, чтобы такой код скомпилировался. Нам нужно прибегнуть к использованию механизма неявных преобразований. Давайте посмотрим на следующий пример:
1
2
3
implicit def intToString(x: Int): String = x.toString
val x: String = 123 // будет вызван intToString
Такой код скомпилируется? Да! Что же тут происходит? У нас есть функция, которая помечена ключевым словом implicit
, из-за чего она может вызываться неявно.
1
implicit def func(param: A): B = ???
Неявные преобразования могут носить произвольные названия. Но вы спросите - она же вызывается неявно, зачем ей название? Название неявной функции играет роль только в двух ситуациях:
- если вы хотите вызывать его явно
1 2
implicit def intToString(x: Int): String = x.toString val x: String = intToString(123)
- если нужно определить, какие неявные преобразования доступны в том или ином месте программы когда делаем импорт
1 2 3 4 5
object my_implicits { implicit def intToString(x: Int): String = x.toString } import my_implicits.intToString val x: String = 123
Таким образом, для определения неявных функций
- Нужно использовать ключевое слово
implicit
- Это функция и должна быть объявлена внутри трейта/класса/объекта/метода (главное, что она не может быть на верхнем уровне)
- В списке аргументов должен быть только один параметр
1 2 3
// Если неявная функция будет принимать два или более аргументов, // то она не будет вызываться неявно implicit def func(argA: A, argB: B): C = ???
Implicit scopes and priorities
Компилятор будет использовать только те неявные преобразования, которые находятся в области видимости. Поэтому, чтобы обеспечить доступность имплиситных функций, нужно каким то образом поместить их в область видимости.
Рассмотрим ключевые моменты, на которые стоит обратить внимание
Local scope
Неявные функции можно определить в текущей области видимости, например, внутри метода или объекта. Такие функции будут иметь приоритет над неявными функциями из других областей видимости.
1
2
3
4
5
6
object Example {
implicit def intToString(x: Int): String = x.toString
val x: String = 123
}
Стоит отметить, что если объявить две неявных функции с одинаковыми сигнатурами, то в этом случае (ожидаемо) получим ошибку компиляции, поскольку компилятор не знает какую из них использовать для преобразования.
1
2
3
4
5
6
7
8
9
10
11
12
object Example {
implicit def intToString1(x: Int): String = x.toString
implicit def intToString2(x: Int): String = x.toString
val x: String = 123
}
// Получим ошибку
// [error] Note that implicit conversions are not applicable because they are ambiguous:
// [error] both method intToString1 in object Example of type (x: Int): String
// [error] and method intToString2 in object Example of type (x: Int): String
Imports
Неявные функции, импортированные в текущую область видимости, также доступны для использования. Это позволяет управлять доступностью неявных преобразований и параметров на уровне отдельных файлов или блоков кода.
1
2
3
4
5
6
7
8
9
10
11
12
object ExternalImplicits {
implicit def intToString(x: Int): String = x.toString
}
object Example {
import ExternalImplicits.intToString
// ИЛИ импортируем все
import ExternalImplicits._
val x: String = 123
}
Объекты-компаньоны
Так же компилятор будет искать неявные функции в объекте-компаньоне типа, для которого происходит преобразование, или для типа параметра функции. Это означает, что если вы определите неявную функцию в объекте-компаньоне класса Currency
, то неявная функция будет доступна везде, где доступен Currency
.
1
2
3
4
5
6
7
8
9
10
11
12
trait Currency
case class Dollar(amount: Double) extends Currency
case class Euro(amount: Double) extends Currency
object Currency {
implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount * 1.13)
}
object Example extends App {
val dollar: Dollar = Euro(100) // euroToDollar
}
Если определены две неявные функции с одинаковой сигнатурой - одна в объекте компаньоне, а другая в текущей области, то из них будет использована функция из текущей области видимости.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
trait Currency
case class Dollar(amount: Double) extends Currency
case class Euro(amount: Double) extends Currency
object Currency {
implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount * 1.13)
}
object Example extends App {
implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount)
val dollar: Dollar = Euro(100) // result: 100
}
Предостережение!
С большой силой приходит большая ответственность! Неосознанное использование неявных преобразований может привести к написанию трудного для понимания кода (тем более когда кодовой базы становится много). Применять их нужно осознанно только там, где это действительно улучшает код и не создает дополнительной путаницы!
У вас может возникнуть вопрос - зачем тогда мы изучали неявные преобразования если их использование является антипаттерном?
Ответ такой - это механизм языка о котором стоит знать и понимать как оно работает. Поскольку сами имплиситы используются не только лишь для преобразований, но еще участвуют в других механизмах языка.
Implicit parameters
1
def func(implicit x: Int): Unit = ???
Неявные параметры - это мощная особенность, позволяющая функциям автоматически получать значения для своих параметров из текущей области видимости без явной передачи аргументов при вызове функции.
Давайте рассмотрим простой пример:
1
2
3
4
5
6
def multiply(x: Int)(implicit y: Int) = x * y
implicit val z: Int = 10 // должна быть неявной
multiply(3) // result: 30
multiply(4) // result: 40
В данном случае у метода multiply
аргумент y
передается неявно. Стоит отметить такой момент, что передаваемая переменная тоже должна быть отмечена как implicit
.
Если в области видимости будут две неявно определенные переменные с одним и тем же типом, то (опять же ожидаемо) на выходе получим ошибку компиляции, поскольку компилятору непонято какую из переменных использовать:
1
2
3
4
5
6
7
8
9
10
implicit val z: Int = 10
implicit val y: Int = 42
multiply(3)
// [error] ....scala:119:11: ambiguous implicit values:
// [error] both value z in object ExampleImplicitParameters of type Int
// [error] and value y in object ExampleImplicitParameters of type Int
// [error] match expected type Int
// [error] multiply(3)
Корректные и некорректные примеры объявления функций с неявными параметрами:
def func(implicit x: Int)
- аргументx
неявныйdef func(implicit x: Int, y: Int)
- аргументыx
иy
неявныеdef func(x: Int, implicit y: Int)
- ошибка компиляции!def func(x: Int)(implicit y: Int)
- аргументy
неявныйdef func(implicit x: Int)(y: Int)
- ошибка компиляции!def func(implicit x: Int)(implicit y: Int)
- ошибка компиляции!
То есть, группа неявных параметров всегда должна быть последней.
Давайте рассмотрим пример использования неявных параметров больше приближенный к реальной жизни. Допустим у нас есть некое приложение в котором есть логгер. Приложение имеет некоторый контекст, например, он содержит некоторый id запроса, и нам нужно логировать информацию из этого контекста. При передаче в логгер этого параметра явно в коде приложения при логировании вынуждены писать logger.log(...)(requestContext)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case class RequestContext(requestId: String)
class Logger {
def log(message: String)(ctx: RequestContext): Unit = {
println(s"[${ctx.requestId}] $message")
}
}
object SomeApplication extends App {
val logger = new Logger()
def handle(requestContext: RequestContext) = {
logger.log("Starting process")(requestContext)
// some action ...
logger.log("Continue process...")(requestContext)
// some action ...
logger.log("End process")(requestContext)
}
}
Тогда как сделав параметр запроса неявным, мы можем избавиться от явной передачи этого параметра, тем самым упростив и уменьшив количество кода в бизнес логике нашего приложения.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case class RequestContext(requestId: String)
class Logger {
def log(message: String)(implicit ctx: RequestContext): Unit = {
println(s"[${ctx.requestId}] $message")
}
}
object SomeApplication extends App {
val logger = new Logger()
def handle(implicit requestContext: RequestContext) = {
logger.log("Starting process")
// some action ...
logger.log("Continue process...")
// some action ...
logger.log("End process")
}
}
Implicit classes
В Scala есть возможность сделать классы неявными, выглядеть это будет следующим образом.
1
2
3
implicit class ImplicitClass(val field: Int) extends AnyVal {
def method: Unit = ???
}
Зачем они нужны?
Давайте разберемся! Начнем с вопроса - какая разница между нашим кодом и библиотеками других разработчиков? Принципиальная разница в том, что свой код при желании мы можем изменить или расширить, но библиотеки, зачастую, приходится принимать такими, какие они есть.
Чтобы облегчить решение этой проблемы, в языках программирования есть ряд подходов. В ООП языках, например, можно воспользоваться структурным паттерном адаптер. К примеру, мы хотим расширить тип (или класс) Int
методами для проверки четности и нечетности. Для этого создаем класс обертку IntAdapter
, в котором реализуем необходимые нам методы.
1
2
3
4
5
6
7
8
class IntAdapter(val i: Int) {
def isEven: Boolean = i % 2 == 0
def isOdd: Boolean = !isEven
}
// Создание экземпляра адаптера и использование его методов
new IntAdapter(42).isEven // true
new IntAdapter(42).isOdd // false
В Scala для этой цели мы можем использовать имплиситные классы. Для этого перепишем наш пример следующим образом.
1
2
3
4
5
6
7
implicit class RichInt(val i: Int) extends AnyVal {
def isEven: Boolean = i % 2 == 0
def isOdd: Boolean = !isEven
}
10.isEven // true
10.isOdd // false
Отличие будет в том, что методы для проверки на четность и нечетность теперь сможем вызывать так, будто они принадлежат типу Int
. Это позволяет писать более лаконичный и выразительный код.
Примечание: наследование от
AnyVal
в Scala используется для создания value классов, которые представляют собой механизм оптимизации, позволяющий избежать выделения памяти для объектов-оберток.
Вы наверняка заметили, что пример с имплиситным классом подозрительно сильно похож на пример с адаптером, написанный выше. Дело в том, что если мы избавимся от синтаксического сахара (в IntelliJ IDEA можно сделать Desugar Scala Code
), то увидим, что в обессахаренном коде производится явное оборачивание в класс обертку и вызов его методов.
1
2
3
// Обессахаренный код
org.example.app.RichInt(10).isEven // true
org.example.app.RichInt(10).isOdd // false
То есть фактически под капотом применяется тот же самый паттерн адаптер приправленный механизмом имплиситов.
Type classes
Тайпкласс - это паттерн, используемый в функциональном программировании для обеспечения Ad-hoc полиморфизма, известного как перегрузка методов. Этот паттерн позволяет писать код, в котором мы оперируем интерфейсами и абстракциями и при этом использовать правильную реализацию этих абстракций на основе типов.
Полиморфизм через наследование
Начнем с рассмотрения абстрактного примера, в котором есть классы Circle
и Rectangle
. Нам нужно обогатить их методом для вычисления площади.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
trait Area {
def area: Double
}
class Circle(radius: Double) extends Area {
override def area: Double = math.Pi * math.pow(radius, 2)
}
class Rectangle(width: Double, length: Double) extends Area {
override def area: Double = width * length
}
// Обобщенная функция
def areaOf(area: Area): Double = area.area
areaOf(new Circle(10))
areaOf(new Rectangle(5, 5))
При использовании полиморфизма через наследование мы создаем интерфейс Area
с методом area
и наследуем от него классы Circle
и Rectangle
, в которых делаем реализацию этого метода. Это позволяет нам создать общую функцию areaOf
, способную работать с любым типом, который наследуется от Area
.
Данный подход, в большей степени, присущ ООП, когда поля и методы лежат в определении класса. То есть сущности, представляющие данные, сосредоточены рядом с сущностями, отвечающих за поведение.
Полиморфизм через тайпклассы
Тайпклассы предлагают подход, когда сущности, представляющие данные, отделены от сущностей, отвечающих за поведение.
В следующем примере интерфейс Area
является тайпклассом. Он параметризован и метод его принимает на вход аргумент - те самые данные, которыми нужно будет оперировать в реализациях интерфейса.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// сущности, представляющие данные
case class Circle(radius: Double)
case class Rectangle(width: Double, length: Double)
// тайпкласс
trait Area[A] {
def area(a: A): Double
}
// сущности, отвечающие за реализацию
object CircleArea extends Area[Circle] {
override def area(circle: Circle): Double = math.Pi * math.pow(circle.radius, 2)
}
object RectangleArea extends Area[Rectangle] {
override def area(rectangle: Rectangle): Double = rectangle.width * rectangle.length
}
// Обобщенная функция
def areaOf[A](shape: A, area: Area[A]): Double = area.area(shape)
areaOf(Circle(11), CircleArea)
areaOf(Rectangle(12, 15), RectangleArea)
Мы можем уменьшить количество кода, если создадим неявные инстансы тайпкласса Area
для типов Circle
и Rectangle
, а так же если будем пробрасывать эти инстансы в функцию areaOf
неявно.
1
2
3
4
5
6
7
8
9
10
11
12
13
implicit val circleArea: Area[Circle] = new Area[Circle] {
override def area(circle: Circle): Double = math.Pi * math.pow(circle.radius, 2)
}
implicit val rectangleArea: Area[Rectangle] = new Area[Rectangle] {
override def area(rectangle: Rectangle): Double = rectangle.width * rectangle.length
}
// Обобщенная функция
def areaOf[A](figure: A)(implicit area: Area[A]): Double = area.area(figure)
areaOf(Circle(42))
areaOf(Rectangle(12, 15))
Можно пойти еще дальше. Путем замены функции areaOf
на имплиситный класс, мы можем добавить синтаксис для тайпкласса, что позволит вызывать метод area
так, будто он принадлежит типам Circle
и Rectangle
.
1
2
3
4
5
6
7
// Синтаксис
implicit class AreaSyntax[A](val figure: A) extends AnyVal {
def area(implicit area: Area[A]): Double = area.area(figure)
}
Circle(42).area
Rectangle(12, 15).area
По этим примерам видно, что тайпклассы, на самом деле, можно реализовать и в ООП языках программирования, но в Scala они, за счет имплиситов, выглядят более изящно и выразительно.
Анатомия тайпклассов
Так из чего, в итоге, строятся тайпклассы? Они состоят их трех обязательных компонентов:
- trait (сам тайпкласс)
- методы тайпклассов
- инстансы трейта для определенных типов
- синтаксис, на базе implicit class (опционально)
Давайте рассмотрим эти компоненты поподробнее. Вот пример трейта:
1
2
3
trait TypeClass[A] {
def method(value: A): Unit
}
Это собственно сам тайпкласс у которого есть некоторый метод. Важно обратить внимание, что этот трейт параметризован некоторым типом A
.
Далее мы создаем инстансы этого тайпкласса, например для типа Int
. По сути мы тут пишем реализацию класса и создаем его экземпляр, причем инстанс его создается в виде неявной переменной. Обычно инстансы размещают внутри объекта, который именуется названием тайпкласса и приставкой Instances
.
1
2
3
4
5
object TypeClassInstances {
implicit val intInstance: TypeClass[Int] = new TypeClass[Int] {
def method(value: Int): Unit = ???
}
}
Ну и необязательный компонент тайпклассов - это имплиситный класс, который позволяет создать некоторый синтаксис для нашего тайпкласса.
1
2
3
4
5
object TypeClassSyntax {
implicit class TypeClassOps[A](private val value: A) extends AnyVal {
def method(implicit ev: TypeClass[A]): Unit = ev.method(value)
}
}
Способы доставки инстансов тайпклассов
Инстанс тайпкласса мы можем прокинуть через аргументы функций (явно или неявно).
1
2
3
4
5
object SomeApp {
def someMethod[A](arg: A)(implicit t: TypeClass[A]): Unit = {
t.method(arg)
}
}
Так же тайпклассы можно прокидывать через контекст баунды тайп-параметров.
1
2
3
4
5
object SomeApp {
def someMethod[A: TypeClass]: Unit = {
TypeClass[A].method
}
}
Но для этого, нужно предварительно создать объект компаньон тайпкласса с методом apply
, который умеет доставать неявный инстанс тайпкласса.
1
2
3
4
5
6
7
trait TypeClass[A] {
def method(value: A): Unit
}
object TypeClass {
def apply[A](implicit ev: TypeClass[A]): TypeClass[A] = ev
}
Для использования синтаксиса тайпкласса необходимо этот синтаксис импортировать.
1
2
3
4
5
6
7
object SomeApp {
import TypeClassSyntax._
def someMethod[A: TypeClass](arg: A): Unit = {
arg.method
}
}
Simple type classes
Давайте рассмотрим пару тайпклассов из реальной жизни.
Show
Show
- это альтернатива для джавового метода toString
. Он определяется единственной функцией show
.
Вам может быть любопытно для чего нужен этот тайпкласс, учитывая, что toString
уже служит той же цели. Причем кейс-классы имеют неплохие реализации метода toString
. Проблема в том, что toString
определен на уровне Any
(или джавовый Object
) и, следовательно, может быть вызван для чего угодно, что не всегда корректно:
1
2
(new {}).toString
// result: "example.ExampleApp$$anon$5@5b464ce8"
То есть, тайпкласс Show
позволит нам определять преобразования в строки только для нужных нам типов. Рассмотрим пример реализации тайпкласса:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Тайпкласс
trait Show[A] {
def show(value: A): String
}
// Объект-компаньон
object Show {
def apply[A](implicit ev: Show[A]): Show[A] = ev
}
// Инстансы тайпкласса для Int и String
object ShowInstances {
implicit val showInt: Show[Int] = new Show[Int] {
def show(value: Int): String = value.toString
}
implicit val showString: Show[String] = new Show[String] {
def show(value: String): String = value
}
}
// Синтаксис
object ShowSyntax {
implicit class ShowOps[A](private val value: A) extends AnyVal {
def show(implicit ev: Show[A]): Unit = ev.show(value)
}
}
Пример использования Show
c примитивными типами.
1
2
3
4
5
6
7
8
import ShowInstances._
import ShowSyntax._
val meaningOfLife = 42
Show[Int].show(meaningOfLife) // result: "42"
// or
meaningOfLife.show // result: "42"
Пример использования Show
c кастомными типами.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ShowInstances._
import ShowSyntax._
case class User(name: String, age: Int)
object User {
implicit val showUser: Show[User] = new Show[User] {
def show(user: User): String = s"User(name = ${user.name}, age = ${user.age})"
}
}
val user = User("Mark", 25)
user.show
// result: "User(name = Mark, age = 25)"
Eq
Eq
является альтернативой стандартному методу джавового equals
. Проблема с Java equals
заключается в том, что мы можем сравнить два совершенно не связанных между собой типа и не получим ошибку от компилятора (максимум получим предупреждение), что может привести к веселым багам.
1
2
"Hello" == 42
// res1: Boolean = false
Введением этого тайпкласса, мы отсечем возможность сравнивать значения с разными типами на уровне компиляции.
Рассмотрим пример реализации тайпкласса:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Тайпкласс
trait Eq[A] {
def eqv(x: A, y: A): Boolean
}
// Объект-компаньон
object Eq {
def apply[A](implicit ev: Eq[A]): Eq[A] = ev
}
// Инстансы тайпкласса для Int и String
object EqInstances {
implicit val eqInt: Eq[Int] = new Eq[Int] {
def eqv(x: Int, y: Int): Boolean = x == y
}
implicit val eqString: Eq[String] = new Eq[String] {
def eqv(x: String, y: String): Boolean = x == y
}
}
// Синтаксис
object EqSyntax {
implicit class EqOps[A](private val x: A) extends AnyVal {
def eqv(y: A)(implicit ev: Eq[A]): Boolean = ev.eqv(x, y)
def ===(y: A)(implicit ev: Eq[A]): Boolean = ev.eqv(x, y)
def =!=(y: A)(implicit ev: Eq[A]): Boolean = !ev.eqv(x, y)
}
}
Пример использования Eq
c примитивными типами.
1
2
3
4
5
6
7
import EqInstances._
import EqSyntax._
Eq[Int].eqv(2 + 2, 4) // result: true
"Hello" === "world" // result: false
"Hello" =!= "world" // result: true
Попытка сравнить значения с разными типами будет жестко пресечена компилятором.
1
2
3
4
5
6
7
8
9
"Hello" === 42
/*
[error] fff.scala:202:15: type mismatch;
[error] found : Int(42)
[error] required: String
[error] "Hello" === 42
[error] ^
[error] one error found
[error] (Compile / compileIncremental) Compilation failed */
Пример использования Eq
c кастомными типами.
1
2
3
4
5
6
7
8
9
10
11
12
case class User(name: String, age: Int)
object User {
implicit val eqUser: Eq[User] = new Eq[User] {
def eqv(x: User, y: User): Boolean = x.name === y.name && x.age === y.age
}
}
val mark = User("Mark", 25)
val joe = User("Joe", 33)
mark === joe // result: false
Заключение
Освоение имплиситов и тайпклассов является важным шагом на пути становления Scala разработчика. Однако, важно использовать их с умом. Правильно применяемые имплиситы и тайпклассы способствуют написанию лаконичного, выразительного и легко расширяемого кода, подчеркивая при этом мощь Scala.