Русский
Русский
English
Статистика
Реклама

Dotty

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

15.01.2021 16:07:39 | Автор: admin


Это моя вторая статья с обзором изменений в 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.targetNameextension [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.implicitConversionsscala> 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 Salaryscala> 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 и др.), или как реализовать концепции из теории категорий монаду и моноид.

Подробнее..

Перевод Scala 3 избавление от implicit. Тайпклассы

25.01.2021 14:08:49 | Автор: admin


Моя предыдущая статья была про неявные преобразования и extension-методы. В этой статье обсудим новый способ объявления тайпклассов в Scala 3.


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


Но сначала разберемся, что же такое тайпкласс. Как и сама эта концепция, термин "класс типов" (от англ. type class прим. перев.) появился в Haskell. Слово "класс" используется здесь не в том узком смысле, который принят в ООП, а в более широком как обозначение набора сущностей, имеющих что-то общее. ( Я понимаю, что большинство людей, которые будут читать эту статью, имеют ООП-бекграунд, и для них термин "класс типов" звучит примерно как "масло масел", хотя имеется в виду "категория масел". Чтобы избежать путаницы с обычными ООП-классами, я вместо "класса типов" буду использовать просто транслитерацию "тайпкласс" прим. перев.)


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

Тайпкласс определяет абстракцию, которая может быть реализована для множества типов, а затем определяет конкретные реализации для тех конкретных типов, которые нам нужны. Вот пример с использованием нового синтаксиса Scala 3:


// Adapted from this Dotty documentation:// https://dotty.epfl.ch/docs/reference/contextual/type-classes.htmltrait Semigroup[T]:  extension (t: T)    def combine(other: T): T    def <+>(other: T): T = t.combine(other)trait Monoid[T] extends Semigroup[T]:  def unit: T

В математике полугруппа это абстракция сложения, как можно догадаться по введенному нами оператору <+>. Моноид это полугруппа с нейтральным элементом, например, 0 нейтральный элемент для операции сложения. В примере мы объявляем эти абстракции, используя трейты Semigroup и Monoid.


В Semigroup для произвольного типа T добавляются extension-методы combine и <+>, причем combine не реализован. unit в Monoid объявлен как обычный, а не как extension-метод. Это сделано потому, что значение unit для каждого отдельного типа T будет одно, и оно не зависит от того, с каким конкретным значением, имеющим тип T, мы работаем.


Пример реализации моноида для конкретных типов:


given StringMonoid: Monoid[String] with  def unit: String = ""  extension (s: String) def combine(other: String): String = s + othergiven IntMonoid: Monoid[Int] with  def unit: Int = 0  extension (i: Int) def combine(other: Int): Int = i + other

Реализация выглядит достаточно прямолинейно. Стоит лишь отметить, что given foo: Bar это новый синтаксис для implicit-значений. Если ввести код этого примера в Scala3 REPL, можно увидеть, что в реальности создаются два объекта: StringMonoid и IntMonoid.


Давайте теперь попробуем сделать с нашими моноидами что-нибудь полезное:


"2" <+> ("3" <+> "4")             // "234"("2" <+> "3") <+> "4"             // "234"StringMonoid.unit <+> "2"         // "2""2" <+> StringMonoid.unit         // "2"2 <+> (3 <+> 4)                   // 9(2 <+> 3) <+> 4                   // 9IntMonoid.unit <+> 2              // 22 <+> IntMonoid.unit              // 2

StringMonoid и IntMonoid содержат внутри реализацию unit. Оператор <+> объявлен как extension-метод, который вызывается для конкретных экземпляров String или Int. По определению полугруппы <+> должен быть ассоциативным, что и продемонстрировано в примере.


Мы могли бы объявить реализации моноида анонимными: given Monoid[String] with .... Но тогда для доступа к методу unit нам пришлось бы вызывать summon[Monoid[String]]. Где summon это аналог старого implicitly, глобального метода для получения ссылки на implicit-значение из контекста. Или можно использовать автоматически сгенерированное компилятором имя given_Monoid_String, хотя лучше не полагаться на то, что в будущих версиях компилятора будут придерживаться этой же конвенции именования сгенерированных объектов.


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


Наконец, конкретную реализацию тайпкласса можно сделать параметризованной. В примере ниже мы, чтобы не писать реализацию моноида для каждого числового типа, просто обобщаем IntMonoid для любого Numeric[T]:


