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

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

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

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

Добавлен: 09.12.2023

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

Скачиваний: 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

21
Гивены
Поведение функции зачастую зависит от контекста, в котором она вы­
зывается. Например, она может менять свое поведение в зависимости от контекстных данных, таких как системные свойства, разрешения безопас­
ности, аутентифицированный пользователь, транзакция базы данных или заданное время ожидания. Функция также может зависеть от контекст­
ного поведения — алгоритма, имеющего смысл в контексте, в котором эта функция вызывается. Например, функция сортировки может зависеть от алгоритма сравнения, который определяет, как упорядочивать сортиру­
емые элементы. Разные контексты могут требовать разных алгоритмов сравнения.
Для предоставления функции такой контекстной информации и поведения существует множество приемов, однако в функциональном программиро­
вании решение традиционно сводится к одному: передавать все в качестве параметров. И хотя это вполне рабочий подход, у него есть недостаток: чем больше вы передаете функции данных и алгоритмов, тем более общей и по­
лезной она становится, но при этом увеличивается количество аргументов, которые нужно указывать при каждом ее вызове. К сожалению, передача всего в виде параметров может быстро сделать ваш код повторяющимся и шаблонным.
В этой главе описываются контекстные параметры, которые часто называют гивенами (given). Они позволяют вам опускать некоторые аргументы при вызове функций, давая возможность компилятору подставить подходящие значения для каждого контекста в зависимости от типа.

448 Глава 21 • Гивены
21 .1 . Как это работает
Компилятор иногда меняет someCall(a)
на someCall(a)(b)
или
SomeClass(a)
на new
SomeClass(a)(b)
, добавляя тем самым один или несколько недостающих списков параметров, чтобы сделать вызов функции завершенным. Предостав­
ляются не отдельные параметры, а целые их каррированные списки. Напри­
мер, если недостающий список параметров someCall состоит из трех значений, компилятор может подставить someCall(a)(b,
c,
d)
вместо someCall(a)
. В этом случае подставленные идентификаторы, такие как b
, c
и d
в
(b,
c,
d)
, должны быть помечены как заданные (given) в месте их определения, а сам список параметров в определении someCall или someClass должен начинаться с using
Представьте, к примеру, что у вас есть множество методов, принимающих приглашение командной строки (например,
"$
"
или ">
"
), которое предпо­
читает текущий пользователь. Вы можете сократить количество шаблонного кода, сделав запрос контекстным параметром. Для начала нужно создать спе­
циальный тип, инкапсулирующий строку с предпочитаемым приглашением:
class PreferredPrompt(val preference: String)
Далее нужно отредактировать каждый метод, который принимает приглаше­
ние, заменив параметр отдельным списком параметров с ключевым словом using
. Например, у следующего объекта
Greeter есть метод greet
, который принимает
PreferredPrompt в качестве контекстного параметра:
object Greeter:
def greet(name: String)(using prompt: PreferredPrompt) =
println(s"Welcome, $name. The system is ready.")
println(prompt.preference)
Чтобы компилятор мог неявно подставлять контекстный параметр, вы долж­
ны определить given­экземпляр ожидаемого типа (в данном случае
Prefer- redPrompt
) с использованием ключевого слова given
. Это можно сделать в объекте настроек, как показано далее:
object JillsPrefs:
given prompt: PreferredPrompt =
PreferredPrompt("Your wish> ")
Теперь компилятор может автоматически подставлять этот экземпляр
Pre- fer redPrompt
, но только при условии, что тот находится в области видимости:
scala> Greeter.greet("Jill")
1 |Greeter.greet("Jill")
| ˆ
|no implicit argument of type PreferredPrompt was found
|for parameter prompt of method greet in object Greeter


21 .1 . Как это работает 449
Если сделать этот объект доступным, он будет использоваться для предо­
ставления недостающего списка параметров:
scala> import JillsPrefs.prompt scala> Greeter.greet("Jill")
Welcome, Jill. The system is ready.
Your wish>
Поскольку приглашение командной строки объявлено в качестве контекст­
ного параметра, оно не скомпилируется, если вы попытаетесь передать ар­
гумент как обычно, явным образом:
scala> Greeter.greet("Jill")(JillsPrefs.prompt)
1 |Greeter.greet("Jill")(JillsPrefs.prompt)
|ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ
|method greet in object Greeter does not take more
|parameters
Вместо этого вам следует указать, что вы хотите явно подставить контекст­
ный параметр, используя в момент вызова ключевое слово using
, как по­
казано ниже:
scala> Greeter.greet("Jill")(using JillsPrefs.prompt)
Welcome, Jill. The system is ready.
Your wish>
Обратите внимание на то, что ключевое слово using относится не к от­
дельным параметрам, а ко всему списку. В листинге 21.1 показан пример, в котором второй список параметров метода greet из объекта
Greeter
(ко­
торый опять же помечен как using
) состоит из двух элементов: prompt
(типа
PreferredPrompt
) и drink
(типа
PreferredDrink
).
Листинг 21.1. Неявный список с несколькими параметрами class PreferredPrompt(val preference: String)
class PreferredDrink(val preference: String)
object Greeter:
def greet(name: String)(using prompt: PreferredPrompt,
drink: PreferredDrink) =
println(s"Welcome, $name. The system is ready.")
print("But while you work, ")
println(s"why not enjoy a cup of ${drink.preference}?")
println(prompt.preference)
object JoesPrefs:
given prompt: PreferredPrompt =
PreferredPrompt("relax> ")
given drink: PreferredDrink =
PreferredDrink("tea")

