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 проект с примерами.

Комментарии (7)


  1. Hixon10
    23.04.2018 00:13

    Всё это, конечное же, выглядит супер классно, но, как вы заметили, отлаживать это — такое себе удовольствие. Даже IDEA не всегда может помочь, к сожалению.


    1. Envy
      23.04.2018 09:56
      +1

      отлаживать такое значительно проще, чем ошибки в рантайме. Компилятор поможет.


    1. andr1983
      23.04.2018 12:11

      На самом деле, как правильно заметил Envy, компилятор хорошо помогает в отладке. Просто нужно понимать куда смотреть. Зато никаких рантайм проверок, всё на этапе компиляции и в итоге код получается безопаснее.


  1. acmnu
    23.04.2018 12:39

    Основная критика была направлена но то, что Scala слишком мощный язык с высоким уровнем свободы, где одно и тоже можно реализовать различными способами.

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


    1. andr1983
      23.04.2018 12:49

      Действительно есть такое. Из-за проблем с обратной совместимостью и крупные игроки, типа Confluent, бывает переходят обратно на Java. С другой стороны, это позволяет языку двигаться вперед и развиваться. В принципе, это и послужило причиной появления Dotty.


  1. JediPhilosopher
    23.04.2018 15:18

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

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

    Главное совершенно непонятно зачем оно вообще нужно, типа лень написать дополнительный параметр метода? Экономия на спичках.


    1. andr1983
      23.04.2018 16:02

      Бездумное использование implicit, конечно же может привезти к тому, о чём вы пишите. Всё надо делать с умом. Конкретно имплиситными параметрами любят злоупотреблять, но есть случаи, где они полезны. Один из примеров я привел в статье — ad hoc полиморфизм. Т.е. в данной строчке

      def as[A](path: String)(implicit reader: Reader[A]): Reader.Result[A]

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

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

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