given NumericMonoid[T](using num: Numeric[T]): Monoid[T] with  def unit: T = num.zero  extension (t: T) def combine(other: T): T = num.plus(t, other)2.2 <+> (3.3 <+> 4.4)             // 9.9(2.2 <+> 3.3) <+> 4.4             // 9.9BigDecimal(3.14) <+> NumericMonoid.unitNumericMonoid[BigDecimal].unit  <+> BigDecimal(3.14)

Обратите внимание на новое ключевое слово using, оно заменяет использовавшееся в Scala 2 implicit для объявления неявных параметров метода. Мы обсудим это подробнее в следующей статье.


Остановимся подробнее на первой строке примера. NumericMonoid это имя реализации тайпкласса, а Monoid[T] ее тип. Поскольку теперь у нас есть параметр T, вместо объекта компилятор сгенерирует класс. И когда мы пишем NumericMonoid[BigDecimal], будет создаваться экземпляр класса NumericMonoid для BigDecimal. num это аргумент конструктора класса NumericMonoid, но благодаря using нам не нужно задавать его явно.


Также обратите внимание на то, как мы вызываем unit. В последней строке мы явно указываем тип-параметр, в то время как в предпоследней он выводится автоматом из типа левого операнда <+>. Вывод типов в Scala не симметричен относительно операции вызова метода obj1.method(obj2).


Что дальше?


В следующей статье мы подробнее рассмотрим новое ключевое слово using и работу со списком неявных параметров метода.

Подробнее..

Перевод Scala 3 Dotty Факты и Мнения. Что мы ожидаем?

04.03.2021 20:15:20 | Автор: admin

Привет, Хабр. Для будущих студентов курса Scala-разработчик подготовили перевод материала.

Приглашаем также на открытый вебинар Эффекты в Scala. Участники вместе с экспертом рассмотрят понятие эффекта и сложности, которые могут возникать при их наличии, а также рассмотрят понятие функционального эффекта и его свойства.


Что такое Scala 3?

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

Что мотивировало появление новой версии, которая связана с самой сутью Scala (а именно DOT-вычисления причина, по которой Scala 3 начиналась как Dotty); в новой версии наблюдается повышение производительности и предсказуемости, что делает код более легким, интересным и безопасным; улучшение инструментария и бинарной совместимости; а также еще более дружелюбное отношение к новичкам.

В этой статье мы выделим некоторые изменения, которые, по нашему мнению, имеют большую ценность для начинающих программистов Scala. Мы также поговорим о процессе миграции и бинарной совместимости. Наконец, в конце мы поделимся нашим мнением об этой новой версии.

Scala 3 новый язык?

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

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

Почему происходит столь много изменений одновременно?

В конце этой статьи вы будете задаваться вопросом, почему там сразу много меняется. Ответ в книге Scala 3. Она представляет собой сам язык. Выпуская все изменения сразу, нет необходимости каждый раз переписывать книгу, так как это делается с многими другими книгами. В основном, изменения затрагивают основы языка, упрощая жизнь его пользователей или заменяя существующие функции. Поэтому предстоящие изменения должны быть ограничены и расставлены по приоритетам в соответствии с основами, упрощениями и ограничениями. Тогда всё, что остаётся изменить в любых возможных более поздних версиях, будет больше связано с добавлением большей функциональности и выразительности, особенно для опытных пользователей, т.е. вещей, которые можно отложить и которые не оказывают резкого влияния на язык.

Scala 3 это новый Python 3?

Существует необоснованное убеждение, что Scala 3 это новый Python 3 относительно его совместимости с предыдущей версией. Однако, есть некоторые аргументы против этого мнения: а именно, что вам не нужно переносить все на Scala 3, так как есть бинарная совместимость с Scala 2.13 (подробнее об этом будет в разделе о миграции); вы можете уверенно мигрировать благодаря сильной системе типа Scala; и есть гораздо больше преимуществ при миграции с 2 на 3 в Scala, чем было бы при миграции с 2 на 3 на Python 3.

Какие изменения ключевые?

Мы выбрали некоторые ключевые функции, которые считаем более актуальными для начинающих программистов Scala. Мы их опишем и прокомментируем то, как они могут на нас повлиять. Мы не будем комментировать все новые возможности, потому что список слишком длинный. В любом случае, эта статья не будет учебным пособием по каждой функции. Если вы хотите увидеть список всех изменений, ссылок и других ресурсов, вы можете посмотреть это на dotty.epfl.ch.

Optional Braces (опциональные или необязательные фигурные скобки)

Одной из самых революционных новинок являются optional braces и использование правил отступа, как в случае с Python. Это действительно революционно, потому что визуально меняется код и это влияет на читабельность достаточно, чтобы невнимательный читатель подумал, что это новый язык. В дополнение к тому, что это приводит к более чистому и короткому коду. Optional braces хорошая вещь, потому что:

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

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

