Файл: Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста.pdf

ВУЗ: Не указан

Категория: Не указан

Дисциплина: Не указана

Добавлен: 09.12.2023

Просмотров: 754

Скачиваний: 11

ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.

СОДЕРЖАНИЕ

90 Глава 3 • Дальнейшие шаги в Scala возвращает новый Option, содержащий результат передачи исходного эле­мента Some в функцию, переданную в map. Вот пример преобразования startsW в Some, содержащего строку WHO:startsW.map(word => word.toUpperCase) // Some(WHO)Как и в случае с List и Vector, вы можете добиться того же преобразования в Option с помощью for-yield:for word <- startsW yield word.toUpperCase // Some(WHO)Если вы вызываете map с параметром None (параметр, который не определен), то вернете значение None. Например:startsH.map(word => word.toUpperCase) // NoneА вот такое же преобразование с использованием for-yield:for word <- startsH yield word.toUpperCase // NoneВы можете преобразовать многие другие типы с помощью map и for-yield, но пока этого достаточно. Этот шаг дал вам представление о том, сколько кода Scala написано в виде функциональных преобразований неизменяемых структур данных.РезюмеЗнания, полученные в этой главе, позволят вам начать применять Scala для решения небольших задач, в особенности тех, для которых используются скрипты. В последующих главах мы глубже разберем рассмотренные темы, а также представим другие, не затронутые здесь. 4Классы и объектыВ предыдущих двух главах вы разобрались в основах классов и объектов. В этой главе вам предстоит углубленно проработать данную тему. Здесь мы дадим дополнительные сведения о классах, полях и методах, а также общее представление о том, когда подразумевается использование точки с запятой. Кроме того, рассмотрим объекты­одиночки (singleton) и то, как с их помо­щью писать и запускать приложения на Scala. Если вам уже знаком язык Java, то вы увидите, что в Scala фигурируют похожие, но все же немного отличающиеся концепции. Поэтому чтение данной главы пойдет на пользу даже великим знатокам языка Java.4 .1 . Классы, поля и методыКлассы — «чертежи» объектов. После определения класса из него, как по чертежу, можно создавать объекты, воспользовавшись для этого ключевым словом new. Например, при наличии следующего определения класса:class ChecksumAccumulator: // Сюда помещается определение класса с отступом с помощью кода new ChecksumAccumulator можно создавать объекты ChecksumAccumulatorВнутри определения класса помещаются поля и методы, которые в общем называются членами класса. Поля, которые определяются либо как val­, либо как var­переменные, являются переменными, относящимися к объектам. 92 Глава 4 • Классы и объекты Методы, определяемые с помощью ключевого слова def, содержат испол­няемый код. В полях хранятся состояние или данные объекта, а методы используют эти данные для выполнения в отношении объекта вычислений. При создании экземпляра класса среда выполнения приложения резервирует часть памяти для хранения образа состояния получающегося при этом объ­екта (то есть содержимого его полей). Например, если вы определите класс ChecksumAccumulator и дадите ему var­поле по имени sum:class ChecksumAccumulator: var sum = 0а потом дважды создадите его экземпляры с помощью следующего кода:val acc = new ChecksumAccumulator val csa = new ChecksumAccumulator то образ объектов в памяти может выглядеть так:Поскольку sum — поле, определенное внутри класса ChecksumAccumulator, и относится к var­, а не к val­переменным, то впоследствии ему (полю) можно заново присвоить другое Int­значение:acc.sum = 3Теперь картинка может выглядеть так: 4 .1 . Классы, поля и методы 93По поводу этой картинки нужно отметить следующее: на ней показаны две переменные sum. Одна из них находится в объекте, на который ссылается acc, а другая — в объекте, на который ссылается csa. Поля также называют пере-менными экземпляра, поскольку каждый экземпляр получает собственный набор переменных. Все переменные экземпляра объекта составляют образ объекта в памяти. То, что здесь показано, свидетельствует не только о на­личии двух переменных sum, но и о том, что изменение одной из них никак не отражается на другой.В этом примере следует также отметить: у вас есть возможность изменить объект, на который ссылается acc, даже несмотря на то, что acc относится к val­переменным. Но с учетом того, что acc (или csa) являются val­, а не var­переменными, вы не можете присвоить им какой­нибудь другой объект. Например, попытка, показанная ниже, не будет успешной:// Не пройдет компиляцию, поскольку acc является val-переменной acc = new ChecksumAccumulatorТеперь вы можете рассчитывать на то, что переменная acc всегда будет ссылаться на тот же объект ChecksumAccumulator, с помощью которого вы ее инициализировали; поля же, содержащиеся внутри этого объекта, могут со временем измениться.Один из важных способов обеспечения надежности объекта — гарантия того, что состояние этого объекта, то есть значения его переменных экземпляра, остается корректным в течение всего его жизненного цикла. Первый шаг к предотвращению непосредственного стороннего доступа к полям — созда­ние приватных (private) полей. Доступ к приватным полям можно получить только методами, определенными в том же самом классе, поэтому весь код, который может обновить состояние, будет локализован в классе. Чтобы объ­явить поле приватным, перед ним нужно поставить модификатор доступа private:class ChecksumAccumulator: private var sum = 0С таким определением ChecksumAccumulator любая попытка доступа к sum за пределами класса будет неудачной:val acc = new ChecksumAccumulator acc.sum = 5 // Не пройдет компиляцию, поскольку поле sum // является приватнымТеперь, когда поле sum стало приватным, доступ к нему можно получить только из кода, определенного внутри тела самого класса. Следовательно, 94 Глава 4 • Классы и объекты класс ChecksumAccumulator не будет особо полезен, пока внутри него не будут определены некоторые методы:class ChecksumAccumulator: private var sum = 0 def add(b: Byte): Unit = sum += b def checksum(): Int = return (sum & 0xFF) + 1ПРИМЕЧАНИЕВ Scala элементы класса делают публичными, если нет явного указания какого-либо модификатора доступа . Иначе говоря, там, где в Java ставится модификатор public, в Scala вы обходитесь простым замалчиванием . Публич- ный (public) доступ в Scala — уровень доступа по умолчанию .Теперь у ChecksumAccumulator есть два метода: add и checksum, оба они демон­стрируют основную форму определения функции, показанную на рис. 2.1 1Внутри этого метода могут использоваться любые параметры метода. Од­ной из важных характеристик параметров метода в Scala является то, что они относятся к val­, а не к var­переменным2. При попытке повторного присваивания значения параметру внутри метода в Scala произойдет сбой компиляции:def add(b: Byte): Unit = b = 1 // Не пройдет компиляцию, поскольку b относится к val-переменным sum += bХотя методы add и checksum в данной версии ChecksumAccumulator реали­зуют желаемые функциональные свойства вполне корректно, их можно определить в более лаконичном стиле. Во­первых, в конце метода checksum можно избавиться от лишнего слова return. В отсутствие явно указанной инструкции return метод в Scala возвращает последнее вычисленное им значение.1 В методе checksum используются два оператора: тильда () для побитового допол­нения и амперсанд (&) для побитового И. Оба оператора описаны в разделе 5.7.2 Причина, по которой параметры имеют значение val, заключается в том, что о val легче рассуждать. Вам не нужно ничего дополнительно изучать, как это делается с var, чтобы определить, переназначается ли val 4 .1 . Классы, поля и методы 95При написании методов рекомендуется применять стиль, исключающий явное и особенно многократное использование инструкции return. Каждый метод нужно рассматривать в качестве выражения, выдающего одно значение, которое и является возвращаемым. Эта философия будет побуждать вас соз­давать небольшие методы и разбивать слишком крупные методы на несколько мелких. В то же время выбор конструктивного решения зависит от контекста решаемых задач, и, если того требуют условия, Scala упрощает написание методов, которые имеют несколько явно указанных возвращаемых значений.Поскольку checksum выполняет только вычисление значений, ему не тре­буется прямая инструкция return. Еще одним способом обобщения мето­дов является то, что если метод вычисляет только одно результирующее выражение и оно короткое, его можно поместить в ту же строку, что и сам def. Для максимальной краткости вы можете не указывать тип результата, и Scala самостоятельно сделает его вывод. С этими изменениями класс ChecksumAccumulator выглядит так:class ChecksumAccumulator: private var sum = 0 def add(b: Byte) = sum += b def checksum() = (sum & 0xFF) + 1Несмотря на то что компилятор Scala вполне корректно выполнит вывод ре­зультирующих типов методов add и checksum, показанных в предыдущем при­мере, читатели кода будут вынуждены вывести результирующие типы путем логических умозаключений на основе изучения тел методов. Поэтому лучше все­таки будет всегда явно указывать результирующие типы для публичных методов, объявленных в классе, даже когда компилятор может вывести их для вас самостоятельно. Применение этого стиля показано в листинге 4.1.Листинг 4.1. Окончательная версия класса ChecksumAccumulator// Этот код находится в файле ChecksumAccumulator.scala class ChecksumAccumulator: private var sum = 0 def add(b: Byte): Unit = sum += b def checksum(): Int = (sum & 0xFF) + 1Методы с результирующим типом Unit, к которым относится и метод add класса ChecksumAccumulator, выполняются для получения побочного эф­фекта. Последний обычно определяется в виде изменения внешнего по от­ношению к методу состояния или в виде выполнения какой­либо операции ввода­вывода. Что касается метода add, то побочный эффект заключается в присваивании sum нового значения. Метод, который выполняется только для получения его побочного эффекта, называется процедурой. 96 Глава 4 • Классы и объекты 4 .2 . Когда подразумевается использование точки с запятойВ программе на Scala точку с запятой в конце инструкции обычно можно не ставить. Если вся инструкция помещается на одной строке, то при же­лании можете поставить в конце данной строки точку с запятой, но это не обязательно. В то же время точка с запятой нужна, если на одной строке размещаются сразу несколько инструкций:val s = "hello"; println(s)Если требуется набрать инструкцию, занимающую несколько строк, то в большинстве случаев вы можете просто ее ввести, а Scala разделит инструк­ции в нужном месте. Например, следующий код рассматривается как одна инструкция, расположенная на четырех строках:if x < 2 then "too small"else "ok"Правила расстановки точек с запятойПравила разделения операторов удивительно просты. Вкратце, точка с запятой всегда обозначает конец строки, кроме случаев, когда не вы­полняется одно из следующих условий.1. Рассматриваемая строка заканчивается словом или символом, который недопустим в качестве конца оператора, например точкой или инфиксным оператором.2. Следующая строка начинается со слова, с которого не может на­чинаться оператор.3. Строка заканчивается внутри круглых (...) или квадратных [...] скобок, потому что они не могут содержать несколько операторов.4 .3 . Объекты-одиночкиКак упоминалось в главе 1, один из аспектов, позволяющих Scala быть бо­лее объектно­ориентированным языком, чем Java, заключается в том, что в классах Scala не могут содержаться статические элементы. Вместо этого 4 .3 . Объекты-одиночки 97в Scala есть объекты-одиночки, или синглтоны. Определение объекта­оди­ночки выглядит так же, как определение класса, за исключением того, что вместо ключевого слова class используется ключевое слово object. Пример показан в листинге 4.2.Объект­одиночка в этом листинге называется ChecksumAccumulator, то есть носит имя, совпадающее с именем класса в предыдущем примере. Когда объ­ект­одиночка использует общее с классом имя, то для класса он называется объектом-компаньоном. И класс, и его объект­компаньон нужно определять в одном и том же исходном файле. Класс по отношению к объекту­одиночке называется классом-компаньоном. Класс и его объект­компаньон могут об­ращаться к приватным элементам друг друга.Листинг 4.2. Объект-компаньон для класса ChecksumAccumulator// Этот код находится в файле ChecksumAccumulator.scala import scala.collection.mutable object ChecksumAccumulator: private val cache = mutable.Map.empty[String, Int] def calculate(s: String): Int = if cache.contains(s) then cache(s) else val acc = new ChecksumAccumulator for c <- s do acc.add((c >> 8).toByte) acc.add(c.toByte) val cs = acc.checksum() cache += (s –> cs) csОбъект­одиночка ChecksumAccumulator располагает одним методом по имени calculate, который получает строку String и вычисляет контрольную сумму символов этой строки. Вдобавок он имеет одно приватное поле cache, пред­ставленное изменяемым отображением, в котором кэшируются ранее вычис­ленные контрольные суммы1. В первой строке метода, "if cache.contains(s)1 Здесь cache используется, чтобы показать объект­одиночку с полем. Кэширование с помощью поля cache помогает оптимизировать производительность, сокращая за счет расхода памяти время вычисления и разменивая расход памяти на время вычисления. Как правило, использовать кэш­память таким образом целесообразно только в том случае, если с ее помощью можно решить проблемы производительно­сти и воспользоваться отображением со слабыми ссылками, например WeakHashMap в scala.collection.mutable, чтобы записи в кэш­памяти могли попадать в сборщик мусора при наличии дефицита памяти. 98 Глава 4 • Классы и объекты then", определяется, не содержится ли в отображении cache переданная строка в качестве ключа. Если да, то просто возвращается отображенное на этот ключ значение cache(s). В противном случае выполняется условие else, которое вычисляет контрольную сумму. В первой строке условия else определяется val­переменная по имени acc, которая инициализируется новым экземпля­ром ChecksumAccumulator1. В следующей строке находится выражение forОно выполняет последовательный перебор каждого символа в переданной строке, преобразует символ в значение типа Byte, вызывая в отношении это­го символа метод toByte, и передает результат в метод add того экземпляра ChecksumAccumulator, на который ссылается acc2. Когда завершится вычисле­ние выражения for, в следующей строке метода в отношении acc будет вызван метод checksum, который берет контрольную сумму для переданного значения типа String и сохраняет ее в val­переменной по имени csВ следующей строке, cache+=(s–>cs), переданный строковый ключ отобра­жается на целочисленное значение контрольной суммы, и эта пара «ключ — значение» добавляется в отображение cache. В последнем выражении метода, cs, обеспечивается использование контрольной суммы в качестве результата выполнения метода.Если у вас есть опыт программирования на Java, то объекты­одиночки можно представить в качестве хранилища для любых статических методов, которые вполне могли быть написаны на Java. Методы в объектах­одиночках можно вызывать с помощью такого синтаксиса: имя объекта, точка, имя метода. Например, метод calculate объекта­одиночки ChecksumAccumulator можно вызвать следующим образом:ChecksumAccumulator.calculate("Every value is an object.")Но объект­одиночка не только хранилище статических методов. Он объект первого класса. Поэтому имя объекта­одиночки можно рассматривать в ка­честве «этикетки», прикрепленной к объекту.1 Поскольку ключевое слово new используется только для создания экземпляров классов, новый объект, созданный здесь в качестве экземпляра класса ChecksumAc- cumulator, не является одноименным объектом­одиночкой.2 Оператор >>, выполняющий побитовый сдвиг вправо, описан в разделе 5.7. 4 .4 . Case-классы 99Определение объекта­одиночки не является определением типа на том уровне абстракции, который используется в Scala. Имея лишь определение объекта ChecksumAccumulator, невозможно создать одноименную переменную типа. Точнее, тип с именем ChecksumAccumulator определяется классом­компаньоном объекта­одиночки. Тем не менее объекты­одиночки расширяют суперкласс и могут подмешивать трейты. Учитывая то, что каждый объект­одиночка — эк­земпляр своего суперкласса и подмешанных в него трейтов, его методы можно вызывать через эти типы, ссылаясь на него из переменных этих типов и пере­давая ему методы, ожидающие использования этих типов. Примеры объектов­одиночек, являющихся наследниками классов и трейтов, показаны в главе 12.Одно из отличий классов от объектов­одиночек состоит в том, что объекты­одиночки не могут принимать параметры, а классы — могут. Создать экзем­пляр объекта­одиночки с помощью ключевого слова new нельзя, поэтому передать ему параметры не представляется возможным. Каждый объект­оди­ночка реализуется как экземпляр синтетического класса, ссылка на который находится в статической переменной, поэтому у них и у статических классов Java одинаковая семантика инициализации1. В частности, объект­одиночка инициализируется при первом обращении к нему какого­либо кода.Объект­одиночка, который не имеет общего имени с классом­компаньоном, называется самостоятельным. Такие объекты можно применять для решения многих задач, включая сбор в одно целое родственных вспомогательных ме­тодов или определение точки входа в приложение Scala. Именно этот случай мы и рассмотрим в следующем разделе.4 .4 . Case-классыЧасто при написании класса вам потребуется реализация таких методов, как equals, hashCode, toString — методы доступа или фабричные методы. Их написание может занять много времени и привести к ошибкам. Scala предлагает такой инструмент, как case­классы (классы­образцы), которые могут генерировать реализации нескольких методов на основе значений, переданных его основному конструктору. Вы создаете класс case, помещая модификатор case перед class, например:case class Person(name: String, age: Int)1 В качестве имени синтетического класса используется имя объекта со знаком дол­лара. Следовательно, синтетический класс, применяемый для объекта­одиночки Check sumAccumulator, называется ChecksumAccumulator$ 1   ...   7   8   9   10   11   12   13   14   ...   64

