[Из песочницы] Понимаем implicit'ы в Scala
В последнее время у меня было несколько разговоров с друзьями из Java мира об их опыте использования Scala. Большинство использовали Scala, как улучшенную Java и, в итоге, были разочарованы. Основная критика была направлена, но то, что Scala слишком мощный язык с высоким уровнем свободы, где одно и тоже можно реализовать различными способами. Ну и вишенкой на торте недовольства являются, конечно же, implicit’ы. Я соглашусь, что implicit’ы одна из самых спорных фич языка, особенно для новичков. Само название «неявные», как бы намекает. В неопытных руках implicit’ы могут стать причиной плохого дизайна приложения и множества ошибок. Я думаю каждый, работающий со Scala, хотя бы раз сталкивался с ошибками разрешения ипмлиситных зависимостей и первые мысли были что делать? куда смотреть? как решить проблему? В результате приходилось гуглить или даже читать документацию к библиотеке, если она есть, конечно же. Обычно решение находится импортом необходимых зависимостей и проблема забывается до следующего раза.
В этом посте я бы хотел рассказать о некоторых распространенных практиках использования имплиситов и помочь их сделать более «явными» и понятными. Наиболее распространенные варианты их использования:
- Неявные параметры (implicit parameters)
- Неявные преобразования (implicit conversions)
- Неявные классы (implicit classes — «Pimp My Library» паттерн)
- Тайп-классы (type classes)
В сети много статей, документации и докладов, посвященных этой теме. Я, однако, хотел бы остановиться на их практическом применении на примере создания Scala-friendly API для замечательной Java библиотеки TypesafeLightbend Config. Для начала нужно ответить на вопрос, а что, собственно, не так с родным API? Давайте взглянем на пример из документации.
import com.typesafe.config.ConfigFactory
val conf = ConfigFactory.load();
val foo = config.getString("simple-lib.foo")
val bar = config.getInt("simple-lib.bar")
Я вижу здесь, как минимум, две проблемы:
- Обработка ошибок. Например, если метод
getInt
не сможет вернуть значение нужного типа, то будет брошено исключение. А мы хотим писать «чистый» код, без исключений. - Расширяемость. Этот API поддерживает некоторые Java типы, но что, если мы захотим расширить поддержку типов?
Давайте начнем со второй проблемы. Стандартное Java решение — наследование. Мы можем расширить функциональность базового класса путем добавления новых методов. Обычно это не является проблемой, если вы владеете кодом, но что делать если это сторонняя библиотека? «Наивный» путь решения в Scala будет через использование неявных классов или «Pimp My Library» паттерна.
implicit class RichConfig(val config: Config) extends AnyVal {
def getLocalDate(path: String): LocalDate = LocalDate.parse(config.getString(path), DateTimeFormatter.ISO_DATE)
}
Теперь мы можем использовать метод getLocalDate
, как если бы он был определен в исходном классе. Неплохо. Но мы решили проблему только локально и мы должны поддерживать всю новую функциональность в одном RichConfig
классе или потенциально иметь ошибку «Ambiguous implicit values», если одинаковые методы будут определены в разных неявных классах.
Можно ли как-то это улучшить? Здесь давайте вспомним, что обычно в Java, наследование используется для реализации полиморфизма. На самом деле, полиморфизм бывает разных видов:
- Ad hoc полиморфизм.
- Параметрический полиморфизм.
- Полиморфизм подтипов.
Наследование используется для реализации полиморфизма подтипов. Нас же интересует ad hoc полиморфизм. Он означает, что мы будем использовать другую реализацию в зависимости от типа параметра. В Java это реализуется при помощи перегрузки методов. В Scala его можно дополнительно реализовать при помощи тайп классов. Эта концепция пришла из Haskel, где является встроенной в язык, а в Scala это паттерн, который требует implicit’ов для реализации. Если описать вкратце, то тайп класс — это некоторый контракт, например трейт Foo[T]
, параметризованный типом T
, который используется в разрешении неявных зависимостей и нужная имплементация контракта выбирается по типу. Звучит запутано, но на самом деле это просто.
Давайте рассмотрим на примере. Для нашего случая, определим контракт для чтения значения из конфига:
trait Reader[A] {
def read(config: Config, path: String): Either[Throwable, A]
}
Как мы видим, трейт Reader
параметризирован типом A
. Для решения первой проблемы мы возвращаем Either
. Больше никаких исключений. Для упрощения кода можем написать тайп алиас.
trait Reader[A] {
def read(config: Config, path: String): Reader.Result[A]
}
object Reader {
type Result[A] = Either[Throwable, A]
def apply[A](read: (Config, String) => A): Reader[A] = new Reader[A] {
def read[A](config: Config, path: String): Result[A] = Try(read(config, path)).toEither
}
implicit val intReader = Reader[Int]((config: Config, path: String) => config.getInt(path))
implicit val stringReader = Reader[String]((config: Config, path: String) => config.getString(path))
implicit val localDateReader = Reader[LocalDate]((config: Config, path: String) => LocalDate.parse(config.getString(path), DateTimeFormatter.ISO_DATE);)
}
Мы определили тайп класс Reader и добавили несколько реализаций для типов Int
, String
, LocalDate
. Теперь нужно научить Config
работать с нашим тайп классом. И здесь уже пригодится «Pimp My Library» паттерн и неявные аргументы:
implicit class ConfigSyntax(config: Config) extends AnyVal {
def as[A](path: String)(implicit reader: Reader[A]): Reader.Result[A] = reader.read(config, path)
}
Мы можем переписать более кратко при помощи ограничения контекста (context bounds):
implicit class ConfigSyntax(config: Config) extends AnyVal {
def as[A : Reader](path: String): Reader.Result[A] = implicitly[Reader[A]].read(config, path)
}
И теперь, пример использования:
val foo = config.as[String]("simple-lib.foo")
val bar = config.as[Int]("simple-lib.bar")
Тайп классы — очень мощный механизм, который позволяет писать легко расширяемый код. Если требуется поддержка новых типов, то можно просто написать реализацию нужного тайп класса и поместить её в контекст. Также, используя приоритет в разрешении неявных зависимостей, можно переопределять стандартную реализацию. Например, можно определить другой вариант LocalDate
ридера:
implicit val localDateReader2 = Reader[LocalDate]((config: Config, path: String) =>
Instant
.ofEpochMilli(config.getLong(path))
.atZone(ZoneId.systemDefault())
.toLocalDate()
)
Как мы видим, implicit’ы, при правильном использовании, позволяют писать чистый и расширяемый код. Они позволяют расширить функциональность сторонних библиотек, без изменения исходного кода. Позволяют писать обобщённый код и использовать ad hoc полиморфизм при помощи тайп классов. Нет необходимости беспокоиться о сложной иерархии классов, можно просто разделить функциональность на части и реализовывать их отдельно. Принцип разделяй и властвуй в действии.
Github проект с примерами.