trait Printer:  def print(msg: String): Unitclass ConsolePrinter extends Printer:  def print(msg: String): Unit = println(msg)class EmojiPrinter(underlying: Printer) extends Printer:  def print(msg: String): Unit =    val emoji = msg match      case ":)"  => ?      case ":D"  => ?      case ":|"  => ?      case other => other    underlying.print(emoji)

Одним из недостатков использования правил отступов является распознавание того момента, когда заканчивается большая область отступов. Для решения этой проблемы Scala 3 предлагает end маркер.

class EmojiPrinter(underlying: Printer) extends Printer:  def print(msg: String): Unit =    if msg != null then      val emoji = msg match        case ":)"  => ?        case ":D"  => ?        case ":|"  => ?        case other => other      underlying.print(emoji)    end ifend EmojiPrinter

Обратите внимание, что мы не ставим скобки, а также обратите внимание на then.

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

Помимо увеличения кодовой базы по сравнению с опцией работы со скобками, преимущество end маркеров по сравнению со скобками заключается в том, что вы можете маркировать замыкание. Таким образом, становится легче найти тот компонент, который замыкается.

Не рекомендуется повсеместно ставить end маркер. Подсказка такая использовать его, когда область отступов слишком длинная. Однако, определение слишком длинный может варьироваться от человека к человеку. Таким образом, в соответствии с официальной документацией, end маркер имеет смысл если:

  • Конструктор содержит пустые строки, или

  • Конструктор имеет 15 линий и более

  • Конструктор имеет 4 уровня отступов и более

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

Enums

Почти каждый программист Scala, работающий на Java, пропускает ключевое слово enum и концепцию, которую он воплощает. Представим, что до Scala 3 вам нужно было написать какой-нибудь шаблонный код, чтобы достичь чего-то похожего на перечисление:

sealed trait Colorcase object Red extends Colorcase object Green extends Colorcase object Blue extends Color

В Scala 3, мы можем использовать стандартные типы enum:

enum Color:  case Red, Blue, Green

С годами все больше и больше код пишется с учетом безопасности типа. Такие концепции, как алгебраические типы данных (ADT), стали обычным явлением в системном моделировании. Поэтому было бы целесообразно предложить программистам более простой механизм реализации этих структур данных. Действительно, Scala 3 предлагает более простой способ реализации ADT через enums:

enum Option[+T]:  case Some(x: T) // extends Option[T]       (omitted)  case None       // extends Option[Nothing] (omitted)

Если вы хотите сделать ваш определенный Scala-enum совместимым с Java-enum, вам необходимо расширить java.lang.Enum, который импортируется по умолчанию:

enum Color extends Enum[Color]:  case Red, Blue, Greenprintln(Color.Green.compareTo(Color.Red)) // 2

Если вам интересен более сложный случай перечисления, например, с параметрами или обобщенными ADT, посмотрите на ссылку enums.

Редизайн implicit (неявность)

Несмотря на критику, implicit является одной из наиболее характерных черт Scala. Однако, она также является одной из самых противоречивых. Есть свидетельства о том, что implicit скорее является механизмом, чем реальным намерением, которое заключается в решении проблем. Более того, несмотря на то, что implicit легко сочетается с множеством конструкторов, становится не так легко, когда речь заходит о предотвращении нарушений и неправомерного использования. Поэтому Scala 3 редизайнит особенности implicit, ставя каждый случай использования на своё место. Ниже приведены изменения, которые мы считаем наиболее актуальными в отношении implicit редизайна.

Implicit определения Заданные экземпляры

В данном случае речь идет о том, как Scala 3 использует синтез контекстных параметров для определенного типа. Она заменяет предыдущее implicit использование для этой цели. В Scala 3 вы можете дополнительно указывать имя заданного экземпляра. Если вы пропустите это имя, то компилятор выведет его.

trait Ord[T]:  def compare(a: T, b: T): Intgiven intOrd: Ord[Int] with // with name  def compare(a: Int, b: Int): Int = a - bgiven Order[String] with // without name  def compare(a: String, b: String): Int = a.compareTo(b)

Implicit параметры Использование clauses

Контекстные параметры (или implicit параметры) помогают избежать написания повторяющихся параметров по цепочке вызовов. В Scala 3 вы используете implicit параметры через ключевое слово using. Например, из приведенных выше примеров можно определить функцию min , которая работает с ними.

def min[T](a: T, b: T)(using ord: Ord[T]): T =  if ord.compare(a, b) < 0 then a else bmin(4, 2)min(1, 2)(using intOrd)min("Foo", "Bar")

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