110 Глава 5 • Основные типы и операции Проблема во включении в строку пробелов перед второй строкой текста! Чтобы справиться с этой весьма часто возникающей ситуацией, вы можете вызывать в отношении строк метод stripMargin. Чтобы им воспользоваться, поставьте символ вертикальной черты (|) перед каждой строкой текста, а за­тем в отношении всей строки вызовите метод stripMargin:println("""|Welcome to Ultamix 3000. |Type "HELP" for help.""".stripMargin)Вот теперь код ведет себя подобающим образом:Welcome to Ultamix 3000.Type "HELP" for help.Булевы литералыУ типа Boolean имеется два литерала, true и false:val bool = true // true: Boolean val fool = false // false: BooleanВот, собственно, и все. Теперь вы буквально (или литерально) стали боль­шим специалистом по Scala.5 .3 . Интерполяция строкВ Scala включен довольно гибкий механизм для интерполяции строк, позволяющий вставлять выражения в строковые литералы. В самом рас­пространенном случае использования этот механизм предоставляет лако­ничную и удобочитаемую альтернативу конкатенации строк. Рассмотрим пример:val name = "reader"println(s"Hello, $name!")Выражение s"Hello,$name!" — обрабатываемый строковый литерал. По­скольку за буквой s стоят открывающие кавычки, то Scala для обработки литерала воспользуется интерполятором строк s. Он станет вычислять каждое встроенное выражение, вызывая в отношении каждого результата метод toString и заменяя встроенные выражения в литерале этими резуль­татами. Таким образом, из s"Hello,$name!" получится "Hello,reader!", 5 .3 . Интерполяция строк 111то есть точно такой же результат, как при использовании кода "Hello,"+name+"!"После знака доллара ($) в обрабатываемом строковом литерале можно ука­зать любое выражение. Для выражений с одной переменной зачастую можно просто поместить после знака доллара имя этой переменной. Все символы, вплоть до первого символа, не относящегося к идентификатору, Scala будет интерпретировать как выражение. Если в него включены символы, не явля­ющиеся идентификаторами, то это выражение следует заключить в фигур­ные скобки, а открывающая фигурная скобка должна ставиться сразу же после знака доллара, например:scala> s"The answer is ${6 * 7}."val res0: String = The answer is 42.Scala содержит еще два интерполятора строк: raw и f. Интерполятор строк raw ведет себя практически так же, как и s, за исключением того, что не рас­познает управляющие последовательности символьных литералов (те самые, которые показаны в табл. 5.2). Например, следующая инструкция выводит четыре, а не два обратных слеша:println(raw"No\\\\escape!") // выводит: No\\\\escape!Интерполятор строк f позволяет прикреплять к встроенным выражениям инструкции форматирования в стиле функции printf. Инструкции ставятся после выражения и начинаются со знака процента (%), при этом используется синтаксис, заданный классом java.util.Formatter. Например, вот как можно было бы отформатировать число π:scala> f"${math.Pi}%.5f"val res1: String = 3.14159Если для встроенного выражения не указать никаких инструкций форма­тирования, то интерполятор строк f по умолчанию превратится в %s, что означает подстановку значения, полученного в результате выполнения ме­тода toString, точно так же, как это делает интерполятор строк s, например:scala> val pi = "Pi"val pi: String = Pi scala> f"$pi is approximately ${math.Pi}%.8f."val res2: String = Pi is approximately 3.14159265.В Scala интерполяция строк реализуется перезаписью кода в ходе ком­пиляции. Компилятор в качестве выражения интерполятора строк будет 112 Глава 5 • Основные типы и операции рассматривать любое выражение, состоящее из идентификатора, за которым сразу же стоят открывающие двойные кавычки строкового литерала. Интер­поляторы строк s, f и raw реализуются с помощью этого общего механизма. Библиотеки и пользователи могут определять другие интерполяторы строк, применяемые в иных целях.5 .4 . Все операторы являются методамиДля основных типов Scala предоставляет весьма богатый набор операторов. Как упоминалось в предыдущих главах, эти операторы — всего лишь прият­ный синтаксис для обычных вызовов методов. Например, 1+2 означает то же самое, что и 1.+(2). Иными словами, в классе Int имеется метод по имени +, который получает Int­значение и возвращает Int­результат. Он вызывается при сложении двух Int­значений:val sum = 1 + 2 // Scala вызывает 1.+(2)Чтобы убедиться в этом, можете набрать выражение, в точности соответ­ствующее вызову метода:scala> val sumMore = 1.+(2)val sumMore: Int = 3Фактически в классе Int содержится несколько перегруженных методов +, получающих различные типы параметров1. Например, у Int есть еще один метод, тоже по имени +, который получает и возвращает значения типа LongПри сложении Long и Int будет вызван именно этот альтернативный метод:scala> val longSum = 1 + 2L // Scala вызывает 1.+(2L)val longSum: Long = 3Символ + — оператор, точнее, инфиксный оператор. Форма записи опера­торов не ограничивается методами, подобными +, которые в других языках выглядят как операторы. Любой метод может использоваться в нотации операторов, если он принимает только один параметр2. Например, в классе String есть метод indexOf, получающий один параметр типа Char. Метод indexOf ведет поиск первого появления в строке указанного символа и воз­1 Перегруженные методы имеют точно такие же имена, но используют другие типы аргументов. Более подробно перегрузка методов рассматривается в разделе 6.11.2 В будущих версиях Scala методы с несимвольными именами будут разрешены в качестве операторов только в том случае, если они объявлены с модификато­ром infix 5 .4 . Все операторы являются методами 113вращает его индекс или –1, если символ найден не будет. Метод indexOf можно использовать как оператор:scala> val s = "Hello, world!"val s: String = Hello, world!scala> s indexOf 'o' // Scala вызывает s.indexOf('o')val res0: Int = 4Любой однопараметрический метод может быть операторомВ Scala операторы не относятся к специальному синтаксису языка. Любой метод, который содержит один параметр, может быть операто­ром. Однопараметрический метод становится оператором в зависимо­сти от того, как вы его используете. Если вы напишете s.indexOf('o'), то indexOf не будет являться оператором, но станет им, если запись будет иметь вид формы оператора — sindexOf'o'До сих пор рассматривались только примеры инфиксной формы записи операторов, означающей, что вызываемый метод находится между объектом и параметром или параметрами, которые нужно передать методу, как в вы­ражении 7+2. В Scala также имеются две другие формы записи операторов: префиксная и постфиксная. В префиксной форме записи имя метода ста­вится перед объектом, в отношении которого вызывается этот метод (напри­мер, – в выражении –7). В постфиксной форме имя метода ставится после объекта (например, toLong в выражении 7toLong).В отличие от инфиксной формы записи, в которой операторы получают два операнда (один слева, другой справа), префиксные и постфиксные операторы являются унарными — получают только один операнд. В префиксной форме записи операнд размещается справа от оператора. В качестве примеров мож­но привести выражения –2.0, !found и 0xFF. Как и в случае использования инфиксных операторов, эти префиксные операторы являются сокращенной формой вызова методов. Но в данном случае перед символом оператора в име­ни метода ставится приставка unary_. Например, Scala превратит выражение –2.0 в вызов метода (2.0).unary_–. Вы можете убедиться в этом, набрав вызов метода как с использованием формы записи операторов, так и в явном виде:scala> -2.0 // Scala вызывает (2.0).unary_- val res2: Double = -2.0scala> (2.0).unary_- val res3: Double = -2.0 114 Глава 5 • Основные типы и операции Идентификаторами, которые могут служить в качестве префиксных операто­ров, являются только +, –, ! и . Следовательно, если вы определите метод по имени unary_!, то сможете вызвать его в отношении значения или переменной подходящего типа, прибегнув к префиксной форме записи операторов, напри­мер !p. Но, определив метод по имени unary_*, вы не сможете использовать префиксную форму записи операторов, поскольку * не входит в число четы­рех идентификаторов, которые могут использоваться в качестве префиксных операторов. Метод можно вызвать обычным способом как p.unary_*, но при попытке вызвать его в виде *p Scala воспримет код так, словно он записан в виде *.p, что, вероятно, совершенно не совпадает с задуманным1! Постфиксные операторы, будучи вызванными без точки или круглых ско­бок, являются методами, не получающими аргументов. В Scala при вызове метода пустые круглые скобки можно не ставить. Соглашение гласит, что круглые скобки ставятся, если метод имеет побочные эффекты, как в случае с методом println(). Но их можно не ставить, если метод не имеет побочных эффектов, как в случае с методом toLowerCase, вызываемым в отношении значения типа String:scala> val s = "Hello, world!"val s: String = Hello, world!scala> s.toLowerCase val res4: String = hello, world!В последнем случае, где методу не требуются аргументы, можно при желании не ставить точку и воспользоваться постфиксной формой записи операто­ров. Однако компилятор потребует, чтобы вы импортировали scala.lan- guage.postfixOps, прежде чем вызывать метод:scala> import scala.language.postfixOps scala> s toLowerCase val res5: String = hello, world!Здесь метод toLowerCase используется в качестве постфиксного оператора в отношении операнда sЧтобы понять, какие операторы можно использовать с основными типами Scala, нужно посмотреть на методы, объявленные в классах типов, в доку­ментации по Scala API. Но данная книга — пособие по языку Scala, поэтому в нескольких следующих разделах будет представлен краткий обзор боль­шинства этих методов.1 Однако не обязательно все будет потеряно. Есть весьма незначительная вероят­ность того, что программа с кодом *p может скомпилироваться как код C++. 5 .5 . Арифметические операции 115УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ ДЛЯ JAVA-ПРОГРАММИСТОВМногие аспекты Scala, рассматриваемые в оставшейся части главы, со- впадают с аналогичными Java-аспектами . Если вы хорошо разбираетесь в Java и у вас мало времени, то можете спокойно перейти к разделу 5 .8, в котором рассматриваются отличия Scala от Java в области равенства объектов .5 .5 . Арифметические операции Арифметические методы при работе с любыми числовыми типами можно вызвать в инфиксной форме для сложения (+), вычитания (–), умноже­ния (*), деления (/) и получения остатка от деления (%). Вот несколько примеров:1.2 + 2.3 // 3.5: Double 3 — 1 // 2: Int'b' — 'a' // 1: Int2L * 3L // 6: Long11 / 4 // 2: Int11 % 4 // 3: Int11.0f / 4.0f // 2.75: Float11.0 % 4.0 // 3.0: DoubleКогда целочисленными типами являются как правый, так и левый операнды (Int, Long, Byte, Short или Char), оператор / выведет всю числовую часть результата деления, исключая остаток. Оператор % показывает остаток от предполагаемого целочисленного деления.Остаток от деления числа с плавающей точкой, полученный с помощью метода %, не определен в стандарте IEEE 754. Что касается операции вы­числения остатка, в этом стандарте используется деление с округлением, а не деление с отбрасыванием остатка. Поэтому данная операция сильно от­личается от операции вычисления остатка от целочисленного деления. Если все­таки нужно получить остаток по стандарту IEEE 754, то можно вызвать метод IEEEremainder из scala.math:math.IEEEremainder(11.0, 4.0) // -1.0: DoubleЧисловые типы также предлагают прибегнуть к унарным префиксным операторам + (метод unary_+) и – (метод unary_–), позволяющим показать положительное или отрицательное значение числового литерала, как в –3или +4.0. Если не указать унарный + или –, то литерал интерпретируется как положительный. Унарный + существует исключительно для симметрии с унарным –, однако не производит никаких действий. Унарный – может 116 Глава 5 • Основные типы и операции также использоваться для смены знака переменной. Вот несколько при­меров:val neg = 1 + -3 // -2 : Neg val y = +3 // 3: Int-neg // 2: Int 5 .6 . Отношения и логические операцииЧисловые типы можно сравнивать с помощью методов отношений «боль­ше» (>), «меньше» (<), «больше или равно» (>=) и «меньше или равно» (<=), которые выдают в качестве результата булево значение. Дополнительно, чтобы инвертировать булево значение, можно использовать унарный опе­ратор ! (метод unary_!). Вот несколько примеров:1 > 2 // false: Boolean1 < 2 // true: Boolean1.0 <= 1.0 // true: Boolean3.5f >= 3.6f // false: Boolean'a' >= 'A' // true: Boolean val untrue = !true // false: BooleanМетоды «логическое И» (&& и &) и «логическое ИЛИ» (|| и |) получают операнды типа Boolean в инфиксной нотации и выдают результат в виде Boolean­значения, например:val toBe = true // true: Boolean val question = toBe || !toBe // true: Boolean val paradox = toBe && !toBe // false: BooleanОперации && и ||, как и в Java, — сокращенно вычисляемые: выражения, по­строенные с помощью этих операторов, вычисляются, только когда это нуж­но для определения результата. Иными словами, правая часть выражений с использованием && и || не будет вычисляться, если результат уже опреде­лился при вычислении левой части. Например, если левая часть выражения с методом && вычисляется в false, то результатом выражения, несомненно, будет false, поэтому правая часть не вычисляется. Аналогично этому если левая часть выражения с методом || вычисляется в true, то результатом выражения конечно же будет true, поэтому правая часть не вычисляется:scala> def salt() = { println("salt"); false }def salt(): Boolean scala> def pepper() = { println("pepper"); true }def pepper(): Boolean 5 .7 . Поразрядные операции 117scala> pepper() && salt()pepper salt val res21: Boolean = false scala> salt() && pepper()salt val res22: Boolean = falseВ первом выражении вызываются pepper и salt, но во втором вызывается только salt. Поскольку salt возвращает false, то необходимость в вызове pepper отпадает.Если правую часть нужно вычислить при любых условиях, то вместо пока­занных выше методов следует обратиться к методам & и |. Первый выполняет логическую операцию И, а второй — операцию ИЛИ, но при этом они не прибегают к сокращенному вычислению, как это делают методы && и ||. Вот как выглядит пример их использования:scala> salt() & pepper()salt pepper val res23: Boolean = falseПРИМЕЧАНИЕВозникает вопрос: как сокращенное вычисление может работать, если операторы — всего лишь методы? Обычно все аргументы вычисляются перед входом в метод, тогда каким же образом метод может избежать вы- числения своего второго аргумента? Дело в том, что у всех методов Scala есть средство для задержки вычисления его аргументов или даже полной его отмены . Оно называется «параметр, передаваемый по имени» и будет рассмотрено в разделе 9 .5 .5 .7 . Поразрядные операцииScala позволяет выполнять операции над отдельными разрядами целочис­ленных типов, используя несколько поразрядных методов. К таким методам относятся поразрядное И (&), поразрядное ИЛИ (|) и поразрядное исклю­чающее ИЛИ (^)1. Унарный поразрядный оператор дополнения (, метод unary_) инвертирует каждый разряд в своем операнде, например:1 Метод поразрядного исключающего ИЛИ выполняет соответствующую опера­цию в отношении своих операндов. Из одинаковых разрядов получается 0, а из разных -1. Следовательно, выражение 0011^0101 вычисляется в 0110 118 Глава 5 • Основные типы и операции 1 & 2 // 0: Int1 | 2 // 3: Int1 ^ 3 // 2: Int1 // -2: IntВ первом выражении, 1&2, выполняется поразрядное И над каждым раз­рядом чисел 1 (0001) и 2 (0010) и выдается результат 0 (0000). Во втором выражении, 1|2, выполняется поразрядное ИЛИ над теми же операндами и выдается результат 3 (0011). В третьем выражении, 1^3, выполняется поразрядное исключающее ИЛИ над каждым разрядом 1 (0001) и 3 (0011) и выдается результат 2 (0010). В последнем выражении, 1, инвертируется каждый разряд в 1 (0001) и выдается результат –2, который в двоичной форме выглядит как 11111111111111111111111111111110Целочисленные типы Scala также предлагают три метода сдвига: влево (<<), вправо (>>) и беззнаковый сдвиг вправо (>>>). Методы сдвига, примененные в инфиксной форме записи операторов, сдвигают разряды целочисленного значения, указанные слева от оператора, на количество разрядов, указанное в целочисленном значении справа от оператора. При сдвиге влево и беззнако­вом сдвиге вправо разряды по мере сдвига заполняются нулями. При сдвиге вправо разряды указанного слева значения по мере сдвига заполняются зна­чением самого старшего разряда (разряда знака). Вот несколько примеров:-1 >> 31 // -1: Int-1 >>> 31 // 1: Int1 << 2 // 4: IntЧисло –1 в двоичном виде выглядит как 11111111111111111111111111111111В первом примере, –1>>31, в числе –1 происходит сдвиг вправо на 31 разряд­ную позицию. В значении типа Int содержатся 32 разряда, поэтому данная операция, по сути, перемещает самый левый разряд до тех пор, пока тот не станет самым правым1. Поскольку метод >> выполняет заполнение по мере сдвига единицами ввиду того, что самый левый разряд числа –1 — это 1, результат получается идентичным исходному левому операнду и состоит из 32 единичных разрядов, или равняется –1. Во втором примере, –1>>>31, самый левый разряд опять сдвигается вправо в самую правую позицию, од­нако на сей раз освобождающиеся разряды заполняются нулями. Поэтому результат в двоичном виде получается 00000000000000000000000000000001, или 1. В последнем примере, 1<<2, левый операнд, 1, сдвигается влево на две позиции (освобождающиеся позиции заполняются нулями), в результате 1 Самый левый разряд в целочисленном типе является знаковым. Если самый левый разряд установлен в 1, значит, число отрицательное, если в 0, то положительное. 5 .8 . Равенство объектов1   ...   9   10   11   12   13   14   15   16   ...   64

