Серия, часть 2, из «Scala и имплициты дерева»

В первой статье этой серии из трех частей мы узнали, что в Scala 2.x использовалось это ключевое слово неявный, которое использовалось тремя разными (и немного запутанными) способами. Для тех, кто хочет сразу перейти к Scala 3, слово неявный трансформируется в другие термины — не волнуйтесь, мы расскажем, как работает новый синтаксис, в каждой статье.

Сначала мы рассмотрели неявное преобразование, в основном потому, что это самый простой «магический трюк» и его легче всего освоить новичку в Scala, но, по иронии судьбы, он наименее полезен и на самом деле настолько подвержен проблемам, что создатели намеренно запутали его.

Вторая из «трех сестер» — полная противоположность: она невероятно мощная и полезная и может сделать ваш код такими, казалось бы, волшебными вещами. Это фундаментальная часть так называемого шаблона проектирования «прокачай мою библиотеку». Он также удивительно прост в использовании.

Практический пример: попарное добавление списков вместе

Давайте начнем с реального примера, и мы будем опираться на него в остальной части серии. Как инженер данных и периодический пользователь Spark, я часто работаю с записями k-мерных векторов, по сути k-мерными списками чисел, и мне нравится выполнять с ними основные математические операции. т.е. Я часто ловлю себя на том, что хочу сделать что-то вроде:

List(1.0, 1.2) + List(3.2, 3.2) = List(4.2, 4.4)

Если я попытаюсь сделать это в Scala, я немедленно получу ошибку, что само по себе сбивает с толку, поскольку мы не знаем, почему Scala пытается превратить это в манипуляцию со строками.

scala> List(1.0, 1.2) + List(3.2, 3.2)
       error: type mismatch;
        found   : List[Double]
        required: String

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

List(1.0, 1.2) ++ List(3.2, 3.2) = List(1.0, 1.2, 3.2, 3.2)

(И да, это работает в консоли Scala.) Но я действительно хотел бы включить основные векторные операции, и я хочу, чтобы это выглядело элегантно. В объектно-ориентированном программировании решением было бы что-то вроде списка подтипа с чем-то вроде MathList, а затем поместить новую логику в подтип, но подтипирование может стать беспорядочным и запутанным. Разве мы не можем сделать что-то чище?

Короче говоря, да. Scala позволяет нам добавить уже существующий класс (например, List) к функциональности, и мы делаем это с помощью так называемого неявного класса.

implicit class Ops(list: List[Double]) {
  def +(other: List[Double]): List[Double] = {
    (list zip other).map(t => t._1 + t._2)
  }
}

Бам! Все, что вам нужно сделать, это ввести этот неявный класс в область видимости с помощью оператора импорта, и метод «+» будет определен! Компилятор Scala в фоновом режиме автоматически создает оболочку класса Ops вокруг любого объекта List[Double], который он видит, и разрешает доступ к определенным методам.

Другими словами, если есть что-то, к чему вы хотите добавить функциональность, будь то часть языка Scala (например, «String») или что-то, полученное из сторонней проприетарной библиотеки, вы можете добавить функциональность поверх этого.

Давайте посмотрим на еще один простой пример того, что это может быть элегантно. Допустим, у нас есть класс case Coordinate, который представляет координаты широты/долготы. Мы знаем, что часто у пользователя может быть простой кортеж (Double, Double), который он или она хотели бы удобно вызвать методом .toCoordinate для преобразования в наш класс case. Это будет просто сделано с помощью:

case class Coordinate(latitude: Double, longitude: Double)
object Coordinate {
  implicit class Ops(latLong: (Double, Double)) {
    def toCoordinate: Coordinate = new Coordinate(latLong._1,
                                                  latLong._2)
  }
}

Оказавшись в области видимости, вы можете просто сделать это с обычным кортежем двойников:

scala> (234.2, 4225.8).toCoordinate
val res1: Coordinate = Coordinate(234.2,4225.8)

Честно говоря, вы могли бы просто пропустить класс case и кучу методов на основе координат, таких как преобразование градусов в радианы или обратно, в любые (Double, Double) кортежи. Это довольно волшебно, но я бы оставил класс case, потому что он не добавляет много багажа и упрощает использование системы типов для документирования того, что вы на самом деле работаете с Coordinates. (У меня есть короткая статья, в которой говорится об этом здесь.)

Выполнение этого в Scala 3

Поскольку вы действительно «расширяете» класс некоторыми новыми функциями, разработчики Scala решили сделать синтаксис более интуитивно понятным, создав методы расширения. То, как вы обработаете пример попарного добавления списка, будет просто использовать ключевое слово extension следующим образом:

extension (list: List[Double])
  def +(other: List[Double]): List[Double] =
    (list zip other).map(t => t._1 + t._2)

И пример координат будет написан:

extension (latLong: (Double, Double))
  def toCoordinate: Coordinate =  new Coordinate(latLong._1,
                                                 latLong._2)

Далее…

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