def printMin[T](a: T, b: T)(using Ord[T]): Unit =  println(min(a, b))

Implicit Импорт Заданный Импорт

Бывают случаи, когда неправильный implicit импорт может стать причиной проблемы Кроме того, некоторые инструменты, такие как IDE и генераторы документации, не справляются с implicit импортом. Scala 3 предоставляет новый способ отличить заданный импорт от обычного.

object A:  class TC  given tc: TC = ???  def f(using TC) = ???object B:  import A._  import A.given  ...

В приведенном выше примере нам пришлось импортировать заданные импорты отдельно, даже после импорта с помощью wildcad (_), потому что в Scala 3 заданные импорты работают не так, как обычные. Вы можете объединить оба импорта в один.

object C:  import A.{using, _}

Вот некоторые спецификации, касающиеся заданных импортов по типам. Посмотрите на данную импортную документацию.

Implicit Conversion Заданная Conversion

Представим, что до Scala 3, если вы хотели определить Implicit Conversion, вам просто нужно было написать Implicit Conversion, которое получает экземпляр исходного типа и возвращает экземпляр целевого типа. Теперь нужно определить данный экземпляр классаscala.Conversion, который ведет себя как функция. Действительно, экземплярыscala. Conversion это функции. Посмотрите на их определение.

abstract class Conversion[-T, +U] extends (T => U):  def apply (x: T): U

Например, здесь можно посмотреть на преобразование от Int к Double и на более короткую версию:

given int2double: Conversion[Int, Double] withdef apply(a: Int): Double = a.toDoublegiven Conversion[Int, Double] = _.toDouble

Основной причиной использования Заданной Conversion является наличие специального механизма преобразования стоимости без каких-либо сомнительных конфликтов с другими конструкторами языка. Согласно данной документации по преобразованию, все другие формы implicit преобразований будут постепенно ликвидированы.

Implicit классы Методы расширения

Методы расширения являются более интуитивным и менее шаблонным способом, чем Implicit классы для добавления методов к уже определенным типам.

case class Image(width: Int, height: Int, data: Array[Byte])extension (img: Image)  def isSquare: Boolean = img.width == img.heightval image = Image(256, 256, readBytes("image.png"))println(image.isSquare) // true

Методы расширения могут иметь параметры типа как в их определении, так и в их методах. Их определения также могут иметь несколько методов.

extension [T](list: List[T])def second: T = list.tail.headdef heads: (T, T) = (list.head, second)

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

Типы пересечения и соединения

Scala 3 предоставляет новые способы объединения типов, два из которых Типы пересечения и соединения.

Типы пересечения

Типы пересечения это типы, элементы которых принадлежат к обоим типам, составляющим его. Они определяются оператором & более двух типов. & являются коммутаторами: A & B производит один и тот же тип B & A. Они также могут быть сцеплены, так как они тоже являются типами.

trait Printable[T]: def print(x: T): Unittrait Cleanable: def clean(): Unittrait Flushable: def flush(): Unitdef f(x: Printable[String] & Cleanable & Flushable) = x.print("working on...") x.flush() x.clean()

Вам может быть интересно узнать, как компилятор решает конфликты разделяемых элементов. Ответ состоит в том, что компилятор в этом не нуждается. Типы пересечений представляют собой требования к members типам (члены типов). Они работают практически так же, как и при формировании типов. В момент построения members необходимо просто убедиться, что все полученные members определены корректно.

trait A:  def parent: Option[A]trait B:  def parent: Option[B]class C extends A,B:  def parent: Option[A & B] = None  // or  // def parent: Option[A] & Option[B] = Nildef work(x: A & B) =  val parent:[A & B] = x.parent  // or  // val parent: Option[A] & Option[B] = x.parent  println(parent) // Nonework(new C)

Заметьте, что в in class C нам нужно решить конфликты связанные с тем, что children member появляется и в A и в B. То есть тип C это пересечение его типа в A и его типа в B, например, Option[A] & Option[B] могут быть упрощены в вид Option[A & B], так как Option (опция) является ковариантной.

Типы соединения

Тип соединения A | B принимает все экземпляры типа A и все экземпляры типа B. Обратите внимание, что мы говорим об экземплярах, а не о members (членах), как это делают типы пересечения. Поэтому, если мы хотим получить доступ к его members, нам нужно сопоставить их по шаблону.

def parseFloat(value: String | Int): Float =   value match     case str: String => str.toFloat    case int: Int => int.floatValueparseFloat("3.14") // 3.14parseFloat(42) // 42.0