119чего в двоичном виде получается число 00000000000000000000000000000100, или 4 5 .8 . Равенство объектовЕсли нужно сравнить два объекта на равенство, то можно воспользоваться либо методом ==, либо его противоположностью — методом !=. Вот несколь­ко простых примеров:1 == 2 // false: Boolean1 != 2 // true: Boolean2 == 2 // true: BooleanПо сути, эти две операции применимы ко всем объектам, а не только к ос­новным типам. Например, оператор == можно использовать для сравнения списков:List(1, 2, 3) == List(1, 2, 3) // true: BooleanList(1, 2, 3) == List(4, 5, 6) // false: BooleanЕсли пойти еще дальше, то можно сравнить два объекта, имеющих разные типы:1 == 1.0 // true: BooleanList(1, 2, 3) == "hello" // false: BooleanМожно даже выполнить сравнение со значением null или с тем, что может иметь данное значение. Никакие исключения при этом выдаваться не будут:List(1, 2, 3) == null // false: Boolean null == List(1, 2, 3) // false: BooleanКак видите, оператор == реализован весьма искусно, и вы в большинстве слу­чаев получите то сравнение на равенство, которое вам нужно. Все делается по очень простому правилу: сначала левая часть проверяется на null. Если ее значение не null, то вызывается метод equals. Ввиду того что equals — метод, точность получаемого сравнения зависит от типа левого аргумента. Проверка на null выполняется автоматически, поэтому вам не нужно проводить ее1Этот вид сравнения выдает true в отношении различных объектов, если их содержимое одинаково и их методы equals созданы на основе проверки 1 Автоматическая проверка игнорирует правую сторону, но любой корректно реализо­ванный метод equals должен возвращать false, если его аргумент имеет значение null 120 Глава 5 • Основные типы и операции содержимого. Например, вот как сравниваются две строки, в которых по пять одинаковых букв:("he" + "llo") == "hello" // true: BooleanРазличия операторов == в Scala и JavaВ Java оператор == может использоваться для сравнения как при­митивных, так и ссылочных типов. В отношении примитивных ти­пов оператор == в Java проверяет равенство значений, как и в Scala. Но в отношении ссылочных типов оператор == в Java проверяет ра-венство ссылок. Это значит, две переменные указывают на один и тот же объект в куче, принадлежащей JVM. Scala также предоставляет средство eq для сравнения равенства ссылок. Но метод eq и его про­тивоположность, метод ne, применяются только к объектам, которые непосредственно отображаются на объекты Java. Исчерпывающие подробности о eq и ne приводятся в разделах 17.1 и 17.2. Кроме того, в главе 8 показано, как создавать хорошие методы equals5 .9 . Приоритет и ассоциативность операторовПриоритет операторов определяет, какая часть выражения вычисляется самой первой. Например, выражение 2+2*7 вычисляется в 16, а не в 28, поскольку оператор * имеет более высокий приоритет, чем оператор + Поэтому та часть выражения, в которой требуется перемножить числа, вы­числяется до того, как будет выполнена часть, в которой числа складыва­ются. Разумеется, чтобы уточнить в выражении порядок вычисления или переопределить приоритеты, можно воспользоваться круглыми скобками. Например, если вы действительно хотите, чтобы результат вычисления ранее показанного выражения был 28, то можете набрать следующее вы­ражение:(2 + 2) * 7Если учесть, что в Scala, по сути, нет операторов, а есть только способ приме­нения методов в форме записи операторов, то возникает вопрос: а как тогда работает приоритет операторов? Scala принимает решение о приоритете на основе первого символа метода, использованного в форме записи операторов (из этого правила есть одно исключение, рассматриваемое ниже). Если имя метода начинается, к примеру, с *, то он получит более высокий приоритет, 5 .9 . Приоритет и ассоциативность операторов 121чем метод, чье имя начинается на +. Следовательно, выражение 2+2*7будет вычислено как 2+(2*7). Аналогично этому выражение a+++b***c, в котором a, b и c — переменные, а +++ и *** — методы, будет вычислено как a+++(b***c), поскольку метод *** обладает более высоким уровнем прио­ритета, чем метод +++В табл. 5.3 показан приоритет применительно к первому символу метода в убывающем порядке, где символы, показанные на одной строке, опре­деляют одинаковый уровень приоритета. Чем выше символ в списке, тем выше приоритет начинающегося с него метода. Вот пример, показывающий влияние приоритета:2 << 2 + 2 // 32: IntТаблица 5.3. Приоритет операторов(Все специальные символы)* / %+ –:= !< >&^|(Все буквы)(Все операторы присваивания)Имя метода << начинается с символа <, который появляется в приведенном списке ниже символа + — первого и единственного символа метода +. Следо­вательно, << будет иметь более низкий уровень приоритета, чем +, и выраже­ние будет вычислено путем вызова сначала метода +, а затем метода <<, как в выражении 2<<(2+2). При сложении 2+2 в результате математического действия получается 4, а вычисление выражения 2<<4 дает результат 32Если поменять операторы местами, то будет получен другой результат:2 + 2 << 2 // 16: IntПоскольку первые символы, по сравнению с предыдущим примером, не изменились, то методы будут вызваны в том же порядке: +, а затем <<. Сле­довательно, 2+2 опять будет равен 4, а 4<<2 даст результат 16 122 Глава 5 • Основные типы и операции Единственное исключение из правил, о существовании которого уже гово­рилось, относится к операторам присваивания, заканчивающимся знаком равенства. Если оператор заканчивается знаком равенства (=) и не относится к одному из операторов сравнения <=, >=, == и !=, то приоритет оператора имеет такой же уровень, что и простое присваивание (=). То есть он ниже приоритета любого другого оператора. Например:x *= y + 1означает то же самое, что и x *= (y + 1)поскольку оператор *= классифицируется как оператор присваивания, прио­ритет которого ниже, чем у +, даже притом что первым символом оператора выступает знак *, который обозначил бы приоритет выше, чем у +Если в выражении рядом появляются операторы с одинаковым уровнем приоритета, то способ группировки операторов определяется их ассоциатив-ностью. Ассоциативность оператора в Scala определяется по его последнему символу. Как уже упоминалось в главе 3, любой метод, имя которого за­канчивается символом :, вызывается в отношении своего правого операнда с передачей ему левого. Методы, в окончании имени которых используются любые другие символы, действуют наоборот: они вызываются в отношении своего левого операнда с передачей себе правого. То есть из выражения a*b получается a.*(b), но из a:::b получается b.:::(a)Но независимо от того, какова ассоциативность оператора, его операнды всегда вычисляются слева направо. Следовательно, если a — выражение, не являющееся простой ссылкой на неизменяемое значение, то выраже­ние a:::b при более точном рассмотрении представляется следующим блоком:{ val x = a; b.:::(x) }В этом блоке а по­прежнему вычисляется раньше b, а затем результат данного вычисления передается в качестве операнда принадлежащему b методу :::Это правило ассоциативности играет роль также при появлении в одном выражении рядом сразу нескольких операторов с одинаковым уровнем приоритета. Если имена методов заканчиваются на :, они группируются справа налево, в противном случае — слева направо. Например, a:::b:::c рассматривается как a:::(b:::c). Но a*b*c, в отличие от этого, рассма­тривается как (a*b)*c 5 .10 . Обогащающие операции 123Правила приоритета операторов — часть языка Scala, и вам не следует боять­ся применять ими. При этом, чтобы прояснить первоочередность использо­вания операторов, в некоторых выражениях все же лучше прибегнуть к кру­глым скобкам. Пожалуй, единственное, на что можно реально рассчитывать в отношении знания порядка приоритета другими программистами, — то, что мультипликативные операторы *, / и % имеют более высокий уровень приоритета, чем аддитивные + и –. Таким образом, даже если выражение a+b<<c выдает нужный результат и без круглых скобок, стоит внести до­полнительную ясность с помощью записи (a+b)<<c. Это снизит количество нелестных отзывов ваших коллег по поводу использованной вами формы записи операторов, которое выражается, к примеру, в недовольном воскли­цании вроде «Опять в его коде невозможно разобраться!» и отправке вам сообщения наподобие bills!*&ˆ%code!1 5 .10 . Обогащающие операцииВ отношении основных типов Scala можно вызвать намного больше методов, чем рассмотрено в предыдущих разделах. Некоторые примеры показаны в табл. 5.4. Начиная со Scala 3, эти методы доступны через неявные преоб­разования — устаревшую технику, которая в конечном итоге будет заменена методами расширения, подробно описанными в главе 22. А пока вам нужно знать лишь то, что для каждого основного типа, рассмотренного в текущей Таблица 5.4. Некоторые обогащающие операцииКодРезультат0 max 5 50 min 5 0–2.7 abs2.7–2.7 round–3L1.5 isInfinityFalse(1.0 / 0) isInfinityTrue4 to 6Range(4, 5, 6)"bob" capitalize"Bob""robert" drop 2"bert"1 Теперь вы уже знаете, что, получив такой код, компилятор Scala создаст вызов (bills.!*&^%(code)).! 124 Глава 5 • Основные типы и операции главе, существует обогащающая оболочка, которая предоставляет ряд допол­нительных методов. Поэтому увидеть все доступные методы, применяемые в отношении основных типов, можно, обратившись к документации по API, которая касается обогащающей оболочки для каждого основного типа. Эти классы перечислены в табл. 5.5.Таблица 5.5. Классы обогащающих оболочекОсновной типОбогащающая оболочкаByte scala.runtime.RichByteShort scala.runtime.RichShortInt scala.runtime.RichIntLong scala.runtime.RichLongChar scala.runtime.RichCharFloat scala.runtime.RichFloatDouble scala.runtime.RichDoubleBoolean scala.runtime.RichBooleanString scala.collection.immutable.StringOpsРезюмеОсновное, что следует усвоить, прочитав данную главу, — операторы в Scala являются вызовами методов и для основных типов Scala существуют неяв­ные преобразования в обогащенные варианты, которые добавляют дополни­тельные полезные методы. В главе 6 мы покажем, что означает конструиро­вание объектов в функциональном стиле, обеспечивающее новые реализации некоторых операторов, рассмотренных в настоящей главе. 6Функциональные объектыУсвоив основы, рассмотренные в предыдущих главах, вы готовы разработать больше полнофункциональных классов Scala. В этой главе основное вни­мание мы уделим классам, определяющим функциональные объекты или объекты, не имеющие никакого изменяемого состояния. Запуская примеры, мы создадим несколько вариантов класса, моделирующего рациональные числа в виде неизменяемых объектов. Попутно будут показаны дополни­тельные аспекты объектно­ориентированного программирования на Scala: параметры класса и конструкторы, методы и операторы, приватные члены, переопределение, проверка соблюдения предварительных условий, пере­грузка и рекурсивные ссылки.6 .1 . Спецификация класса RationalРациональным называется число, которое может быть выражено соотноше­нием n/d, где n и d представлены целыми числами, за исключением того, что d не может быть нулем. Здесь n называется числителем, а d — знаменателем. Примерами рациональных чисел могут послужить 1/2, 2/3, 112/239 и 2/1. В сравнении с числами с плавающей точкой рациональные числа имеют то преимущество, что дроби представлены точно, без округлений или при­ближений.Разрабатываемый в этой главе класс должен моделировать поведение рацио­нальных чисел, позволяя производить над ними арифметические действия по сложению, вычитанию, умножению и делению. Для сложения двух ра­циональных чисел сначала нужно получить общий знаменатель, после чего сложить два числителя. Например, чтобы выполнить сложение 1/2 + 2/3, 126 Глава 6 • Функциональные объекты обе части левого операнда умножаются на 3, а обе части правого операнда — на 2, в результате чего получается 3/6 + 4/6. Сложение двух числителей дает результат 7/6. Для перемножения двух рациональных чисел можно просто перемножить их числители, а затем знаменатели. Таким образом, 1/2 · 2/5 дает число 2/10, которое можно представить более кратко в нормализованном виде как 1/5. Деление выполняется путем перестановки местами числителя и зна­менателя правого операнда с последующим перемножением чисел. Например, 1/2 / 3/5 — то же самое, что и 1/2 · 5/3, в результате получается число 5/6.Одно, возможно, очевидное наблюдение заключается в том, что в математике рациональные числа не имеют изменяемого состояния. Можно сложить два рациональных числа, и результатом будет новое рациональное число. Исход­ные числа не будут изменены. Неизменяемый класс Rational, разрабатыва­емый в данной главе, будет иметь такое же свойство. Каждое рациональное число будет представлено одним объектом Rational. При сложении двух объ­ектов Rational для хранения суммы будет создаваться новый объект RationalВ этой главе мы представим некоторые допустимые в Scala способы написа­ния библиотек, которые создают впечатление, будто используется поддерж­ка, присущая непосредственно самому языку программирования. Например, в конце этой главы вы сможете сделать с классом Rational следующее:scala> val oneHalf = Rational(1, 2)val oneHalf: Rational = 1/2scala> val twoThirds = Rational(2, 3)val twoThirds: Rational = 2/3scala> (oneHalf / 7) + (1 — twoThirds)val res0: Rational = 17/42 6 .2 . Конструирование класса RationalКонструирование класса Rational неплохо начать с рассмотрения того, как клиенты­программисты будут создавать новый объект Rational. Было ре­шено создавать объекты Rational неизменяемыми, и потому мы потребуем, чтобы эти клиенты при создании экземпляра предоставляли все необходи­мые ему данные (в нашем случае числитель и знаменатель). Поэтому начнем конструирование со следующего кода:class Rational(n: Int, d: Int)По поводу этой строки кода в первую очередь следует заметить: если у класса нет тела, то вам не нужно ставить пустые фигурные скобки, а также нет необ­ 6 .2 . Конструирование класса Rational 127ходимости завершать строку двоеточием. Идентификаторы n и d, указанные в круглых скобках после имени класса, Rational, называются параметрами класса. Компилятор Scala подберет эти два параметра и создаст первичный конструктор, получающий их же.Плюсы и минусы неизменяемого объектаНеизменяемые объекты имеют ряд преимуществ над изменяемы­ми и один потенциальный недостаток. Во­первых, о неизменяемых объектах проще говорить, чем об изменяемых, поскольку у них нет изменяемых со временем сложных областей состояния. Во­вторых, неизменяемые объекты можно совершенно свободно куда­нибудь передавать, а перед передачей изменяемых объектов в другой код порой приходится делать страховочные копии. В­третьих, если объ­ект правильно сконструирован, то при одновременном обращении к неизменяемому объекту из двух потоков повредить его состояние невозможно, поскольку никакой поток не может изменить состояние неизменяемого объекта. В­четвертых, неизменяемые объекты обеспе­чивают безопасность ключей хеш­таблиц. Если, к примеру, изменяе­мый объект изменился после помещения в HashSet, то в следующий раз при поиске там его можно не найти.Главный недостаток неизменяемых объектов — им иногда требует­ся копирование больших графов объектов, тогда как вместо этого можно было бы сделать обновление. В некоторых случаях это может быть сложно выразить, а также могут выявиться узкие места в про­изводительности. В результате в библиотеки нередко включают из­меняемые альтернативы неизменяемым классам. Например, класс StringBuilder — изменяемая альтернатива неизменяемого класса String. Дополнительная информация о конструировании изменяемых объектов в Scala будет дана в главе 16.ПРИМЕЧАНИЕИсходный пример с Rational подчеркивает разницу между Java и Scala . В Java классы имеют конструкторы, которые могут принимать параметры, а в Scala классы могут принимать параметры напрямую . Система записи в Scala куда более лаконична — параметры класса могут использоваться напрямую в теле, нет никакой необходимости определять поля и записывать присваивания, копирующие параметры конструктора в поля . Это может привести к дополнительной экономии на шаблонном коде, особенно когда дело касается небольших классов . 128 Глава 6 • Функциональные объекты Компилятор Scala скомпилирует любой код, помещенный в тело класса и не являющийся частью поля или определения метода, в первичный конструк­тор. Например, можно вывести такое отладочное сообщение:class Rational(n: Int, d: Int): println("Created " + n + "/" + d)Получив данный код, компилятор Scala поместит вызов println в первич­ный конструктор класса Rational. Поэтому при создании нового экземпляра Rational вызов println приведет к выводу отладочного сообщения:scala> new Rational(1, 2)Created 1/2Val res0: Rational = Rational@6121a7ddПри создании экземпляров классов, таких как Rational, вы можете при желании опустить ключевое слово new. Такое выражение использует так на­зываемый универсальный метод применения. Вот пример:scala> Rational(1, 2)Created 1/2val res1: Rational = Rational@5dc7841c6 .3 . Переопределение метода toStringПри создании экземпляра Rational в предыдущем примере REPL вывел Rational@5dc7841c. Эта странная строка получилась ввиду вызова в от­ношении объекта Rational метода toString. По умолчанию класс Rational наследует реализацию toString, определенную в классе java.lang.Object, которая просто выводит имя класса, символ @ и шестнадцатеричное число. Предполагалось, что результат выполнения toString поможет программи­стам, предоставив информацию, которую можно использовать в отладоч­ных инструкциях вывода информации, для ведения логов, в отчетах о сбоях тестов, а также для просмотра выходной информации REPL и отладчика. Результат, выдаваемый на данный момент методом toString, не приносит особой пользы, поскольку не дает никакой информации относительно значения рационального числа. Более полезная реализация toString будет выводить значения числителя и знаменателя объекта Rational. Переопре-делить исходную реализацию можно, добавив метод toString к классу Rational:class Rational(n: Int, d: Int): override def toString = s"$n/$d" 6 .4 . Проверка соблюдения предварительных условий1   ...   10   11   12   13   14   15   16   17   ...   64

