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

image

В последнее время у меня было несколько разговоров с друзьями из 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 проект с примерами.
  • +11
  • 3,4k
  • 7
Поделиться публикацией

Похожие публикации

Комментарии 7
    0
    Всё это, конечное же, выглядит супер классно, но, как вы заметили, отлаживать это — такое себе удовольствие. Даже IDEA не всегда может помочь, к сожалению.
      +2
      отлаживать такое значительно проще, чем ошибки в рантайме. Компилятор поможет.
        0
        На самом деле, как правильно заметил Envy, компилятор хорошо помогает в отладке. Просто нужно понимать куда смотреть. Зато никаких рантайм проверок, всё на этапе компиляции и в итоге код получается безопаснее.
        0
        Основная критика была направлена но то, что Scala слишком мощный язык с высоким уровнем свободы, где одно и тоже можно реализовать различными способами.

        Меня больше всего достают проблемы обратной совместимости. Фактический лок минорной версии в pom файлах очень частое явление.

          0
          Действительно есть такое. Из-за проблем с обратной совместимостью и крупные игроки, типа Confluent, бывает переходят обратно на Java. С другой стороны, это позволяет языку двигаться вперед и развиваться. В принципе, это и послужило причиной появления Dotty.
          0
          Я на скале не так уж много писал, но как раз вот имплиситы мне показались каким-то воплощением зла. Ладно еще неявные преобразования типов — немного магии и больше не надо писать простыни конвертаций при вызове Java-кода. Но вот например implicit параметры методов это какой-то вообще ад за гранью добра и зла. Код становится абсолютно нечитаемым, так как на логику безобидно выглядящей строчки может внезапно влиять строчка двумя экранами выше, при этом между этими двумя строчками нет ничего общего (общих переменных) и IDE тут бессильна помочь. Да что там, с имплиситами даже код со Stackoverflow больше нельзя скопировать! На святое покусились! Копируешь кусок кода, вроде бы все необходимые переменные (те которые в нем используются) в нем объявлены, а он не работает. Или что еще хуже — работает как-то не так. Потому что пролез какой-то имплисит откуда-то, или наоборот не пролез.

          Мне это напоминает шутку с инструкцией COMEFROM как злого аналога GOTO — совершенно посторонний кусок кода, находящийся возможно даже в другом файле, может сломать вот этот казалось бы совершенно от него независящий фрагмент программы.

          Главное совершенно непонятно зачем оно вообще нужно, типа лень написать дополнительный параметр метода? Экономия на спичках.
            +1
            Бездумное использование implicit, конечно же может привезти к тому, о чём вы пишите. Всё надо делать с умом. Конкретно имплиситными параметрами любят злоупотреблять, но есть случаи, где они полезны. Один из примеров я привел в статье — ad hoc полиморфизм. Т.е. в данной строчке
            def as[A](path: String)(implicit reader: Reader[A]): Reader.Result[A]

            он необходим, чтобы была возможность в дальнейшем писать такой код:
            config.as[String]("path")

            Это всё же не то же самое, что
            config.as[String]("path")(stringReader)

            Вариант с имплиситным параметром более гибкий и простой в использовании.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое