Привет! Меня зовут Сергей Грибков, я тимлид команды FM&RA в билайне, и в этом посте я хочу рассказать об одной фирменной особенности Scala под названием implicits. Это неявные параметры, неявные преобразования, неявные классы.

Почему неявные — потому что они не требуют прямого вызова, если мы говорим о методах, не требуют прямой передачи в метод, если мы говорим о параметрах, и так далее.

В Scala implicits широко распространены. Скорее всего, вы уже сталкивались с ними в различных библиотеках и фреймворках, например, Apache Spark. 

Чтобы успешно использовать implicits в собственном коде и работать со сторонними библиотеками, требуется понимание принципов их работы. Поэтому давайте разберем, как всё устроено.

Итак, существует три основных категории implicits:

  1. implicit conversions — неявные преобразования, 

  2. implicit parameters — неявные параметры методов

  3. conditional implicits – неявные преобразования по условию, сочетание первых двух.

Implicit conversions

Самая старая категория, которая появилась еще в 2004 году. Ее создали для решения проблемы позднего расширения. 

Например, у нас есть трейт с каким-то функционалом, нужно чтобы этим функционалом обладал некий класс C из какой-то сторонней библиотеки. Наследование здесь невозможно. Для решения этой проблемы был внедрен механизм неявной конвертации типов.

Допустим, наша задача получать схему avro из dataframe. Сам по себе dataframe такой функциональности не имеет, поэтому мы ее добавим с помощью implicit conversions.

Создадим приложение для получения схемы avro:

import org.apache.avro.Schema
import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.sql.avro.SchemaConverters
object ImplicitConversions {
  class DfAvroSchema(df: DataFrame) {
    def getAvroSchema(nameSpace: String, recName: String): Schema =
      SchemaConverters.toAvroType(df.schema, recordName = recName, nameSpace = nameSpace)
  }
  implicit def dfToDfAvroSchema(df: DataFrame): DfAvroSchema = new DfAvroSchema(df)
  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.toLevel("OFF"))
    val spark: SparkSession = SparkSession.builder
      .master("local[]")
      .appName("ImplicitConversions")
      .getOrCreate()
    import spark.implicits._
    val currencyRatesDf = currencyRates.toDF()
    val avroSchema =
      currencyRatesDf.getAvroSchema("currency", "currency_rates_rec")
    println(avroSchema.toString(true))
  }
}

Мы будем работать с dataframe, содержашим данные о курсах валют. Состав его полей следующий: date: String, code: Long, name: String, rate: Double

Сначала создаем класс DfAvroSchema с dataframe в качестве параметра конструктора. В классе реализуем метод getAvroSchema для получения схемы avro из dataframe.

Но, опять же, для того, чтобы этот метод применить к dataframe, мы должны иметь возможность преобразовывать экземпляр dataframe, из которого требуется получить схему avro, в DfAvroSchema.

Для этого мы создаем implicit-метод, который принимает в качестве аргумента dataframe и возвращает экземпляр DfAvroSchema.

Обратите внимание: implicit — это ключевое слово, без него неявного преобразования не будет. Теперь возможен вызов метода getAvroSchema у dataframe.

Попробуем запустить приложение. Мы помним, какие у нас должны быть поля в схеме -date, code, name и rate. Проверяем – все работает, состав полей корректный. 

Казалось бы, все хорошо, но есть нюанс - если поступивший на вход метода объект не может быть обработан корректно, и вы узнаете об этом уже в runtime, а не на этапе компиляции. 

Вообще, риск получить неожиданное поведение при использовании этой категории implicits присутствует всегда. Так что использовать implicit conversions стоит только если решить задачу более безопасно невозможно. Как сделать такие преобразования более безопасными, используя conditional implicit, мы рассмотрим дальше.

Implicit-параметры

Задача implicit-параметров - повторное использование контекста в цепочке вызовов.

Вот пример кода:

import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.{DataFrame, SparkSession}
object ImplicitsParameters {
  def readCsv(path: String, header: Boolean)(implicit spark: SparkSession): DataFrame =
    spark.read.option("header", header).csv(path)
  def readJson(path: String)(implicit spark: SparkSession): DataFrame =
    spark.read.json(path)
  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.toLevel("OFF"))
    implicit val spark: SparkSession = SparkSession.builder
      .master("local[]")
      .appName("ImplicitsParameters")
      .getOrCreate()
    val dfFromCsv = readCsv("src/main/resources/datasets/offense_codes.csv", true)
    dfFromCsv.show()
    val dfFromJson = readJson("src/main/resources/datasets/currency_rates.json")
    dfFromJson.show()
  }
}