138 Глава 6 • Функциональные объекты в смешанном регистре должны начинаться с буквы в верхнем регистре, на­пример: BigInt, List и UnbalancedTreeMap1ПРИМЕЧАНИЕОдним из последствий использования в идентификаторе замыкающего знака подчеркивания при попытке, к примеру, написания объявления val name_: Int = 1 может стать ошибка компиляции . Компилятор подумает, что вы пытаетесь объявить val-переменную по имени name_: . Чтобы такой идентификатор прошел компиляцию, перед двоеточием нужно поставить дополнительный пробел, как в коде val name_ : Int = 1 .Один из примеров отступления Scala от соглашений, принятых в Java, касается имен констант. В Scala слово «константа» означает не только val­переменную. Даже притом что val­переменная остается неизменной после инициализации, она не перестает быть переменной. Например, параметры метода относятся к val­переменным, но при каждом вызове метода в этих val­переменных содержатся разные значения. Константа обладает более выраженным постоянством. Например, scala.math.Pi определяется как значение с двойной точностью, наиболее близкое к реальному значению числа π — отношению длины окружности к ее диаметру. Это значение вряд ли когда­либо изменится, поэтому со всей очевидностью можно сказать, что Pi — константа. Константы можно использовать также для присваивания имен значениям, которые иначе были бы в вашем коде магическими числа-ми — буквальными значениями без объяснений, которые в худшем случае появлялись бы в коде в нескольких местах. Вдобавок может понадобиться определить константы для использования при сопоставлении с образцом (подобный случай будет рассматриваться в разделе 13.2). В соответствии с соглашением, принятым в Java, константам присваиваются имена, в ко­торых используются символы в верхнем регистре, где знак подчеркивания является разделителем слов, например MAX_VALUE или PI. В Scala соглашение требует, чтобы в верхнем регистре была только первая буква. Таким образом, константы, названные в стиле Java, например X_OFFSET, будут работать в Scala в качестве констант, но в соответствии с соглашением, принятым в Scala, для имен констант применяется смешанный регистр, например XOffsetИдентификатор оператора состоит из одного или нескольких символов операторов. Таковыми являются выводимые на печать ASCII­символы, такие 1 В разделе 14.5 вы увидите, что иногда может возникнуть желание придать классу особый вид, как у case­класса, чье имя состоит только из символов оператора. Например, в API Scala имеется класс по имени ::, облегчающий сопоставление с образцом для объектов List 6 .10 . Идентификаторы в Scala 139как +, :, ?, или #1. Ниже показаны некоторые примеры идентификаторов операторов:+ ++ ::: > :–>Компилятор Scala на внутреннем уровне перерабатывает идентификаторы операторов, чтобы превратить их в допустимые Java­идентификаторы со встроенными символами $. Например, идентификатор :–> будет представлен как $colon$minus$greater. Если вам когда­либо захочется получить доступ к этому идентификатору из кода Java, то потребуется использовать данное внутреннее представление.Поскольку идентификаторы операторов в Scala могут принимать произ­вольную длину, то между Java и Scala есть небольшая разница в этом вопросе. В Java введенный код x<–y будет разобран на четыре лексических символа, в результате чего станет эквивалентен x<–y. В Scala оператор <– будет рас­смотрен как единый идентификатор, в результате чего получится x<–y. Если нужно получить первую интерпретацию, то следует отделить символы < и –друг от друга пробелом. На практике это вряд ли станет проблемой, так как немногие станут писать на Java x<–y, не вставляя пробелы или круглые скобки между операторами.Смешанный идентификатор состоит из буквенно­цифрового идентифика­тора, за которым стоят знак подчеркивания и идентификатор оператора. Например, unary_+, использованный как имя метода, определяет унарный оператор +. А myvar_=, использованный как имя метода, определяет оператор присваивания. Кроме того, смешанный идентификатор вида myvar_= генери­руется компилятором Scala в целях поддержки свойств (более подробно этот вопрос рассматривается в главе 16).Литеральный идентификатор представляет собой произвольную строку, заключенную в обратные кавычки (`...`). Примеры литеральных иденти­фикаторов выглядят следующим образом:`x` `` `yield`Замысел состоит в том, что между обратными кавычками можно поместить любую строку, которую среда выполнения станет воспринимать в качестве 1 Точнее, символ оператора принадлежит к математическим символам (Sm) или прочим символам (So) стандарта Unicode либо к семибитным ASCII­символам, не являющимся буквами, цифрами, круглыми, квадратными и фигурными скобками, одинарными или двойными кавычками или знаками подчеркивания, точки, точки с запятой, запятой или обратных кавычек. 140 Глава 6 • Функциональные объекты идентификатора. В результате всегда будет получаться идентификатор Scala. Это сработает даже в том случае, если имя, заключенное в обратные кавычки, является в Scala зарезервированным словом. Обычно такие иден­тификаторы используются при обращении к статическому методу yield в Java­классе Thread. Вы не можете прибегнуть к коду Thread.yield(), по­скольку в Scala yield является зарезервированным словом. Но имя метода все же можно применить, если заключить его в обратные кавычки, например Thread.`yield`()6 .11 . Перегрузка методовВернемся к классу Rational. После внесения последних изменений появи­лась возможность применять операции сложения и умножения рациональ­ных чисел в их естественном виде. Но мы все же упустили из виду смешан­ную арифметику. Например, вы не можете умножить рациональное число на целое, поскольку операнды у оператора * всегда должны быть объектами Rational. Следовательно, для рационального числа r вы не можете написать код r*2. Вам нужно написать r*Rational(2), а это имеет неприглядный вид.Чтобы сделать класс Rational еще более удобным в использовании, добавим к нему новые методы, выполняющие смешанное сложение и умножение ра­циональных и целых чисел. А заодно добавим методы вычитания и деления. Результат показан в листинге 6.5.Теперь здесь две версии каждого арифметического метода: одна в качестве аргумента получает рациональное число, вторая — целое. Иными словами, все эти методы называются перегруженными, поскольку каждое имя теперь используется несколькими методами. Например, имя + применяется и ме­тодом, получающим объект Rational, и методом, получающим объект IntПри вызове метода компилятор выбирает версию перегруженного метода, которая в точности соответствует типу аргументов. Например, если аргу­мент y в вызове x.+(y) является объектом Rational, то компилятор выберет метод +, получающий в качестве параметра объект Rational. Но если аргу­мент — целое число, то компилятор выберет метод +, получающий в качестве параметра объект Int. Если испытать код в действии:Val r = Rational(2, 3) // 2/3r * r // 4/9r * 2 // 4/3станет понятно, что вызываемый метод * определяется каждый раз по типу его правого операнда. 6 .11 . Перегрузка методов 141ПРИМЕЧАНИЕВ Scala процесс анализа при выборе перегруженного метода очень похож на аналогичный процесс в Java . В любом случае выбирается перегруженная версия, которая лучше подходит к статическим типам аргументов . Иногда случается, что одной такой версии нет, и тогда компилятор выдаст ошибку, связанную с неоднозначной ссылкой, — ambiguous reference .Листинг 6.5. Класс Rational с перегруженными методами class Rational(n: Int, d: Int): require(d != 0) private val g = gcd(n.abs, d.abs) val numer = n / g val denom = d / g def this(n: Int) = this(n, 1) def + (that: Rational): Rational = Rational( numer * that.denom + that.numer * denom, denom * that.denom ) def + (i: Int): Rational = Rational(numer + i * denom, denom) def - (that: Rational): Rational = Rational( numer * that.denom - that.numer * denom, denom * that.denom ) def - (i: Int): Rational = Rational(numer — i * denom, denom) def * (that: Rational): Rational = Rational(numer * that.numer, denom * that.denom) def * (i: Int): Rational = Rational(numer * i, denom) def / (that: Rational): Rational = Rational(numer * that.denom, denom * that.numer) def / (i: Int): Rational = Rational(numer, denom * i) override def toString = s"$numer/$denom" private def gcd(a: Int, b: Int): Int = if b == 0 then a else gcd(b, a % b) 142 Глава 6 • Функциональные объекты 6 .12 . Методы расширенияТеперь, когда можно воспользоваться кодом r*2, вы также можете захотеть поменять операнды местами, задействовав код 2*r. К сожалению, пока этот код работать не будет: scala> 2 * r1 |2 * r |ˆˆˆ |None of the overloaded alternatives of method * in | class Int with types | (x: Double): Double | (x: Float): Float | (x: Long): Long | (x: Int): Int | (x: Char): Int | (x: Short): Int | (x: Byte): Int |match arguments ((r : Rational))Проблема в том, что эквивалент выражения 2*r — выражение 2.*(r), то есть вызов метода в отношении числа 2, которое является целым. Но в классе Int не содержится метода умножения, получающего в качестве аргумента объект Rational, его там и не может быть, поскольку он не входит в состав стандартных классов библиотеки Scala.Но в Scala есть другой способ решения этой проблемы. Вы можете создавать методы расширения для Int, которые содержат рациональные числа. Попро­буйте добавить эти строки в REPL:extension (x: Int) def + (y: Rational) = Rational(x) + y def - (y: Rational) = Rational(x) - y def * (y: Rational) = Rational(x) * y def / (y: Rational) = Rational(x) / yЭто определяет четыре метода расширения для Int, каждый из которых использует Rational. Компилятор может использовать их автоматически в ряде ситуаций. С определенными методами расширения теперь вы можете повторить пример, который раньше не удался:val r = Rational(2,3) // 2/3 2 * r // 4/3Чтобы метод расширения работал, он должен находиться в области види­мости. Если вы поместите определение метода расширения внутри класса Резюме 143Rational, он не попадет в область действия REPL, поэтому вам необходимо определить его непосредственно в REPL.Как видно из этого примера, методы расширения — это очень эффективная техника, позволяющая сделать библиотеки более гибкими и удобными в использовании. Однако ее чрезмерное использование может и навредить. В главе 22 вы узнаете больше о методах расширения, в том числе о способах включения их в область видимости там, где они необходимы.6 .13 . ПредостережениеСоздание методов с именами операторов и определение методов расширения, продемонстрированные в этой главе, призваны помочь в проектировании библиотек, для которых код клиента будет лаконичным и понятным. Scala предоставляет широкие возможности для разработки таких весьма доступ­ных для использования библиотек. Но, пожалуйста, имейте в виду: реализуя возможности, не стоит забывать об ответственности.При неумелом использовании и методы­операторы, и методы расширения могут сделать клиентский код таким, что его станет трудно читать и по­нимать. Выполнение компилятором методов расширения никак внешне не проявляется и не записывается в явном виде в исходный код. Поэтому про­граммистам на клиенте может быть невдомек, что именно оно и применяется в вашем коде. И хотя методы­операторы обычно делают клиентский код более лаконичным и читабельным, таким он становится только для наибо­лее сведущих программистов­клиентов, способных запомнить и распознать значение каждого оператора.При проектировании библиотек всегда нужно стремиться сделать клиент­ский код не просто лаконичным, но и легкочитаемым и понятным. Читабель­ность в значительной степени может быть обусловлена лаконичностью, кото­рая способна заходить очень далеко. Проектируя библиотеки, позволяющие добиваться изысканной лаконичности, и в то же время создавая понятный клиентский код, вы можете существенно повысить продуктивность работы программистов, которые используют эти библиотеки.РезюмеВ этой главе мы рассмотрели многие аспекты классов Scala. Вы увидели способы добавления к классу параметров, определили несколько конструк­ 144 Глава 6 • Функциональные объекты торов, операторы и методы и настроили классы таким образом, чтобы их применение приобрело более естественный вид. Важнее всего, вероятно, было показать вам, что определение и использование неизменяющихся объ­ектов — вполне естественный способ программирования на Scala.Хотя показанная здесь финальная версия класса Rational соответствует всем требованиям, обозначенным в начале главы, ее можно усовершенствовать. Позже мы вернемся к этому примеру. В частности, в главе 8 будет рассмотре­но переопределение методов equals и hashcode, которое позволяет объектам Rational улучшить свое поведение в момент, когда их сравнивают с помощью оператора == или помещают в хеш­таблицы. В главе 22 поговорим о том, как помещать методы расширения в объекты­компаньоны класса Rational, кото­рые упрощают для программистов­клиентов помещение в область видимости объектов типа Rational 7Встроенные управляющие конструкцииВ Scala имеется весьма незначительное количество встроенных управля­ющих конструкций. К ним относятся if, while, for, try, match и вызовы функций. Их в Scala немного, поскольку с момента создания данного языка в него были включены функциональные литералы. Вместо накопления в базовом синтаксисе одной за другой высокоуровневых управляющих конструкций Scala собирает их в библиотеках. Как именно это делается, мы покажем в главе 9. А здесь рассмотрим имеющиеся в Scala немногочисленные встроенные управляющие конструкции.Следует учесть, что почти все управляющие конструкции Scala приводят к какому­либо значению. Такой подход принят в функциональных языках, где программы рассматриваются в качестве вычислителей значений, стало быть, компоненты программы тоже должны вычислять значения. Можно рассматривать данное обстоятельство как логическое завершение тенденции, уже присутствующей в императивных языках. В них вызовы функций могут возвращать значение, даже когда наряду с этим будет происходить обнов­ление вызываемой функцией выходной переменной, переданной в качестве аргумента. Кроме того, в императивных языках зачастую имеется тернарный оператор (такой как оператор ?: в C, C++ и Java), который ведет себя полно­стью аналогично if, но при этом возвращает значение. Scala позаимствовал эту модель тернарного оператора, но назвал ее if. Иными словами, исполь­зуемый в Scala оператор if может выдавать значение. Затем эта тенденция в Scala получила развитие: for, try и match тоже стали выдавать значения.Программисты могут использовать полученное в результате значение, чтобы упростить свой код, применяя те же приемы, что и для значений, возвращаемых функциями. Не будь этой особенности, программистам пришлось бы созда­вать временные переменные, просто чтобы хранить результаты, вычисленные 146 Глава 7 • Встроенные управляющие конструкции внутри управляющей конструкции. Отказ от таких переменных немного упрощает код, а также избавляет от многих ошибок, возникающих, когда в од­ном ответвлении переменная создается, а в другом о ее создании забывают.В целом, основные управляющие конструкции Scala в минимальном соста­ве обеспечивают все, что нужно было взять из императивных языков. При этом они позволяют сделать код более лаконичным за счет неизменного на­личия значений, получаемых в результате их применения. Чтобы показать все это в работе, далее более подробно рассмотрим основные управляющие конструкции Scala.7 .1 . Выражения ifВыражение if в Scala работает практически так же, как во многих других языках. Оно проверяет условие, а затем выполняет одну из двух ветвей кода в зависимости от того, вычисляется ли условие в true. Простой пример, на­писанный в императивном стиле, выглядит следующим образом:var filename = "default.txt"if !args.isEmpty then filename = args(0)В этом коде объявляется переменная по имени filename, которая инициа­лизируется значением по умолчанию. Затем в нем используется выражение if с целью проверить, предоставлены ли программе какие­либо аргументы. Если да, то в переменную вносят изменения, чтобы в ней содержалось зна­чение, указанное в списке аргументов. Если нет, то выражение оставляет значение переменной, установленное по умолчанию.Этот код можно сделать гораздо более выразительным, поскольку, как упоминалось в шаге 3 главы 2, выражение if в Scala возвращает значение. В листинге 7.1 показано, как можно выполнить те же самые действия, что и в предыдущем примере, не прибегая к использованию var­переменных.Листинг 7.1. Особый стиль Scala, применяемый для условной инициализации val filename = if !args.isEmpty then args(0) else "default.txt"На этот раз у if имеются два ответвления. Если массив args непустой, то выбирается его начальный элемент args(0). В противном случае выбирается значение по умолчанию. Выражение if выдает результат в виде выбранного 7 .2 . Циклы while 147значения, которым инициализируется переменная filename. Данный код немного короче предыдущего. Но гораздо более существенно то, что в нем используется val­, а не var­переменная. Это соответствует функциональному стилю и помогает вам примерно так же, как применение финальной (final) переменной в Java. Она сообщает читателям кода, что переменная никогда не изменится, избавляя их от необходимости просматривать весь код в об­ласти видимости переменной, чтобы понять, изменяется ли она где­нибудь.Второе преимущество использования v a r­переменной вместо v a l­переменной заключается в том, что она лучше поддерживает выводы, кото­рые делаются с помощью эквациональных рассуждений (equational reasoning). Введенная переменная равна вычисляющему выражению при условии, что у него нет побочных эффектов. Таким образом, всякий раз, собираясь напи­сать имя переменной, вы можете вместо него написать выражение. Вместо println(filename), к примеру, можно просто написать следующий код:println(if (!args.isEmpty) args(0) else "default.txt")Выбор за вами. Вы можете прибегнуть к любому из вариантов. Использова­ние val­переменных помогает совершенно безопасно проводить подобный рефакторинг кода по мере его развития.Всегда ищите возможности для применения val­переменных. Они смогут упростить не только чтение вашего кода, но и его рефакторинг.7 .2 . Циклы whileИспользуемые в Scala циклы while ведут себя точно так же, как и в других языках. В них имеются условие и тело, которое выполняется снова и снова, пока условие вычисляется в true. Пример показан в листинге 7.2.1   ...   12   13   14   15   16   17   18   19   ...   64