450 Глава 21 • Гивены
Объект­одиночка объявляет два given­экземпляра: prompt типа
Prefer- redPrompt и drink типа
PreferredDrink
. Но, как и прежде, они не будут ис­
пользоваться для подстановки недостающего списка параметров в greet
, если они находятся вне области видимости:
scala> Greeter.greet("Joe")
1 |Greeter.greet("Joe")
| ˆ
|no implicit argument of type PreferredPrompt was found
|for parameter prompt of method greet in object Greeter
Вы можете сделать оба given­экземпляра из листинга 21.1 доступными с по­
мощью инструкции import
:
scala> import JoesPrefs.{prompt, drink}
Поскольку и prompt
, и drink теперь находятся в области видимости в каче­
стве отдельных идентификаторов, вы можете использовать их для явного предоставления последнего списка параметров:
scala> Greeter.greet("Joe")(using prompt, drink)
Welcome, Joe. The system is ready.
But while you work, why not enjoy a cup of tea?
relax>
И поскольку ваши контекстные параметры теперь удовлетворяют всем правилам, вы можете также позволить компилятору Scala подставить prompt и drink автоматически, целиком опустив весь список параметров:
scala> Greeter.greet("Joe")
Welcome, Joe. The system is ready.
But while you work, why not enjoy a cup of tea?
relax>
Одной из особенностей предыдущих примеров является то, что мы не ис­
пользовали
String в качестве типа для prompt или drink
, хотя в итоге оба этих значения предоставили именно
String через свои поля preference
. По­
скольку компилятор выбирает контекстные параметры путем сопоставле­
ния типов параметров и типов given­экземпляров, контекстные параметры должны иметь достаточно редкие, или особенные типы, которые делают случайное совпадение маловероятным. Например, типы
PreferredPrompt и
PreferredDrink в листинге 21.1 были определены исключительно для контекстных параметров. В результате given­экземпляры этих типов, ско­
рее всего, не будут существовать, если только они не предназначены для использования в качестве контекстных параметров для таких методов, как greet


21 .2 . Параметризованные given-типы 451
21 .2 . Параметризованные given-типы
Контекстные параметры, наверное, чаще всего используются для предо­
ставления информации о типе, явно указанном в предыдущем списке параметров, подобно классам типов (type class) в Haskell. Это важный ме­
ханизм достижения специального полиморфизма (ad hoc polymorphism) при написании функций в Scala: ваши функции можно применять к значениям с подходящими типами, но при использовании для значений любых других типов код не скомпилируется. Представьте, к примеру, двухстрочную со­
ртировку вставками, показанную в листинге 14.1. Это определение isort работает только для списка целых чисел. Чтобы сортировать списки других типов, вам нужно сделать тип аргумента isort более общим. Для этого первым делом можно ввести параметр типа,
T
, и подставить его вместо
Int в параметре типа
List
:
// Не компилируется def isort[T](xs: List[T]): List[T] =
if xs.isEmpty then Nil else insert(xs.head, isort(xs.tail))
def insert[T](x: T, xs: List[T]): List[T] =
if xs.isEmpty || x <= xs.head then x :: xs else xs.head :: insert(x, xs.tail)
Но, попытавшись скомпилировать isort после внесения этого изменения, вы получите от компилятора следующее сообщение:
6 | if xs.isEmpty || x <= xs.head then x :: xs
| ˆˆˆˆ
| value <= is not a member of T, ...
Если класс
Int определяет метод
<=
, устанавливающий, является ли одно целое число меньше или равно другому, то для других типов могут потре­
боваться альтернативные стратегии сравнения или же их и вовсе нельзя сравнивать. Чтобы метод isort мог работать со списками, элементы которых имеют типы, отличные от
Int
, ему нужно предоставить чуть больше инфор­
мации, позволяющей определить способ сравнения двух элементов.
Чтобы решить эту проблему, методу isort можно передать функцию «мень­
ше или равно», подходящую для типа
List
. Эта функция должна принимать два экземпляра
T
и возвращать значение
Boolean
, указывающее на то, явля­
ется ли первый экземпляр
T
меньше или равным второму:
def isort[T](xs: List[T])(lteq: (T, T) => Boolean): List[T] =
if xs.isEmpty then Nil

452 Глава 21 • Гивены else insert(xs.head, isort(xs.tail)(lteq))(lteq)
def insert[T](x: T, xs: List[T])
(lteq: (T, T) => Boolean): List[T] =
if xs.isEmpty || lteq(x, xs.head) then x :: xs else xs.head :: insert(x, xs.tail)(lteq)
Теперь вместо
<=
вспомогательная функция insert использует параметр lteq для сравнения двух элементов во время сортировки. Это позволяет сортиро­
вать список любого типа
T
, главное — предоставить методу isort функцию сравнения, которая подходит для
T
. Например, с помощью этой версии isort можно сортировать списки
Int
,
String и класса
Rational
, представленного в листинге 6.5:
isort(List(4, -10, 10))((x: Int, y: Int) => x <= y)
// List(-10, 4, 10)
isort(List("cherry", "blackberry", "apple", "pear"))
((x: String, y: String) => x.compareTo(y) <= 0)
// List(apple, blackberry, cherry, pear)
isort(List(Rational(7, 8), Rational(5, 6), Rational(1, 2)))
((x: Rational, y: Rational) =>
x.numer * y.denom <= x.denom * y.numer)
// List(1/2, 5/6, 7/8)
Как уже описывалось в разделе 14.10, компилятор Scala последовательно определяет типы параметров в каждом списке, продвигаясь слева направо.
Таким образом, он может определить типы x
и y
, указанные во втором спи­
ске параметров, исходя из типа элемента
T
экземпляра
List[T]
, переданного в первом списке параметров:
isort(List(4, -10, 10))((x, y) => x <= y)
// List(-10, 4, 10)
isort(List("cherry", "blackberry", "apple", "pear"))
((x, y) => x.compareTo(y) < 1)
// List(apple, blackberry, cherry, pear)
isort(List(Rational(7, 8), Rational(5, 6), Rational(1, 2)))
((x, y) => x.numer * y.denom <= x.denom * y.numer)
// List(1/2, 5/6, 7/8)
Теперь функция isort полезна в более общем смысле, однако за эту обоб­
щенность приходится платить потерей лаконичности: при каждом вызове необходимо указывать функцию сравнения, которую определение isort теперь должно передавать каждому рекурсивному вызову isort
, а также


21 .2 . Параметризованные given-типы 453
каждому вызову вспомогательной функции insert
. Эта версия isort больше не является простым выражением сортировки, как прежде.
Вы можете сделать более лаконичной как реализацию метода isort
, так и его вызовы, если оформите функцию сравнения в виде контекстного параметра.
Вы могли бы использовать контекстный параметр
(Int,
Int)
=>
Boolean
, но этот тип слишком общий, что делает его не самым оптимальным решением.
У вашей программы, к примеру, может быть много функций, которые при­
нимают целочисленные параметры и возвращают логическое значение, но при этом не имеют ничего общего с сортировкой. Поскольку поиск given­
значений происходит по типу, вы должны позаботиться о том, чтобы тип вашего given­экземпляра выражал его назначение.
Определение типов с определенным назначением, таким как сортировка, обычно является хорошим решением, но, как упоминалось ранее, некоторые типы становятся особенно полезными при использовании контекстных па­
раметров. Помимо гарантии использования подходящего given­экземпляра, тщательно определенные типы могут помочь вам более ясно выразить ваши намерения. Это позволяет вам развивать ваши программы постепенно, рас­
ширяя типы за счет дополнительного функционала, но не нарушая при этом существующие между ними контракты. Вы можете определить тип, чтобы выбрать, в каком порядке должны размещаться два элемента:
trait Ord[T]:
def compare(x: T, y: T): Int def lteq(x: T, y: T): Boolean = compare(x, y) < 1
Этот трейт реализует функцию «меньше или равно» в виде более общего абстрактного метода compare
. Контракт этого метода состоит в том, что он возвращает
0
, если два параметра равны, положительное целое число, если первый параметр больше второго, и отрицательное целое число, если второй параметр больше первого. Теперь, имея это определение, вы можете указать стратегию сравнения для
T
, используя
Ord[T]
в качестве контекстного пара­
метра, как показано в листинге 21.2.
Листинг 21.2. Контекстные параметры, передаваемые с помощью using def isort[T](xs: List[T])(using ord: Ord[T]): List[T] =
if xs.isEmpty then Nil else insert(xs.head, isort(xs.tail))
def insert[T](x: T, xs: List[T])
(using ord: Ord[T]): List[T] =
if xs.isEmpty || ord.lteq(x, xs.head) then x :: xs else xs.head :: insert(x, xs.tail)

