Файл: Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста.pdf
ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 09.12.2023
Просмотров: 736
Скачиваний: 11
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
Резюме 387
Помимо трейтов, явно помеченных как прозрачные, Scala 3 будет также счи
тать прозрачными scala.Product
, java.lang.Serializable и java.lang.Com- parable
. Поскольку эти типы никогда не будут выводиться в Scala 3, когда вы захотите использовать их, вам придется делать это с помощью явных аннотаций типов или приписываний.
Резюме
В этой главе мы показали классы, находящиеся в верхней и нижней части иерархии классов Scala. Вы также увидели, как создавать свои собственные классы значений, в том числе как использовать их для «крошечных типов».
Вы узнали о типах пересечений и объединений и увидели, как они превра
щают иерархию типов Scala в решетку. Наконец, вы узнали, как использовать модификатор transparent
, чтобы алгоритм вывода типов Scala не исполь
зовал трейты, созданные в основном как примешивания, в качестве типов.
В следующей главе вы узнаете о параметризации типов.
18
Параметризация типов
В этой главе мы рассмотрим детали параметризации типов в Scala. Попутно продемонстрируем несколько техник сокрытия информации, представлен
ных в главе 12, на конкретном примере: проектирования класса для чисто функцио нальных очередей.
Параметризация типов позволяет создавать обобщенные классы и трейты.
Например, множества имеют обобщенный характер и получают параметр типа: они определяются как
Set[T]
. В результате любой отдельно взятый эк
земпляр множества может иметь тип
Set[String]
,
Set[Int]
и т. д., но должен быть множеством чего-либо. В отличие от языка Java, в котором разрешено использовать «сырые» типы (raw types), Scala требует указывать параметры типа. Вариантность определяет взаимоотношения наследования параме
тризованных типов, к примеру, таких, при которых
Set[String]
является подтипом
Set[AnyRef]
Глава состоит из трех частей. В первой разрабатывается структура данных для чисто функциональных очередей. Во второй разрабатываются техно
логические приемы сокрытия внутреннего представления деталей этой структуры. В третьей объясняется вариантность параметров типов и то, как она взаимодействует со сокрытием информации.
18 .1 . Функциональные очереди
Функциональная очередь представляет собой структуру данных с тремя операциями:
z z
head
— возвращает первый элемент очереди;
z z
tail
— возвращает очередь без первого элемента;
18 .1 . Функциональные очереди 389
z z
enqueue
— возвращает новую очередь с заданным элементом, добавлен
ным в ее конец.
В отличие от изменяемой функциональная очередь не изменяет свое содер
жимое при добавлении элемента. Вместо этого возвращается новая очередь, содержащая элемент. В данной главе наша цель — создать класс по имени
Queue
, работающий так:
val q = Queue(1, 2, 3) // Queue(1, 2, 3)
val q1 = q.enqueue(4) // Queue(1, 2, 3, 4)
q // Queue(1, 2, 3)
Будь у
Queue изменяемая реализация, операция enqueue в показанной ранее второй строке ввода повлияла бы на содержимое q
: по сути, после этой опе
рации оба результата, и q1
, и исходная очередь q
, будут содержать последо
вательность 1, 2, 3, 4. А для функциональной очереди добавленное значение обнаруживается только в результате q1
, но не в очереди q
, в отношении которой выполнялась операция.
Кроме того, чистые функциональные очереди слегка похожи на списки. Обе эти коллекции имеют так называемую абсолютно постоянную структуру данных, где старые версии остаются доступными даже после расширений или модификаций. Обе они поддерживают операции head и tail
. Но список растет, как правило, с начала с помощью операции
::
, а очередь — с конца с помощью операции enqueue
Как добиться эффективной реализации очереди? В идеале функциональная
(неизменяемая) очередь не должна иметь высоких издержек, существенно больших по сравнению с императивной (изменяемой) очередью. То есть все три операции: head
, tail и enqueue
— должны выполняться за постоянное время.
Одним из простых подходов к реализации функциональной очереди станет использование списка в качестве типа представления. Тогда head и tail
— просто аналогичные операции над списком, а enqueue
— конкатенация.
В таком варианте получится следующая реализация:
class SlowAppendQueue[T](elems: List[T]): // Неэффективное решение def head = elems.head def tail = new SlowAppendQueue(elems.tail)
def enqueue(x: T) = SlowAppendQueue(elems ::: List(x))
Проблемной в данной реализации является операция enqueue
. На нее уходит время, пропорциональное количеству элементов, хранящихся в очереди.
Если требуется постоянное время добавления, то можно также попробовать
390 Глава 18 • Параметризация типов изменить в списке, представляющем очередь, порядок следования элементов на обратный, чтобы последний добавляемый элемент стал в списке первым.
Тогда получится такая реализация:
class SlowHeadQueue[T](smele: List[T]): // Неэффективное решение
// smele — это реверсированный elems def head = smele.last def tail = new SlowHeadQueue(smele.init)
def enqueue(x: T) = SlowHeadQueue(x :: smele)
Теперь у операции enqueue постоянное время выполнения, а вот у head и tail
— нет. На их выполнение теперь уходит время, пропорциональное количеству элементов, хранящихся в очереди.
При изучении этих двух примеров реализация, в которой на все три опе
рации будет затрачиваться постоянное время, не представляется такой уж простой. И действительно, возникают серьезные сомнения в возможности подобной реализации! Но, воспользовавшись сочетанием двух операций, можно подойти к желаемому результату очень близко. Замысел состоит в представлении очереди в виде двух списков: leading и trailing
. Список leading содержит элементы, которые располагаются от конца к началу, а элементы списка trailing следуют из начала в конец очереди, то есть в об
ратном порядке. Содержимое всей очереди в любой момент времени равно коду leading
:::
trailing.reverse
Теперь, чтобы добавить элемент, следует просто провести консоперацию в отношении списка trailing
, воспользовавшись оператором
::
, и тогда операция enqueue будет выполняться за постоянное время. Это значит, если изначально пустая очередь выстраивается на основе последовательно про
веденных операций enqueue
, то список trailing будет расти, а список leading останется пустым. Затем перед выполнением первой операции head или tail в отношении пустого списка leading весь список trailing копируется в leading в обратном порядке следования элементов. Это делается с по
мощью операции по имени mirror
. Реализация очередей с использованием данного подхода показана в листинге 18.1.
1 ... 36 37 38 39 40 41 42 43 ... 64
Листинг 18.1. Базовая функциональная очередь class Queue[T](
private val leading: List[T],
private val trailing: List[T]
):
private def mirror =
if leading.isEmpty then new Queue(trailing.reverse, Nil)
18 .1 . Функциональные очереди 391
else this def head = mirror.leading.head def tail =
val q = mirror new Queue(q.leading.tail, q.trailing)
def enqueue(x: T) =
new Queue(leading, x :: trailing)
Какова вычислительная сложность этой реализации очереди? Операция mirror может занять время, пропорциональное количеству элементов очере
ди, но только при условии, что список leading пуст. Если же нет, то возврат из метода происходит немедленно. Поскольку head и tail вызывают mirror
, то их вычислительная сложность также может иметь линейную зависимость от размера очереди. Но чем длиннее становится очередь, тем реже вызыва
ется mirror
И действительно, допустим, есть очередь длиной n с пустым списком leading
Тогда операции mirror придется скопировать в обратном порядке список длиной n. Однако следующий раз, когда операции mirror придется делать чтолибо, наступит только по опустошении списка leading
, что произойдет после n операций tail
. То есть вы можете расплатиться за каждую из этих
n операций tail одной nной от вычислительной сложности операции mirror
, что означает постоянный объем работы. При условии, что операции head
, tail и enqueue используются примерно с одинаковой частотой, амортизиро-
ванная вычислительная сложность является, таким образом, константой для каждой операции. Следовательно, функциональные очереди асимптотически так же эффективны, как и изменяемые очереди.
Но этот аргумент следует сопроводить некоторыми оговорками. Вопервых, речь шла только об асимптотическом поведении, а постоянно действующие факторы могут несколько различаться. Вовторых, аргумент основывается на том, что операции head
, tail и enqueue вызываются с примерно одинако
вой частотой. Если операция head вызывается намного чаще, чем остальные две, то данный аргумент утратит силу, поскольку каждый вызов head может повлечь за собой весьма затратную реорганизацию списка с помощью опера
ции mirror
. Негативных последствий, названных во второй оговорке, можно избежать, поскольку есть возможность сконструировать функциональные очереди таким образом, что в последовательности операций head реоргани
зация потребуется только для первой из них. Как это делается, мы покажем в конце главы.
392 Глава 18 • Параметризация типов
18 .2 . Сокрытие информации
Теперь по эффективности реализация класса
Queue
, показанная в листин
ге 18.1, нас вполне устраивает. Конечно, вы можете возразить, что за эту эффективность пришлось расплачиваться неоправданно подробной детализа
цией. Глобально доступный конструктор
Queue получает в качестве параметров два списка, в одном из которых элементы следуют в обратном порядке, что вряд ли совпадает с интуитивным представлением об очереди. Нам нужен способ, позволяющий скрыть этот конструктор от кода клиента. Некоторые способы решения данной задачи на Scala мы покажем в текущем разделе.
Приватные конструкторы и фабричные методы
В Java конструктор можно скрыть, объявив его приватным. В Scala первич
ный конструктор не имеет явно указываемого определения — подразумевает
ся, что он автоматически определяется с параметрами и телом класса. Тем не менее, как показано в листинге 18.2, скрыть первичный конструктор можно, добавив перед списком параметров класса модификатор private
Листинг 18.2. Сокрытие первичного конструктора путем превращения его в приватный class Queue[T] private (
private val leading: List[T],
private val trailing: List[T]
)
Модификатор private
, указанный между именем класса и его параметрами, служит признаком того, что конструктор класса
Queue является приватным: доступ к нему можно получить только изнутри самого класса и из его объ
ектакомпаньона. Имя класса
Queue попрежнему остается публичным, поэтому вы можете использовать его в качестве типа, но не можете вызвать его конструктор:
scala> Queue(List(1, 2), List(3))
1 |Queue(List(1, 2), List(3))
|ˆˆˆˆˆ
|constructor Queue cannot be accessed as a member of
|Queue from module class rs$line$4$.
Теперь, когда первичный конструктор класса
Queue уже не может быть вы
зван из кода клиента, нужен какойто другой способ создания новых очере
дей. Одна из возможностей — добавить вспомогательный конструктор:
def this() = this(Nil, Nil)
18 .2 . Сокрытие информации 393
Этот конструктор, показанный в предыдущем примере, создает пустую оче
редь. В качестве уточнения он может получать список исходных элементов очереди:
def this(elems: T*) = this(elems.toList, Nil)
Следует напомнить, что в соответствии с описанием из раздела 8.8
T*
— фор
ма запи си для повторяющихся параметров.
Еще одна возможность состоит в добавлении фабричного метода, созда
ющего очередь из последовательности исходных элементов. Прямой путь сделать это — определить объект
Queue
, который имеет такое же имя, как и определяемый класс, и, как показано в листинге 18.3, содержит метод apply
Листинг 18.3. Фабричный метод apply в объекте-компаньоне object Queue:
// создает очередь с исходными элементами xs def apply[T](xs: T*) = new Queue[T](xs.toList, Nil)
Помещая этот объект в тот же самый исходный файл, в котором находится определение класса
Queue
, вы превращаете объект в объекткомпаньон это
го класса. В разделе 12.5 было показано: объекткомпаньон имеет такие же права доступа, что и его класс. Поэтому метод apply в объекте
Queue может создать новый объект
Queue
, несмотря на то что конструктор класса
Queue является приватным.
Обратите внимание: по причине вызова фабричным методом метода apply клиенты могут создавать очереди с помощью выражений вида
Queue(1,
2,
3)
Данное выражение разворачивается в
Queue.apply(1,
2,
3)
, поскольку
Queue
— объект, заменяющий функцию. В результате этого клиенты видят
Queue в качестве глобально определенного фабричного метода. На самом же деле в Scala нет методов с глобальной областью видимости, поскольку каж
дый метод должен быть заключен в объект, класс или пакет. Но, используя методы по имени apply внутри глобальных объектов, вы можете поддержи
вать схемы, похожие на вызов глобальных методов.
Альтернативный вариант: приватные классы
Приватные конструкторы и приватные члены класса — всего лишь один из способов скрыть инициализацию и представить класс. Еще один более ради
кальный способ — сокрытие самого класса и экспорт только трейта, показы
вающего публичный интерфейс класса. Код реализации такой конструкции
394 Глава 18 • Параметризация типов представлен в листинге 18.4. В нем показан трейт
Queue
, в котором объявля
ются методы head
, tail и enqueue
. Все три метода реализованы в подклассе
QueueImpl
, который является приватным подклассом внутри класса объекта
Queue
. Тем самым клиентам открывается доступ к той же информации, что и раньше, но с помощью другого приема. Вместо сокрытия отдельно взятых конструкторов и методов в этой версии скрывается весь реализующий оче
реди класс.
Листинг 18.4. Абстракция типа для функциональных очередей trait Queue[T]:
def head: T
def tail: Queue[T]
def enqueue(x: T): Queue[T]
object Queue:
def apply[T](xs: T*): Queue[T] =
QueueImpl[T](xs.toList, Nil)
private class QueueImpl[T](
private val leading: List[T],
private val trailing: List[T]
) extends Queue[T]:
def mirror =
if leading.isEmpty then
QueueImpl(trailing.reverse, Nil)
else this def head: T = mirror.leading.head def tail: QueueImpl[T] =
val q = mirror
QueueImpl(q.leading.tail, q.trailing)
def enqueue(x: T) =
QueueImpl(leading, x :: trailing)
18 .3 . Аннотации вариантности
Элемент
Queue согласно определению в листинге 18.4 — трейт, но не тип, поскольку получает параметр типа
1
. В результате вы не можете создавать переменные типа
Queue
:
1
Queue можно считать типом более высокого рода.
18 .3 . Аннотации вариантности 395
scala> def doesNotCompile(q: Queue) = {}
1 |def doesNotCompile(q: Queue) = {}
| ˆˆˆˆˆ
| Missing type parameter for Queue
Вместо этого
Queue позволяет указывать параметризованные типы, такие как
Queue[String]
,
Queue[Int]
или
Queue[AnyRef]
:
scala> def doesCompile(q: Queue[AnyRef]) = {}
def doesCompile: (q: Queue[AnyRef]): Unit
Таким образом,
Queue
— трейт, а
Queue[String]
— тип.
Queue также называ
ют конструктором типа, поскольку вы можете сконструировать тип с его участием, указав параметр типа. (Это аналогично конструированию экзем
пляра объекта с использованием самого обычного конструктора с указанием параметра значения.) Конструктор типа
Queue генерирует семейство типов, включающее
Queue[Int]
,
Queue[String]
и
Queue[AnyRef]
Можно также сказать, что
Queue
— обобщенный трейт. (Классы и трейты, которые получают параметры типа, являются обобщенными, а вот типы, генерируемые ими, являются параметризованными, а не обобщенными.) По
нятие «обобщенный» означает, что вы определяете множество конкретных типов, используя один обобщенно написанный класс или трейт. Например, трейт
Queue в листинге 18.4 определяет обобщенную очередь. Конкретными очередями будут
Queue[Int]
,
Queue[String]
и т. д.
Сочетание параметров типа и системы подтипов вызывает ряд интересных вопросов. Например, существуют ли какието особые подтиповые отноше
ния между членами семейства типов, генерируемого
Queue[T]
? Конкретнее говоря, следует ли рассматривать
Queue[String]
как подтип
Queue[AnyRef]
?
Или в более широком смысле: если
S
— подтип
T
, то следует ли рассматривать
Queue[S]
как подтип
Queue[T]
? Если да, то можно сказать, что трейт
Queue
ковариантный (или гибкий) в своем параметре типа
T
. Или же, поскольку у него всего один параметр типа, можно просто сказать, что
Queue
очереди ковариантны. Такая ковариантность
Queue будет означать, к примеру, что вы можете передать
Queue[String]
ранее показанному методу doesCompile
, который принимает параметр значения типа
Queue[AnyRef]
Интуитивно все это выглядит хорошо, поскольку очередь из
String
элементов похожа на частный случай очереди из
AnyRef
элементов. Но в Scala у обобщен
ных типов изначально имеется нонвариантная (или жесткая) подтипизация.
То есть при использовании трейта
Queue
, определенного в показанном выше листинге 18.4, очереди с различными типами элементов никогда не будут состоять в подтиповых отношениях.
Queue[String]
не будет использоваться