282 Глава 13 • Сопоставление с образцом Листинг 13.15. Выражение сопоставления, в котором порядок следования вариантов имеет значение def simplifyAll(expr: Expr): Expr = expr match case UnOp("-", UnOp("-", e)) => simplifyAll(e) // '-' является своей собственной обратной величиной case BinOp("+", e, Num(0)) => simplifyAll(e) // '0' нейтральный элемент для '+' case BinOp("*", e, Num(1)) => simplifyAll(e) // '1' нейтральный элемент для '*' case UnOp(op, e) => UnOp(op, simplifyAll(e)) case BinOp(op, l, r) => BinOp(op, simplifyAll(l), simplifyAll(r)) case _ => exprВерсия метода simplify, показанная в данном листинге, станет применять правила упрощения в любом месте выражения, а не только в его верхней части, как это сделала бы версия simplifyTop. Данную версию можно вы­вести из версии simplifyTop, добавив два дополнительных варианта для обычных унарных и бинарных выражений (четвертый и пятый варианты case в листинге 13.15).В четвертом варианте используется паттерн UnOp(op,e), который соответ­ствует любой унарной операции. Оператор и операнд унарной операции могут быть какими угодно. Они привязаны к паттернам­переменным op и e соответственно. Альтернативой в данном варианте будет рекурсивное применение simplifyAll к операнду e с последующим перестроением той же самой унарной операции с (возможно) упрощенным операндом. Пятый вариант для BinOp аналогичен четвертому: он является вариантом «поймать все» для произвольных бинарных операций, который рекурсивно применяет метод упрощения к своим двум операндам.Важным обстоятельством в этом примере является то, что варианты «пой­мать все» следуют после более конкретизированных правил упрощения. Если расположить их в другом порядке, то вариант «поймать все» будет запущен вместо более конкретизированных правил. Во многих случаях компилятор будет жаловаться на такие попытки. Например, вот как выглядит выражение match, которое не пройдет компиляцию, поскольку первый вариант будет соответствовать всему тому, чему будет соответствовать второй вариант:scala> def simplifyBad(expr: Expr): Expr = expr match case UnOp(op, e) => UnOp(op, simplifyBad(e)) case UnOp("-", UnOp("-", e)) => e case _ => expr 13 .5 . Запечатанные классы 283def simplifyBad(expr: Expr): Expr4 | case UnOp("-", UnOp("-", e)) => e | ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ | Unreachable case13 .5 . Запечатанные классыПри написании сопоставления с образцом нужно удостовериться в том, что охвачены все возможные варианты. Иногда это можно сделать, добавив в конец match вариант по умолчанию, но данный способ применим, только когда есть вполне определенное поведение по умолчанию. А что делать, если его нет? Как узнать, что охвачены все варианты и нет опасности упустить что­либо?Чтобы определить пропущенные в выражении match комбинации паттернов, можно обратиться за помощью к компилятору Scala. Для этого компилятор должен иметь возможность сообщить обо всех потенциальных вариантах. По сути, в Scala это сделать нереально, поскольку классы могут быть опре­делены в любое время и в произвольных блоках компиляции. Например, ничто не помешает вам добавить к иерархии класса Expr пятый case­класс не в том блоке компиляции, в котором определены четыре других case­класса, а в другом.Альтернативой этому может стать превращение суперкласса ваших case­классов в запечатанный класс. У такого запечатанного класса не может быть никаких дополнительных подклассов, кроме тех, которые определены в том же самом файле. Особую пользу из этого можно извлечь при сопо­ставлении с образцом, поскольку запечатанность класса будет означать, что беспокоиться придется только по поводу тех подклассов, о которых вам уже известно. Более того, будет улучшена поддержка со стороны компилятора. При сопоставлении с образцом case­классам, являющимся наследниками запечатанного класса, компилятор в предупреждении отметит пропущенные комбинации паттернов.Если создается иерархия классов, предназначенная для сопоставления с образцом, то нужно предусмотреть ее запечатанность. Чтобы это сделать, просто поставьте перед классом на вершине иерархии ключевое слово sealed. Программисты, использующие вашу иерархию классов, при сопо­ставлении с образцом будут чувствовать себя уверенно. Таким образом, ключевое слово sealed зачастую выступает лицензией на сопоставление с образцом. Пример, в котором Expr превращается в запечатанный класс, показан в листинге 13.16. 284 Глава 13 • Сопоставление с образцом Листинг 13.16. Запечатанная иерархия case-классов sealed trait Expr case class Var(name: String) extends Expr case class Num(number: Double) extends Expr case class UnOp(operator: String, arg: Expr) extends Expr case class BinOp(operator: String, left: Expr, right: Expr) extends ExprА теперь определим сопоставление с образцом, в котором пропущены не­которые возможные варианты:def describe(e: Expr): String = e match case Num(_) => "число" case Var(_) => "переменная"В результате будет получена следующая ошибка компилятора:def describe(e: Expr): String2 | e match | ˆ | match may not be exhaustive. | | It would fail on pattern case: UnOp(_, _), | BinOp(_, _, _)Такая ошибка компилятора сообщает о существовании риска генерации вашим кодом исключения MatchError, поскольку некоторые возможные паттерны (UnOp, BinOp) не обрабатываются. Ошибка указывает на потен­циальный источник сбоя в ходе выполнения программы и помогает при корректировке кода.Но порой можно столкнуться с ситуацией, в которой компилятор при вы­даче ошибки проявляет излишнюю дотошность. Например, из контекста может быть известно, что показанный ранее метод describe будет приме­няться только к выражениям типа Num или Var, следовательно, исключение MatchError не станет генерироваться. Чтобы избавиться от ошибки, к методу можно добавить третий вариант по умолчанию:def describe(e: Expr): String = e match case Num(_) => "число" case Var(_) => "переменная" case _ => throw new RuntimeException // Не должно произойтиРешение вполне работоспособное, однако не идеальное. Вряд ли вас обрадует принуждение добавить код, который никогда не будет выполнен (по вашему мнению), лишь для того, чтобы успокоить компилятор. 13 .6 . Сопоставление паттерна Options 285Более экономной альтернативой станет добавление к селектору выражения сопоставления с образцом аннотации @unchecked. Делается это следующим образом:def describe(e: Expr): String = (e: @unchecked) match case Num(_) => "число" case Var(_) => "переменная"В общем, аннотации можно добавлять к выражению точно так же, как это дела­ется при добавлении типа: нужно после выражения поставить двоеточие, знак «собачка» и указать название аннотации. Например, в данном случае к пере­менной e добавляется аннотация @unchecked, для чего используется код e:@unchecked. Аннотация @unchecked имеет особое значение для сопоставления с образцом. Если выражение селектора поиска содержит данную аннотацию, то исчерпывающая проверка последующих паттернов будет подавлена.13 .6 . Сопоставление паттерна OptionsВы можете использовать сопоставление шаблонов для обработки стандарт­ного типа Option в Scala. Как упоминалось в шаге 12 главы 3, Option может быть двух видов: это либо Some(x), где x — реальное значение, либо None, у которого отсутствует значение.Необязательные значения производятся некоторыми стандартными опе­рациями над коллекциями Scala. Например, метод get из Scala­класса Map производит Some(значение), если найдено значение, соответствующее за­данному ключу, или None, если заданный ключ не определен в Map­объекте. Пример выглядит так:val capitals = Map("France" –> "Paris", "Japan" –> "Tokyo")capitals.get("France") // Some(Paris)capitals.get("North Pole") // NoneСамый распространенный способ разобрать необязательные значения — ис­пользовать сопоставление с образцом, например:def show(x: Option[String]) = x match case Some(s) => s case None => "?"show(capitals.get("Japan")) // Tokyo show(capitals.get("France")) // Paris show(capitals.get("North Pole")) // ? 286 Глава 13 • Сопоставление с образцом Тип Option применяется в программах на языке Scala довольно часто. Его использование можно сравнить с доминирующей в Java идиомой null, по­казывающей отсутствие значения. Например, метод get из java.util.HashMap возвращает либо значение, сохраненное в HashMap, либо null, если значение не было найдено. В Java такой подход работает, но, применяя его, легко до­пустить ошибку, поскольку на практике довольно трудно отследить, каким переменным в программе разрешено иметь значение nullВ случае, когда переменной разрешено иметь значение null, вы должны вспомнить о ее проверке на наличие этого значения при каждом исполь­зовании. Если забыть выполнить эту проверку, то появится вероятность генерации в ходе выполнения программы исключений NullPointerExceptionПодобные исключения могут генерероваться довольно редко, поэтому с вы­явлением ошибки при тестировании могут возникнуть затруднения. В Scala такой подход вообще не сработает, поскольку этот язык позволяет сохранять типы значений в хеш­отображениях, а null не является допустимым элемен­том для типов значений. Например, HashMap[Int,Int] не может вернуть null, чтобы обозначить отсутствие элемента.Вместо этого в Scala для указания необязательного значения применяется тип Option. Такой способ имеет ряд преимуществ по сравнению с используемым в подходе null. Во­первых, тем, кто читает код, намного понятнее, что перемен­ная, типом которой является Option[String], — необязательная переменная String, а не переменная типа String, которая иногда может иметь значение null. Во­вторых, что более важно, рассмотренные ранее ошибки програм­мирования, связанные с использованием переменной со значением null без предварительной проверки ее на null, превращаются в Scala в ошибку типа. Если переменная имеет тип Option[String], то при попытке ее использования в качестве строки ваша программа на Scala не пройдет компиляцию.13 .7 . Паттерны повсюдуПаттерны можно использовать не только в отдельно взятых match­выра­жениях, но и во многих других местах программы на языке Scala. Рассмотрим несколько подобных мест применения паттернов.Паттерны в определениях переменныхПри определении val­ или var­переменной вместо простых идентификато­ров можно использовать паттерны. Например, можно, как показано в ли­ 13 .7 . Паттерны повсюду 287стинге 13.17, разобрать кортеж и присвоить каждую его часть собственной переменной.Листинг 13.17. Определение нескольких переменных с помощью одного присваивания scala> val myTuple = (123, "abc")val myTuple: (Int, String) = (123,abc)scala> val (number, string) = myTuple val number: Int = 123val string: String = abcОсобенно полезной эта конструкция может быть при работе с case­классами. Если точно известен case­класс, с которым ведется работа, то вы можете разобрать его с помощью паттерна. Пример выглядит следующим образом:scala> val exp = new BinOp("*", Num(5), Num(1))val exp: BinOp = BinOp(*,Num(5.0),Num(1.0))scala> val BinOp(op, left, right) = exp val op: String = *val left: Expr = Num(5.0)val right: Expr = Num(1.0)Последовательности вариантов в качестве частично примененных функцийПоследовательность вариантов (то есть альтернатив), заключенную в фигур­ные скобки, можно задействовать везде, где может использоваться функцио­нальный литерал. По сути, последовательность вариантов и есть функцио­нальный литерал, только более универсальный. Вместо единственной точки входа и списка параметров последовательность вариантов имеет несколько точек входа, каждой из которых присущ собственный список параметров. Каждый вариант является точкой входа в функцию, а параметры указы­ваются с помощью паттерна. Тело каждой точки входа — правосторонняя часть варианта.Простой пример выглядит следующим образом:val withDefault: Option[Int] => Int = case Some(x) => x case None => 0В теле этой функции имеется два варианта. Первый соответствует Some и возвращает число, находящееся внутри Some. Второй соответствует 288 Глава 13 • Сопоставление с образцом None и возвращает стандартное значение 0. А вот как используется данная функция:withDefault(Some(10)) // 10withDefault(None) // 0Такая возможность особенно полезна для библиотеки акторов Akka, посколь­ку позволяет определить ее метод receive в виде серии вариантов:var sum = 0def receive = case Data(byte) => sum += byte case GetChecksum(requester) => val checksum = (sum & 0xFF) + 1 requester ! checksumКроме того, стоит упомянуть еще одно общее правило: последовательность вариантов дает вам частично примененную функцию. Если применить такую функцию в отношении не поддерживаемого ею значения, то она сгенерирует исключение времени выполнения. Например, ниже показана частично при­мененная функция, которая возвращает второй элемент списка, состоящего из целых чисел:val second: List[Int] => Int = case x :: y :: _ => yПри компиляции этого кода компилятор вполне резонно выведет предупре­ждение о том, что сопоставление с образцом не охватывает все возможные варианты:2 | case x :: y :: _ => y | ˆ | match may not be exhaustive. | | It would fail on pattern case: List(_), NilФункция справится со своей задачей, если ей передать список, состоящий из трех элементов, но не станет работать при передаче пустого списка:scala> second(List(5, 6, 7))val res24: Int = 6scala> second(List())scala.MatchError: List() (of class Nil$) at rs$line$10$.$init$$$anonfun$1(rs$line$10:2) at rs$line$12$.(rs$line$12:1) 13 .7 . Паттерны повсюду 289Если нужно проверить, определена ли частично примененная функция, то сначала следует сообщить компилятору: вы знаете, что работаете с ча­стично примененными функциями. Тип List[Int]=>Int включает все функции, получающие из целочисленных списков целочисленные значе­ния независимо от того, частично они применяются или нет. Тип, который включает только частично примененные функции, которые получают из целочисленных списков целочисленные значения, записывается в виде PartialFunction[List[Int],Int]. Ниже представлен еще один вариант функции second, определенной с типом частично примененной функции:val second: PartialFunction[List[Int],Int] = case x :: y :: _ => yУ частично примененных функций есть метод isDefinedAt, который может использоваться для тестирования того, определена ли функция в отношении конкретного значения. В данном случае функция определена для любого списка, состоящего по крайней мере из двух элементов:second.isDefinedAt(List(5,6,7)) // true second.isDefinedAt(List()) // falseТипичным образчиком частично примененной функции может послужить функциональный литерал сопоставления с образцом, подобный представлен­ному в предыдущем примере. Фактически такое выражение преобразуется компилятором Scala в частично примененную функцию с помощью двойного преобразования паттернов: один раз для реализации реальной функции, а второй — для проверки того, определена ли функция.Например, функциональный литерал {case x::y::_=>y} преобразуется в следующее значение частично примененной функции:new PartialFunction[List[Int], Int]: def apply(xs: List[Int]) = xs match case x :: y :: _ => y def isDefinedAt(xs: List[Int]) = xs match case x :: y :: _ => true case _ => falseЭто преобразование осуществляется в том случае, когда в качестве объяв­ляемого типа функционального литерала выступает PartialFunction. Если объявляемый тип — просто Function1 или не указан, функциональный ли­терал вместо этого преобразуется в полноценную функцию. 290 Глава 13 • Сопоставление с образцом Вообще­то, полноценными функциями нужно пробовать пользоваться везде, где только можно, поскольку использование частично примененных функций допускает возникновение ошибок времени выполнения, устра­нить которые компилятор вам не может помочь. Но иногда частично при­мененные функции приносят реальную пользу. Вам следует позаботиться о том, чтобы этим функциям не было предоставлено необрабатываемое значение. Как вариант, вы можете задействовать фреймворк, который до­пускает использование частично примененных функций и поэтому всегда перед вызовом функции выполняет проверку функцией isDefinedAt. По­следнее проиллюстрировано приведенным ранее примером метода receive, где результатом выступает частично примененная функция с определе­нием, данным в точности для тех сообщений, которые нужно обработать вызывающему коду.Паттерны в выражениях forПаттерны, как показано ниже, в листинге 13.18, можно использовать также в выражениях for. Это выражение извлекает все пары «ключ — значение» из отображения capitals (столицы). Каждая пара соответствует паттер­ну (country,city) (страна, город), который определяет две переменные: country и cityЛистинг 13.18. Выражение for с паттерном-кортежем for (country, city) <- capitals yield s"Столицей $country является $city"//// List(Столицей France является Paris,// Столицей Japan является Tokyo)Паттерн пар, показанный в данном листинге, интересен, поскольку сопостав­ление с ним никогда не даст сбой. Конечно, capitals выдает последователь­ность пар, следовательно, можно быть уверенными, что каждая сгенериро­ванная пара может соответствовать паттерну пар.Но с равной долей вероятности возможно, что паттерн не будет соответ­ствовать сгенерированному значению. Именно такой случай показан в ли­стинге 13.19.1   ...   27   28   29   30   31   32   33   34   ...   64

