Scala. Профессиональное программирование 2022. Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста
Скачать 6.24 Mb.
|
Листинг 12.14. Объект пакета // в файле ShowFruit.scala package bobsdelights def showFruit(fruit: Fruit) = import fruit.* s"${name}s are $color" // в файле PrintMenu.scala package printmenu 262 Глава 12 • Пакеты, импорты и экспорты import bobsdelights.Fruits import bobsdelights.showFruit object PrintMenu: def main(args: Array[String]) = println( for fruit <- Fruits.menu yield showFruit(fruit) ) При наличии этого определения любой другой код в любом пакете может импортировать метод точно так же, как класс. Например, в данном листинге показан самостоятельный объект PrintMenu , который находится в другом пакете. PrintMenu может импортировать вспомогательный метод showFruit так же, как и класс Fruit Забегая вперед, следует отметить, что есть и другие способы использова ния определений верхнего уровня, которые еще не были вам показаны. Эти определения часто применяются для содержания псевдонимов типов, предназначенных для всего пакета (см. главу 20) и методов расширения (см. главу 22). Пакет scala включает определения верхнего уровня, которые доступны всему коду Scala. 12 .7 . Экспорты В разделе 10.11 мы рекомендовали предпочитать композицию, а не наследо вание, особенно если вашей основной целью является повторное использо вание кода. Это применение принципа наименьшей мощности: композиция рассматривает компоненты как «черные ящики», а наследование путем переопределения влияет на их внутреннюю работу. Иногда тесная связь, подразумеваемая наследованием, является лучшим решением проблемы, но там, где в этом нет необходимости, лучше использовать более слабую связь композиции. В большинстве популярных объектноориентированных языков програм мирования проще использовать наследование. Например, в Scala 2 для него требовалось только предложение extends , в то время как композиция требо вала подробного описания последовательности серверов пересылки. Таким образом, подавляющая часть объектноориентированных языков подтал кивала программистов к решению, которое зачастую оказывается слишком мощным. Экспорты в Scala 3 направлены на устранение этого дисбаланса. Эта новая функция помогает выразить отношения композиции так же крат 12 .7 . Экспорты 263 ко и просто, как и отношения наследования. Она обеспечивает большую гибкость, чем директива extends , поскольку в ней можно переименовывать или исключать элементы. В качестве примера представьте, что вы хотите создать тип для представле ния целых положительных чисел. Вы могли бы определить его следующим образом 1 : case class PosInt(value: Int): require(value > 0) Этот класс позволяет указать в типе, что целое число является положитель ным. Однако для выполнения любых арифметических операций с Int в этом коде вам потребуется получить доступ к значению value : val x = PosInt(88) x.value + 1 // 89 Вы можете сделать это удобнее, реализовав метод + на PosInt , который де легируется методу + базового значения value , например, так: case class PosInt(value: Int): require(value > 0) def +(x: Int): Int = value + x С добавлением этого метода пересылки теперь можно выполнять сложение целых чисел в PosInts без необходимости доступа к значению value : val x = PosInt(77) x + 1 // 78 Вы могли бы сделать PosInt еще более удобным, реализовав все методы Int , но их более ста. Если бы вы могли определить PosInt как подкласс Int , вы бы унаследовали все эти методы и вам бы не понадобилась их повторная реализация. Но поскольку Int является окончательным, вы не можете этого сделать. Вот почему класс PosInt должен использовать композицию и деле гирование вместо наследования. В Scala 3 вы можете использовать ключевое слово export для указания нуж ных вам методов пересылки, и компилятор сгенерирует их для вас. Вот как можно создать класс PosInt , который объявляет методы пересылки к соот ветствующим именованным методам базового значения value : 1 Два альтернативных способа создания этого типа, которые позволяют избежать упаковки, — это AnyVals и непрозрачные типы. AnyVals будут рассмотрены в раз деле 17.4. 264 Глава 12 • Пакеты, импорты и экспорты case class PosInt(value: Int): require(value > 0) export value.* С помощью этой конструкции вы можете вызывать любые методы в PosInt , которые объявлены непосредственно на Int : val x = PosInt(99) x + 1 // 100 x — 1 // 98 x / 3 // 33 Директива экспорта создает окончательные методы, называемые экспорт- ными псевдонимами, для каждой перегруженной формы каждого имени экс портируемого метода. Например, метод + , который принимет значение Int , будет иметь такую подпись в PosInt : final def +(x: Int): Int = value + x Вам доступны все различные формы синтаксиса для импорта с экспортом. Например, вы можете не использовать операторы символьного сдвига << , >> и >>> в PosInt : val x = PosInt(24) x << 1 // 48 (shift left) x >> 1 // 12 (shift right) x >>> 1 // 12 (unsigned shift right) У вас есть возможность переименовывать эти операторы при экспорте так же, как и идентификаторы при импорте, — с помощью as . Давайте рассмо трим пример: case class PosInt(value: Int): require(value > 0) export value.{<< as shl, >> as shr, >>> as ushr, *} С учетом этой директивы экспорта операторы сдвига в PosInt больше не будут иметь символьных имен: val x = PosInt(24) x shl 1 // 48 x shr 1 // 12 x ushr 1 // 12 Вы также можете исключить методы из экспорта подстановочных знаков с помощью as _ . Например, сдвиг вправо ( >> ) и беззнаковый сдвиг вправо ( >>> ) всегда дают одинаковый результат для целого положительного числа, Резюме 265 поэтому возможно использование только одного оператора сдвига впра во — shr . Этого можно добиться, опустив оператор >>> с помощью >>> as _ , например, так: case class PosInt(value: Int): require(value > 0) export value.{<< as shl, >> as shr, >>> as _, *} Теперь для метода >>> не создается никакого псевдонима: scala> val x = PosInt(39) val x: PosInt = PosInt(39) scala> x shr 1 val res0: Int = 19 scala> x >>> 1 1 |x >>> 1 |ˆˆˆˆˆ |value >>> is not a member of PosInt Резюме В данной главе мы показали основные конструкции, предназначенные для разбиения программы на пакеты. Благодаря этому вы имеете простую и по лезную разновидность модульности и можете работать с весьма большими объемами кода, не допуская взаимного влияния различных его частей. Существующая в Scala система по духу аналогична пакетированию, ис пользуемому в Java, но с некоторыми отличиями: в Scala проявляется более последовательный или же более универсальный подход. Вы также видели новую функцию, exports , которая призвана сделать композицию такой же удобной, как и наследование, для повторного использования кода. В следующей главе мы переключимся на сопоставление шаблонов. 13 Сопоставление с образцом Данная глава описывает понятия case-классов и сопоставления с образцом (pattern matching) — конструкций, способствующих созданию обычных, неинкапсулированных структур данных. Особенно полезны эти две кон струкции при работе с древовидными рекурсивными данными. Если вам уже приходилось программировать на функциональном языке, то, возможно, сопоставление с образцом вам уже знакомо. А вот понятие case классов будет для вас новым. case классы в Scala позволяют применять сопоставление с образцом к объектам, добавляя к ним лишь ключевое слово case , не требуя при этом большого объема шаблонного кода. Эту главу мы начнем с примера case классов и сопоставления с образцом. Затем разберем все виды поддерживаемых шаблонов, рассмотрим роль за- печатанных классов (sealed classes), обсудим перечисления, Options и пока жем некоторые неочевидные места в языке, где используется сопоставление с образцом, а также более объемный и приближенный к реальному пример его использования. 13 .1 . Простой пример Прежде чем вникать во все правила и нюансы сопоставления с образцом, есть смысл рассмотреть простой пример, дающий общее представление. До пустим, нужно написать библиотеку, которая работает с арифметическими выражениями и, возможно, является частью разрабатываемого предметно ориентированного языка. Первым шагом к решению этой задачи будет определение входных данных. Чтобы ничего не усложнять, сконцентрируемся на арифметических выра 13 .1 . Простой пример 267 жениях, состоящих из переменных, чисел и унарных и бинарных операций. Все это выражается иерархией классов Scala, показанной в листинге 13.1. Листинг 13.1. Определение case-классов 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 Эта иерархия включает трейт Expr с четырьмя подклассами, по одному для каждого вида рассматриваемых выражений. Тела всех пяти классов пусты. case-классы Еще одна особенность объявлений в листинге 13.1 (см. выше), которая за служивает внимания, — наличие у каждого подкласса модификатора case Классы с таким модификатором называются case-классами. Как упоминалось в разделе 4.4, использование модификатора case заставляет компилятор Scala добавлять к вашему классу некоторые синтаксические удобства. Первое заключается в том, что к классу добавляется фабричный метод с именем дан ного класса. Это означает, к примеру, что для создания var объекта можно применить код Var("x") : val v = Var("x") Особенно полезны фабричные методы благодаря их вложенности. Теперь код не загроможден ключевыми словами new , и структуру выражения можно воспринять с одного взгляда: val op = BinOp("+", Num(1), v) Второе синтаксическое удобство заключается в том, что все аргументы в спи ске параметров case класса автоматически получают префикс val , то есть сохраняются в качестве полей: v.name // x op.left // Num(1.0) Третье удобство состоит в том, что компилятор добавляет к вашему классу «естественную» реализацию методов toString , hashCode и equals . Они будут заниматься подготовкой данных к выводу, их хешированием и сравнением всего дерева, состоящего из класса, и (рекурсивно) всех его аргументов. 268 Глава 13 • Сопоставление с образцом Поскольку метод == в Scala всегда передает полномочия методу equals , это означает, что элементы case классов всегда сравниваются структурно: op.toString // BinOp(+,Num(1.0),Var(x)) op.right == Var("x") // true И наконец, чтобы создать измененные копии, компилятор добавляет к ва шему классу метод copy . Он пригодится для создания нового экземпляра класса, аналогичного другому экземпляру, за исключением того, что будет отличаться одним или двумя атрибутами. Метод работает за счет использо вания именованных параметров и параметров по умолчанию (см. раздел 8.8). Применение именованных параметров позволяет указать требуемые измене ния. А для любого неуказанного параметра используется значение из старо го объекта. Посмотрим в качестве примера, как можно создать операцию, похожую на op во всем, кроме того, что будет изменен параметр operator : op.copy(operator = "-") // BinOp(-,Num(1.0),Var(x)) Все эти соглашения в качестве небольшого бонуса придают вашей работе массу удобств. Нужно просто указать модификатор case , и ваши классы и объекты приобретут гораздо более оснащенный вид. Они станут больше за счет создания дополнительных методов и неявного добавления поля для каждого параметра конструктора. Но самым большим преимуществом case классов является то, что они поддерживают сопоставления с образцом 1 Сопоставление с образцом Предположим, нужно упростить арифметические выражения только что представленных видов. Существует множество возможных правил упроще ния. В качестве иллюстрации подойдут три следующих правила: UnOp("-", UnOp("-", е)) => е // двойное отрицание BinOp("+", е, Num(0)) => е // прибавление нуля BinOp("*", е, Num(1)) => е // умножение на единицу Как показано в листинге 13.2, чтобы в Scala сформировать ядро функ ции упрощения с помощью сопоставления с образцом, эти правила можно взять практически в неизменном виде. Показанную в листинге функцию simplifyTop можно использовать следующим образом: simplifyTop(UnOp("-", UnOp("-", Var("x")))) // Var(x) 1 case классы поддерживают сопоставление шаблонов путем создания метода из влечения unapply в объектекомпаньоне. 13 .1 . Простой пример 269 Листинг 13.2. Функция simplifyTop, выполняющая сопоставление с образцом def simplifyTop(expr: Expr): Expr = expr match case UnOp("-", UnOp("-", e)) => e // двойное отрицание case BinOp("+", e, Num(0)) => e // прибавление нуля case BinOp("*", e, Num(1)) => e // умножение на единицу case _ => expr Правая часть simplifyTop состоит из выражения match , которое соответству ет switch в Java, но записывается после выражения выбора. Иными словами, оно выглядит как выбор match { альтернативы } вместо switch (выбор) { альтернативы } Сопоставление с образцом включает последовательность альтернатив, каждая из которых начинается с ключевого слова case . Каждая альтернатива состоит из паттерна и одного или нескольких выражений, которые будут вычислены при соответствии паттерну. Обозначение стрелки => отделяет паттерн от выражений. Выражение match вычисляется проверкой соответствия каждого из пат тернов в порядке их написания. Выбирается первый же соответствующий паттерн, а также выбирается и выполняется та часть, которая следует за обозначением стрелки. Паттерн-константа вида + или 1 соответствует значениям, равным констан те в случае применения метода == . Паттерн-переменная вида e соответствует любому значению. Затем переменная ссылается на это же значение в правой части условия case . Обратите внимание: в данном примере первые три аль тернативы вычисляются в e , то есть в переменную, связанную внутри соот ветствующего паттерна. Подстановочный паттерн ( _ ) также соответствует любому значению, но без представления имени переменной для ссылки на это значение. Стоит отметить, что в листинге 13.2 выражение match закан чивается условием case , которое применяется при отсутствии соответству ющих паттернов и не предполагает никаких действий с выражением. Вместо этого получается просто выражение expr , в отношении которого и выполня ется сопоставление с образцом. Паттерн-конструктор выглядит как UnOp("-", e) . Он соответствует всем значениям типа UnOp , первый аргумент которых соответствует "-" , а второй — e . Обратите внимание: аргументы конструктора сами являются 270 Глава 13 • Сопоставление с образцом паттернами. Это позволяет составлять многоуровневые паттерны, исполь зуя краткую форму записи. Примером может послужить следующий паттерн: UnOp("-", UnOp("-", e)) Представьте попытку реализовать такую же функциональную возможность с помощью шаблона проектирования visitor 1 . Практически так же трудно представить реализацию такой же функциональной возможности в виде длинной последовательности инструкций, проверок соответствия типам и явного приведения типов. Сравнение match со switch Выражения match могут быть представлены в качестве общих случаев switch выражений в стиле Java. В Java switch выражение может быть вполне есте ственно представлено в виде match выражения, где каждый паттерн является константой, а последний паттерн может быть подстановочным (который представлен в switch выражении вариантом, используемым при отсутствии других соответствий). И тем не менее следует учитывать три различия. Вопервых, match является выражением языка Scala, то есть всегда вычисляется в значение. Вовторых, применяемые в Scala выражения альтернатив никогда не «выпадают» в сле дующий вариант. Втретьих, если не найдено соответствие ни одному из паттернов, то выдается исключение MatchError . Следовательно, вам придется всегда обеспечивать охват всех возможных вариантов, даже если это будет означать вариант по умолчанию, в котором не делается ничего. Листинг 13.3. Сопоставление с образцом с пустым вариантом по умолчанию expr match case BinOp(op, left, right) => println(s"$expr является бинарной операцией") case _ => Пример показан в листинге 13.3. Второй необходим, поскольку без него вы ражение match выдаст исключение MatchError для любого expr аргумента, 1 Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Паттерны объектноориентированного проектирования. — СПб.: Питер, 2020. 13 .2 . Разновидности паттернов 271 не являющегося BinOp . В данном примере для этого второго варианта не указан никакой код, поэтому при его срабатывании ничего не произойдет. Результатом в любом случае будет unit значение () , которое также будет результатом вычисления всего выражения match 13 .2 . Разновидности паттернов В предыдущем примере мы кратко описали некоторые разновидности пат тернов. А теперь потратим немного времени на более подробное изучение каждого из них. Синтаксис паттернов довольно прост, поэтому не стоит особо переживать из за него. Все паттерны выглядят точно так же, как и соответствующие им вы ражения. Например, если взять иерархию из листинга 13.1, то паттерн Var(x) соответствует любому выражению, содержащему переменную, с привязкой x к имени переменной. Будучи использованным в качестве выражения, Var(x) с точно таким же синтаксисом воссоздает эквивалентный объект, предпола гая, что идентификатор x уже привязан к имени переменной. В синтаксисе паттернов все прозрачно, поэтому главное, на что следует обратить внима ние, — это какого вида паттерны можно применять. Подстановочные паттерны Подстановочный паттерн ( _ ) соответствует абсолютно любому объекту. Вы уже видели, как он используется в качестве общего паттерна, выявляющего все оставшиеся альтернативы: expr match case BinOp(op, left, right) => s"$expr является бинарной операцией" case _ => // handle the default case s"Это что-то другое" Кроме того, подстановочные паттерны могут использоваться для игнори рования тех частей объекта, которые не представляют для вас интереса. Например, в предыдущем примере нас не интересует, что представляют собой элементы бинарной операции, в нем лишь проверяется, является ли она бинарной. Поэтому, как показано в листинге 13.4, для элементов BinOp в коде также могут использоваться подстановочные паттерны. |