Здесь у нас есть два метода – один для чтения CSV-файлов, другой - JSON. В оба в качестве параметра передается экземпляр Spark-сессии. Но передавать его напрямую, как мы видим, не требуется.

Мы объявляем наш экземпляр Spark сессии implicit. Теперь запускаем приложение – видим, что все работает без передачи spark напрямую в каждый метод. Конечно же, параметр в методы передан был, но неявно.

Механизм этот также стоит использовать очень ограниченно — если у вас есть какой-то глобальный контекст, который требуется передать в цепочку вызовов, либо в паттерне type class, который мы рассмотрим позже.

Conditional implicit

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

Здесь мы видим тот же самый неявный метод, но работать он будет только при наличии в зоне видимости (scope) значения, отмеченного как implicit и имеющего заданный тип.

И это более безопасно, потому что поведение можно сделать более предсказуемым.

Доработаем наше приложение для получения схемы avro из dataframe:

import org.apache.avro.Schema
import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.{DataFrame, SparkSession}
import com.hortonworks.registries.schemaregistry.client.SchemaRegistryClient
import scala.jdk.CollectionConverters.MapHasAsJava
import org.apache.spark.sql.avro.SchemaConverters
object ConditionalImplicits {
  class DfAvroSchema(df: DataFrame) {
    def getAvroSchema(nameSpace: String, recName: String): Schema =
      SchemaConverters.toAvroType(df.schema, recordName = recName, nameSpace = nameSpace)
  }
  implicit def dfToDfAvroSchema(df: DataFrame)
                               (implicit client: SchemaRegistryClient): DfAvroSchema = new DfAvroSchema(df)
  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.toLevel("OFF"))
    val spark: SparkSession = SparkSession.builder
      .master("local[]")
      .appName("ConditionalImplicits")
      .getOrCreate()
    import spark.implicits._
    val currencyRatesDf = currencyRates.toDF()
    val clientConfig = Map("schema.registry.url" -> "http://my-schema-registry:8080")
    implicit val client: SchemaRegistryClient = new SchemaRegistryClient(clientConfig.asJava)
    val avroSchema =
      currencyRatesDf.getAvroSchema("currency", "currency_rates_rec")
    println(avroSchema.toString(true))
  }
}

Для чего нам может понадобиться получать схему avro из dataframe? Например, чтобы сохранить ее в Schema registry. Для этого нужен какой-то клиент Schema registry, в нашем случае, из библиотеки Hortonworks. Его экземпляр мы и будем неявно передавать в наш метод. 

Видим в коде – создается экземпляр SchemaRegistryClient и неявно передается в метод getAvroSchema, добавленный для dataframe с помощью неявного преобразования. 

Запускаем приложение и получаем схему avro на основе нашего dataframe. Видим – схема снова корректна.

Но при отсутствии в scope этого клиента Schema registry преобразование выполняться не будет, и функциональность будет недоступна. И это уже более безопасно чем implicit conversions.

Поиск implicits

Итак, я упоминал о поиске implicits и scope. Откуда вообще компилятор берет эти неявные значения? Как он определяет, что должно быть передано, например, в качестве неявного параметра?

Во-первых, объект должен быть помечен ключевым словом implicit, во-вторых, ее тип должен соответствовать объявленному, и в-третьих, она должна находиться в соответствующем implicit scope, то есть зоне видимости.

Зоны видимости бывают разные, у них есть своя иерархия. 

В первую очередь поиск происходит в локальном scope, в том числе среди импортированных объектов. Именно после импорта чаще всего возникает конфликт implicit.

Далее поиск ведется в package object, доступном для классов и объектов пакета.

После компилятор ищет подходящие значения в объекте-компаньоне исходного типа, который указан в implicit параметре. Если вы хотите добавить implicits в свой код, рекомендуется размещать их именно в объекте-компаньоне – там их скорее всего будут искать в первую очередь.

Последний уровень - объект-компаньон супертипа, если было наследование.

При конфликте implicits возникает ошибка ambiguous implicit value, и обнаружить ее источник порой непросто, хотя IDE может вам с этим помочь, но об этом позже. Поэтому всегда стоит иметь в виду, что с наследованием, импортом и т.д. в ваш код могут попасть implicits.

Вы можете посмотреть примеры размещения implicits в разных scope и смоделировать их конфликты, раскомментировав одни блоки кода и закомментировав другие, используя этот код (пакет scopes).

Type сlass

Теперь рассмотрим очень популярный паттерн, использующий implicit — type class. Это параметризированный трейт с неким функционалом, который требуется применить к широкому спектру типов. Этот паттерн позволяет обеспечить ad hoc полиморфизм без наследования и перегрузки методов.