$text

chap:vcls

529Таблица 24.4. Операции в трейте BufferЧто Что делаетДобавленияbuf += x (или buf.append(x))Добавляет элемент x в конец buf и возвращает в ка­честве результата сам buf buf ++= xs (или buf.appendAll(xs))Добавляет в конец буфера все элементы xs x +=: buf (или buf.prepend(x))Добавляет элемент x в начало буфера xs ++=: buf (или buf.prependAll(xs))Добавляет в начало буфера все элементы xs buf.insert(i, x)Вставляет элемент x в то место в буфере, на которое указывает индекс ibuf.insertAll(i, xs)Вставляет все элементы xs в то место в буфере, на которое указывает индекс ibuf.padToInPlace(n, x)Добавляет в буфер элементы x, пока общее количе­ство его элементов не достигнет nУдаленияbuf –= x (или buf.subtractOne(x))Удаляет из буфера элемент xbuf ––= x (или buf.subtractAll(xs))Удаляет из буфера все элементы xs buf.remove(i)Удаляет из буфера элемент с индексом ibuf.remove(i, n)Удаляет из буфера n элементов, начиная с элемента с индексом ibuf.trimStart(n)Удаляет из буфера первые n элементов buf.trimEnd(n)Удаляет из буфера последние n элементов buf.clear()Удаляет из буфера все элементыЗамена:buf.patchInPlace(i,xs,n)Заменяет (максимум) n элементов буфера элемен­тами из xs, начиная с индекса iКопированиеbuf.clone()Новый буфер с теми же элементами, что и в buf 530 Глава 24 • Углубленное изучение коллекций 24 .5 . МножестваКоллекции Set — это итерируемые Iterable­коллекции, которые не содер­жат повторяющихся элементов. Общие операции над множествами сведены в табл. 24.5, в табл. 24.6 показаны операции для неизменяемых множеств, а в табл. 24.7 — операции для изменяемых множеств. Операции разбиты на следующие категории.z zПроверки contains, apply и subsetOf. Метод contains показывает, со­держит ли множество заданный элемент. Метод apply для множества является аналогом contains, поэтому set(elem) — то же самое, что и set contains elem. Следовательно, множества могут также использоваться в качестве тестовых функций, возвращающих true для содержащихся в них элементов, например:val fruit = Set("apple", "orange", "peach", "banana")fruit("peach") // true fruit("potato") // false zzДобавления + (псевдоним: incl) и ++ (псевдоним: concat) добавляют в множество один и более элементов, возвращая в качестве результата новое множество.z zУдаления - (псевдоним: excl) и -- (псевдоним: removedAll) удаляют из множества один и более элементов, возвращая новое множество.z zОперации над множествами для объединения, пересечения и разности множеств. Существуют в двух формах: текстовом и символьном. К тек­стовым относятся версии intersect, union и diff, а к символьным — &, |и &. Оператор ++, наследуемый Set из Iterable, может рассматриваться в качестве еще одного псевдонима union или |, за исключением того, что ++ получает IterableOnce­аргумент, а union и | получают множества.Таблица 24.5. Операции в трейте SetЧто Что делаетПроверкиxs.contains(x)Проверяет, является ли x элементом xs xs(x)Делает то же самое, что и xs contains xxs.subsetOf(ys)Проверяет, является ли xs подмножеством ysУдаленияxs.emptyПустое множество того же класса, что и xs 24 .5 . Множества 531Что Что делаетБинарные операцииxs & ys (или xs.intersect(ys))Пересечение множеств xs и ys xs | ys (или xs.union(ys))Объединение множеств xs и ys xs & ys (или xs.diff(ys))Разность множеств xs и ysНеизменяемые множества предлагают методы добавления и удаления эле­ментов путем возвращения новых множеств, которые сведены в табл. 24.6.Таблица 24.6. Операции в трейте immutable .SetЧтоЧто делаетДобавленияxs + x (или xs.incl(x))Множество, содержащее все элементы xs и эле­мент xxs ++ ys (или xs.concat(ys))Множество, содержащее все элементы xs и все элементы ysУдаленияxs – x (или xs.excl(x))Множество, содержащее все элементы xs, кроме xxs –– ys (или xs.removedAll(ys))Множество, содержащее все элементы xs, кроме элементов множества ysУ изменяемых множеств есть методы, которые добавляют, удаляют и обнов­ляют элементы, которые сведены в табл. 24.7.Таблица 24.7. Операции в трейте mutable .SetЧтоЧто делаетДобавленияxs += x (или xs.addOne(x))Добавляет элемент x в множество xs как побочный эффект и возвращает само множество xs xs ++= ys (или xs.addAll(ys))Добавляет все элементы ys в множество xs как по­бочный эффект и возвращает само множество xs 532 Глава 24 • Углубленное изучение коллекций ЧтоЧто делаетxs.add(x)Добавляет элемент x в xs и возвращает true, если xпрежде не был в множестве, или false, если уже был Удаленияxs –= x (или xs.subtractOne(x))Удаляет элемент x из множества xs как побочный эффект и возвращает само множество xs xs ––= ys (или xs.subtractAll(ys))Удаляет все элементы ys из множества xs как побоч­ный эффект и возвращает само множество xs xs.remove(x)Удаляет элемент x из xs и возвращает true, если xпрежде уже был в множестве, или false, если его прежде там не было xs.filterInPlace(p)Сохраняет только те элементы в xs, которые удов­летворяют условию pxs.clear()Удаляет из xs все элементыОбновлениеxs(x) = b (или после раскрытия xs.update(x,b))Если аргумент b типа Boolean имеет значение true, то добавляет x в xs, в противном случае удаляет xиз xsКлонированиеxs.clone()Возвращает новое изменяемое множество с такими же элементами, как и в xsОперация s+=elem в качестве побочного эффекта добавляет elem во множе­ство s и в качестве результата возвращает измененное множество. По ана­логии с этим s-=elem удаляет элемент elem из множества и возвращает в качестве результата измененное множество. Помимо += и -=, есть также операции над несколькими элементами ++= и --=, которые добавляют или удаляют все элементы Iterable или итератора.Выбор в качестве имен методов += и -= означает, что очень похожий код может работать как с изменяемыми, так и с неизменяемыми множествами. Рассмотрим сначала следующий интерпретатор, в котором используется неизменяемое множество s:var s = Set(1, 2, 3)s += 4s = 2s // Set(1, 3, 4)Таблица 24.7 (окончание) 24 .5 . Множества 533В этом примере в отношении var­переменной типа immutable.Set использу­ются методы += и -=. Согласно объяснениям, которые были даны в шаге 10 главы 3, инструкции вида s+=4 — это сокращенная форма записи для s=s+4. Следовательно, в их выполнении участвует еще один метод +, применяемый в отношении множества s, а затем результат присваивается переменной s. А теперь рассмотрим аналогичную работу в интерпретаторе с изменяемым множеством:val s = collection.mutable.Set(1, 2, 3)s += 4 // Set(1, 2, 3, 4)s = 2 // Set(1, 3, 4)s // Set(1, 3, 4)Конечный эффект очень похож на предыдущий диалог с интерпретатором: начинаем мы с множеством Set(1,2,3), а заканчиваем с множеством Set(1,3,4). Но даже притом что инструкции выглядят такими же, как и раньше, они выполняют несколько иные действия. Теперь инструкция s+=4 вызыва­ет метод += в отношении значения s, которое представляет собой изменяемое множество, выполняя изменения на месте. Аналогично этому инструкция s-=2 теперь вызывает в отношении этого же множества метод -=Сравнение этих двух диалогов позволяет выявить весьма важный принцип. Зачастую можно заменить изменяемую коллекцию, хранящуюся в val­переменной, неизменяемой коллекцией, хранящейся в var­переменной, и наоборот. Это работает по крайней мере до тех пор, пока нет псевдонимов ссылок на коллекцию, позволяющих заметить, обновилась она на месте или была создана новая коллекция.Изменяемые множества также предоставляют в качестве вариантов += и -=методы add и remove. Разница в том, что методы add и remove возвращают булев результат, показывающий, возымела ли операция эффект над множе­ством.В текущей реализации по умолчанию изменяемого множества его элементы хранятся с помощью хеш­таблицы. В реализации по умолчанию неизменя­емых множеств используется представление, которое адаптируется к ко­личеству элементов множества. Пустое множество представляется в виде простого объекта­одиночки. Множества размером до четырех элементов представляются в виде одиночного объекта, сохраняющего все элементы как поля. Все неизменяемые множества, имеющие большие размеры, реализуют­ся в виде сжатых хеш­массивов из сопоставленных префиксных деревьев1 1 Префиксные деревья на основе сжатых хеш­массивов описываются в разделе 24.7. 534 Глава 24 • Углубленное изучение коллекций Последствия применения таких вариантов представления заключаются в том, что для множеств небольших размеров с количеством элементов, не превышающим четырех, неизменяемые множества получаются более ком­пактными и более эффективными в работе, чем изменяемые. Поэтому, если предполагается, что множество будет небольшим, попробуйте сделать его неизменяемым.24 .6 . ОтображенияКоллекции типа Map представляют собой Iterable­коллекции, состоящие из пар «ключ — значение», которые также называются отображениями или ассоциациями. Объект Predef в Scala предлагает неявное преобразование, позволяющее использовать запись вида ключ–>значение в качестве альтерна­тивы синтаксиса для пары вида (ключ,значение). Таким образом, выражение для инициализации Map("x"–>24,"y"–>25,"z"–>26) означает абсолютно то же самое, что и выражение Map(("x",24),("y",25),("z",26)), но чи­тается легче.Основные операции над отображениями, сведенные в табл. 24.8, похожи на аналогичные операции над множествами. Неизменяемые отображения под­держивают дополнительные операции добавления и удаления, которые воз­вращают новые отображения, как показано в табл. 24.9. Изменяемые отобра­жения дополнительно поддерживают операции, перечисленные в табл. 24.10. Операции над отображениями разбиваются на следующие категории.z zОперации поиска apply, get, getOrElse, contains и isDefinedAt превра­щают отображения в частично примененные функции от ключей к значе­ниям. Основной метод поиска для отображений выглядит так:def get(key): Option[Value]Операция m.get(key) проверяет, содержит ли отображение ассоциацию для заданного ключа. Будучи в наличии, такая ассоциация возвращает значение ассоциации в виде объекта типа Some. Если такой ключ в ото­бражении не определен, то get возвращает None. В отображениях также определяется метод apply, возвращающий значение, непосредственно ассоциированное с заданным ключом, без его инкапсуляции в OptionЕсли ключ в отображении не определен, то выдается исключение.z zДобавления и обновления + (псевдоним: updated), ++ (псевдоним: concat) updateWith и updatedWith позволяют добавлять к отображению новые привязки или изменять уже существующие. 24 .6 . Отображения 535z zУдаления - (псевдоним: removed) и -- (псевдоним: removedAll) позво­ляют удалять привязки из отображения.z zОперации создания подколлекций keys, keySet, keysIterator, valu-esIterator и values возвращают по отдельности ключи и значения ото­бражений в различных формах.z zПреобразования filterKeys и mapValues создают новое отображение путем фильтрации и преобразования привязок существующего отображения.Таблица 24.8. Операции в трейте MapЧтоЧто делаетПоискms.get(k)Значение Option, связанное с ключом k в отображе­нии ms, или None, если ключ не найден ms(k) (или после рас­крытия ms apply k)Значение, связанное с ключом k в отображении ms, или выдает исключение, если ключ не найден ms.getOrElse(k, d)Значение, связанное с ключом k в отображении ms, или значение по умолчанию d, если ключ не найден ms.contains(k)Проверяет, содержится ли в ms отображение для ключа kms.isDefinedAt(k)То же, что и containsСоздание подколлекцийms.keysIterable­коллекция, содержащая каждый ключ, име­ющийся в ms ms.keySetМножество, содержащее каждый ключ, имеющийся в ms ms.keysIteratorИтератор, выдающий каждый ключ, имеющийся в ms ms.valuesIterable­коллекция, содержащая каждое значение, связанное с ключом в ms ms.valuesIteratorИтератор, выдающий каждое значение, связанное с ключом в msПреобразованияms.view.filterKeys(p)Представление отображения, содержащее только те отображения в ms, в которых ключ удовлетворяет условию pms.view.mapValues(f)Представление отображения, получающееся в резуль­тате применения функции f к каждому значению, связанному с ключом в ms 536 Глава 24 • Углубленное изучение коллекций Таблица 24.9. Операции в трейте immutable .MapЧто Что делаетДобавления и обновленияms + (k –> v) (или ms.updated(k, v))Отображение, содержащее все ассоциации ms, а также ассоциацию k –> v ключа k со значением vms ++= kvs (или ms.concat(kvs))Отображение, содержащее все ассоциации ms, а также все пары «ключ — значение» из kvs ms.updatedWith(k)(f)Отображение с добавлением, обновлением или уда­лением привязки для ключа k. Функция f принимает в качестве параметра значение, связанное в настоя­щий момент с ключом k (или None, если такой привяз­ки нет), и возвращает новое значение (или None для удаления привязки)Удаленияms – k (или ms.removed(k))Отображение, содержащее все ассоциации ms, за ис­ключением тех, которые относятся к ключу kms –– ks (или ms.removedAll(ks))Отображение, содержащее все ассоциации ms, за ис­ключением тех, ключи которых входят в ksТаблица 24.10. Операции в трейте mutable .MapЧто Что делаетДобавления и обновленияms(k)=v (или после рас­крытия ms.update(k,v))Добавляет в качестве побочного эффекта ассо­циацию ключа k со значением v к отображе­нию ms, перезаписывая все ранее имевшиеся ассоциации kms+=(k–>v)Добавляет в качестве побочного эффекта ассоциа­цию ключа k со значением v к отображению ms и возвращает само отображение ms ms++=kvsДобавляет в качестве побочного эффекта все ассо­циации, имеющиеся в kvs, к ms и возвращает само отображение ms ms.put(k,v)Добавляет к ms ассоциацию ключа k со значением vи возвращает как Option любое значение, ранее связанное с kms.getOrElseUpdate(k,d)Если ключ k определен в отображении ms, то воз­вращает связанное с ним значение. В противном случае обновляет ms ассоциацией k –> d и возвра­щает d 24 .6 . Отображения 537Что Что делаетms.updateWith(k)(f)Добавляет, обновляет или удаляет ассоциацию с ключом k. Функция f принимает в качестве параметра значение, которое в настоящий момент связано с k (или None, если такой ассоциации нет), и возвращает новое значение (или None t при удале­нии ассоциации)Удаленияms–=kУдаляет в качестве побочного эффекта ассоциацию с ключом k из ms и возвращает само отображение ms ms––=ksУдаляет в качестве побочного эффекта все ассоциа­ции из ms с ключами, имеющимися в ks, и возвраща­ет само отображение ms ms.remove(k)Удаляет все ассоциации с ключом k из ms и воз­вращает как Option любое значение, ранее связан­ное с kms.filterInPlace(p)Сохраняет в ms только те ассоциации, у которых ключ удовлетворяет условию pms.clear()Удаляет из ms все ассоциацииПреобразование и клонированиеms.mapValuesInPlace(f)Выполняет преобразование всех связанных значе­ний в отображении ms с помощью функции fms.clone()Возвращает новое изменяемое отображение с таки­ми же ассоциациями, как и в msОперации добавления и удаления для отображений — зеркальные отражения таких же операций для множеств. Неизменяемое отображение может быть преобразовано с помощью операций +, - и updated. Для сравнения: изменя­емое отображение m можно обновить «на месте» двумя способами: m(key)=value и m+=(key–>value). Изменяемые отображения также поддерживают вариант m.put(key,value), который возвращает значение Option, содержа­щее то, что прежде ассоциировалось с ключом, или None, если ранее такой ключ в отображении отсутствовал.Метод getOrElseUpdate пригодится для обращения к отображениям там, где они действуют в качестве кэша. Скажем, у вас есть весьма затратное вычис­ление, запускаемое путем вызова функции f:def f(x: String) = println("taking my time.") Thread.sleep(100) x.reverse 538 Глава 24 • Углубленное изучение коллекций Далее предположим, что у f нет побочных эффектов, поэтому ее повторный вы­зов с тем же самым аргументом всегда будет выдавать тот же самый результат. В таком случае можно сберечь время, сохранив ранее вычисленные привязки аргумента и результата выполнения f в отображении, и вычислять результат выполнения f, только если результат для аргумента не был найден в отображе­нии. Можно сказать, что отображение — это кэш для вычислений функции f:val cache = collection.mutable.Map[String, String]()Теперь можно создать более эффективную кэшированную версию функ­ции f:scala> def cachedF(s: String) = cache.getOrElseUpdate(s, f(s))def cachedF(s: String): String scala> cachedF("abc")taking my time.val res16: String = cba scala> cachedF("abc")val res17: String = cbaОбратите внимание: второй аргумент getOrElseUpdate — это аргумент, пере­даваемый по имени. Следовательно, показанное ранее вычисление f("abc")выполняется лишь в том случае, если методу getOrElseUpdate потребуется значение его второго аргумента, что происходит именно тогда, когда его первый аргумент не найден в кэширующем отображении. Вы могли бы также непосредственно реализовать cachedF, используя только основные операции с отображениями, но для этого понадобится дополнительный код:def cachedF(arg: String) = cache.get(arg) match case Some(result) => result case None => val result = f(arg) cache(arg) = result result24 .7 . Конкретные классы неизменяемых коллекцийВ Scala на выбор предлагается множество конкретных классов неизменя­емых коллекций. Друг от друга они отличаются реализуемыми трейтами (отображения, множества, последовательности) тем, могут ли они быть бес­ 24 .7 . Конкретные классы неизменяемых коллекций1   ...   52   53   54   55   56   57   58   59   ...   64