Типы соединения не выводятся автоматически. Если вы хотите, чтобы тип определения (val, var или def) был типом соединения, вам нужно сделать это явно, иначе компилятор выведет наименьший общий ancestor (предок).

val any = if (cond) 42 else "3.14" // Anyval union: String | Int = if (cond) 42 else "3.14" // String | Int

Почётные упоминания

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

Трейт параметры

Scala 3 позволяет трейтам иметь параметры. Эти параметры оцениваются непосредственно перед инициализацией трейта. Параметры трейта являются заменой для ранних инициализаторов, которые были удалены из Scala 3.

Универсальные применяемые методы

Конструкторы Case class стали достаточно популярными, и многие разработчики пишут Case class просто для того, чтобы не писать new для создания объектов. Поэтому в Scala 3 больше не нужно писать new для создания экземпляров классов.

Типы Opaque

Opaque-типы обеспечивают абстракцию типа без каких-либо перегрузок. Модифицируя определение типа с помощью Opaque, вы ограничиваете тот факт, что определение типа является просто псевдонимом для другого типа, где оно определено. Для клиентов своей области видимости Opaque-типы ведут себя идеально как тип, а не просто как псевдоним. Таким образом, например, вы не можете предполагать существование псевдонима для создания значений псевдонима и присвоения определений Opaque-типу.

Export clauses

Export clauses это простой способ передачи members (членов) от одного типа к другому без какого-либо наследования. Откладывая export от членов-класса (включая трейты и объекты) в тело другого класса (также включая трейты и объекты), вы копируете members и делаете их доступными через экземпляры целевых классов.

Редизайн метапрограммирования

В Scala 2 макросы оставались экспериментальной функцией. В связи с тем, что макросы сильно зависят от компилятора Scala 2, поэтому мигрировать на Scala 3 оказалось невозможным. В метапрограммировании Scala 3 появились новые конструкции, облегчающие его использование. Взгляните на обзор метапрограммирования Scala 3.

Ограничения и удаленные фитчи