Ad hoc полиморфизм — это изменение поведения в зависимости от типа параметра, с которым работает метод. И он может быть полезен, например, когда вы работаете со сторонними библиотеками и вам нужно к какому-то классу добавить новую функциональность, как в нашем примере с dataframe.

Для того, чтобы реализовать этот паттерн, нужны 3 обязательных компонента и 2 дополнительных.

В первую очередь нам нужен сам трейт с параметром типа, объектом-компаньоном, и неким абстрактным методом, который должен работать с объектами разных типов и менять свое поведение в зависимости от типа. 

Также понадобятся неявные значения для получения экземпляров этого трейта различных типов.

Кроме того, потребуется метод с неявными параметрами, который будет работать непосредственно с объектами разных типов.

Дополнительные компонеты - implicit-класс и summoner-метод.

Допустим, нам нужно преобразовывать элементы dataframe и dataset, содержащих данные о курсах валют, в JSON строку. Dataset имеет тип CurrencyRateRec:

case class CurrencyRateRec(date: String, code: Long, name: String, rate: Double)

Такое преобразование возможно реализовать в Spark-приложении с помощью стандартных инструментов, но мы для наглядности сделаем все сами. Кроме того, добавим дополнительную функциональность для dataset - перевод содержимого поля name в верхний регистр и выбор только трех первых символов, форматирование поля code, и преобразование дат в поле date в UNIX-формат. 

Сначала рассмотрим базовый вариант приложения:

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.{Row, SparkSession}
import scala.util.parsing.json.JSONObject
object TypeClasses1 {
  trait JsonEncoder[T] {
    def encode(entity: T): String
  }
  object JsonEncoder {
    implicit val rowEncoder: JsonEncoder[Row] =
      new JsonEncoder[Row] {
        override def encode(entity: Row): String = {
          val data = entity.getValuesMap(entity.schema.fieldNames)
          JSONObject(data).toString()
        }
      }
    implicit val curRateRecEncoder: JsonEncoder[CurrencyRateRec] =
      new JsonEncoder[CurrencyRateRec] {
        override def encode(entity: CurrencyRateRec): String = {
          val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
          val dateUnix = LocalDate.parse(entity.date, formatter).toEpochDay
          val data = Map(
            "date" -> dateUnix,
            "code" -> "%03d".format(entity.code),
            "name" -> entity.name.take(3).toUpperCase,
            "rate" -> entity.rate
          )
          JSONObject(data).toString()
        }
      }
  }
  def toJson[T](entity: T)(implicit ev: JsonEncoder[T]): String = ev.encode(entity)
  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.toLevel("OFF"))
    val spark: SparkSession = SparkSession.builder
      .master("local[]")
      .appName("TypeClasses1")
      .getOrCreate()
    import spark.implicits.
    val currencyRatesDf = currencyRates.toDF()
    val currencyRatesDs = currencyRates.toDS()
    val currencyRatesJsonDf = currencyRatesDf.map(toJson())
    currencyRatesJsonDf.show(truncate = false)
    val currencyRatesJsonDs = currencyRatesDs.map(toJson())
    currencyRatesJsonDs.show(truncate = false)
  }
}

В первую очередь создаем трейт JsonEncoder с абстрактным методом encode, который принимает параметр entity с типом T и возвращает JSON-строку. 

Теперь для трейта JsonEncoder добавим объект-компаньон. В нем должны находиться implicit экземпляры JsonEncoder для всех типов, с которыми потребуется работать. Мы создадим экземпляры для типов Row для dataframe и CurrencyRateRec для dataset. Именно в них реализуем метод encode, то есть определим, как будет выполняться преобразование для каждого конкретного типа.

Непосредственно с элементами dataframe и dataset будет работать метод toJson, который неявно принимает экземпляр JsonEncoder с типом T и явно некий entity с тем же типом. И уже на экземпляре JsonEncoder вызывается метод encode, в который передается entity. Тип entity и JsonEncoder, как видим, является абстрактным.

Преобразование dataframe и dataset выполним с помощью метода map, поместив каждый элемент в toJson в качестве явного параметра. При этом неявный параметр – экземпляр JsonEncoder соотвествующего типа – метод возьмет из объекта-компаньона JsonEncoder

Помним, что если подать на вход метода toJson явный параметр такого типа, для которого в объекте-компаньоне  JsonEncoder нет экземпляра, он не сработает. Вы узнаете об этом еще на этапе компиляции. В этом одно из преимуществ паттерна type class – предсказуемое поведение.

Запускаем приложение – видим, что оно работает и преобразования выполняются корректно. Но не очень удобно использовать отдельностоящий метод toJson, хотелось бы вызывать его непосредственно на dataframe или dataset. В этом нам помогут дополнительные компоненты type class - implicit class и summoner метод.