548 Глава 24 • Углубленное изучение коллекций queue ++= List("b", "c") // Queue(a, b, c)
queue // Queue(a, b, c)
queue.dequeue // a queue // Queue(b, c)
Стеки
Scala предоставляет изменяемый стек. Ниже представлен пример:
val stack = new scala.collection.mutable.Stack[Int]
stack.push(1) // Stack(1)
stack // Stack(1)
stack.push(2) // Stack(2, 1)
stack // Stack(2, 1)
stack.top // 2
stack // Stack(2, 1)
stack.pop // 2
stack // Stack(1)
Обратите внимание: в Scala нет поддержки неизменяемых стеков, поскольку данная функциональность уже реализована в списках. Операция push над неизменяемым стеком аналогична выражению a
::
для списка. Операция pop
— то же самое, что вызов head и tail для списка.
Изменяемые ArraySeq
Последовательный массив — это изменяемая последовательность фикси­
рованного размера, которая хранит свои элементы внутри
Array[AnyRef]
В Scala он реализован в виде класса
ArraySeq
Данный класс обычно используется в случаях, когда вам нужен производи­
тельный массив и притом необходимо создавать обобщенные экземпляры последовательности с элементами, тип которых не известен заранее и не может быть получен во время выполнения с помощью
ClassTag
. С этими проблемами, которые присущи массивам, вы познакомитесь чуть позже, в разделе 24.9.
Хеш-таблицы
Хеш­таблица сохраняет свои элементы в образующем ее массиве, помещая каждый в позицию в массиве, определяемую хеш­кодом этого элемента.
На добавление элемента в хеш­таблицу всегда уходит одно и то же время,

24 .8 . Конкретные классы изменяемых коллекций 549
если только в массиве нет еще одного элемента с точно таким же хеш­кодом.
Поэтому, пока помещенные в хеш­таблицу элементы имеют хорошее рас­
пределение хеш­кодов, работа с ней выполняется довольно быстро. По этой причине типы изменяемых отображений и множеств по умолчанию в Scala основаны на хеш­таблицах.
Хеш­множества и хеш­отображения используются точно так же, как и любые другие множества или отображения. Ниже представлены некоторые простые примеры:
val map = collection.mutable.HashMap.empty[Int,String]
map += (1 –> "make a web site")
// Map(1 –> make a web site)
map += (3 –> "profit!")
// Map(1 –> make a web site, 3 –> profit!)
map(1) // make a web site map.contains(2) // false
Конкретный порядок обхода элементов хеш­таблицы не гарантируется.
Выполняется простой обход элементов массива, на котором основана хеш­таблица, в порядке расположения его элементов. Чтобы получить гарантированный порядок обхода, следует воспользоваться связанными хеш­отображением или множеством вместо обычных. Связанные хеш­
отображение или множество почти аналогичны обычным хеш­отображению или множеству, но включают связанный список элементов в порядке их до­
бавления. Обход элементов такой коллекции всегда выполняется в том же порядке, в котором они добавлялись в нее изначально.
Слабые хеш-отображения
Слабое хеш­отображение представляет собой особую разновидность хеш­
отображения, в которой сборщик мусора не следует по ссылкам от отобра­
жения к хранящимся в нем ключам. Это значит, ключ и связанное с ним значение исчезнут из отображения, если на данный ключ нет другой ссылки.
Слабые хеш­отображения используются для решения таких задач, как кэши­
рование, когда нужно повторно задействовать результат затратной функции в случае повторного вызова функции в отношении того же самого ключа.
Если ключи и результаты функции хранятся в обычном хеш­отображении, то оно может бесконечно разрастись и никакие ключи никогда не станут мусо­
ром. Этой проблемы удается избежать с помощью слабого хеш­отображения.
Как только объект ключа становится недоступным, связанная с ним запись удаляется из такого отображения. Слабые хеш­отображения реализованы


