Статья, в большей степени, будет интересна для начинающих скалистов и по сути является переработанным конспектом лекции. А еще стоит отметить, что все примеры кода написаны на Scala 2.
План у нас такой
Implicit conversions
И так, давайте же сразу начнем с примера! Допустим, у нас есть такой код. Вопрос - скомпилируется ли он?
Конечно же нет! Мы получим ошибку, поскольку пытаемся присвоить строковому типу численный тип и поэтому компилятор бьет нас по рукам - говорит, что так делать нельзя:
[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 есть способ сделать так, чтобы такой код скомпилировался. Нам нужно прибегнуть к использованию механизма неявных преобразований. Давайте посмотрим на следующий пример:
Такой код скомпилируется? Да! Что же тут происходит? У нас есть функция, которая помечена ключевым словом implicit
, из-за чего она может вызываться неявно.
Неявные преобразования могут носить произвольные названия. Но вы спросите - она же вызывается неявно, зачем ей название? Название неявной функции играет роль только в двух ситуациях:
- если вы хотите вызывать его явно
- если нужно определить, какие неявные преобразования доступны в том или ином месте программы когда делаем импорт
Таким образом, для определения неявных функций
- Нужно использовать ключевое слово
implicit
- Это функция и должна быть объявлена внутри трейта/класса/объекта/метода (главное, что она не может быть на верхнем уровне)
- В списке аргументов должен быть только один параметр
Implicit scopes and priorities
Компилятор будет использовать только те неявные преобразования, которые находятся в области видимости. Поэтому, чтобы обеспечить доступность имплиситных функций, нужно каким то образом поместить их в область видимости.
Рассмотрим ключевые моменты, на которые стоит обратить внимание
Local scope
Неявные функции можно определить в текущей области видимости, например, внутри метода или объекта. Такие функции будут иметь приоритет над неявными функциями из других областей видимости.
Стоит отметить, что если объявить две неявных функции с одинаковыми сигнатурами, то в этом случае (ожидаемо) получим ошибку компиляции, поскольку компилятор не знает какую из них использовать для преобразования.
Imports
Неявные функции, импортированные в текущую область видимости, также доступны для использования. Это позволяет управлять доступностью неявных преобразований и параметров на уровне отдельных файлов или блоков кода.
Объекты-компаньоны
Так же компилятор будет искать неявные функции в объекте-компаньоне типа, для которого происходит преобразование, или для типа параметра функции. Это означает, что если вы определите неявную функцию в объекте-компаньоне класса Currency
, то неявная функция будет доступна везде, где доступен Currency
.
Если определены две неявные функции с одинаковой сигнатурой - одна в объекте компаньоне, а другая в текущей области, то из них будет использована функция из текущей области видимости.
Предостережение!
С большой силой приходит большая ответственность! Неосознанное использование неявных преобразований может привести к написанию трудного для понимания кода (тем более когда кодовой базы становится много). Применять их нужно осознанно только там, где это действительно улучшает код и не создает дополнительной путаницы!
У вас может возникнуть вопрос - зачем тогда мы изучали неявные преобразования если их использование является антипаттерном?
Ответ такой - это механизм языка о котором стоит знать и понимать как оно работает. Поскольку сами имплиситы используются не только лишь для преобразований, но еще участвуют в других механизмах языка.
Implicit parameters
Неявные параметры - это мощная особенность, позволяющая функциям автоматически получать значения для своих параметров из текущей области видимости без явной передачи аргументов при вызове функции.
Давайте рассмотрим простой пример:
В данном случае у метода multiply
аргумент y
передается неявно. Стоит отметить такой момент, что передаваемая переменная тоже должна быть отмечена как implicit
.
Если в области видимости будут две неявно определенные переменные с одним и тем же типом, то (опять же ожидаемо) на выходе получим ошибку компиляции, поскольку компилятору непонято какую из переменных использовать:
Корректные и некорректные примеры объявления функций с неявными параметрами:
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)
Тогда как сделав параметр запроса неявным, мы можем избавиться от явной передачи этого параметра, тем самым упростив и уменьшив количество кода в бизнес логике нашего приложения.
Implicit classes
В Scala есть возможность сделать классы неявными, выглядеть это будет следующим образом.
Зачем они нужны?
Давайте разберемся! Начнем с вопроса - какая разница между нашим кодом и библиотеками других разработчиков? Принципиальная разница в том, что свой код при желании мы можем изменить или расширить, но библиотеки, зачастую, приходится принимать такими, какие они есть.
Чтобы облегчить решение этой проблемы, в языках программирования есть ряд подходов. В ООП языках, например, можно воспользоваться структурным паттерном адаптер. К примеру, мы хотим расширить тип (или класс) Int
методами для проверки четности и нечетности. Для этого создаем класс обертку IntAdapter
, в котором реализуем необходимые нам методы.
В Scala для этой цели мы можем использовать имплиситные классы. Для этого перепишем наш пример следующим образом.
Отличие будет в том, что методы для проверки на четность и нечетность теперь сможем вызывать так, будто они принадлежат типу Int
. Это позволяет писать более лаконичный и выразительный код.
Примечание: наследование от
AnyVal
в Scala используется для создания value классов, которые представляют собой механизм оптимизации, позволяющий избежать выделения памяти для объектов-оберток.
Вы наверняка заметили, что пример с имплиситным классом подозрительно сильно похож на пример с адаптером, написанный выше. Дело в том, что если мы избавимся от синтаксического сахара (в IntelliJ IDEA можно сделать Desugar Scala Code
), то увидим, что в обессахаренном коде производится явное оборачивание в класс обертку и вызов его методов.
То есть фактически под капотом применяется тот же самый паттерн адаптер приправленный механизмом имплиситов.
Type classes
Тайпкласс - это паттерн, используемый в функциональном программировании для обеспечения Ad-hoc полиморфизма, известного как перегрузка методов. Этот паттерн позволяет писать код, в котором мы оперируем интерфейсами и абстракциями и при этом использовать правильную реализацию этих абстракций на основе типов.
Полиморфизм через наследование
Начнем с рассмотрения абстрактного примера, в котором есть классы Circle
и Rectangle
. Нам нужно обогатить их методом для вычисления площади.
При использовании полиморфизма через наследование мы создаем интерфейс Area
с методом area
и наследуем от него классы Circle
и Rectangle
, в которых делаем реализацию этого метода. Это позволяет нам создать общую функцию areaOf
, способную работать с любым типом, который наследуется от Area
.
Данный подход, в большей степени, присущ ООП, когда поля и методы лежат в определении класса. То есть сущности, представляющие данные, сосредоточены рядом с сущностями, отвечающих за поведение.
Полиморфизм через тайпклассы
Тайпклассы предлагают подход, когда сущности, представляющие данные, отделены от сущностей, отвечающих за поведение.
В следующем примере интерфейс Area
является тайпклассом. Он параметризован и метод его принимает на вход аргумент - те самые данные, которыми нужно будет оперировать в реализациях интерфейса.
Мы можем уменьшить количество кода, если создадим неявные инстансы тайпкласса Area
для типов Circle
и Rectangle
, а так же если будем пробрасывать эти инстансы в функцию areaOf
неявно.
Можно пойти еще дальше. Путем замены функции areaOf
на имплиситный класс, мы можем добавить синтаксис для тайпкласса, что позволит вызывать метод area
так, будто он принадлежит типам Circle
и Rectangle
.
По этим примерам видно, что тайпклассы, на самом деле, можно реализовать и в ООП языках программирования, но в Scala они, за счет имплиситов, выглядят более изящно и выразительно.
Анатомия тайпклассов
Так из чего, в итоге, строятся тайпклассы? Они состоят их трех обязательных компонентов:
- trait (сам тайпкласс)
- методы тайпклассов
- инстансы трейта для определенных типов
- синтаксис, на базе implicit class (опционально)
Давайте рассмотрим эти компоненты поподробнее. Вот пример трейта:
Это собственно сам тайпкласс у которого есть некоторый метод. Важно обратить внимание, что этот трейт параметризован некоторым типом A
.
Далее мы создаем инстансы этого тайпкласса, например для типа Int
. По сути мы тут пишем реализацию класса и создаем его экземпляр, причем инстанс его создается в виде неявной переменной. Обычно инстансы размещают внутри объекта, который именуется названием тайпкласса и приставкой Instances
.
Ну и необязательный компонент тайпклассов - это имплиситный класс, который позволяет создать некоторый синтаксис для нашего тайпкласса.
Способы доставки инстансов тайпклассов
Инстанс тайпкласса мы можем прокинуть через аргументы функций (явно или неявно).
Так же тайпклассы можно прокидывать через контекст баунды тайп-параметров.
Но для этого, нужно предварительно создать объект компаньон тайпкласса с методом apply
, который умеет доставать неявный инстанс тайпкласса.
Для использования синтаксиса тайпкласса необходимо этот синтаксис импортировать.
Simple type classes
Давайте рассмотрим пару тайпклассов из реальной жизни.
Show
Show
- это альтернатива для джавового метода toString
. Он определяется единственной функцией show
.
Вам может быть любопытно для чего нужен этот тайпкласс, учитывая, что toString
уже служит той же цели. Причем кейс-классы имеют неплохие реализации метода toString
. Проблема в том, что toString
определен на уровне Any
(или джавовый Object
) и, следовательно, может быть вызван для чего угодно, что не всегда корректно:
То есть, тайпкласс Show
позволит нам определять преобразования в строки только для нужных нам типов. Рассмотрим пример реализации тайпкласса:
Пример использования Show
c примитивными типами.
Пример использования Show
c кастомными типами.
Eq
Eq
является альтернативой стандартному методу джавового equals
. Проблема с Java equals
заключается в том, что мы можем сравнить два совершенно не связанных между собой типа и не получим ошибку от компилятора (максимум получим предупреждение), что может привести к веселым багам.
Введением этого тайпкласса, мы отсечем возможность сравнивать значения с разными типами на уровне компиляции.
Рассмотрим пример реализации тайпкласса:
Пример использования Eq
c примитивными типами.
Попытка сравнить значения с разными типами будет жестко пресечена компилятором.
Пример использования Eq
c кастомными типами.
Заключение
Освоение имплиситов и тайпклассов является важным шагом на пути становления Scala разработчика. Однако, важно использовать их с умом. Правильно применяемые имплиситы и тайпклассы способствуют написанию лаконичного, выразительного и легко расширяемого кода, подчеркивая при этом мощь Scala.