Чтобы упростить язык и сделать его более безопасным, Scala 3 ограничивает количество опций и снимает некоторые функции с производства. Самые важные из них:

  • Ограничение проекций типов (C#P) только в отношении классов, т.е. абстрактные типы больше не поддерживают их;

  • Для использования надстрочной нотации, модификатор infix должен быть помечен на желаемых методах;

  • Мультиверсальное равенство - это оптический способ избегания неожиданных равнозначностей;

  • Implicit преобразования и приведенные выше импорты также являются видами ограничений;

  • Специальная обработка трейта DelayedInit больше не поддерживается;

  • Отброшен синтаксис процедуры (опускание типа возврата и = при определении функции);

  • Библиотеки XML все еще поддерживаются, но будут удалены в ближайшем будущем;

  • Автоматическое приложение, когда пустой список аргументов () неявно вставляется при вызове метода без аргументов и больше не поддерживается;

  • Символьные литералы больше не поддерживаются.

Полный список выпавших функций доступен в официальной документации.

Нужно ли вам переходить на Scala 3?

Прежде всего, есть очень хорошо сделанная документация, посвященная исключительно переходу на Scala 3. Здесь мы просто поделимся некоторыми мыслями, которые вы можете учесть, когда начнете использовать Scala 3 в своих текущих проектах.

Хорошо известно, что рекомендуется быть в курсе работы со стеком технологий и зависимостями, так как они могут исправлять ошибки и улучшать удобство использования, производительность и так далее. Это также относится и к Scala. Независимо от того, насколько мало усилий прилагается для перехода, иногда необходимо согласовать это с заинтересованными сторонами. Тем не менее, миграция Scala 3 была спроектирована так, чтобы быть как можно более плавной. Это означает, что вы можете воспользоваться самыми заметными эволюциями в языке и сделать программирование более легким, интересным и безопасным.

Какое время подходит для перехода на Scala 3?

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

Что такое бинарная совместимость в Scala 3?

Scala 3 предлагает обратную двоичную совместимость с Scala 2. Это означает, что вы все еще можете полагаться на библиотеку Scala 2. Начиная с версии Scala 2.13.4, выпущенной в ноябре 2020 года, вы можете использовать библиотеки, написанные на Scala 3. Таким образом, в Scala 3 вы получаете двойную совместимость туда и обратно.

Scala 3 поддерживает обратную и прямую совместимость с помощью уникального революционного механизма в экосистеме Scala. Scala 3 выводит файлы TASTy и поддерживает Pickle из версий Scala 2.x. Scala 2.13.4 поставляется с считывателями TASTy, поэтому поддерживает как все традиционные функции, так и новые, такие как Enums, Intersection types (типы соединения) и другие. Дополнительные сведения см. в руководстве по совместимости.

Заключение

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

В целом, мне кажется, что Scala 3 это отличная доработка Scala 2. Мы научились жить без многих вещей: для одних это были библиотеки, решавшие проблемы в той или иной степени с ограничениями, для других это было либо невозможно, либо выходило за пределы их зоны комфорта. Поэтому, как только Scala 3 получит широкое распространение, мы ожидаем увидеть более грамотно написанный код, в основном потому, что там это намного проще.


Узнать подробнее о курсе Scala-разработчик.

Смотреть открытый вебинар Эффекты в Scala.

Подробнее..

Автоматическая генерация type classes в Scala 3

07.01.2021 16:12:32 | Автор: admin

В Scala широко используется подход к наделению классов дополнительной функциональностью, называемый type classes. Для тех, кто никогда не сталкивался с этим подходом рекомендую почитать вот эту статью. Этот подход позволяет держать код каких-то аспектов функционирования класса отдельно от самой реализации класса. И создавать его даже не имея доступа к коду самого класса. В частности, такой подход оправдан и рекомендуем при наделении классов возможностью сериализации/десериализации в определенный формат. Например библиотека работы с Json из фреймворка Play использует type classes для задания правил представления объектов в json формате.

Если type class предназначен для использования в большом количестве разнообразных классов (как например при сериализации/десериализации), то писать код type class для каждого класса с которым он должен работать нерационально и трудозатратно. Во многих случаях можно сгенерировать реализацию type class автоматически зная набор атрибутов класса для которого он предназначается. К сожалению в текущей версии scala автоматическая генерация type class затруднена. Она требует либо самостоятельного написания макросов, либо использования сторонних фреймворков для генерации type class таких как shapeless или magnolia, которые также основаны на макросах.

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

Объявление type class

В качестве примера будет использоваться достаточно искусственный type class который мы назовем Inverter. Он будет содержать один метод:

trait Inverter[T] {  def invert(value: T): T}

Предполагается что он будет принимать значение и каким либо образом "инвертировать" его. Для строк под инвертированием мы будем понимать изменение порядка символов на противоположный, для чисел - изменение знака числа, а для логического типа - применение логического NOT. Для всех структурных типов инвертированием будет инвертирование значений всех их атрибутов. Такой type class имеет мало практического смысла, но для нас он хорош тем, что позволяет продемонстрировать как обработку входящего значения базового класса так и выдачу результата в виде значения базового класса.

Итак первое что нужно сделать - это определить type class для элементарных типов. Делается это объявлением given значений (аналог implicit из Scala 2) с реализацией Inverter в объекте компаньоне Inverter:

object Inverter {  given Inverter[String] = new Inverter[String] {    override def invert(str: String): String =      str.reverse  }  given Inverter[Int] = new Inverter[Int] {    override def invert(value: Int): Int =      -value  }    given Inverter[Boolean] = new Inverter[Boolean] {    override def invert(value: Boolean): Boolean =      !value  }  }

Теперь займемся автоматической генерацией Inverter для сложных типов. Для того чтобы автоматическая генерация была возможна необходимо объявить в объекте компаньоне метод derived[T] возвращающий Inverter[T]. Реализация этого метода может быть любой. Например можно генерировать type class с помощью макроса или при помощи высокоуровневой библиотеки генерации (например shapeless 3). Нас же будет интересовать генерация через встроенный низкоуровневый механизм. Для его работы метод derived должен получить контекстный параметр типа Mirror.Of[T]. Этот параметр позволит нам получить информацию о структуре нашего типа. Параметр Mirror.Of[T] генерируется компилятором автоматически для следующих типов:

  • case классы и case объекты

  • перечисления (enum и enum cases)

  • sealed trait-ы единственными наследниками которых являются case классы и case объекты.

Собственно этот список это и есть тот список классов для которых может автоматически генерироваться type class при использовании описываемого механизма.

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

Приведем реализацию метода derived для нашего случая. В первом варианте реализации мы будем реализовывать только генерацию для case классов и объектов (а также кортежей).

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances)      case s: Mirror.SumOf[T] => ???    }  }  inline def summonAll[T <: Tuple]: List[Inverter[_]] =    inline erasedValue[T] match      case _: EmptyTuple => List()      case _: (t *: ts) => summonInline[Inverter[t]] :: summonAll[ts]