550 Глава 24 • Углубленное изучение коллекций в Scala в виде обертки положенной в их основу Java­реализации java.util.We- akHashMap
Совместно используемые отображения
К совместно используемому отображению могут обращаться сразу несколько потоков. В дополнение к обычным операциям с
Map это отображение предо­
ставляет атомарные операции (табл. 24.11).
Таблица 24.11. Операции в трейте concurrent .Map
Что
Что делает
m.putIfAbsent(k,
v)
Добавляет привязку «ключ — значение» k
–>
v
, кроме тех случаев, когда k
уже определен в m
m.remove(k,
v)
Удаляет запись для ключа k
, если он в данный момент отображен на значение v
m.replace(k,
old,
new)
Заменяет значение, связанное с k
, новым значением new
, если ранее с ключом было связано значение old m.replace(k,
v)
Заменяет значение, связанное с k
, значением v
, если ранее этот ключ был связан с каким­либо значением
Трейт scala.concurrent.Map определяет интерфейс для изменяемых отобра­
жений с поддержкой конкурентного доступа. Стандартная библиотека пред­
лагает две реализации этого трейта. Первая — это java.util.concurrent.Con- currentMap из Java, которую можно автоматически превратить в отображение языка Scala, используя стандартные для Java/Scala операции приведения коллекций (эти преобразования будут описаны в разделе 24.16). Вторая реализация,
TrieMap
, основана на HAMT без блокировок.
Изменяемые битовые множества
Изменяемое битовое множество похоже на неизменяемое, но отличается тем, что может быть изменено на месте. По сравнению с неизменяемым оно работает при обновлениях немного эффективнее, поскольку неизмененные
Long
­значения копировать не нужно. Пример использования выглядит так:
val bits = scala.collection.mutable.BitSet.empty bits += 1 // BitSet(1)
bits += 3 // BitSet(1, 3)
bits // BitSet(1, 3)

24 .9 . Массивы 551
24 .9 . Массивы
Массивы в Scala — особая разновидность коллекции. С одной стороны, мас­
сивы Scala в точности соответствуют массивам Java. То есть Scala­массив
Ar ray[Int]
представлен как Java­массив int[]
,
Array[Double]
— как double[]
, а
Ar ray[String]
— как
String[]
. Но вместе с тем массивы Scala предостав­
ляют гораздо больше, чем их Java­аналоги. Во­первых, массивы Scala могут быть обобщенными. То есть можно воспользоваться массивом
Array[T]
, где
T
является параметром типа или абстрактным типом. Во­вторых, массивы
Scala совместимы со Scala­последовательностями, то есть туда, где требу­
ется
Seq[T]
, можно передавать
Array[T]
. И наконец, массивы Scala также поддерживают все операции с последовательностями. Приведем несколько практических примеров:
val a1 = Array(1, 2, 3)
val a2 = a1.map(_ * 3) // Array(3, 6, 9)
val a3 = a2.filter(_ % 2 != 0) // Array(3, 9)
a3.reverse // Array(9, 3)
Если учесть, что массивы Scala представлены точно так же, как массивы Java, то как эти дополнительные свойства могут поддерживаться в Scala?
Ответ заключается в систематическом использовании неявных преобразова­
ний. Массив не может претендовать на то, чтобы быть последовательностью, поскольку тип данных, представляющих настоящий массив, не является под­
типом типа
Seq
. Вместо этого там, где массив будет использоваться в качестве последовательности
Seq
, он будет неявно обернут в подкласс класса
Seq
. Имя этого подкласса — scala.collection.mutable.ArraySeq
. Вот как это работает:
val seq: collection.Seq[Int] = a1 // ArraySeq(1, 2, 3)
val a4: Array[Int] = seq.toArray // Array(1, 2, 3)
a1 eq a4 // false
Сеанс работы с интерпретатором показывает, что массивы совместимы с последовательностями благодаря неявному преобразованию из
Array в
ArraySeq
. Преобразование в обратном направлении, из
ArraySeq в
Array
, можно выполнить с использованием метода toArray
, который определен в классе
Iterable
. В последней строке приведенного ранее диалога с интер­
претатором показано, что заключенный в оболочку массив при его изъятии оттуда с помощью метода toArray возвращает копию исходного массива.
Есть еще одно неявное преобразование, применимое к массивам. Оно просто
«добавляет» к массивам все методы, применимые к последовательностям, но сами массивы в последовательности не превращает. «Добавляет» означает,


552 Глава 24 • Углубленное изучение коллекций что массив заворачивается в другой объект типа
ArrayOps
, который поддер­
живает все методы работы с последовательностями. Обычно этот
ArrayOps
­
объект существует весьма непродолжительное время — он становится недо­
ступен после вызова метода для работы с последовательностью, и его место хранения может быть использовано повторно. Современные виртуальные машины зачастую вообще избегают создания таких объектов.
Разница между этими двумя неявными преобразованиями массивов пока­
зана в следующем примере:
val seq: collection.Seq[Int] = a1 // ArraySeq(1, 2, 3)
seq.reverse // ArraySeq(3, 2, 1)
val ops: collection.ArrayOps[Int] = a1 // Array(1, 2, 3)
ops.reverse // Array(3, 2, 1)
Как видите, при вызове метода reverse в отношении объекта seq типа
ArraySeq опять будет получен объект типа
ArraySeq
. Это не противоречит здравому смыслу, поскольку массивы
ArraySeq
, заключенные в оболочку, относятся к типам
Seq
, а вызов метода reverse в отношении любого
Seq
­
объекта вновь даст
Seq
­объект. В то же время вызов метода reverse в от­
ношении значения ops класса
ArrayOps приведет к возвращению значения типа
Array
, а не
Seq
Приведенный ранее пример с
ArrayOps был надуманным, предназначенным лишь для демонстрации разницы с
ArraySeq
. В обычной ситуации вы бы не стали определять значение класса
ArrayOps
, а просто вызвали бы в отноше­
нии массива метод из класса
Seq
:
a1.reverse // Array(3, 2, 1)
Объект
ArrayOps вставляется автоматически в ходе неявного преобразова­
ния. Следовательно, показанная выше строка кода эквивалентна следующей строке, где метод intArrayOps является преобразованием, которое неявно вставлялось в предыдущем примере:
intArrayOps(a1).reverse // Array(3, 2, 1)
Возникает вопрос, касающийся способа выбора компилятором intArrayOps в показанной выше строке среди других неявных преобразований в
ArraySeq
Ведь оба преобразования отображают массив на тип, поддерживающий метод reverse
, указанный во введенном коде. Ответ на данный вопрос — уровни приоритета этих двух преобразований. У преобразования
ArrayOps более высокий приоритет, чем у
ArraySeq
. Первое преобразование определено в объекте
Predef
, а второе — в классе scala.LowPriorityImplicits
, явля­
ющемся суперклассом для
Predef
. Неявные преобразования в подклассах

24 .9 . Массивы 553
и подобъектах имеют приоритет над неявными преобразованиями в базовых классах. Следовательно, если применимы оба преобразования, то будет вы­
брано то, которое определено в
Predef
. Очень похожая схема, рассмотренная в разделе 21.7, работает для строк.
Теперь вы знаете, что массивы совместимы с последовательностями и могут поддерживать все операции, применяемые к последовательностям. А как на­
счет обобщенности? В Java вы не можете воспользоваться записью
T[]
, где
T
представляет собой параметр типа. А как же тогда представлен имеющийся в Scala тип
Array[T]
? Фактически такой обобщенный массив, как
Array[T]
, может во время выполнения программы стать любым из восьми примитив­
ных типов массивов Java: byte[]
, short[]
, char[]
, int[]
, long[]
, float[]
, double[]
, boolean[]
— или же стать массивом объектов. Единственный об­
щий тип, охва тывающий во время выполнения программы все эти типы, —
AnyRef
(или его аналог java.lang.Object
), следовательно, это именно тот тип, на который компилятор Scala отображает
Array[T]
. Во время выполнения программы, когда происходит обращение к элементу массива типа
Array[T]
или обновление этого элемента, производится ряд проверок на соответствие типам. Благодаря этому определяется действительный тип массива, а затем выполняется корректная операция уже над Java­массивом. Проверки на соответствие типам несколько замедляют операции над массивами. Можно ожидать, что обращения к обобщенным массивам будут в три­четыре раза медленнее обращений к простым массивам или массивам объектов. Сле­
довательно, если требуется максимально высокая производительность, то предпочтение следует отдавать конкретным, а не обобщенным массивам.
Но одного представления типа обобщенного массива недостаточно, дол­
жен существовать также способ создания обобщенных массивов. А это еще более сложная задача, которая требует от вас оказать некоторую помощь.
В качестве примера рассмотрим попытку создания метода, работающего с обобщениями и создающего массив:
// Неправильно!
def evenElems[T](xs: Vector[T]): Array[T] =
val arr = new Array[T]((xs.length + 1) / 2)
for i <- 0 until xs.length by 2 do arr(i / 2) = xs(i)
arr
Метод evenElems возвращает новый массив, состоящий из тех элементов используемого в качестве аргумента вектора xs
, которые находятся в нем на четных позициях. В первой строке тела метода evenElems создается получа­
емый в результате массив, имеющий тот же тип элементов, что и аргумент.
Следовательно, в зависимости от фактического параметра типа для
T
это


554 Глава 24 • Углубленное изучение коллекций может быть
Array[Int]
, или
Array[Boolean]
, или массив из других прими­
тивных типов Java, или же массив какого­нибудь ссылочного типа. Но все эти типы имеют во время выполнения программы различные представления, поэтому возникает вопрос: как среда выполнения Scala собирается выбирать из них нужное? По сути, сделать это, основываясь на имеющейся инфор­
мации, она не может, поскольку фактический тип, который соответствует параметру типа
T
, во время выполнения кода затирается. Поэтому при по­
пытке скомпилировать показанный ранее код будет получено следующее сообщение об ошибке:
2 | val arr = new Array[T]((xs.length + 1) / 2)
| ˆ
| No ClassTag available for T
Здесь вам следует помочь компилятору, предоставив подсказку времени выполнения о том, какой параметр типа у evenElems
. Эта подсказка прини­
мает форму тега класса типа scala.reflect.ClassTag
. Тег класса описывает заданный исполняемый класс, предоставляя исчерпывающую информацию о нем конструктору массива.
Во многих случаях компилятор может создавать тег класса самостоятель­
но. Именно так обстоит дело с конкретными типами наподобие
Int или
String
. То же распространяется и на некоторые обобщенные типы наподо­
бие
List[T]
, где для выстраивания предположения об исполняемом классе информации вполне достаточно; в данном примере исполняемым классом будет
List
Для полностью обобщенных случаев обычно практикуется передача тега класса с помощью контекстного ограничителя, рассмотренного в разде­
ле 23.2. А вот как, используя этот ограничитель, можно исправить показанное ранее определение:
// Этот код работает import scala.reflect.ClassTag def evenElems[T: ClassTag](xs: Vector[T]): Array[T] =
val arr = new Array[T]((xs.length + 1) / 2)
for i <- 0 until xs.length by 2 do arr(i / 2) = xs(i)
arr
В этом новом определении компилятор при создании
Array[T]
ищет тег класса для параметра типа
T
, то есть будет искать неявное значение типа
ClassTag[T]
. Если такое значение будет найдено, то тег класса будет исполь­
зован для создания массива нужного вида. В противном случае вы увидете сообщение об ошибке, похожее на показанное ранее.

24 .10 . Строки 555
Вот как выглядит диалог с интерпретатором, в котором используется метод evenElems
:
evenElems(Vector(1, 2, 3, 4, 5)) // Array(1, 3, 5)
evenElems(Vector("this", "is", "a", "test", "run")) // Array(this, a, run)
В обоих случаях компилятор Scala автоматически создает тег класса для типа элемента (сначала
Int
, потом
String
) и передает его неявному параметру мето­
да evenElems
. Компилятор может сделать то же самое для всех конкретных ти­
пов, но не способен на это, если сам аргумент является еще одним параметром типа без признака класса. Например, следующий код не пройдет компиляцию:
scala> def wrap[U](xs: Vector[U]) = evenElems(xs)
1 |def wrap[U](xs: Vector[U]) = evenElems(xs)
| ˆ
| No ClassTag available for U
Здесь метод evenElems получает тег класса для параметра типа
U
, но ничего не находит. Разумеется, решение в данном случае — потребовать еще один неяв­
ный тег класса для
U
. Код, представленный ниже, уже пройдет компиляцию:
def wrap[U: ClassTag](xs: Vector[U]) = evenElems(xs)
Этот пример показывает также, что контекстное ограничение в определе­
нии
U
— краткая форма неявного параметра, названного здесь evidence$1
и имеющего тип
ClassTag[U]
24 .10 . Строки
Как и массивы, строки не являются последовательностями в прямом смысле слова, но могут быть в них преобразованы и вдобавок поддерживают все операции с последовательностями. Ниже приводятся примеры операций, которые могут вызываться в отношении строк:
val str = "hello"
str.reverse // olleh str.map(_.toUpper) // HELLO
str.drop(3) // lo str.slice(1, 4) // ell val s: Seq[Char] = str // hello
Эти операции поддерживаются двумя неявными преобразованиями, рассмо­
тренными в разделе 23.5. Первое, имеющее более низкий уровень приорите­
та, отображает класс
String на класс
WrappedString
, являющийся подклассом


556 Глава 24 • Углубленное изучение коллекций immutable.IndexedSeq
. Это преобразование было применено в последней строке предыдущего примера, в котором строка была преобразована в значе­
ние типа
Seq
. Другое преобразование, с более высоким уровнем приоритета, отображает строку на объект
StringOps
, который добавляет к строкам все методы, применяемые к неизменяемым последовательностям. В предыду­
щем примере это преобразование было неявно вставлено в вызовы методов reverse
, map
, drop и slice
24 .11 . Характеристики производительности
Как показали все предыдущие разъяснения, разным типам коллекций свойственны различные характеристики производительности. Именно это обстоя тельство становится главной причиной выбора конкретного типа коллекции из множества других типов. Характеристики производительности некоторых наиболее востребованных операций над коллекциями сведены в табл. 24.12 и 24.13.
1   ...   54   55   56   57   58   59   60   61   ...   64