Scala. Профессиональное программирование 2022. Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста
Скачать 6.24 Mb.
|
492 Глава 23 • Классы типов ходятся в объектекомпаньоне FromString . Если вы хотите написать главный метод, принимающий нестандартный тип, можете объявить для этого типа givenэкземпляр класса типов FromString Представьте, к примеру, что вам нужно дополнить главный метод repeat третьим параметром командной строки, описывающим одно из трех настрое ний: удивлен, зол и нейтрален. Чтобы описать настроение, можно определить перечисление Mood , как показано в листинге 23.7. Листинг 23.7. Перечисление Mood // В файле moody.scala enum Mood: case Surprised, Angry, Neutral Создав перечисление, вы можете улучшить главный метод repeat так, чтобы он принимал Mood в качестве третьего параметра. Это показано в листин ге 23.8. Листинг 23.8. Главный метод, принимающий нестандартный тип // В файле moody.scala val errmsg = "Please enter a word, a positive integer count, and\n" + "a mood (one of 'angry', 'surprised', or 'neutral')" @main def repeat(word: String, count: Int, mood: Mood) = val msg = if count > 0 then val words = List.fill(count)(word.trim) val punc = mood match case Mood.Angry => "!" case Mood.Surprised => "?" case Mood.Neutral => "" val sep = punc + " " words.mkString(sep) + punc else errmsg println(msg) Теперь остается только научить компилятор преобразовывать строковой параметр командной строки в Mood . В этой связи для типа Mood нужно опре делить экземпляр FromString . Хорошим местом размещения этого экземп ляра будет объекткомпаньон Mood , так как компилятор заглянет в него при поиске гивена FromString[Mood] . В листинге 23.9 показан один из вариантов реализации. 23 .3 . Главные методы 493 Листинг 23.9. Given-экземпляр FromString для Mood // В файле moody.scala object Mood: import scala.util.CommandLineParser.FromString given moodFromString: FromString[Mood] with def fromString(s: String): Mood = s.trim.toLowerCase match case "angry" => Mood.Angry case "surprised" => Mood.Surprised case "neutral" => Mood.Neutral case _ => throw new IllegalArgumentException(errmsg) Имея в своем распоряжении определение givenэкземпляра F r o m - String[Mood] , вы можете запустить свое приложение repeat : $ scalac moody.scala $ scala repeat hello 3 neutral hello hello hello $ scala repeat hello 3 surprised hello? hello? hello? $ scala repeat hello 3 angry hello! hello! hello! Решение, основанное на классе типов, хорошо подходит для анализаторов ар гументов командной строки, которые принимают главные методы, так как эта возможность требуется лишь для определенных типов, которые в остальном не имеют друг к другу никакого отношения. Помимо String и Int , объект компаньон FromString определяет givenэкземпляры FromString для Byte , Short , Long , Boolean , Float и Double . Если прибавить к этому givenэкземпляр FromString[Mood] , размещенный в объектекомпаньоне Mood из листинга 23.9, множество типов, составляющих класс типов FromString , будет выглядеть как на рис. 23.4. Рис. 23.4 . Множество типов T с given-экземплярами FromString[T] 494 Глава 23 • Классы типов 23 .4 . Многостороннее равенство В Scala 2 было реализовано универсальное равенство, которое позволяет проверять на равенство два любых объекта с использованием == и != . Поль зователям было легко понять этот подход, и он хорошо сочетался с методом equals из Java, с помощью которого можно сравнить два любых экземпляра Object . Это также сделало возможной поддержку в Scala 2 совместного ра- венства — это когда на равенство проверяются разные взаимодействующие между собой типы. Совместное равенство, к примеру, позволило Scala 2 продолжить традицию Java, состоящую в том, что проверку равенства Int и Long можно осуществлять без явного приведения первого типа ко второму. Тем не менее у универсального равенства был один серьезный недостаток: оно скрывало программные дефекты. Например, в Scala 2 вы могли прове рять на равенство строки и объекты Option . Например: scala> "hello" == Option("hello") // (в Scala 2) val res0: Boolean = false И хотя на этапе выполнения Scala 2 дает верный ответ (строка "hello" дей ствительно не равна Option("hello") ), не существует такой строки, которая была бы равна какомулибо объекту Option . Результатом этого сравне ния всегда будет false . Таким образом, любая проверка строки и объекта Option на равенство, скорее всего, является ошибкой, которую не выявил компилятор Scala 2. Такого рода ошибки можно легко допустить во время рефакторинга — например, вы можете поменять тип переменной с String на Option[String] и не заметить, что теперь в другом месте на равенство про веряются String и Option[String] Для сравнения: попытка проделать то же самое в Scala 3 завершится ошибкой компиляции: scala> "hello" == Option("hello") // (в Scala 3) 1 |”hello” == Option("hello") |ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ |Values of types String and Option[String] cannot be | compared with == or != Это улучшение в безопасности Scala 3 достигается за счет новой возможно сти под названием многостороннее равенство. Оно заключается в том, что компилятор поособому обращается с методами == и != . Определения этих методов, показанные в листинге 23.10, не изменились в Scala 3 по сравнению со Scala 2. Поменялось лишь поведение компилятора: в Scala 3 универсаль ное сравнение преобразуется в многостороннее. 23 .4 . Многостороннее равенство 495 Листинг 23.10. Методы == и != в Scala 2 и Scala 3 // На примере класса Any: final def ==(that: Any): Boolean final def !=(that: Any): Boolean Чтобы понять, как работает многостороннее равенство в Scala 3, будет по лезно рассмотреть детали реализации универсального равенства в Scala 2. Вот как оно было устроено на уровне JVM: когда компилятор Scala 2 об наруживал вызов == и != , он сначала проверял, принадлежат ли сравнива емые значения к простым типам Java. Если да, то компилятор генерировал специальный байткод для эффективной проверки этих простых типов на равенство. В противном случае, если один из типов был простым, а другой — нет, компилятор генерировал код для упаковки простого значения. В итоге оба операнда имели ссылочные типы. Затем компилятор генерировал код, который сначала определял, равен ли левый операнд null . Если да, то сге нерированный код проводил ту же проверку для правого операнда, чтобы получить результат типа Boolean . Это гарантировало, что вызовы == и != не могут сгенерировать NullPointerException . В противном случае сгенериро ванный код вызывал из левого операнда, который, как уже было установлено, не равнялся null , метод equals , передавая ему правый операнд. В Scala 3 компилятор выполняет точно такие же действия, но сначала про веряет, допускается ли это сравнение. Для этого он ищет givenэкземпляр класса типов с именем CanEqual . Вот его определение: sealed trait CanEqual[-L, -R] Трейт CanEqual принимает два параметра типов, L и R 1 L — это тип левого операнда проверки на равенство, а R — тип правого операнда. CanEqual не предоставляет никакого метода для непосредственной проверки на равен ство двух объектов типа L и R , поскольку в Scala 3 эта операция попрежнему выполняется за счет методов == и != . Если вкратце, то вместо того, чтобы предоставлять операцию проверки типов L и R на равенство, как можно было бы ожидать от обычного класса типов, CanEqual просто дает разрешение на проведение этой операции с помощью == и != 1 Класс типов обычно означает множество типов, для которых доступны given экземпляры трейта, принимающего один параметр типа, но CanEqual можно считать трейтом, определяющим множество, которое состоит из пар типов. Например, проверка String и Option[String] на равенство не скомпилируется в Scala 3, так как тип ( String , Option[String] ) не входит в множество, из которого состоит класс типов CanEqual 496 Глава 23 • Классы типов Как описывалось в разделе 18.6, знак минус рядом с параметром типа оз начает, что трейт CanEqual является контравариантным как по L , так и по R . Ввиду этой контравариантности CanEqual[Any, Any] является подтипом любого другого типа, CanEqual[L, R] , независимо от L или R . В результате эк земпляр CanEqual[Any, Any] можно использовать для выдачи разрешения на проверку на равенство любых двух типов. Например, если givenэкземпляр CanEqual[Int, Int] нужен, чтобы разрешить проверку на равенство двух значений Int , givenэкземпляра CanEqual[Any, Any] будет достаточно, так как CanEqual[Any, Any] является подтипом CanEqual[Int, Int] . Благодаря этому факту CanEqual является запечатанным трейтом всего с одним экземп ляром, который имеет универсально применимый тип CanEqual[Any, Any] Этот объект носит имя derived и объявлен в объектекомпаньоне CanEqual : object CanEqual: object derived extends CanEqual[Any, Any] Следовательно, чтобы предоставить givenэкземпляр CanEqual[L, R] , неза висимо от того, что собой представляют типы L и R , вы должны использовать один и только один экземпляр CanEqual , CanEqual.derived В целях обратной совместимости компилятор Scala 3 позволяет проводить некоторые проверки на равенство по умолчанию, даже если для нужного типа недоступен givenэкземпляр CanEqual . Компилятор позволяет про верить, равны ли значения типов L и R , даже в случае отсутствия given экземпляра типа CanEqual[L, R] , если выполняется любое из следующих условий. 1. L и R являются одним и тем же типом. 2. После выполнения подъема типа L является подтипом R или наоборот 1 3. Для типов L и R не существует рефлексивного givenэкземпляра CanEqual Рефлексивными являются экземпляры, которые разрешают сравнение типа с самим собой, как в случае с CanEqual[L, L] Третье правило гарантирует, что, как только вы предоставите ре флексивный givenэкземпляр CanEqual , позволив тем самым сравнивать тип с самим со бой, этот тип больше нельзя будет сравнивать ни с каким другим типом — разве что существует givenэкземпляр CanEqual , который позволяет это делать. В целях обратной совместимости Scala 3 фактически возвращается 1 Чтобы поднять тип, компилятор заменяет ссылки на абстрактные типы в его кова риантных позициях их верхней границей и подставляет вместо типов уточнения, находящиеся в ковариантных позициях типа, их родителей. 23 .4 . Многостороннее равенство 497 к универсальному равенству по умолчанию при сравнении типов, для кото рых не было определено рефлексивных экземпляров CanEqual Scala 3 предоставляет givenэкземпляры для нескольких типов из стан дартной библиотеки, включая рефлексивные экземпляры для строк. Вот почему проверка String и Option[String] на равенство по умолчанию запрещена. Givenэкземпляра CanEqual[String, String] , предоставля емого стандартной библиотекой, достаточно для того, чтобы компиля тор Scala 3 по умолчанию не позволял проверять на равенство String и Option[String] Это поведение по умолчанию дает возможность беспроблемного перехода со Scala 2 на Scala 3, так как в существующем пользовательском коде, который переносится из Scala 2, нет никаких экземпляров CanEqual для этих типов. Представьте, к примеру, что ваш имеющийся проект на Scala 2 содержит класс Apple , определенный следующим образом: case class Apple(size: Int) И гдето в вашем коде происходит сравнение двух яблок: val appleTwo = Apple(2) val appleTwoToo = Apple(2) appleTwo == appleTwoToo // true Это сравнение по умолчанию будет компилироваться и работать в Scala 3, так как левая и правая стороны имеют один и тот же тип. Однако в Scala 3 компилятор по умолчанию все так же позволяет сравнивать типы, для ко торых не существует рефлексивных givenэкземпляров CanEqual , поэтому следующая нежелательная проверка на равенство попрежнему успешно компилируется: case class Orange(size: Int) val orangeTwo = Orange(2) appleTwo == orangeTwo // false Это сравнение Apple и Orange , скорее всего, является ошибочным, так как оно всегда возвращается false . Чтобы выполнить полную проверку рабо тоспособности всех сравнений на равенство в Scala 3, даже если для задей ствованных типов не было определено рефлексивных givenэкземпляров, вы можете включить «строгое равенство». Для этого нужно либо передать компилятору параметр командной строки -language:strictEquality , либо добавить следующую инструкцию импорта в исходный файл: import scala.language.strictEquality 498 Глава 23 • Классы типов После включения строгого равенства сравнение яблок и апельсинов будет заканчиваться ожидаемой ошибкой компиляции: scala> appleTwo == orangeTwo 1 |appleTwo == orangeTwo |ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ |Values of types Apple and Orange cannot be | compared with == or != К сожалению, вместе с этим теперь возникает другая, нежелательная ошибка компиляции, связанная с корректным сравнением двух яблок: scala> appleTwo == appleTwoToo 1 |appleTwo == appleTwoToo |ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ |Values of types Apple and Apple cannot be | compared with == or != Чтобы сделать возможным это сравнение в режиме строгого равенства, нуж но предоставить экземпляр CanEqual , который разрешает проверять яблоки на равенство с другими яблоками. Для этого в объектекомпаньоне Apple можно определить явный givenэкземпляр, как показано в листинге 23.11 (хотя это идет вразрез с философией языка). Листинг 23.11 . Явно определенный (но не идиоматический) провайдер CanEqual case class Apple(size: Int) object Apple: given canEq: CanEqual[Apple, Apple] = CanEqual.derived Вместо этого лучше указать, что вы хотите вывести для своего экземпляра Apple экземпляр класса типов CanEqual , как показано в листинге 23.12. Листинг 23.12. Предоставление CanEqual с помощью инструкции derives case class Apple(size: Int) derives CanEqual // идиоматически Этот идиоматический подход основан на выводе класса типов, который по зволяет делегировать определение givenэкземпляра класса типов члену с именем derived в объектекомпаньоне этого класса. Это заставляет ком пилятор вставить заданный провайдер (как тот, что был показан в листин ге 23.11) в объекткомпаньон Apple Об инструкции derives можно было бы рассказывать и дальше, так как боль шинство методов derived генерируют экземпляры класса типов с помощью метапрограммирования на этапе компиляции. 23 .5 . Неявные преобразования 499 Теперь благодаря тому, что вы определили экземпляр CanEqual[Apple, Apple] с использованием инструкции derives , компилятор позволит вам сравнивать яблоки в режиме строгого равенства: appleTwo == appleTwoToo // тоже true 23 .5 . Неявные преобразования Неявные преобразования были первой неявной конструкцией в Scala. Они создавались для того, чтобы помочь сделать код яснее и избавиться от шаблонных приведений типов. Например, стандартная библиотека Scala определяет неявное преобразование Int в Long . Если передать Int методу, ко торый ожидает Long , компилятор автоматически приведет тип Int к Long , не требуя явного вызова функции преобразования, такой как toLong . Поскольку любое значение Int можно безопасно привести к значению Long и оба этих типа представляют целые числа в дополнительном коде, это неявное пре образование может облегчить чтение исходного кода за счет устранения повторяющихся участков. Однако со временем неявные преобразования вышли из моды, так как они могли делать код не только яснее за счет удаления шаблонных участков, но и более запутанным ввиду отсутствия четкости. В Scala были добавлены другие конструкции, которые являются более удачными альтернативами неявных преобразований, включая методы расширения и контекстные па раметры. В Scala 3 для неявных преобразований осталось всего несколько сценариев применения. И хотя они попрежнему поддерживаются, для их использования нужно импортировать флаг переключения возможностей, иначе компилятор выдаст предупреждение. Вот как они работают. Если компилятор Scala приходит к выводу, что указанный тип не отвечает ожидаемому, он начинает искать неявное пре образование, которое сможет исправить ошибку выбора кандидата. Иными словами, каждый раз, когда компилятор встречает X там, где требуется Y , он ищет неявное преобразование, которое приводит X к Y . Представьте, к примеру, что у вас есть крошечный тип 1 для представления поля «улица» в почтовом адресе: case class Street(value: String) 1 Крошечные типы обсуждались в разделе 17.4. 500 Глава 23 • Классы типов И у вас имеется экземпляр этого класса: val street = Street("123 Main St") В этом случае вы не сможете инициализировать переменную типа String с помощью Street : scala> val streetStr: String = street 1 |val streetStr: String = street | ˆˆˆˆˆˆ | Found: (street : Street) | Required: String Вместо этого вам придется вручную привести Street к String путем вызова street.value : val streetStr: String = street.value // 123 Main St Этот код легко понять, но у вас может быть ощущение того, что вызов value из String для преобразования значения в String является шаблонным и мало информативным кодом. Поскольку тип Street всегда можно безопасно при вести к типу String , который лежит в его основе, вам, возможно, захочется предоставить неявное преобразование из Street в String . В Scala 3 для этого нужно определить givenэкземпляр типа Conversion[Street, String] 1 , кото рый является потомком типа функции Street => String . Вот его определение: abstract class Conversion[-T, +U] extends (T => U): def apply(x: T): U Поскольку у трейта Conversion есть всего один абстрактный метод, для определения экземпляра зачастую можно использовать функциональный литерал SAM 2 . Следовательно, неявное приведение Street к String можно определить так: given streetToString: Conversion[Street, String] = _.value Чтобы не получать предупреждения во время компиляции при использова нии неявных преобразований, для их включения следует либо передать ком пилятору параметр -language:implicitConversions , либо локально указать следующую инструкцию импорта: import scala.language.implicitConversions 1 В Scala 3 неявное преобразование можно также определить с помощью i mplicit def , чтобы сохранить совместимость со Scala 2. В будущем этот подход может быть признан устаревшим. 2 Методы SAM были описаны в разделе 8.9. |