Главная » Хабрахабр » [Из песочницы] Понимаем implicit’ы в Scala

[Из песочницы] Понимаем implicit’ы в Scala

image

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

  • Неявные параметры (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")

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

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


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

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Прогнозирование продаж недвижимости. Лекция в Яндексе

Успех в проектах по машинному обучению обычно связан не только с умением применять разные библиотеки, но и с пониманием той области, откуда взяты данные. Отличной иллюстрацией этого тезиса стало решение, предложенное командой Алексея Каюченко, Сергея Белова, Александра Дроботова и Алексея ...

Как IaaS приходит в ритейл и производство: кто и зачем перешел на виртуальную инфраструктуру

Есть мнение, что «облако» — это лишь маркетинговое название. Оно часто упоминается к месту и не к месту, а что скрывается под этим понятием люди не из бизнеса и не из ИТ не всегда знают. У себя в блоге мы ...