Понимаем implicit’ы в Scala

Reading Time: 3 minutes

В последнее время у меня было несколько разговоров с друзьями из Java мира об их опыте использования Scala. Большинство использовали Scala, как улучшенную Java и, в итоге, были разочарованы.

Основная критика была направлена но то, что Scala слишком мощный язык с высоким уровнем свободы, где одно и тоже можно реализовать различными способами. Ну и вишенкой на торте недовольства являются, конечно же, implicit’ы. Я соглашусь, что implicit’ы одна из самых спорных фич языка, особенно для новичков. Само название «неявные», как бы намекает. В неопытных руках implicit’ы могут стать причиной плохого дизайна приложения и множества ошибок. Я думаю каждый, работающий со Scala, хотя бы раз сталкивался с ошибками разрешения ипмлиситных зависимостей и первые мысли были что делать? куда смотреть? как решить проблему? В результате приходилось гуглить или даже читать документацию к библиотеке, если она есть, конечно же. Обычно решение находится импортом необходимых зависимостей и проблема забывается до следующего раза.

В этом посте я бы хотел рассказать о некоторых распространенных практиках использования имплиситов и помочь их сделать более «явными» и понятными. Наиболее распространенные варианты их использования:

  • Неявные параметры (implicit parameters)
  • Неявные преобразования (implicit conversions)
  • Неявные классы (implicit classes — «Pimp My Library» паттерн)
  • Тайп-классы (type classes)

В сети много статей, документации и докладов, посвященных этой теме. Я, однако, хотел бы остановиться на их практическом применении на примере создания Scala-friendly API для замечательной Java библиотеки Typesafe Lightbend 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")

Я вижу здесь, как минимум, две проблемы:

  1. Обработка ошибок. Например, если метод getInt не сможет вернуть значение нужного типа, то будет брошено исключение. А мы хотим писать «чистый» код, без исключений.
  2. Расширяемость. Этот 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, наследование используется для реализации полиморфизма. На самом деле, полиморфизм бывает разных видов:

  1. Ad hoc полиморфизм.
  2. Параметрический полиморфизм.
  3. Полиморфизм подтипов.

Наследование используется для реализации полиморфизма подтипов. Нас же интересует 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 проект с примерами.

Статья на Habrahabr.

This post is also available in: Английский

Оставьте комментарий