Доработаем наше приложение:

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.{Row, SparkSession}
import scala.util.parsing.json.JSONObject
object TypeClasses2 {
  trait JsonEncoder[T] {
    def encode(entity: T): String
  }
  object JsonEncoder {
    implicit val rowEncoder: JsonEncoder[Row] =
      new JsonEncoder[Row] {
        override def encode(entity: Row): String = {
          val data = entity.getValuesMap(entity.schema.fieldNames)
          JSONObject(data).toString()
        }
      }
    implicit val curRateRecEncoder: JsonEncoder[CurrencyRateRec] =
      new JsonEncoder[CurrencyRateRec] {
        override def encode(entity: CurrencyRateRec): String = {
          val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
          val dateUnix = LocalDate.parse(entity.date, formatter).toEpochDay
          val data = Map(
            "date" -> dateUnix,
            "code" -> "%03d".format(entity.code),
            "name" -> entity.name.take(3).toUpperCase,
            "rate" -> entity.rate
          )
          JSONObject(data).toString()
        }
      }
  }
  implicit class JsonEncoderSyntax[T](entity: T) {
    def toJson(implicit enc: JsonEncoder[T]): String = enc.encode(entity) 
  }
  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.toLevel("OFF"))
    val spark: SparkSession = SparkSession.builder
      .master("local[*]")
      .appName("TypeClasses2")
      .getOrCreate()
    import spark.implicits.
    val currencyRatesDf = currencyRates.toDF()
    val currencyRatesDs = currencyRates.toDS()
    val currencyRatesJsonDf = currencyRatesDf.map(.toJson)
    currencyRatesJsonDf.show(truncate = false)
    val currencyRatesJsonDs = currencyRatesDs.map(.toJson)
    currencyRatesJsonDs.show(truncate = false)
  }
}

Сначала создадим implicit класс с типом T и параметром конструктора entity, тоже типа T. Как видим, entity из параметра метода toJson перешел в конструктор implicit класса. Сам метод toJson, который осуществляет преобразования в JSON-строку, мы тоже разместим в implicit-классе. Возможны три варианта его реализации. 

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

Первый вариант – добавить в метод toJson impilcit-параметр типа JsonEncoder[T], и на нем вызывать метод encode, передавая в качестве параметра entity. При вызове метода экземпляр JsonEncoder нужного типа будет взят из его же объекта-компаньона.

implicit class JsonEncoderSyntax[T](entity: T) {
    def toJson(implicit enc: JsonEncoder[T]): String = enc.encode(entity) 
  }

Для второго варианта сначала добавим в объект-компаньон JsonEncoder так называемый summoner метод с implicit параметром JsonEncoder[T], который будет возвращать его же. Он нужен для получения экземпляра JsonEncoder в implicit классе.

def apply[T](implicit ev: JsonEncoder[T]): JsonEncoder[T] = ev

В implicit-классе сначала понадобится добавить context bound для параметра типа. В методе toJson implicit-параметр больше не нужен – мы можем получить экземпляр JsonEncoder с помощью summoner метода и вызвать на нем encode.

implicit class JsonEncoderSyntax[T : JsonEncoder](entity: T) {
    def toJson: String = JsonEncoder[T].encode(entity)
  }

Третий вариант самый лаконичный – нам не понадобится summoner-метод, но по прежнему потребуется context bound. Экземпляр JsonEncoder для вызова метода encode мы получим с помощью специального метода implicitly.

implicit class JsonEncoderSyntax[T : JsonEncoder](entity: T) {
    def toJson: String = implicitly[JsonEncoder[T]].encode(entity)
  }

Все три варианта дают одинаковый результат, различается только дизайн. Первый вариант, на мой взгляд, более прозрачный, второй и третий подходит тем, кто предпочитает context bound

Implicits в IntelliJ IDEA

Теперь немного о том, как работать с implicits в IntelliJ IDEA. Невооруженным глазом в коде они не видны, что порой затрудняет его написание и отладку. Но есть специальные сочетания горячих клавиш для отображения implicits.

Кстати, чтобы убедиться, насколько implicits распространены, откройте в IntellijIdea какой-нибудь проект со Spark-приложением и нажмите Ctrl Alt Shift +.

Итоги

Конечно, мы рассмотрели только базовые варианты исользования implicits. Вся сила тех же type классов раскрывается при конструировании более сложных типов. Это активно используется в различных библиотеках, например, при работе с JSON, и много где еще.

Как мы убедились сегодня, возможности implicits при правильном использовании очень велики. Но используя их, и не только, мы должны придерживаться принципа наименьшей силы — если вам нужно забить гвоздь, не нужно для этого использовать кувалду. Будет достаточно и молотка. Поэтому все должно быть соразмерно, целесообразно и обоснованно.

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