Файл: Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста.pdf
ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 09.12.2023
Просмотров: 752
Скачиваний: 11
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
396 Глава 18 • Параметризация типов как
Queue[AnyRef]
. Но получить ковариантность (гибкость) подтипизации очередей можно следующим изменением первой строчки трейта
Queue
:
trait Queue[+T] { ... }
Указанный знак плюс (
+
) в качестве префикса формального параметра типа показывает, что подтипизация в этом параметре ковариантна (гибка).
Добавляя этот единственный знак, вы сообщаете Scala о необходимости, к примеру, рассматривать тип
Queue[String]
как подтип
Queue[AnyRef]
Компилятор проверит факт определения
Queue в соответствии со способом, предполагаемым подобной подтипизацией.
Помимо префикса
+
, существует префикс
-
, который показывает контрава-
риантность подтипизации. Если определение
Queue имеет вид trait Queue[-T] { ... }
и если тип
T
— подтип типа
S
, то это будет означать, что
Queue[S]
— под
тип
Queue[T]
(что в случае с очередями было бы довольно неожиданно!).
Ковариантность, контравариантность или нонвариантость параметра типа называются вариантностью параметров. Знаки
+
и
-
, которые могут разме
щаться рядом с параметрами типа, называются аннотациями вариантности.
В чисто функциональном мире многие типы по своей природе ковариантны
(гибки). Но ситуация становится иной, как только появляются изменяемые данные. Чтобы выяснить, почему так происходит, рассмотрим показанный в ли
стинге 18.5 простой тип одноэлементных ячеек, допускающих чтение и запись.
Листинг 18.5. Нонвариантный (жесткий) класс Cell class Cell[T](init: T):
private var current = init def get = current def set(x: T) =
current = x
Показанный здесь тип
Cell объявлен нонвариантным (жестким). Ради аргументации представим на время, что
Cell был объявлен ковариантным, то есть был объявлен класс
Cell[+T]
, и этот код передан компилятору Scala.
(Этого делать не стоит, и скоро мы объясним почему.) Значит, можно скон
струировать следующую проблематичную последовательность инструкций:
val c1 = new Cell[String]("abc")
val c2: Cell[Any] = c1
c2.set(1)
val s: String = c1.get
18 .3 . Аннотации вариантности 397
Если рассмотреть строки по отдельности, то все они выглядят вполне нор
мально. В первой строке создается строковая ячейка, которая сохраняется в val
переменной по имени c1
. Во второй строке определяется новая val
переменная c2
, имеющая тип
Cell[Any]
, которая инициализируется значе
нием переменной c1
. Кажется, все в порядке, поскольку экземпляры класса
Cell считаются ковариантными. В третьей строке для c2
устанавливается значение
1
. С этим тоже все в порядке, так как присваиваемое значение
1
— экземпляр, относящийся к объявленному для c2
типу элемента
Any
. И на
конец, в последней строке значение элемента c1
присваивается строковой переменной. Здесь нет ничего странного, поскольку с обеих сторон выраже
ния находятся значения одного и того же типа. Но если взять все в совокуп
ности, то эти четыре строки в конечном счете присваивают целочисленное значение
1
строковому значению s
. Это явное нарушение целостности типа.
Какая из операций вызывает сбой в ходе выполнения кода? Видимо, вторая, в которой используется ковариантная подтипизация. Все другие операции слишком простые и базовые. Стало быть, ячейка
Cell
, хранящая значение типа
String
, не является также ячейкой
Cell
, хранящей значение типа
Any
, поскольку есть вещи, которые можно делать с
Cell из
Any
, но нельзя делать с
Cell из
String
. К примеру, в отношении
Cell из
String нельзя использовать set с
Int
аргументом.
Получается, если передать ковариантную версию
Cell компилятору Scala, то будет выдана ошибка компиляции:
4 | def set(x: T) =
| ˆˆˆˆ
| covariant type T occurs in contravariant position
| in type T of value x
Вариантность и массивы
Интересно сравнить данное поведение с массивами в Java. В принципе, массивы подобны ячейкам, за исключением того, что могут иметь несколько элементов. При этом массивы в Java считаются ковариантными.
Пример, аналогичный работе с ячейкой, можно проверить в работе с масси
вами Java:
// Это код Java
String[] a1 = { "abc" };
Object[] a2 = a1;
a2[0] = new Integer(17);
String s = a1[0];
398 Глава 18 • Параметризация типов
Если запустить данный пример, то окажется, что он пройдет компиляцию.
Но в ходе выполнения, когда элементу a2[0]
будет присваиваться значение
Integer
, программа сгенерирует исключение
ArrayStore
:
Exception in thread "main" java.lang.ArrayStoreException:
java.lang.Integer at JavaArrays.main(JavaArrays.java:8)
Дело в том, что в Java сохраняется тип элемента массива во время выпол
нения программы. При каждом обновлении элемента массива новое зна
чение элемента проверяется на принадлежность к сохраненному типу.
Если оно не является экземпляром этого типа, то выдается исключение
ArrayStore
Может возникнуть вопрос: а почему в Java принята такая на вид небезопас
ная и затратная конструкция? Отвечая на данный вопрос, Джеймс Гослинг
(James Gosling), основной изобретатель языка Java, говорил, что создатели хотели получить простые средства обобщенной обработки массивов. На
пример, им хотелось получить возможность писать метод сортировки всех элементов массива с использованием следующей сигнатуры, которая полу
чает массив из
Object
элементов:
void sort(Object[] a, Comparator cmp) { ... }
Ковариантность массивов требовалась для того, чтобы этому методу сорти
ровки можно было передавать массивы произвольных ссылочных типов.
Разумеется, после появления в Java обобщенных типов (дженериков) подоб
ный метод сортировки уже мог быть написан с параметрами типа, поэтому необходимость в ковариантности массивов отпала. Сохранение ее до наших дней обусловлено соображениями совместимости.
Scala старается быть чище, чем Java, и не считает массивы ковариантными.
Вот что получится, если перевести первые две строки кода примера с мас
сивом на язык Scala:
scala> val a1 = Array("abc")
val a1: Array[String] = Array(abc)
scala> val a2: Array[Any] = a1 1 |val a2: Array[Any] = a1
| ˆˆ
| Found: (a1 : Array[String])
| Required: Array[Any]
В данном случае получилось, что Scala считает массивы нонвариантными
(жесткими), следовательно,
Array[String]
не считается соответствующим
18 .4 . Проверка аннотаций вариантности 399
Array[Any]
. Но иногда необходимо организовать взаимодействие между име
ющимися в Java устаревшими методами, которые используют в качестве сред
ства эмуляции обобщенного массива
Object
массив. Например, может воз
никнуть потребность вызвать метод сортировки наподобие того, который был рассмотрен ранее в отношении
String
массива, передаваемого в качестве аргу
мента. Чтобы допустить такую возможность, Scala позволяет выполнять приве
дение массива из элементов типа
T
к массиву элементов любого из супертипов
T
:
val a2: Array[Object] = a1.asInstanceOf[Array[Object]]
Приведение типов во время компиляции не вызывает никаких возражений и всегда будет успешно работать во время выполнения программы, по
скольку положенная в основу работы JVMмодель времени выполнения рассматривает массивы в качестве ковариантных точно так же, как это де
лается в языке Java. Но впоследствии, как и на Java, может быть получено исключение
ArrayStore
18 .4 . Проверка аннотаций вариантности
Рассмотрев несколько примеров ненадежности вариантности, можно задать вопрос: какие именно определения классов следует отвергать, а какие — при
нимать? До сих пор во всех нарушениях целостности типов фигурировали переназначаемые поля или элементы массивов. В то же время чисто функ
циональная реализация очередей представляется хорошим кандидатом на ковариантность. Но в следующем примере показано, что ситуацию нена
дежности можно подстроить даже при отсутствии переназначаемого поля.
Чтобы выстроить пример, предположим: очереди из показанного выше листинга 18.4 определены как ковариантные. Затем создадим подкласс оче
редей с указанным типом
Int и переопределим метод enqueue
:
class StrangeIntQueue extends Queue[Int]:
override def enqueue(x: Int) =
println(math.sqrt(x))
super.enqueue(x)
Перед выполнением добавления метод enqueue в подклассе
StrangeIntQueue выводит на стандартное устройство квадратный корень из своего (целочис
ленного) аргумента.
Теперь можно создать контрпример из двух строк кода:
val x: Queue[Any] = new StrangeIntQueue x.enqueue("abc")
1 ... 37 38 39 40 41 42 43 44 ... 64
400 Глава 18 • Параметризация типов
Первая из этих двух строк вполне допустима, поскольку
StrangeIntQueue
— подкласс
Queue[Int]
, и, если предполагается ковариантность очередей, то
Queue[Int]
является подтипом
Queue[Any]
. Вполне допустима и вторая строка, так как
String
значение можно добавлять в
Queue[Any]
. Но если взять их вместе, то у этих двух строк проявляется не имеющий никако
го смысла эффект применения метода извлечения квадратного корня к строке.
Это явно не те простые изменяемые поля, которые делают ковариантные поля ненадежными. Проблема носит более общий характер. Получается, как только обобщенный параметр типа появляется в качестве типа параметра метода, содержащие данный метод класс или трейт в этом параметре типа могут не быть ковариантными.
Для очередей метод enqueue нарушает это условие:
class Queue[+T]:
def enqueue(x: T) =
При запуске модифицированного класса очередей, подобного показанному ранее, в компиляторе Scala последний выдаст следующий результат:
17 | def enqueue(x: T) =
| ˆˆˆˆ
| covariant type T occurs in contravariant position
| in type T of value x
Переназначаемые поля — частный случай правила, которое не позволяет параметрам типа, имеющим аннотацию
+
, использоваться в качестве типов параметра метода. Как упоминалось в разделе 16.2, переназначаемое поле var x:
T
рассматривается в Scala как геттер def x:
T
и как сеттер def x_=(y:
T)
Как видите, сеттер имеет параметр поля типа
T
. Следовательно, этот тип не может быть ковариантным.
УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ
Далее в этом разделе мы рассмотрим механизм, с помощью которого компи- лятор Scala проверяет аннотацию вариантности . Если данные подробности вас пока не интересуют, то можете смело переходить к разделу 18 .5 . Сле- дует усвоить главное: компилятор Scala будет проверять любую аннотацию вариантности, которую вы укажете в отношении параметров типа . Напри- мер, при попытке объявить ковариантный параметр типа (путем добавления знака +), способного вызвать потенциальные ошибки в ходе выполнения программы, программа откомпилирована не будет .
18 .4 . Проверка аннотаций вариантности 401
Чтобы проверить правильность аннотаций вариантности, компилятор Scala классифицирует все позиции в теле класса или трейта как положительные,
отрицательные или нейтральные. Под позицией понимается любое место в теле класса или трейта (далее в тексте будет фигурировать только термин
«класс»), где может использоваться параметр типа. Например, позицией является каждый параметр значения в методе, поскольку у параметра зна
чения метода есть тип. Следовательно, в этой позиции может применяться параметр типа.
Компилятор проверяет каждое использование каждого из имеющихся в клас
се параметров типа. Параметры типа, для аннотации которых применяется знак
+
, могут быть задействованы только в положительных позициях, а па
раметры, для аннотации которых используется знак
-
, могут применяться лишь в отрицательных. Параметр типа, не имеющий аннотации вариант
ности, может использоваться в любой позиции. Таким образом, он является единственной разновидностью параметров типа, которая применима в ней
тральных позициях тела класса.
В целях классификации позиций компилятор начинает работу с объявления параметра типа, а затем идет через более глубокие уровни вложенности. По
зиции на верхнем уровне объявляемого класса классифицируются как поло
жительные. По умолчанию позиции на более глубоких уровнях вложенности классифицируются таким же образом, как и охватывающие их уровни, но есть небольшое количество исключений, в которых классификация меняется.
Позиции параметров значений метода классифицируются по перевернутой схеме относительно позиций за пределами метода, там положительная клас
сификация становится отрицательной, отрицательная — положительной, а нейтральная классификация так и остается нейтральной.
Текущая классификация действует по перевернутой схеме в отношении не только позиций параметров значений методов, но и параметров типа методов. В зависимости от вариантности соответствующего параметра типа классификация иногда бывает перевернута в имеющейся в типе позиции аргумента типа, например, это касается
Arg в
C[Arg]
. Если параметр типа у
C
аннотирован с помощью знака
+
, то классификация остается такой же. Если с помощью знака
-
, то классификация определяется по перевернутой схеме.
При отсутствии у параметра типа у
C
аннотации вариантности текущая клас
сификация изменяется на нейтральную.
В качестве слегка надуманного примера рассмотрим следующее определе
ние класса, в котором несколько позиций аннотированы согласно их клас
сификации с помощью обозначений
^+
(для положительных) или
^-
(для отрицательных):
402 Глава 18 • Параметризация типов abstract class Cat[-T, +U]:
def meow[W—](volume: T—, listener: Cat[U+, T—]—)
: Cat[Cat[U+, T—]—, U+]+
Позиции параметра типа
W
и двух параметров значений, volume и listener
, помечены как отрицательные. Если посмотреть на результирующий тип метода meow
, то позиция первого аргумента,
Cat[U,
T]
, помечена как отрица
тельная, поскольку первый параметр типа у
Cat
,
T
, аннотирован с помощью знака
–
. Тип
U
внутри этого аргумента опять имеет положительную позицию
(после двух перевертываний), а тип
T
внутри этого аргумента остается в от
рицательной.
Из всего вышесказанного можно сделать вывод, что отследить позиции вариантностей очень трудно. Поэтому помощь компилятора Scala, проде
лывающего эту работу за вас, несомненно, приветствуется.
После вычисления классификации компилятор проверяет, используется ли каждый параметр типа только в позициях, которые имеют соответствующую классификацию. В данном случае
T
используется лишь в отрицательных по
зициях, а
U
— лишь в положительных. Следовательно, класс
Cat типизирован корректно.
18 .5 . Нижние ограничители
Вернемся к классу
Queue
. Вы видели, что прежнее определение
Queue[T]
, по
казанное выше в листинге 18.4, не может быть превращено в ковариантное в отношении
T
, поскольку
T
фигурирует в качестве типа параметра метода enqueue и находится в отрицательной позиции.
К счастью, существует способ выйти из этого положения: можно обобщить enqueue
, превратив данный метод в полиморфный (то есть предоставить самому методу enqueue параметр типа), и воспользоваться нижним ограни-
чителем его параметра типа. Новая формулировка
Queue с реализацией этой идеи показана в листинге 18.6.
Листинг 18.6. Параметр типа с нижним ограничителем class Queue[+T] (private val leading: List[T],
private val trailing: List[T]):
def enqueue[U >: T](x: U) =
new Queue[U](leading, x :: trailing) // ...
В новом определении enqueue дается параметр типа
U
, и с помощью синтак
сиса
U
>:
T
тип
T
определяется как нижний ограничитель для
U
. В результате
18 .5 . Нижние ограничители 403
от типа
U
требуется, чтобы он был супертипом для
T
1
. Теперь параметр для enqueue имеет тип
U
, а не
T
, а возвращаемое значение метода теперь не
Queue[T]
, а
Queue[U]
Предположим, есть класс
Fruit
, имеющий два подкласса:
Apple и
Orange
С новым определением класса
Queue появилась возможность добавить
Orange в
Queue[Apple]
. Результатом будет
Queue[Fruit]
В этом пересмотренном определении enqueue типы используются правильно.
Интуитивно понятно, что если
T
— более конкретный тип, чем ожидалось
(например,
Apple вместо
Fruit
), то вызов enqueue все равно будет работать, поскольку
U
(
Fruit
) попрежнему будет супертипом для
T
(
Apple
)
2
Возможно, новое определение enqueue лучше старого, поскольку имеет более обобщенный характер. В отличие от старой версии новое определение по
зволяет добавлять в очередь с элементами типа
T
элементы произвольного супертипа
U
. Результат получается типа
Queue[U]
. Наряду с ковариантностью очереди это позволяет получить правильную разновидность гибкости для моделирования очередей из различных типов элементов вполне естествен
ным образом.
Это показывает, что в совокупности аннотации вариантности и нижние ограничители — хорошо сыгранная команда. Они являются хорошим при
мером разработки, управляемой типами, где типы интерфейса управляют ее детальным проектированием и реализацией. В случае с очередями высока вероятность того, что вы не стали бы продумывать улучшенную реализацию enqueue с нижним ограничителем. Но у вас могло созреть решение сделать очереди ковариантными, в случае чего компилятор указал бы для enqueue на ошибку вариантности. Исправление ошибки вариантности путем добавления нижнего ограничителя придает enqueue большую обобщенность, а очереди в целом делает более полезными.
Вдобавок это наблюдение — главная причина того, почему в Scala предпо
читаема вариантность по месту объявления (declarationsite variance), а не ва
риантность по месту использования (usesite variance), встречающаяся в Java в подстановочных символах (wildcards). В случае вариантности по месту
1
Отношения супертипов и подтипов рефлексивны. Это значит, что тип является одновременно супертипом и подтипом по отношению к себе. Даже притом что
T
— нижняя граница для
U
,
T
все же можно передавать методу enqueue
2
С технической точки зрения произошедшее — переворот для нижних границ.
Параметр типа
U
находится в отрицательной позиции (один переворот), а нижняя граница (
>: T
) — в положительной (два переворота).