Scala 3: избавление от implicit. Extension-методы и неявные преобразования

Автор оригинала: Dean Wampler


Это моя вторая статья с обзором изменений в Scala 3. Первая статья была про новый бесскобочный синтаксис.


Одна из наиболее известных фич языка Scala — имплиситы (от англ. implicit — неявный — прим. перев.), механизм, который использовался для нескольких разных целей, например: эмуляция extension-методов (обсудим в этой статье), неявная передача параметров при вызове метода, наложение ограничений на возможный тип и др. Все это — способы абстрагирования контекста.


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


Scala 3 начинает переход от слишком универсального и слишком широко используемого механизма имплиситов к набору отдельных конструкций для решения конкретных задач. Этот переход растянется на несколько релизов Scala, для того, чтобы разработчикам было проще адаптироваться к новым конструкциям без необходимости сразу переписывать на них весь код. Самой Scala также понадобится переходный период, поскольку в библиотеке коллекций (которая без особых изменений перекочевала из Scala 2.13) имплиситы используются крайне активно.


Scala 3 будет побуждать вас начать этот переход, при этом все старые способы использования имплиситов (с небольшими изменениями для большей безопасности) по-прежнему будут работать.


Изменения в имплиситах — это обширная тема, которой посвящены две главы в готовящемся 3-ем издании моей книги Programming Scala. Я разобью ее обсуждение здесь на несколько частей, но даже так мы сможем разобрать только главные изменения. Для всей полноты знаний вам придется купить и прочитать мою книгу :) Ну или просто найти интересующие вас детали в документации к Dotty.


Синтаксис примеров актуален на момент Scala 3.0.0-M3.

Extension-методы


Один из способов создания кортежа из двух элементов в Scala — использовать a -> b, альтернативу привычному всем (a, b). В Scala 2 это реализовано с помощью неявного преобразования из типа переменной a в ArrowAssoc, где определен метод ->:


implicit final class ArrowAssoc[A](private val self: A) extends AnyVal {
  @inline def -> [B](y: B): (A, B) = (self, y)
  @deprecated("Use `->` instead...", "2.13.0")
  def →[B](y: B): (A, B) = ->(y)
}

Обратите внимание, что юникодовская стрелочка помечена как deprecated. Не буду объяснять другие детали, типа @inline. (Ну ладно, эта аннотация говорит компилятору пытаться инлайнить этот код, избегая оверхеда на вызов метода...)


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