Разберемся что здесь происходит. Для всех структурных типов Miror.Of[T] позволяет определить типы элементов класса через MirroredElemTypes. Для случая case классов и кортежей это просто типы всех полей. Поскольку для инвертирования нашего типа нам надо инвертировать все его поля, то нам необходимо получить экземпляры Inverter для всех типов полей нашего класса. Это делается через метод summonAll. Реализация summonAll использует новый механизм поиска given значений summonInline. Мы сейчас не будет останавливаться на тонкостях этой реализации, так как для наших целей реализация метода summonAll будет всегда одинаковой независимо от того какой type class мы генерируем.

После получения списка Inverter для всех элементов класса мы определяем чем является наш класс - произведением других классов (case классы, case объекты, кортежи) или суммой (sealed trait или enum). Поскольку сейчас нас интересуют только случай произведения, то для этого случая вызывается метод productInverter, который создает имплементацию Inverter на основе Inverter для всех элементов класса:

def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val oldValues = value.asInstanceOf[Product].productIterator        val newValues = oldValues.zip(elems)          .map { case (value, inverter) =>            inverter.asInstanceOf[Inverter[Any]].invert(value)          }          .map(_.asInstanceOf[AnyRef])          .toArray        p.fromProduct(Tuple.fromArray(newValues))      }    }  }

Реализация этого метода делает следующее. Во-первых, получается список значений всех полей экземпляра класса. Так как мы знаем что наш класс является произведением типов то он реализует trait Product, который позволяет получить итератор всех значений полей. Во-вторых список значений полей объединяется со списком Inverter для них и для каждого поля к значению применяется свой Inverter. Наконец, в-третьих, из списка инвертированных значений собирается новый экземпляр класса. За эту сборку отвечает метод fromProduct доступный через Mirror объект.

Использование derived

Каким же образом теперь можно использовать созданный метод derived для получения type class для конечного класса. Тут есть несколько подходов. Самый простой - использовать при объявлении case класса конструкцию derives которая указывает что для этого класса необходимо сгенерировать указанный type class. Вот пример такого объявления:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean) derives Inverter

После этого экземпляр Inverter[Sample] будет сгенерирован и доступен везде где виден класс Sample. Далее мы просто можем получать его через summon и использовать:

println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))// Результат: Sample(-1,cba,true)

Такой подход можно использовать если у нас есть полный доступ к классам для которых нам необходимы type class и мы хотим явно заявлять в них эту новую функциональность.

Однако часто мы не хотим или не можем в явном виде модифицировать класс, для которого нам нужен type class. В этом случае мы можем объявить given значение для этого класса пользуясь методом derived:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean)@main def mainProc = {    given Inverter[Sample] = Inverter.derived  println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))  // Результат: Sample(-1,cba,true)  } 

Нужно однако понимать что такая генерация type class является полуавтоматической. Для вложенных case классов type class автоматически сгенерирован не будет. Скажем для иерархии:

case class InnerSample(s: String)case class OuterSample(inner: InnerSample)

необходимо будет последовательно сгенерировать необходимые type class:

  given Inverter[InnerSample] = Inverter.derived  given Inverter[OuterSample] = Inverter.derived  println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))  // Результат: OuterSample(InnerSample(cba))

В большинстве случаев однако можно разрешить компилятору автоматически генерировать type class для всех типов для которых доступен Mirror.Of. Для этого просто объявляем универсальный given:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))  // Результат: OuterSample(InnerSample(cba))

По какому пути идти и насколько автоматизировать генерацию type class в каждом конкретном случае нужно решать индивидуально. Разработчикам библиотек я бы рекомендовал прятать автоматическую генерацию в отдельный trait или объект, которые можно подключить через наследование (или import соответственно) там, где это необходимо:

trait AutoInverting {  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]}

Кастомные type class и автоматическая генерация

Автоматическая генерация type class вполне допускает использование type class созданных вручную для тех типов где нужна специальная реализация. При этом при эти созданные вручную реализации будут использоваться в том числе и там где они входят в качестве элементов в другие классы.

Например рассмотрим следующую иерархию case классов:

case class SampleUnprotected(value: String)case class SampleProtected(value: String)case class Sample(prot: SampleProtected, unprot: SampleUnprotected)

Допустим для класса SampleProtected мы хотим иметь специальную реализацию Inverter, которая не инвертирует его поле value. Посмотрим как будет это сочетаться с автоматической генерацией type class для Sample:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    given Inverter[SampleProtected] = new Inverter[SampleProtected] {    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)  }    println(summon[Inverter[Sample]].invert(Sample(SampleProtected("abc"), SampleUnprotected("abc"))))  // Результат: Sample(SampleProtected(abc),SampleUnprotected(cba))