454 Глава 21 • Гивены
Как уже описывалось ранее, чтобы параметры можно было передавать не­
явно, перед ними нужно указать using
. После этого вам больше не нужно предоставлять эти параметры вручную при вызове функции: если доступно значение подходящего типа, компилятор возьмет его и передаст вашей функ­
ции. Чтобы сделать значение given­экземпляром типа, его следует объявить с помощью ключевого слова given
Хорошим местом для размещения given­экземпляров, представляющих
«естественный» вариант использования типа, такой как сортировка целых чисел в порядке возрастания, является объект­компаньон «вовлеченного» типа. Например, естественный given­экземпляр
Ord[Int]
можно было бы разместить в объекте­компаньоне для
Ord или
Int
— двух типов, «фигури­
рующих» в
Ord[Int]
. Если компилятор не найдет given­экземпляр
Ord[Int]
в лексической области видимости, он проведет дополнительный поиск в этих двух объектах­компаньонах. Поскольку компаньон
Int не подлежит изменению, лучшим выбором является компаньон
Ord
:
object Ord:
// (Пока что не является устоявшимся решением)
given intOrd: Ord[Int] =
new Ord[Int]:
def compare(x: Int, y: Int) =
if x == y then 0 else if x > y then 1 else 1
Все примеры given­объявлений, показанные до сих пор в этой главе, на­
зываются псевдонимными (alias). Имя по левую сторону от знака равенства является псевдонимом значения, указанного справа. Поскольку при объяв­
лении псевдонимного given­экземпляра справа от знака равенства зачастую определяют анонимный экземпляр трейта или класса, Scala предлагает со­
кращенный синтаксис, который позволяет подставить вместо знака равен­
ства и «имени нового класса» ключевое слово with
1
. В листинге 21.3 показано более компактное определение intOrd
Листинг 21.3. Объявление естественного given-экземпляра в компаньоне object Ord:
// Общепринятое решение given intOrd: Ord[Int] with def compare(x: Int, y: Int) =
if x == y then 0 else if x > y then 1 else 1 1
Этот способ использования with отличается от того, который был описан в главе 11 и предназначался для объединения трейтов.


21 .3 . Анонимные given-экземпляры 455
Теперь, когда в объекте
Ord имеется given­экземпляр
Ord[Int]
, сортировка с использованием isort снова становится лаконичной:
isort(List(10, 2, -10))
// List(-10, 2, 10)
Если опустить второй параметр isort
, компилятор начнет искать для него заданное значение с учетом его типа. Если речь идет о сортировке значе­
ний
Int
, этим типом будет
Ord[Int]
. Вначале компилятор поищет given­
экземпляр
Ord[Int]
в лексической области видимости, и, если его там не обнаружится, он пройдется по объектам­компаньонам вовлеченных типов
Ord и
Int
. Поскольку в листинге 21.3 заданное значение intOrd имеет явно указанный тип, компилятор подставит intOrd вместо недостающего списка параметров.
Для сортировки строк достаточно предоставить given­экземпляр для пара­
метра, предназначенного для сравнения строковых значений:
// Добавлено в объект Ord given stringOrd: Ord[String] with def compare(s: String, t: String) = s.compareTo(t)
Теперь, когда в компаньоне
Ord определен given­экземпляр
Ord[String]
, вы можете использовать isort для сортировки списков строк:
isort(List("mango", "jackfruit", "durian"))
// List(durian, jackfruit, mango)
Если заданное объявление не принимает параметризованные значения, given­экземпляр инициализируется при первом к нему обращении, что похо­
же на ленивые значения. Эта инициализация проводится потокобезопасным образом. Если же given­экземпляр принимает параметры, он создается зано­
во при каждом обращении, подобно тому как ведет себя def
. Действительно, компилятор Scala преобразует given­экземпляры в val или def
, дополнитель­
но делая их доступными для параметров using
21 .3 . Анонимные given-экземпляры
Заданное объявление можно считать частным случаем ленивого val или def
, однако оно обладает одной важной особенностью. При объявлении val
, к примеру, нужно задать выражение, указывающее на значение val
:
val age = 42

1   ...   43   44   45   46   47   48   49   50   ...   64