Другими словами, Scala 2 использует универсальный механизм имплиситов, чтобы достичь конкретной цели — появления extension-метода. Именно так в других языках (например, в C#) называется способ добавления к типу метода, который объявлен вне этого типа.


В Scala 3 extension-методы становятся сущностями первого класса. Вот как теперь можно переписать ArrowAssoc, используя ~> в качестве имени метода (поскольку настоящий ArrowAssoc все еще существует в Scala 3):


// From https://github.com/deanwampler/programming-scala-book-code-examples/
import scala.annotation.targetName

extension [A, B] (a: A)
  @targetName("arrow2") def ~>(b: B): (A, B) = (a, b) 

Сначала идет ключевое слово extension, после него типы-параметры (в нашем случае — [A, B]). A — это тип, который мы расширяем, значение a позволяет сослаться на экземпляр этого типа, для которого был вызван наш extension-метод (аналог this). Обратите внимание, что я использую новый бесскобочный синтаксис, который мы обсуждали в предыдущей статье. После ключевого слова extension можно указать сколько угодно методов.


Еще одно нововведение в Scala 3 — аннотация @targetName. С ее помощью можно определить буквенно-цифровое имя для методов, выполняющих в Scala роль операторов. Это имя нельзя будет использовать из Scala-кода (нельзя написать a.arrow2(b)), зато можно использовать из Java-кода, чтобы вызвать такой метод. Использовать @targetName теперь рекомендуется для всех "операторных" методов.


Неявные преобразования


С появлением extension-методов вам гораздо реже будет нужна возможность конвертации из одного типа в другой, однако иногда такая возможность также может пригодиться. Например, у вас есть финансовое приложение с case-классами для суммы в валюте, процента налогов и зарплаты. Вы хотите для удобства указывать значения этих величин как литерал типа double с последующим неявным преобразованием в типы из предметной области. Вот как это будет выглядеть в интерпретаторе Scala 3:


scala> import scala.language.implicitConversions
scala> case class Dollars(amount: Double):
     |   override def toString = f"$$$amount%.2f"
     | case class Percentage(amount: Double):
     |   override def toString = f"${(amount*100.0)}%.2f%%" 
     | case class Salary(gross: Dollars, taxes: Percentage):
     |   def net: Dollars = Dollars(gross.amount * (1.0 - taxes.amount))
// defined case class Dollars
// defined case class Percentage
// defined case class Salary

scala> given Conversion[Double,Dollars] = d => Dollars(d)
def given_Conversion_Double_Dollars: Conversion[Double, Dollars]

scala> given d2P: Conversion[Double,Percentage] = d => Percentage(d) 
def d2P: Conversion[Double, Percentage]

scala> val salary = Salary(100_000.0, 0.20)
scala> println(s"salary: $salary. Net pay: ${salary.net}")
salary: Salary($100000.00,20.00%). Net pay: $80000.00

Сначала мы объявляем, что будем использовать неявные преобразования. Для этого надо импортировать implicitConversions. Затем объявляем три case-класса, которые нужны в нашей предметной области.


Далее показан новый способ объявления неявных преобразований. Ключевое слово given заменяет старое implicit def. Смысл остался тот же, но есть небольшие отличия. Для каждого объявления генерируется специальный метод. Если неявное преобразование анонимное, название этого метода также будет сгенерировано автоматически (обратите внимание на префикс given_Conversion в имени метода для первого преобразования).


Новый абстрактный класс Conversion содержит метод apply, в который компилятор подставит тело анонимной функции, которая идет после =. Если необходимо, метод apply можно переопределить явно:


given Conversion[Double,Dollars] with
  def apply(d: Double): Dollars = Dollars(d)

Ключевое слово with знакомо нам по подмешиванию трейтов в Scala 2. Здесь его можно интерпретировать как подмешивание анонимного трейта, который переопределяет реализацию apply в классе Conversion.


Возвращаясь к предыдущему примеру, хотелось бы отметить еще одну новую возможность (на самом деле она появилась еще в 2.13 — прим. перев.): можно вставлять подчеркивания _ в длинные числовые литералы для улучшения читаемости. Вы могли такое видеть например в Python (или в Java 7+ — прим. перев.).


Scala 3 по-прежнему поддерживает implicit-методы из Scala 2. Например, конвертацию из Double в Dollars можно было бы записать так:


implicit def toDollars(d: Double): Dollars = Dollars(d)

Поддержка старого стиля записи может быть удалена в следующих релизах.


Что дальше?


В следующей статье мы рассмотрим новый синтаксис для тайпклассов, который сочетает given и extension-методы. Для затравки, подумайте, как можно было бы добавить метод toJson к типам из нашей предметной области (Dollars и др.), или как реализовать концепции из теории категорий — монаду и моноид.

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

Как вам новые extension-методы?

  • 82,9%Гораздо лучше, чем раньше29
  • 17,1%Лучше бы оставили implicit6

Нужны ли extension-методы в современном ООП-языке?

  • 43,6%Конечно! В моем основном языке они есть17
  • 53,8%Да, но в моем основном языке их нет21
  • 2,6%Не нужны, в ООП такие проблемы решаются наследованием, адаптерами или игнорированием1

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

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 3 227 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 11

    0

    кстати, если метод один, можно слово extension не использовать.


    def[T, T2] (a: T) ~> (b: T2) = (a, b)
      +1
        0

        Действительно, в более новых версиях убрали. Жалко, мне такой синтаксис кажется красивым обобщением над объявлением функций.

      +2
      Extensions одна из любимых фич c#, особенно круто работает с интерфейсами, не знаю как без этого вообще жить. Вот бы ещё в джаву добавили.
        +1

        Я забил на изучение Scala и «похоронил» язык когда узнал про implicit, который позиционируется как некие type class. Мне было очевидно, что это плохая идея, странно, что создатели языка этого не видели. Думаю каждый, кто с C++ работал это тоже понимает, implicit поведение это всегда зло.

          0
          implicit, который позиционируется как некие type class

          Следующая статья как раз будет про тайпклассы в Scala 3. Stay tuned!


          implicit поведение это всегда зло

          Тут надо понимать, что имплиситами вообще и тайпклассами в частности пользуются не от хорошей жизни. Как правило это уровень библиотек, которые должны быть максимально гибкими, и альтернатива там — запутанные иерархии наследования, рефлексия и куча оберток. Что из этого большее зло — вопрос как минимум дискуссионный.

            –1
            Тут надо понимать, что имплиситами вообще и тайпклассами в частности пользуются не от хорошей жизни. Как правило это уровень библиотек, которые должны быть максимально гибкими, и альтернатива там — запутанные иерархии наследования, рефлексия и куча оберток. Что из этого большее зло — вопрос как минимум дискуссионный.

            Это ложная дихотомия, выбор не между запутанной иерархией и implicit.

              +1

              Хм… было бы интересно узнать, какие еще есть опции. Вот хочу я сделать библиотеку для сериализации JSON. Естественно, пользователи этой библиотеки могут захотеть сериализовать любой класс, который у них есть. Какие у меня есть варианты кроме наследования/рефлексии/оберток с одной стороны и тайпклассов с другой?

                0

                Добавлю мультиметоды к списку. Но я говорил про «запутанная иерархия» против «implicit» — иерархия не обязана быть запутанной, implicit реализация тоже может быть запутанной.


                Сравнивать type class с классическим ООП подходом удобно на expression problem. Там видно, что type class (по одному параметру) строго лучше, но проблему все ещё не решает. А мультиметоды решают. Но все это верно пока мы производительность не берём во внимание. Там окажется, скорее всего, что single dispatch (классическое ООП) зарулит всех.


                В scala варианте implicit меня оттолкнуло то, что поведение кода меняется в зависимости от import в заголовке файла. Это сродни тому, как C++ код меняется, при подключении разных заголовочных файлов (в одном из которых может быть #define TRUE FALSE :). Это плохая практика, глядя на код нужно помнить чего наимпортировали сверху, лишняя нагрузка на мозги. Сделать так, чтобы все было explicit, но при этом не было нагромождения деталей — это искусство, думаю тут ещё много прогресса можно ждать.

                  0
                  > В scala варианте implicit меня оттолкнуло то, что поведение кода меняется в зависимости от import в заголовке файла

                  имплиситы по-разному могут резолвиться, не обязательно импортом в заголовке или импортом вообще.
          0
          Стоит добавить, что во второй скале на каждый вызов расширения создается объект неявного класса
          x -> y
          //эквивалентно
          new ArrowAssoc(x).->(y)
          

          В третьей версии создается функция верхнего уровня
          x ~> y
          //эквивалентно
          ~>(x, y)
          

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

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