Как видим Inverter автоматически сгенерированный для класса Sample подхватил кастомную реализацию Inverter для SampleProtected. Это позволяет определять в библиотеке автоматическую генерацию и все равно оставлять пользователю возможность делать кастомные реализации там где это необходимо.

Обработка sealed trait и enum

Помимо генерации type class для case классов (и прочих произведений классов) можно генерировать type class и для sealed trait иерархий. Для этого в методе derived необходимо дописать ветку, отвечающую за сумму классов:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])      case s: Mirror.SumOf[T] =>         sumInverter(s, elemInstances)    }  }  def sumInverter[T](s: Mirror.SumOf[T], elems: List[Inverter[_]]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val index = s.ordinal(value)        elems(index).asInstanceOf[Inverter[Any]].invert(value).asInstanceOf[T]      }    }  }

Посмотрим что здесь происходит. В случае суммы типов типы элементы в Mirror определяют типы наследников от базового типа которые и могут выступать типом нашего экземпляра. Для того чтобы определить какой именно тип элемента нужно использовать нужно воспользоваться методом ordinal из Mirror. Он возвращает индекс типа элемента который соответствует текущему значению экземпляра. Далее мы берем соответствующий Inverter (выбирая его из списка по этому индексу) и используем для инвертирования нашего экземпляра.

Посмотрим как это работает на простейших примерах. Мы не будем создавать собственную иерархию с sealed trait а воспользуемся уже готовыми классами Either и Option:

def checkInverter[T](value: T)(using inverter: Inverter[T]): Unit = {  println(s"$value => ${inverter.invert(value)}")}  @main def mainProc = {    inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    given Inverter[SampleProtected] = new Inverter[SampleProtected] {    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)  }    val eitherSampleLeft: Either[SampleProtected, SampleUnprotected] = Left(SampleProtected("xyz"))  checkInverter(eitherSampleLeft)  // Результат: Left(SampleProtected(xyz)) => Left(SampleProtected(xyz))  val eitherSampleRight: Either[SampleProtected, SampleUnprotected] = Right(SampleUnprotected("xyz"))  checkInverter(eitherSampleRight)  // Результат: Right(SampleUnprotected(xyz)) => Right(SampleUnprotected(zyx))  val optionalValue: Option[String] = Some("123")  checkInverter(optionalValue)  // Результат: Some(123) => Some(321)  val optionalValue2: Option[String] = None  checkInverter(optionalValue2)  // Результат: None => None  checkInverter((6, "abc"))  // Результат: (6,abc) => (-6,cba)}

Здесь мы для наглядности выделили использование Inverter в отдельный метод чтобы показать автоматическую генерацию type class без явного указания summon. Как видно генерация работает правильно и для Either и для опционального типа и для кортежей (они на самом деле обрабатываются не веткой SumOf, а веткой ProductOf).

Использование наименований полей класса

Вернемся к вопросу генерации type class для случая произведения классов и рассмотрим еще один аспект, который может оказаться важным для задач сериализации/десериализации. В нашем примере реализация инвертирования зависела только от типов полей класса, но не от названий этих полей. Однако во многих случаях реализация type class должна будет использовать наименования полей. Чтобы продемонстрировать как это можно делать введем в наш пример генерации Inverter еще одно требование: те поля класса наименование которых начинается на два символа подчеркивания инвертирование выполняться не должно. Попробуем реализовать это требование. Для этого нам понадобится реализовать метод получения списка названий полей и поправить реализацию derived:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])      case s: Mirror.SumOf[T] =>         sumInverter(s, elemInstances)    }  }  inline def getFields[Fields <: Tuple]: List[String] =    inline erasedValue[Fields] match {      case _: (field *: fields) => constValue[field].toString :: getFields[fields]      case _ => List()    }  def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]], labels: Seq[String]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val newValues = value.asInstanceOf[Product].productIterator          .zip(elems).zip(labels)          .map { case ((value, inverter), label) =>            if (label.startsWith("__"))              value            else              inverter.asInstanceOf[Inverter[Any]].invert(value)          }          .map(_.asInstanceOf[AnyRef])          .toArray        p.fromProduct(Tuple.fromArray(newValues))      }    }  }

Проверим как работает такая реализация на следующем классе:

case class Sample(value: String, __hidden: String)

Для такого класса должно инвертироваться значение value, но не должно инвертироваться значение __hidden:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    println(summon[Inverter[Sample]].invert(Sample("abc","abc")))  // Результат: Sample(cba,abc)

Выводы

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

С исходным кодом финального примера, рассмотренного в данной статье, можно поиграться тут.

Подробнее..
Категории: Scala , Macro , Dotty , Type class

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru