Привет! Меня зовут Сергей Грибков, я тимлид команды FM&RA в билайне, и в этом посте я хочу рассказать об одной фирменной особенности Scala под названием implicits. Это неявные параметры, неявные преобразования, неявные классы. Почему неявные — потому что они не требуют прямого вызова, если мы говорим о методах, не требуют прямой передачи в метод, если мы говорим о параметрах, и так далее. В Scala implicits широко распространены. Скорее всего, вы уже сталкивались с ними в различных библиотеках и фреймворках, например, Apache Spark.  Чтобы успешно использовать implicits в собственном коде и работать со сторонними библиотеками, требуется понимание принципов их работы. Поэтому давайте разберем, как всё устроено. Итак, существует три основных категории implicits: implicit conversions — неявные преобразования,  implicit parameters — неявные параметры методов conditional implicits – неявные преобразования по условию, сочетание первых двух. Самая старая категория, которая появилась еще в 2004 году. Ее создали для решения проблемы позднего расширения.  Например, у нас есть трейт с каким-то функционалом, нужно чтобы этим функционалом обладал некий класс C из какой-то сторонней библиотеки. Наследование здесь невозможно. Для решения этой проблемы был внедрен механизм неявной конвертации типов. Допустим, наша задача получать схему avro из dataframe. Сам по себе dataframe такой функциональности не имеет, поэтому мы ее добавим с помощью implicit conversions. Создадим приложение для получения схемы avro: Мы будем работать с 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-параметров - повторное использование контекста в цепочке вызовов. Вот пример кода: Здесь у нас есть два метода – один для чтения CSV-файлов, другой - JSON. В оба в качестве параметра передается экземпляр Spark-сессии. Но передавать его напрямую, как мы видим, не требуется. Мы объявляем наш экземпляр Spark сессии implicit. Теперь запускаем приложение – видим, что все работает без передачи spark напрямую в каждый метод. Конечно же, параметр в методы передан был, но неявно. Механизм этот также стоит использовать очень ограниченно — если у вас есть какой-то глобальный контекст, который требуется передать в цепочку вызовов, либо в паттерне type class, который мы рассмотрим позже. Объединение предыдущих двух категорий, то есть неявное преобразование при наличии неявного параметра. Здесь мы видим тот же самый неявный метод, но работать он будет только при наличии в зоне видимости (scope) значения, отмеченного как implicit и имеющего заданный тип. И это более безопасно, потому что поведение можно сделать более предсказуемым. Доработаем наше приложение для получения схемы avro из dataframe: Для чего нам может понадобиться получать схему avro из dataframe? Например, чтобы сохранить ее в Schema registry. Для этого нужен какой-то клиент Schema registry, в нашем случае, из библиотеки Hortonworks. Его экземпляр мы и будем неявно передавать в наш метод.  Видим в коде – создается экземпляр SchemaRegistryClient и неявно передается в метод getAvroSchema, добавленный для dataframe с помощью неявного преобразования.  Запускаем приложение и получаем схему avro на основе нашего dataframe. Видим – схема снова корректна. Но при отсутствии в scope этого клиента Schema registry преобразование выполняться не будет, и функциональность будет недоступна. И это уже более безопасно чем implicit conversions. Итак, я упоминал о поиске implicits и scope. Откуда вообще компилятор берет эти неявные значения? Как он определяет, что должно быть передано, например, в качестве неявного параметра? Во-первых, объект должен быть помечен ключевым словом implicit, во-вторых, ее тип должен соответствовать объявленному, и в-третьих, она должна находиться в соответствующем implicit scope, то есть зоне видимости. Зоны видимости бывают разные, у них есть своя иерархия.  В первую очередь поиск происходит в локальном scope, в том числе среди импортированных объектов. Именно после импорта чаще всего возникает конфликт implicit. Далее поиск ведется в package object, доступном для классов и объектов пакета. После компилятор ищет подходящие значения в объекте-компаньоне исходного типа, который указан в implicit параметре. Если вы хотите добавить implicits в свой код, рекомендуется размещать их именно в объекте-компаньоне – там их скорее всего будут искать в первую очередь. Последний уровень - объект-компаньон супертипа, если было наследование. При конфликте implicits возникает ошибка ambiguous implicit value, и обнаружить ее источник порой непросто, хотя IDE может вам с этим помочь, но об этом позже. Поэтому всегда стоит иметь в виду, что с наследованием, импортом и т.д. в ваш код могут попасть implicits. Вы можете посмотреть примеры размещения implicits в разных scope и смоделировать их конфликты, раскомментировав одни блоки кода и закомментировав другие, используя этот код (пакет scopes). Теперь рассмотрим очень популярный паттерн, использующий implicit — type class. Это параметризированный трейт с неким функционалом, который требуется применить к широкому спектру типов. Этот паттерн позволяет обеспечить ad hoc полиморфизм без наследования и перегрузки методов. Ad hoc полиморфизм — это изменение поведения в зависимости от типа параметра, с которым работает метод. И он может быть полезен, например, когда вы работаете со сторонними библиотеками и вам нужно к какому-то классу добавить новую функциональность, как в нашем примере с dataframe. Для того, чтобы реализовать этот паттерн, нужны 3 обязательных компонента и 2 дополнительных. В первую очередь нам нужен сам трейт с параметром типа, объектом-компаньоном, и неким абстрактным методом, который должен работать с объектами разных типов и менять свое поведение в зависимости от типа.  Также понадобятся неявные значения для получения экземпляров этого трейта различных типов. Кроме того, потребуется метод с неявными параметрами, который будет работать непосредственно с объектами разных типов. Дополнительные компонеты - implicit-класс и summoner-метод. Допустим, нам нужно преобразовывать элементы dataframe и dataset, содержащих данные о курсах валют, в JSON строку. Dataset имеет тип CurrencyRateRec: Такое преобразование возможно реализовать в Spark-приложении с помощью стандартных инструментов, но мы для наглядности сделаем все сами. Кроме того, добавим дополнительную функциональность для dataset - перевод содержимого поля name в верхний регистр и выбор только трех первых символов, форматирование поля code, и преобразование дат в поле date в UNIX-формат.  Сначала рассмотрим базовый вариант приложения: В первую очередь создаем трейт 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 метод. Доработаем наше приложение: Сначала создадим implicit класс с типом T и параметром конструктора entity, тоже типа T. Как видим, entity из параметра метода toJson перешел в конструктор implicit класса. Сам метод toJson, который осуществляет преобразования в JSON-строку, мы тоже разместим в implicit-классе. Возможны три варианта его реализации.  В приведенном выше коде реализован первый вариант, далее мы рассмотрим каждый вариант в той части, которая отличает его от других. Первый вариант – добавить в метод toJson impilcit-параметр типа JsonEncoder[T], и на нем вызывать метод encode, передавая в качестве параметра entity. При вызове метода экземпляр JsonEncoder нужного типа будет взят из его же объекта-компаньона. Для второго варианта сначала добавим в объект-компаньон JsonEncoder так называемый summoner метод с implicit параметром JsonEncoder[T], который будет возвращать его же. Он нужен для получения экземпляра JsonEncoder в implicit классе. В implicit-классе сначала понадобится добавить context bound для параметра типа. В методе toJson implicit-параметр больше не нужен – мы можем получить экземпляр JsonEncoder с помощью summoner метода и вызвать на нем encode. Третий вариант самый лаконичный – нам не понадобится summoner-метод, но по прежнему потребуется context bound. Экземпляр JsonEncoder для вызова метода encode мы получим с помощью специального метода implicitly. Все три варианта дают одинаковый результат, различается только дизайн. Первый вариант, на мой взгляд, более прозрачный, второй и третий подходит тем, кто предпочитает context bound.  Теперь немного о том, как работать с implicits в IntelliJ IDEA. Невооруженным глазом в коде они не видны, что порой затрудняет его написание и отладку. Но есть специальные сочетания горячих клавиш для отображения implicits. Кстати, чтобы убедиться, насколько implicits распространены, откройте в IntellijIdea какой-нибудь проект со Spark-приложением и нажмите Ctrl Alt Shift +. Конечно, мы рассмотрели только базовые варианты исользования implicits. Вся сила тех же type классов раскрывается при конструировании более сложных типов. Это активно используется в различных библиотеках, например, при работе с JSON, и много где еще. Как мы убедились сегодня, возможности implicits при правильном использовании очень велики. Но используя их, и не только, мы должны придерживаться принципа наименьшей силы — если вам нужно забить гвоздь, не нужно для этого использовать кувалду. Будет достаточно и молотка. Поэтому все должно быть соразмерно, целесообразно и обоснованно.