Профессиональное программирование Scala 4-е издание Мартин Одерски Лекс Спун Билл Веннерс 2021 ББК 32.973.2-018.1 УДК 004.43 О-41 Одерски М., Спун Л., Веннерс Б. О-41Scala. Профессиональное программирование. 4-е изд. — СПб.: Питер, 2021. — 720 с.: ил. — (Серия «Библиотека программиста»). ISBN 978-5-4461-1827-4 «Scala. Профессиональное программирование» — главная книга по Scala, популярному языку для платформы Java, в котором сочетаются концепции объектно-ориентированного и функционального программирования, благодаря чему он превращается в уникальное и мощное средство разработки. Этот авторитетный труд, написанный создателями Scala, поможет вам пошагово изучить язык и идеи, лежащие в его основе. Данное четвертое издание полностью обновлено. Добавлен материал об изменениях, появившихся в Scala 2.13, в том числе: • новая иерархия типов коллекций; • новые конкретные типы коллекций; • новые методы, добавленные к коллекциям; • новые способы определять собственные типы коллекций; • новые упрощенные представления. 16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.) ББК 32.973.2-018.1 УДК 004.43 Права на издание получены по соглашению с Artima Inc. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернетресурсы были действующими. ISBN 978-0981531618 англ. ISBN 978-5-4461-1827-4 © 2019 Martin Odersky, Lex Spoon, Bill Venners © Перевод на русский язык ООО Издательство «Питер», 2021 © Издание на русском языке, оформление ООО Издательство «Питер», 2021 © Серия «Библиотека программиста», 2021 Краткое содержание Предисловие............................................................................................................. 21 Благодарности......................................................................................................... 23 Введение................................................................................................................... 27 Глава 1. Масштабируемый язык................................................................................. 33 Глава 2. Первые шаги в Scala..................................................................................... 51 Глава 3. Дальнейшие шаги в Scala............................................................................. 62 Глава 4. Классы и объекты......................................................................................... 81 Глава 5. Основные типы и операции.......................................................................... 93 Глава 6. Функциональные объекты........................................................................... 114 Глава 7. Встроенные управляющие конструкции...................................................... 132 Глава 8. Функции и замыкания................................................................................. 154 Глава 9. Управляющие абстракции........................................................................... 175 Глава 10. Композиция и наследование..................................................................... 187 Глава 11. Иерархия Scala......................................................................................... 210 Глава 12. Трейты..................................................................................................... 220 Глава 13. Пакеты и импорты.................................................................................... 237 Глава 14. Утверждения и тесты................................................................................ 252 Глава 15. Case-классы и сопоставление с образцом................................................. 263 Глава 16. Работа со списками.................................................................................. 292 Глава 17. Работа с другими коллекциями................................................................. 321 Глава 18. Изменяемые объекты................................................................................ 341 Глава 19. Параметризация типов............................................................................. 361 Глава 20. Абстрактные члены.................................................................................. 382 Глава 21. Неявные преобразования и параметры..................................................... 408 Глава 22. Реализация списков.................................................................................. 431 Глава 23. Возвращение к выражениям for................................................................ 442 6 Краткое содержание Глава 24. Углубленное изучение коллекций............................................................. 456 Глава 25. Архитектура коллекций Scala.................................................................... 517 Глава 26. Экстракторы............................................................................................. 546 Глава 27. Аннотации................................................................................................ 560 Глава 28. Работа с XML............................................................................................ 567 Глава 29. Модульное программирование с использованием объектов...................... 579 Глава 30. Равенство объектов.................................................................................. 592 Глава 31. Сочетание кода на Scala и Java................................................................. 613 Глава 32. Фьючерсы и многопоточность................................................................... 627 Глава 33. Синтаксический разбор с помощью комбинаторов.................................... 647 Глава 34. Программирование GUI............................................................................ 673 Глава 35. Электронная таблица SCells...................................................................... 683 Приложение. Скрипты Scala на Unix и Windows....................................................... 705 Глоссарий................................................................................................................ 706 Об авторах.............................................................................................................. 719 Оглавление Предисловие............................................................................................................. 21 Благодарности......................................................................................................... 23 Введение................................................................................................................... 27 Целевая аудитория................................................................................................. 27 Как пользоваться книгой........................................................................................ 27 Как изучать Scala.................................................................................................... 28 Условные обозначения........................................................................................... 28 Обзор глав............................................................................................................. 29 Ресурсы.................................................................................................................. 32 Исходный код......................................................................................................... 32 От издательства..................................................................................................... 32 Глава 1. Масштабируемый язык................................................................................. 33 1.1. Язык, который растет вместе с вами............................................................ 34 Растут новые типы....................................................................................... 35 Растут новые управляющие конструкции..................................................... 36 1.2. Что делает Scala масштабируемым языком................................................... 38 Scala — объектно-ориентированный язык.................................................... 39 Scala — функциональный язык..................................................................... 40 1.3. Почему именно Scala.................................................................................... 42 Scala — совместимый язык........................................................................... 42 Scala — лаконичный язык............................................................................ 43 Scala — язык высокого уровня..................................................................... 44 Scala — статически типизированный язык.................................................... 46 1.4. Истоки Scala................................................................................................. 48 Резюме................................................................................................................... 50 Глава 2. Первые шаги в Scala..................................................................................... 51 2.1. Шаг 1. Осваиваем интерпретатор Scala........................................................ 51 2.2. Шаг 2. Объявляем переменные.................................................................... 53 2.3. Шаг 3. Определяем функции........................................................................ 54 2.4. Шаг 4. Пишем Scala-скрипты........................................................................ 56 2.5. Шаг 5. Организуем цикл с while и принимаем решение с if........................... 57 2.6. Шаг 6. Перебираем элементы с foreach и for................................................ 59 Резюме................................................................................................................... 61 8 Оглавление Глава 3. Дальнейшие шаги в Scala............................................................................. 62 3.1. Шаг 7. Параметризуем массивы типами........................................................ 62 3.2. Шаг 8. Используем списки............................................................................ 66 3.3. Шаг 9. Используем кортежи......................................................................... 69 3.4. Шаг 10. Используем множества и отображения............................................ 70 3.5. Шаг 11. Учимся распознавать функциональный стиль.................................. 74 3.6. Шаг 12. Читаем строки из файла.................................................................. 77 Резюме................................................................................................................... 80 Глава 4. Классы и объекты......................................................................................... 81 4.1. Классы, поля и методы................................................................................ 81 4.2. Когда подразумевается использование точки с запятой............................... 85 4.3. Объекты-одиночки....................................................................................... 86 4.4. Приложение на языке Scala.......................................................................... 89 4.5. Трейт App.................................................................................................... 91 Резюме................................................................................................................... 92 Глава 5. Основные типы и операции.......................................................................... 93 5.1. Некоторые основные типы........................................................................... 93 5.2. Литералы..................................................................................................... 94 Целочисленные литералы............................................................................ 95 Литералы чисел с плавающей точкой.......................................................... 96 Символьные литералы................................................................................. 97 Строковые литералы.................................................................................... 98 Литералы обозначений................................................................................ 98 Булевы литералы......................................................................................... 99 5.3. Интерполяция строк................................................................................... 100 5.4. Все операторы являются методами............................................................ 101 5.5. Арифметические операции......................................................................... 104 5.6. Отношения и логические операции............................................................ 105 5.7. Поразрядные операции.............................................................................. 107 5.8. Равенство объектов................................................................................... 108 5.9. Приоритет и ассоциативность операторов.................................................. 110 5.10. Обогащающие оболочки............................................................................ 112 Резюме................................................................................................................. 113 Глава 6. Функциональные объекты........................................................................... 114 6.1. Спецификация класса Rational.................................................................... 114 6.2. Конструирование класса Rational................................................................ 115 6.3. Переопределение метода toString.............................................................. 116 6.4. Проверка соблюдения предварительных условий....................................... 117 6.5. Добавление полей..................................................................................... 118 Оглавление 9 6.6. Собственные ссылки.................................................................................. 120 6.7. Вспомогательные конструкторы................................................................. 120 6.8. Приватные поля и методы.......................................................................... 122 6.9. Определение операторов........................................................................... 123 6.10. Идентификаторы в Scala............................................................................ 125 6.11. Перегрузка методов................................................................................... 127 6.12. Неявные преобразования........................................................................... 129 6.13. Предостережение...................................................................................... 130 Резюме................................................................................................................. 131 Глава 7. Встроенные управляющие конструкции...................................................... 132 7.1. Выражения if.............................................................................................. 133 7.2. Циклы while............................................................................................... 134 7.3. Выражения for........................................................................................... 136 Обход элементов коллекций...................................................................... 136 Фильтрация............................................................................................... 138 Вложенные итерации................................................................................. 139 Привязки промежуточных переменных...................................................... 139 Создание новой коллекции........................................................................ 140 7.4. Обработка исключений с помощью выражений try..................................... 141 Генерация исключений.............................................................................. 142 Перехват исключений................................................................................ 142 Условие finally............................................................................................ 143 Выдача значения....................................................................................... 144 7.5. Выражения match...................................................................................... 145 7.6. Программирование без break и continue..................................................... 146 7.7. Область видимости переменных................................................................. 148 7.8. Рефакторинг кода, написанного в императивном стиле.............................. 152 Резюме................................................................................................................. 153 Глава 8. Функции и замыкания................................................................................. 154 8.1. Методы...................................................................................................... 154 8.2. Локальные функции................................................................................... 156 8.3. Функции первого класса............................................................................ 157 8.4. Краткие формы функциональных литералов.............................................. 159 8.5. Синтаксис заместителя.............................................................................. 160 8.6. Частично примененные функции................................................................ 161 8.7. Замыкания................................................................................................. 164 8.8. Специальные формы вызова функций........................................................ 167 Повторяющиеся параметры........................................................................ 168 Именованные аргументы............................................................................ 169 Значения параметров по умолчанию.......................................................... 169 10 Оглавление 8.9. Хвостовая рекурсия.................................................................................... 170 Трассировка функций с хвостовой рекурсией............................................. 171 Ограничения хвостовой рекурсии............................................................... 173 Резюме................................................................................................................. 174 Глава 9. Управляющие абстракции........................................................................... 175 9.1. Сокращение повторяемости кода............................................................... 175 9.2. Упрощение клиентского кода..................................................................... 178 9.3. Карринг..................................................................................................... 180 9.4. Создание новых управляющих конструкций............................................... 182 9.5. Передача параметров по имени................................................................. 184 Резюме................................................................................................................. 186 Глава 10. Композиция и наследование..................................................................... 187 10.1. Библиотека двумерной разметки................................................................ 187 10.2. Абстрактные классы................................................................................... 188 10.3. Определяем методы без параметров.......................................................... 189 10.4. Расширяем классы..................................................................................... 191 10.5. Переопределяем методы и поля................................................................. 193 10.6. Определяем параметрические поля........................................................... 194 10.7. Вызываем конструктор суперкласса........................................................... 195 10.8. Используем модификатор override.............................................................. 196 10.9. Полиморфизм и динамическое связывание................................................ 198 10.10. Объявляем финальные элементы............................................................... 200 10.11. Используем композицию и наследование................................................... 201 10.12. Реализуем методы above, beside и toString................................................. 202 10.13. Определяем фабричный объект................................................................. 204 10.14. Методы heighten и widen............................................................................ 207 10.15. Собираем все воедино............................................................................... 208 Резюме................................................................................................................. 209 Глава 11. Иерархия Scala......................................................................................... 210 11.1. Иерархия классов Scala.............................................................................. 210 11.2. Как реализованы примитивы...................................................................... 214 11.3. Низшие типы.............................................................................................. 216 11.4. Определение собственных классов значений............................................. 217 Уход от монокультурности типов................................................................ 218 Резюме................................................................................................................. 219 Глава 12. Трейты..................................................................................................... 220 12.1. Как работают трейты................................................................................. 220 12.2. Сравнение «тонких» и «толстых» интерфейсов.......................................... 223 12.3. Пример: прямоугольные объекты............................................................... 224 Оглавление 11 12.4. Трейт Ordered............................................................................................ 226 12.5. Трейты как наращиваемые модификации................................................... 228 12.6. Почему не используется множественное наследование.............................. 232 12.7. Так все же с трейтами или без?................................................................. 235 Резюме................................................................................................................. 236 Глава 13. Пакеты и импорты.................................................................................... 237 13.1. Помещение кода в пакеты.......................................................................... 237 13.2. Краткая форма доступа к родственному коду............................................. 239 13.3. Импортирование кода................................................................................ 241 13.4. Неявное импортирование........................................................................... 244 13.5. Модификаторы доступа.............................................................................. 245 Приватные члены....................................................................................... 245 Защищенные члены................................................................................... 246 Публичные члены...................................................................................... 246 Область защиты......................................................................................... 246 Видимость и объекты-компаньоны............................................................. 249 13.6. Объекты пакетов........................................................................................ 249 Резюме................................................................................................................. 251 Глава 14. Утверждения и тесты................................................................................ 252 14.1. Утверждения.............................................................................................. 252 14.2. Тестирование в Scala................................................................................. 253 14.3. Информативные отчеты об ошибках.......................................................... 255 14.4. Использование тестов в качестве спецификаций....................................... 256 14.5. Тестирование на основе свойств................................................................ 259 14.6. Подготовка и проведение тестов................................................................ 260 Резюме................................................................................................................. 262 Глава 15. Case-классы и сопоставление с образцом................................................. 263 15.1. Простой пример......................................................................................... 263 Case-классы............................................................................................... 264 Сопоставление с образцом......................................................................... 265 Сравнение match со switch......................................................................... 267 15.2. Разновидности паттернов.......................................................................... 267 Подстановочные паттерны......................................................................... 268 Паттерны-константы.................................................................................. 268 Патерны-переменные................................................................................. 269 Паттерны-конструкторы............................................................................. 271 Паттерны-последовательности................................................................... 271 Паттерны-кортежи..................................................................................... 272 Типизированные паттерны......................................................................... 272 Привязка переменной................................................................................ 275 12 Оглавление 15.3. 15.4. 15.5. 15.6. 15.7. Ограждение образца.................................................................................. 275 Наложение паттернов................................................................................ 276 Запечатанные классы................................................................................. 277 Тип Option.................................................................................................. 279 Паттерны везде.......................................................................................... 281 Паттерны в определениях переменных...................................................... 281 Последовательности вариантов в качестве частично примененных функций............................................................................... 281 Паттерны в выражениях for....................................................................... 284 15.8. Большой пример........................................................................................ 285 Резюме................................................................................................................. 291 Глава 16. Работа со списками.................................................................................. 292 16.1. Литералы списков...................................................................................... 292 16.2. Тип List...................................................................................................... 293 16.3. Создание списков....................................................................................... 293 16.4. Основные операции над списками.............................................................. 294 16.5. Паттерны-списки........................................................................................ 295 16.6. Методы первого порядка класса List.......................................................... 296 Конкатенация двух списков........................................................................ 297 Принцип «разделяй и властвуй»................................................................ 297 Получение длины списка: length................................................................ 299 Обращение к концу списка: init и last......................................................... 299 Реверсирование списков: reverse............................................................... 300 Префиксы и суффиксы: drop, take и splitAt................................................. 301 Выбор элемента: apply и indices................................................................. 301 Линеаризация списка списков: flatten........................................................ 302 Объединение в пары: zip и unzip................................................................ 302 Отображение списков: toString и mkString.................................................. 303 Преобразование списков: iterator, toArray, copyToArray............................... 304 Пример: сортировка слиянием................................................................... 305 16.7. Методы высшего порядка класса List.......................................................... 307 Отображения списков: map, flatMap и foreach............................................ 307 Фильтрация списков: filter, partition, find, takeWhile, dropWhile и span........................................................................................................ 309 Применение предикатов к спискам: forall и exists....................................... 310 Свертка списков: foldLeft и foldRight........................................................... 310 Пример: реверсирование списков с помощью свертки................................ 312 Сортировка списков: sortWith..................................................................... 313 16.8. Методы объекта List................................................................................... 314 Создание списков из их элементов: List.apply............................................. 314 Оглавление 13 Создание диапазона чисел: List.range........................................................ 314 Создание единообразных списков: List.fill.................................................. 315 Табулирование функции: List.tabulate........................................................ 315 Конкатенация нескольких списков: List.concat............................................ 315 16.9. Совместная обработка нескольких списков................................................ 316 16.10. Осмысление имеющегося в Scala алгоритма вывода типов......................... 317 Резюме................................................................................................................. 320 Глава 17. Работа с другими коллекциями................................................................. 321 17.1. Последовательности.................................................................................. 321 Списки....................................................................................................... 321 Массивы..................................................................................................... 322 Буферы списков......................................................................................... 323 Буферы массивов....................................................................................... 324 Строки (реализуемые через StringOps)....................................................... 325 17.2. Множества и отображения......................................................................... 325 Использование множеств........................................................................... 326 Применение отображений.......................................................................... 328 Множества и отображения, используемые по умолчанию........................... 330 Отсортированные множества и отображения............................................. 331 17.3. Выбор между изменяемыми и неизменяемыми коллекциями...................... 332 17.4. Инициализация коллекций......................................................................... 335 Преобразование в массив или список......................................................... 336 Преобразования между изменяемыми и неизменяемыми множествами и отображениями....................................................................................... 337 17.5. Кортежи..................................................................................................... 338 Резюме................................................................................................................. 340 Глава 18. Изменяемые объекты................................................................................ 341 18.1. Что делает объект изменяемым................................................................. 341 18.2. Переназначаемые переменные и свойства................................................. 343 18.3. Практический пример: моделирование дискретных событий...................... 346 18.4. Язык для цифровых схем........................................................................... 347 18.5. API моделирования.................................................................................... 350 18.6. Моделирование электронной логической схемы......................................... 354 Класс Wire.................................................................................................. 355 Метод inverter............................................................................................ 356 Методы andGate и orGate........................................................................... 357 Вывод симуляции....................................................................................... 358 Запуск симулятора..................................................................................... 358 Резюме................................................................................................................. 360 14 Оглавление Глава 19. Параметризация типов............................................................................. 361 19.1. Функциональные очереди.......................................................................... 361 19.2. Скрытие информации................................................................................. 364 Приватные конструкторы и фабричные методы......................................... 364 Альтернативный вариант: приватные классы............................................. 366 19.3. Аннотации вариантности............................................................................ 367 Вариантность и массивы............................................................................ 369 19.4. Проверка аннотаций вариантности............................................................ 371 19.5. Нижние ограничители................................................................................ 373 19.6. Контравариантность.................................................................................. 375 19.7. Приватные данные объекта........................................................................ 378 19.8. Верхние ограничители............................................................................... 379 Резюме................................................................................................................. 381 Глава 20. Абстрактные члены.................................................................................. 382 20.1. Краткий обзор абстрактных членов............................................................ 382 20.2. Члены-типы............................................................................................... 383 20.3. Абстрактные val-переменные..................................................................... 383 20.4. Абстрактные var-переменные..................................................................... 384 20.5. Инициализация абстрактных val-переменных............................................. 385 Предынициализируемые поля.................................................................... 387 Ленивые val-переменные........................................................................... 389 20.6. Абстрактные типы...................................................................................... 392 20.7. Типы, зависящие от пути........................................................................... 394 20.8. Уточняющие типы...................................................................................... 396 20.9. Перечисления............................................................................................ 397 20.10. Практический пример: работа с валютой................................................... 399 Резюме................................................................................................................. 407 Глава 21. Неявные преобразования и параметры..................................................... 408 21.1. Неявные преобразования........................................................................... 408 21.2. Правила для неявных преобразований....................................................... 411 Названия неявных преобразований............................................................ 413 Где применяются неявные преобразования............................................... 413 21.3. Неявное преобразование в ожидаемый тип............................................... 414 21.4. Преобразование получателя...................................................................... 415 Взаимодействие с новыми типами.............................................................. 415 Имитация нового синтаксиса...................................................................... 417 Неявные классы......................................................................................... 418 21.5. Неявные параметры................................................................................... 419 Правило стиля для неявных параметров.................................................... 423 Оглавление 15 21.6. Контекстные ограничители........................................................................ 424 21.7. Когда применяются множественные преобразования................................. 426 21.8. Отладка неявных преобразований............................................................. 428 Резюме................................................................................................................. 430 Глава 22. Реализация списков.................................................................................. 431 22.1. Принципиальный взгляд на класс List........................................................ 431 Объект Nil.................................................................................................. 432 Класс ::...................................................................................................... 433 Еще несколько методов.............................................................................. 434 Создание списка........................................................................................ 434 22.2. Класс ListBuffer.......................................................................................... 436 22.3. Класс List на практике................................................................................ 438 22.4. Внешняя функциональность....................................................................... 440 Резюме................................................................................................................. 441 Глава 23. Возвращение к выражениям for................................................................ 442 23.1. Выражения for........................................................................................... 443 23.2. Задача N ферзей........................................................................................ 444 23.3. Выполнение запросов с помощью выражений for....................................... 447 23.4. Трансляция выражений for......................................................................... 448 Трансляция выражений for с одним генератором....................................... 448 Трансляция выражений for, начинающихся с генератора и фильтра........... 449 Трансляция выражений for, начинающихся с двух генераторов.................. 449 Трансляция паттернов в генераторах......................................................... 450 Трансляция определений........................................................................... 451 Трансляция, применяемая для циклов....................................................... 452 23.5. «Мы пойдем другим путем»....................................................................... 452 23.6. Обобщение for........................................................................................... 453 Резюме................................................................................................................. 455 Глава 24. Углубленное изучение коллекций............................................................. 456 24.1. Изменяемые и неизменяемые коллекции................................................... 457 24.2. Согласованность коллекций....................................................................... 459 24.3. Трейт Iterable............................................................................................. 460 Подкатегории Iterable................................................................................ 467 24.4. Трейты последовательностей Seq, IndexedSeq и LinearSeq......................... 467 Буферы...................................................................................................... 471 24.5. Множества................................................................................................. 473 24.6. Отображения............................................................................................. 476 24.7. Конкретные классы неизменяемых коллекций............................................ 481 Списки....................................................................................................... 481 16 Оглавление Ленивые списки......................................................................................... 481 Неизменяемые ArraySeq............................................................................. 482 Векторы..................................................................................................... 482 Неизменяемые очереди.............................................................................. 484 Диапазоны................................................................................................. 484 Сжатые коллекции HAMT........................................................................... 485 Красно-черные деревья............................................................................. 486 Неизменяемые битовые множества............................................................ 486 Векторные отображения............................................................................ 487 Списочные отображения............................................................................ 487 24.8. Конкретные классы изменяемых коллекций............................................... 488 Буферы массивов....................................................................................... 488 Буферы списков......................................................................................... 488 Построители строк..................................................................................... 489 ArrayDeque................................................................................................. 489 Очереди..................................................................................................... 489 Стеки......................................................................................................... 490 Изменяемые ArraySeq................................................................................. 491 Хеш-таблицы.............................................................................................. 491 Слабые хеш-отображения.......................................................................... 492 Совместно используемые отображения...................................................... 492 Изменяемые битовые множества................................................................ 493 24.9. Массивы..................................................................................................... 493 24.10. Строки....................................................................................................... 498 24.11. Характеристики производительности......................................................... 498 24.12. Равенство.................................................................................................. 501 24.13. Представления........................................................................................... 502 24.14. Итераторы................................................................................................. 505 Буферизованные итераторы....................................................................... 511 24.15. Создание коллекций с нуля........................................................................ 512 24.16. Преобразования между коллекциями Java и Scala...................................... 514 Резюме................................................................................................................. 516 Глава 25. Архитектура коллекций Scala.................................................................... 517 25.1. Исключение общих операций..................................................................... 517 Абстрагирование типов коллекций............................................................. 519 Управление строгостью.............................................................................. 522 Когда строгое вычисление предпочтительно или неизбежно..................... 524 25.2. Интеграция новых коллекций..................................................................... 525 Ограниченные последовательности........................................................... 525 Оглавление 17 Последовательности РНК........................................................................... 531 Префиксные отображения.......................................................................... 540 Краткие выводы......................................................................................... 545 Резюме................................................................................................................. 545 Глава 26. Экстракторы............................................................................................. 546 26.1. Пример извлечения адресов электронной почты........................................ 546 26.2. Экстракторы............................................................................................... 547 26.3. Паттерны без переменных или с одной переменной................................... 550 26.4. Экстракторы переменного количества аргументов..................................... 551 26.5. Экстракторы и паттерны последовательностей.......................................... 554 26.6. Сравнение экстракторов и case-классов..................................................... 554 26.7. Регулярные выражения.............................................................................. 556 Формирование регулярных выражений...................................................... 556 Поиск регулярных выражений.................................................................... 557 Извлечение с помощью регулярных выражений......................................... 558 Резюме................................................................................................................. 559 Глава 27. Аннотации................................................................................................ 560 27.1. Зачем нужны аннотации............................................................................ 560 27.2. Синтаксис аннотаций................................................................................. 561 27.3. Стандартные аннотации............................................................................. 563 Устаревание............................................................................................... 563 Непостоянные поля.................................................................................... 564 Двоичная сериализация............................................................................. 564 Автоматические методы get и set............................................................... 565 Хвостовая рекурсия.................................................................................... 565 Unchecked.................................................................................................. 566 Native-методы............................................................................................ 566 Резюме................................................................................................................. 566 Глава 28. Работа с XML............................................................................................ 567 28.1. Слабоструктурированные данные.............................................................. 567 28.2. Краткий обзор XML..................................................................................... 568 28.3. Литералы XML............................................................................................ 569 28.4. Сериализация............................................................................................ 571 28.5. Разбор XML................................................................................................ 572 28.6. Десериализация......................................................................................... 574 28.7. Загрузка и сохранение............................................................................... 574 28.8. Сопоставление с образцом XML.................................................................. 575 Резюме................................................................................................................. 578 18 Оглавление Глава 29. Модульное программирование с использованием объектов...................... 579 29.1. Суть проблемы........................................................................................... 580 29.2. Приложение для работы с рецептами........................................................ 581 29.3. Абстракция................................................................................................ 584 29.4. Разбиение модулей на трейты.................................................................... 587 29.5. Компоновка во время выполнения............................................................. 588 29.6. Отслеживание экземпляров модулей......................................................... 590 Резюме................................................................................................................. 591 Глава 30. Равенство объектов.................................................................................. 592 30.1. Понятие равенства в Scala......................................................................... 592 30.2. Написание метода равенства..................................................................... 593 Ловушка № 1. Определение equals с неверной сигнатурой........................ 594 Ловушка № 2. Изменение equals без изменения hashCode......................... 596 Ловушка № 3. Определение equals в терминах изменяемых полей............ 597 Ловушка № 4. Ошибка в определении equals как отношения эквивалентности........................................................................................ 598 30.3. Определение равенства для параметризованных типов............................. 605 30.4. Рецепты для equals и hashCode.................................................................. 608 Рецепт для equals...................................................................................... 609 Рецепт для hashCode.................................................................................. 611 Резюме................................................................................................................. 612 Глава 31. Сочетание кода на Scala и Java................................................................. 613 31.1. Использование Scala из Java...................................................................... 613 Основные правила..................................................................................... 613 Типы значений........................................................................................... 614 Объекты-одиночки..................................................................................... 614 Трейты в качестве интерфейсов................................................................ 615 31.2. Аннотации.................................................................................................. 616 Дополнительные эффекты от стандартных аннотаций............................... 616 Сгенерированные исключения................................................................... 616 Аннотации Java.......................................................................................... 618 Написание собственных аннотаций............................................................ 619 31.3. Подстановочные типы................................................................................ 620 31.4. Совместная компиляция Scala и Java.......................................................... 622 31.5. Интеграция Java 8...................................................................................... 623 Лямбда-выражения и SAM-типы................................................................. 623 Использование Stream-объектов Java 8 из Scala......................................... 625 Резюме................................................................................................................. 626 Оглавление 19 Глава 32. Фьючерсы и многопоточность................................................................... 627 32.1. Неприятности в раю................................................................................... 627 32.2. Асинхронное выполнение и Try.................................................................. 629 32.3. Работа с фьючерсами................................................................................. 631 Преобразование фьючерсов с помощью map............................................. 631 Преобразование фьючерсов с помощью выражений for............................. 632 Создание фьючерса: Future.failed, Future.successful, Future.fromTry и Promise................................................................................................... 633 Фильтрация: filter и collect.......................................................................... 634 Обработка сбоев: failed, fallBackTo, recover и recoverWith........................... 635 Отображение обеих возможностей: transform............................................ 638 Объединение фьючерсов: zip, Future.foldLeft, Future.reduceLeft, Future.sequence и Future.traverse................................................................ 639 Получение побочных эффектов: foreach, onComplete и andThen................ 641 Другие методы, добавленные в версии 2.12: flatten, zipWith и transformWith.......................................................................................... 642 32.4. Тестирование с помощью фьючерсов......................................................... 644 Резюме................................................................................................................. 646 Глава 33. Синтаксический разбор с помощью комбинаторов.................................... 647 33.1. Пример: арифметические выражения........................................................ 648 33.2. Запуск парсера.......................................................................................... 650 33.3. Основные парсеры — регулярные выражения............................................ 651 33.4. Еще один пример: JSON............................................................................. 651 33.5. Вывод парсера........................................................................................... 653 Сравнение символьных и буквенно-цифровых имен................................... 657 33.6. Реализация комбинаторов парсеров........................................................... 658 Входные данные парсера........................................................................... 659 Результаты парсера................................................................................... 660 Класс Parser............................................................................................... 661 Псевдонимы this......................................................................................... 661 Парсеры отдельных токенов...................................................................... 662 Последовательная композиция.................................................................. 663 Альтернативная композиция...................................................................... 664 Работа с рекурсией.................................................................................... 664 Преобразование результата....................................................................... 665 Парсеры, не считывающие данных............................................................. 665 Option и повторение................................................................................... 665 33.7. Строковые литералы и регулярные выражения.......................................... 666 33.8. Лексинг и парсинг...................................................................................... 667 20 Оглавление 33.9. Сообщения об ошибках.............................................................................. 667 33.10. Сравнение отката с LL(1)........................................................................... 669 Резюме................................................................................................................. 671 Глава 34. Программирование GUI............................................................................ 673 34.1. Первое Swing-приложение......................................................................... 673 34.2. Панели и разметки..................................................................................... 675 34.3. Обработка событий.................................................................................... 677 34.4. Пример: программа перевода градусов между шкалами Цельсия и Фаренгейта........................................................................................................ 680 Резюме................................................................................................................. 682 Глава 35. Электронная таблица SCells...................................................................... 683 35.1. Визуальная среда....................................................................................... 683 35.2. Разделение введенных данных и отображения.......................................... 686 35.3. Формулы.................................................................................................... 689 35.4. Синтаксический разбор формул................................................................. 690 35.5. Вычисление............................................................................................... 694 35.6. Библиотеки операций................................................................................ 697 35.7. Распространение изменений...................................................................... 699 Резюме................................................................................................................. 702 Приложение. Скрипты Scala на Unix и Windows....................................................... 705 Глоссарий................................................................................................................ 706 Об авторах.............................................................................................................. 719 Предисловие Когда в 2004 году, на заре своей карьеры, я сел и выбрал малоизвестный язык под названием Scala, мне и в голову не могло прийти, что впереди меня ждет множество открытий. Поначалу мой опыт использования Scala во многом напоминал работу с другими языками: пробы, ошибки, эксперименты и открытия, заблуждения и на­ конец просветление. В те дни у нас было очень мало источников информации: никаких учебных пособий, блогов или опытных пользователей, желавших по­ делиться знаниями. А уж таких ресурсов, как эта книга, у нас точно не было. Все, что мы имели, — язык с прекрасным набором новых инструментов, которыми еще никто не умел как следует пользоваться. Это открывало новые горизонты и в то же время приводило в замешательство! С опытом программирования на Java у меня сформировались определенные ожидания, однако мои ощущения от повседневно­ го написания кода на Scala были другими. Я помню свои первые приключения со Scala, когда мы вместе еще с одним человеком работали над небольшим проектом. Решив заняться рефакторингом (ввиду регулярного обнаружения и изучения но­ вых возможностей и методик это случалось довольно часто), я проходил несколько этапов компиляции. Каждый раз компилятор выдавал мне список ошибок вместе с номерами строк. И всякий раз я переходил к нужному участку кода, пытался понять, что с ним не так, вносил изменения, чтобы исправить ошибки (или, скорее, переместить их в другое место). Но компилятор раз за разом направлял меня прямо к источнику проблемы. Иногда это могло повторяться на протяжении нескольких дней без еди­ ной успешной компиляции. Но, постепенно уменьшив количество ошибок с сотни до десяти, а затем и до нуля, я наконец впервые смог скомпилировать и запустить существенно переработанный проект. И, вопреки ожиданиям, он заработал с первого раза. Я был молодым программи­ стом, который до этого писал только на Java, Perl, Pascal, BASIC, PHP и JavaScript, и это произвело на меня неизгладимое впечатление. На первой конференции Scala World, которую я организовал в 2015 году, во вступительной презентации Рунара Бьярнасона (Ru' nar Bjarnason) была озвучена следующая мысль: «Ограничения освобождают, а свободы сковывают». Нагляднее всего это демонстрирует процесс компиляции в Scala: понимание того, что scalac накладывает исчерпывающий набор ограничений, не позволяющих программисту допускать предсказуемые ошибки времени выполнения (хуже которых ничего нет), должно давать ощущение свободы любому программисту. Оно должно придавать уверенность, а это, в свою очередь, позволяет экспериментировать, 22 Предисловие исследовать и вносить амбициозные изменения даже при отсутствии полноценного набора тестов. С тех пор я продолжаю изучать Scala и даже сегодня открываю для себя но­ вые возможности, нюансы и интересные сочетания разных функций. Я не знаю ни одного другого языка, способного увлекать своей глубиной на протяжении стольких лет. Scala стоит на пороге больших изменений. Следующая версия, 3, будет таким же большим шагом вперед по сравнению со Scala 2, как тот, который я сделал 15 лет назад, когда перешел с Java. Повседневное программирование на Scala во многом останется прежним, но при этом мы получим доступ к широкому спектру новых возможностей, охватывающих все аспекты данного языка. На момент написания этих строк широкое внедрение Scala 3 ожидается лишь через несколько лет, и в ближайшем будущем Scala 2 останется фактически стан­ дартной версией языка. В последнем выпуске Scala 2 (версия 2.13), подробно рассматриваемом в данной книге, в стандартной библиотеке появились новые, переработанные и упрощенные коллекции, вобравшие в себя множество уроков, усвоенных с момента последнего крупного изменения (Scala 2.8). Эти новые коллекции поддерживают компиля­ цию в Scala 2 и 3, формируя основу для большей части кода, который мы будем писать еще и в грядущем десятилетии. Учитывая то, какой интерес у людей вы­ зывает следующий выпуск Scala, самое время обзавестись этой книгой и начать ее изучать! Джон Претти (Jon Pretty), Краков, Польша, 14 сентября 2019 года Благодарности Мы благодарны за вклад в эту книгу многим людям. Сам язык Scala — плод усилий множества специалистов. Свой вклад в про­ ектирование и реализацию версии 1.0 внесли Филипп Альтер (Philippe Altherr), Винсент Кремет (Vincent Cremet), Жиль Дюбоше (Gilles Dubochet), Бурак Эмир (Burak Emir), Стефан Мишель (Ste' phane Micheloud), Николай Михайлов (Nikolay Mihaylov), Мишель Шинц (Michel Schinz), Эрик Стенман (Erik Stenman) и Матиас Зенгер (Matthias Zenger). К разработке второй и текущей версий языка, а также ин­ струментальных средств подключились Фил Багвел (Phil Bagwell), Антонио Куней (Antonio Cunei), Юлиан Драгос (Iulian Dragos), Жиль Дюбоше (Gilles Dubochet), Мигель Гарсиа (Miguel Garcia), Филипп Халлер (Philipp Haller), Шон Макдирмид (Sean McDirmid), Инго Майер (Ingo Maier), Донна Малайери (Donna Malayeri), Адриан Мурс (Adriaan Moors), Хуберт Плоциничак (Hubert Plociniczak), Пол Филлипс (Paul Phillips), Александр Прокопец (Aleksandar Prokopec), Тиарк Ромпф (Tiark Rompf), Лукас Рыц (Lukas Rytz) и Джеффри Уошберн (Geoffrey Washburn). Следует также упомянуть тех, кто участвовал в работе над структурой языка. Эти люди любезно делились с нами своими идеями в оживленных и вдохновля­ ющих дискуссиях, вносили важные фрагменты кода в открытую разработку и дела­ ли весьма ценные замечания по поводу предыдущих версий. Это Гилад Браха (Gilad Bracha), Натан Бронсон (Nathan Bronson), Коаюан (Caoyuan), Эймон Кэннон (Aemon Cannon), Крейг Чамберс (Craig Chambers), Крис Конрад (Chris Conrad), Эрик Эрнст (Erik Ernst), Матиас Феллизен (Matthias Felleisen), Марк Харра (Mark Harrah), Шрирам Кришнамурти (Shriram Krishnamurti), Гэри Ливенс (Gary Leavens), Дэвид Макивер (David MacIver), Себастьян Манит (Sebastian Maneth), Рикард Нильссон (Rickard Nilsson), Эрик Мейер (Erik Meijer), Лалит Пант (Lalit Pant), Дэвид Поллак (David Pollak), Джон Претти (Jon Pretty), Клаус Остерман (Klaus Ostermann), Хорхе Ортис (Jorge Ortiz), Дидье Реми (Didier Rémy), Майлз Сабин (Miles Sabin), Виджей Сарасват (Vijay Saraswat), Даниэль Спивак (Daniel Spiewak), Джеймс Страчан (James Strachan), Дон Симе (Don Syme), Эрик Торре­ бор (Erik Torreborre), Мэдс Торгерсен (Mads Torgersen), Филип Уодлер (Philip Wadler), Джейми Уэбб (Jamie Webb), Джон Уильямс (John Williams), Кевин Райт (Kevin Wright) и Джейсон Зауг (Jason Zaugg). Очень полезные отзывы, которые помогли нам улучшить язык и его инструментальные средства, были получены от людей, подписанных на наши рассылки по Scala. Джордж Бергер (George Berger) усердно работал над тем, чтобы процесс соз­ дания и размещения книги в Интернете протекал гладко. Как результат, в данном проекте не было никаких технических сбоев. 24 Благодарности Ценные отзывы о начальных вариантах текста книги были получены нами от многих людей. Наших благодарностей заслуживают Эрик Армстронг (Eric Armstrong), Джордж Бергер (George Berger), Алекс Блевитт (Alex Blewitt), Гилад Браха (Gilad Bracha), Уильям Кук (William Cook), Брюс Экель (Bruce Eckel), Стефан Мишель (Ste' phane Micheloud), Тод Мильштейн (Todd Millstein), Дэвид Поллак (David Pollak), Фрэнк Соммерс (Frank Sommers), Филип Уодлер (Philip Wadler) и Матиас Зенгер (Matthias Zenger). Спасибо также представителям Silicon Valley Patterns group за их весьма полезный обзор. Это Дейв Астелс (Dave Astels), Трейси Бялик (Tracy Bialik), Джон Брюер (John Brewer), Эндрю Чейз (Andrew Chase), Брэдфорд Кросс (Bradford Cross), Рауль Дюк (Raoul Duke), Джон П. Эйрих (John P. Eurich), Стивен Ганц (Steven Ganz), Фил Гудвин (Phil Goodwin), Ральф Йочем (Ralph Jocham), Ян-Фа Ли (Yan-Fa Li), Тао Ма (Tao Ma), Джеффри Миллер (Jeffery Miller), Суреш Пай (Suresh Pai), Русс Руфер (Russ Rufer), Дэйв У. Смит (Dave W. Smith), Скотт Торнквест (Scott Turnquest), Вальтер Ваннини (Walter Vannini), Дарлин Уоллах (Darlene Wallach) и Джонатан Эндрю Уолтер (Jonathan Andrew Wolter). Кроме того, хочется поблагодарить Дуэйна Джонсона (Dewayne Johnson) и Кима Лиди (Kim Leedy) за помощь в художественном оформлении об­ ложки, а также Фрэнка Соммерса (Frank Sommers) — за работу над алфавитным указателем. Хотелось бы выразить особую благодарность и всем нашим читателям, при­ славшим комментарии. Они нам очень пригодились для повышения качества книги. Мы не в состоянии опубликовать имена всех приславших комментарии, но объявим имена тех читателей, кто прислал не менее пяти комментариев на стадии eBook PrePrint ™, воспользовавшись ссылкой Suggest. Отсортируем их имена по убыванию количества комментариев, а затем в алфавитном порядке. Наших благодарностей заслуживают Дэвид Бизак (David Biesack), Дон Стефан (Donn Stephan), Матс Хенриксон (Mats Henricson), Роб Диккенс (Rob Dickens), Блэр Захак (Blair Zajac), Тони Слоан (Tony Sloane), Найджел Харрисон (Nigel Harrison), Хавьер Диас Сото (Javier Diaz Soto), Уильям Хелан (William Heelan), Джастин Фурдер (Justin Forder), Грегор Перди (Gregor Purdy), Колин Перкинс (Colin Perkins), Бьярте С. Карлсен (Bjarte S. Karlsen), Эрвин Варга (Ervin Varga), Эрик Уиллигерс (Eric Willigers), Марк Хейс (Mark Hayes), Мартин Элвин (Martin Elwin), Калум Маклин (Calum MacLean), Джонатан Уолтер (Jonathan Wolter), Лес Прушински (Les Pruszynski), Сет Тисуе (Seth Tisue), Андрей Формига (Andrei Formiga), Дмитрий Григорьев (Dmitry Grigoriev), Джордж Бергер (George Berger), Говард Ловетт (Howard Lovatt), Джон П. Эйрих (John P. Eurich), Мариус Скур­ теску (Marius Scurtescu), Джефф Эрвин (Jeff Ervin), Джейми Уэбб (Jamie Webb), Курт Зольман (Kurt Zoglmann), Дин Уэмплер (Dean Wampler), Николай Линд­ берг (Nikolaj Lindberg), Питер Маклейн (Peter McLain), Аркадиуш Стрыйски (Arkadiusz Stryjski), Шанки Сурана (Shanky Surana), Крейг Бордолон (Craig Bordelon), Александр Пэтри (Alexandre Patry), Филип Моэнс (Filip Moens), Фред Янон (Fred Janon), Джефф Хеон (Jeff Heon), Борис Лорбер (Boris Lorbeer), Джим Менар (Jim Menard), Тим Аццопарди (Tim Azzopardi), Томас Юнг (Thomas Jung), Уолтер Чанг (Walter Chang), Йерун Дийкмейер (Jeroen Dijkmeijer), Кейси Боу­ Благодарности 25 мен (Casey Bowman), Мартин Смит (Martin Smith), Ричард Даллауэй (Richard Dallaway), Антони Стаббс (Antony Stubbs), Ларс Вестергрен (Lars Westergren), Маартен Хазевинкель (Maarten Hazewinkel), Мэтт Рассел (Matt Russell), Ремигиус Михаловский (Remigiusz Michalowski), Андрей Толопко (Andrew Tolopko), Кертис Стэнфорд (Curtis Stanford), Джошуа Каш (Joshua Cough), Земен Денг (Zemian Deng), Кристофер Родригес Масиас (Christopher Rodrigues Macias), Хуан Мигель Гарсия Лопес (Juan Miguel Garcia Lopez), Мишель Шинц (Michel Schinz), Питер Мур (Peter Moore), Рэндолф Кале (Randolph Kahle), Владимир Кельман (Vladimir Kelman), Даниэль Гронау (Daniel Gronau), Дирк Детеринг (Dirk Detering), Хироа­ ки Накамура (Hiroaki Nakamura), Оле Хогаард (Ole Hougaard), Бхаскар Маддала (Bhaskar Maddala), Дэвид Бернар (David Bernard), Дерек Махар (Derek Mahar), Джордж Коллиас (George Kollias), Кристиан Нордал (Kristian Nordal), Нормен Мюллер (Normen Mueller), Рафаэль Феррейра (Rafael Ferreira), Бинил Томас (Binil Thomas), Джон Нильсон (John Nilsson), Хорхе Ортис (Jorge Ortiz), Маркус Шульте (Marcus Schulte), Вадим Герасимов (Vadim Gerasimov), Камерон Таггарт (Cameron Taggart), Джон-Андерс Тейген (Jon-Anders Teigen), Сильвестр Забала (Silvestre Zabala), Уилл Маккуин (Will McQueen) и Сэм Оуэн (Sam Owen). Хочется также сказать спасибо тем, кто отправил сообщения о замеченных неточностях после публикации первых двух изданий. Это Феликс Зигрист (Felix Siegrist), Лотар Мейер-Лербс (Lothar Meyer-Lerbs), Диетард Михаэлис (Diethard Michaelis), Рошан Даврани (Roshan Dawrani), Донн Стефан (Donn Stephan), Уильям Утер (William Uther), Франсиско Ревербель (Francisco Reverbel), Джим Балтер (Jim Balter), Фрик де Брюйн (Freek de Bruijn), Амброз Лэнг (Ambrose Laing), Сехар Прабхала (Sekhar Prabhala), Левон Салдамли (Levon Saldamli), Эндрю Бурсавич (Andrew Bursavich), Хьялмар Петерс (Hjalmar Peters), Томас Фер (Thomas Fehr), Ален О’Ди (Alain O’Dea), Роб Диккенс (Rob Dickens), Тим Тейлор (Tim Taylor), Кристиан Штернагель (Christian Sternagel), Мишель Пари­ зьен (Michel Parisien), Джоэл Нили (Joel Neely), Брайан МакКеон (Brian McKeon), Томас Фер (Thomas Fehr), Джозеф Эллиотт (Joseph Elliott), Габриэль да Силва Рибейро (Gabriel da Silva Ribeiro), Томас Фер (Thomas Fehr), Пабло Рипольес (Pablo Ripolles), Дуглас Гейлор (Douglas Gaylor), Кевин Сквайр (Kevin Squire), Гарри-Антон Талвик (Harry-Anton Talvik), Кристофер Симпкинс (Christopher Simpkins), Мартин Витман-Функ (Martin Witmann-Funk), Джим Балтер (Jim Balter), Питер Фостер (Peter Foster), Крейг Бордолон (Craig Bordelon), ХайнцПитер Гум (Heinz-Peter Gumm), Питер Чапин (Peter Chapin), Кевин Райт (Kevin Wright), Анантан Сринивасан (Ananthan Srinivasan), Омар Килани (Omar Kilani), Дон Стефан (Donn Stephan), Гюнтер Ваффлер (Guenther Waffler). Лекс хотел бы поблагодарить специалистов, среди которых Аарон Абрамс (Aaron Abrams), Джейсон Адамс (Jason Adams), Генри и Эмили Крутчер (Henry and Emily Crutcher), Джои Гибсон (Joey Gibson), Гунар Хиллерт (Gunnar Hillert), Мэтью Линк (Matthew Link), Тоби Рейлтс (Toby Reyelts), Джейсон Снейп (Jason Snape), Джон и Мелинда Уэзерс (John and Melinda Weathers), и всех представителей Atlanta Scala Enthusiasts за множество полезных обсуждений структуры языка, его математиче­ ских основ и способов представления языка Scala специалистам-практикам. 26 Благодарности Особую благодарность хочется выразить Дэйву Брикчетти (Dave Briccetti) и Адриану Мурсу (Adriaan Moors) за рецензирование третьего издания, а также Маркони Ланна (Marconi Lanna) не только за рецензирование, но и за мотивацию выпустить третье издание, которая возникла после разговора о новинках, появив­ шихся со времени выхода предыдущего издания. Билл хотел бы поблагодарить нескольких специалистов за предоставленную ин­ формацию и советы по изданию книги. Его благодарность заслужили Гэри Корнелл (Gary Cornell), Грег Доенч (Greg Doench), Энди Хант (Andy Hunt), Майк Леонард (Mike Leonard), Тайлер Ортман (Tyler Ortman), Билл Поллок (Bill Pollock), Дейв Томас (Dave Thomas) и Адам Райт (Adam Wright). Билл также хотел бы поблаго­ дарить Дика Уолла (Dick Wall) за сотрудничество над разработкой нашего курса Stairway to Scala, который большей частью основывался на материалах, вошедших в эту книгу. Наш многолетний опыт преподавания курса Stairway to Scala помог повысить ее качество. И наконец, Билл хотел бы выразить благодарность Дарлин Грюндль (Darlene Gruendl) и Саманте Вулф (Samantha Woolf) за помощь в завер­ шении третьего издания. Наконец, мы хотели бы поблагодарить Жюльена Ричарда-Фоя за работу над обновлением четвертого издания этой книги до версии Scala 2.13, в частности за перепроектирование библиотеки коллекций. Введение Эта книга — руководство по Scala, созданное людьми, непосредственно занимающи­ мися разработкой данного языка программирования. Нашей целью было научить вас всему, что необходимо для превращения в продуктивного программиста на языке Scala. Все примеры в книге компилируются Scala версии 2.13.1. Целевая аудитория Книга в основном рассчитана на программистов, желающих научиться программи­ ровать на Scala. Если у вас есть желание создать свой следующий проект на этом языке, то наша книга вам подходит. Кроме того, она должна заинтересовать про­ граммистов, которые хотят расширить кругозор, изучив новые концепции. Если вы, к примеру, программируете на Java, то эта книга раскроет для вас множество кон­ цепций функционального программирования, а также передовых мыслей из сфе­ ры объектно-ориентированного программирования. Мы уверены: изучение Scala и заложенных в этот язык идей поможет вам повысить свой профессиональный уровень как программиста. Предполагается, что вы уже владеете общими знаниями в области программирования. Scala вполне подходит на роль первого изучаемого языка, однако это не та книга, которая может использоваться для обучения про­ граммированию. В то же время вам не нужно быть каким-то особенным знатоком языков программирования. Большинство людей использует Scala на платформе Java, однако наша книга не предполагает, что вы тесно знакомы с языком Java. Но все же мы ожидаем, что Java известен многим читателям, и поэтому иногда сравниваем оба языка, чтобы помочь таким читателям понять разницу. Как пользоваться книгой Книгу рекомендуется читать в порядке следования глав, от начала до конца. Мы очень старались в каждой главе вводить читателя в курс только одной темы и объяснять новый материал лишь в понятиях из ранее рассмотренных тем. По­ этому если перескочить вперед, чтобы поскорее в чем-то разобраться, то можно встретить объяснения, в которых используются еще непонятные концепции. Мы считаем, что получать знания в области программирования на Scala лучше посте­ пенно, читая главы в порядке их следования. 28 Введение Обнаружив незнакомое понятие, можно обратиться к глоссарию. Многие чита­ тели бегло просматривают части книги, и это вполне нормально. Но при встрече с непонятными терминами можно выяснить, что просмотр был слишком поверх­ ностным, и вернуться к справочным материалам. Прочитав книгу, вы можете в дальнейшем использовать ее в качестве справоч­ ника по Scala. Конечно, существует официальная спецификация языка, но в ней прослеживается стремление к точности в ущерб удобству чтения. В нашем издании не охвачены абсолютно все подробности Scala, но его особенности изложены в ней вполне обстоятельно. Так что по мере освоения программирования на этом языке издание вполне может стать доступным справочником. Как изучать Scala Весьма обширные познания о Scala можно получить, просто прочитав книгу от на­ чала до конца. Но быстрее и основательнее освоить язык можно с помощью ряда дополнительных действий. Прежде всего можно воспользоваться множеством примеров программ, вклю­ ченных в эту книгу. Самостоятельно набирая их, вам придется вдумываться в каждую строку кода. Попытки его разнообразить позволят вам глубже заинте­ ресоваться изучаемой темой и убедиться в том, что вы действительно поняли, как работает этот код. Затем можно поучаствовать в работе множества онлайн-форумов. Это позво­ лит вам и многим другим приверженцам Scala помочь друг другу в его освоении. Есть множество рассылок, дискуссионных форумов, чатов, вики-источников и не­ сколько информационных каналов, которые посвящены этому языку и содержат соответствующие публикации. Уделите время поиску источников информации, более всего отвечающих вашим запросам. Вы будете гораздо быстрее решать мел­ кие проблемы, что позволит уделять больше времени более серьезным и глубоким вопросам. И наконец, получив при чтении книги достаточный объем знаний, приступайте к разработке собственного проекта. Поработайте с нуля над созданием какой-ни­ будь небольшой программы или разработайте дополнение к более объемной. Вы не добьетесь быстрых результатов одним только чтением. Условные обозначения При первом упоминании какого-либо понятия или термина его название дается курсивом. Для небольших встроенных в текст примеров кода, таких как x + 1, ис­ пользуется моноширинный шрифт. Большие примеры кода представлены в виде отдельных блоков, для которых тоже используется моноширинный шрифт: Обзор глав 29 def hello() = { println("Hello, world!") } Когда показывается работа с интерактивной оболочкой, ответы последней вы­ деляются шрифтом на сером фоне: scala> 3 + 4 res0: Int = 7 Обзор глав Глава 1 «Масштабируемый язык» представляет обзор структуры языка Scala, а также ее логическое обоснование и историю. Глава 2 «Первые шаги в Scala» показывает, как в языке выполняется ряд основ­ ных задач программирования, не вдаваясь в подробности, касающиеся особен­ ностей работы механизмов языка. Цель этой главы — заставить ваши пальцы набирать и запускать код на Scala. Глава 3 «Дальнейшие шаги в Scala» показывает ряд основных задач программи­ рования, помогающих ускорить освоение этого языка. Изучив данную главу, вы сможете использовать Scala для автоматизации простых задач. Глава 4 «Классы и объекты» закладывает начало углубленного рассмотрения языка Scala, приводит описание его основных объектно-ориентированных строительных блоков и указания по выполнению компиляции и запуску при­ ложений Scala. Глава 5 «Основные типы и операции» охватывает основные типы Scala, их литералы, операции, которые могут над ними проводиться, вопросы работы уровней приоритета и ассоциативности и дает представление об обогащающих оболочках. Глава 6 «Функциональные объекты» углубляет представление об объектно- ориентированных свойствах Scala, используя в качестве примера функциональ­ ные (то есть неизменяемые) рациональные числа. Глава 7 «Встроенные управляющие конструкции» показывает способы исполь­ зования таких конструкций Scala, как if, while, for, try и match. Глава 8 «Функции и замыкания» углубленно рассматривает функции как основные строительные блоки функциональных языков. Глава 9 «Управляющие абстракции» показывает, как усовершенствовать основ­ ные управляющие конструкции Scala с помощью определения собственных управляющих абстракций. Глава 10 «Композиция и наследование» рассматривает имеющуюся в Scala дополнительную поддержку объектно-ориентированного программирования. 30 Введение Затрагиваемые темы не столь фундаментальны, как те, что излагались в гла­ ве 4, но вопросы, которые в них рассматриваются, часто встречаются на прак­ тике. Глава 11 «Иерархия Scala» объясняет иерархию наследования языка и рассма­ тривает универсальные методы и низшие типы. Глава 12 «Трейты» охватывает существующий в Scala механизм создания композиции примесей. Показана работа трейтов, описываются примеры их наиболее частого использования и объясняется, как с помощью трейтов совер­ шенствуется традиционное множественное наследование. Глава 13 «Пакеты и импорты» рассматривает вопросы программирования в целом, включая высокоуровневые пакеты, инструкции импортирования и мо­ дификаторы управления доступом, такие как protected и private. Глава 14 «Утверждения и тесты» показывает существующий в Scala механизм утверждений и дает обзор ряда инструментов, доступных для написания тестов в этом языке. Основное внимание уделено средству ScalaTest. Глава 15 «Case-классы и сопоставление с образцом» вводит двойные конструк­ ции, поддерживающие ваши действия при написании обычных, неинкапсу­ лированных структур данных. Case-классы и сопоставление с образцом будут особенно полезны при работе с древовидными рекурсивными данными. Глава 16 «Работа со списками» подробно рассматривает списки, которые, веро­ ятно, можно отнести к самым востребованным структурам данных в программах на Scala. Глава 17 «Работа с другими коллекциями» показывает способы использования основных коллекций Scala, таких как списки, массивы, кортежи, наборы и ото­ бражения. Глава 18 «Изменяемые объекты» объясняет суть изменяемых объектов и син­ таксиса для выражения этих объектов, обеспечиваемого Scala. Глава заверша­ ется практическим примером моделирования дискретного события, в котором показан ряд изменяемых объектов в действии. Глава 19 «Параметризация типов» на конкретных примерах объясняет не­ которые из технологических приемов скрытия информации, представленных в главе 13, например создание класса для функциональных запросов. Материа­ лы главы подводят к описанию вариантности параметров типа и способов их взаимодействия с скрытием информации. Глава 20 «Абстрактные члены» дает описание всех разновидностей поддер­ живаемых Scala абстрактных членов — не только методов, но и полей и типов, которые можно объявлять абстрактными. Глава 21 «Неявные преобразования и параметры» рассматривает две конструк­ ции, которые могут помочь избавить исходный код от излишней детализации, позволяя предоставить ее самому компилятору. Обзор глав 31 Глава 22 «Реализация списков» описывает реализацию класса List. В ней рас­ сматривается работа списков в Scala, в которой важно разбираться. Кроме того, реализация списков показывает, как используется ряд характерных особенно­ стей этого языка. Глава 23 «Возвращение к выражениям for» показывает, как последние превра­ щаются в вызовы методов map, flatMap, filter и foreach. Глава 24 «Углубленное изучение коллекций» предлагает углубленный обзор библиотеки коллекций. Глава 25 «Архитектура коллекций Scala» показывает устройство библиотеки коллекций и то, как можно реализовывать собственные коллекции. Глава 26 «Экстракторы» демонстрирует способы применения поиска по шабло­ нам в отношении не только case-классов, но и любых произвольных классов. Глава 27 «Аннотации» показывает, как можно работать с расширениями языка с помощью аннотаций. Описывает несколько стандартных аннотаций и описы­ вает способы создания собственных аннотаций. Глава 28 «Работа с XML» объясняет порядок обработки в Scala соответству­ ющих данных. Показаны идиомы для создания XML, проведения синтаксиче­ ского анализа данных в этом формате и их обработки после анализа. Глава 29 «Модульное программирование с использованием объектов» показы­ вает способы применения объектов Scala в качестве модульной системы. Глава 30 «Равенство объектов» освещает ряд проблемных вопросов, которые возникают при написании метода equals. Вдобавок рассматривается ряд узких мест, которые следует обходить стороной. Глава 31 «Сочетание кода на Scala и Java» рассматривает проблемы, возникающие при сочетании совместно используемых кодов на Scala и Java в одном и том же проекте, и предлагает способы, позволяющие находить решения таких проблем. Глава 32 «Фьючерсы и многопоточность» описывает способы применения класса Future. Для программ на Scala могут использоваться примитивы много­ поточных вычислений и библиотеки, применяемые на Java-платформе. Однако фьючерсы помогут избежать возникновения условий взаимных блокировок и состояний гонки, которые мешают реализовывать традиционные подходы к многопоточности в виде потоков и блокировок. Глава 33 «Синтаксический разбор с помощью комбинаторов» показывает способы создания парсеров с использованием имеющейся в Scala библиотеки парсер-комбинаторов. Глава 34 «Программирование GUI» предлагает краткий обзор библиотеки Scala, упрощающей этот вид программирования с помощью среды Swing. Глава 35 «Электронная таблица SCells» связывает все воедино, показывая полнофункциональное приложение электронной таблицы, написанное на языке Scala. 32 Введение Ресурсы Самые последние версии Scala, ссылки на документацию и ресурсы сообщества можно найти на сайте www.scala-lang.org. Исходный код Исходный код, рассматриваемый в данной книге, выпущенный под открытой лицензией в виде ZIP-файла, можно найти на сайте книги: booksites.artima.com/ programming_in_scala_4ed. От издательства Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (из­ дательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах. 1 Масштабируемый язык Scala означает «масштабируемый язык» (от англ. scalable language). Это название он получил, поскольку был спроектирован так, чтобы расти вместе с запросами своих пользователей. Язык Scala может решать широкий круг задач программиро­ вания: от написания небольших скриптов до создания больших систем1. Освоить Scala нетрудно. Он запускается на стандартной Java-платформе и пол­ ноценно взаимодействует со всеми Java-библиотеками. Язык очень хорошо под­ ходит для написания скриптов, объединяющих Java-компоненты. Но его сильные стороны проявляются еще лучше при создании больших систем и сред повторно используемых компонентов. С технической точки зрения Scala — смесь объектно-ориентированной и функ­ циональной концепций программирования в статически типизированном языке. Подобный сплав проявляется во многих аспектах Scala — он, вероятно, может считаться более всеобъемлющим, чем другие широко используемые языки. Когда дело доходит до масштабируемости, два стиля программирования дополняют друг друга. Используемые в Scala конструкции функционального программирования упрощают быстрое создание интересных компонентов из простых частей. Объ­ ектно-ориентированные конструкции же облегчают структурирование больших систем и их адаптацию к новым требованиям. Сочетание двух стилей в Scala позволяет создавать новые виды шаблонов программирования и абстракций компонентов. Оно также способствует выработке понятного и лаконичного стиля программирования. И благодаря такой гибкости языка программирование на Scala может принести массу удовольствия. В этой вступительной главе мы отвечаем на вопрос: «Почему именно Scala?» Мы даем общий обзор структуры Scala и ее обоснование. Прочитав главу, вы получите базовое представление о том, что такое Scala и с какого рода задачами он поможет справиться. Книга представляет собой руководство по языку Scala, однако данную главу нельзя считать частью этого руководства. И если вам не тер­ пится приступить к написанию кода на Scala, то можете сразу перейти к изучению главы 2. 1 Scala произносится как «ска' ла». 34 Глава 1 • Масштабируемый язык 1.1. Язык, который растет вместе с вами Программы различных размеров требуют, как правило, использования различных программных конструкций. Рассмотрим, к примеру, следующую небольшую про­ грамму на Scala: var capital = Map("US" -> "Washington", "France" -> "Paris") capital += ("Japan" -> "Tokyo") println(capital("France")) Эта программа устанавливает отображение стран на их столицы, модифици­ рует отображение, добавляя новую конструкцию ("Japan" -> "Tokyo"), и выводит название столицы, связанное со страной France1. В этом примере используется настолько высокоуровневая система записи, что она не загромождена ненужными точками с запятыми и сигнатурами типов. И действительно возникает ощущение использования современного языка скриптов наподобие Perl, Python или Ruby. Одна из общих характеристик этих языков, применимая к данному примеру, — поддержка всеми ими в синтаксисе языка конструкции ассоциативного отобра­ жения. Ассоциативные отображения очень полезны, поскольку помогают поддерживать понятность и краткость программ, но порой вам может не подойти их философия «на все случаи жизни», поскольку вам в своей программе нужно управлять свой­ ствами отображений более тонко. При необходимости Scala обеспечивает точное управление, поскольку отображения в нем не являются синтаксисом языка. Это библиотечные абстракции, которые можно расширять и приспосабливать под свои нужды. В показанной ранее программе вы получите исходную реализацию отображе­ ния Map, но ее можно будет без особого труда изменить. К примеру, можно указать конкретную реализацию, такую как HashMap или TreeMap, или вызвать метод par для получения отображения ParMap, операции в котором выполняются параллель­ но. Можно указать для отображения значение по умолчанию или переопределить любой другой метод созданного вами отображения. Во всех случаях для отобра­ жений вполне пригоден такой же простой синтаксис доступа, как и в показанном примере. В нем показано, что Scala может обеспечить вам как удобство, так и гибкость. Язык содержит набор удобных конструкций, которые помогают быстро начать ра­ боту и позволяют программировать в приятном лаконичном стиле. В то же время есть гарантии, что вы не сможете перерасти язык. Вы всегда сможете перекроить программу под свои требования, поскольку все в ней основано на библиотечных модулях, которые можно выбрать и приспособить под свои нужды. 1 Пожалуйста, не сердитесь на нас, если не сможете разобраться со всеми тонкостями этой программы. Объяснения будут даны в двух следующих главах. 1.1. Язык, который растет вместе с вами 35 Растут новые типы Эрик Рэймонд (Eric Raymond) в качестве двух метафор разработки программных продуктов ввел собор и базар1. Под собором понимается почти идеальная разра­ ботка, создание которой требует много времени. После сборки она долго остается неизменной. Разработчики же базара, напротив, что-то адаптируют и дополняют каждый день. В книге Рэймонда базар — метафора, описывающая разработку ПО с открытым кодом. Гай Стил (Guy Steele) отметил в докладе о «растущем языке», что аналогичное различие можно применить к структуре языка программирова­ ния2. Scala больше похож на базар, чем на собор, в том смысле, что спроектирован с расчетом на расширение и адаптацию его теми, кто на нем программирует. Вместо того чтобы предоставлять все конструкции, которые только могут пригодиться в одном всеобъемлющем языке, Scala дает вам инструменты для создания таких конструкций. Рассмотрим пример. Многие приложения нуждаются в целочисленном типе, который при выполнении арифметических операций может становиться произ­ вольно большим без переполнения или циклического перехода в начало. В Scala такой тип определяется в библиотеке класса scala.math.BigInt. Определение использующего этот тип метода, который вычисляет факториал переданного ему целочисленного значения, имеет следующий вид3: def factorial(x: BigInt): BigInt = if (x == 0) 1 else x * factorial(x - 1) Теперь, вызвав factorial(30), вы получите: 265252859812191058636308480000000 Тип BigInt похож на встроенный, поскольку со значениями этого типа можно использовать целочисленные литералы и операторы наподобие * и –. Тем не ме­ нее это просто класс, определение которого задано в стандартной библиотеке Scala4. Если бы класса не было, то любой программист на Scala мог бы запросто написать его реализацию, например создав оболочку для имеющегося в языке Java класса java.math.BigInteger (фактически именно так и реализован класс BigInt в Scala). 1 Raymond E. The Cathedral & the Bazaar: Musings on Linux and Open Source by an Accidental Revolutionary. — O’Reilly, 1999. 2 Steele Jr., Guy L. Growing a Language // Higher-Order and Symbolic Computation, 12:221–223, 1999. Транскрипция речи, произнесенной на OOPSLA в 1998 году. 3 factorial(x), или x! в математической записи, — результат вычисления 1 × 2 … x, где для 0! определено значение 1. 4 Scala поставляется со стандартной библиотекой, часть которой будет рассмотрена в книге. За дополнительной информацией можно обратиться к имеющейся в библио­ теке документации Scaladoc, доступной в дистрибутиве и в Интернете по адресу www.scala-lang.org. 36 Глава 1 • Масштабируемый язык Конечно же, класс Java можно использовать напрямую. Но результат будет не столь приятным: хоть Java и позволяет вам создавать новые типы, они не про­ изводят впечатление получающих естественную поддержку языка: import java.math.BigInteger def factorial(x: BigInteger): BigInteger = if (x == BigInteger.ZERO) BigInteger.ONE else x.multiply(factorial(x.subtract(BigInteger.ONE))) Тип BigInt — один из многих других числовых типов: больших десятичных чисел, комплексных и рациональных чисел, доверительных интервалов, поли­ номов, — и данный список можно продолжить. В некоторых языках программи­ рования часть этих типов реализуется естественным образом. Например, в Lisp, Haskell и Python есть большие целые числа, в Fortran и Python — комплексные. Но любой язык, в котором пытаются одновременно реализовать все эти абстракции, разрастается до таких размеров, что становится неуправляемым. Более того, даже существуй подобный язык, нашлись бы приложения, требующие других числовых типов, которые все равно не были бы представлены. Следовательно, подход, при котором предпринимается попытка реализовать все в одном языке, не позволяет получить хорошую масштабируемость. Язык Scala, напротив, дает пользователям возможность наращивать и адаптировать его в нужных направлениях. Он делает это с помощью определения простых в использовании библиотек, которые произ­ водят впечатление средств, естественно реализованных в языке. Растут новые управляющие конструкции Предыдущий пример показывает, что Scala позволяет добавлять новые типы, ис­ пользовать которые так же удобно, как и встроенные. Тот же принцип расширения применяется и к управляющим конструкциям. Этот вид расширения иллюстри­ руется с помощью Akka — API Scala для многопоточного программирования, основанного на использовании акторов. Поскольку распространение многоядерных процессоров в ближайшие годы продолжится, то для достижения приемлемой производительности все чаще мо­ жет требоваться более высокая степень распараллеливания ваших приложений. Зачастую это чревато переписыванием кода с целью распределить вычисления по нескольким параллельным потокам. К сожалению, на практике создание надеж­ ных многопоточных приложений — весьма непростая задача. Модель потоковой обработки в Java выстроена вокруг совместно используемой памяти и блокиро­ вок и часто трудно поддается осмыслению, особенно по мере масштабирования систем и увеличения их объема и сложности. Обеспечить отсутствие состояния гонок или скрытых взаимных блокировок, не выявляемых в ходе тестирования, но способных проявиться в ходе эксплуатации, довольно трудно. Возможно, бо­ лее безопасным альтернативным решением станет использование архитектуры 1.1. Язык, который растет вместе с вами 37 передачи сообщений, подобной применению акторов в языке программирова­ ния Erlang. Java поставляется с богатой библиотекой параллельных вычислений, основан­ ной на применении потоков. Программы на Scala могут задействовать ее точно так же, как и любой другой API Java. Но есть еще Akka — дополнительная библиотека Scala, реализующая модель акторов, похожую на ту, что используется в Erlang. Акторы — абстракции параллельных вычислений, которые могут быть реализо­ ваны в качестве надстроек над потоками. Они обмениваются данными, отправляя друг другу сообщения. Актор может выполнить две основные операции: отправку сообщения и его получение. Операция отправки, обозначаемая восклицательным знаком (!), отправляет сообщение актору. Пример, в котором фигурирует актор с именем recipient, выглядит следующим образом: recipient ! msg Отправка осуществляется в асинхронном режиме, то есть отправляющий со­ общение актор может продолжить выполнение кода, не дожидаясь получения и обработки сообщения. У каждого актора имеется почтовый ящик, в котором входящие сообщения выстраиваются в очередь. Актор обрабатывает сообщения, поступающие в его почтовый ящик, в блоке получения receive: def receive = { case Msg1 => ... // обработка Msg1 case Msg2 => ... // обработка Msg2 // ... } Блок receive состоит из нескольких инструкций выбора case, в каждой из ко­ торых содержится запрос к почтовому ящику на соответствие шаблону сообщения. В ящике выбирается первое же сообщение, которое соответствует условию какойлибо инструкции выбора, и в отношении него выполняется какое-либо действие. Когда в почтовом ящике не оказывается сообщений, актор приостанавливает работу и ожидает их дальнейшего поступления. В качестве примера рассмотрим простой Akka-актор, реализующий сервис под­ счета контрольной суммы: class ChecksumActor extends Actor { var sum = 0 def receive = { case Data(byte) => sum += byte case GetChecksum(requester) => val checksum = ~(sum & 0xFF) + 1 requester ! checksum } } Сначала в акторе определяется локальная переменная sum с начальным нулевым значением. Затем — блок получения receive, который будет обрабатывать сообще­ ния. При получении сообщения Data он прибавляет значение содержащегося в нем аргумента byte к значению переменной sum. При получении сообщения GetChecksum 38 Глава 1 • Масштабируемый язык он вычисляет контрольную сумму из текущего значения переменной sum и от­ правляет результат обратно в адрес пославшего запрос requester, используя код отправки сообщения requester ! checksum. Поле requester отправляется в сообще­ нии GetChecksum, обычно оно ссылается на актор, пославший запрос. Мы не ожидаем, что прямо сейчас вы полностью поймете пример использования актора. Скорее, существенным обстоятельством для темы масштабируемости в этом примере служит тот факт, что ни блок receive, ни инструкция отправки сообще­ ния (!) не являются встроенными операциями Scala. Даже притом, что внешний вид и работа блока receive во многом напоминают встроенную управляющую конструкцию, на самом деле это метод, определенный в библиотеке акторов Akka. Аналогично даже притом, что оператор ! похож на встроенный оператор, он также является всего лишь методом, определенным в библиотеке акторов Akka. Обе кон­ струкции абсолютно независимы от языка программирования Scala. Синтаксис блока receive и оператора отправки (!) выглядят в Scala во многом так же, как в Erlang, но в последнем эти конструкции встроены в язык. Кроме того, в Akka реализуется большинство других конструкций многопоточного програм­ мирования, имеющихся в Erlang, например конструкции отслеживания сбойных акторов и истечения времени ожидания. В целом модель акторов показала себя весьма удачным средством для выражения многопоточных и распределенных вы­ числений. Несмотря на то что акторы должны быть определены в библиотеке, они могут рассматриваться как составная часть языка Scala. Пример иллюстрирует возможность наращивания языка Scala в новых направ­ лениях, даже в таких специализированных, как программирование многопоточных приложений. Разумеется, для этого понадобятся квалифицированные разработчи­ ки и программисты. Но важен сам факт наличия такой возможности: вы можете проектировать и реализовывать в Scala абстракции, которые относятся к принци­ пиально новым прикладным областям, но при использовании воспринимаются как естественно поддерживаемые самим языком. 1.2. Что делает Scala масштабируемым языком На возможность масштабирования влияет множество факторов, от особенностей синтаксиса до структуры абстрактных компонентов. Но если бы потребовалось на­ звать всего один аспект Scala, который способствует масштабируемости, то мы бы выбрали присущее этому языку сочетание объектно-ориентированного и функ­ ционального программирования (мы немного слукавили, на самом деле это два аспекта, но они взаимосвязаны). Scala в объединении объектно-ориентированного и функционального програм­ мирования в однородную структуру языка пошел дальше всех остальных широко известных языков. Например, там, где в других языках объекты и функции — два разных понятия, в Scala функция по смыслу является объектом. Функциональные типы являются классами, которые могут наследоваться подклассами. Эти особен­ ности могут показаться не более чем теоретическими, но имеют весьма серьезные 1.2. Что делает Scala масштабируемым языком 39 последствия для возможностей масштабирования. Фактически ранее упомянутое понятие актора не может быть реализовано без этой унификации функций и объ­ ектов. В данном разделе дается обзор примененных в Scala способов смешивания объектно-ориентированной и функциональной концепций. Scala — объектно-ориентированный язык Развитие объектно-ориентированного программирования шло весьма успешно. Появившись в языке Simula в середине 1960-х годов и в Smalltalk в 1970-х, оно теперь доступно в подавляющем большинстве языков. В некоторых областях все полностью захвачено объектами. Точного определения «объектной ориентирован­ ности» нет, однако объекты явно чем-то привлекают программистов. В принципе, мотивация для применения объектно-ориентированного програм­ мирования очень проста: все, за исключением самых простых программ, нуждается в определенной структуре. Наиболее понятный путь достижения желаемого резуль­ тата заключается в помещении данных и операций в своеобразные контейнеры. Основной замысел объектно-ориентированного программирования состоит в при­ дании этим контейнерам полной универсальности, чтобы в них могли содержаться не только операции, но и данные и чтобы сами они также были элементами, которые могли бы храниться в других контейнерах или передаваться операциям в качестве параметров. Подобные контейнеры называются объектами. Алан Кей (Alan Kay), изобретатель языка Smalltalk, заметил, что таким образом простейший объект имеет принцип построения, аналогичный полноценному компьютеру: под формализован­ ным интерфейсом данные в нем сочетаются с операциями1. То есть объекты имеют непосредственное отношение к масштабируемости языка: одни и те же технологии применяются к построению как малых, так и больших программ. Хотя долгое время объектно-ориентированное программирование преобладало, немногие языки стали последователями Smalltalk по части внедрения этого прин­ ципа построения в свое логическое решение. Например, множество языков допу­ скают использование элементов, не являющихся объектами, — можно вспомнить имеющиеся в языке Java значения примитивных типов. Или же в них допускается применение статических полей и методов, не входящих в какой-либо объект. Эти отклонения от чистой идеи объектно-ориентированного программирования на первый взгляд выглядят вполне безобидными, но имеют досадную тенденцию к усложнению и ограничению масштабирования. В отличие от этого, Scala — объектно-ориентированный язык в чистом виде: каж­ дое значение является объектом и каждая операция — вызовом метода. Например, когда в Scala речь заходит о вычислении 1 + 2, фактически вызывается метод по имени +, который определен в классе Int. Можно определять методы с именами, похожими на операторы, а клиенты вашего API смогут с помощью этих методов записать 1 Kay A. C. The Early History of Smalltalk // History of programming languages. — II, P. 511–598. — New York: ACM, 1996 [Электронный ресурс]. — Режим доступа: http:// doi.acm.org/10.1145/234286.1057828. 40 Глава 1 • Масштабируемый язык операторы. Именно так API акторов Akka позволяет вам воспользоваться выражени­ ем requester ! checksum, показанным в предыдущем примере: ! — метод класса Actor. Когда речь заходит о составлении объектов, Scala проявляется как более совер­ шенный язык по сравнению с большинством других. В качестве примера приведем имеющиеся в Scala трейты. Они подобны интерфейсам в Java, но могут содержать также реализации методов и даже поля1. Объекты создаются путем композиции примесей, при котором к членам класса добавляются члены нескольких трейтов. Таким образом, различные аспекты классов могут быть инкапсулированы в раз­ личных трейтах. Это выглядит как множественное наследование, но есть разница в конкретных деталях. В отличие от класса трейт может добавить в суперкласс новые функциональные возможности. Это придает трейтам более высокую степень подключаемости по сравнению с классами. В частности, благодаря этому удается избежать возникновения присущих множественному наследованию классических проблем «ромбовидного» наследования, которые возникают, когда один и тот же класс наследуется по нескольким различным путям. Scala — функциональный язык Наряду с тем, что Scala является чистым объектно-ориентированным языком, его можно назвать и полноценным функциональным языком. Идеи функционального программирования старше электронных вычислительных систем. Их основы были заложены в лямбда-исчислении Алонзо Чёрча (Alonzo Church), разработанном в 1930-е годы. Первым языком функционального программирования был Lisp, появление которого датируется концом 1950-х. К другим популярным функцио­ нальным языкам относятся Scheme, SML, Erlang, Haskell, OCaml и F#. Долгое время функциональное программирование играло второстепенные роли — будучи популярным в научных кругах, оно не столь широко использовалось в промышлен­ ности. Но в последние годы интерес к его языкам и технологиям растет. Функциональное программирование базируется на двух основных идеях. Первая заключается в том, что функции являются значениями первого класса. В функцио­ нальных языках функция есть значение, имеющее такой же статус, как целое число или строка. Функции можно передавать в качестве аргументов другим функциям, возвращать их в качестве результатов из других функций или сохранять в перемен­ ных. Вдобавок функцию можно определять внутри другой функции точно так же, как это делается при определении внутри функции целочисленного значения. И функции можно определять, не присваивая им имен, добавляя в код функцио­ нальные литералы с такой же легкостью, как и целочисленные, наподобие 42. Функции как значения первого класса — удобное средство абстрагирования, касающееся операций и создания новых управляющих конструкций. Эта универ­ сальность функций обеспечивает более высокую степень выразительности, что зачастую приводит к созданию весьма разборчивых и кратких программ. Она также 1 Начиная с Java 8, у интерфейсов могут быть реализации методов по умолчанию, но они не предлагают всех тех возможностей, которые есть у трейтов языка Scala. 1.2. Что делает Scala масштабируемым языком 41 играет важную роль в обеспечении масштабируемости. В качестве примера библио­ тека тестирования ScalaTest предлагает конструкцию eventually, получающую функцию в качестве аргумента. Данная конструкция используется следующим образом: val xs = 1 to 3 val it = xs.iterator eventually { it.next() shouldBe 3 } Код внутри eventually, являющийся утверждением, it.next() shouldBe 3, вклю­ чает в себя функцию, передаваемую невыполненной в метод eventually. Через на­ страиваемый период времени eventually станет неоднократно выполнять функцию до тех пор, пока утверждение не будет успешно подтверждено. В отличие от этого в большинстве традиционных языков функции не являются значениями. Языки, в которых есть функциональные значения, относят их статус ко второму классу. Например, указатели на функции в C и C++ не имеют в этих языках такого же статуса, как другие значения, не относящиеся к функциям: указатели на функции могут только ссылаться на глобальные функции, не позволяя определять вложенные функции первого класса, имеющие некоторые значения в их окружении. Они также не позволяют определять литералы безымянных функций. Вторая основная идея функционального программирования заключается в том, что операции программы должны преобразовать входные значения в выходные, а не изменять данные на месте. Чтобы понять разницу, рассмотрим реализацию строк в Ruby и Java. В Ruby строка является массивом символов. Символы в строке могут быть изменены по отдельности. Например, внутри одного и того же строково­ го объекта символ точки с запятой в строке можно заменить точкой. А в Java и Scala строка — последовательность символов в математическом смысле. Замена символа в строке с использованием выражения вида s.replace(';', '.') приводит к воз­ никновению нового строкового объекта, отличающегося от s. То же самое можно сказать по-другому: в Java строки неизменяемые, а в Ruby — изменяемые. То есть, рассматривая только строки, можно прийти к выводу, что Java — функциональный язык, а Ruby — нет. Неизменяемая структура данных — один из краеугольных камней функционального программирования. В библиотеках Scala в качестве надстроек над соответствующими API Java определяется также множество других неизменяемых типов данных. Например, в Scala имеются неизменяемые списки, кортежи, отображения и множества. Еще один способ утверждения второй идеи функционального программиро­ вания заключается в том, что у методов не должно быть никаких побочных эффектов. Они должны обмениваться данными со своим окружением только путем получения аргументов и возвращения результатов. Например, под это описание подпадает метод replace, принадлежащий Java-классу String. Он получает строку и два символа и выдает новую строку, где все появления одного символа заменены появлениями второго. Других эффектов от вызова replace нет. Методы, подобные replace, называются ссылочно прозрачными. Это значит, что для любого заданного ввода вызов функции можно заменить его результатом, при этом семантика про­ граммы остается неизменной. 42 Глава 1 • Масштабируемый язык Функциональные языки заставляют применять неизменяемые структуры дан­ ных и ссылочно прозрачные методы. В некоторых функциональных языках это выражено в виде категоричных требований. Scala же дает возможность выбрать. При желании можно писать программы в императивном стиле — так называется программирование с изменяемыми данными и побочными эффектами. Но при необходимости в большинстве случаев Scala позволяет с легкостью избежать использования императивных конструкций благодаря существованию хороших функциональных альтернатив. 1.3. Почему именно Scala Подойдет ли вам язык Scala? Разбираться и принимать решение придется само­ стоятельно. Мы считаем, что, помимо хорошей масштабируемости, существует еще множество причин, по которым вам может понравиться программирование на Scala. В этом разделе будут рассмотрены четыре наиболее важных аспекта: совме­ стимость, лаконичность, абстракции высокого уровня и расширенная статическая типизация. Scala — совместимый язык Scala не требует резко отходить от платформы Java, чтобы опередить на шаг этот язык. Scala позволяет повышать ценность уже существующего кода, то есть опи­ раться на то, что у вас уже есть, поскольку он был разработан для достижения беспрепятственной совместимости с Java1. Программы на Scala компилируются в байт-коды виртуальной машины Java (JVM). Производительность при выпол­ нении этих кодов находится на одном уровне с производительностью программ на Java. Код Scala может вызывать методы Java, обращаться к полям этого языка, поддерживать наследование от его классов и реализовывать его интерфейсы. Для всего перечисленного не требуются ни специальный синтаксис, ни явные описания интерфейса, ни какой-либо связующий код. По сути, весь код Scala интенсивно использует библиотеки Java, зачастую даже без ведома программистов. Еще один аспект полной совместимости — интенсивное заимствование в Scala типов данных Java. Данные типа Int в Scala представлены в виде имеющегося в Java примитивного целочисленного типа int, соответственно Float представлен как float, Boolean — как boolean и т. д. Массивы Scala отображаются на массивы Java. В Scala из Java позаимствованы и многие стандартные библиотечные типы. Например, тип строкового литерала "abc" в Scala фактически представлен классом java.lang.String, а исключение должно быть подклассом java.lang.Throwable. 1 Изначально существовала реализация Scala, запускаемая на платформе .NET, но она боль­ ше не используется. В последнее время все большую популярность набирает реализация Scala под названием Scala.js, запускаемая на JavaScript. 1.3. Почему именно Scala 43 Java-типы в Scala не только заимствованы, но и «принаряжены» для придания им привлекательности. Например, строки в Scala поддерживают такие методы, как toInt или toFloat, которые преобразуют строки в целое число или число с плава­ ющей точкой. То есть вместо Integer.parseInt(str) вы можете написать str.toInt. Как такое возможно без нарушения совместимости? Ведь в Java-классе String нет метода toInt! По сути, в Scala имеется универсальное решение для устранения противоречия между сложной структурой библиотеки и совместимостью. Scala позволяет определять неявные преобразования, которые всегда применяются при несовпадении типов или выборе несуществующих элементов. В рассматриваемом случае при поиске метода toInt для работы со строковым значением компилятор Scala не найдет такого элемента в классе String. Однако он найдет неявное преоб­ разование, превращающее Java-класс String в экземпляр Scala-класса StringOps, в котором такой элемент определен. Затем преобразование будет автоматически применено, прежде чем будет выполнена операция toInt. Код Scala также может быть вызван из кода Java. Иногда при этом следует учитывать некоторые нюансы. Scala — более богатый язык, чем Java, и потому некоторые расширенные функции Scala должны быть закодированы, прежде чем могут быть отображены на код Java. Подробности объясняются в главе 31. Scala — лаконичный язык Программы на Scala, как правило, отличаются краткостью. Программисты, ра­ ботающие с данным языком, отмечают сокращение количества строк почти на порядок по сравнению с Java. Но это можно считать крайним случаем. Более консервативные оценки свидетельствуют о том, что обычная программа на Scala должна умещаться в половину тех строк, которые используются для аналогичной программы на Java. Меньшее количество строк означает не только сокращение объ­ ема набираемого текста, но и экономию сил при чтении и осмыслении программ, а также уменьшение количества возможных недочетов. Свой вклад в сокращение количества строк кода вносят сразу несколько факторов. В синтаксисе Scala не используются некоторые шаблонные элементы, отяго­ щающие программы на Java. Например, в Scala не обязательно применять точки с запятыми. Есть и несколько других областей, где синтаксис Scala менее зашумлен. В качестве примера можно сравнить, как записывается код классов и конструкторов в Java и Scala. В Java класс с конструктором зачастую выглядит следующим образом: // Это Java class MyClass { private int index; private String name; public MyClass(int index, String name) { this.index = index; this.name = name; } } 44 Глава 1 • Масштабируемый язык А в Scala, скорее всего, будет использована такая запись: class MyClass(index: Int, name: String) Получив указанный код, компилятор Scala создаст класс с двумя приватными переменными экземпляра (типа Int по имени index и типа String по имени name) и конструктор, который получает исходные значения для этих переменных в виде параметров. Код данного конструктора проинициализирует две переменные эк­ земпляра значениями, переданными в качестве параметров. Короче говоря, в итоге вы получите ту же функциональность, что и у более многословной версии кода на Java1. Класс в Scala быстрее пишется и проще читается, а еще — и это наиболее важно — допустить ошибку при его создании значительно труднее, чем при созда­ нии класса в Java. Еще один фактор, способствующий лаконичности, — используемый в Scala вывод типов. Повторяющуюся информацию о типе можно отбросить, и тогда про­ граммы избавятся от лишнего и их легче будет читать. Но, вероятно, наиболее важный аспект сокращения объема кода — наличие кода, не требующего внесения в программу, поскольку это уже сделано в библио­ теке. Scala предоставляет вам множество инструментальных средств для опре­ деления эффективных библиотек, позволяющих выявить и вынести за скобки общее поведение. Например, различные аспекты библиотечных классов можно выделить в трейты, которые затем можно перемешивать произвольным образом. Или же библиотечные методы могут быть параметризованы с помощью опера­ ций, позволяя вам определять конструкции, которые, по сути, являются вашими собственными управляющими конструкциями. Собранные вместе, эти конструк­ ции позволяют определять библиотеки, сочетающие в себе высокоуровневый характер и гибкость. Scala — язык высокого уровня Программисты постоянно борются со сложностью. Для продуктивного програм­ мирования нужно понимать код, над которым вы работаете. Чрезмерно сложный код был причиной краха многих программных проектов. К сожалению, важные программные продукты обычно бывают весьма сложными. Избежать сложности невозможно, но ею можно управлять. Scala помогает управлять сложностью, позволяя повышать уровень абстракции в разрабатываемых и используемых интерфейсах. Представим, к примеру, что есть переменная name, имеющая тип String, и нужно определить, наличествует ли в этой строковой переменной символ в верхнем регистре. До выхода Java 8 приходилось создавать следующий цикл: 1 Единственное отличие заключается в том, что переменные экземпляра, полученные в случае применения Scala, будут финальными (final). Как сделать их не финальными, рассказывается в разделе 10.6. 1.3. Почему именно Scala 45 boolean nameHasUpperCase = false; // Это Java for (int i = 0; i < name.length(); ++i) { if (Character.isUpperCase(name.charAt(i))) { nameHasUpperCase = true; break; } } А в Scala можно написать такой код: val nameHasUpperCase = name.exists(_.isUpper) Код Java считает строки низкоуровневыми элементами, требующими посим­ вольного перебора в цикле. Код Scala рассматривает те же самые строки как высокоуровневые последовательности символов, в отношении которых можно применять запросы с предикатами. Несомненно, код Scala намного короче и — для натренированного глаза — более понятен, чем код Java. Следовательно, код Scala значительно меньше влияет на общую сложность приложения. Кроме того, умень­ шается вероятность допустить ошибку. Предикат _.isUpper — пример используемого в Scala функционального лите­ рала1. В нем дается описание функции, которая получает аргумент в виде символа (представленного знаком подчеркивания) и проверяет, не является ли этот символ буквой в верхнем регистре2. В Java 8 появилась поддержка лямбда-выражений и потоков (streams), позво­ ляющая выполнять подобные операции на Java. Вот как это могло бы выглядеть: boolean nameHasUpperCase = // Это Java 8 name.chars().anyMatch( (int ch) -> Character.isUpperCase((char) ch) ); Несмотря на существенное улучшение по сравнению с более ранними версия­ ми Java, код Java 8 все же более многословен, чем его эквивалент на языке Scala. Излишняя тяжеловесность кода Java, а также давняя традиция использования в этом языке циклов может натолкнуть многих Java-программистов на мысль о не­ обходимости новых методов, подобных exists, позволяющих просто переписать циклы и смириться с растущей сложностью кода. В то же время функциональные литералы в Scala действительно воспринима­ ются довольно легко и задействуются очень часто. По мере углубления знакомства со Scala перед вами будут открываться все больше и больше возможностей для определения и использования собственных управляющих абстракций. Вы пойме­ те, что это поможет избежать повторений в коде, сохраняя лаконичность и чистоту программ. 1 Функциональный литерал может называться предикатом, если результирующим типом будет Boolean. 2 Такое использование символа подчеркивания в качестве заместителя для аргументов рассматривается в разделе 8.5. 46 Глава 1 • Масштабируемый язык Scala — статически типизированный язык Системы со статической типизацией классифицируют переменные и выражения в соответствии с видом хранящихся и вычисляемых значений. Scala выделяется как язык своей совершенной системой статической типизации. Обладая системой вложенных типов классов, во многом похожей на имеющуюся в Java, этот язык позволяет вам проводить параметризацию типов с помощью средств обобщенного программирования, комбинировать типы с использованием пересечений и скрывать особенности типов, применяя абстрактные типы1. Так формируется прочный фундамент для создания собственных типов, который дает возможность разраба­ тывать безопасные и в то же время гибкие в использовании интерфейсы. Если вам нравятся динамические языки, такие как Perl, Python, Ruby или Groovy, то вы можете посчитать немного странным факт, что система статических типов в Scala упоминается как одна из его сильных сторон. Ведь отсутствие такой системы часто называют основным преимуществом динамических языков. Наи­ более часто, говоря о ее недостатках, приводят такие аргументы, как присущая программам многословность, воспрепятствование свободному самовыражению программистов и невозможность применения конкретных шаблонов динамиче­ ских изменений программных систем. Но зачастую эти аргументы направлены не против идеи статических типов в целом, а против конкретных систем типов, воспринимаемых как слишком многословные или недостаточно гибкие. Например, Алан Кей, автор языка Smalltalk, однажды заметил: «Я не против типов, но не знаю ни одной беспроблемной системы типов. Так что мне все еще нравится динамиче­ ская типизация»2. В этой книге я надеюсь убедить вас в том, что система типов в Scala далека от проблемной. На самом деле она вполне изящно справляется с двумя обычными опасениями, связываемыми со статической типизацией: многословия удается избежать за счет логического вывода типов, а гибкость достигается благодаря сопоставлению с образцом и ряду новых способов записи и составления типов. По мере устранения этих препятствий к классическим преимуществам систем статических типов начинают относиться намного более благосклонно. Среди наиболее важных преимуществ можно назвать верифицируемые свойства про­ граммных абстракций, безопасный рефакторинг и более качественное докумен­ тирование. Верифицируемые свойства. Системы статических типов способны подтвер­ ждать отсутствие конкретных ошибок, выявляемых в ходе выполнения программы. Это могут быть следующие правила: булевы значения никогда не складываются с целыми числами; приватные переменные недоступны за пределами своего класса; 1 Обобщенные типы рассматриваются в главе 19, пересечения (например, A с B и с C) — в главе 12, а абстрактные типы — в главе 20. 2 Kay A. C. Письмо, адресованное Стефану Раму, с описанием термина «Объектноориентированное программирование». Июль 2003 [Электронный ресурс]. — Режим доступа: www.purl.org/stefan_ram/pub/doc_kay_oop_en. 1.3. Почему именно Scala 47 функции применяются к надлежащему количеству аргументов; во множество строк можно добавлять только строки. Существующие в настоящее время системы статических типов не выявляют ошибки других видов. Например, обычно они не обнаруживают бесконечные функции, нарушение границ массивов или деление на ноль. Вдобавок эти системы не смогут определить несоответствие вашей программы ее спецификации (при наличии таковой!). Поэтому некоторые отказываются от них, считая не слишком полезными. Аргументация такова: если эти системы могут выявлять только простые ошибки, а модульные тесты обеспечивают более широкий охват, то зачем вообще связываться со статическими типами? Мы считаем, что в этих аргументах упущено главное. Система статических типов, конечно же, не может заменить собой модульное тестирование, однако может сократить количество необходимых модульных тестов, выявляя некие свойства, которые в противном случае нужно было бы протестировать. А модульное тестирование не способно заменить статическую типизацию. Ведь Эдсгер Дейкстра (Edsger Dijkstra) ска­ зал, что тестирование позволяет убедиться лишь в наличии ошибок, но не в их отсутствии1. Гарантии, которые обеспечиваются статической типизацией, могут быть простыми, но это реальные гарантии, не способные обеспечить никакие объемы тестирования. Безопасный рефакторинг. Системы статических типов дают гарантии, позво­ ляющие вам вносить изменения в основной код, будучи совершенно уверенными в благополучном исходе этого действия. Рассмотрим, к примеру, рефакторинг, при котором к методу нужно добавить еще один параметр. В статически типизирован­ ном языке вы можете внести изменения, перекомпилировать систему и просто ис­ править те строки, которые вызовут ошибку типа. Сделав это, вы будете пребывать в уверенности, что были найдены все места, требовавшие изменений. То же самое справедливо для другого простого рефакторинга, например изменения имени ме­ тода или перемещения метода из одного класса в другой. Во всех случаях проверка статического типа позволяет быть вполне уверенными в том, что работоспособ­ ность новой системы осталась на уровне работоспособности старой. Документирование. Статические типы — документация программы, про­ веряемой компилятором на корректность. В отличие от обычного комментария, аннотация типа никогда не станет устаревшей (по крайней мере, если содержащий ее исходный файл недавно успешно прошел компиляцию). Более того, компиля­ торы и интегрированные среды разработки (integrated development environments, IDE) могут использовать аннотации для выдачи более качественной контекстной справки. Например, IDE может вывести на экран все элементы, доступные для вы­ бора, путем определения статического типа выражения, которое выбрано, и дать возможность просмотреть все элементы этого типа. Хотя статические типы в целом полезны для документирования программы, иногда они могут вызывать раздражение тем, что засоряют ее. Обычно полезным 1 Dijkstra E. W. Notes on Structured Programming. — Апрель 1970 [Электронный ресурс]. — Режим доступа: www.cs.utexas.edu/users/EWD/ewd02xx/EWD249.PDF. 48 Глава 1 • Масштабируемый язык считается документирование тех сведений, которые читателям программы самостоя­ тельно извлечь довольно трудно. Полезно знать, что в методе, определенном так: def f(x: String) = ... аргументы метода f должны принадлежать типу String. В то же время может вы­ звать раздражение по крайней мере одна из двух аннотаций в следующем примере: val x: HashMap[Int, String] = new HashMap[Int, String]() Понятно, что было бы достаточно показать отношение x к типу HashMap с Intтипами в качестве ключей и String-типами в качестве значений только один раз, дважды повторять одно и то же нет смысла. В Scala имеется весьма сложная система логического вывода типов, позволя­ ющая опускать почти всю информацию о типах, которая обычно вызывает раздра­ жение. В предыдущем примере вполне работоспособны и две менее раздражающие альтернативы: val x = new HashMap[Int, String]() val x: Map[Int, String] = new HashMap() Вывод типа в Scala может заходить довольно далеко. Фактически пользователь­ ский код нередко вообще обходится без явного задания типов. Поэтому программы на Scala часто выглядят похожими на программы, написанные на динамически типизированных языках скриптов. Это, в частности, справедливо для прикладно­ го клиентского кода, который склеивается из заранее написанных библиотечных компонентов. Но для них самих это менее характерно, поскольку в них зачастую применяются довольно сложные типы, не допускающие гибкого использования таких схем. И это вполне естественно. Ведь сигнатуры типов элементов, составля­ ющих интерфейс повторно используемых компонентов, должны задаваться в явном виде, поскольку составляют существенную часть соглашения между компонентом и его клиентами. 1.4. Истоки Scala На идею создания Scala повлияли многие языки программирования и идеи, выра­ ботанные на основе исследований таких языков. Фактически обновления в Scala незначительны — большинство характерных особенностей языка уже применя­ лось в том или ином виде в других языках программирования. Инновации в Scala появляются в основном из того, как его конструкции сводятся воедино. В этом разделе будут перечислены основные факторы, оказавшие влияние на структуру языка Scala. Перечень не может быть исчерпывающим, поскольку в дизайне языков программирования так много толковых идей, что перечислить здесь их все просто невозможно. На внешнем уровне Scala позаимствовал существенную часть синтаксиса у Java и C#, которые, в свою очередь, взяли большинство своих синтаксических соглаше­ 1.4. Истоки Scala 49 ний у C и C++. Выражения, инструкции и блоки — в основном из Java, как, соб­ ственно, и синтаксис классов, создание пакетов и импорт1. Кроме синтаксиса, Scala позаимствовал и другие элементы Java, такие как его основные типы, библиотеки классов и модель выполнения. Scala многим обязан и другим языкам. Его однородная модель объектов впер­ вые появилась в Smalltalk и впоследствии была принята языком Ruby. Его идея универсальной вложенности (почти каждую конструкцию в Scala можно вложить в любую другую) реализована также в Algol, Simula, а в последнее время в Beta и gbeta. Его принцип единообразного доступа к вызову методов и выбору полей пришел из Eiffel. Его подход к функциональному программированию очень близок по духу к применяемому в семействе языков ML, видными представителями кото­ рого являются SML, OCaml и F#. Многие функции высшего порядка в стандартной библиотеке Scala присутствуют также в ML или Haskell. Толчком для появления в Scala неявных параметров стали классы типов языка Haskell — в более классиче­ ском объектно-ориентированном окружении они дают аналогичные результаты. Используемая в Scala основная библиотека многопоточного вычисления на основе акторов — Akka — создавалась под сильным влиянием особенностей языка Erlang. Scala — не первый язык, в котором акцент сделан на масштабируемости и расши­ ряемости. Исторические корни расширяемых языков для различных областей при­ менения обнаруживаются в статье Петера Ландина (Peter Landin) 1966 года The Next 700 Programming Languages («Следующие 700 языков программирования»)2. (Опи­ сываемый в ней язык Iswim, наряду с Lisp, — один из первых в своем роде функ­ циональных языков.) Происхождение конкретной идеи трактовки инфиксного оператора в качестве функции можно проследить до Iswim и Smalltalk. Еще одна важная идея заключается в разрешении использования функционального литерала (или блока) в качестве параметра, позволяющего библиотекам определять управ­ ляющие конструкции. Она также проистекает из Iswim и Smalltalk. И у Smalltalk, и у Lisp довольно гибкий синтаксис, широко применявшийся для создания закры­ тых предметно-ориентированных языков. Еще один масштабируемый язык, C++, который может быть адаптирован и расширен благодаря применению перегрузки 1 Главное отличие от Java касается синтаксиса для объявления типов: вместо «Тип переменная», как в Java, задействуется форма «переменная: Тип». Используемый в Scala постфиксный синтаксис типа похож на синтаксис, применяемый в Pascal, Modula-2 или Eiffel. Основная причина такого отклонения имеет отношение к логическому выводу ти­ пов, зачастую позволяющему опускать тип переменной или тип возвращаемого методом значения. Легче использовать синтаксис «переменная: Тип», поскольку двоеточие и тип можно просто не указывать. Но в стиле языка C, применяющем форму «Тип переменная», просто так не указывать тип нельзя, поскольку при этом исчезнет сам признак начала определения. Неуказанный тип в качестве заполнителя требует какое-нибудь ключевое слово (C# 3.0, в котором имеется логический вывод типов, для этой цели задействует ключевое слово var). Такое альтернативное ключевое слово представляется несколько более надуманным и менее привычным, чем подход, который используется в Scala. 2 Landin P. J. The Next 700 Programming Languages // Communications of the ACM, 1966. — № 9 (3). — P. 157–166. 50 Глава 1 • Масштабируемый язык операторов и своей системе шаблонов, построен, по сравнению со Scala, на низко­ уровневой и более системно-ориентированной основе. Кроме того, Scala — не пер­ вый язык, объединяющий в себе функциональное и объектно-ориентированное программирование, хотя, вероятно, в этом направлении продвинулся гораздо дальше прочих. К числу других языков, объединивших некоторые элементы функциональ­ ного программирования с объектно-ориентированным, относятся Ruby, Smalltalk и Python. Расширения Java-подобного ядра некоторыми функциональными идеями были предприняты на Java-платформе в Pizza, Nice, Multi-Java и самом Java 8. Су­ ществуют также изначально функциональные языки, которые приобрели систему объектов. В качестве примера можно привести OCaml, F# и PLT-Scheme. В Scala применяются также некоторые нововведения в области языков програм­ мирования. Например, его абстрактные типы — более объектно-ориентированная альтернатива обобщенным типам, его трейты позволяют выполнять гибкую сборку компонентов, а экстракторы обеспечивают независимый от представления способ сопоставления с образцом. Эти нововведения были представлены в статьях на конференциях по языкам программирования в последние годы1. Резюме Ознакомившись с текущей главой, вы получили некоторое представление о том, что представляет собой Scala и как он может помочь программисту в работе. Раз­ умеется, этот язык не решит все ваши проблемы и не увеличит волшебным образом вашу личную продуктивность. Следует заранее предупредить, что Scala нужно применять искусно, а для этого потребуется получить некоторые знания и прак­ тические навыки. Если вы перешли к Scala от языка Java, то одними из наиболее сложных аспектов его изучения для вас могут стать система типов Scala, которая существенно богаче, чем у Java, и его поддержка функционального стиля про­ граммирования. Цель данной книги — послужить руководством при поэтапном, от простого к сложному, изучении особенностей Scala. Полагаем, что вы приобретете весьма полезный интеллектуальный опыт, расширяющий ваш кругозор и изменя­ ющий взгляд на проектирование программных средств. Надеемся, что вдобавок вы получите от программирования на Scala истинное удовольствие и познаете творческое вдохновение. В следующей главе вы приступите к написанию кода Scala. 1 .. Для получения большей информации см.: Odersky. M., Cremet V., Rockl C., Zenger M. A Nomi­ nal Theory of Objects with Dependent Types // In Proc. ECOOP’03, Springer LNCS. 2003. July. — P. 201–225; Odersky M., Zenger M. Scalable Component Abstractions // Proceedings of OOPSLA. 2005. October. — P. 41–58; Emir B., Odersky M., Williams J. Matching Objects With Patterns // Proc. ECOOP, Springer LNCS. 2007. July. — P. 273–295. 2 Первые шаги в Scala Пришло время написать какой-нибудь код на Scala. Прежде чем углубиться в руко­ водство по этому языку, мы приведем две обзорные главы по нему и, что наиболее важно, заставим вас приступить к написанию кода. Рекомендуем по мере освоения материала на практике проверить работу всех примеров кода, представленных в этой и последующей главах. Лучше всего приступить к изучению Scala, програм­ мируя на данном языке. Запуск представленных далее примеров возможен с помощью стандартной установки Scala. Получить ее можно по адресу www.scala-lang.org/downloads, следуя инструкциям для вашей платформы. Кроме того, вы можете воспользоваться до­ полнительным модулем Scala для Eclipse, IntelliJ или NetBeans. Применительно к шагам, рассматриваемым в данной главе, предполагаем, что вы обратились к ди­ стрибутиву Scala из scala-lang.org.1 Если вы опытный программист, но новичок в Scala, то внимательно прочитайте следующие две главы: в них приводится достаточный объем информации, позволя­ ющий приступить к написанию полезных программ на этом языке. Если же опыт программирования у вас невелик, то часть материалов может показаться чем-то загадочным. Однако не стоит переживать. Чтобы ускорить процесс изучения, нам пришлось обойтись без некоторых подробностей. Более обстоятельные пояснения мы предоставим в последующих главах. Кроме того, в следующих двух главах дадим ряд сносок с указанием разделов книги, в которых можно найти более по­ дробные объяснения. 2.1. Шаг 1. Осваиваем интерпретатор Scala Проще всего приступить к изучению Scala, задействуя Scala-интерпретатор — интер­активную оболочку для написания выражений и программ на этом языке. Интерпретатор, который называется scala, будет вычислять набираемые вами 1 Приводимые в книге примеры тестировались с применением Scala версии 2.13.1. 52 Глава 2 • Первые шаги в Scala выражения и выводить на экран значение результата. Чтобы воспользоваться ин­ терпретатором, нужно в приглашении командной строки набрать команду scala1: $ scala Welcome to Scala version 2.13.1 Type in expressions to have them evaluated. Type :help for more information. scala> После того как вы наберете выражение, например, 1 + 2 и нажмете клавишу Enter: scala> 1 + 2 интерпретатор выведет на экран: res0: Int = 3 Эта строка включает: автоматически сгенерированное или определенное пользователем имя для ссылки на вычисленное значение (res0, означающее результат 0); двоеточие (:), за которым следует тип выражения (Int); знак равенства (=); значение, полученное в результате вычисления выражения (3). Тип Int означает класс Int в пакете scala. Пакеты в Scala аналогичны пакетам в Java — они разбивают глобальное пространство имен на части и предоставля­ ют механизм для сокрытия данных2. Значения класса Int соответствуют int значениям в Java. Если говорить в общем, то все примитивные типы Java имеют соответствующие классы в пакете scala. Например, scala.Boolean соответствует Java-типу boolean. А scala.Float соответствует Java-типу float. И при компиля­ ции вашего кода Scala в байт-код Java компилятор Scala будет по возможности использовать примитивные типы Java, чтобы обеспечить вам преимущество в про­ изводительности при работе с примитивными типами. Идентификатор resX может использоваться в последующих строках. Например, поскольку ранее для res0 было установлено значение 3, то результат выражения res0 * 3 будет равен 9: scala> res0 * 3 res1: Int = 9 Чтобы вывести на экран необходимое, но недостаточно информативное при­ ветствие Hello, world!, наберите следующую команду: scala> println("Hello, world!") Hello, world! 1 Если вы используете Windows, то команду scala нужно набирать в окне командной строки. 2 Если вы не знакомы с пакетами Java, то их можно рассматривать как средство предо­ ставления классам полных имен. Поскольку Int входит в пакет scala, Int — простое имя класса, а scala.Int — полное. Более подробно пакеты рассматриваются в главе 13. 2.2. Шаг 2. Объявляем переменные 53 Функция println выводит на стандартное устройство вывода переданную ей строку, подобно тому как это делает System.out.println в Java. 2.2. Шаг 2. Объявляем переменные В Scala имеется две разновидности переменных: val-переменные и var-переменные. Первые аналогичны финальным переменным в Java. После инициализации valпеременная уже никогда не может быть присвоена повторно. В отличие от нее var-переменная аналогична нефинальной переменной в Java и может быть при­ своена повторно в течение своего жизненного цикла. Определение val-переменной выглядит так: scala> val msg = "Hello, world!" msg: String = Hello, world! Эта инструкция вводит в употребление переменную msg в качестве имени для строки "Hello, world!". Типом msg является java.lang.String, поскольку строки в Scala реализуются Java-классом String. Если вы привыкли объявлять переменные в Java, то в этом примере кода мо­ жете заметить одно существенное отличие: в val-определении нигде не фигурируют ни java.lang.String, ни String. Пример демонстрирует логический вывод типов, то есть возможность Scala определять неуказанные типы. В данном случае, по­ скольку вы инициализировали msg строковым литералом, Scala придет к выводу, что типом msg должен быть String. Когда интерпретатор (или компилятор) Scala хочет выполнить вывод типов, зачастую лучше всего будет позволить ему сделать это, не засоряя код ненужными явными аннотациями типов. Но при желании можете указать тип явно, и, вероятно, иногда это придется делать. Явная аннотация типа может не только гарантировать, что компилятор Scala выведет желаемый тип, но и послужить полезной документаций для тех, кто впоследствии станет читать ваш код. В отличие от Java, где тип переменной указывается перед ее именем, в Scala вы указываете тип переменной после ее имени, отделяя его двоеточием, например: scala> val msg2: java.lang.String = "Hello again, world!" msg2: String = Hello again, world! Или же, поскольку типы java.lang вполне опознаваемы в программах на Scala по их простым именам1, запись можно упростить: scala> val msg3: String = "Hello yet again, world!" msg3: String = Hello yet again, world! Возвратимся к исходной переменной msg. Поскольку она определена, то ею можно воспользоваться в соответствии с вашими ожиданиями, например: scala> println(msg) Hello, world! 1 Простым именем java.lang.String является String. 54 Глава 2 • Первые шаги в Scala Учитывая, что msg является val-, а не var-переменной, вы не сможете повторно присвоить ей другое значение1. Посмотрите, к примеру, как интерпретатор выра­ жает свое недовольство при попытке сделать следующее: scala> msg = "Goodbye cruel world!" ^ error: reassignment to val При необходимости выполнить повторное присваивание следует воспользо­ ваться var-переменной: scala> var greeting = "Hello, world!" greeting: String = Hello, world! Как только приветствие станет var-, а не val-переменной, ему можно будет присвоить другое значение. Если, к примеру, чуть позже вы станете более раз­ дражительным, то можете поменять приветствие на просьбу оставить вас в покое: scala> greeting = "Leave me alone, world!" mutated greeting Чтобы ввести в интерпретатор код, который не помещается в одну строку, про­ сто продолжайте набирать код после заполнения первой строки. Если набор кода еще не завершен, то интерпретатор отреагирует установкой на следующей строке вертикальной черты: scala> val multiLine = | "This is the next line." multiLine: String = This is the next line. Если поймете, что набрали не то, а интерпретатор все еще ждет ввода дополни­ тельного текста, то можете сделать отмену, дважды нажав клавишу Enter: scala> val oops = | | You typed two blank lines. Starting a new command. scala> Далее в книге символы вертикальной черты отображаться не будут, чтобы код читался легче и его было проще скопировать и вставить в интерпретатор из электронной версии в формате PDF. 2.3. Шаг 3. Определяем функции После работы с переменными в Scala вам, вероятно, захотелось написать какиенибудь функции. В данном языке это делается так: 1 Но в интерпретаторе новую val-переменную можно определить с именем, которое до этого уже использовалось. Этот механизм рассматривается в разделе 7.7. 2.3. Шаг 3. Определяем функции 55 scala> def max(x: Int, y: Int): Int = { if (x > y) x else y } max: (x: Int, y: Int)Int Определение функции начинается с ключевого слова def. После имени функ­ ции, в данном случае max, стоит заключенный в круглые скобки перечень параме­ тров, разделенных запятыми. За каждым параметром функции должна следовать аннотация типа, перед которой ставится двоеточие, поскольку компилятор Scala (и интерпретатор, но с этого момента будет упоминаться только компилятор) не выводит типы параметров функции. В данном примере функция по имени max получает два параметра, x и y, и оба они относятся к типу Int. После закрывающей круглой скобки перечня параметров функции max обнаруживается аннотация типа : Int. Она определяет результирующий тип самой функции max1. За ним стоят знак равенства и пара фигурных скобок, внутри которых содержится тело функции. В данном случае в теле содержится одно выражение if, с помощью которого в каче­ стве результата выполнения функции max выбирается либо x, либо y в зависимости от того, значение какой из переменных больше. Как здесь показано, выражение if в Scala может вычисляться в значение, подобное тому, что вычисляется тернарным оператором Java. Например, Scala-выражение if (x > y) x else y вычисляется точ­ но так же, как выражение (x > y) ? x : y в Java. Знак равенства, предшествующий телу функции, дает понять, что с точки зрения функционального мира функция определяет выражение, результатом вычисления которого становится значение. Основная структура функции показана на рис. 2.1. Рис. 2.1. Основная форма определения функции в Scala Иногда компилятор Scala может потребовать от вас указать результирующий тип функции. Если, к примеру, функция является рекурсивной2, то вы должны 1 В Java тип возвращаемого из метода значения является возвращаемым типом. В Scala то же самое понятие называется результирующим типом. 2 Функция называется рекурсивной, если вызывает саму себя. 56 Глава 2 • Первые шаги в Scala указать ее результирующий тип явно. Но в случае с функцией max вы можете не указывать результирующий тип функции — компилятор выведет его самостоя­ тельно1. Кроме того, если функция состоит только из одной инструкции, то при желании фигурные скобки можно не ставить. Следовательно, альтернативный вариант функции max может быть таким: scala> def max(x: Int, y: Int) = if (x > y) x else y max: (x: Int, y: Int)Int После определения функции ее можно вызвать по имени: scala> max(3, 5) res4: Int = 5 А вот определение функции, которая не получает параметры и не возвращает полезный результат: scala> def greet() = println("Hello, world!") greet: ()Unit Когда определяется функция приветствия greet(), интерпретатор откликается следующим приветствием: ()Unit. Разумеется, слово greet — это имя функции. Пустота в скобках показывает, что функция не получает параметров. А Unit — результирующий тип функции greet . Он показывает, что функция не возвра­ щает никакого интересного значения. Тип Unit в Scala подобен типу void в Java. Фактически каждый метод, возвращающий void в Java, отображается на метод, возвращающий Unit в Scala. Таким образом, методы с результирующим типом Unit выполняются только для того, чтобы проявились их побочные эффекты. В случае с greet() побочным эффектом будет дружеское приветствие, выведенное на стан­ дартное устройство вывода. При выполнении следующего шага код Scala будет помещен в файл и запущен в качестве скрипта. Если нужно выйти из интерпретатора, то это можно сделать с помощью команды :quit или :q: scala> :quit $ 2.4. Шаг 4. Пишем Scala-скрипты Несмотря на то что язык Scala разработан, чтобы помочь программистам создавать очень большие масштабируемые системы, он вполне может подойти и для решения менее масштабных задач наподобие написания скриптов. Скрипт представляет собой простую последовательность инструкций, размещенных в файле и выпол­ няемых друг за другом. Поместите в файл по имени hello.scala следующий код: 1 Тем не менее зачастую есть смысл указывать результирующий тип явно, даже когда компилятор этого не требует. Такая аннотация типа может упростить чтение кода, по­ скольку читателю не придется изучать тело функции, чтобы определить, каким будет вывод результирующего типа. 2.5. Шаг 5. Организуем цикл с while и принимаем решение с if 57 println("Hello, world, from a script!") а затем запустите файл на выполнение1: $ scala hello.scala И вы получите еще одно приветствие: Hello, world, from a script! Аргументы командной строки, указанные для скрипта Scala, можно получить из Scala-массива по имени args. В Scala индексация элементов массива начинается с нуля и обращение к элементу выполняется указанием индекса в круглых скобках. Следовательно, первым элементом в Scala-массиве по имени steps будет steps(0), а не steps[0], как в Java. Убедиться в этом на практике можно, создав новый файл по имени helloarg.scala: // Поприветствуйте содержимое первого аргумента println("Hello, " + args(0) + "!") а затем запустив его на выполнение: $ scala helloarg.scala planet В данной команде planet передается в качестве аргумента командной строки, доступного в скрипте при использовании выражения args(0). Поэтому вы должны увидеть на экране следующий текст: Hello, planet! Обратите внимание на наличие комментария в скрипте. Компилятор Scala проигнорирует символы между парой символов // и концом строки, а также все символы между сочетаниями символов /* и */. Вдобавок в этом примере показана конкатенация String-значений, выполненная с помощью оператора +. Весь код работает вполне предсказуемо. Выражение "Hello, " + "world!" будет вычислено в строку "Hello, world!". 2.5. Шаг 5. Организуем цикл с while и принимаем решение с if Чтобы попробовать в работе конструкцию while, наберите следующий код и со­ храните его в файле printargs.scala: var i = 0 while (i < args.length) { println(args(i)) i += 1 } 1 В Unix и Windows скрипты можно запускать, не набирая слово scala, а используя син­ таксис «решетка — восклицательный знак», показанный в приложении. 58 Глава 2 • Первые шаги в Scala ПРИМЕЧАНИЕ Хотя примеры в данном разделе помогают объяснить суть циклов while, они не демонстрируют наилучший стиль программирования на Scala. В следующем разделе будут показаны более рациональные подходы, которые позволяют избежать перебора элементов массива с помощью индексов. Этот скрипт начинается с определения переменой, var i = 0. Вывод типов от­ носит переменную i к типу scala.Int, поскольку это тип ее начального значения 0. Конструкция while на следующей строке заставляет блок (код между фигурными скобками) повторно выполняться, пока булево выражение i < args.length будет вычисляться в false. Метод args.length вычисляет длину массива args. Блок со­ держит две инструкции, каждая из которых набрана с отступом в два пробела, что является рекомендуемым стилем отступов для кода на Scala. Первая инструкция, println(args(i)), выводит на экран i-й аргумент командной строки. Вторая, i += 1, увеличивает значение переменной i на единицу. Обратите внимание: Java-код ++i и i++ в Scala не работает. Чтобы в Scala увеличить значение переменной на единицу, нужно использовать одно из двух выражений: либо i = i + 1, либо i += 1. Запустите этот скрипт с помощью команды, показанной ниже: $ scala printargs.scala Scala is fun И вы увидите: Scala is fun Чтобы продолжить развлекаться, наберите в новом файле по имени echoargs.scala следующий код: var i = 0 while (i < args.length) { if (i != 0) print(" ") print(args(i)) i += 1 } println() В целях вывода всех аргументов в одной и той же строке в этой версии вместо вызова println используется вызов print. Чтобы эту строку можно было прочитать, перед каждым аргументом, за исключением первого, благодаря использованию кон­ струкции if (i != 0) вставляется пробел. При первом проходе цикла while выраже­ ние i != 0 станет вычисляться в false, поэтому перед начальным элементом пробел выводиться не будет. В самом конце добавлена еще одна инструкция println, чтобы после вывода аргументов произошел переход на новую строку. Тогда у вас полу­ чится очень красивая картинка. Если запустить этот скрипт с помощью команды: $ scala echoargs.scala Scala is even more fun то вы увидите на экране такой текст: Scala is even more fun 2.6. Шаг 6. Перебираем элементы с foreach и for 59 Обратите внимание: в Scala, как и в Java, булевы выражения для while или if нужно помещать в круглые скобки. (Иными словами, вы не можете в Scala прибег­ нуть к такому выражению, как if i < 10, вполне допустимому в языках, подобных Ruby. В Scala нужно воспользоваться записью if (i < 10).) Еще один прием про­ граммирования, аналогичный применяемому в Java, заключается в том, что если в блоке if используется только одна инструкция, то при желании фигурные скобки можно не ставить, как показано в конструкции if в файле echoargs.scala. Но хотя их там нет, в Scala, как и в Java, для отделения инструкций друг от друга исполь­ зуются точки с запятыми (стоит уточнить: в Scala точки с запятыми зачастую не­ обязательны, что снижает нагрузку на правый мизинец при наборе текста). Но если вы склонны к многословию, то можете записать содержимое файла echoargs.scala в следующем виде: var i = 0; while (i < args.length) { if (i != 0) { print(" "); } print(args(i)); i += 1; } println(); 2.6. Шаг 6. Перебираем элементы с foreach и for Возможно, при написании циклов while на предыдущем шаге вы даже не осо­ знавали того, что программирование велось в императивном стиле. Обычно он применяется с такими языками, как Java, C++ и C. При работе в этом стиле им­ перативные команды в случае последовательного перебора элементов в цикле выдаются поочередно и зачастую изменяемое состояние совместно используется различными функциями. Scala позволяет программировать в императивном сти­ ле, но, узнав этот язык получше, вы, скорее всего, перейдете преимущественно на функциональный стиль. По сути, одна из основных целей этой книги — помочь освоить работу в функциональном стиле, чтобы она стала такой же комфортной, как и работа в императивном. Одна из основных характеристик функционального языка — то, что его функции относятся к конструкциям первого класса, и это абсолютно справедливо для язы­ ка Scala. Например, еще один, гораздо более лаконичный способ вывода каждого аргумента командной строки выглядит так: args.foreach(arg => println(arg)) В этом коде в отношении массива args вызывается метод foreach, в который передается функция. В данном случае передается функциональный литерал с одним параметром arg. Тело функции — вызов println(arg). Если набрать показанный 60 Глава 2 • Первые шаги в Scala ранее код в новом файле по имени pa.scala и запустить этот файл на выполнение с помощью команды: $ scala pa.scala Concise is nice то на экране появятся строки: Concise is nice В предыдущем примере интерпретатор Scala вывел тип arg , причислив эту переменную к String, поскольку String — тип элемента массива, в отношении которого вызван метод foreach . Если вы предпочитаете конкретизировать, то можете упомянуть название типа. Но, пойдя по этому пути, придется часть кода, в которой указывается переменная аргумента, заключать в круглые скобки (это и есть обычный синтаксис): args.foreach((arg: String) => println(arg)) При запуске этот скрипт ведет себя точно так же, как и предыдущий. Если же вы склонны не к конкретизации, а к более лаконичному изложению кода, то можете воспользоваться специальными сокращениями, принятыми в Scala. Если функциональный литерал функции состоит из одной инструкции, прини­ мающей один аргумент, то обозначать данный аргумент явным образом по имени не нужно1. Поэтому работать будет и следующий код: args.foreach(println) Резюмируем усвоенное: синтаксис для функционального литерала представ­ ляет собой список поименованных параметров, заключенный в круглые скобки, а также правую стрелку, за которой следует тело функции. Этот синтаксис показан на рис. 2.2. Рис. 2.2. Синтаксис функционального литерала в Scala Теперь вы можете поинтересоваться: что же случилось с теми проверенными циклами for, которые вы привыкли использовать в таких императивных языках, 1 Это сокращение, называемое частично примененной функцией, рассматривается в раз­ деле 8.6. Резюме 61 как Java или C? Придерживаться функционального направления в Scala возможно с помощью только одного функционального родственника императивной кон­ струкции for, который называется выражением for. Так как вы не сможете понять всю его эффективность и выразительность, пока не доберетесь до раздела 7.3 (или не заглянете в него), здесь о нем будет дано лишь общее представление. Наберите в новом файле по имени forargs.scala следующий код: for (arg <- args) println(arg) В круглых скобках после for содержится выражение arg <- args1. Справа от обо­ значения <- указан уже знакомый вам массив args. Слева от <- указана переменная arg , относящаяся к val -, а не к var -переменным. (Так как она всегда относится к val-переменным, записывается только arg, а не val arg.) Может показаться, что arg относится к var-переменной, поскольку она будет получать новое значение при каждой итерации, однако в действительности она относится к val-переменной: arg не может получить новое значение внутри тела выражения. Вместо этого для каждого элемента массива args будет создана новая val-переменная по имени arg, которая будет инициализирована значением элемента, и тело for будет выполнено. Если скрипт forargs.scala запустить с помощью команды: $ scala forargs.scala for arg in args то вы увидите: for arg in args Диапазон применения используемого в Scala выражения for значительно шире, чем показано здесь, но для начала достаточно и этого примера. Дополнительные возможности for будут показаны в разделе 7.3 и в главе 23. Резюме В данной главе мы привели основную информацию о Scala. Надеемся, вы вос­ пользовались возможностью создать код на этом языке. В следующей главе мы продолжим вводный обзор и рассмотрим более сложные темы. 1 Обозначение <– можно трактовать как «в». То есть код for (arg <– args) может быть прочитан как «для arg в args». 3 Дальнейшие шаги в Scala В этой главе продолжается введение в Scala, начатое в предыдущей главе. Здесь мы рассмотрим более сложные функциональные возможности. Когда вы усвоите материал главы, у вас будет достаточно знаний, чтобы начать создавать полезные скрипты на Scala. Мы вновь рекомендуем по мере чтения текста получать практи­ ческие навыки с помощью приводимых примеров. Лучше всего осваивать Scala, начиная создавать код на данном языке. 3.1. Шаг 7. Параметризуем массивы типами В Scala создавать объекты или экземпляры класса можно с помощью ключевого слова new. При создании объекта в Scala вы можете параметризовать его значе­ ниями и типами. Параметризация означает конфигурирование экземпляра при его создании. Параметризация экземпляра значениями производится путем пере­ дачи конструктору объектов в круглых скобках. Например, код Scala, показанный ниже, создает новый объект java.math.BigInteger, выполняя его параметризацию значением "12345": val big = new java.math.BigInteger("12345") Параметризация экземпляра типами выполняется с помощью указания одного или нескольких типов в квадратных скобках. Пример показан в листинге 3.1. Здесь greetStrings — значение типа Array[String] («массив строк»), инициализируемое длиной 3 путем его параметризации значением 3 в первой строке кода. Если запу­ стить код в листинге 3.1 в качестве скрипта, то вы увидите еще одно приветствие Hello, world!. Учтите, что при параметризации экземпляра как типом, так и зна­ чением тип стоит первым и указывается в квадратных скобках, а за ним следует значение в круглых скобках. Листинг 3.1. Параметризация массива типом val greetStrings = new Array[String](3) greetStrings(0) = "Hello" 3.1. Шаг 7. Параметризуем массивы типами 63 greetStrings(1) = ", " greetStrings(2) = "world!\n" for (i <- 0 to 2) print(greetStrings(i)) ПРИМЕЧАНИЕ Хотя код в листинге 3.1 содержит важные понятия, он не показывает рекомендуемый способ создания и инициализации массива в Scala. Более рациональный способ будет показан в листинге 3.2. Если вы склонны делать более явные указания, то тип greetStrings можно обозначить так: val greetStrings: Array[String] = new Array[String](3) С учетом имеющегося в Scala вывода типов эта строка кода семантически эк­ вивалентна первой строке листинга 3.1. Но в данной форме показано следующее: часть параметризации, которая относится к типу (название типа в квадратных скобках), формирует часть типа экземпляра, однако часть параметризации, отно­ сящаяся к значению (значения в круглых скобках), в формировании не участвует. Типом greetStrings является Array[String], а не Array[String](3). В следующих трех строках кода в листинге 3.1 инициализируется каждый эле­ мент массива greetStrings: greetStrings(0) = "Hello" greetStrings(1) = ", " greetStrings(2) = "world!\n" Как уже упоминалось, доступ к массивам в Scala осуществляется за счет поме­ щения индекса элемента в круглые, а не в квадратные скобки, как в Java. Следова­ тельно, нулевым элементом массива будет greetStrings(0), а не greetStrings[0]. Эти три строки кода иллюстрируют важное понятие, помогающее осмыслить значение для Scala val-переменных. Когда переменная определяется с помощью val, повторно присвоить значение данной переменной нельзя, но объект, на кото­ рый она ссылается, потенциально может быть изменен. Следовательно, в данном случае присвоить greetStrings значение другого массива невозможно — пере­ менная greetStrings всегда будет указывать на один и тот же экземпляр типа Array[String], которым она была инициализирована. Но впоследствии в элементы типа Array[String] можно вносить изменения, следовательно, сам массив является изменяемым. Последние две строки листинга 3.1 содержат выражение for, которое поочеред­ но выводит каждый элемент массива greetStrings: for (i <- 0 to 2) print(greetStrings(i)) В первой строке кода для этого выражения for показано еще одно общее правило Scala: если метод получает лишь один параметр, то его можно вызвать 64 Глава 3 • Дальнейшие шаги в Scala без точки или круглых скобок. В данном примере to на самом деле является методом, получающим один Int-аргумент. Код 0 to 2 преобразуется в вызов ме­ тода (0).to(2)1. Следует заметить, что этот синтаксис работает только при явном указании получателя вызова метода. Код println 10 использовать нельзя, а код Console println 10 — можно. С технической точки зрения в Scala нет перегрузки операторов, поскольку в нем фактически отсутствуют операторы в традиционном понимании. Вместо этого такие символы, как +, -, *, /, могут использоваться в качестве имен методов. Следовательно, когда при выполнении шага 1 вы набираете в интерпретаторе Scala код 1 + 2, в действительности вы вызываете метод по имени + в отношении Int-объекта 1, передавая ему в качестве параметра значение 2. Как показано на рис. 3.1, вместо этого 1 + 2 можно записать с помощью традиционного синтаксиса вызова метода: (1).+(2). Рис. 3.1. Все операции в Scala являются вызовами методов Еще одна весьма важная идея, проиллюстрированная в этом примере, поможет понять, почему доступ к элементам массивов Scala осуществляется с помощью круглых скобок. В Scala меньше особых случаев по сравнению с Java. Массивы в Scala, как и в случае с любыми другими классами, — просто экземпляры классов. При использовании круглых скобок, окружающих одно или несколько значений переменной, Scala преобразует код в вызов метода по имени apply применительно к данной переменной. Следовательно, код greetStrings(i) преобразуется в код greetStrings.apply(i). Получается, что элемент массива в Scala является просто вызовом обычного метода, ничем не отличающегося от любого своего собрата. Этот принцип не ограничивается массивами: любое использование объекта в от­ ношении каких-либо аргументов в круглых скобках будет преобразовано в вызов метода apply. Разумеется, данный код будет скомпилирован, только если в этом типе объекта определен метод apply. То есть это не особый случай, а общее пра­ вило. 1 Этот метод to фактически возвращает не массив, а иную разновидность последователь­ ности, содержащую значения 0, 1 и 2, последовательный перебор которых выполняется вы­ ражением for. Последовательности и другие коллекции будут рассматриваться в главе 17. 3.1. Шаг 7. Параметризуем массивы типами 65 По аналогии с этим, когда присваивание выполняется в отношении переменной, к которой применены круглые скобки с одним или несколькими аргументами вну­ три, компилятор выполнит преобразование в вызов метода update, получающего не только аргументы в круглых скобках, но и объект, расположенный справа от знака равенства. Например, код: greetStrings(0) = "Hello" будет преобразован в код: greetStrings.update(0, "Hello") Таким образом, следующий код семантически эквивалентен коду листинга 3.1: val greetStrings = new Array[String](3) greetStrings.update(0, "Hello") greetStrings.update(1, ", ") greetStrings.update(2, "world!\n") for (i <- 0.to(2)) print(greetStrings.apply(i)) Концептуальная простота в Scala достигается за счет того, что все — от массивов до выражений — рассматривается как объекты с методами. Вам не нужно запо­ минать особые случаи, например такие, как существующее в Java различие между примитивными типами и соответствующими им типами-оболочками или между массивами и обычными объектами. Более того, подобное единообразие не вы­ зывает больших потерь производительности. Компилятор Scala везде, где только возможно, использует в скомпилированном коде массивы Java, элементарные типы и чистые арифметические операции. Рассмотренные до сих пор в этом шаге примеры компилируются и выполня­ ются весьма неплохо, однако в Scala имеется более лаконичный способ создания и инициализации массивов, который, как правило, вы и будете использовать (см. листинг 3.2). Данный код создает новый массив длиной три элемента, иници­ ализируемый переданными строками "zero", "one" и "two". Компилятор выводит тип массива как Array[String], поскольку ему передаются строки. Листинг 3.2. Создание и инициализация массива val numNames = Array("zero", "one", "two") Фактически в листинге 3.2 вызывается фабричный метод по имени apply , создающий и возвращающий новый массив. Метод apply получает переменное количество аргументов1 и определяется в объекте-компаньоне Array. Подробнее объекты-компаньоны будут рассматриваться в разделе 4.3. Если вам приходилось программировать на Java, то можете воспринимать это как вызов статического 1 Списки аргументов переменной длины или повторяемые параметры рассматриваются в разделе 8.8. 66 Глава 3 • Дальнейшие шаги в Scala метода по имени apply в отношении класса Array. Менее лаконичный способ вы­ зова того же метода apply выглядит следующим образом: val numNames2 = Array.apply("zero", "one", "two") 3.2. Шаг 8. Используем списки Одна из превосходных отличительных черт функционального стиля програм­ мирования — полное отсутствие у методов побочных эффектов. Единственным действием метода должно быть вычисление и возвращение значения. Получаемые в результате применения такого подхода преимущества заключаются в том, что методы становятся менее запутанными, и это упрощает их чтение и повторное использование. Есть и еще одно преимущество (в статически типизированных языках): все попадающее в метод и выходящее за его пределы проходит проверку на принадлежность к определенному типу, поэтому логические ошибки, скорее всего, проявятся сами по себе в виде ошибок типов. Применять данную функциональную философию к миру объектов означает превратить эти объекты в неизменяемые. Как вы уже видели, массив Scala — неизменяемая последовательность объектов с общим типом. Тип Array[String], к примеру, содержит только строки. Изменить длину массива после создания его экземпляра невозможно, но вы можете изменять значения его элементов. Таким образом, массивы относятся к изменяемым объ­ ектам. Для неизменяемой последовательности объектов с общим типом можно вос­ пользоваться списком, определяемым Scala-классом List . Как и в случае при­ менения массивов, в типе List[String] содержатся только строки. Список Scala, scala.List, отличается от Java-типа java.util.List тем, что списки Scala всегда неизменямые, а списки Java могут изменяться. В более общем смысле список Scala разработан с прицелом на использование функционального стиля программирова­ ния. Список создается очень просто, и листинг 3.3 как раз показывает это. Листинг 3.3. Создание и инициализация списка val oneTwoThree = List(1, 2, 3) Код в листинге 3.3 создает новую val-переменную по имени oneTwoThree, ини­ циализируемую новым списком List[Int] с целочисленными элементами 1 , 2 и 31. Из-за своей неизменяемости списки ведут себя подобно строкам в Java: при вызове метода в отношении списка из-за имени данного метода может создаваться впечатление, что обрабатываемый список будет изменен, но вместо этого создается и возвращается новый список с новым значением. Например, в List для объедине­ ния списков имеется метод, обозначаемый как :::. Используется он следующим образом: 1 Использовать запись new List не нужно, поскольку List.apply() определен в объектекомпаньоне scala.List как фабричный метод. Более подробно объекты-компаньоны рассматриваются в разделе 4.3. 3.2. Шаг 8. Используем списки 67 val oneTwo = List(1, 2) val threeFour = List(3, 4) val oneTwoThreeFour = oneTwo ::: threeFour println(oneTwo + " и " + threeFour + " не изменились.") println("Следовательно, " + oneTwoThreeFour + " является новым списком.") Запустив этот скрипт на выполнение, вы увидите следующую картину: List(1, 2) и List(3, 4) не изменились. Следовательно, List(1, 2, 3, 4) является новым списком. Возможно, со списками чаще всего будет использоваться оператор ::, который произносится как «конс». Он добавляет в начало существующего списка новый элемент и возвращает список, который получился в результате. Например, если запустить на выполнение следующий скрипт: val twoThree = List(2, 3) val oneTwoThree = 1 :: twoThree println(oneTwoThree) то вы увидите: List(1, 2, 3) ПРИМЕЧАНИЕ В выражении 1 :: twoThree метод :: является методом его правого операнда — списка twoThree. Можно заподозрить, будто с ассоциативностью метода :: что-то не то, но есть простое мнемоническое правило: если метод используется в виде оператора, например a * b, то вызывается в отношении левого операнда, как в выражении a.*(b), если только имя метода не заканчивается двоеточием. А если оно заканчивается двоеточием, то метод вызывается в отношении правого операнда. Поэтому в выражении 1 :: twoThree метод :: вызывается в отношении twoThree с передачей ему 1, то есть: twoThree.::(1). Ассоциативность операторов более подробно будет рассматриваться в разделе 5.9. Исходя из того, что короче всего указать пустой список с помощью Nil, один из способов инициализировать новые списки — связать элементы с помощью consоператора с Nil в качестве последнего элемента1. Например, следующий скрипт создаст ту же картину на выходе, что и предыдущий List(1, 2, 3): val oneTwoThree = 1 :: 2 :: 3 :: Nil println(oneTwoThree) Имеющийся в Scala класс List укомплектован весьма полезными методами, многие из которых показаны в табл. 3.1. Вся эффективность списков будет рас­ крыта в главе 16. 1 Причина, по которой в конце списка нужен Nil, заключается в том, что метод :: определен в классе List. Если попытаться просто воспользоваться кодом 1 :: 2 :: 3, то он не пройдет компиляцию, поскольку 3 относится к типу Int, у которого нет метода ::. 68 Глава 3 • Дальнейшие шаги в Scala Почему со списками не следует использовать операцию добавления в конец (append)? В классе List есть операция добавления, которая записывается как :+ (о ней гово­ рится в главе 24), но используется очень редко. Так происходит потому, что время, которое она тратит на добавление к списку, растет линейно с размером списка, а на добавление в начало списка с помощью метода :: всегда затрачивается одно и то же время. Если нужно добиться эффективности при создании списка с помощью добав­ ления элементов, то можно добавлять их в начало, а завершив добавление, вызвать метод реверсирования reverse. Или же можно воспользоваться изменяемым списком ListBuffer, предлагающим операцию добавления, а затем, завершив добавление, вы­ звать метод toList и преобразовать его в обычный список. Список типа ListBuffer будет рассмотрен в разделе 22.2. Таблица 3.1. Некоторые методы класса List и их использование Что используется Что этот метод делает List() или Nil Создает пустой список List List("Cool", "tools", "rule") Создает новый список типа List[String] с тремя значениями: "Cool", "tools" и "rule" val thrill = "Will" :: "fill" :: Создает новый список типа List[String] с тремя значениями: "Will", "until" :: Nil "fill" и "until" List("a", "b") ::: List("c", "d") Объединяет два списка (возвращает новый список типа List[String] со значениями "a", "b", "c" и "d") thrill(2) Возвращает элемент с индексом 2 (при начале отсчета с нуля) списка thrill (возвращает "until") thrill.count(s => s.length == 4) Подсчитывает количество строковых элементов в thrill, имеющих длину 4 (возвращает 2) thrill.drop(2) Возвращает список thrill без его первых двух элементов (возвращает List("until")) thrill.dropRight(2) Возвращает список thrill без крайних справа двух элементов (возвращает List("Will")) thrill.exists(s => s == "until") Определяет наличие в списке thrill строкового элемента, имеющего значение "until" (возвращает true) thrill.filter(s => s.length == 4) Возвращает список всех элементов списка thrill, имеющих длину 4, соблюдая порядок их следования в списке (возвращает List("Will", "fill")) thrill.forall(s => s.endsWith("l")) Показывает, заканчиваются ли все элементы в списке thrill буквой "l" (возвращает true) thrill.foreach(s => print(s)) Выполняет инструкцию print в отношении каждой строки в списке thrill (выводит "Willfilluntil") 3.3. Шаг 9. Используем кортежи 69 Что используется Что этот метод делает thrill.foreach(print) Делает то же самое, что и предыдущий код, но с использованием более лаконичной формы записи (также выводит "Willfilluntil") thrill.head Возвращает первый элемент в списке thrill (возвращает "Will") thrill.init Возвращает список всех элементов списка thrill, кроме последнего (возвращает List("Will", "fill")) thrill.isEmpty Показывает, не пуст ли список thrill (возвращает false) thrill.last Возвращает последний элемент в списке thrill (возвращает "until") thrill.length Возвращает количество элементов в списке thrill (возвращает 3) thrill.map(s => s + "y") Возвращает список, который получается в результате добавления "y" к каждому строковому элементу в списке thrill (возвращает List("Willy", "filly", "untily")) thrill.mkString(", ") Создает строку с элементами списка (возвращает "Will, fill, until") thrill.filterNot(s => s.length == 4) Возвращает список всех элементов в порядке их следования в списке thrill, за исключением имеющих длину 4 (возвращает List("until")) thrill.reverse Возвращает список, содержащий все элементы списка thrill, следующие в обратном порядке (возвращает List("until", "fill", "Will")) thrill.sortWith((s, t) => s.charAt(0).toLower < t.charAt(0).toLower) Возвращает список, содержащий все элементы списка thrill в алфавитном порядке с первым символом, преобразованным в символ нижнего регистра (возвращает List("fill", "until", "will")) thrill.tail Возвращает список thrill за исключением его первого элемента (возвращает List("fill", "until")) 3.3. Шаг 9. Используем кортежи Еще один полезный объект-контейнер — кортеж. Как и списки, кортежи не мо­ гут быть изменены, но, в отличие от списков, могут содержать различные типы элементов. Список может быть типа List[Int] или List[String], а кортеж может содержать одновременно как целые числа, так и строки. Кортежи находят широкое применение, например, при возвращении из метода сразу нескольких объектов. Там, где на Java для хранения нескольких возвращаемых значений зачастую при­ ходится создавать JavaBean-подобный класс, в Scala можно просто вернуть кортеж. Все делается просто: чтобы создать экземпляр нового кортежа, содержащего объ­ екты, нужно лишь заключить объекты в круглые скобки, отделив их друг от друга запятыми. После создания экземпляра кортежа доступ к его элементам можно получить, используя точку, знак подчеркивания и индекс элемента, причем подсчет элементов начинается с единицы. Пример показан в листинге 3.4. Листинг 3.4. Создание и использование кортежа val pair = (99, "Luftballons") println(pair._1) println(pair._2) 70 Глава 3 • Дальнейшие шаги в Scala В первой строке листинга 3.4 создается новый кортеж, содержащий в каче­ стве первого элемента целочисленное значение 99, а в качестве второго — строку "Luftballons". Scala выводит тип кортежа в виде Tuple2[Int, String], а также присваивает этот тип паре переменных. Во второй строке выполняется доступ к полю _1, в результате чего получается первый элемент 99. Символ точки (.) во второй строке аналогичен той точке, которая используется для доступа к полю или вызова метода. В данном случае выполняется доступ к полю по имени _1. Если запустим этот скрипт на выполнение, то получим следующий результат: 99 Luftballons Реальный тип кортежа зависит от количества содержащихся в нем элементов и от типов этих элементов. Следовательно, типом кортежа (99, "Luftballons") является Tuple2[Int, String]. А типом кортежа ('u', 'r', "the", 1, 4, "me") яв­ ляется Tuple6[Char, Char, String, Int, Int, String]1. Обращение к элементам кортежа Возникает вопрос: а почему к элементам кортежа нельзя обратиться точно так же, как к элементам списка, например pair(0)? Дело в том, что используемый в списках метод apply всегда возвращает один и тот же тип, а в кортеже все элементы могут быть разных типов: у _1 может быть один результирующий тип, у _2 — другой и т. д. Эти числа вида _N начинаются с единицы, а не с нуля, поскольку начало отсчета с единицы традиционно используется в языках со статически типизированными кортежами, например Haskell и ML. 3.4. Шаг 10. Используем множества и отображения Scala призван помочь вам использовать преимущества как функционального, так и объектно-ориентированного стиля, поэтому в библиотеках его коллекций особое внимание обращают на разницу между изменяемыми и неизменяемыми коллекциями. Например, массивы всегда изменяемы, а списки всегда неизменяе­ мы. Scala также предоставляет изменяемые и неизменяемые альтернативы для множеств и отображений, но использует для обеих версий одни и те же простые имена. Для множеств и отображений Scala моделирует изменяемость в иерархии классов. Например, в API Scala содержится основной трейт для множеств, где этот трейт аналогичен Java-интерфейсу. (Более подробно трейты рассматриваются в главе 12.) 1 Концептуально можно создавать кортежи любой длины, однако на данный момент би­ блиотека Scala определяет их только до Tuple22. 3.4. Шаг 10. Используем множества и отображения 71 Затем Scala предоставляет два трейта-наследника: один для изменяемых, а второй для неизменяемых множеств. На рис. 3.2 показано, что для всех трех трейтов используется одно и то же про­ стое имя Set. Но их полные имена отличаются друг от друга, поскольку все трейты размещаются в разных пакетах. Классы для конкретных множеств в Scala API, например HashSet (см. рис. 3.2), являются расширениями либо изменяемого, либо неизменяемого трейта Set. (В то время как в Java вы реализуете интерфейсы, в Scala расширяете (иначе говоря, подмешиваете) трейты.) Следовательно, если нужно воспользоваться HashSet, то в зависимости от потребностей можно выбирать между его изменяемой и неизменяемой разновидностями. Способ создания множества по умолчанию показан в листинге 3.5. Листинг 3.5. Создание, инициализация и использование неизменяемого множества var jetSet = Set("Boeing", "Airbus") jetSet += "Lear" println(jetSet.contains("Cessna")) Рис. 3.2. Иерархия классов для множеств Scala В первой строке кода листинга 3.5 определяется новая var-переменная по имени jetSet, которая инициализируется неизменяемым множеством, содержащим две строки: "Boeing" и "Airbus". В этом примере показано, что в Scala множества можно создавать точно так же, как списки и массивы: путем вызова фабричного метода по имени apply в отношении объекта-компаньона Set. В листинге 3.5 метод apply вызывается в отношении объекта-компаньона для scala.collection.immutable.Set, возвращающего экземпляр исходного, неизменяемого класса Set. Компилятор Scala выводит тип переменной jetSet, определяя его как неизменяемый Set[String]. Чтобы добавить новый элемент в неизменяемое множество, в отношении по­ следнего вызывается метод +, которому и передается этот элемент. Метод + создает и возвращает новое неизменяемое множество с добавленным элементом. Конкрет­ ный метод += предоставляется исключительно для изменяемых множеств. 72 Глава 3 • Дальнейшие шаги в Scala В данном случае вторая строка кода, jetSet += "Lear", фактически является сокращенной формой записи следующего кода: jetSet = jetSet + "Lear" Следовательно, во второй строке кода листинга 3.5 var-переменной jetSet при­ сваивается новое множество, содержащее "Boeing", "Airbus" и "Lear". И наконец, в последней строке выводятся данные о том, содержится ли во множестве строка "Cessna". (Как и ожидалось, выводится false.) Если нужно изменяемое множество, то следует, как показано в листинге 3.6, воспользоваться инструкцией import. Листинг 3.6. Создание, инициализация и использование изменяемого множества import scala.collection.mutable val movieSet = mutable.Set("Hitch", "Poltergeist") movieSet += "Shrek" println(movieSet) В первой строке данного листинга выполняется импорт scala.collection.mu­ table. Инструкция import позволяет использовать простое имя, например Set, вместо длинного полного имени. В результате при указании mutable.Set во второй строке компилятор знает, что имеется в виду scala.collection.mutable.Set. В этой строке movieSet инициализируется новым изменяемым множеством, содержащим строки "Hitch" и "Poltergeist". В следующей строке к изменяемому множеству добавляется "Shrek", для чего в отношении множества вызывается метод += с пере­ дачей ему строки "Shrek". Как уже упоминалось, += — метод, определенный для изменяемых множеств. При желании можете вместо кода movieSet += "Shrek" вос­ пользоваться кодом movieSet.+=("Shrek")1. Рассмотренной до сих пор исходной реализации множеств, которые выполняют­ ся изменяемыми и неизменяемыми фабричными методами Set, скорее всего, будет достаточно для большинства ситуаций. Однако временами может потребоваться специальный вид множества. К счастью, при этом используется аналогичный синтаксис. Следует просто импортировать нужный класс и применить фабричный метод в отношении его объекта-компаньона. Например, если требуется неизменя­ емый HashSet, то можно сделать следующее: import scala.collection.immutable.HashSet val hashSet = HashSet("Tomatoes", "Chilies") println(hashSet + "Coriander") Еще одним полезным трейтом коллекций в Scala является отображение — Map. Как и для множеств, Scala предоставляет изменяемые и неизменяемые версии Map с применением иерархии классов. Как показано на рис. 3.3, иерархия классов для ото­ 1 Множество в листинге 3.6 изменяемое, поэтому повторно присваивать значение movieSet не нужно и данная переменная может относиться к val-переменным. В отличие от этого использование метода += с неизменяемым множеством в листинге 3.5 требует повторно­ го присваивания значения переменной jetSet, поэтому она должна быть var-переменной. 3.4. Шаг 10. Используем множества и отображения 73 бражений во многом похожа на иерархию для множеств. В пакете scala.collection есть основной трейт Map и два трейта-наследника отображения Map: изменяемый вариант в scala.collection.mutable и неизменяемый в scala.collection.immutable. Рис. 3.3. Иерархия классов для Scala-отображений Реализации Map, например HashMap-реализации в иерархии классов, показанной на рис. 3.3, расширяются либо в изменяемый, либо в неизменяемый трейт. Ото­ бражения можно создавать и инициализировать, используя фабричные методы, подобные тем, что применялись для массивов, списков и множеств. Например, в листинге 3.7 показана работа с изменяемым отображением. Листинг 3.7. Создание, инициализация и использование изменяемого отображения import scala.collection.mutable val treasureMap = mutable.Map[Int, String]() treasureMap += (1 -> "Go to island.") treasureMap += (2 -> "Find big X on ground.") treasureMap += (3 -> "Dig.") println(treasureMap(2)) В первой строке импортируется изменяемое отображение. Затем определяется val-переменная treasureMap, которая инициализируется пустым изменяемым ото­ бражением, имеющим целочисленные ключи и строковые значения. Отображение задается пустым, поскольку фабричному методу ничего не передается (в круглых скобках в Map[Int, String]() ничего не указано)1. В следующих трех строках 1 Явная параметризация типа, "[Int, String]", требуется в листинге 3.7 из-за того, что без како­ го-либо значения, переданного фабричному методу, компилятор не в состоянии выполнить логический вывод типов параметров отображения. В отличие от этого компилятор может выполнить вывод типов параметров из значений, переданных фабричному методу map, показанному в листинге 3.8, поэтому явного указания типов параметров там не требуется. 74 Глава 3 • Дальнейшие шаги в Scala к отображению добавляются пары «ключ — значение», для чего используются методы -> и +=. Как уже было показано, компилятор Scala преобразует выраже­ ния бинарных операций вида 1 -> "Go to island." в код (1).->("Go to island."). Следовательно, когда указывается 1 -> "Go to island.", фактически в отношении объекта 1 вызывается метод по имени ->, которому передается строка со значением "Go to island.". Метод ->, который можно вызвать в отношении любого объекта в программе Scala, возвращает двухэлементный кортеж, содержащий ключ и зна­ чение1. Затем этот кортеж передается методу += объекта отображения, на который ссылается treasureMap. И наконец, в последней строке выводится значение, соот­ ветствующее в treasureMap ключу 2. Если запустить этот код, то он выведет следующие данные: Find big X on ground. Если отдать предпочтение неизменяемому отображению, то ничего импортиро­ вать не нужно, поскольку это отображение используется по умолчанию. Пример показан в листинге 3.8. Листинг 3.8. Создание, инициализация и использование неизменяемого отображения val romanNumeral = Map( 1 -> "I", 2 -> "II", 3 -> "III", 4 -> "IV", 5 -> "V" ) println(romanNumeral(4)) Учитывая отсутствие импортирования, при указании Map в первой строке данного листинга вы получаете используемый по умолчанию экземпляр класса scala.collection.immutable.Map. Фабричному методу отображения передаются пять кортежей «ключ — значение», а он возвращает неизменяемое Map-отображение, содержащее эти переданные пары. Если запустить код, показанный в листинге 3.8, то он выведет IV. 3.5. Шаг 11. Учимся распознавать функциональный стиль Как упоминалось в главе 1, Scala позволяет программировать в императивном стиле, но побуждает вас переходить преимущественно к функциональному. Если к Scala вы пришли, имея опыт работы в императивном стиле, к примеру, вам при­ ходилось программировать на Java, то одной из основных возможных сложностей станет программирование в функциональном стиле. Мы понимаем, что поначалу этот стиль может быть неизвестен, и в данной книге стараемся перевести вас из одного состояния в другое. От вас также потребуются некоторые усилия, которые 1 Используемый в Scala механизм, позволяющий вызывать метод –> в отношении любого объекта, — неявное преобразование — будет рассмотрен в главе 21. 3.5. Шаг 11. Учимся распознавать функциональный стиль 75 мы настоятельно рекомендуем приложить. Мы уверены, что при наличии опыта работы в императивном стиле изучение программирования в функциональном позволит вам не только стать более квалифицированным программистом на Scala, но и расширит ваш кругозор, сделав вас более ценным программистом в общем смысле. Сначала нужно усвоить разницу между двумя стилями, отражающуюся в коде. Один верный признак заключается в том, что если код содержит var-переменные, то он, вероятнее всего, написан в императивном стиле. Если он вообще не со­ держит var-переменных, то есть включает только val-переменные, то, вероятнее всего, он написан в функциональном стиле. Следовательно, один из способов приблизиться к последнему — попытаться обойтись в программах без var переменных. Обладая багажом императивности, то есть опытом работы с такими языками, как Java, C++ или C#, var-переменные можно рассматривать в качестве обычных, а val-переменные — в качестве переменных особого вида. В то же время, если у вас имеется опыт работы в функциональном стиле на таких языках, как Haskell, OCaml или Erlang, val-переменные можно представлять как обычные, а var-переменные — как некое кощунственное обращение с кодом. Но с точки зрения Scala val- и varпеременные — всего лишь два разных инструмента в вашем арсенале средств и оба одинаково полезны и не отвергаемы. Scala побуждает вас к использованию valпеременных, но, по сути, дает возможность применять тот инструмент, который лучше подходит для решаемой задачи. И тем не менее, даже будучи согласными с подобной философией, вы поначалу можете испытывать трудности, связанные с избавлением от var-переменных в коде. Рассмотрим позаимствованный из главы 2 пример цикла while, в котором ис­ пользуется var-переменная, означающая, что он выполнен в императивном стиле: def printArgs(args: Array[String]): Unit = { var i = 0 while (i < args.length) { println(args(i)) i += 1 } } Вы можете преобразовать этот код — придать ему более функциональный стиль, отказавшись от использования var-переменной, например, так: def printArgs(args: Array[String]): Unit = { for (arg <- args) println(arg) } или вот так: def printArgs(args: Array[String]): Unit = { args.foreach(println) } 76 Глава 3 • Дальнейшие шаги в Scala В этом примере демонстрируется одно из преимуществ программирования с меньшим количеством var-переменных. Код после рефакторинга (более функ­ циональный) выглядит понятнее, он более лаконичен, и в нем труднее допустить какие-либо ошибки, чем в исходном (более императивном) коде. Причина навязы­ вания в Scala функционального стиля заключается в том, что он помогает создавать более понятный код, при написании которого труднее ошибиться. Но вы можете пойти еще дальше. Метод после рефакторинга printArgs нельзя отнести к чисто функциональным, поскольку у него имеются побочные эффекты. В данном случае такой эффект — вывод в поток стандартного устройства вывода. Признаком функции, имеющей побочные эффекты, выступает то, что результиру­ ющим типом у нее является Unit. Если функция не возвращает никакое интерес­ ное значение, о чем, собственно, и свидетельствует результирующий тип Unit, то единственный способ внести этой функцией какое-либо изменение в окружающий мир — проявить некий побочный эффект. Более функциональным подходом будет определение метода, который форматирует передаваемые аргументы в целях их последующего вывода и, как показано в листинге 3.9, просто возвращает отфор­ матированную строку. Листинг 3.9. Функция без побочных эффектов или var-переменных def formatArgs(args: Array[String]) = args.mkString("\n") Теперь вы действительно перешли на функциональный стиль: нет ни побочных эффектов, ни var-переменных. Метод mkString, который можно вызвать в отноше­ нии любой коллекции, допускающей последовательный перебор элементов (вклю­ чая массивы, списки, множества и отображения), возвращает строку, состоящую из результата вызова метода toString в отношении каждого элемента, с раздели­ телями из переданной строки. Таким образом, если args содержит три элемента, "zero", "one" и "two", то метод formatArgs возвращает "zero\none\ntwo". Разуме­ ется, эта функция, в отличие от методов printArgs, ничего не выводит, но в целях выполнения данной работы ее результаты можно легко передать функции println: println(formatArgs(args)) Каждая полезная программа, вероятнее всего, будет иметь какие-либо побоч­ ные эффекты. Отдавая предпочтение методам без побочных эффектов, вы будете стремиться к разработке программ, в которых такие эффекты сведены к минимуму. Одним из преимуществ такого подхода станет упрощение тестирования ваших программ. Например, чтобы протестировать любой из трех показанных ранее в этом раз­ деле методов printArgs, вам придется переопределить метод println, перехватить передаваемый ему вывод и убедиться в том, что он соответствует вашим ожи­ даниям. В отличие от этого функцию formatArgs можно протестировать, просто проверяя ее результат: val res = formatArgs(Array("zero", "one", "two")) assert(res == "zero\none\ntwo") 3.6. Шаг 12. Читаем строки из файла 77 Имеющийся в Scala метод assert проверяет переданное ему булево выражение и, если последнее вычисляется в false, выдает ошибку AssertionError. Если же переданное булево выражение вычисляется в true, то метод просто молча воз­ вращает управление вызвавшему его коду. Более подробно о тестах, проводимых с помощью assert, и тестировании речь пойдет в главе 14. И все-таки нужно иметь в виду: ни var-переменные, ни побочные эффекты не следует рассматривать как нечто абсолютно неприемлемое. Scala не является чисто функциональным языком, заставляющим вас программировать в функ­ циональном стиле. Scala — гибрид императивного и функционального языков. Может оказаться, что в некоторых ситуациях для решения текущей задачи больше подойдет императивный стиль, и тогда вы должны прибегнуть к нему без всяких колебаний. Но чтобы помочь вам разобраться в программировании без использо­ вания var-переменных, в главе 7 мы покажем множество конкретных примеров кода с использованием var-переменных и рассмотрим способы их преобразования в val-переменные. Сбалансированная позиция для программистов, работающих на Scala Отдавайте предпочтение val-переменным, неизменяемым объектам и методам без побочных эффектов. Стремитесь применять их в первую очередь. Используйте var-переменные, изменяемые объекты и методы с побочными эффектами в случае необходимости и при наличии четкой обоснованности их применения. 3.6. Шаг 12. Читаем строки из файла Скрипты, выполняющие небольшие повседневные задачи, часто нуждаются в об­ работке строк, взятых из файлов. В этом разделе будет создан скрипт, который считывает строки из файла и выводит их на стандартное устройство, предваряя каждую строку количеством содержащихся в ней символов. Первая версия скрипта показана в листинге 3.10. Листинг 3.10. Считывание строк из файла import scala.io.Source if (args.length > 0) { for (line <- Source.fromFile(args(0)).getLines()) println(line.length.toString + " " + line) } else Console.err.println("Please enter filename") 78 Глава 3 • Дальнейшие шаги в Scala Скрипт начинается с импорта класса Source из пакета scala.io. Затем он прове­ ряет, указан ли в командной строке хотя бы один аргумент. Если указан, то первый аргумент рассматривается как имя открываемого и обрабатываемого файла. Вы­ ражение Source.fromFile(args(0)) пробует открыть указанный файл и возвращает объект типа Source, в отношении которого вызывается метод getLines. Этот метод возвращает значение типа Iterator[String] , в котором при каждой итерации предоставляется по одной строке за исключением символа конца строки. Выраже­ ние for выполняет последовательный перебор этих строк и выводит длину каждой строки, затем пробел, а потом саму строку. Если аргументы в командной строке не указаны, то финальное условие else выведет сообщение в поток стандартного устройства вывода. Поместив данный код в файл countchars1.scala и запустив его с указанием самого этого файла: $ scala countchars1.scala countchars1.scala вы увидите следующий текст: 22 import scala.io.Source 0 22 if (args.length > 0) { 0 51 for (line <- Source.fromFile(args(0)).getLines()) 37 println(line.length + " " + line) 1 } 4 else 46 Console.err.println("Please enter filename") Скрипт в его текущем виде выводит необходимую информацию, однако, возмож­ но, вам захочется выстроить числа, выровняв их по правому краю, и добавить символ вертикальной черты, чтобы выводимая информация приобрела следующий вид: 22 0 22 0 51 37 1 4 46 | | | | | | | | | import scala.io.Source if (args.length > 0) { for (line <- Source.fromFile(args(0)).getLines()) println(line.length + " " + line) } else Console.err.println("Please enter filename") Чтобы реализовать задуманное, можно выполнить последовательный перебор строк дважды. При первом переборе нужно определить максимальную ширину, требующуюся для вывода количества символов в любой строке. А при втором — вывести данные, используя вычисленную ранее максимальную ширину. Посколь­ ку перебор строк будет выполняться дважды, то вы также можете присвоить эти строки переменной: val lines = Source.fromFile(args(0)).getLines().toList 3.6. Шаг 12. Читаем строки из файла 79 Завершающий выражение вызов метода toList нужен потому, что метод getLines возвращает итератор. По мере проведения итерации через итератор он истощается. Преобразование в список с помощью вызова toList дает воз­ можность выполнять итерацию любое необходимое количество раз, не при­ бегая к многократному выделению памяти для хранения всех строк из файла. Получается, переменная lines ссылается на список строк с содержимым файла, указанного в командной строке. Вам нужно вычислять ширину позиции для коли­ чества символов в каждой строке дважды — по одному разу за каждую итерацию. Поэтому из данного выражения можно вывести небольшую функцию, которая подсчитывает ширину, необходимую для символов, отображающих длину пере­ данной строки: def widthOfLength(s: String) = s.length.toString.length Применяя эту функцию, максимальную ширину можно вычислить следующим образом: var maxWidth = 0 for (line <- lines) maxWidth = maxWidth.max(widthOfLength(line)) Здесь с помощью выражения for выполняется последовательный перебор всех строк, вычисляется ширина, необходимая для символов, показывающих длину строки. И если она больше текущего максимума, то ее значение присваивается varпеременной maxWidth, которой было присвоено начальное значение 0. (Метод max, который можно вызвать в отношении любого объекта типа Int, возвращает боль­ шее число, сравнивая значение, в отношении которого он был вызван, и передан­ ное ему значение.) В качестве альтернативного варианта, если предпочтительнее искать максимум, не прибегая к использованию var-переменных, можно сначала определить самую длинную строку: val longestLine = lines.reduceLeft( (a, b) => if (a.length > b.length) a else b ) Метод reduceLeft применяет переданную ему функцию к первым двум эле­ ментам в списке lines, затем — к результату первого применения и к следующему элементу в lines и далее до конца списка. Результатом каждого применения будет самая длинная строка из встреченных до сих пор, поскольку переданная функция (a, b) => if (a.length > b.length) a else b возвращает самую длинную из двух переданных строк. Метод reduceLeft возвратит результат последнего применения функции, который в данном случае будет самым длинным строковым элементом списка lines. Получив результат, можно вычислить максимальную ширину, передав самую длинную строку функции widthOfLength: val maxWidth = widthOfLength(longestLine) 80 Глава 3 • Дальнейшие шаги в Scala Теперь останется только вывести строки с надлежащим форматированием. Это можно сделать следующим образом: for (line <- lines) { val numSpaces = maxWidth - widthOfLength(line) val padding = " " * numSpaces println(padding + line.length + " | " + line) } В этом выражении for еще раз выполняет последовательный перебор элемен­ тов списка lines. Для каждой строки сначала вычисляется количество пробелов, устанавливаемых перед указанием длины строки, и присваивается переменной numSpaces с помощью выражения " " * numSpaces. И наконец, выводится информа­ ция с надлежащим форматированием. Весь скрипт показан в листинге 3.11. Листинг 3.11. Вывод отформатированного количества символов каждой строки файла import scala.io.Source def widthOfLength(s: String) = s.length.toString.length if (args.length > 0) { val lines = Source.fromFile(args(0)).getLines().toList val longestLine = lines.reduceLeft( (a, b) => if (a.length > b.length) a else b ) val maxWidth = widthOfLength(longestLine) for (line <- lines) { val numSpaces = maxWidth - widthOfLength(line) val padding = " " * numSpaces println(padding + line.length + " | " + line) } } else Console.err.println("Пожалуйста, введите имя файла ") Резюме Знания, полученные в этой главе, позволят вам начать применять Scala для ре­ шения небольших задач, в особенности тех, для которых используются скрипты. В последующих главах мы глубже изучим рассмотренные темы, а также представим другие, не затронутые здесь. 4 Классы и объекты В предыдущих двух главах вы разобрались в основах классов и объектов. В этой главе вам предстоит углубленно проработать данную тему. Здесь мы дадим допол­ нительные сведения о классах, полях и методах, а также общее представление о том, когда подразумевается использование точки с запятой. Кроме того, рассмотрим объекты-одиночки (singleton) и то, как с их помощью писать и запускать приложе­ ния на Scala. Если вам уже знаком язык Java, то вы увидите, что в Scala фигурируют похожие, но все же немного отличающиеся концепции. Поэтому чтение данной главы пойдет на пользу даже великим знатокам языка Java. 4.1. Классы, поля и методы Классы — «чертежи» объектов. После определения класса из него, как по чертежу, можно создавать объекты, воспользовавшись для этого ключевым словом new. Например, при наличии следующего определения класса: class ChecksumAccumulator { // Сюда помещается определение класса } с помощью кода: new ChecksumAccumulator можно создавать объекты ChecksumAccumulator. Внутри определения класса помещаются поля и методы, которые в общем на­ зываются членами класса. Поля, которые определяются либо как val-, либо как var -переменные, являются переменными, относящимися к объектам. Методы, определяемые с помощью ключевого слова def, содержат исполняемый код. В по­ лях хранятся состояние или данные объекта, а методы используют эти данные для выполнения в отношении объекта вычислений. При создании экземпляра класса среда выполнения приложения резервирует часть памяти для хранения образа со­ стояния получающегося при этом объекта (то есть содержимого его переменных). 82 Глава 4 • Классы и объекты Например, если вы определите класс ChecksumAccumulator и дадите ему var-поле по имени sum: class ChecksumAccumulator { var sum = 0 } а потом дважды создадите его экземпляры с помощью следующего кода: val acc = new ChecksumAccumulator val csa = new ChecksumAccumulator то образ объектов в памяти может выглядеть так: Поскольку sum — поле, определенное внутри класса ChecksumAccumulator, и от­ носится к var-, а не к val-переменным, то впоследствии ему (полю) можно заново присвоить другое Int-значение: acc.sum = 3 Теперь картинка может выглядеть так: По поводу этой картинки нужно отметить следующее: на ней показаны две переменные sum . Одна из них находится в объекте, на который ссылается acc , а другая — в объекте, на который ссылается csa. Поля также называют переменными экземпляра, поскольку каждый экземпляр получает собственный набор пере­ менных. Все переменные экземпляра объекта составляют образ объекта в памяти. То, что здесь показано, свидетельствует не только о наличии двух переменных sum, но и о том, что изменение одной из них никак не отражается на другой. 4.1. Классы, поля и методы 83 В этом примере следует также отметить: у вас есть возможность изменить объект, на который ссылается 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 является приватным ПРИМЕЧАНИЕ В Scala элементы класса делают публичными, если нет явного указания какого-либо модификатора доступа. Иначе говоря, там, где в Java ставится модификатор public, в Scala вы обходитесь простым замалчиванием. Публичный (public) доступ в Scala — уровень доступа по умолчанию. Теперь, когда поле sum стало приватным, доступ к нему можно получить только из кода, определенного внутри тела самого класса. Следовательно, класс ChecksumAccumulator не будет особо полезен, пока внутри него не будут определены некоторые методы: class ChecksumAccumulator { private var sum = 0 def add(b: Byte): Unit = { sum += b } 84 Глава 4 • Классы и объекты def checksum(): Int = { return ~(sum & 0xFF) + 1 } } Теперь у ChecksumAccumulator есть два метода, add и checksum, и оба они де­ монстрируют основную форму определения функции, показанную на рис. 2.1 (см. выше). Внутри этого метода могут использоваться любые параметры метода. Одной из важных характеристик параметров метода в Scala является то, что они относятся к val-, а не к var-переменным1. При попытке повторного присваивания значения параметру внутри метода в Scala произойдет сбой компиляции: def add(b: Byte): Unit = { b = 1 // Не пройдет компиляцию, поскольку b относится к val-переменным sum += b } Хотя методы add и checksum в данной версии ChecksumAccumulator реализуют желаемые функциональные свойства вполне корректно, их можно определить в более лаконичном стиле. Во-первых, в конце метода checksum можно избавиться от лишнего слова return. В отсутствие явно указанной инструкции return метод в Scala возвращает последнее вычисленное им значение. При написании методов рекомендуется применять стиль, исключающий явное и особенно многократное использование инструкции return. Каждый метод нужно рассматривать в качестве выражения, выдающего одно значение, которое и явля­ ется возвращаемым. Эта философия будет побуждать вас создавать небольшие методы и разбивать слишком крупные методы на несколько мелких. В то же время выбор конструктивного решения зависит от контекста решаемых задач, и, если того требуют условия, Scala упрощает написание методов, которые имеют несколько явно указанных возвращаемых значений. Метод checksum всего лишь вычисляет значение, поэтому ему не нужна явно указанная инструкция return. Еще один способ сократить метод — отказаться от использования фигурных скобок, если в методе вычисляется только одно выра­ жение, дающее результат. Если результирующее выражение достаточно краткое, то его можно даже поместить на той же строке, в которой указано ключевое слово def. В целях максимальной лаконичности можно не указывать результирующий тип, и тогда Scala выведет его самостоятельно. После внесения всех этих изменений класс ChecksumAccumulator приобретет следующий вид: class ChecksumAccumulator { private var sum = 0 1 Причина, по которой val-переменные используются в качестве параметров, заключается в более легком составлении представления об этих переменных. Такую переменную больше не нужно отслеживать на предмет присваивания ей нового значения, как это было бы в случае использования var-переменной. 4.2. Когда подразумевается использование точки с запятой 85 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 нового значения. Метод, который выполняется только для получения его побочного эффекта, на­ зывается процедурой. 4.2. Когда подразумевается использование точки с запятой В программе на Scala точку с запятой в конце инструкции обычно можно не ставить. Если вся инструкция помещается на одной строке, то при желании можете поставить в конце данной строки точку с запятой, но это не обязательно. В то же время точка с запятой нужна, если на одной строке размещаются сразу несколько инструкций: val s = "hello"; println(s) Если требуется набрать инструкцию, занимающую несколько строк, то в боль­ шинстве случаев вы можете просто ее ввести, а Scala разделит инструкции в нуж­ ном месте. Например, следующий код рассматривается как одна инструкция, рас­ положенная на четырех строках: if (x < 2) println("too small") else println("ok") 86 Глава 4 • Классы и объекты И все же временами Scala разбивает инструкции на две части, вопреки вашим желаниям: x + y Этот код рассматривается как две инструкции, x и +y. Если подразумевается, что он должен рассматриваться как одна инструкция x + y, то его нужно заключать в круглые скобки: (x + y) В качестве альтернативного варианта можно поместить + в конец строки. Имен­ но поэтому при составлении цепочки из инфиксных операций, таких как +, в Scala обычно применяется стиль, который предусматривает помещение операторов в конец строки, а не в начало: x + y + z Правило, при соблюдении которого подразумевается постановка точки с запятой Принцип работы точных правил разделения инструкций удивительно прост. Если вкратце, то конец строки рассматривается как точка с запятой, пока не возникнет одно из следующих условий. 1. Рассматриваемая строка заканчивается элементом, который согласно правилам не может находиться в конце строки, например точкой или инфиксным оператором. 2. Следующая строка начинается с элемента, с которого не может начинаться ин­ струкция. 3. Строка заканчивается внутри круглых (...) или квадратных [...] скобок, по­ скольку там в любом случае не могут содержаться сразу несколько инструкций. 4.3. Объекты-одиночки Как упоминалось в главе 1, один из аспектов, позволяющих Scala быть более объектно-ориентированным языком, чем Java, заключается в том, что в классах Scala не могут содержаться статические элементы. Вместо этого в Scala есть объекты-одиночки, или синглтоны. Определение объекта-одиночки выглядит так же, как определение класса, за исключением того, что вместо ключевого слова class используется ключевое слово object. Пример показан в листинге 4.2. Объект-одиночка в этом листинге называется ChecksumAccumulator, то есть носит имя, совпадающее с именем класса в предыдущем примере. Когда объ­ 4.3. Объекты-одиночки 87 ект-одиночка использует общее с классом имя, то для класса он называется объектом-компаньоном. И класс, и его объект-компаньон нужно определять в одном и том же исходном файле. Класс по отношению к объекту-одиночке называется классом-компаньоном. Класс и его объект-компаньон могут обращаться к приват­ ным элементам друг друга. Листинг 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)) cache(s) else { val acc = new ChecksumAccumulator for (c <- s) acc.add(c.toByte) val cs = acc.checksum() cache += (s -> cs) cs } } Объект-одиночка ChecksumAccumulator располагает одним методом по имени calculate , который получает строку String и вычисляет контрольную сумму символов этой строки. Вдобавок он имеет одно приватное поле cache, представ­ ленное изменяемым отображением, в котором кэшируются ранее вычисленные контрольные суммы1. В первой строке метода, if (cache.contains(s)), определя­ ется, не содержится ли в отображении cache переданная строка в качестве ключа. Если да, то просто возвращается отображенное на этот ключ значение cache(s). В противном случае выполняется условие else, вычисляющее контрольную сумму. В первой строке условия else определяется val-переменная по имени acc, которая инициализируется новым экземпляром ChecksumAccumulator2. В следующей строке 1 Здесь cache используется, чтобы показать объект-одиночку с полем. Кэширование с помо­ щью поля cache помогает оптимизировать производительность, сокращая за счет расхода памяти время вычисления и разменивая расход памяти на время вычисления. Как прави­ ло, использовать кэш-память таким образом целесообразно только в том случае, если с ее помощью можно решить проблемы производительности и воспользоваться отображением со слабыми ссылками, например WeakHashMap в scala.collection.mutable, чтобы записи в кэш-памяти могли попадать в сборщик мусора при наличии дефицита памяти. 2 Поскольку ключевое слово new используется только для создания экземпляров классов, новый объект, созданный здесь в качестве экземпляра класса ChecksumAccumulator, не является одноименным объектом-одиночкой. 88 Глава 4 • Классы и объекты находится выражение for. Оно выполняет последовательный перебор каждого символа в переданной строке, преобразует символ в значение типа Byte, вызывая в отношении этого символа метод toByte, и передает результат в метод add того экземпляра ChecksumAccumulator, на который ссылается acc. Когда завершится вычисление выражения for, в следующей строке метода в отношении acc будет вызван метод checksum, который берет контрольную сумму для переданного значе­ ния типа String и сохраняет ее в val-переменной по имени cs. В следующей строке, cache += (s -> cs), переданный строковый ключ отображается на целочисленное значение контрольной суммы, и эта пара «ключ — значение» добавляется в ото­ бражение cache. В последнем выражении метода, cs, обеспечивается использование контрольной суммы в качестве результата выполнения метода. Если у вас есть опыт программирования на Java, то объекты-одиночки можно представить в качестве хранилища для любых статических методов, которые впол­ не могли быть написаны на Java. Методы в объектах-одиночках можно вызывать с помощью такого синтаксиса: имя объекта, точка, имя метода. Например, метод calculate объекта-одиночки ChecksumAccumulator можно вызвать следующим образом: ChecksumAccumulator.calculate("Каждое значениие является объектом.") Но объект-одиночка не только хранилище статических методов. Он объект первого класса. Поэтому имя объекта-одиночки можно рассматривать в качестве «этикетки», прикрепленной к объекту. Определение объекта-одиночки не является определением типа на том уров­ не абстракции, который используется в Scala. Имея лишь определение объекта ChecksumAccumulator, невозможно создать одноименную переменную типа. Точнее, тип с именем ChecksumAccumulator определяется классом-компаньоном объектаодиночки. Тем не менее объекты-одиночки расширяют суперкласс и могут под­ мешивать трейты. Учитывая то, что каждый объект-одиночка — экземпляр своего суперкласса и подмешанных в него трейтов, его методы можно вызывать через эти типы, ссылаясь на него из переменных этих типов и передавая ему методы, ожидающие использования этих типов. Примеры объектов-одиночек, являющихся наследниками классов и трейтов, показаны в главе 13. Одно из отличий классов от объектов-одиночек состоит в том, что объектыодиночки не могут принимать параметры, а классы — могут. Создать экземпляр объекта-одиночки с помощью ключевого слова new нельзя, поэтому передать ему параметры не представляется возможным. Каждый объект-одиночка реализуется 4.4. Приложение на языке Scala 89 как экземпляр синтетического класса, ссылка на который находится в статической переменной, поэтому у них и у статических классов Java одинаковая семантика инициализации1. В частности, объект-одиночка инициализируется при первом обращении к нему какого-либо кода. Объект-одиночка, который не имеет общего имени с классом-компаньоном, называется самостоятельным. Такие объекты можно применять для решения многих задач, включая сбор в одно целое родственных вспомогательных методов или определение точки входа в приложение Scala. Именно этот случай мы и рас­ смотрим в следующем разделе. 4.4. Приложение на языке Scala Чтобы запустить программу на Scala, нужно предоставить имя автономного объекта-одиночки с методом main , который получает один параметр с типом Array[String] и имеет результирующий тип Unit. Точкой входа в приложение мо­ жет стать любой самостоятельный объект с методом main, имеющим надлежащую сигнатуру. Пример показан в листинге 4.3. Листинг 4.3. Приложение Summer // Код находится в файле Summer.scala import ChecksumAccumulator.calculate object Summer { def main(args: Array[String]) = { for (arg <- args) println(arg + ": " + calculate(arg)) } } Объект-одиночка, показанный в данном листинге, называется Summer. Его метод main имеет надлежащую сигнатуру, поэтому его можно задействовать в качестве приложения. Первая инструкция в файле импортирует метод calculate, который определен в объекте ChecksumAccumulator из предыдущего примера. Инструкция import позволяет далее использовать в файле простое имя метода2. Тело метода main всего лишь выводит на стандартное устройство каждый аргумент и контроль­ ную сумму для аргумента, разделяя их двоеточием. 1 В качестве имени синтетического класса используется имя объекта со знаком дол­ лара. Следовательно, синтетический класс, применяемый для объекта-одиночки ChecksumAccu­mulator, называется ChecksumAccumulator$. 2 Наличие опыта программирования на Java позволяет сопоставить такой импорт с объ­ явлением статического импорта, введенным в Java 5. Единственное отличие — в Scala импортировать элементы можно из любого объекта, а не только из объектов-одиночек. 90 Глава 4 • Классы и объекты ПРИМЕЧАНИЕ Подразумевается, что в каждый свой исходный файл Scala импортирует элементы пакетов java.lang и scala, а также элементы объекта-одиночки по имени Predef. В Predef, который находится в пакете scala, содержится множество полезных методов. Например, когда в исходном файле Scala встречается println, фактически вызывается println из Predef. (А метод Predef.println, в свою очередь, вызывает метод Console.println, который фактически и выполняет всю работу.) Когда же встречается assert, вызывается метод Predef.assert. Чтобы запустить приложение Summer, поместите код из листинга 4.3 в файл Summer.scala. В Summer используется ChecksumAccumulator, поэтому поместите код для ChecksumAccumulator как для класса, показанного в листинге 4.1, так и для его объекта-компаньона, показанного в листинге 4.2, в файл ChecksumAccumula­ tor.scala. Одним из отличий Scala от Java является то, что в Java от вас требуется поместить публичный класс в файл, названный по имени класса, например, класс SpeedRacer — в файл SpeedRacer.java. А в Scala файл с расширением .scala можно называть как угодно, независимо от того, какие классы Scala или код в них помещаются. Но обычно, когда речь идет не о скриптах, рекомендуется придерживаться стиля, при котором файлы называются по именам включенных в них классов, как это делается в Java, чтобы программистам было легче искать классы по именам их файлов. Именно этим подходом мы и воспользовались в отношении двух файлов в данном примере. Имеются в виду файлы Summer.scala и ChecksumAccumulator.scala. Ни ChecksumAccumulator.scala , ни Summer.scala не являются скриптами, поскольку заканчиваются определением. В отличие от этого скрипт должен заканчиваться выражением, выдающим результат. Поэтому при попытке запу­ стить Summer.scala в качестве скрипта интерпретатор Scala пожалуется на то, что Summer.scala не заканчивается выражением, выдающим результат. (Конечно, если предположить, что вы самостоятельно не добавили какое-либо выражение после определения объекта Summer.) Вместо этого нужно будет скомпилировать данные файлы с помощью компилятора Scala, а затем запустить получившиеся в резуль­ тате файлы классов. Для этого можно воспользоваться основным компилятором Scala по имени scalac: $ scalac ChecksumAccumulator.scala Summer.scala Эта команда скомпилирует ваши исходные файлы, но завершиться компиляция может после весьма ощутимой задержки. Дело в том, что при каждом своем запуске компилятор тратит время на сканирование содержимого jar-файлов и выполнение другой предварительной работы до того, как обратит внимание на новые исходные файлы. Поэтому в дистрибутив Scala включается работающий в фоновом режиме Scala-компилятор под названием fsc (от fast Scala compiler — быстрый компилятор Scala). Он используется следующим образом: $ fsc ChecksumAccumulator.scala Summer.scala 4.5. Трейт App 91 При первом запуске fsc будет создан локальный фоновый сервер (демон), при­ крепленный к порту вашего компьютера. Затем через этот порт будут отправляться списки компилируемых файлов, а фоновая программа станет заниматься их компи­ ляцией. При следующем запуске fsc она уже будет запущена, поэтому fsc просто отправит ей список файлов, а та без промедления скомпилирует их. Использование fsc чревато ожиданием запуска среды выполнения Java лишь в первый раз. Если понадобится остановить фоновую программу fsc , то можно будет прибегнуть к команде fsc -shutdown. Запуск любой из команд scalac или fsc приведет к созданию файлов классов Java, которые затем можно будет запускать через команду scala — ту же самую, с помощью которой вы вызывали интерпретатор в предыдущих примерах. Во всех предыдущих примерах интерпретатору передавалось имя файла с расширением .scala, который содержит предназначенный для интерпретации код Scala1. В дан­ ном же случае ему нужно будет передать имя самостоятельного объекта, содержа­ щего метод main, имеющий надлежащую сигнатуру. Следовательно, приложение Summer можно запустить, набрав команду: $ scala Summer of love Вы сможете увидеть контрольные суммы, выведенные для двух аргументов командной строки: of: -213 love: -182 4.5. Трейт App В Scala есть трейт scala.App , который призван экономить время, которое вы тратите на набор текста. До сих пор мы не рассматривали все позволившее бы досконально разобраться в работе трейтов, однако все же решили, что об этом трейте вам захочется узнать прямо сейчас. Пример его использования показан в листинге 4.4. Листинг 4.4. Использование трейта App import ChecksumAccumulator.calculate object FallWinterSpringSummer extends App { for (season <- List("fall", "winter", "spring")) println(season + ": " + calculate(season)) } 1 Механизм, который программа scala использует для интерпретации исходного файла Scala, заключается в том, что она компилирует исходный код Scala в байт-коды Java, тут же загружает их с помощью загрузчика класса и приступает к их выполнению. 92 Глава 4 • Классы и объекты Чтобы воспользоваться трейтом, сначала нужно указать после имени вашего объекта-одиночки инструкцию расширения extends App. Затем, вместо того чтобы писать метод main, между фигурными скобками объекта-одиночки нужно набрать код, который вы поместили бы непосредственно в метод main. Обратиться к ар­ гументам командной строки можно через массив строковых элементов по имени args. Вот и все. Это приложение можно скомпилировать и запустить точно так же, как и любое другое. Резюме В этой главе мы рассмотрели основы классов и объектов в Scala и показали приемы компиляции и запуска приложений. В следующей главе рассмотрим основные типы данных и их использование. 5 Основные типы и операции После того как были рассмотрены в действии классы и объекты, самое время по­ глубже изучить имеющиеся в Scala основные типы и операции. Если вы хорошо знакомы с Java, то вас может обрадовать тот факт, что в Scala и в Java основные типы и операторы имеют тот же смысл. И все же есть интересные различия, ради которых с этой главой стоит ознакомиться даже тем, кто считает себя опытным разработчиком Java-приложений. Некоторые аспекты Scala, рассматриваемые в данной главе, в основном такие же, как и в Java, поэтому мы указываем, какие разделы Java-разработчики могут пропустить. В текущей главе мы представим обзор основных типов Scala, включая строки типа String и типы значений Int, Long, Short, Byte, Float, Double, Char и Boolean. Кроме того, рассмотрим операции, которые могут выполняться с этими типами, и вопросы соблюдения приоритета операторов в выражениях Scala. Поговорим мы и о том, как неявные преобразования могут обогатить варианты основных типов, позволяя выполнять дополнительные операции вдобавок к тем, что поддержива­ ются в Java. 5.1. Некоторые основные типы В табл. 5.1 показан ряд основных типов, используемых в Scala, а также диапазоны значений, которые могут принимать их экземпляры. В совокупности типы Byte, Short, Int, Long и Char называются целочисленными. Целочисленные типы плюс Float и Double называются числовыми. Таблица 5.1. Некоторые основные типы Основной тип Диапазон Byte 8-битовое знаковое целое число в дополнительном коде (от –27 до 27 – 1 включительно) Short 16-битовое знаковое целое число в дополнительном коде (от –215 до 215 – 1 включительно) Продолжение 94 Глава 5 • Основные типы и операции Таблица 5.1 (продолжение) Основной тип Диапазон Int 32-битовое знаковое целое число в дополнительном коде (от –231 до 231 – 1 включительно) Long 64-битовое знаковое целое число в дополнительном коде (от –263 до 263 – 1 включительно) Char 16-битовый беззнаковый Unicode-сивол (от 0 до 216 – 1 включительно) String Последовательность из Char Float 32-битовое число с плавающей точкой одинарной точности, которое соответствует стандарту IEEE 754 Double 64-битовое число с плавающей точкой двойной точности, которое соответствует стандарту IEEE 754 Boolean true или false За исключением типа String, который находится в пакете java.lang, все типы, показанные в данной таблице, входят в пакет scala1. Например, полное имя типа Int обозначается scala.Int. Но, учитывая, что все элементы пакета scala и java.lang автоматически импортируются в каждый исходный файл Scala, можно повсеместно использовать только простые имена, то есть имена вида Boolean, Char или String. Опытные Java-разработчики заметят, что основные типы Scala имеют в точ­ ности такие же диапазоны, как и соответствующие им типы в Java. Это позволяет компилятору Scala в создаваемом им байт-коде преобразовывать экземпляры типов значений Scala, например Int или Double, в примитивные типы Java. 5.2. Литералы Все основные типы, перечисленные в табл. 5.1 (см. выше), можно записать с помо­ щью литералов. Литерал представляет собой способ записи постоянного значения непосредственно в коде. УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ ДЛЯ JAVA-ПРОГРАММИСТОВ Синтаксис большинства литералов, показанных в данном разделе, совпадает с синтаксисом, применяемым в Java, поэтому знатоки Java могут спокойно пропустить практически весь раздел. Отдельные различия, о которых стоит прочитать, касаются используемых в Scala неформатированных строк и символов (рассматриваются в подразделе «Строковые литералы»), а также интерполяции строк. Кроме того, в Scala не поддерживаются восьмеричные литералы, а целочисленные, начинающиеся с нуля, например 031, не проходят компиляцию. 1 Пакеты, кратко рассмотренные в шаге 1 главы 2, подробнее рассматриваются в главе 13. 5.2. Литералы 95 Целочисленные литералы Целочисленные литералы для типов Int, Long, Short и Byte используются в двух видах: десятичном и шестнадцатеричном. Способ, применяемый для начала записи целочисленного литерала, показывает основание числа. Если число начинается с 0x или 0X, то оно шестнадцатеричное (по основанию 16) и может содержать цифры от 0 до 9, а также буквы от A до F в верхнем или нижнем регистре. Примеры ис­ пользования выглядят следующим образом: scala> val hex = 0x5 hex: Int = 5 scala> val hex2 = 0x00FF hex2: Int = 255 scala> val magic = 0xcafebabe magic: Int = -889275714 Обратите внимание на то, что оболочка Scala всегда выводит целочисленные значения в десятичном виде, независимо от формы литерала, которую вы могли задействовать для инициализации этих значений. Таким образом, интерпретатор показывает значение переменной hex2, которая была инициализирована с помощью литерала 0x00FF, как десятичное число 255. (Разумеется, не нужно все принимать на веру. Хорошим способом начать осваивать язык станет практическая работа с эти­ ми инструкциями в интерпретаторе по мере чтения данной главы.) Если цифра, с которой начинается число, не нуль и не имеет никаких других знаков отличия, значит, число десятичное (по основанию 10), например: scala> val dec1 = 31 dec1: Int = 31 scala> val dec2 = 255 dec2: Int = 255 scala> val dec3 = 20 dec3: Int = 20 Если целочисленный литерал заканчивается на L или l, значит, показывает число типа Long, в противном случае это число относится к типу Int. Посмотрите на примеры целочисленных литералов Long: scala> val prog = 0XCAFEBABEL prog: Long = 3405691582 scala> val tower = 35L tower: Long = 35 scala> val of = 31l of: Long = 31 96 Глава 5 • Основные типы и операции Если Int-литерал присваивается переменной типа Short или Byte, то рассма­ тривается как принадлежащий к типу Short или Byte, если, конечно, его значение находится внутри диапазона, допустимого для данного типа, например: scala> val little: Short = 367 little: Short = 367 scala> val littler: Byte = 38 littler: Byte = 38 Литералы чисел с плавающей точкой Литералы чисел с плавающей точкой состоят из десятичных цифр, которые также могут содержать необязательный символ десятичной точки, и после них может стоять необязательный символ E или e и экспонента. Посмотрите на примеры ли­ тералов чисел с плавающей точкой: scala> val big = 1.2345 big: Double = 1.2345 scala> val bigger = 1.2345e1 bigger: Double = 12.345 scala> val biggerStill = 123E45 biggerStill: Double = 1.23E47 Обратите внимание: экспонента означает степень числа 10, на которую умножа­ ется остальная часть числа. Следовательно, 1.2345e1 равняется числу 1,2345, умноженному на 101, то есть получается число 12,345. Если литерал числа с плавающей точкой заканчивается на F или f, значит, число относится к типу Float, в противном случае оно относится к типу Double. Дополнительно литералы чисел с плавающей точкой могут заканчиваться на D или d. Посмотрите на примеры литералов чисел с плавающей точкой: scala> val little = 1.2345F little: Float = 1.2345 scala> val littleBigger = 3e5f littleBigger: Float = 300000.0 Последнее значение, выраженное как тип Double, может также принимать иную форму: scala> val anotherDouble = 3e5 anotherDouble: Double = 300000.0 scala> val yetAnother = 3e5D yetAnother: Double = 300000.0 5.2. Литералы 97 Символьные литералы Символьные литералы состоят из любого Unicode-символа, заключенного в оди­ нарные кавычки: scala> val a = 'A' a: Char = A Помимо того что символ представляется в одинарных кавычках в явном виде, его можно указывать с помощью кода из таблицы символов Unicode. Для этого нужно записать \u, после чего указать четыре шестнадцатеричные цифры кода: scala> val d = '\u0041' d: Char = A scala> val f = '\u0044' f: Char = D Такие символы в кодировке Unicode могут появляться в любом месте про­ граммы на языке Scala. Например, вы можете набрать следующий идентификатор: scala> val B\u0041\u0044 = 1 BAD: Int = 1 Он рассматривается точно так же, как идентификатор BAD, являющийся резуль­ татом раскрытия символов в кодировке Unicode в показанном ранее коде. По сути, в именовании идентификаторов подобным образом нет ничего хорошего, поскольку их трудно прочесть. Иногда с помощью этого синтаксиса исходные файлы Scala, которые содержат отсутствующие в таблице ASCII символы из таблицы Unicode, можно представить в кодировке ASCII. И наконец, нужно упомянуть о нескольких символьных литералах, представ­ ленных специальными управляющими последовательностями (escape sequences), показанными в табл. 5.2, например: scala> val backslash = '\\' backslash: Char = \ Таблица 5.2. Управляющие последовательности специальных символьных литералов Литерал Предназначение \n Перевод строки (\u000A) \b Возврат на одну позицию (\u0008) \t Табуляция (\u0009) \f Перевод страницы (\u000C) \r Возврат каретки (\u000D) \” Двойная кавычка (\u0022) \’ Одинарная кавычка (\u0027) \\ Обратный слеш (\u005C) 98 Глава 5 • Основные типы и операции Строковые литералы Строковый литерал состоит из символов, заключенных в двойные кавычки: scala> val hello = "hello" hello: String = hello Синтаксис символов внутри кавычек такой же, как и в символьных литералах, например: scala> val escapes = "\\\"\'" escapes: String = \"' Данный синтаксис неудобен для строк, в которых содержится множество управляющих последовательностей, или для строк, не умещающихся в одну строку текста, поэтому для неформатированных строк в Scala включен специальный син­ таксис. Неформатированная строка начинается и заканчивается тремя идущими подряд двойными кавычками ("""). Внутри нее могут содержаться любые символы, включая символы новой строки, кавычки и специальные символы, за исключением, разумеется, трех кавычек подряд. Например, следующая программа выводит со­ общение, используя неформатированную строку: println("""Welcome to Ultamix 3000. Type "HELP" for help.""") Но при запуске этого кода получается не совсем то, что хотелось: Welcome to Ultamix 3000. Type "HELP" for help. Проблема во включении в строку пробелов перед второй строкой текста! Чтобы справиться с этой весьма часто возникающей ситуацией, вы можете вызывать в от­ ношении строк метод stripMargin. Чтобы им воспользоваться, поставьте символ вертикальной черты (|) перед каждой строкой текста, а затем в отношении всей строки вызовите метод stripMargin: println("""|Welcome to Ultamix 3000. |Type "HELP" for help.""".stripMargin) Вот теперь код ведет себя подобающим образом: Welcome to Ultamix 3000. Type "HELP" for help. Литералы обозначений Литерал обозначения записывается как 'ident, где ident может быть любым бук­ венно-цифровым идентификатором. Такие литералы отображаются на экземпляры предопределенного класса scala.Symbol. Например, литерал 'cymbal будет рас­ ширен компилятором в вызов фабричного метода Symbol("cymbal"). К литералам обозначений обычно прибегают в ситуациях, при которых в динамически типи­ 5.2. Литералы 99 зированных языках вы бы воспользовались просто идентификатором. Например, может потребоваться определить метод, обновляющий запись в базе данных: scala> def updateRecordByName(r: Symbol, value: Any) = { // Сюда помещается код } updateRecordByName: (Symbol,Any)Unit Метод получает в качестве параметров обозначение, указывающее на имя поля, в которое ведется запись, и значение, которым это поле будет обновлено в записи. В динамически типизированных языках данную операцию можно вызвать, пере­ давая методу необъявленный идентификатор поля, но в Scala такой код не пройдет компиляцию: scala> updateRecordByName(favoriteAlbum, "OK Computer") <console>:6: error: not found: value favoriteAlbum updateRecordByName(favoriteAlbum, "OK Computer") ^ Вместо этого практически с такой же лаконичностью можно передать литерал обозначения: scala> updateRecordByName('favoriteAlbum, "OK Computer") С обозначением, кроме как узнать его имя, сделать ничего нельзя: scala> val s = 'aSymbol s: Symbol = 'aSymbol scala> val nm = s.name nm: String = aSymbol Следует также заметить, что обозначения характеризуются изолированностью. Если набрать один и тот же литерал обозначения дважды, то оба выражения будут ссылаться на один и тот же Symbol-объект. Булевы литералы У типа Boolean имеется два литерала, true и false: scala> val bool = true bool: Boolean = true scala> val fool = false fool: Boolean = false Вот, собственно, и все. Теперь вы буквально (или литерально)1 стали большим специалистом по Scala. 1 Образно говоря. 100 Глава 5 • Основные типы и операции 5.3. Интерполяция строк В Scala включен довольно гибкий механизм для интерполяции строк, позволя­ ющий вставлять выражения в строковые литералы. В самом распространенном случае использования этот механизм предоставляет лаконичную и удобочитаемую альтернативу конкатенации строк. Рассмотрим пример: val name = "reader" println(s"Hello, $name!") Выражение s"Hello, $name!" — обрабатываемый строковый литерал. По­ скольку за буквой s стоит открывающая кавычка, то Scala для обработки литерала воспользуется интерполятором строк s. Он станет вычислять каждое встроенное выражение, вызывая в отношении каждого результата метод toString и заменяя встроенные выражения в литерале этими результатами. Таким образом, из s"Hello, $name!" получится "Hello, reader!", то есть точно такой же результат, как при ис­ пользовании кода "Hello, " + name + "!". После знака доллара ($) в обрабатываемом строковом литерале можно указать любое выражение. Для выражений с одной переменной зачастую можно просто по­ местить после знака доллара имя этой переменной. Все символы, вплоть до первого символа, не относящегося к идентификатору, Scala будет интерпретировать как выражение. Если в него включены символы, не являющиеся идентификаторами, то это выражение следует заключить в фигурные скобки, а открывающая фигурная скобка должна ставиться сразу же после знака доллара, например: scala> s"The answer is ${6 * 7}." 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" res1: String = 3.14159 Если для встроенного выражения не указать никаких инструкций форматиро­ вания, то интерполятор строк f по умолчанию превратится в %s, что означает под­ 5.4. Все операторы являются методами 101 становку значения, полученного в результате выполнения метода toString, точно так же, как это делает интерполятор строк s, например: scala> val pi = "Pi" pi: String = Pi scala> f"$pi is approximately ${math.Pi}%.8f." res2: String = Pi is approximately 3.14159265. В Scala интерполяция строк реализуется перезаписью кода в ходе компиляции. Компилятор в качестве выражения интерполятора строк будет рассматривать любое выражение, состоящее из идентификатора, за которым сразу же стоит от­ крывающая двойная кавычка строкового литерала. Интерполяторы строк s, f и raw реализуются с помощью этого общего механизма. Библиотеки и пользователи могут определять другие интерполяторы строк, применяемые в иных целях. 5.4. Все операторы являются методами Для основных типов Scala предоставляет весьма богатый набор операторов. Как упоминалось в предыдущих главах, эти операторы — всего лишь приятный син­ таксис для обычных вызовов методов. Например, 1 + 2 означает то же самое, что и 1.+(2). Иными словами, в классе Int имеется метод по имени +, который полу­ чает Int-значение и возвращает Int-результат. Он вызывается при сложении двух Int-значений: scala> val sum = 1 + 2 // Scala вызывает 1.+(2) sum: Int = 3 Чтобы убедиться в этом, можете набрать выражение, в точности соответству­ ющее вызову метода: scala> val sumMore = 1.+(2) sumMore: Int = 3 Фактически в классе Int содержится несколько перегруженных методов +, полу­ чающих различные типы параметров1. Например, у Int есть еще один метод, тоже по имени +, который получает и возвращает значения типа Long. При сложении Long и Int будет вызван именно этот альтернативный метод: scala> val longSum = 1 + 2L // Scala вызывает 1.+(2L) longSum: Long = 3 Символ + — оператор, точнее, инфиксный оператор. Форма записи операторов не ограничивается методами, подобными +, которые в других языках выглядят как 1 Перегруженные методы имеют точно такие же имена, но используют другие типы аргу­ ментов. Более подробно перегрузка методов рассматривается в разделе 6.11. 102 Глава 5 • Основные типы и операции операторы. Любой метод может использоваться в нотации операторов. Например, в классе String есть метод indexOf, получающий один параметр типа Char. Метод indexOf ведет поиск первого появления в строке указанного символа и возвращает его индекс или -1, если символ найден не будет. Метод indexOf можно использовать как оператор: scala> val s = "Hello, world!" s: String = Hello, world! scala> s indexOf 'o' // Scala вызывает s.indexOf('o') res0: Int = 4 Кроме того, в классе String предлагается перегруженный метод indexOf, полу­ чающий два параметра: символ, поиск которого будет вестись, и индекс, с которого нужно начинать поиск. (Другой метод, показанный ранее indexOf, начинает поиск с нулевого индекса, то есть с начала строки String.) Несмотря на то что данный метод indexOf получает два аргумента, его также можно применять, используя форму записи операторов. Но когда с использованием формы записи операторов вызывается метод, получающий несколько аргументов, последние нужно заключать в круглые скобки. Например, вот каким образом эта другая форма метода indexOf используется в качестве оператора (в продолжение предыдущего примера): scala> s indexOf ('o', 5) // Scala вызывает s.indexOf('o', 5) res1: Int = 8 Оператором может быть любой метод Операторы в Scala не являются специальным синтаксисом языка, оператором мо­ жет быть любой метод. Метод превращается в оператор в зависимости от способа его применения. Когда записывается код s.indexOf('o'), метод indexOf не является оператором. Но, когда запись принимает вид s indexOf 'o', этот метод становится опе­ ратором, поскольку при его использовании применяется форма записи операторов. До сих пор рассматривались только примеры инфиксной формы записи операто­ ров, означающей, что вызываемый метод находится между объектом и параметром или параметрами, которые нужно передать методу, как в выражении 7 + 2. В Scala также имеются две другие формы записи операторов: префиксная и постфиксная. В префиксной форме записи имя метода ставится перед объектом, в отношении ко­ торого вызывается этот метод (например, - в выражении -7). В постфиксной форме имя метода ставится после объекта (например, toLong в выражении 7 toLong). В отличие от инфиксной формы записи, в которой операторы получают два операнда (один слева, другой справа), префиксные и постфиксные операторы яв­ ляются унарными — получают только один операнд. В префиксной форме записи операнд размещается справа от оператора. В качестве примеров можно привести выражения -2.0, !found и ~0xFF. Как и в случае использования инфиксных опера­ 5.4. Все операторы являются методами 103 торов, эти префиксные операторы являются сокращенной формой вызова методов. Но в данном случае перед символом оператора в имени метода ставится приставка unary_. Например, Scala превратит выражение -2.0 в вызов метода (2.0).unary_-. Вы можете убедиться в этом, набрав вызов метода как с использованием формы записи операторов, так и в явном виде: scala> -2.0 // Scala вызывает (2.0).unary_res2: Double = -2.0 scala> (2.0).unary_res3: Double = -2.0 Идентификаторами, которые могут служить в качестве префиксных операторов, являются только +, -, ! и ~. Следовательно, если вы определите метод по имени unary_!, то сможете вызвать его в отношении значения или переменной подходяще­ го типа, прибегнув к префиксной форме записи операторов, например, !p. Но опре­ делив метод по имени unary_*, вы не сможете использовать префиксную форму записи операторов, поскольку * не входит в число четырех идентификаторов, которые могут использоваться в качестве префиксных операторов. Метод можно вызвать обычным способом как p.unary_*, но при попытке вызвать его в виде *p Scala воспримет код так, словно он записан в виде *.p, что, вероятно, совершенно не совпадает с задуманным!1 Постфиксные операторы, будучи вызванными без точки или круглых скобок, являются методами, не получающими аргументов. В Scala при вызове метода пу­ стые круглые скобки можно не ставить. Соглашение гласит, что круглые скобки ставятся в том случае, если метод имеет побочные эффекты, как в случае с методом println(). Но их можно не ставить, если метод не имеет побочных эффектов, как в случае с методом toLowerCase, вызываемым в отношении значения типа String: scala> val s = "Hello, world!" s: String = Hello, world! scala> s.toLowerCase res4: String = hello, world! В последнем случае, где методу не требуются аргументы, можно при желании не ставить точку и воспользоваться постфиксной формой записи операторов: scala> s toLowerCase res5: String = hello, world! Здесь метод toLowerCase используется в качестве постфиксного оператора в от­ ношении операнда s. Чтобы понять, какие операторы можно использовать с основными типами Scala, нужно посмотреть на методы, объявленные в классах типов, в документации 1 Однако не обязательно все будет потеряно. Есть весьма незначительная вероятность того, что программа с кодом *p может скомпилироваться как код C++. 104 Глава 5 • Основные типы и операции по Scala API. Но данная книга — учебник по языку Scala, поэтому в нескольких следующих разделах будет представлен краткий обзор большинства этих методов. УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ ДЛЯ JAVA-ПРОГРАММИСТОВ Многие аспекты Scala, рассматриваемые в оставшейся части главы, совпадают с аналогичными Java-аспектами. Если вы хорошо разбираетесь в Java и у вас мало времени, то можете спокойно перейти к разделу 5.8, в котором рассматриваются отличия Scala от Java в области равенства объектов. 5.5. Арифметические операции Арифметические методы при работе с любыми числовыми типами можно вызвать в инфиксной форме для сложения (+), вычитания (-), умножения (*), деления (/) и получения остатка от деления (%). Вот несколько примеров: scala> 1.2 + 2.3 res6: Double = 3.5 scala> 3 - 1 res7: Int = 2 scala> 'b' - 'a' res8: Int = 1 scala> 2L * 3L res9: Long = 6 scala> 11 / 4 res10: Int = 2 scala> 11 % 4 res11: Int = 3 scala> 11.0f / 4.0f res12: Float = 2.75 scala> 11.0 % 4.0 res13: Double = 3.0 Когда целочисленными типами являются как правый, так и левый операнды (Int, Long, Byte, Short или Char), оператор / выведет всю числовую часть результата деления, исключая остаток. Оператор % показывает остаток от предполагаемого целочисленного деления. Остаток от деления числа с плавающей точкой, полученный с помощью ме­ тода %, не определен в стандарте IEEE 754. Что касается операции вычисления остатка, в этом стандарте используется деление с округлением, а не деление с отбрасыванием остатка. Поэтому данная операция сильно отличается от опе­ 5.6. Отношения и логические операции 105 рации вычисления остатка от целочисленного деления. Если все-таки нужно получить остаток по стандарту IEEE 754, то можно вызвать метод IEEEremainder из scala.math: scala> math.IEEEremainder(11.0, 4.0) res14: Double = -1.0 Числовые типы также предлагают прибегнуть к унарным префиксным операто­ рам + (метод unary_+) и - (метод unary_-), позволяющим показать положительное или отрицательное значение числового литерала, как в -3 или +4.0. Если не указать унарный + или -, то литерал интерпретируется как положительный. Унарный + существует исключительно для симметрии с унарным -, однако не производит никаких действий. Унарный - может также использоваться для смены знака пере­ менной. Вот несколько примеров: scala> val neg = 1 + -3 neg: Int = -2 scala> val y = +3 y: Int = 3 scala> -neg res15: Int = 2 5.6. Отношения и логические операции Числовые типы можно сравнивать с помощью методов отношений «больше» (>), «меньше» (<), «больше или равно» (>=) и «меньше или равно» (<=), которые вы­ дают в качестве результата булево значение. Дополнительно, чтобы инвертировать булево значение, можно использовать унарный оператор ! (метод unary_!). Вот не­ сколько примеров: scala> 1 > 2 res16: Boolean = false scala> 1 < 2 res17: Boolean = true scala> 1.0 <= 1.0 res18: Boolean = true scala> 3.5f >= 3.6f res19: Boolean = false scala> 'a' >= 'A' res20: Boolean = true scala> val untrue = !true untrue: Boolean = false 106 Глава 5 • Основные типы и операции Методы «логическое “И”» (&& и &) и «логическое “ИЛИ”» (|| и |) получают операнды типа Boolean в инфиксной нотации и выдают результат в виде Booleanзначения, например: scala> val toBe = true toBe: Boolean = true scala> val question = toBe || !toBe question: Boolean = true scala> val paradox = toBe && !toBe paradox: Boolean = false Операции && и ||, как и в Java, — сокращенно вычисляемые: выражения, постро­ енные с помощью этих операторов, вычисляются, только когда это нужно для опре­ деления результата. Иными словами, правая часть выражений с использованием && и || не будет вычисляться, если результат уже определился при вычислении левой части. Например, если левая часть выражения с методом && вычисляется в false, то результатом выражения, несомненно, будет false, поэтому правая часть не вычис­ ляется. Аналогично этому, если левая часть выражения с методом || вычисляется в true, то результатом выражения, конечно же, будет true, поэтому правая часть не вычисляется: scala> def salt() = { println("salt"); false } salt: ()Boolean scala> def pepper() = { println("pepper"); true } pepper: ()Boolean scala> pepper() && salt() pepper salt res21: Boolean = false scala> salt() && pepper() salt res22: Boolean = false В первом выражении вызываются pepper и salt , но во втором вызывается только salt. Поскольку salt возвращает false, то необходимость в вызове pepper отпадает. Если правую часть нужно вычислить при любых условиях, то вместо показан­ ных выше методов следует обратиться к методам & и |. Первый выполняет логиче­ скую операцию «И», а второй — операцию «ИЛИ», но при этом они не прибегают к сокращенному вычислению, как этот делают методы && и ||. Вот как выглядит пример их использования: scala> salt() & pepper() salt pepper res23: Boolean = false 5.7. Поразрядные операции 107 ПРИМЕЧАНИЕ Возникает вопрос: как сокращенное вычисление может работать, если операторы — всего лишь методы? Обычно все аргументы вычисляются перед входом в метод, тогда каким же образом метод может избежать вычисления своего второго аргумента? Дело в том, что у всех методов Scala есть средство для задержки вычисления его аргументов или даже полной его отмены. Оно называется «параметр, передаваемый по имени» и будет рассмотрено в разделе 9.5. 5.7. Поразрядные операции Scala позволяет выполнять операции над отдельными разрядами целочислен­ ных типов, используя несколько поразрядных методов. К таким методам от­ носятся поразрядное «И» (&), поразрядное «ИЛИ» (|) и поразрядное исключа­ ющее «ИЛИ» (^)1. Унарный поразрядный оператор дополнения (~, метод unary_~) инвертирует каждый разряд в своем операнде, например: scala> 1 & 2 res24: Int = 0 scala> 1 | 2 res25: Int = 3 scala> 1 ^ 3 res26: Int = 2 scala> ~1 res27: Int = -2 В первом выражении, 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 Метод поразрядного исключающего «ИЛИ» выполняет соответствующую операцию в отношении своих операндов. Из одинаковых разрядов получается 0, а из разных — 1. Следовательно, выражение 0011 ^ 0101 вычисляется в 0110. 108 Глава 5 • Основные типы и операции слева значения по мере сдвига заполняются значением самого старшего разряда (разряда знака). Вот несколько примеров: scala> -1 >> 31 res28: Int = -1 scala> -1 >>> 31 res29: Int = 1 scala> 1 << 2 res30: Int = 4 Число –1 в двоичном виде выглядит как 11111111111111111111111111111111. В первом примере, -1 >> 31, в числе -1 происходит сдвиг вправо на 31 разрядную позицию. В значении типа Int содержатся 32 разряда, поэтому данная операция по сути перемещает самый левый разряд до тех пор, пока тот не станет самым правым1. Поскольку метод >> выполняет заполнение по мере сдвига единицами ввиду того, что самый левый разряд числа –1 — это 1, результат получается идентичным исходному левому операнду и состоит из 32 единичных разрядов или равняется –1. Во втором примере, -1 >>> 31, самый левый разряд опять сдвигается вправо в самую правую по­ зицию, однако на сей раз освобождающиеся разряды заполняются нулями. Поэтому результат в двоичном виде получается 00000000000000000000000000000001, или 1. В последнем примере, 1 << 2, левый операнд, 1, сдвигается влево на две позиции (освобождающиеся позиции заполняются нулями), в результате чего в двоичном виде получается число 00000000000000000000000000000100, или 4. 5.8. Равенство объектов Если нужно сравнить два объекта на равенство, то можно воспользоваться либо методом ==, либо его противоположностью — методом !=. Вот несколько простых примеров: scala> 1 == 2 res31: Boolean = false scala> 1 != 2 res32: Boolean = true scala> 2 == 2 res33: Boolean = true По сути, эти две операции применимы ко всем объектам, а не только к основным типам. Например, оператор == можно использовать для сравнения списков: scala> List(1, 2, 3) == List(1, 2, 3) res34: Boolean = true 1 Самый левый разряд в целочисленном типе является знаковым. Если крайний слева разряд установлен в 1, значит, число отрицательное. Если он в 0, то положительное. 5.8. Равенство объектов 109 scala> List(1, 2, 3) == List(4, 5, 6) res35: Boolean = false Если пойти еще дальше, то можно сравнить два объекта, имеющих разные типы: scala> 1 == 1.0 res36: Boolean = true scala> List(1, 2, 3) == "hello" res37: Boolean = false Можно даже выполнить сравнение со значением null или с тем, что может иметь данное значение. Никакие исключения при этом выдаваться не будут: scala> List(1, 2, 3) == null res38: Boolean = false scala> null == List(1, 2, 3) res39: Boolean = false Как видите, оператор == реализован весьма искусно, и вы в большинстве случаев получите то сравнение на равенство, которое вам нужно. Все делается по очень про­ стому правилу: сначала левая часть проверяется на null. Если ее значение не null, то вызывается метод equals. Ввиду того что equals — метод, точность получаемого сравнения зависит от типа левого аргумента. Проверка на null выполняется авто­ матически, поэтому вам не нужно проводить ее1. Этот вид сравнения выдает true в отношении различных объектов, если их со­ держимое одинаково и их методы equals созданы на основе проверки содержимого. Например, вот как сравниваются две строки, в которых по пять одинаковых букв: scala> ("he" + "llo") == "hello" res40: Boolean = true Чем оператор == в Scala отличается от аналогичного оператора в Java В Java оператор == может использоваться для сравнения как примитивных, так и ссылочных типов. В отношении примитивных типов оператор == в Java проверяет равенство значений, как и в Scala. Но в отношении ссылочных типов оператор == в Java проверяет равенство ссылок. Это значит, две переменные указывают на один и тот же объект в куче, принадлежащей JVM. Scala также предоставляет средство eq для сравнения равенства ссылок. Но метод eq и его противоположность, метод ne, применяются только к объектам, которые непосредственно отображаются на объекты Java. Исчерпывающие подробности о eq и ne приводятся в разделах 11.1 и 11.2. Кроме того, в главе 30 показано, как создавать хорошие методы equals. 1 Автоматическая проверка игнорирует правую сторону, но любой корректно реализован­ ный метод equals должен возвращать false, если его аргумент имеет значение null. 110 Глава 5 • Основные типы и операции 5.9. Приоритет и ассоциативность операторов Приоритет операторов определяет, какая часть выражения вычисляется самой первой. Например, выражение 2 + 2 * 7 вычисляется как 16, а не как 28, поскольку оператор * имеет более высокий приоритет, чем оператор +. Поэтому та часть вы­ ражения, в которой требуется перемножить числа, вычисляется до того, как будет выполнена часть, в которой числа складываются. Разумеется, чтобы уточнить в выражении порядок вычисления или переопределить приоритеты, можно вос­ пользоваться круглыми скобками. Например, если вы действительно хотите, чтобы результат вычисления ранее показанного выражения был 28, то можете набрать следующее выражение: (2 + 2) * 7 Если учесть, что в Scala, по сути, нет операторов, а есть только способ примене­ ния методов в форме записи операторов, то возникает вопрос: а как тогда работает приоритет операторов? Scala принимает решение о приоритете на основе первого символа метода, использованного в форме записи операторов (из этого правила есть одно исключение, рассматриваемое ниже). Если имя метода начинается, к примеру, с *, то он получит более высокий приоритет, чем метод, чье имя на­ чинается на +. Следовательно, выражение 2 + 2 * 7 будет вычислено как 2 + (2 * 7). Аналогично этому выражение a +++ b *** c, в котором a, b и c — переменные, а +++ и *** — методы, будет вычислено как a +++ (b *** c), поскольку метод *** обладает более высоким уровнем приоритета, чем метод +++. Таблица 5.3. Приоритет операторов (Все специальные символы) */% +– : =! <> & ^ | (Все буквы) (Все операторы присваивания) В табл. 5.3 показан приоритет применительно к первому символу метода в убы­ вающем порядке, где символы, показанные на одной строке, определяют оди­ 5.9. Приоритет и ассоциативность операторов 111 наковый уровень приоритета. Чем выше символ в списке, тем выше прио­ритет начинающегося с него метода. Вот пример, показывающий влияние прио­ритета: scala> 2 << 2 + 2 res41: Int = 32 Имя метода << начинается с символа <, который появляется в приведенном списке ниже символа + — первого и единственного символа метода +. Следова­ тельно, << будет иметь более низкий уровень приоритета, чем +, и выражение будет вычислено путем вызова сначала метода + , а затем метода << , как в вы­ ражении 2 << (2 + 2). При сложении 2 + 2 в результате математического действия получается 4, а вычисление выражения 2 << 4 дает результат 32. Если поменять операторы местами, то будет получен другой результат: scala> 2 + 2 << 2 res42: Int = 16 Поскольку первые символы по сравнению с предыдущим примером не изме­ нились, то методы будут вызваны в том же порядке: +, а затем <<. Следовательно, 2 + 2 опять вычислится как 4, а 4 << 2 даст результат 16. Единственное исключение из правил, о существовании которого уже говори­ лось, относится к операторам присваивания, заканчивающихся знаком равенства. Если оператор заканчивается знаком равенства (=) и не относится к одному из операторов сравнения (<=, >=, == и !=), то приоритет оператора имеет такой же уро­ вень, что и простое присваивание (=). То есть он ниже приоритета любого другого оператора. Например: 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 методу :::. Это правило ассоциативности играет роль также при появлении в одном вы­ ражении рядом сразу нескольких операторов с одинаковым уровнем приоритета. 112 Глава 5 • Основные типы и операции Если имена методов заканчиваются на :, они группируются справа налево, в про­ тивном случае — слева направо. Например, a ::: b ::: c рассматривается как a ::: (b ::: c). Но a * b * c, в отличие от этого, рассматривается как (a * b) * c. Правила приоритета операторов — часть языка Scala, и вам не следует бояться применять ими. При этом, чтобы прояснить первоочередность использования опе­ раторов, в некоторых выражениях все же лучше прибегнуть к круглым скобкам. Пожалуй, единственное, на что можно реально рассчитывать в отношении знания порядка приоритета другими программистами, — то, что мультипликативные опе­ раторы *, / и % имеют более высокий уровень приоритета, чем аддитивные + и -. Таким образом, даже если выражение a + b << c выдает нужный результат и без кру­ глых скобок, стоит внести дополнительную ясность с помощью записи (a + b) << c. Это снизит количество нелестных отзывов ваших коллег по поводу использованной вами формы записи операторов, которое выражается, к примеру, в недовольном восклицании вроде «Опять в его коде невозможно разобраться!» и отправке вам сообщения наподобие bills !*&^%~ code!1. 5.10. Обогащающие оболочки В отношении основных типов Scala можно вызвать намного больше методов, чем рассмотрено в предыдущих разделах. Некоторые примеры показаны в табл. 5.4. Эти методы становятся доступными при использовании неявного преобразования — технологии, подробное описание которой будет дано в главе 21. А пока вам нужно знать лишь то, что для каждого основного типа, рассмотренного в текущей главе, существует «обогащающая оболочка», которая предоставляет ряд дополнитель­ ных методов. Поэтому увидеть все доступные методы, применяемые в отношении основных типов, можно, обратившись к документации по API, которая касается обогащающей оболочки для каждого основного типа. Эти классы перечислены в табл. 5.5. Таблица 5.4. Некоторые обогащающие операции 1 Код Результат 0 max 5 5 0 min 5 0 –2.7 abs 2.7 –2.7 round –3L 1.5 isInfinity False (1.0 / 0) isInfinity True Теперь вы уже знаете, что, получив такой код, компилятор Scala создаст вызов (bills.!*&^%~(code)).!(). Резюме 113 Код Результат 4 to 6 Range(4, 5, 6) "bob" capitalize "Bob" "robert" drop 2 "bert" Таблица 5.5. Классы обогащающих оболочек Основной тип Обогащающая оболочка Byte scala.runtime.RichByte Short scala.runtime.RichShort Int scala.runtime.RichInt Long scala.runtime.RichLong Char scala.runtime.RichChar Float scala.runtime.RichFloat Double scala.runtime.RichDouble Boolean scala.runtime.RichBoolean String 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, обе части левого операнда умножаются на 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. 6.2. Конструирование класса Rational 115 Одно, возможно, очевидное наблюдение заключается в том, что в математике рациональные числа не имеют изменяемого состояния. Можно сложить два рацио­ нальных числа, и результатом будет новое рациональное число. Исходные числа не будут изменены. Неизменяемый класс Rational, разрабатываемый в данной гла­ ве, будет иметь такое же свойство. Каждое рациональное число будет представлено одним объектом Rational. При сложении двух объектов Rational для хранения суммы будет создаваться новый объект Rational. В этой главе мы представим некоторые допустимые в Scala способы написания библиотек, которые создают впечатление, будто используется поддержка, присущая непосредственно самому языку программирования. Например, в конце этой главы вы сможете сделать с классом Rational следующее: scala> val oneHalf = new Rational(1, 2) oneHalf: Rational = 1/2 scala> val twoThirds = new Rational(2, 3) twoThirds: Rational = 2/3 scala> (oneHalf / 7) + (1 - twoThirds) res0: Rational = 17/42 6.2. Конструирование класса Rational Конструирование класса Rational неплохо начать с рассмотрения того, как клиен­ ты-программисты будут создавать новый объект Rational. Было решено создавать объекты Rational неизменяемыми, и потому мы потребуем, чтобы эти клиенты при создании экземпляра предоставляли все необходимые ему данные (в нашем случае числитель и знаменатель). Поэтому начнем конструирование со следующего кода: class Rational(n: Int, d: Int) По поводу этой строки кода в первую очередь следует заметить: если у класса нет тела, то вам не нужно ставить пустые фигурные скобки (конечно, при желании можете поставить). Идентификаторы n и d, указанные в круглых скобках после имени класса, Rational, называются параметрами класса. Компилятор Scala под­ берет эти два параметра и создаст первичный конструктор, получающий их же. Плюсы и минусы неизменяемого объекта Неизменяемые объекты имеют ряд преимуществ над изменяемыми и один потен­ циальный недостаток. Во-первых, о неизменяемых объектах проще говорить, чем об изменяемых, поскольку у них нет изменяемых со временем сложных областей состоя­ ния. Во-вторых, неизменяемые объекты можно совершенно свободно куда-нибудь передавать, а перед передачей изменяемых объектов в другой код порой приходится делать страховочные копии. В-третьих, если объект правильно сконструирован, то при одновременном обращении к неизменяемому объекту из двух потоков повредить 116 Глава 6 • Функциональные объекты его состояние невозможно, поскольку никакой поток не может изменить состояние неизменяемого объекта. В-четвертых, неизменяемые объекты обеспечивают безопас­ ность ключей хеш-таблиц. Если, к примеру, изменяемый объект изменился после помещения в HashSet, то в следующий раз при поиске там его можно не найти. Главный недостаток неизменяемых объектов — им иногда требуется копирование больших графов объектов, тогда как вместо этого можно было бы сделать обновление. В некоторых случаях это может быть сложно выразить, а также могут выявиться узкие места в производительности. В результате в библиотеки нередко включают изменя­ емые альтернативы неизменяемым классам. Например, класс StringBuilder — из­ меняемая альтернатива неизменяемого класса String. Дополнительная информация о конструировании изменяемых объектов в Scala будет дана в главе 18. ПРИМЕЧАНИЕ Исходный пример с Rational подчеркивает разницу между Java и Scala. В Java классы имеют конструкторы, которые могут принимать параметры, а в Scala классы могут принимать параметры напрямую. Система записи в Scala куда более лаконична — параметры класса могут использоваться напрямую в теле, нет никакой необходимости определять поля и записывать присваивания, копирующие параметры конструктора в поля. Это может привести к дополнительной экономии на шаблонном коде, особенно когда дело касается небольших классов. Компилятор Scala скомпилирует любой код, помещенный в тело класса и не явля­ ющийся частью поля или определения метода, в первичный конструктор. Напри­ мер, можно вывести такое отладочное сообщение: class Rational(n: Int, d: Int) { println("Created " + n + "/" + d) } Получив данный код, компилятор Scala поместит вызов println в первичный конструктор класса Rational. Поэтому при создании нового экземпляра Rational вызов println приведет к выводу отладочного сообщения: scala> new Rational(1, 2) Created 1/2 res0: Rational = Rational@6121a7dd 6.3. Переопределение метода toString При создании экземпляра Rational в предыдущем примере интерпретатор вывел Rational@2591e0c9. Эта странная строка получилась ввиду вызова в отношении объекта Rational метода toString. По умолчанию класс Rational наследует реали­ зацию toString, определенную в классе java.lang.Object, которая просто выводит имя класса, символ @ и шестнадцатеричное число. Предполагалось, что результат 6.4. Проверка соблюдения предварительных условий 117 выполнения toString поможет программистам, предоставив информацию, которую можно использовать в отладочных инструкциях вывода информации, для ведения логов, в отчетах о сбоях тестов, а также для просмотра выходной информации интерпретатора и отладчика. Результат, выдаваемый на данный момент методом toString, не приносит особой пользы, поскольку не дает никакой информации от­ носительно значения рационального числа. Более полезная реализация toString будет выводить значения числителя и знаменателя объекта Rational. Переопределить исходную реализацию можно, добавив метод toString к классу Rational: class Rational(n: Int, d: Int) { override def toString = s"$n/$d } Модификатор override перед определением метода показывает, что предыду­ щее определение метода переопределяется (более подробно этот вопрос рассма­ тривается в главе 10). Поскольку отныне числа типа Rational будут выводиться совершенно отчетливо, мы удаляем отладочную инструкцию println, помещенную в тело предыдущей версии класса Rational. Теперь новое поведение Rational мож­ но протестировать в интерпретаторе: scala> val x = new Rational(1, 3) x: Rational = 1/3 scala> val y = new Rational(5, 7) y: Rational = 5/7 6.4. Проверка соблюдения предварительных условий В качестве следующего шага переключим внимание на проблему, связанную с те­ кущим поведением первичного конструктора. Как упоминалось в начале главы, рациональные числа не должны содержать нуль в знаменателе. Но пока первичный конструктор может принимать нуль, передаваемый в качестве параметра d: scala> new Rational(5, 0) res1: Rational = 5/0 Одно из преимуществ объектно-ориентированного программирования — воз­ можность инкапсуляции данных внутри объектов, чтобы можно было гарантиро­ вать, что данные корректны в течение всей жизни объекта. В данном случае для такого неизменяемого объекта, как Rational, это значит, что вы должны гаранти­ ровать корректность данных на этапе конструирования объекта при условии, что нулевой знаменатель — недопустимое состояние числа типа Rational и такое число не должно создаваться, если в качестве параметра d передается нуль. Лучше всего решить эту проблему, определив для первичного конструкто­ ра предусловие, согласно которому d должен иметь ненулевое значение. Пред­ условие — ограничение, накладываемое на значения, передаваемые в метод или 118 Глава 6 • Функциональные объекты конструктор, то есть требование, которое должно выполняться вызывающим кодом. Один из способов решить задачу — использовать метод require1: class Rational(n: Int, d: Int) { require(d != 0) override def toString = s"$n/$d } Метод require получает один булев параметр. Если переданное значение приведет к вычислению в true , то из метода require произойдет нормальный выход. В противном случае объект не создастся и будет выдано исключение IllegalArgumentException. 6.5. Добавление полей Теперь, когда первичный конструктор выдвигает нужные предусловия, мы пере­ ключимся на поддержку сложения. Для этого определим в классе Rational пу­ бличный метод add, получающий в качестве параметра еще одно значение типа Rational. Чтобы сохранить неизменяемость класса Rational, метод add не должен прибавлять переданное рациональное число к объекту, в отношении которого он вызван. Ему нужно создать и вернуть новый объект Rational, содержащий сумму. Можно подумать, что метод add создается следующим образом: class Rational(n: Int, d: Int) { // Этот код не будет скомпилирован require(d != 0) override def toString = s"$n/$d def add(that: Rational): Rational = new Rational(n * that.d + that.n * d, d * that.d) } Но, получив этот код, компилятор выдаст свои возражения: <console>:11: error: value d is not a member of Rational new Rational(n * that.d + that.n * d, d * that.d) ^ <console>:11: error: value d is not a member of Rational new Rational(n * that.d + that.n * d, d * that.d) ^ Хотя параметры n и d класса находятся в области видимости кода вашего мето­ да add, получить доступ к их значениям можно только в объекте, в отношении кото­ рого вызван данный метод. Следовательно, когда в реализации последнего указыва­ ется n или d, компилятор рад предоставить вам значения для этих параметров класса. Но он не может позволить указать that.n или that.d, поскольку они не ссылаются 1 Метод require определен в самостоятельном объекте Predef. Как упоминалось в раз­ деле 4.4, элементы класса Predef автоматически импортируются в каждый исходный файл Scala. 6.5. Добавление полей 119 на объект Rational, в отношении которого был вызван метод add1. Чтобы подобным образом получить доступ к числителю или знаменателю, их нужно сделать полями. Как эти поля можно добавить к классу Rational, показано в листинге 6.12. Листинг 6.1. Класс Rational с полями class Rational(n: Int, d: Int) { require(d != 0) val numer: Int = n val denom: Int = d override def toString = s"$numer/$denom" def add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) } В версии Rational, показанной в листинге, добавлены два поля с именами numer и denom, которые были проинициализированы значениями параметров n и d данного класса3. Вдобавок были внесены изменения в реализацию методов toString и add, позволяющие им использовать поля, а не параметры класса. Эта версия класса Rational проходит компиляцию. Ее можно протестировать путем сложения рацио­ нальных чисел: scala> val oneHalf = new Rational(1, 2) oneHalf: Rational = 1/2 scala> val twoThirds = new Rational(2, 3) twoThirds: Rational = 2/3 scala> oneHalf add twoThirds res2: Rational = 7/6 Теперь вы уже можете сделать то, чего не могли сделать раньше, а именно по­ лучить доступ к значениям числителя и знаменателя из-за пределов объекта. Для этого нужно просто обратиться к публичным полям numer и denom: scala> val r = new Rational(1, 2) r: Rational = 1/2 1 Фактически объект Rational можно сложить с самим собой, тогда ссылка будет на тот же объект, в отношении которого был вызван метод add. Но поскольку данному методу можно передать любой объект Rational, то компилятор все же не позволит вам восполь­ зоваться кодом that.n. 2 Параметрические поля, содержащие сокращения для написания аналогичного кода, будут рассматриваться в разделе 10.6. 3 Несмотря на то, что n и d используются в теле класса, и учитывая, что они применяются только внутри конструкторов, компилятор Scala не станет выделять под них поля. Таким образом, получив этот код, компилятор Scala создаст класс с двумя полями типа Int: одним для numer, другим для denom. 120 Глава 6 • Функциональные объекты scala> r.numer res3: Int = 1 scala> r.denom res4: Int = 2 6.6. Собственные ссылки Ключевое слово this позволяет сослаться на экземпляр объекта, в отношении которого был вызван выполняемый в данный момент метод, или, если оно ис­ пользовалось в конструкторе, — на создаваемый экземпляр объекта. Рассмотрим в качестве примера добавление метода lessThan. Он проверяет, не имеет ли объ­ ект Rational, в отношении которого он вызван, значение, меньшее чем значение параметра: def lessThan(that: Rational) = this.numer * that.denom < that.numer * this.denom Здесь выражение this.numer ссылается на числительное объекта, в отношении которого вызван метод lessThan. Можно также не указывать префикс this и на­ писать просто numer, обе записи будут равнозначны. В качестве примера случаев, когда вам не обойтись без this, рассмотрим добав­ ление к классу Rational метода max, возвращающего наибольшее число из заданного рационального числа и переданного аргумента: def max(that: Rational) = if (this.lessThan(that)) that else this Здесь первое ключевое слово this избыточно. Можно его не указывать и на­ писать lessThan(that). Но второе ключевое слово this представляет результат метода в том случае, если тест вернет false, и если вы его не укажете, то возвращать будет просто нечего! 6.7. Вспомогательные конструкторы Иногда нужно, чтобы в классе было несколько конструкторов. В Scala все кон­ структоры, кроме первичного, называются вспомогательными. Например, рацио­ нальное число со знаменателем 1 можно кратко записать просто в виде числителя. Вместо 5/1, например, можно просто указать 5. Поэтому было бы неплохо, что­ бы вместо записи new Rational(5, 1) программисты могли написать просто new Rational(5). Для этого потребуется добавить к классу Rational вспомогательный конструктор, который получает только один аргумент — числитель, а в качестве знаменателя имеет предопределенное значение 1. Как может выглядеть соответ­ ствующий код, показано в листинге 6.2. 6.7. Вспомогательные конструкторы 121 Листинг 6.2. Класс Rational со вспомогательным конструктором class Rational(n: Int, d: Int) { require(d != 0) val numer: Int = n val denom: Int = d def this(n: Int) = this(n, 1) // Вспомогательный конструктор override def toString = s"$numer/$denom" def add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) } Определения вспомогательных конструкторов в Scala начинаются с def this(...). Тело вспомогательного конструктора класса Rational просто вызывает первичный конструктор, передавая дальше свой единственный аргумент n в качестве числителя и 1 — в качестве знаменателя. Увидеть вспомогательный конструктор в действии можно, набрав в интерпретаторе следующий код: scala> val y = new Rational(3) y: Rational = 3/1 В Scala каждый вспомогательный конструктор в качестве первого действия должен вызывать еще один конструктор того же класса. Иными словами, первой инструкции в каждом вспомогательном конструкторе каждого класса Scala следует иметь вид this(...). Вызываемым должен быть либо первичный конструктор (как в примере с классом Rational), либо другой вспомогательный конструктор, который появляет­ ся в тексте программы перед вызывающим его конструктором. Конечный результат применения данного правила заключается в том, что каждый вызов конструктора в Scala должен в конце концов завершаться вызовом первичного конструктора класса. Первичный конструктор, таким образом, — единственная точка входа в класс. ПРИМЕЧАНИЕ Знатоков Java может удивить то, что в Scala правила в отношении конструкторов более строгие, чем в Java. Ведь в Java первым действием конструктора должен быть либо вызов другого конструктора того же класса, либо вызов конструктора суперкласса напрямую. В классе Scala конструктор суперкласса может быть вызван только первичным конструктором. Более сильные ограничения в Scala фактически являются компромиссом дизайна — платой за большую лаконичность и простоту конструкторов Scala по сравнению с конструкторами Java. Суперклассы и подробности вызова конструкторов и наследования будут рассмотрены в главе 10. 122 Глава 6 • Функциональные объекты 6.8. Приватные поля и методы В предыдущей версии класса Rational мы просто инициализировали numer значе­ нием n, а denom — значением d. Из-за этого числитель и знаменатель Rational могут превышать необходимые значения. Например, дробь 66/42 можно сократить и при­ вести к виду 11/7, но первичный конструктор класса Rational пока этого не делает: scala> new Rational(66, 42) res5: Rational = 66/42 Чтобы выполнить такое сокращение, нужно разделить числитель и знаменатель на их наибольший общий делитель. Например, таковым для 66 и 42 будет число 6. (Иными словами, 6 — наибольшее целое число, на которое без остатка делится как 66, так и 42.) Деление и числителя, и знаменателя числа 66/42 на 6 приводит к получению сокращенной формы 11/7. Один из способов решения данной задачи показан в листинге 6.3. Листинг 6.3. Класс 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 add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) override def toString = s"$numer/$denom" private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) } В данной версии класса Rational было добавлено приватное поле g и измене­ ны инициализаторы для полей numer и denom. (Инициализатором называется код, инициализирующий переменную, например, n / g, который инициализирует поле numer.) Поле g является приватным, поэтому доступ к нему может быть выполнен изнутри, но не снаружи тела класса. Кроме того, был добавлен приватный метод по имени gcd, вычисляющий наибольший общий делитель двух переданных ему значений Int. Например, вызов gcd(12, 8) дает результат 4. Как было показано в разделе 4.1, чтобы сделать поле или метод приватным, следует просто поставить перед его определением ключевое слово private. Назначение приватного «вспомо­ 6.9. Определение операторов 123 гательного метода» gcd — обособление кода, необходимого для остальных частей класса, в данном случае для первичного конструктора. Чтобы обеспечить посто­ янное положительное значение поля g, методу передаются абсолютные значения параметров n и d, которые вызов получает в отношении этих параметров метода abs. Последний может вызываться в отношении любого Int-объекта в целях получения его абсолютного значения. Компилятор Scala поместит коды инициализаторов трех полей класса Rational в первичный конструктор в порядке их следования в исходном коде. Таким об­ разом, инициализатор поля g, имеющий код gcd(n.abs, d.abs), будет выполнен до выполнения двух других инициализаторов, поскольку в исходном коде по­ является первым. Поле g будет инициализировано результатом — наибольшим общим делителем абсолютных значений параметров n и d класса. Затем поле g будет использовано в инициализаторах полей numer и denom. Разделив n и d на их наибольший общий делитель g, каждый объект Rational можно сконструировать в нормализованной форме: scala> new Rational(66, 42) res6: Rational = 11/7 6.9. Определение операторов Текущая реализация класса Rational нас вполне устраивает, но ее можно сделать гораздо более удобной в использовании. Вы можете спросить, почему допустима запись: x + y если x и y — целые числа или числа с плавающей точкой. Но когда это рациональ­ ные числа, приходится пользоваться записью: x.add(y) или в крайнем случае: x add y Такое положение дел ничем не оправдано. Рациональные числа совершенно неотличимы от остальных чисел. В математическом смысле они гораздо более естественны, скажем, числа с плавающей точкой. Так почему бы не воспользоваться во время работы с ними естественными математическими операторами? И в Scala есть такая возможность. Она будет показана в оставшейся части главы. Сначала нужно заменить add обычным математическим символом. Сделать это нетрудно, поскольку знак + является в Scala вполне допустимым иденти­ фикатором. Можно просто определить метод с именем +. Если уж на то пошло, то можно определить и метод *, выполняющий умножение. Результат показан в листинге 6.4. 124 Глава 6 • Функциональные объекты Листинг 6.4. Класс 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 = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) def * (that: Rational): Rational = new Rational(numer * that.numer, denom * that.denom) override def toString = s"$numer/$denom" private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) } После такого определения класса Rational можно будет воспользоваться сле­ дующим кодом: scala> val x = new Rational(1, 2) x: Rational = 1/2 scala> val y = new Rational(2, 3) y: Rational = 2/3 scala> x + y res7: Rational = 7/6 Как всегда, синтаксис оператора в последней строке ввода — эквивалент вызова метода. Можно также использовать следующий код: scala> x.+(y) res8: Rational = 7/6 но читать его будет намного труднее. Следует также заметить, что из-за действующих в Scala правил приоритета опе­ раторов, рассмотренных в разделе 5.9, метод * будет привязан к объектам Rational сильнее метода +. Иными словами, выражения, в которых к объектам Rational при­ меняются операции + и *, будут вести себя вполне ожидаемым образом. Например, x + x * y будет выполняться как x + (x * y), а не как (x + x) * y: 6.10. Идентификаторы в Scala 125 scala> x + x * y res9: Rational = 5/6 scala> (x + x) * y res10: Rational = 2/3 scala> x + (x * y) res11: Rational = 5/6 6.10. Идентификаторы в Scala Вам уже встречались два наиболее важных способа составления идентификаторов в Scala — из буквенно-цифровых символов и из операторов. В Scala используются весьма гибкие правила формирования идентификаторов. Кроме двух уже встречав­ шихся форм, существуют еще две. Все четыре формы составления идентификаторов рассматриваются в этом разделе. Буквенно-цифровые идентификаторы начинаются с буквы или знака подчерки­ вания, за которыми могут следовать другие буквы, цифры или знаки подчеркива­ ния. Символ $ также считается буквой, но зарезервирован для идентификаторов, создаваемых компилятором Scala. Идентификаторы в пользовательских програм­ мах не должны содержать символы $, несмотря на возможность успешно пройти компиляцию: если это произойдет, то могут возникнуть конфликты имен с теми идентификаторами, которые будут созданы компилятором Scala. В Scala соблюдается соглашение, принятое в Java относительно применения идентификаторов в смешанном регистре1, таких как toString и HashSet. Хотя использование знаков подчеркивания в идентификаторах вполне допустимо, в программах на Scala они встречаются довольно редко — отчасти в целях со­ блюдения совместимости с Java, а также из-за того, что знаки подчеркивания в коде Scala активно применяются не только для идентификаторов. Поэтому лучше избегать таких идентификаторов, как, например, to_string , __init__ или name_ . Имена полей, параметры методов, имена локальных переменных и имена функций в смешанном регистре должны начинаться с буквы в нижнем регистре, например: length, flatMap и s. Имена классов и трейтов в смешанном регистре должны начинаться с буквы в верхнем регистре, например: BigInt, List и UnbalancedTreeMap2. 1 Этот стиль именования идентификаторов называется верблюжьим, поскольку у иден­ тификаторов ИмеютсяГорбы, состоящие из символов в верхнем регистре. 2 В разделе 16.5 вы увидите, что иногда может возникнуть желание придать классу осо­ бый вид, как у case-класса, чье имя состоит только из символов оператора. Например, в API Scala имеется класс по имени ::, облегчающий сопоставление с образцом для объ­ ектов List. 126 Глава 6 • Функциональные объекты ПРИМЕЧАНИЕ Одним из последствий использования в идентификаторе замыкающего знака подчеркивания при попытке, к примеру, написания объявления val name_: Int = 1, может стать ошибка компиляции. Компилятор подумает, что вы пытаетесь объявить val-переменную по имени name_:. Чтобы такой идентификатор прошел компиляцию, перед двоеточием нужно поставить дополнительный пробел, как в коде val name_ : Int = 1. Один из примеров отступления Scala от соглашений, принятых в Java, касается имен констант. В Scala слово «константа» означает не только val-переменную. Даже притом, что val-переменная остается неизменной после инициализации, она не перестает быть переменной. Например, параметры метода относятся к valпеременным, но при каждом вызове метода в этих val-переменных содержатся раз­ ные значения. Константа обладает более выраженным постоянством. Например, scala.math.Pi определяется как значение с двойной точностью, наиболее близкое к реальному значению числа π — отношению длины окружности к ее диаметру. Это значение вряд ли когда-либо изменится, поэтому со всей очевидностью можно сказать, что Pi — константа. Константы можно использовать также для присваивания имен значениям, которые иначе были бы в вашем коде магическими числами — буквальными значениями без объяснений, которые в худшем случае появлялись бы в коде в нескольких местах. Вдобавок может понадобиться опре­ делить константы для использования при сопоставлении с образцом (подобный случай будет рассматриваться в разделе 15.2). В соответствии с соглашением, принятым в Java, константам присваиваются имена, в которых используются символы в верхнем регистре, где знак подчеркивания является разделителем слов, например MAX_VALUE или PI. В Scala соглашение требует, чтобы в верхнем регистре была только первая буква. Таким образом, константы, названные в стиле Java, например X_OFFSET, будут работать в Scala в качестве констант, но согласно соглашению, принятому в Scala, для имен констант применяется смешанный ре­ гистр, например XOffset. Идентификатор оператора состоит из одного или нескольких символов опе­ раторов. Таковыми являются выводимые на печать ASCII-символы, такие как +, :, ?, ~ или #1. Ниже показаны некоторые примеры идентификаторов операторов: + ++ ::: <?> :–> Компилятор Scala на внутреннем уровне перерабатывает идентификаторы операторов, чтобы превратить их в допустимые Java-идентификаторы со встро­ енными символами $ . Например, идентификатор :-> будет представлен как 1 Точнее, символ оператора принадлежит к математическим символам (Sm) или прочим символам (So) набора Unicode либо к семибитным ASCII-символам, не являющимся буквами, цифрами, круглыми скобками, квадратными скобками, фигурными скобками, одинарными или двойными кавычками или знаками подчеркивания, точки, точки с за­ пятой, запятой или обратных кавычек. 6.11. Перегрузка методов 127 $colon$minus$greater. Если вам когда-либо захочется получить доступ к этому идентификатору из кода Java, то потребуется использовать данное внутреннее представление. Поскольку идентификаторы операторов в Scala могут принимать произволь­ ную длину, то между Java и Scala есть небольшая разница в этом вопросе. В Java введенный код x<-y будет разобран на четыре лексических символа, в результате чего станет эквивалентен x < - y. В Scala оператор <- будет рассмотрен как единый идентификатор, в результате чего получится x <- y. Если нужно получить пер­ вую интерпретацию, то следует отделить символы < и - друг от друга пробелом. На практике это вряд ли станет проблемой, так как немногие станут писать на Java x<-y, не вставляя пробелы или круглые скобки между операторами. Смешанный идентификатор состоит из буквенно-цифрового идентификато­ ра, за которым стоят знак подчеркивания и идентификатор оператора. Напри­ мер, unary_+, использованный как имя метода, определяет унарный оператор +. А myvar_=, использованный как имя метода, определяет оператор присваивания. Кроме того, смешанный идентификатор вида myvar_= генерируется компилятором Scala в целях поддержки свойств (более подробно этот вопрос рассматривается в главе 18). Литеральный идентификатор представляет собой произвольную строку, за­ ключенную в обратные кавычки (`...`). Примеры литеральных идентификаторов выглядят следующим образом: `x` `<clinit>` `yield` Замысел состоит в том, что между обратными кавычками можно поместить лю­ бую строку, которую среда выполнения станет воспринимать в качестве идентифи­ катора. В результате всегда будет получаться идентификатор Scala. Это сработает даже в том случае, если имя, заключенное в обратные кавычки, является в Scala зарезервированным словом. Обычно такие идентификаторы используются при обращении к статическому методу yield в Java-классе Thread. Вы не можете прибег­ нуть к коду Thread.yield(), поскольку в Scala yield является зарезервированным словом. Но имя метода все же можно применить, если заключить его в обратные кавычки, например Thread.`yield`(). 6.11. Перегрузка методов Вернемся к классу Rational. После внесения последних изменений появилась возможность применять операции сложения и умножения рациональных чисел в их естественном виде. Но мы все же упустили из виду смешанную арифметику. Например, вы не можете умножить рациональное число на целое, поскольку опе­ ранды у оператора * всегда должны быть объектами Rational. Следовательно, для рационального числа r вы не можете написать код r * 2. Вам нужно написать r * new Rational(2), а это имеет неприглядный вид. 128 Глава 6 • Функциональные объекты Чтобы сделать класс Rational еще более удобным в использовании, добавим к нему новые методы, выполняющие смешанное сложение и умножение рацио­ нальных и целых чисел. А заодно добавим методы вычитания и деления. Результат показан в листинге 6.5. Листинг 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 = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) def + (i: Int): Rational = new Rational(numer + i * denom, denom) def - (that: Rational): Rational = new Rational( numer * that.denom - that.numer * denom, denom * that.denom ) def - (i: Int): Rational = new Rational(numer - i * denom, denom) def * (that: Rational): Rational = new Rational(numer * that.numer, denom * that.denom) def * (i: Int): Rational = new Rational(numer * i, denom) def / (that: Rational): Rational = new Rational(numer * that.denom, denom * that.numer) def / (i: Int): Rational = new Rational(numer, denom * i) override def toString = s"$numer/$denom" private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) } 6.12. Неявные преобразования 129 Теперь здесь две версии каждого арифметического метода: одна в качестве аргумента получает рациональное число, вторая — целое. Иными словами, все эти методы называются перегруженными, поскольку каждое имя теперь используется несколькими методами. Например, имя + применяется и методом, получающим объект Rational, и методом, получающим объект Int. При вызове метода компи­ лятор выбирает версию перегруженного метода, которая в точности соответствует типу аргументов. Например, если аргумент y в вызове x.+(y) является объектом Rational, то компилятор выберет метод +, получающий в качестве параметра объект Rational. Но если аргумент — целое число, то компилятор выберет метод +, полу­ чающий в качестве параметра объект Int. Если испытать код в действии: scala> val x = new Rational(2, 3) x: Rational = 2/3 scala> x * x res12: Rational = 4/9 scala> x * 2 res13: Rational = 4/3 станет понятно, что вызываемый метод * определяется всякий раз по типу его правого операнда. ПРИМЕЧАНИЕ В Scala процесс анализа при выборе перегруженного метода очень похож на аналогичный процесс в Java. В любом случае выбирается перегруженная версия, которая лучше подходит к статическим типам аргументов. Иногда случается, что одной такой версии нет, и тогда компилятор выдаст ошибку, связанную с неоднозначной ссылкой, — ambiguous reference. 6.12. Неявные преобразования Теперь, когда можно воспользоваться кодом r * 2, вы также можете захотеть по­ менять операнды местами, задействовав код 2 * r. К сожалению, пока этот код работать не будет: scala> 2 * r ^ error: overloaded method value * with alternatives: (x: Double)Double <and> (x: Float)Float <and> (x: Long)Long <and> (x: Int)Int <and> (x: Char)Int <and> (x: Short)Int <and> (x: Byte)Int cannot be applied to (Rational) 130 Глава 6 • Функциональные объекты Проблема в том, что эквивалент выражения 2 * r — выражение 2.*(r), то есть вызов метода в отношении числа 2, которое является целым. Но в классе Int не со­ держится метода умножения, получающего в качестве аргумента объект Rational, его там и не может быть, поскольку он не входит в состав стандартных классов библиотеки Scala. Но в Scala есть другой способ решения этой проблемы: можно создать неявное преобразование, которое при необходимости автоматически превратит целые числа в рациональные. Попробуйте добавить в интерпретатор следующую строку кода: scala> implicit def intToRational(x: Int) = new Rational(x) Она определяет метод преобразования из типа Int в тип Rational. Модификатор implicit перед определением метода сообщает компилятору о том, что в ряде ситуа­ ций данный метод следует применять автоматически. Определив преобразование, можно еще раз попытаться выполнить код, который перед этим дал сбой: scala> val r = new Rational(2,3) r: Rational = 2/3 scala> 2 * r res15: Rational = 4/3 Чтобы неявное преобразование заработало, оно должно находиться в области видимости. Если поместить определение метода с модификатором implicit внутрь класса Rational, то он не попадет в область видимости интерпретатора. Пока вам следует определить его непосредственно в интерпретаторе. Из данного примера можно сделать вывод, что неявное преобразование — весь­ ма эффективная технология для придания библиотекам гибкости и повышения удобства их использования. Такие преобразования весьма серьезно воздейству­ ют на код программы, поэтому в способах их применения можно легко наделать ошибок. Дополнительные сведения о неявных преобразованиях, а также способах их помещения в случае необходимости в область видимости будут представлены в главе 21. 6.13. Предостережение Создание методов с именами операторов и определение неявного преобразования, продемонстрированные в этой главе, призваны помочь в проектировании библио­ тек, для которых код клиента будет лаконичным и понятным. Scala предоставляет широкие возможности для разработки таких весьма доступных для использования библиотек. Но, пожалуйста, имейте в виду: реализуя возможности, не стоит забы­ вать об ответственности. При неумелом использовании и методы-операторы, и неявные преобразования могут сделать клиентский код таким, что его станет трудно читать и понимать. Выполнение компилятором неявного преобразования никак внешне не проявляет­ Резюме 131 ся и не записывается в явном виде в исходный код. Поэтому программистам-кли­ ентам может быть невдомек, что именно оно и применяется в вашем коде. И хотя методы-операторы обычно делают клиентский код более лаконичным и читабель­ ным, таким он становится только для наиболее сведущих программистов-клиентов, способных запомнить и распознать значение каждого оператора. При проектировании библиотек всегда нужно стремиться сделать клиентский код не просто лаконичным, но и легко читаемым и понятным. Читабельность в значительной степени может быть обусловлена лаконичностью, которая спо­ собна заходить очень далеко. Проектируя библиотеки, позволяющие добиваться изысканной лаконичности, и в то же время создавая понятный клиентский код, вы можете существенно повысить продуктивность работы программистов, которые используют эти библиотеки. Резюме В этой главе мы рассмотрели многие аспекты классов Scala. Вы увидели способы добавления к классу параметров, определили несколько конструкторов, операто­ ры и методы и настроили классы таким образом, чтобы их применение приобрело более естественный вид. Важнее всего, вероятно, было показать вам, что опреде­ ление и использование неизменяющихся объектов — вполне естественный способ программирования на Scala. Хотя показанная здесь финальная версия класса Rational соответствует всем требованиям, обозначенным в начале главы, ее можно усовершенствовать. Позже мы вернемся к этому примеру. В частности, в главе 30 будет рассмотрено пере­ определение методов equals и hashcode, которое позволяет объектам Rational улучшить свое поведение в момент, когда их сравнивают с помощью операто­ ра == или помещают в хеш-таблицы. В главе 21 поговорим о том, как помещать неявные определения методов в объекты-компаньоны класса 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 тоже стали вы­ давать значения. Программисты могут использовать полученное в результате значение, чтобы упростить свой код, применяя те же приемы, что и для значений, возвращаемых функциями. Не будь этой особенности, программистам пришлось бы создавать временные переменные, просто чтобы хранить результаты, вычисленные внутри управляющей конструкции. Отказ от таких переменных немного упрощает код, а также избавляет от многих ошибок, возникающих, когда в одном ответвлении переменная создается, а в другом о ее создании забывают. В целом, основные управляющие конструкции Scala в минимальном составе обеспечивают все, что нужно было взять из императивных языков. При этом они 7.1. Выражения if 133 позволяют сделать код более лаконичным за счет неизменного наличия значений, получаемых в результате их применения. Чтобы показать все это в работе, далее более подробно рассмотрим основные управляющие конструкции Scala. 7.1. Выражения if Выражение if в Scala работает практически так же, как во многих других языках. Оно проверяет условие, а затем выполняет одну из двух ветвей кода в зависимости от того, вычисляется ли условие в true. Простой пример, написанный в импера­ тивном стиле, выглядит следующим образом: var filename = "default.txt" if (!args.isEmpty) filename = args(0) В этом коде объявляется переменная по имени filename , которая инициа­ лизируется значением по умолчанию. Затем в нем используется выражение if с целью проверить, предоставлены ли программе какие-либо аргументы. Если да, то в переменную вносят изменения, чтобы в ней содержалось значение, указанное в списке аргументов. Если нет, то выражение оставляет значение переменной, установленное по умолчанию. Этот код можно сделать гораздо более выразительным, поскольку, как упоми­ налось в шаге 3 главы 2, выражение if в Scala возвращает значение. В листинге 7.1 показано, как можно выполнить те же самые действия, что и в предыдущем при­ мере, не прибегая к использованию var-переменных. Листинг 7.1. Особый стиль Scala, применяемый для условной инициализации val filename = if (!args.isEmpty) args(0) else "default.txt" На этот раз у if имеются два ответвления. Если массив args непустой, то вы­ бирается его начальный элемент args(0). В противном случае выбирается значе­ ние по умолчанию. Выражение if выдает результат в виде выбранного значения, которым инициализируется переменная filename. Данный код немного короче предыдущего. Но гораздо более существенно то, что в нем используется val-, а не var-переменная. Это соответствует функциональному стилю и помогает вам при­ мерно так же, как применение финальной (final) переменной в Java. Она сообщает читателям кода, что переменная никогда не изменится, избавляя их от необходи­ мости просматривать весь код в области видимости переменной, чтобы понять, изменяется ли она где-нибудь. Второе преимущество использования var-переменной вместо val-переменной заключается в том, что она лучше поддерживает выводы, которые делаются с по­ мощью эквациональных рассуждений (equational reasoning). Введенная переменная равна вычисляющему выражению при условии, что у него нет побочных эффектов. 134 Глава 7 • Встроенные управляющие конструкции Таким образом, всякий раз, собираясь написать имя переменной, вы можете вместо него написать выражение. Вместо println(filename), к примеру, можно просто написать следующий код: println(if (!args.isEmpty) args(0) else "default.txt") Выбор за вами. Вы можете прибегнуть к любому из вариантов. Использование val-переменных помогает совершенно безопасно проводить подобный рефакторинг кода по мере его развития. Всегда ищите возможности для применения val-переменных. Они смогут упростить не только чтение вашего кода, но и его рефакторинг. 7.2. Циклы while Используемые в Scala циклы while ведут себя точно так же, как и в других языках. В них имеются условие и тело, которое выполняется снова и снова, пока условие вычисляется в true. Пример показан в листинге 7.2. Листинг 7.2. Вычисление наибольшего общего делителя с применением цикла while def gcdLoop(x: Long, y: Long): Long = { var a = x var b = y while (a != 0) { val temp = a a = b % a b = temp } b } В Scala имеется также цикл do-while. Он работает точно так же, как и цикл while, но условие в нем проверяется после тела цикла, а не до него. В листинге 7.3 показан скрипт на Scala, использующий цикл do-while для вывода на экран строк, считанных со стандартного устройства ввода, до тех пор, пока не будет введена пустая строка. Листинг 7.3. Чтение со стандартного устройства ввода с применением цикла do-while var line = "" do { line = readLine() println("Read: " + line) } while (line != "") Конструкции while и do-while называются циклами, а не выражениями, по­ скольку в результате выполнения они не возвращают никакого содержательного значения. Типом результата является Unit. Получается так, что фактически суще­ 7.2. Циклы while 135 ствует только одно значение, имеющее тип Unit. Оно называется unit-значением и записывается как (). Существование () отличает имеющийся в Scala класс Unit от используемого в Java типа void. Попробуйте выполнить в интерпретаторе сле­ дующий код: scala> def greet() = { println("hi") } greet: ()Unit scala> val whatAmI = greet() hi whatAmI: Unit = () Поскольку тип выражения println("hi") определяется как Unit, то greet опре­ деляется как процедура с результирующим типом Unit. Поэтому greet возвращает Unit значение (). Это подтверждается в следующей строке, где переменная whatAmI имеет тип Unit и возвращает значение (). Еще одна конструкция с типом результата в виде Unit-значения, о которой здесь стоит упомянуть, — присваивание нового значения var-переменным. Например, если попробовать считать строки в Scala, воспользовавшись следующей идиомой цикла while из Java (а также из C и C++), то возникнет проблема: var line = "" while ((line = readLine()) != "") // Этот код не будет работать! println("Read: " + line) При компиляции этого кода Scala выдаст предупреждение о том, что сравнение значений типа Unit и String с использованием != всегда дает результат true. В то время как в Java присваивание выдает в качестве результата присваиваемое зна­ чение (в данном случае строку из стандартного ввода), в Scala оно всегда выдает Unit-значение (). Таким образом, значением присваивания line = readLine() всегда будет (), но никак не "". В результате условие данного цикла while никогда не будет вычислено в false, и из-за этого цикл никогда не завершится. В результате цикла while никакое значение не возвращается, поэтому зачастую его не включают в чисто функциональные языки. В таких языках имеются выра­ жения, а не циклы. И тем не менее цикл while включен в Scala, поскольку иногда императивные решения бывает легче читать, особенно тем программистам, которые много работали с императивными языками. Например, если нужно закодировать алгоритм, повторяющий процесс до тех пор, пока не изменятся какие-либо условия, то цикл while позволяет выразить это напрямую, в то время как функциональную альтернативу наподобие использования рекурсии читателям кода распознавать оказывается сложнее. Например, в листинге 7.4 показан альтернативный способ определения наиболь­ шего общего знаменателя двух чисел1. При условии присваивания x и y в функции 1 Здесь в функции gcd используется точно такой же подход, который применялся в одно­ именной функции в листинге 6.3 в целях вычисления наибольших общих делителей для класса Rational. Основное отличие заключается в том, что вместо Int-значений gcd код работает с Long-значениями. 136 Глава 7 • Встроенные управляющие конструкции gcd таких же значений, как и в функции gcdLoop, показанной в листинге 7.2, будет выдан точно такой же результат. Разница между этими двумя подходами состоит в том, что функция gcdLoop написана в императивном стиле с использованием varпеременных и цикла while, а функция gcd — в более функциональном стиле с при­ менением рекурсии (gcd вызывает саму себя), для чего не нужны var-переменные. Листинг 7.4. Вычисление наибольшего общего делителя с применением рекурсии def gcd(x: Long, y: Long): Long = if (y == 0) x else gcd(y, x % y) В целом мы рекомендуем относиться к циклам while в своем коде с оглядкой, как и к использованию в нем var-переменных. Фактически циклы while и varпеременные зачастую идут рука об руку. Поскольку циклы не дают результата в виде значения, то для внесения в программу каких-либо изменений цикл while обычно будет нуждаться либо в обновлении var-переменных, либо в выполнении ввода-вывода. В этом можно убедиться, посмотрев на работу показанного ранее примера gcdLoop. По мере выполнения своей задачи цикл while обновляет значения var-переменных a и b. Поэтому мы советуем проявлять особую осмотрительность при использовании в коде циклов while. Если нет достаточных оснований для применения цикла while или do-while, то попробуйте найти способ сделать то же самое без их участия. 7.3. Выражения for Используемые в Scala выражения for являются для итераций чем-то наподобие швейцарского армейского ножа. Чтобы можно было реализовать широкий спектр итераций, эти выражения позволяют различными способами составлять комби­ нации из довольно простых ингредиентов. Простое применение дает возможность решать простые задачи вроде поэлементного обхода последовательности целых чисел. Более сложные выражения могут выполнять обход элементов нескольких коллекций различных видов, фильтровать элементы на основе произвольных условий и создавать новые коллекции. Обход элементов коллекций Самое простое, что можно сделать с выражением for, — это выполнить обход всех элементов коллекции. Например, в листинге 7.5 показан код, который выводит име­ на всех файлов, содержащихся в текущем каталоге. Ввод-вывод выполняется с по­ мощью API Java. Сначала в текущем каталоге, ".", создается объект java.io.File и вызывается его метод listFiles. Последний возвращает массив объектов File, по одному на каталог или файл, содержащийся в текущем каталоге. Получившийся в результате массив сохраняется в переменной filesHere. 7.3. Выражения for 137 Листинг 7.5. Получение списка файлов в каталоге с применением выражения for val filesHere = (new java.io.File(".")).listFiles for (file <- filesHere) println(file) С помощью синтаксиса file <- filesHere, называемого генератором, выпол­ няется обход элементов массива filesHere . При каждой итерации значением элемента инициализируется новая val-переменная по имени file. Компилятор приходит к выводу, что типом file является File, поскольку filesHere имеет тип Array[File]. Для каждой итерации выполняется тело выражения for, имеющее код println(file). Метод toString, определенный в классе File, выдает имя файла или каталога, поэтому будут выведены имена всех файлов и каталогов текущего каталога. Синтаксис выражения for работает не только с массивами, но и с коллекциями любого типа1. Особый и весьма удобный случай — применение типа Range, который был упомянут в табл. 5.4 (см. выше). Можно создавать объекты Range, используя синтаксис вида 1 to 5, и выполнять их обход с помощью for. Простой пример имеет следующий вид: scala> for (i <- 1 to 4) println("Iteration " + i) Iteration 1 Iteration 2 Iteration 3 Iteration 4 Если не нужно включать верхнюю границу диапазона в перечисляемые значе­ ния, то вместо to используется until: scala> for (i <- 1 until 4) println("Iteration " + i) Iteration 1 Iteration 2 Iteration 3 Перебор целых чисел наподобие этого встречается в Scala довольно часто, но намного реже, чем в других языках, где данным свойством можно воспользоваться для перебора элементов массива: // В Scala такой код встречается довольно редко... for (i <- 0 to filesHere.length - 1) println(filesHere(i)) 1 Точнее, выражение справа от символов <– в выражении for может быть любого типа, имеющего определенные методы (в данном случае foreach) с соответствующими сигна­ турами. Подробности того, как компилятор Scala обрабатывает выражения for, рассма­ триваются в главе 23. 138 Глава 7 • Встроенные управляющие конструкции В это выражение for введена переменная i, которой по очереди присваивается каждое целое число от 0 до filesHere.length - 1, и для каждой установки i выпол­ няется тело выражения. Для каждого значения i из массива filesHere извлекается и обрабатывается i-й элемент. Такого вида итерации меньше распространены в Scala потому, что есть возмож­ ность выполнить непосредственный обход элементов коллекции. При этом код становится короче и исключаются многие ошибки смещения на единицу, которые могут возникнуть при обходе элементов массива. С чего нужно начинать, с 0 или 1? Что нужно прибавлять к завершающему индексу, –1, +1 или вообще ничего? На подобные вопросы ответить несложно, но также просто дать и неверный ответ. Безопаснее их вообще исключить. Фильтрация Иногда перебирать коллекцию целиком не нужно, а требуется отфильтровать ее в некое подмножество. В выражении for это можно сделать путем добавления фильтра в виде условия if, указанного в выражении for внутри круглых скобок. Например, код, показанный в листинге 7.6, выводит список только тех файлов текущего каталога, имена которых заканчиваются на .scala. Листинг 7.6. Поиск файлов с расширением .scala с помощью for с фильтром val filesHere = (new java.io.File(".")).listFiles for (file <- filesHere if file.getName.endsWith(".scala")) println(file) Для достижения той же цели можно применить альтернативный вариант: for (file <- filesHere) if (file.getName.endsWith(".scala")) println(file) Этот код выдает на выходе то же самое, что и предыдущий, и выглядит, ве­ роятно, более привычно для программистов с опытом работы на императивных языках. Но императивная форма — только вариант, поскольку данное выражение for выполняется в целях получения побочных эффектов, выражающихся в выво­ де данных, и выдает результат в виде Unit-значения (). Чуть позже в этом разделе будет показано, что for называется выражением, так как по итогу его выполнения получается представляющий интерес результат, то есть коллекция, чей тип опре­ деляется компонентами <- выражения for. Если потребуется, то в выражение можно включить еще больше фильтров. В него просто нужно добавлять условия if. Например, чтобы обеспечить безопас­ ность, код в листинге 7.7 выводит только файлы, исключая каталоги. Для этого добавляется фильтр, который проверяет имеющийся у файла метод isFile. 7.3. Выражения for 139 Листинг 7.7. Использование в выражении for нескольких фильтров for ( file <- filesHere if file.isFile if file.getName.endsWith(".scala") ) println(file) Вложенные итерации Если добавить несколько операторов <-, то будут получены вложенные циклы. Например, выражение for , показанное в листинге 7.8, имеет два таких цикла. Внешний перебирает элементы массива filesHere , а внутренний — элементы fileLines(file) для каждого файла, имя которого заканчивается на .scala. Листинг 7.8. Использование в выражении for нескольких генераторов def fileLines(file: java.io.File) = scala.io.Source.fromFile(file).getLines().toArray def grep(pattern: String) = for ( file <- filesHere if file.getName.endsWith(".scala"); line <- fileLines(file) if line.trim.matches(pattern) ) println(s"file: ${line.trim}") grep(".*gcd.*") При желании вокруг генераторов и фильтров вместо круглых скобок можно поставить фигурные. Одно из преимуществ использования фигурных скобок за­ ключается в том, что они позволяют не ставить некоторые точки с запятой, которые нужны в момент применения круглых скобок. Это возможно потому, что (см. объ­ яснение в разделе 4.2) компилятор Scala не будет определять использование точки с запятой внутри круглых скобок. Привязки промежуточных переменных Обратите внимание на повторение в предыдущем коде выражения line.trim . Данное вычисление довольно сложное, и потому выполнить его лучше один раз. Сделать это позволяет привязка результата к новой переменной с помощью знака равенства (=). Связанная переменная вводится и используется точно так же, как и val-переменная, но ключевое слово val не ставится. Пример показан в листинге 7.9. 140 Глава 7 • Встроенные управляющие конструкции Листинг 7.9. Промежуточное присваивание в выражении for def grep(pattern: String) = for { file <- filesHere if file.getName.endsWith(".scala") line <- fileLines(file) trimmed = line.trim if trimmed.matches(pattern) } println(s"file: $trimmed") grep(".*gcd.*") В листинге 7.9 переменная по имени trimmed вводится в ходе выполнения выражения for. Ее инициализирует результат вызова метода line.trim. Затем остальная часть кода выражения for использует новую переменную в двух местах: в выражении if и в методе println. Создание новой коллекции Хотя во всех рассмотренных до сих пор примерах вы работали со значениями, получаемыми при обходе элементов, после чего о них уже не вспоминали, у вас есть возможность создать значение, чтобы запомнить результат каждой итерации. Для этого нужно перед телом выражения for поставить ключевое слово yield. Рассмотрим, к примеру, функцию, которая определяет файлы с расширением .scala и сохраняет их имена в массиве: def scalaFiles = for { file <- filesHere if file.getName.endsWith(".scala") } yield file При каждом выполнении тела выражения for создается одно значение, в дан­ ном случае это просто file. Когда выполнение выражения for завершится, резуль­ тат будет включать все выданные значения, содержащиеся в единой коллекции. Тип получающейся коллекции задается на основе вида коллекции, обрабатываемой операторами итерации. В данном случае результат будет иметь тип Array[File], поскольку filesHere является массивом, а выдаваемые выражением значения от­ носятся к типу File. Кстати, будьте внимательны при выборе места для ключевого слова yield. Син­ таксис для выражения for-yield имеет следующий вид: for условия yield тело Ключевое слово yield ставится перед всем телом. Даже если последнее явля­ ется блоком, заключенным в фигурные скобки, yield нужно ставить перед первой 7.4. Обработка исключений с помощью выражений try 141 фигурной скобкой, а не перед последним выражением блока. Избегайте создания кода, похожего на следующий: for (file <- filesHere if file.getName.endsWith(".scala")) { yield file // Синтаксическая ошибка! } Например, выражение for, показанное в листинге 7.10, сначала преобразует объект типа Array[File] по имени filesHere, в котором содержатся имена всех файлов, которые есть в текущем каталоге, в объект, содержащий только име­ на файлов с расширением .scala. Для каждого из элементов создается объект типа Array[String] , являющийся результатом выполнения метода fileLines , определение которого показано в листинге 7.8. Каждый элемент этого объекта Array[String] содержит одну строку из текущего обрабатываемого файла. Данный объект превращается в другой объект типа Array[String], содержащий только те строки, обработанные методом trim, которые включают подстроку "for". И на­ конец, для каждого из них выдается целочисленное значение длины. Результатом этого выражения for становится объект, имеющий тип Array[Int] и содержащий эти значения длины. Листинг 7.10. Преобразование объекта типа Array[File] в объект типа Array[Int] с помощью выражения for val forLineLengths = for { file <- filesHere if file.getName.endsWith(".scala") line <- fileLines(file) trimmed = line.trim if trimmed.matches(".*for.*") } yield trimmed.length Итак, основные свойства выражения for, применяемого в Scala, рассмотрены, но мы прошлись по ним слишком поверхностно. Более подробно о выражениях for поговорим в главе 23. 7.4. Обработка исключений с помощью выражений try Исключения в Scala ведут себя практически так же, как во многих других языках. Вместо того чтобы возвращать значение (как это обычно происходит), метод может прервать работу с генерацией исключения. Код, вызвавший метод, может либо перехватить и обработать данное исключение, либо прекратить собствен­ ное выполнение, при этом передав исключение тому коду, который его вызвал. Исключение распространяется подобным образом, раскручивая стек вызова, до 142 Глава 7 • Встроенные управляющие конструкции тех пор, пока не встретится обрабатывающий его метод или вообще не останется ни одного метода. Генерация исключений Генерация исключений в Scala выглядит так же, как в Java. Создается объект ис­ ключения, который затем бросается с помощью ключевого слова throw: throw new IllegalArgumentException Как бы парадоксально это ни звучало, в Scala throw — это выражение, у которого есть результирующий тип. Рассмотрим пример, где этот тип играет важную роль: val half = if (n % 2 == 0) n / 2 else throw new RuntimeException("n должно быть четным числом ") Здесь получается, что если n — четное число, то переменная half будет инициа­ лизирована половинным значением n. Если нечетное, то исключение сгенерируется еще до того, как переменная half вообще инициализируется каким-либо значением. Поэтому генерируемое исключение можно без малейших опасений рассматривать как абсолютно любое значение. Любой контекст, который пытается воспользовать­ ся значением, возвращаемым из throw, никогда не сможет этого сделать, и потому никакого вреда ожидать не приходится. С технической точки зрения сгенерированное исключение имеет тип Nothing. Генерацией исключения можно воспользоваться, даже если оно никогда и ни во что не будет вычислено. Подобные технические нюансы могут показаться несколько странными, но в случаях, подобных предыдущему примеру, зачастую могут при­ годиться. Одно ответвление от if вычисляет значение, а другое выдает исключение и вычисляется в Nothing. Тогда типом всего выражения if является тип, вычислен­ ный в той ветви, в которой проводилось вычисление. Тип Nothing дополнительно будет рассмотрен в разделе 11.3. Перехват исключений Перехват исключений выполняется с применением синтаксиса, показанного в ли­ стинге 7.11. Для выражений catch был выбран синтаксис с прицелом на совмести­ мость с весьма важной частью Scala — сопоставлением с образцом. Этот механизм — весьма эффективное средство, которое вкратце рассматривается в данной главе, а более подробно — в главе 15. Листинг 7.11. Применение в Scala конструкции try-catch import java.io.FileReader import java.io.FileNotFoundException import java.io.IOException 7.4. Обработка исключений с помощью выражений try 143 try { val f = new FileReader("input.txt") // использование и закрытие файла } catch { case ex: FileNotFoundException => // обработка ошибки отсутствия файла case ex: IOException => // обработка других ошибок ввода-вывода } Поведение данного выражения try-catch ничем не отличается от его поведения в других языках, использующих исключения. Если при выполнении тела генериру­ ется исключение, то по очереди предпринимается попытка выполнить каждый ва­ риант case. Если в данном примере исключение имеет тип FileNotFoundException, то будет выполнено первое условие, если тип IOException — то второе. Если ис­ ключение не относится ни к одному из этих типов, то выражение try-catch прервет свое выполнение и исключение будет распространено далее. ПРИМЕЧАНИЕ Одно из отличий Scala от Java, которое довольно просто заметить, заключается в том, что язык Scala не требует от вас перехватывать проверяемые исключения или их объявления в условии генерации исключений. При необходимости условие генерации исключений можно объявить с помощью аннотации @throws, но делать это не обязательно. Дополнительную информацию о @throws можно найти в разделе 31.2. Условие finally Если нужно, чтобы некий код выполнялся независимо от того, как именно завер­ шилось выполнение выражения, то можно воспользоваться условием finally, за­ ключив в него этот код. Например, может понадобиться гарантированное закрытие открытого файла, даже если выход из метода произошел с генерацией исключения. Пример показан в листинге 7.121. Листинг 7.12. Применение в Scala условия try-finally import java.io.FileReader val file = new FileReader("input.txt") try { // использование файла } finally { file.close() // гарантированное закрытие файла } 1 Хотя инструкции case оператора catch всегда нужно заключать в фигурные скобки, try и finally не требуют использования фигурных скобок, если в них содержится только одно выражение. Например, можно написать: try t() catch { case e: Exception => ... } finally f(). 144 Глава 7 • Встроенные управляющие конструкции ПРИМЕЧАНИЕ В листинге 7.12 показан характерный для языка способ гарантированного закрытия ресурса, не имеющего отношения к оперативной памяти, например файла, сокета или подключения к базе данных. Сначала вы получаете ресурс. Затем запускается на выполнение блок try, в котором используется этот ресурс. И наконец, вы закрываете ресурс в блоке finally. Этот способ характерен для Scala точно так же, как и для Java, но в качестве альтернативного варианта достичь той же цели более лаконичным способом в Scala можно с помощью технологии под названием «шаблон временного пользования» (loan pattern). Он будет рассмотрен в разделе 9.4. Выдача значения Как и большинство других управляющих конструкций Scala, try-catch-finally выдает значение. Например, в листинге 7.13 показано, как можно попытаться разо­ брать URL, но при этом воспользоваться значением по умолчанию в случае плохого формирования URL. Результат получается при выполнении условия try, если не генерируется исключение, или же при выполнении связанного с ним условия catch, если исключение генерируется и перехватывается. Значение, вычисленное в условии finally, при наличии такового, отбрасывается. Как правило, условия finally выполняют какую-либо подчистку, например закрытие файла. Обычно они не должны изменять значение, вычисленное в основном теле или в catchусловии, связанном с try. Листинг 7.13. Условие catch, выдающее значение import java.net.URL import java.net.MalformedURLException def urlFor(path: String) = try { new URL(path) } catch { case e: MalformedURLException => new URL("http://www.scala-lang.org") } Если вы знакомы с Java, то стоит отметить, что поведение Scala отличается от поведения Java только тем, что используемая в Java конструкция try-finally не возвращает в результате никакого значения. Как и в Java, если в условие finally включена в явном виде инструкция возвращения значения return или же в нем генерируется исключение, то это возвращаемое значение или исключение будут перевешивать все ранее выданное try или одним из его условий catch. Например, если взять вот такое несколько надуманное определение функции: def f(): Int = try return 1 finally return 2 то при вызове f() будет получен результат 2. Для сравнения, если взять определение: def g(): Int = try 1 finally 2 7.5. Выражения match 145 то при вызове g() будет получен результат 1. Обе функции демонстрируют пове­ дение, которое может удивить большинство программистов, поэтому все же лучше обойтись без значений, возвращаемых из условий finally. Условие finally более предпочтительно считать способом, который гарантирует выполнение какого-либо побочного эффекта, например закрытие открытого файла. 7.5. Выражения match Используемое в Scala выражение сопоставления match позволяет выбрать из не­ скольких альтернатив (вариантов), как это делается в других языках с помощью инструкции switch. В общем, выражение match позволяет задействовать произ­ вольные шаблоны, которые будут рассмотрены в главе 15. Общая форма может подождать. А пока нужно просто рассматривать использование match для выбора среди ряда альтернатив. В качестве примера скрипт, показанный в листинге 7.14, считывает из списка аргументов название пищевого продукта и выводит пару к нему. Это выражение match анализирует значение переменной firstArg, которое установлено на пер­ вый аргумент, извлеченный из списка аргументов. Если это строковое значение "salt" («соль»), то оно выводит "pepper" («перец»), а если это "chips" («чипсы»), то "salsa" («острый соус») и т. д. Вариант по умолчанию указывается с помощью знака подчеркивания (_), который является подстановочным символом, часто ис­ пользуемым в Scala в качестве заместителя для неизвестного значения. Листинг 7.14. Выражение сопоставления с побочными эффектами val firstArg = if (args.length > 0) args(0) else "" firstArg match { case "salt" => println("pepper") case "chips" => println("salsa") case "eggs" => println("bacon") case _ => println("huh?") } Есть несколько важных отличий от используемой в Java инструкции switch. Одно из них заключается в том, что в case-инструкциях Scala наряду с прочим могут применяться любые разновидности констант, а не только константы цело­ численного типа, перечисления или строковые константы, как в case-инструкциях Java. В представленном выше листинге в качестве альтернатив используются строки. Еще одно отличие заключается в том, что в конце каждой альтернативы нет инструкции break. Она присутствует неявно, и нет «выпадения» (fall through) с одной альтернативы на следующую. Общий случай — без «выпадения» — стано­ вится короче, а частых ошибок удается избежать, поскольку программисты теперь не «выпадают» нечаянно. Но, возможно, наиболее существенным отличием от switch-инструкции яв­ ляется то, что выражения сопоставления дают значение. В предыдущем примере 146 Глава 7 • Встроенные управляющие конструкции в каждой альтернативе в выражении сопоставления на стандартное устройство выводится значение. Как показано в листинге 7.15, данный вариант будет работать так же хорошо и выдавать значение вместо того, чтобы выводить его на устройство. Значение, получаемое из этого выражения сопоставления, сохраняется в перемен­ ной friend. Кроме того, что код становится короче (по крайней мере на несколько символов), теперь он выполняет две отдельные задачи: сначала выбирает продукт питания, а затем выводит его на устройство. Листинг 7.15. Выражение сопоставления, выдающее значение val firstArg = if (!args.isEmpty) args(0) else "" val friend = firstArg match { case "salt" => "pepper" case "chips" => "salsa" case "eggs" => "bacon" case _ => "huh?" } println(friend) 7.6. Программирование без break и continue Вероятно, вы заметили, что здесь не упоминались ни break, ни continue. Из Scala эти инструкции исключены, поскольку плохо сочетаются с функциональными литералами, которые описываются в следующей главе. Назначение инструкции continue в цикле while понятно, но что она будет означать внутри функциональ­ ного литерала? Так как в Scala поддерживаются оба стиля программирования — и императивный, и функциональный, — в данном случае из-за упрощения языка прослеживается небольшой перекос в сторону функционального программирова­ ния. Но волноваться не стоит. Существует множество способов писать программы, не прибегая к break и continue, и если воспользоваться функциональными лите­ ралами, то варианты с ними зачастую могут быть короче первоначального кода. Простейший подход заключается в замене каждой инструкции continue услови­ ем if, а каждой инструкции break — булевой переменной. Последняя показывает, должен ли продолжаться охватывающий цикл while. Предположим, ведется поиск в списке аргументов строки, которая заканчивается на .scala, но не начинается с дефиса. В Java можно, отдавая предпочтение циклам while, а также инструкциям break и continue, написать следующий код: int I = 0; // Это код Java boolean foundIt = false; while (I < args.length) { if (args[i].startsWith("-")) { I = I + 1; continue; 7.6. Программирование без break и continue 147 } if (args[i].endsWith(".scala")) { foundIt = true; break; } I = I + 1; } Данный фрагмент на Java можно перекодировать непосредственно в код Scala. Для этого, вместо того чтобы использовать условие if с последующей инструк­ цией continue, можно написать условие if, охватывающее всю оставшуюся часть цикла while. Чтобы избавиться от break, обычно добавляют булеву переменную, которая указывает на необходимость продолжения, но в данном случае можно задействовать уже существующую переменную foundIt. При использовании этих двух приемов код приобретает вид, показанный в листинге 7.16. Листинг 7.16. Выполнение цикла без break или continue var i = 0 var foundIt = false while (i < args.length && !foundIt) { if (!args(i).startsWith("-")) { if (args(i).endsWith(".scala")) foundIt = true } i = i + 1 } Код Scala, показанный в листинге 7.16, очень похож на первоначальный код Java. Основные части остались на месте и располагаются в том же порядке. Использу­ ются две переназначаемые переменные и цикл while. Внутри цикла выполняются проверки того, что i меньше args.length, а также наличия "-" и ".scala". Если в коде листинга 7.16 нужно избавиться от var-переменных, то можно по­ пробовать применить один из подходов, заключающийся в переписывании цикла в рекурсивную функцию. Можно, к примеру, определить функцию searchFrom, которая получает на входе целочисленное значение, выполняет поиск с указанной им позиции, а затем возвращает индекс желаемого аргумента. При использовании данного приема код приобретет вид, показанный в листинге 7.17. Листинг 7.17. Рекурсивная альтернатива циклу с применением var-переменных def searchFrom(i: Int): Int = if (i >= args.length) -1 else if (args(i).startsWith("-")) searchFrom(i + 1) else if (args(i).endsWith(".scala")) i else searchFrom(i + 1) val i = searchFrom(0) В версии, показанной в данном листинге, в имени функции отображено ее назначение, изложенное в понятной человеку форме, а в качестве замены цикла 148 Глава 7 • Встроенные управляющие конструкции применяется рекурсия. Каждая инструкция continue заменена рекурсивным вызовом, в котором в качестве аргумента используется выражение i + 1, позволя­ ющее эффективно переходить к следующему целочисленному значению. Многие программисты, привыкшие к рекурсиям, считают этот стиль программирования более наглядным. ПРИМЕЧАНИЕ В действительности компилятор Scala не будет выдавать для кода, показанного в листинге 7.17, рекурсивную функцию. Поскольку все рекурсивные вызовы находятся в хвостовой позиции, то компилятор создаст код, похожий на цикл while. Каждый рекурсивный вызов будет реализован как возврат к началу функции. Оптимизация хвостовых вызовов рассматривается в разделе 8.9. Если после всего сказанного вам все еще нужен break, то поможет стандартная библиотека Scala. Для выхода из охватывающего блока кода, помеченного как breakable, можно задействовать метод break, который предлагается в классе Breaks из пакета scala.util.control. Пример использования этого метода, предоставляе­ мого библиотекой, выглядит следующим образом: import scala.util.control.Breaks._ import java.io._ val in = new BufferedReader(new InputStreamReader(System.in)) breakable { while (true) { println("? ") if (in.readLine() == "") break } } Этот код будет многократно считывать непустые строки со стандартного устрой­ ства ввода. Как только пользователь введет пустую строку, поток управления вы­ полнит выход из охватывающего блока breakable, а вместе с ним и из цикла while. В классе Breaks метод break реализуется путем генерации исключения, пере­ хватываемого приложением, которое охватывает метод в блоке breakable. Поэтому вызову данного метода не нужно быть в том же методе, в котором вызывается блок breakable. 7.7. Область видимости переменных Теперь, когда стала понятна суть встроенных управляющих конструкций Scala, мы воспользуемся ими, чтобы объяснить, как в этом языке работает область ви­ димости. 7.7. Область видимости переменных 149 УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ ДЛЯ JAVA-ПРОГРАММИСТОВ Если вы программировали на Java, то увидите, что правила области видимости в Scala почти идентичны правилам, которые действуют в Java. Единственное различие между языками состоит в том, что в Scala позволяется определять переменные с одинаковыми именами во вложенных областях видимости. Поэтому Java-программистам может быть интересно по крайней мере бегло просмотреть данный раздел. У объявлений переменных в программах Scala есть область видимости, опреде­ ляющая, где конкретно может использоваться имя переменной. Самый распростра­ ненный пример определения области видимости — применение фигурных скобок, которые, как правило, вводят новую область видимости, и все, что определяется внутри этих скобок, теряет видимость после закрывающей скобки1. Рассмотрим в качестве иллюстрации функцию, показанную в листинге 7.18. Листинг 7.18. Область видимости переменных при выводе на стандартное устройство таблицы умножения def printMultiTable() = { var i = 1 // Здесь в области видимости только переменная i while (i <= 10) { var j = 1 // Здесь в области видимости обе переменные, как i, так и j while (j <= 10) { val prod = (i * j).toString // Здесь в области видимости i, j и prod var k = prod.length // Здесь в области видимости i, j, prod и k while (k < 4) { print(" ") k += 1 } print(prod) j += 1 } 1 Из этого правила есть несколько исключений, поскольку в Scala иногда можно исполь­ зовать вместо круглых фигурные скобки. Один из примеров подобного использования фигурных скобок — альтернативный синтаксис выражения for, рассмотренный в раз­ деле 7.3. 150 Глава 7 • Встроенные управляющие конструкции // i и j все еще в области видимости, а prod и k — уже нет println() i += 1 } // i все еще в области видимости, а j, prod и k — уже нет } Показанная здесь функция printMultiTable выводит таблицу умножения1. В первой инструкции этой функции вводится переменная i, которая инициализи­ руется целым числом 1. Затем имя i можно использовать во всей остальной части функции. Следующая инструкция в printMultiTable является циклом while: while (i <= 10) { var j = 1 ... } Переменная i может использоваться здесь, поскольку по-прежнему находится в области видимости. В первой инструкции внутри цикла while вводится еще одна переменная, которой дается имя j, и она также инициализируется значением 1. Переменная j была определена внутри открытого блока с фигурными скобками, который относится к циклу while, поэтому может использоваться только внутри данного цикла while. При попытке что-либо сделать с j после закрывающей фи­ гурной скобки цикла while после комментария, сообщающего, что j, prod и k уже вне области видимости, ваша программа не будет скомпилирована. Все переменные, определенные в этом примере: i, j, prod и k — локальные. Они локальны по отношению к функциям, в которых определены. При каждом вызове функции используется новый набор локальных переменных. После определения переменной определить новую переменную с таким же именем в той же области видимости уже нельзя. Например, следующий скрипт с двумя переменными по имени a в одной и той же области видимости скомпили­ рован не будет: val a = 1 val a = 2 // Не скомпилируется println(a) В то же время во внешней области видимости вполне возможно определить переменную с точно таким же именем, что и во внутренней области видимости. Следующий скрипт будет скомпилирован и сможет быть запущен: val a = 1; { 1 Функция printMultiTable, показанная в листинге 7.18, написана в императивном стиле. В следующем разделе она будет приведена к функциональному стилю. 7.7. Область видимости переменных 151 val a = 2 // Компилируется без проблем println(a) } println(a) При выполнении данный скрипт выведет 2, а затем 1, поскольку переменная a, определенная внутри фигурных скобок, — это уже другая переменная, область видимости которой распространяется только до закрывающей фигурной скобки1. Следует отметить одно различие между Scala и Java. Оно состоит в том, что Java не позволит создать во внутренней области видимости переменную, имя которой совпадает с именем переменной во внешней области видимости. В программе на Scala внутренняя переменная, как говорят, перекрывает внешнюю переменную с точно таким же именем, поскольку внешняя переменная становится невидимой во внутренней области видимости. Вы уже, вероятно, замечали что-либо подобное эффекту перекрытия в интер­ претаторе: scala> val a = 1 a: Int = 1 scala> val a = 2 a: Int = 2 scala> println(a) 2 Там имена переменных можно использовать повторно как вам угодно. Среди прочего, это позволяет вам передумать, если при первом определении переменной в интерпретаторе была допущена ошибка. Подобная возможность появляется благодаря тому, что интерпретатор концептуально создает для каждой введенной вами инструкции новую вложенную область видимости. Таким образом, можно визуализировать ранее введенный код следующим образом: val a = 1; { val a = 2; { println(a) } } Этот код будет скомпилирован и запущен как скрипт на Scala и, подобно коду, набранному в интерпретаторе, выведет 2. Следует помнить: такой код может сильно запутать читателей, поскольку имена переменных приобретают во вложенных об­ ластях видимости совершенно новый смысл. Зачастую вместо того, чтобы перекры­ вать внешнюю переменную, лучше выбрать для переменной новое узнаваемое имя. 1 Кстати, в данном случае после первого определения нужно поставить точку с запятой, поскольку в противном случае действующий в Scala механизм, который подразумевает их использование, не сработает. 152 Глава 7 • Встроенные управляющие конструкции 7.8. Рефакторинг кода, написанного в императивном стиле Чтобы помочь вам вникнуть в функциональный стиль, в данном разделе мы проведем рефакторинг императивного подхода к выводу таблицы умножения, показанной в листинге 7.18. Наша функциональная альтернатива представлена в листинге 7.19. Листинг 7.19. Функциональный способ создания таблицы умножения // возвращение строчки в виде последовательности def makeRowSeq(row: Int) = for (col <- 1 to 10) yield { val prod = (row * col).toString val padding = " " * (4 – prod.length) padding + prod } // возвращение строчки в виде строкового значения def makeRow(row: Int) = makeRowSeq(row).mkString // возвращение таблицы в виде строковых значений, по одному значению // на каждую строчку def multiTable() = { val tableSeq = // последовательность строк из строчек таблицы for (row <- 1 to 10) yield makeRow(row) tableSeq.mkString("\n") } На наличие в листинге 7.18 (см. выше) императивного стиля указывают два момента. Первый — побочный эффект от вызова printMultiTable — вывод таблицы умножения на стандартное устройство. В листинге 7.19 функция реорганизована таким образом, чтобы возвращать таблицу умножения в виде строкового значения. Поскольку функция больше не занимается выводом на стандартное устройство, то переименована в multiTable. Как уже упоминалось, одно из преимуществ функ­ ций, не имеющих побочных эффектов, — упрощение их модульного тестирования. Для тестирования printMultiTable понадобилось бы каким-то образом переопре­ делять print и println , чтобы можно было проверить вывод на корректность. А протестировать multiTable гораздо проще — проверив ее строковой результат. Второй момент, служащий верным признаком императивного стиля в функции printMultiTable — ее цикл while и var-переменные. В отличие от этого в функции multiTable для выражений, вспомогательных функций и вызовов mkString исполь­ зуются val-переменные. Чтобы облегчить чтение кода, мы выделили две вспомогательные функции: makeRowSeq и makeRow . Первая использует выражение for , генератор которого перебирает номера столбцов от 1 до 10. В теле этого выражения вычисляется Резюме 153 произведение значения строки на значение столбца, определяется отступ, не­ обходимый для произведения, выдается результат объединения строк отступа и произведения. Результатом выражения for будет последовательность (один из подклассов scala.Seq), содержащая выданные строки в качестве элементов. Вторая вспомогательная функция, makeRow, просто вызывает метод mkString в отношении результата, возвращенного функцией makeRowSeq. Этот метод объединяет имеющи­ еся в последовательности строки, возвращая их в виде одной строки. Метод multiTable сначала инициализирует tableSeq результатом выполнения выражения for, генератор которого перебирает числа от 1 до 10, чтобы для каж­ дого вызова makeRow получалось строковое значение для данной строки таблицы. Именно эта строка и выдается, вследствие чего результатом выполнения данного выражения for будет последовательность строковых значений, представляющих строки таблицы. Остается лишь преобразовать последовательность строк в одну строку. Для выполнения этой задачи вызывается метод mkString, и, поскольку ему передается значение "\n", мы получаем символ конца строки, вставленный после каждой строки. Передав строку, возвращенную multiTable , функции println , вы увидите, что выводится точно такая же таблица, что и при вызове функции printMultiTable: 1 2 3 4 5 6 7 8 9 10 2 4 6 8 10 12 14 16 18 20 3 6 9 12 15 18 21 24 27 30 4 8 12 16 20 24 28 32 36 40 5 10 15 20 25 30 35 40 45 50 6 12 18 24 30 36 42 48 54 60 7 14 21 28 35 42 49 56 63 70 8 16 24 32 40 48 56 64 72 80 9 10 18 20 27 30 36 40 45 50 54 60 63 70 72 80 81 90 90 100 Резюме Перечень встроенных в Scala управляющих конструкций минимален, но они вполне справляются со своими задачами. Их работа похожа на действия их императивных эквивалентов, но, поскольку им свойственно выдавать значение, они поддерживают и функциональный стиль. Не менее важно и то, что управляющие конструкции внимательны к тому, что опускают, оставляя, таким образом, поле деятельности для одной из самых эффективных возможностей Scala — функциональных литералов, которые рассматриваются в следующей главе. 8 Функции и замыкания По мере роста программ появляется необходимость разбивать их на небольшие части, которыми удобнее управлять. Разделение потока управления в Scala реали­ зуется с помощью подхода, знакомого всем опытным программистам: разделение кода на функции. Фактически в Scala предлагается несколько способов определе­ ния функций, которых нет в Java. Кроме методов, представляющих собой функции, являющиеся частью объектов, есть также функции, вложенные в другие функции, функциональные литералы и функциональные значения. В данной главе вам пред­ стоит познакомиться со всеми этими разновидностями функций, имеющимися в Scala. 8.1. Методы Наиболее распространенный способ определения функций — включение их в со­ став объекта. Такая функция называется методом. В качестве примера в листин­ ге 8.1 показаны два метода, которые вместе считывают данные из файла с заданным именем и выводят строки, длина которых превышает заданную. Перед каждой выведенной строкой указывается имя файла, в котором она появляется. Листинг 8.1. LongLines с приватным методом processLine import scala.io.Source object LongLines { def processFile(filename: String, width: Int) = { val source = Source.fromFile(filename) for (line <- source.getLines()) processLine(filename, width, line) } 8.1. Методы 155 private def processLine(filename: String, width: Int, line: String) = { if (line.length > width) println(filename + ": " + line.trim) } } Метод processFile получает в качестве параметров имя файла и длину строки. Он создает объект Source, и в отношении этого объекта в генераторе выражения for вызывается метод getLines. Как упоминалось в шаге 12 главы 3, метод getLines возвращает итератор, который при каждой итерации выдает одну строку из файла, исключая при этом символ конца строки. Выражение for обрабатывает каждую из этих строк путем вызова вспомогательного метода processLine. Последний полу­ чает три параметра: имя файла (filename), длину строки (width) и строку (line). Он проверяет длину строки на превышение заданной длины и при положительном результате выводит имя файла, двоеточие и строку. Чтобы использовать LongLines из командной строки, мы создадим приложение, которое ожидает применения длины строки в качестве первого аргумента команд­ ной строки и интерпретирует последующие аргументы в качестве имен файлов1: object FindLongLines { def main(args: Array[String]) = { val width = args(0).toInt for (arg <- args.drop(1)) LongLines.processFile(arg, width) } } Для поиска в LongLines.scala строк, длина которых превышает 45 символов (там всего одна такая строка), это приложение можно использовать следующим образом: $ scala FindLongLines 45 LongLines.scala LongLines.scala: def processFile(filename: String, width: Int) = { До сих пор все это было очень похоже на то, что бы вы делали в любом объ­ ектно-ориентированном языке. Но концепция функции в Scala намного уни­ версальнее применения простого метода. В данном языке программирования предлагаются другие способы выражения функций; они рассматриваются в сле­ дующих разделах. 1 В примерах приложений, которые даются в книге, проверка аргументов командной строки чаще всего проводиться не будет как из соображений экономии лесных угодий, так и в силу стремления избавиться от повторяющегося кода, который может заслонить собой важный код примеров. Издержки такого решения будут выражаться в том, что вместо выдачи информативного сообщения об ошибке в случае введения неверных данных наши примеры приложений будут генерировать исключение. 156 Глава 8 • Функции и замыкания 8.2. Локальные функции Конструкция метода processFile из предыдущего раздела показала важность принципов разработки, присущих функциональному стилю программирования: программы должны быть разбиты на множество небольших функций с четко определенными задачами. Зачастую отдельно взятая функция весьма невелика. Преимущество подобного стиля — в предоставлении программисту множества строительных блоков, позволяющих составлять гибкие композиции для решения более сложных задач. Такие строительные блоки должны быть довольно простыми, чтобы с ними было легче разобраться по отдельности. Подобный подход выявляет одну проблему: имена всех вспомогательных функ­ ций могут засорять пространство имен программы. В интерпретаторе это про­ является не так ярко, но по мере упаковки функций в многократно применяемые классы и объекты желательно будет скрыть вспомогательные функции от поль­ зователей класса. Зачастую, будучи отдельно взятыми, такие функции не имеют особого смысла, и возникает желание сохранять достаточную степень гибкости, чтобы можно было удалить вспомогательные функции, если позже класс будет переписан. В Java основной инструмент для этого — приватный метод. Как было показа­ но в листинге 8.1, точно такой же подход с использованием приватного метода работает и в Scala, но в этом языке предлагается и еще один: можно определить функцию внутри другой функции. Подобно локальным переменным, такие локальные функции видны только в пределах своего приватного блока. Рас­ смотрим пример: def processFile(filename: String, width: Int) = { def processLine(filename: String, width: Int, line: String) = { if (line.length > width) println(filename + ": " + line.trim) } val source = Source.fromFile(filename) for (line <- source.getLines()) { processLine(filename, width, line) } } Здесь реорганизована исходная версия LongLines , показанная в листин­ ге 8.1: приватный метод processLine был превращен в локальную функцию для processFile. Для этого был удален модификатор private, который мог быть при­ менен (и нужен) только для членов класса, а определение функции processLine было помещено внутрь определения функции processFile. В качестве локальной функция processLine находится в области видимости внутри функции processFile, за пределами которой она недоступна. 8.3. Функции первого класса 157 Но теперь, когда функция processLine определена внутри функции processFile, появилась возможность улучшить кое-что еще. Вы заметили, что filename и width передаются вспомогательной функции, как и прежде? В этом нет никакой необ­ ходимости, поскольку локальные функции могут получать доступ к параметрам охватывающей их функции. Как показано в листинге 8.2, можно просто восполь­ зоваться параметрами внешней функции processFile. Листинг 8.2. LongLines с локальной функцией processLine import scala.io.Source object LongLines { def processFile(filename: String, width: Int) = { def processLine(line: String) = { if (line.length > width) println(filename + ": " + line.trim) } val source = Source.fromFile(filename) for (line <- source.getLines()) processLine(line) } } Заметили, как упростился код? Такое использование параметров охватывающей функции — широко распространенный и весьма полезный пример универсальной вложенности, предоставляемой Scala. Вложенность и области видимости примени­ тельно ко всем конструкциям данного языка, включая функции, рассматриваются в разделе 7.7. Это довольно простой, но весьма эффективный принцип, особенно в языках, использующих функции первого класса. 8.3. Функции первого класса В Scala есть функции первого класса. Вы можете не только определить их и вызвать, но и записать в виде безымянных литералов, после чего передать их в качестве значений. Понятие функциональных литералов было введено в главе 2, а их основной синтаксис показан на рис. 2.2 (см. выше). Функциональный литерал компилируется в класс, который при создании экзем­ пляра во время выполнения программы становится функциональным значением1. 1 Каждое функциональное значение является экземпляром какого-нибудь класса, кото­ рый представляет собой расширение одного из нескольких трейтов FunctionN в пакете scala, например Function0 для функций без параметров, Function1 для функций с одним параметром и т. д. В каждом трейте FunctionN имеется метод apply, используемый для вызова функции. 158 Глава 8 • Функции и замыкания Таким образом, разница между функциональными литералами и значениями со­ стоит в том, что первые существуют в исходном коде, а вторые — в виде объектов во время выполнения программы. Эта разница во многом похожа на разницу между классами (исходным кодом) и объектами (создаваемыми во время выполнения программы). Простой функциональный литерал, прибавляющий к числу единицу, имеет следующий вид: (x: Int) => x + 1 Сочетание символов => указывает на то, что эта функция превращает стоящее слева от данного сочетания (любое целочисленное значение x) в то, что находится справа от него (x + 1). Таким образом, данная функция отображает на любую цело­ численную переменную x значение x + 1. Функциональные значения — это объекты, следовательно, при желании их можно хранить в переменных. Они также являются функциями, следовательно, вы можете вызывать их, используя обычную форму записи вызова функций с при­ менением круглых скобок. Вот как выглядят примеры обоих действий: scala> var increase = (x: Int) => x + 1 increase: Int => Int = $$Lambda$988/1232424564@cf01c2e scala> increase(10) res0: Int = 11 Поскольку increase в данном примере является var-переменной, то позже ей можно присвоить другое значение: scala> increase = (x: Int) => x + 9999 mutated increase scala> increase(10) res1: Int = 10009 Если нужно, чтобы в функциональном литерале использовалось более одной инструкции, то следует заключить его тело в фигурные скобки и поместить каж­ дую инструкцию на отдельной строке, сформировав блок. Как и в случае создания метода, когда вызывается функциональное значение, будут выполнены все ин­ струкции и значением, возвращаемым из функции, станет значение, получаемое при вычислении последнего выражения: scala> increase = (x: Int) => { println("We") println("are") println("here!") x + 1 } mutated increase 8.4. Краткие формы функциональных литералов 159 scala> increase(10) We are here! res2: Int = 11 Итак, вы увидели все основные составляющие функциональных литералов и функциональных значений. Возможности их применения обеспечиваются мно­ гими библиотеками Scala. Например, методом foreach, доступным для всех кол­ лекций1. Он получает функцию в качестве аргумента и вызывает ее в отношении каждого элемента своей коллекции. А вот как его можно использовать для вывода всех элементов списка: scala> val someNumbers = List(-11, -10, -5, 0, 5, 10) someNumbers: List[Int] = List(-11, -10, -5, 0, 5, 10) scala> someNumbers.foreach((x: Int) => println(x)) -11 -10 -5 0 5 10 В другом примере также используется имеющийся у типов коллекций метод filter. Он выбирает те элементы коллекции, которые проходят выполняемую пользователем проверку с применением функции. Например, фильтрацию можно осуществить с помощью функции (x: Int) => x > 0. Она отображает положительные целые числа в true, а все остальные числа — в false. Метод filter можно задей­ ствовать следующим образом: scala> someNumbers.filter((x: Int) => x > 0) res4: List[Int] = List(5, 10) Более подробно о методах, подобных foreach и filter, поговорим позже. В гла­ ве 16 рассматривается их использование в классе List, а в главе 17 — применение с другими типами коллекций. 8.4. Краткие формы функциональных литералов В Scala есть несколько способов избавления от избыточной информации, позво­ ляющих записывать функциональные литералы более кратко. Не упускайте такой возможности, поскольку она позволяет вам убрать из своего кода ненужный хлам. 1 Метод foreach определен в трейте Iterable, который является супертрейтом для List, Set, Array и Map. Подробности — в главе 17. 160 Глава 8 • Функции и замыкания Один из способов писать функциональные литералы более лаконично заключа­ ется в отбрасывании типов параметров. При этом предыдущий пример с фильтром может быть написан следующим образом: scala> someNumbers.filter((x) => x > 0) res5: List[Int] = List(5, 10) Компилятор Scala знает, что переменная x должна относиться к целым числам, поскольку видит, что вы сразу же применяете функцию для фильтрации списка целых чисел, на который ссылается объект someNumbers. Это называется целевой типизацией, поскольку целевому использованию выражения — в данном случае в качестве аргумента для someNumbers.filter() — разрешено влиять на типизацию выражения — в данном случае на определение типа параметра x. Подробности це­ левой типизации нам сейчас неважны. Вы можете просто приступить к написанию функционального литерала без типа аргумента и, если компилятор не сможет в нем разобраться, добавить тип. Со временем вы начнете понимать, в каких ситуациях компилятор сможет решить эту загадку, а в каких — нет. Второй способ избавления от избыточных символов заключается в отказе от круглых скобок вокруг параметра, тип которого будет выведен автоматически. В предыдущем примере круглые скобки вокруг x совершенно излишни: scala> someNumbers.filter(x => x > 0) res6: List[Int] = List(5, 10) 8.5. Синтаксис заместителя Можно сделать функциональный литерал еще короче, воспользовавшись знаком подчеркивания в качестве заместителя для одного или нескольких параметров при условии, что каждый параметр появляется внутри функционального литерала только один раз. Например, _ > 0 — очень краткая форма записи для функции, про­ веряющей, что значение больше нуля: scala> someNumbers.filter(_ > 0) res7: List[Int] = List(5, 10) Знак подчеркивания можно рассматривать как бланк, который следует запол­ нить. Он будет заполнен аргументом функции при каждом ее вызове. Например, при условии, что переменная someNumbers была здесь инициализирована значением List(-11, -10, -5, 0, 5, 10), метод filter заменит бланк в _ > 0 сначала значени­ ем -11, получив -11 > 0, затем значением -10, получив -10 > 0, затем значением -5, получив -5 > 0, и так далее до конца списка List. Таким образом, функциональный литерал _ > 0 является, как здесь показано, эквивалентом немного более простран­ ного литерала x => x > 0: scala> someNumbers.filter(x => x > 0) res8: List[Int] = List(5, 10) 8.6. Частично примененные функции 161 Иногда при использовании знаков подчеркивания в качестве заместителей пара­ метров у компилятора может оказаться недостаточно информации для вывода не­ указанных типов параметров. Предположим, к примеру, что вы сами написали _ + _: scala> val f = _ + _ ^ error: missing parameter type for expanded function ((x$1: <error>, x$2) => x$1.$plus(x$2)) В таких случаях нужно указать типы, используя двоеточие: scala> val f = (_: Int) + (_: Int) f: (Int, Int) => Int = $$Lambda$1075/1481958694@289fff3c scala> f(5, 10) res9: Int = 15 Следует заметить, что _ + _ расширяется в литерал для функции, получающей два параметра. Поэтому сокращенную форму можно использовать, только если каждый параметр применяется в функциональном литерале не более одного раза. Несколько знаков подчеркивания означают наличие нескольких параметров, а не многократное использование одного и того же параметра. Первый знак подчер­ кивания представляет первый параметр, второй знак — второй параметр, третий знак — третий параметр и т. д. 8.6. Частично примененные функции В предыдущих примерах знаки подчеркивания ставились вместо отдельных па­ раметров, но можно заменить знаком подчеркивания и весь список параметров. Например, вместо println(_) можно воспользоваться кодом println _. Рассмотрим пример: someNumbers.foreach(println _) В Scala эта краткая форма интерпретируется так, будто вы воспользовались следующим кодом: someNumbers.foreach(x => println(x)) Таким образом, знак подчеркивания в данном случае не является замести­ телем отдельного параметра. Он замещает весь список параметров. Не забудьте поставить пробел между именем функции и знаком подчеркивания. В противном случае компилятор решит, что вы подразумеваете ссылку на другое обозначение, такое как обозначение метода с именем println_, которого, скорее всего, не су­ ществует. При использовании знака подчеркивания подобным образом создается частично примененная функция. В Scala, когда при вызове функции в нее передаются 162 Глава 8 • Функции и замыкания любые необходимые аргументы, вы применяете эту функцию к этим аргументам. Например, если есть следующая функция: scala> def sum(a: Int, b: Int, c: Int) = a + b + c sum: (a: Int, b: Int, c: Int)Int то функцию sum можно применить к аргументам 1, 2 и 3 таким образом: scala> sum(1, 2, 3) res10: Int = 6 Частично примененная функция — выражение, в котором не содержатся все аргументы, необходимые функции. Вместо этого в ней есть лишь некоторые из них или вообще нет никаких необходимых аргументов. Например, чтобы создать выражение частично примененной функции, вызывающее sum , в котором вы не предоставляете ни одного из трех требуемых аргументов, вы помещаете знак подчеркивания после sum. Получившуюся функцию можно затем сохранить в пере­ менной. Рассмотрим пример: scala> val a = sum _ a: (Int, Int, Int) => Int = $$Lambda$1091/1149618736@6415112c Если данный код есть, то компилятор Scala создает экземпляр функциональ­ ного значения, который получает три целочисленных параметра, не указанных в выражении частично примененной функции, sum _, и присваивает ссылку на это новое функциональное значение переменной a. Когда к этому новому значению применяются три аргумента, оно развернется и вызовет sum, передав в нее те же три аргумента: scala> a(1, 2, 3) res11: Int = 6 Происходит следующее: переменная по имени a ссылается на объект функ­ ционального значения. Это функциональное значение является экземпляром класса, сгенерированного автоматически компилятором Scala из sum _ — выраже­ ния частично примененной функции. Класс, сгенерированный компилятором, имеет метод apply, получающий три аргумента1. Имеющийся метод apply полу­ чает три аргумента, поскольку это и есть количество аргументов, отсутствующих в выражении sum _. Компилятор Scala транслирует выражение a(1, 2, 3) в вызов метода apply, принадлежащего объекту функционального значения, передавая ему три аргумента: 1, 2 и 3. Таким образом, a(1, 2, 3) — краткая форма следу­ ющего кода: scala> a.apply(1, 2, 3) res12: Int = 6 1 Сгенерированный класс является расширением трейта Function3, в котором объявлен метод apply, предусматривающий использование трех аргументов. 8.6. Частично примененные функции 163 Этот метод apply , который определен в автоматически генерируемом ком­ пилятором Scala классе из выражения sum _, просто передает дальше эти три от­ сутствовавших параметра функции sum и возвращает результат. В данном случае метод apply вызывает sum(1, 2, 3) и возвращает то, что возвращает функция sum, то есть число 6. Данный вид выражений, в которых знак подчеркивания используется для пред­ ставления всего списка параметров, можно представить себе и по-другому — в ка­ честве способа преобразования def в функциональное значение. Например, если имеется локальная функция, скажем, sum(a: Int, b: Int, c: Int): Int, то ее можно завернуть в функциональное значение, чей метод apply имеет точно такие же типы списка параметров и результата. Это функциональное значение, будучи при­ мененным к неким аргументам, в свою очередь, применяет sum для тех же самых аргументов и возвращает результат. Вы не можете присвоить переменной метод или вложенную функцию или передать их в качестве аргументов другой функции. Однако все это можно сделать, если завернуть метод или вложенную функцию в функциональное значение, поместив знак подчеркивания после имени метода или вложенной функции. Теперь, несмотря на то что sum _ действительно является частично применен­ ной функцией, вам может быть не вполне понятно, почему она называется именно так. Это потому, что она применяется не ко всем своим аргументам. Что касается sum _, то она не применяется ни к одному из своих аргументов. Но вы также можете выразить частично примененной функцию, предоставив ей только некоторые из требуемых аргументов. Рассмотрим пример: scala> val b = sum(1, _: Int, 3) b: Int => Int = $$Lambda$1092/457198113@280aa1bd В данном случае функции sum предоставлены первый и последний аргументы, а средний аргумент не указан. Поскольку пропущен только один аргумент, то ком­ пилятор Scala сгенерирует новый функциональный класс, чей метод apply получает один аргумент. При вызове с этим одним аргументом метод apply сгенерирован­ ной функции вызывает функцию sum, передавая ей 1, затем аргумент, переданный функции, и, наконец, 3. Рассмотрим несколько примеров: scala> b(2) res13: Int = 6 В этом случае b.apply вызывает sum(1, 2, 3): scala> b(5) res14: Int = 9 А в этом случае b.apply вызывает sum(1, 5, 3). Если функция требуется в конкретном месте кода, то при написании выра­ жения частично примененной функции, в котором не указан ни один параметр, например println _ или sum _, его можно записать более кратко, не указывая знак 164 Глава 8 • Функции и замыкания подчеркивания. Например, вместо следующей записи вывода каждого числа, име­ ющегося в объекте someNumbers (который определен в данном коде): someNumbers.foreach(println _) можно просто воспользоваться кодом: someNumbers.foreach(println) Последняя форма допустима только в тех местах, где требуется функция, напри­ мер, как вызов foreach в данном примере. Компилятор знает, что в данном случае нужна функция, поскольку foreach требует ее передачи в качестве аргумента. В тех ситуациях, когда функция не нужна, попытки применить данную форму записи приведут к ошибке компиляции. Рассмотрим пример: scala> val c = sum error: missing argument list for method sum Unapplied methods are only converted to functions when a function type is expected. You can make this conversion explicit by writing `sum _` or `sum(_,_,_)` instead of `sum`. scala> val d = sum _ d: (Int, Int, Int) => Int = $$Lambda$1095/598308875@12223aed scala> d(10, 20, 30) res14: Int = 60 8.7. Замыкания Все рассмотренные до сих пор в этой главе примеры функциональных литералов ссылались только на передаваемые параметры. Так, в выражении (x: Int) => x > 0 в теле функции x > 0 использовалась только одна переменная, x, которая объявле­ на как параметр функции. Но вы можете ссылаться на переменные, объявленные и в других местах: (x: Int) => x + more // На сколько больше? Эта функция прибавляет значение переменной more к своему аргументу, но что такое more? С точки зрения данной функции more — свободная переменная, поскольку в самом функциональном литерале значение ей не присваивается. В от­ личие от нее переменная x является связанной, поскольку в контексте функции имеет значение: определена как единственный параметр функции, имеющий тип Int. Если попытаться воспользоваться этим функциональным литералом в чистом виде, без каких-либо определений в его области видимости, то компилятор выразит недовольство: scala> (x: Int) => x + more ^ error: not found: value more 8.7. Замыкания 165 Зачем нужен конечный знак подчеркивания Синтаксис, используемый в Scala для частично примененных функций, подчеркивает разницу между компромиссами дизайна Scala и классических функциональных язы­ ков, таких как Haskell или ML. В этих языках использование частично примененных функций в порядке вещей. Более того, в них применяется весьма строгая система ста­ тической типизации, которая обычно позволяет обнаружить каждую ошибку, допу­ щенную вами в использовании частично примененных функций. Scala в этом смысле больше похож на императивные языки, такие как Java, где метод, не применяемый ко всем своим аргументам, считается ошибкой. Более того, объектно-ориентированные традиции порождения подтипов и универсальный корневой тип, допускают програм­ мы, которые в классических функциональных языках считались бы ошибочными. К примеру, вы неправильно применили метод drop(n: Int) класса List для tail , забыв, что нужно передать число для метода drop . Вы могли создать код println(xs.drop). Если бы Scala придерживался классической функциональной традиции, согласно которой частично примененные функции могут находиться где угодно, то этот код проходил бы проверку типа. Но, возможно, к вашему удивлению, инструкция println всегда бы выводила <function>! Произошло то, что выражение drop стало рассматриваться как функциональный объект. Поскольку println при­ нимает объекты любого типа, код успешно проходит компиляцию, но при этом воз­ вращает весьма неожиданный результат. Чтобы избежать подобных ситуаций, в Scala обычно требуется обозначить специ­ ально пропущенные аргументы функции, даже если в качестве такого обозначения используется просто знак подчеркивания (_). Scala позволяет обходиться даже без него, но только когда ожидается функциональный тип. С другой стороны, тот же функциональный литерал будет нормально работать, пока будет доступно нечто с именем more: scala> var more = 1 more: Int = 1 scala> val addMore = (x: Int) => x + more addMore: Int => Int = $$Lambda$1103/2125513028@11cb348c scala> addMore(10) res16: Int = 11 Функциональное значение (объект), создаваемое во время выполнения про­ граммы из этого функционального литерала, называется замыканием. Данное на­ звание появилось из-за «замыкания» функционального литерала путем «захвата» привязок его свободных переменных. Функциональный литерал, не имеющий сво­ бодных переменных, например (x: Int) => x + 1, называется замкнутым термом, где терм — это фрагмент исходного кода. Таким образом, функциональное значение, созданное во время выполнения программы из этого функционального литерала, строго говоря, не является замыканием, поскольку функциональный литерал 166 Глава 8 • Функции и замыкания (x: Int) => x + 1 всегда замкнут уже по факту его написания. Но любой функцио­ нальный литерал со свободными переменными, например (x: Int) => x + more, является открытым термом. Поэтому любое функциональное значение, созданное во время выполнения программы из (x: Int) => x + more, будет по определению требовать, чтобы привязка его свободной переменной, more, была захвачена. Полу­ чившееся функциональное значение, в котором может содержаться ссылка на за­ хваченную переменную more, называется замыканием, поскольку функциональное значение — конечный продукт замыкания открытого терма, (x: Int) => x + more. Этот пример вызывает вопрос: что случится, если значение more изменится по­ сле создания замыкания? В Scala можно ответить, что замыкание видит изменение, например: scala> more = 9999 mutated more scala> addMore(10) res17: Int = 10009 На интуитивном уровне понятно, что замыкания в Scala перехватывают сами переменные, а не те значения, на которые те ссылаются1. Как показано в предыду­ щем примере, замыкание, созданное для (x: Int) => x + more, видит изменение more за пределами замыкания. То же самое справедливо и в обратном направлении. Из­ менения, вносимые в захваченную переменную, видимы за пределами замыкания. Рассмотрим пример: scala> val someNumbers = List(-11, -10, -5, 0, 5, 10) someNumbers: List[Int] = List(-11, -10, -5, 0, 5, 10) scala> var sum = 0 sum: Int = 0 scala> someNumbers.foreach(sum += _) scala> sum res19: Int = -11 Здесь используется обходной способ сложения чисел в списке типа List. Пере­ менная sum находится в области видимости, охватывающей функциональный литерал sum += _, который прибавляет числа к sum. Несмотря на то что замыкание модифицирует sum во время выполнения программы, получающийся конечный результат -11 по-прежнему виден за пределами замыкания. А что, если замыкание обращается к некой переменной, у которой во время выполнения программы есть несколько копий? Например, что, если замыкание 1 В отличие от этого внутренние классы Java вообще не позволяют обращаться к моди­ фицируемым переменным в окружающей области видимости, и потому нет никакой разницы между захватом переменной и захватом того значения, которое в ней находится в данный момент. 8.8. Специальные формы вызова функций 167 использует локальную переменную некой функции и последняя вызывается множество раз? Какой из экземпляров этой переменной будет задействован при каждом обращении? С остальной частью языка согласуется только один ответ: задействуется тот экземпляр, который был активен на момент создания замыкания. Рассмотрим, к примеру, функцию, создающую и возвращающую замыкания прироста значения: def makeIncreaser(more: Int) = (x: Int) => x + more При каждом вызове эта функция будет создавать новое замыкание. Каждое замыкание будет обращаться к той переменной more, которая была активна при создании замыкания. scala> val inc1 = makeIncreaser(1) inc1: Int => Int = $$Lambda$1126/1042315811@4262a8d2 scala> val inc9999 = makeIncreaser(9999) inc9999: Int => Int = $$Lambda$1126/1042315811@4c8bbc5e При вызове makeIncreaser(1) создается и возвращается замыкание, захваты­ вающее в качестве привязки к more значение 1. По аналогии с этим при вызове makeIncreaser(9999) возвращается замыкание, захватывающее для more значе­ ние 9999. Когда эти замыкания применяются к аргументам (в данном случае имеет­ ся только один передаваемый аргумент, x), получаемый результат зависит от того, как переменная more была определена в момент создания замыкания: scala> inc1(10) res20: Int = 11 scala> inc9999(10) res21: Int = 10009 И неважно, что more в данном случае — параметр вызова метода, из которого уже произошел возврат. В подобных случаях компилятор Scala осуществляет реорга­ низацию, которая позволяет захваченным параметрам продолжать существовать в динамической памяти (куче), а не в стеке, и пережить таким образом создавший их метод. Данная реорганизация происходит в автоматическом режиме, поэтому вам о ней не стоит беспокоиться. Захватывайте какую угодно переменную: val или var или любой параметр. 8.8. Специальные формы вызова функций Большинство встречающихся вам функций и вызовов функций будут аналогичны уже увиденным в этой главе. У функции будет фиксированное число параметров, у вызова будет точно такое же количество аргументов, и аргументы будут указаны в точно таких же порядке и количестве, что и параметры. Но поскольку вызовы функций в программировании на Scala весьма важны, то для обеспечения некоторых специальных потребностей в язык были добавлены 168 Глава 8 • Функции и замыкания несколько специальных форм определений и вызовов функций. В Scala поддер­ живаются повторяющиеся параметры, именованные аргументы и аргументы со значениями по умолчанию. Повторяющиеся параметры В Scala допускается указание на то, что последний параметр функции может по­ вторяться. Это позволяет клиентам передавать функции список аргументов пере­ менной длины. Чтобы обозначить повторяющийся параметр, поставьте после типа параметра знак звездочки, например: scala> def echo(args: String*) = for (arg <- args) println(arg) echo: (args: String*)Unit Определенная таким образом функция echo может быть вызвана с нулем и боль­ шим количеством аргументов типа String: scala> echo() scala> echo("one") one scala> echo("hello", "world!") hello world! Внутри функции типом повторяющегося параметра является Seq из элементов объявленного типа параметра. Таким образом, типом переменной args внутри функ­ ции echo, которая объявлена как тип String*, фактически является Seq[String]. Не­ смотря на это, если у вас имеется массив подходящего типа, при попытке передать его в качестве повторяющегося параметра будет получена ошибка компиляции: scala> val seq = Seq("What's", "up", "doc?") seq: Seq[String] = Seq(What's, up, doc?) scala> echo(seq) ^ error: type mismatch; found : Seq[String] required: String Чтобы добиться успеха, нужно после аргумента-массива поставить двоеточие, знак подчеркивания и знак звездочки: scala> echo(seq: _*) What's up doc? 8.8. Специальные формы вызова функций 169 Эта форма записи заставит компилятор передать в echo каждый элемент мас­ сива seq в виде самостоятельного аргумента, а не передавать его целиком в виде одного аргумента. Именованные аргументы При обычном вызове функции аргументы в вызове поочередно сопоставляются в указанном порядке с параметрами вызываемой функции: scala> def speed(distance: Float, time: Float): Float = distance / time speed: (distance: Float, time: Float)Float scala> speed(100, 10) res27: Float = 10.0 В данном вызове 100 сопоставляется с distance, а 10 — с time. Сопоставление 100 и 10 производится в том же порядке, в котором перечислены формальные параметры. Именованные аргументы позволяют передавать аргументы функции в ином порядке. Синтаксис просто предусматривает, что перед каждым аргументом ука­ зывается имя параметра и знак равенства. Например, следующий вызов функции speed эквивалентен вызову speed(100,10): scala> speed(distance = 100, time = 10) res28: Float = 10.0 При вызове с именованными аргументами эти аргументы можно поменять ме­ стами, не изменяя их значения: scala> speed(time = 10, distance = 100) res29: Float = 10.0 Можно также смешивать позиционные и именованные аргументы. В этом случае сначала указываются позиционные аргументы. Именованные чаще всего используются в сочетании со значениями параметров по умолчанию. Значения параметров по умолчанию Scala позволяет указать для параметров функции значения по умолчанию. Аргу­ мент для такого параметра может быть произвольно опущен из вызова функции, в таком случае соответствующий аргумент будет заполнен значением по умол­ чанию. Пример показан в листинге 8.3. У функции printTime есть один параметр, out, имеющий значение по умолчанию Console.out. 170 Глава 8 • Функции и замыкания Листинг 8.3. Параметр со значением по умолчанию def printTime(out: java.io.PrintStream = Console.out) = out.println("time = " + System.currentTimeMillis()) Если функцию вызвать как printTime(), то есть без указания аргумента, ис­ пользуемого для out, то для этого параметра будет установлено его значение по умолчанию Console.out. Можно также вызвать функцию с явно указанным выход­ ным потоком. Например, вызвав функцию в виде printTime(Console.err), можно отправить регистрационную запись на стандартное устройство вывода сообщений об ошибках. Параметры по умолчанию особенно полезны, когда применяются в сочетании с именованными параметрами. В листинге 8.4 у функции printTime2 имеется два необязательных параметра. У out есть значение по умолчанию Console.out, а у divisor — значение по умолчанию 1. Листинг 8.4. Функция с двумя параметрами, у которых имеются значения по умолчанию def printTime2(out: java.io.PrintStream = Console.out, divisor: Int = 1) = out.println("time = " + System.currentTimeMillis()/divisor) Чтобы оба параметра заполнялись их значениями по умолчанию, функцию printTime2 можно вызывать как printTime2(). Но при использовании именованных аргументов может быть указан любой из параметров, а для другого останется зна­ чение по умолчанию. Когда необходимо указать выходной поток, вызов выглядит следующим образом: printTime2(out = Console.err) Указать делитель времени можно с помощью следующего вызова: printTime2(divisor = 1000) 8.9. Хвостовая рекурсия В разделе 7.2 упоминалось, что для преобразования цикла while, который обновля­ ет значение var-переменных в код более функционального стиля, использующий только val-переменные, временами может понадобиться прибегнуть к рекурсии. Рассмотрим пример рекурсивной функции, которая вычисляет приблизительное значение, повторяя уточнение приблизительного расчета, пока не будет получен приемлемый результат: def approximate(guess: Double): Double = if (isGoodEnough(guess)) guess else approximate(improve(guess)) 8.9. Хвостовая рекурсия 171 С соответствующими реализациями isGoodEnough и improve подобные функ­ ции часто используются при решении задач поиска. Если нужно, чтобы функция approximate выполнялась быстрее, то может возникнуть желание написать ее с циклом while: def approximateLoop(initialGuess: Double): Double = { var guess = initialGuess while (!isGoodEnough(guess)) guess = improve(guess) guess } Какая из двух версий approximate более предпочтительна? Если требуется ла­ коничность и нужно избавиться от использования var-переменных, то выиграет первый, функциональный вариант. Но, может быть, более эффективным окажется императивный подход? На самом деле, если измерить время выполнения, окажется, что они практически одинаковы! Этот результат может показаться неожиданным, поскольку рекурсивный вызов выглядит намного более затратным, чем простой переход из конца цикла в его начало. Но в показанном ранее вычислении приблизительного значения компилятор Scala может применить очень важную оптимизацию. Обратите внимание на то, что в вычислении тела функции approximate рекурсивный вы­ зов стоит в самом конце. Функции наподобие approximate, которые в качестве последнего действия вызывают сами себя, называются функциями с хвостовой рекурсией. Компилятор Scala обнаруживает хвостовую рекурсию и заменяет ее переходом к началу функции после обновления параметров функции новыми значениями. Из этого можно сделать вывод, что избегать использования рекурсивных алгоритмов для решения ваших задач не стоит. Зачастую рекурсивное решение выглядит более элегантно и лаконично, чем решение на основе цикла. Если в ре­ шении задействована хвостовая рекурсия, то расплачиваться за него издержками производительности во время выполнения программы не придется. Трассировка функций с хвостовой рекурсией Для каждого вызова функции с хвостовой рекурсией новый фрейм стека созда­ ваться не будет, все вызовы станут выполняться с использованием одного и того же фрейма. Это обстоятельство может вызвать удивление у программиста, который исследует трассировку стека программы, давшей сбой. Например, данная функция вызывает себя несколько раз и генерирует исключение: def boom(x: Int): Int = if (x == 0) throw new Exception("boom!") else boom(x – 1) + 1 172 Глава 8 • Функции и замыкания Эта функция не относится к функциям с хвостовой рекурсией, поскольку по­ сле рекурсивного вызова выполняет операцию инкремента. При ее запуске будет получен вполне ожидаемый результат: scala> boom(3) java.lang.Exception: boom! at .boom(<console>:5) at .boom(<console>:6) at .boom(<console>:6) at .boom(<console>:6) at .<init>(<console>:6) ... Оптимизация хвостового вызова Код, скомпилированный для approximate, по сути, такой же, как и код, скомпи­ лированный для approximateLoop. Обе функции компилируются в одни и те же 13 инструкций байт-кода Java. Если просмотреть байт-коды, сгенерированные ком­ пилятором Scala для метода с хвостовой рекурсией approximate, то можно увидеть, что, хотя и isGoodEnough, и improve вызываются в теле метода, approximate там не вызывается. При оптимизации компилятор Scala убирает рекурсивный вызов: public double approximate(double); Code: 0: aload_0 1: astore_3 2: aload_0 3: dload_1 4: invokevirtual #24; //Method isGoodEnough:(D)Z 7: ifeq 12 10: dload_1 11: dreturn 12: aload_0 13: dload_1 14: invokevirtual #27; //Method improve:(D)D 17: dstore_1 18: goto 2 Если теперь внести изменения в boom так, чтобы в ней появилась хвостовая рекурсия: def bang(x: Int): Int = if (x == 0) throw new Exception("bang!") else bang(x – 1) то получится следующий результат: scala> bang(5) java.lang.Exception: bang! at .bang(<console>:5) at .<init>(<console>:6) ... 8.9. Хвостовая рекурсия 173 На сей раз вы видите только фрейм стека для bang. Можно подумать, bang дает сбой перед своим собственным вызовом, но это не так. Если вы считаете, что при просмотре трассировки стека оптимизации хвостовых вызовов могут вас запутать, то их можно выключить, указав для оболочки scala или компилятора scalac сле­ дующий ключ: -g:notailcalls Указав этот ключ, вы получите удлиненную трассировку стека: scala> bang(5) java.lang.Exception: bang! at .bang(<console>:5) at .bang(<console>:5) at .bang(<console>:5) at .bang(<console>:5) at .bang(<console>:5) at .bang(<console>:5) at .<init>(<console>:6) ... Ограничения хвостовой рекурсии Использование хвостовой рекурсии в Scala строго ограничено, поскольку набор инструкций виртуальной машины Java (JVM) существенно затрудняет реализацию более сложных форм хвостовых рекурсий. Оптимизация в Scala касается только непосредственных рекурсивных вызовов той же самой функции, из которой вы­ полняется вызов. Если рекурсия косвенная, как в следующем примере, где приме­ няются две взаимно рекурсивные функции, то ее оптимизация невозможна: def isEven(x: Int): Boolean = if (x == 0) true else isOdd(x – 1) def isOdd(x: Int): Boolean = if (x == 0) false else isEven(x – 1) Получить оптимизацию хвостового вызова невозможно и в том случае, если за­ вершающий вызов делается в отношении функционального значения. Рассмотрим, к примеру, такой рекурсивный код: val funValue = nestedFun _ def nestedFun(x: Int) : Unit = { if (x != 0) { println(x); funValue(x – 1) } } Переменная funValue ссылается на функциональное значение, которое, по сути, заключает в себе вызов функции nestedFun. В момент применения функциональ­ ного значения к аргументу все изменяется и nestedFun применяется к тому же самому аргументу, возвращая результат. Поэтому вы можете понадеяться на то, что компилятор Scala выполнит оптимизацию хвостового вызова, но в данном случае этого не произойдет. Оптимизация хвостовых вызовов ограничивается ситуация­ ми, когда метод или вложенная функция вызывают сами себя непосредственно 174 Глава 8 • Функции и замыкания в качестве своей последней операции, не обращаясь к функциональному значению или через какого-то другого посредника. (Если вы еще не усвоили, что такое хво­ стовая рекурсия, то перечитайте раздел 8.9.) Резюме В данной главе мы представили довольно подробный обзор использования функ­ ций в Scala. Кроме методов, этот язык предоставляет локальные функции, функ­ циональные литералы и функциональные значения. В дополнение к обычным вызовам функций в Scala используются частично примененные функции и функ­ ции с повторяющимися параметрами. При благоприятной возможности вызовы функций реализуются в виде оптимизированных хвостовых вызовов, благодаря чему многие привлекательные рекурсивные функции выполняются практически так же быстро, как и оптимизированные вручную версии, использующие циклы while. В следующей главе на основе этих основных положений мы покажем, как имеющаяся в Scala расширенная поддержка функций помогает абстрагировать процессы управления. 9 Управляющие абстракции В главе 7 мы отметили, что встроенных управляющих абстракций в Scala не так уж много, поскольку этот язык позволяет вам создавать собственные управляющие абстракции. В предыдущей главе мы рассмотрели функциональные значения. В этой покажем способы применения функциональных значений в целях созда­ ния новых управляющих абстракций. Попутно рассмотрим карринг и передачу параметров по имени. 9.1. Сокращение повторяемости кода Каждую функцию можно разделить на общую часть, одинаковую для всех вызовов функции, и особую часть, которая может варьироваться от одного вызова функции к другому. Общая часть находится в теле функции, а особая должна предоставлять­ ся через аргументы. Когда в качестве аргумента используется функциональное зна­ чение, особая часть алгоритма сама по себе является еще одним алгоритмом! При каждом вызове такой функции ей можно передавать в качестве аргумента другое функциональное значение, и вызванная функция в этом случае будет вызывать переданное функциональное значение. Такие функции высшего порядка, то есть функции, которые получают функции в качестве параметров, обеспечивают вам дополнительные возможности по сокращению и упрощению кода. Одним из преимуществ функций высшего порядка является то, что они предо­ ставляют вам возможность создавать управляющие абстракции, которые позволяют избавиться от повторяющихся фрагментов кода. Предположим, вы создаете браузер файлов и должны разработать API, разрешающий пользователям искать файлы, соответствующие какому-либо критерию. Сначала вы добавляете средство поиска тех файлов, чьи имена заканчиваются конкретной строкой. Это даст пользователям возможность найти, к примеру, все файлы с расширением .scala. Такой API можно создать путем определения публичного метода filesEnding внутри следующего объекта-одиночки: object FileMatcher { private def filesHere = (new java.io.File(".")).listFiles 176 Глава 9 } • Управляющие абстракции def filesEnding(query: String) = for (file <- filesHere; if file.getName.endsWith(query)) yield file Метод filesEnding получает список всех файлов, находящихся в текущем ката­ логе, применяя приватный вспомогательный метод filesHere, затем фильтрует этот список по признаку, завершается ли имя файла тем содержимым, которое указано в пользовательском запросе. Поскольку filesHere является приватным методом, то метод filesEnding — единственный доступный метод, определенный в FileMatcher, то есть в API, который вы предлагаете своим пользователям. Пока все идет неплохо — повторяющегося кода нет. Но чуть позже вы хотите разрешить пользователям искать по любой части имени файла. Такой поиск приго­ дится, когда пользователи не смогут вспомнить, как именно они назвали файл, phbimportant.doc, stupid-phb-report.doc, may2003salesdoc.phb или совершенно иначе, и единственное, в чем они уверены, — что где-то в имени фигурирует phb. Вы воз­ вращаетесь к работе, и к FileMatcher API добавляете соответствующую функцию: def filesContaining(query: String) = for (file <- filesHere; if file.getName.contains(query)) yield file Данная функция работает точно так же, как и filesEnding. Она ищет текущие файлы с помощью filesHere, проверяет имя и возвращает файл, если его имя со­ ответствует критерию поиска. Единственное отличие — функция использует метод contains вместо метода endsWith. Проходит несколько месяцев, и программа на­ бирает популярность. Со временем вы уступаете просьбам некоторых активных пользователей, желающих вести поиск с помощью регулярных выражений. У этих нерадивых пользователей образовались огромные каталоги с тысячами файлов, и им хочется получить возможность искать все PDF-файлы, в названии которых где-нибудь имеется сочетание oopsla. Чтобы позволить им сделать это, вы создаете следующую функцию: def filesRegex(query: String) = for (file <- filesHere; if file.getName.matches(query)) yield file Опытные программисты могут обратить внимание на все допущенные по­ вторения и удивиться тому, что они не были сведены в общую вспомогательную функцию. Но если подходить к решению этой задачи в лоб, то ничего не получится. Можно было бы придумать следующее: def filesMatching(query: String, метод) = for (file <- filesHere; if file.getName.метод(query)) yield file В некоторых динамичных языках такой подход сработал бы, но в Scala не раз­ решается вставлять подобный код во время выполнения. Что же делать? Ответ дают функциональные значения. Передавать имя метода в качестве значения нельзя, но точно такой же эффект можно получить, если передать функ­ циональное значение, вызывающее для вас этот метод. В этом случае к методу, 9.1. Сокращение повторяемости кода 177 единственной задачей которого будет проверка соответствия имени файла запросу, добавляется параметр matcher: def filesMatching(query: String, matcher: (String, String) => Boolean) = { } for (file <- filesHere; if matcher(file.getName, query)) yield file В данной версии метода условие if теперь использует параметр matcher для про­ верки соответствия имени файла запросу. Что именно проверяется, зависит от того, что указано в качестве matcher. А теперь посмотрите на тип самого этого параметра. Это функция, вследствие чего в типе имеется обозначение =>. Функция получает два строковых аргумента, имя файла и запрос, и возвращает булево значение, сле­ довательно, типом этой функции является (String, String) => Boolean. Располагая новым вспомогательным методом по имени filesMatching, можно упростить три поисковых метода, заставив их вызывать вспомогательный метод, передавая в него соответствующую функцию: def filesEnding(query: String) = filesMatching(query, _.endsWith(_)) def filesContaining(query: String) = filesMatching(query, _.contains(_)) def filesRegex(query: String) = filesMatching(query, _.matches(_)) Функциональные литералы, показанные в данном примере, задействуют син­ таксис заместителя, рассмотренный в предыдущей главе, который может быть вам еще не совсем привычен. Поэтому поясним, как применяются заместители: функ­ циональный литерал _.endsWith(_), используемый в методе filesEnding, означает то же самое, что и следующий код: (fileName: String, query: String) => fileName.endsWith(query) Поскольку filesMatching получает функцию, требующую два String-аргумента, то типы аргументов указывать не нужно — можно просто воспользоваться кодом (filename, query) => filename.endsWith(query). А так как в теле функции каждый из параметров используется только раз (то есть первый параметр, filename, при­ меняется в теле первым, второй параметр, query, — вторым), можно прибегнуть к синтаксису заместителей — _.endsWith(_). Первый знак подчеркивания станет заместителем для первого параметра — имени файла, а второй — заместителем для второго параметра — строки запроса. Этот код уже упрощен, но может стать еще короче. Обратите внимание: аргу­ мент query передается filesMatching, но данная функция ничего с ним не делает, за исключением того, что передает его назад переданной функции matcher. Такая передача туда-сюда необязательна, поскольку вызывающий код с самого начала знает о query! Это позволяет удалить параметр query как из filesMatching, так и из matcher, упростив код до состояния, показанного в листинге 9.1. 178 Глава 9 • Управляющие абстракции Листинг 9.1. Использование замыканий для сокращения повторяемости кода object FileMatcher { private def filesHere = (new java.io.File(".")).listFiles private def filesMatching(matcher: String => Boolean) = for (file <- filesHere; if matcher(file.getName)) yield file def filesEnding(query: String) = filesMatching(_.endsWith(query)) def filesContaining(query: String) = filesMatching(_.contains(query)) } def filesRegex(query: String) = filesMatching(_.matches(query)) В этом примере показан способ, позволяющий функциям первого класса помочь вам избавиться от дублирующегося кода, что без них сделать было бы очень трудно. В Java, к примеру, можно создать интерфейс, который содержит метод, получа­ющий одно String-значение и возвращающий значение типа Boolean, а затем создать и передать функции filesMatching экземпляры анонимного внутреннего класса, реализующие этот интерфейс. Такой подход позволит избавиться от дублирующего­ ся кода, чего, собственно, вы и добивались, но в то же время приведет к добавлению чуть ли не большего количества нового кода. Стало быть, цена вопроса сведет на нет все преимущества и лучше будет, вероятно, смириться с повторяемостью кода. Кроме того, данный пример показывает, как замыкания способны помочь со­ кратить повторяемость кода. Функциональные литералы, использованные в пре­ дыдущем примере, такие как _.endsWith(_) и _.contains(_), во время выполнения программы становятся экземплярами функциональных значений, которые не яв­ ляются замыканиями, поскольку не захватывают никаких свободных переменных. К примеру, обе переменные, использованные в выражении _.endsWith(_), представ­ лены в виде знаков подчеркивания, следовательно, берутся из аргументов функции. Таким образом, в _.endsWith(_) задействуются не свободные, а две связанные переменные. В отличие от этого в функциональном литерале _.endsWith(query), использованном в самом последнем примере, содержатся одна связанная перемен­ ная, а именно аргумент, представленный знаком подчеркивания, и одна свободная переменная по имени query. Возможность убрать параметр query из filesMatching в этом примере, тем самым еще больше упростив код, появилась у вас только по­ тому, что в Scala поддерживаются замыкания. 9.2. Упрощение клиентского кода В предыдущем примере было показано, что применение функций высшего порядка способствует сокращению повторяемости кода по мере реализации API. Еще один важный способ использовать функции высшего порядка — поместить их в сам API 9.2. Упрощение клиентского кода 179 с целью повысить лаконичность клиентского кода. Хорошим примером могут по­ служить методы организации циклов специального назначения, принадлежащие имеющимся в Scala типам коллекций1. Многие из них перечислены в табл. 3.1, но сейчас, чтобы понять, почему эти методы настолько полезны, внимательно рас­ смотрите только один пример. Рассмотрим exists — метод, определяющий факт наличия переданного зна­ чения в коллекции. Разумеется, искать элемент можно, инициализировав var переменную значением false и выполнив перебор элементов коллекции, проверяя каждый из них и присваивая var-переменной значение true, если будет найден предмет поиска. Метод, в котором такой подход используется с целью определить, имеется ли в переданном списке List отрицательное число, выглядит следующим образом: def containsNeg(nums: List[Int]): Boolean = { var exists = false for (num <- nums) if (num < 0) exists = true exists } Если определить этот метод в интерпретаторе, то его можно вызвать следу­ ющими командами: scala> containsNeg(List(1, 2, 3, 4)) res0: Boolean = false scala> containsNeg(List(1, 2, -3, 4)) res1: Boolean = true Но более лаконичный способ определения метода предусматривает вызов в от­ ношении списка List функции высшего порядка exists: def containsNeg(nums: List[Int]) = nums.exists(_ < 0) Эта версия containsNeg выдает те же результаты, что и предыдущая: scala> containsNeg(Nil) res2: Boolean = false scala> containsNeg(List(0, -1, -2)) res3: Boolean = true Метод exists представляет собой управляющую абстракцию. Это специализи­ рованная циклическая конструкция, которая не встроена в язык Scala, как while или for, а предоставляется библиотекой Scala. В предыдущем разделе функция высшего порядка filesMatching позволила сократить повторяемость кода в реали­ зации объекта FileMatcher. Метод exists обеспечивает такое же преимущество, но 1 Эти специализированные методы циклической обработки определены в трейте Iterable, который является расширением классов List, Set и Map. Более подробно данный вопрос рассматривается в главе 17. 180 Глава 9 • Управляющие абстракции поскольку это публичный метод в API коллекций Scala, то сокращение повторяе­ мости относится к клиентскому коду этого API. Если бы метода exists не было и потребовалось бы написать метод выявления наличия в списке четных чисел containsOdd, то это можно было бы сделать так: def containsOdd(nums: List[Int]): Boolean = { var exists = false for (num <- nums) if (num % 2 == 1) exists = true exists } Сравнивая тело метода containsNeg с телом метода containsOdd, можно заметить повторяемость во всем, за исключением условия проверки в выражении expression. С помощью метода exists вместо этого можно воспользоваться следующим кодом: def containsOdd(nums: List[Int]) = nums.exists(_ % 2 == 1) Тело кода в этой версии также практически идентично телу соответствующего метода containsNeg (той его версии, в которой используется exists), за исключени­ ем того, что условие, по которому выполняется поиск, иное. И тем не менее объем повторяющегося кода значительно уменьшился, поскольку вся инфраструктура организации цикла убрана в метод exists. В стандартной библиотеке Scala имеется множество других методов для органи­ зации цикла. Как и exists, они зачастую могут сократить объем вашего кода, если появится возможность их применения. 9.3. Карринг В главе 1 говорилось, что Scala позволяет создавать новые управляющие абстрак­ ции, которые воспринимаются как естественная языковая поддержка. Хотя по­ казанные до сих пор примеры фактически и были управляющими абстракциями, вряд ли кто-то смог бы воспринять их как естественную поддержку со стороны языка. Чтобы понять, как создаются управляющие абстракции, больше похожие на расширения языка, сначала нужно разобраться с приемом функционального программирования, который называется карринг. Каррированная функция применяется не к одному, а к нескольким спискам аргументов. В листинге 9.2 показана обычная, некаррированная функция, склады­ вающая два Int-параметра, x и y. Листинг 9.2. Определение и вызов обычной функции scala> def plainOldSum(x: Int, y: Int) = x + y plainOldSum: (x: Int, y: Int)Int scala> plainOldSum(1, 2) res4: Int = 3 9.3. Карринг 181 В листинге 9.3 показана аналогичная, но уже каррированная функция. Вместо списка из двух параметров типа Int эта функция применяется к двум спискам, в каждом из которых содержится по одному параметру типа Int. Листинг 9.3. Определение и вызов каррированной функции scala> def curriedSum(x: Int)(y: Int) = x + y curriedSum: (x: Int)(y: Int)Int scala> curriedSum(1)(2) res5: Int = 3 Здесь при вызове curriedSum вы фактически получаете два обычных вызова функции, следующие непосредственно друг за другом. Первый получает единствен­ ный параметр Int по имени x и возвращает функциональное значение для второй функции. А та получает Int-параметр y. Здесь действие функции по имени first соответствует тому, что должно было происходить при вызове первой традицион­ ной функции curriedSum: scala> def first(x: Int) = (y: Int) => x + y first: (x: Int)Int => Int Применение первой функции к числу 1, иными словами, вызов первой функции и передача ей значения 1, образует вторую функцию: scala> val second = first(1) second: Int => Int = $$Lambda$1044/1220897602@5c6fae3c Применение второй функции к числу 2 дает результат: scala> second(2) res6: Int = 3 Функции first и second всего лишь показывают процесс карринга. Они не свя­ заны непосредственно с функцией curriedSum. И тем не менее это способ получить фактическую ссылку на вторую функцию из curriedSum. Чтобы воспользоваться curriedSum в выражении частично примененной функцией, можно обратиться к форме записи с заместителем: scala> val onePlus = curriedSum(1)_ onePlus: Int => Int = $$Lambda$1054/711248671@3644d12a Знак подчеркивания в curriedSum(1)_ является заместителем для второго списка, используемого в качестве параметра1. В результате получается ссылка на функцию, при вызове которой единица прибавляется к ее единственному Intаргументу, и возвращается результат: scala> onePlus(2) res7: Int = 3 1 В предыдущей главе, где форма записи с заместителем использовалась в отношении традиционных методов наподобие println _, между именем и знаком подчеркивания нужно было ставить пробел. Здесь в этом нет необходимости, поскольку в Scala println_ является допустимым идентификатором, а curriedSum(1)_ — нет. 182 Глава 9 • Управляющие абстракции А вот как можно получить функцию, прибавляющую число 2 к ее единствен­ ному Int-аргументу: scala> val twoPlus = curriedSum(2)_ twoPlus: Int => Int = $$Lambda$1055/473485349@48b85dc5 scala> twoPlus(2) res8: Int = 4 9.4. Создание новых управляющих конструкций В языках, использующих функции первого класса, даже если синтаксис языка устоялся, есть возможность эффективно создавать новые управляющие конструк­ ции. Нужно лишь создать методы, получающие функции в виде аргументов. Например, в фрагменте кода ниже показана удваивающая управляющая кон­ струкция — она повторяет операцию два раза и возвращает результат: scala> def twice(op: Double => Double, x: Double) = op(op(x)) twice: (op: Double => Double, x: Double)Double scala> twice(_ + 1, 5) res9: Double = 7.0 Типом op в данном примере является Double => Double. Это значит, функция получает одно Double-значение в качестве аргумента и возвращает другое Doubleзначение. Каждый раз, замечая шаблон управления, повторяющийся в разных частях вашего кода, вы должны задуматься о его реализации в виде новой управляющей конструк­ ции. Ранее в этой главе был показан filesMatching, узкоспециализированный шаблон управления. Теперь рассмотрим более широко применяющийся шаблон программи­ рования: открытие ресурса, работа с ним, а затем закрытие ресурса. Все это можно собрать в управляющую абстракцию, прибегнув к методу, показанному ниже: def withPrintWriter(file: File, op: PrintWriter => Unit) = { val writer = new PrintWriter(file) try { op(writer) } finally { writer.close() } } При наличии такого метода им можно воспользоваться так: withPrintWriter( new File("date.txt"), writer => writer.println(new java.util.Date) ) Преимущества применения этого метода состоят в том, что закрытие файла в конце работы гарантируется withPrintWriter, а не пользовательским кодом. 9.4. Создание новых управляющих конструкций 183 Поэтому забыть закрыть файл просто невозможно. Данная технология называется шаблоном временного пользования (loan pattern), поскольку функция управляющей абстракции, такая как withPrintWriter, открывает ресурс и отдает его функции во временное пользование. Так, в предыдущем примере withPrintWriter отдает во временное пользование PrintWriter функции op. Когда функция завершает работу, она сигнализирует, что ей уже не нужен одолженный ресурс. Затем в блоке finally ресурс закрывается; это гарантирует его безусловное закрытие независимо от того, как завершилась работа функции — успешно или с генерацией исключения. Один из способов придать клиентскому коду вид, который делает его похожим на встроенную управляющую конструкцию, предусматривает заключение списка аргументов в фигурные, а не в круглые скобки. Если в Scala при каждом вызове метода ему передается строго один аргумент, то можно заключить его не в круглые, а в фигурные скобки. Например, вместо: scala> println("Hello, world!") Hello, world! можно написать: scala> println { "Hello, world!" } Hello, world! Во втором примере аргумент для println вместо круглых скобок заключен в фигурные. Но такой прием использования фигурных скобок будет работать только при передаче одного аргумента. Попытка нарушить это правило приводит к следующему результату: scala> val g = "Hello, world!" g: String = Hello, world! scala> g.substring { 7, 9 } ^ error: ';' expected but ',' found. Поскольку была предпринята попытка передать функции substring два аргу­ мента, то при их заключении в фигурные скобки выдается ошибка. Вместо фигур­ ных в данном случае нужно использовать круглые скобки: scala> g.substring(7, 9) res12: String = wo Назначение такой возможности заменить круглые скобки фигурными при передаче одного аргумента — позволить программистам-клиентам записать в фи­ гурных скобках функциональный литерал. Тем самым можно сделать вызов метода похожим на управляющую абстракцию. В качестве примера можно взять определенный ранее метод withPrintWriter. В своем самом последнем виде метод withPrintWriter получает два аргумента, поэтому использовать фигурные скобки нельзя. Тем не менее, поскольку функция, переданная withPrintWriter, является последним аргументом в списке, можно воспользоваться каррингом, чтобы переме­ стить первый аргумент типа File в отдельный список аргументов. Тогда функция останется единственным параметром второго списка параметров. Способ пере­ определения withPrintWriter показан в листинге 9.4. 184 Глава 9 • Управляющие абстракции Листинг 9.4. Применение шаблона временного пользования для записи в файл def withPrintWriter(file: File)(op: PrintWriter => Unit) = { val writer = new PrintWriter(file) try { op(writer) } finally { writer.close() } } Новая версия отличается от старой всего лишь тем, что теперь есть два списка параметров, по одному параметру в каждом, а не один список из двух параме­ тров. Загляните между двумя параметрами. В показанной здесь прежней версии withPrintWriter вы видите ...File, op.... Но в этой версии вы видите ...File) (op.... Благодаря определению, приведенному ранее, метод можно вызвать с по­ мощью более привлекательного синтаксиса: val file = new File("date.txt") withPrintWriter(file) { writer => writer.println(new java.util.Date) } В этом примере первый список аргументов, в котором содержится один аргу­ мент типа File, заключен в круглые скобки. А второй список аргументов, содержа­ щий функциональный аргумент, заключен в фигурные скобки. 9.5. Передача параметров по имени Метод withPrintWriter, рассмотренный в предыдущем разделе, отличается от встро­ енных управляющих конструкций языка, таких как if и while, тем, что код между фигурными скобками получает аргумент. Функция, переданная withPrintWriter, требует одного аргумента типа PrintWriter. Этот аргумент показан в следующем коде как writer =>: withPrintWriter(file) { writer => writer.println(new java.util.Date) } А что нужно сделать, если понадобится реализовать нечто больше похожее на if или while, где между фигурными скобками нет значения для передачи в код? Помочь справиться с подобными ситуациями могут имеющиеся в Scala параметры, передаваемые по имени (by-name parameters). В качестве конкретного примера представим, будто нужно реализовать кон­ струкцию утверждения под названием myAssert1. Функция myAssert будет получать 1 Здесь используется название myAssert, а не assert, поскольку имя assert предоставля­ ется самим языком Scala. Соответствующее описание будет дано в разделе 14.1. 9.5. Передача параметров по имени 185 в качестве ввода функциональное значение и обращаться к флагу, чтобы решить, что делать. Если флаг установлен, то myAssert вызовет переданную функцию и проверит, что она возвращает true. Если сброшен, то myAssert будет молча без­ действовать. Не прибегая к использованию параметров, передаваемых по имени, конструк­ цию myAssert можно создать следующим образом: var assertionsEnabled = true def myAssert(predicate: () => Boolean) = if (assertionsEnabled && !predicate()) throw new AssertionError С определением все в порядке, но пользоваться им неудобно: myAssert(() => 5 > 3) Конечно, лучше было бы обойтись в функциональном литерале без пустого списка параметров и обозначения => и создать следующий код: myAssert(5 > 3) // Не будет работать из-за отсутствия () => Именно для воплощения задуманного и существуют параметры, передаваемые по имени. Чтобы создать такой параметр, задавать тип параметра нужно с обозна­ чения =>, а не с () =>. Например, можно заменить в myAssert параметр predicate параметром, передаваемым по имени, изменив его тип () => Boolean на => Boolean. Как это должно выглядеть, показано в листинге 9.5. Листинг 9.5. Использование параметра, передаваемого по имени def byNameAssert(predicate: => Boolean) = if (assertionsEnabled && !predicate) throw new AssertionError Теперь в свойстве, по поводу которого нужно высказать утверждение, можно избавиться от пустого параметра. В результате этого использование byNameAssert выглядит абсолютно так же, как встроенная управляющая конструкция: byNameAssert(5 > 3) Тип «по имени» (by-name), в котором отбрасывается пустой список пара­ метров (), допустимо использовать только в отношении параметров. Никаких by-name-переменных или by-name-полей не существует. Можно, конечно, удивиться, почему нельзя просто написать функцию myAssert, воспользовавшись для ее параметров старым добрым типом Boolean и создав сле­ дующий код: def boolAssert(predicate: Boolean) = if (assertionsEnabled && !predicate) throw new AssertionError Разумеется, такая формулировка тоже будет работать, и код, использующий эту версию boolAssert, будет выглядеть точно так же, как и прежде: boolAssert(5 > 3) 186 Глава 9 • Управляющие абстракции И все же эти два подхода различаются весьма значительным образом. Для пара­ метра boolAssert используется тип Boolean, и потому выражение внутри круглых скобок в boolAssert(5 > 3) вычисляется до вызова boolAssert. Выражение 5 > 3 выдает значение true, которое передается в boolAssert. В отличие от этого, по­ скольку типом параметра predicate функции byNameAssert является => Boolean, выражение внутри круглых скобок в byNameAssert(5 > 3) до вызова byNameAssert не вычисляется. Вместо этого будет создано функциональное значение, чей метод apply станет вычислять 5 > 3 , и это функциональное значение будет передано функции byNameAssert. Таким образом, разница между двумя подходами состоит в том, что при от­ ключении утверждений вам будут видны любые побочные эффекты, которые могут быть в выражении внутри круглых скобок в boolAssert, но byNameAssert это не касается. Например, если утверждения отключены, то попытки утверждать, что x / 0 == 0, в случае использования boolAssert приведут к генерации исключения: scala> val x = 5 x: Int = 5 scala> assertionsEnabled = false mutated assertionsEnabled scala> boolAssert(x / 0 == 0) java.lang.ArithmeticException: / by zero ... 27 elided Но попытки утверждать это на основе того же самого кода в случае использо­ вания byNameAssert не приведут к генерации исключения: scala> byNameAssert(x / 0 == 0) Резюме В этой главе мы показали, как с помощью богатого инструментария функций Scala строить управляющие абстракции. Функции внутри вашего кода можно применять для избавления от распространенных шаблонов управления, а чтобы повторно задействовать шаблоны управления, часто встречающиеся в вашем программном коде, можно прибегнуть к функциям высшего порядка из библиотеки Scala. Кроме того, мы обсудили приемы использования карринга и параметров, передаваемых по имени, которые позволяют применять в весьма лаконичном синтаксисе собствен­ ные функции высшего порядка. В двух последних главах мы рассмотрели довольно много всего, что относится к функциям. В следующих нескольких главах вернемся к рассмотрению дополни­ тельных объектно-ориентированных средств языка. 10 Композиция и наследование В главе 6 мы представили часть основных объектно-ориентированных аспектов Scala. В текущей продолжим рассматривать эту тему с того места, где остановились в главе 6, и дадим углубленное и гораздо более полное представление об имеющей­ ся в Scala поддержке объектно-ориентированного программирования. Нам предстоит сравнить два основных вида взаимоотношений между класса­ ми: композицию и наследование. Композиция означает, что один класс содержит ссылку на другой и использует класс, на который ссылается, в качестве вспомога­ тельного средства для выполнения своей миссии. Наследование — это отношения «суперкласс/подкласс» (родительский/дочерний класс). Помимо этих тем, мы рассмотрим абстрактные классы, методы без параметров, расширение классов, переопределение методов и полей, параметрические поля, вызов конструкторов суперкласса, полиморфизм и динамическое связывание, финальные члены и классы, а также фабричные объекты и методы. 10.1. Библиотека двумерной разметки В качестве рабочего примера в этой главе мы создадим библиотеку для построения и вывода на экран двумерных элементов разметки. Каждый элемент будет пред­ ставлен прямоугольником, заполненным текстом. Для удобства библиотека будет предоставлять фабричные методы по имени elem, который создает новые элементы из переданных данных. Например, вы сможете создать элемент разметки, содержа­ щий строку, используя фабричный метод со следующей сигнатурой: elem(s: String): Element Как видите, элементы будут моделироваться с помощью типа данных по имени Element. Чтобы получить новый элемент, объединяющий два элемента, вы можете вызвать в отношении элемента операторы above или beside, передавая им второй 188 Глава 10 • Композиция и наследование элемент. Например, выражение, показанное ниже, создаст более крупный элемент, содержащий два столбца, каждый высотой в два элемента: val column1 = elem("hello") above elem("***") val column2 = elem("***") above elem("world") column1 beside column2 Вывод результата этого выражения даст следующий результат: hello *** *** world Элементы разметки — хороший пример системы, в которой объекты могут соз­ даваться из простых частей с помощью операторов композиции. В данной главе будут определены классы, позволяющие создавать объекты элементов из массивов, рядов и прямоугольников. Эти объекты базовых элементов будут простыми дета­ лями. Вдобавок будут определены операторы композиции above и beside. Такие операторы зачастую называют комбинаторами, поскольку они комбинируют эле­ менты некой области в новые элементы. Подходить к проектированию библиотеки лучше всего в понятиях комбинато­ ров: они позволяют осмыслить основные способы конструирования объектов в при­ кладной области. Что представляют собой простые объекты? Какими способами из простых объектов могут создаваться более интересные объекты? Как комбинаторы должны сочетаться друг с другом? Что должны представлять собой наиболее общие комбинации? Удовлетворяют ли они всем выдвигаемым правилам? Если у вас есть хорошие ответы на все эти вопросы, то вы на правильном пути. 10.2. Абстрактные классы Нашей первой задачей будет определить тип Element, представляющий элементы разметки. Элементы — двумерные прямоугольники из символов, поэтому имеет смысл включить в класс метод по имени contents, ссылающийся на содержи­ мое элемента разметки. Данный метод может быть представлен в виде массива строк, где каждая строка обозначает ряд. Отсюда типом возвращаемого contents значения должен быть Array[String]. Как он будет выглядеть, показано в ли­ стинге 10.1. Листинг 10.1. Определение абстрактного метода и класса abstract class Element { def contents: Array[String] } В этом классе contents объявляется в качестве метода, у которого нет реали­ зации. Иными словами, метод является абстрактным членом класса Element . Класс с абстрактными членами сам по себе должен быть объявлен абстрактным; это можно сделать, указав модификатор abstract перед ключевым словом class: abstract class Element ... 10.3. Определяем методы без параметров 189 Модификатор abstract указывает на то, что у класса могут быть абстрактные члены, не имеющие реализации. Поэтому создать экземпляр абстрактного класса невозможно. При попытке сделать это будет получена ошибка компиляции: scala> new Element <console>:5: error: class Element is abstract; cannot be instantiated new Element ^ Чуть позже в этой главе будет показан способ создания подклассов класса Element, экземпляры которых можно будет создавать, поскольку они заполняют отсутствующее определение метода contents. Обратите внимание на то, что у метода contents класса Element нет модифи­ катора abstract. Метод абстрактный, если у него нет реализации (то есть знака равенства и тела). В отличие от Java здесь при объявлении методов не нужны (не разрешены) модификаторы abstract. Методы, имеющие реализацию, называются конкретными. И еще одно различие в терминологии между объявлениями и определениями. Класс Element объявляет абстрактный метод contents, но пока не определяет ни­ каких конкретных методов. Но в следующем разделе класс Element будет усилен определением нескольких конкретных методов. 10.3. Определяем методы без параметров В качестве следующего шага к Element будут добавлены методы, показывающие его ширину (width) и высоту (height) (листинг 10.2). Метод height возвращает количество рядов в содержимом. Метод width возвращает длину первого ряда или, при отсутсвии рядов в элементе, нуль. Это значит, нельзя определить элемент с нулевой высотой и ненулевой шириной. Листинг 10.2. Определение не имеющих параметров методов width и height abstract class Element { def contents: Array[String] def height: Int = contents.length def width: Int = if (height == 0) 0 else contents(0).length } Обратите внимание: ни в одном из трех методов класса Element нет списка па­ раметров, даже пустого. Например, вместо: def width(): Int метод определен без круглых скобок: def width: Int Такие методы без параметров встречаются в Scala довольно часто. В от­ личие от них методы, определенные с пустыми круглыми скобками, например 190 Глава 10 • Композиция и наследование def height(): Int, называются методами с пустыми скобками. Согласно имеющим­ ся рекомендациям методы без параметров следует использовать, когда параметры отсутствуют и метод обращается к изменяемому состоянию только для чтения по­ лей содержащего его объекта (притом он не модифицирует изменяемое состояние). По этому соглашению поддерживается принцип единообразного доступа1, который гласит, что на клиентский код не должен влиять способ реализации атрибута в виде поля или метода. Например, можно реализовать width и height в виде полей, а не методов, просто заменив def в каждом определении на val: abstract class Element { def contents: Array[String] val height = contents.length val width = if (height == 0) 0 else contents(0).length } С точки зрения клиента, две пары определений абсолютно эквивалентны. Един­ ственное различие заключается в том, что доступ к полю может осуществляться немного быстрее вызова метода, поскольку значения полей предварительно вы­ числены при инициализации класса, а не вычисляются при каждом вызове метода. В то же время полям в каждом объекте Element требуется дополнительное про­ странство памяти. Поэтому вопрос о том, как лучше представить атрибут, в виде поля или в виде метода, зависит от способа использования класса клиентами, который со временем может измениться. Главное, чтобы на клиенты класса Element никак не воздействовали изменения, вносимые во внутреннюю реализацию класса. В частности, клиент класса Element не должен испытывать необходимости в перезаписи кода, если поле данного класса было переделано в функцию доступа, при условии, что это чистая функция доступа (то есть не имеет никаких побочных эффектов и не зависит от изменяемого состояния). Как бы то ни было, клиент не должен решать какие-либо проблемы. Пока у нас все получается. Но есть небольшое осложнение, связанное с мето­ дами работы Java. Дело в том, что в данном языке не реализуется принцип еди­ нообразного доступа. Поэтому string.length() в Java — это не то же самое, что string.length, и даже array.length — не то же самое, что array.length(). Вряд ли стоит говорить, насколько это усложняет дело. Преодолеть это препятствие языку Scala помогает то, что он весьма мягко от­ носится к смешиванию методов без параметров и методов с пустыми круглыми скобками. В частности, можно заменить метод без параметров методом с пустыми круглыми скобками и наоборот. Можно также не ставить пустые круглые скобки при вызове любой функции, не получающей аргументов. Например, в Scala оди­ наково допустимо применение двух следующих строк кода: Array(1, 2, 3).toString "abc".length 1 Meyer B. Object-Oriented Software Construction. — Prentice Hall, 2000. 10.4. Расширяем классы 191 В принципе, в вызовах функций в Scala можно вообще не ставить пустые кру­ глые скобки. Но их все же рекомендуется использовать, когда вызываемый метод представляет нечто большее, чем свойство своего объекта-получателя. Например, пустые круглые скобки уместны, если метод выполняет ввод-вывод, записывает переназначаемые переменные (var-переменные) или считывает var-переменные, не являющиеся полями объекта-получателя, как непосредственно, так и косвенно, используя изменяемые объекты. Таким образом, список параметров служит визу­ альным признаком того, что вызов инициирует некие примечательные вычисления, например: "hello".length println() // Нет (), поскольку побочные эффекты отсутствуют // Лучше () не отбрасывать Подводя итоги, следует отметить, что в Scala приветствуется определение мето­ дов, не получающих параметров и не имеющих побочных эффектов, в виде методов без параметров, то есть без пустых круглых скобок. В то же время никогда не нужно определять метод, имеющий побочные эффекты, без круглых скобок, поскольку вызов этого метода будет выглядеть как выбор поля. Следовательно, ваши клиенты могут быть удивлены, столкнувшись с побочными эффектами. По аналогии с этим при вызове функции, имеющей побочные эффекты, не за­ будьте при написании вызова поставить пустые круглые скобки. Есть еще один способ представить данное правило: если вызываемая функция выполняет опе­ рацию, то используйте круглые скобки. Но если она просто предоставляет доступ к свойству, то круглые скобки следует отбросить. 10.4. Расширяем классы Нам по-прежнему нужна возможность создавать объекты-элементы. Вы уже ви­ дели, что новый класс Element не приспособлен для этого в силу своей абстракт­ ности. Поэтому для получения экземпляра элемента необходимо создать подкласс, расширяющий класс Element и реализующий абстрактный метод contents. Как это делается, показано в листинге 10.3. Листинг 10.3. Определение класса ArrayElement в качестве подкласса класса Element class ArrayElement(conts: Array[String]) extends Element { def contents: Array[String] = conts } Класс ArrayElement определен в целях расширения класса Element. Как и в Java, для выражения данного обстоятельства после имени класса указывается уточнение extends: ... extends Element ... Использование уточнения extends заставляет класс ArrayElement унаследовать у класса Element все его неприватные элементы и превращает тип ArrayElement 192 Глава 10 • Композиция и наследование в подтип типа Element . Поскольку ArrayElement расширяет Element , то класс ArrayElement называется подклассом класса Element. В свою очередь, Element — супер­класс ArrayElement . Если не указать уточнение extends , то компилятор Scala, безусловно, предположит, что ваш класс является расширением клас­ са scala.AnyRef , который на платформе Java будет соответствовать классу java.lang.Object . Получается, класс Element неявно расширяет класс AnyRef . Эти отношения наследования показаны на рис. 10.1. Рис. 10.1. Схема классов для ArrayElement Наследование означает, что все элементы суперкласса являются также элемента­ ми подкласса, но с двумя исключениями. Первое: приватные элементы суперкласса не наследуются подклассом. Второе: элемент суперкласса не наследуется, если элемент с такими же именем и параметрами уже реализован в подклассе. В таком случае говорится, что элемент подкласса переопределяет элемент суперкласса. Если элемент в подклассе является конкретным, а в суперклассе — абстрактным, также говорится, что конкретный элемент — это реализация абстрактного элемента. Например, метод contents в ArrayElement переопределяет (или, в ином толко­ вании, реализует) абстрактный метод contents класса Element1. В отличие от этого класс ArrayElement наследует у класса Element методы width и height. Например, располагая ArrayElement-объектом ae, можно запросить его ширину, используя выражение ae.width, как будто метод width был определен в классе ArrayElement: scala> val ae = new ArrayElement(Array("hello", "world")) ae: ArrayElement = ArrayElement@5ae66c98 scala> ae.width res0: Int = 5 1 Один из недостатков данной конструкции заключается в том, что из-за изменяемости возвращаемого массива клиенты могут его модифицировать. В книге мы старались ничего не усложнять, но будь ArrayElement частью реального проекта, можно было бы рассмотреть возвращение вместо этого копии массива, которая позволяет защититься от изменений. Еще одна проблема заключается в том, что мы пока не гарантируем оди­ наковой длины массива contents каждого String-элемента. Задачу можно решить, про­ веряя соблюдение предварительного условия в первичном конструкторе и генерируя исключения при его нарушении. 10.5. Переопределяем методы и поля 193 Создание подтипов означает, что значение подкласса может быть использовано там, где требуется значение суперкласса. Например: val e: Element = new ArrayElement(Array("hello")) Переменная e определена как принадлежащая типу Element, следовательно, значение, используемое для ее инициализации, также должно быть типа Element. А фактически типом этого значения является класс ArrayElement. Это нормально, поскольку он расширяет класс Element и в результате тип ArrayElement совместим с типом Element1. На рис. 10.1 также показано отношение композиции между ArrayElement и Array[String] . Оно так называется, поскольку класс ArrayElement состоит из Array[String], то есть компилятор Scala помещает в генерируемый им для ArrayElement двоичный класс поле, содержащее ссылку на переданный массив conts. Некоторые моменты, касающиеся композиции и наследования, будут рассмо­ трены чуть позже — в разделе 10.11. 10.5. Переопределяем методы и поля Принцип единообразного доступа является одним из тех аспектов, где Scala под­ ходит к полям и методам единообразно — не так, как Java. Еще одно отличие заключается в том, что в Scala поля и методы принадлежат одному и тому же пространству имен. Этот позволяет полю переопределить метод без параметров. Например, можно, как показано в листинге 10.4, изменить реализацию contents в классе ArrayElement из метода в поле, не модифицируя определение абстрактного метода contents в классе Element. Листинг 10.4. Переопределение метода без параметров в поле class ArrayElement(conts: Array[String]) extends Element { val contents: Array[String] = conts } Поле contents (определенное с ключевым словом val) в этой версии ArrayEle­ ment — вполне подходящая реализация метода без параметров contents (объяв­ ленное с ключевым словом def) в классе Element. В то же время в Scala запрещено в одном и том же классе определять поле и метод с одинаковыми именами, а в Java это разрешено. Например, этот класс в Java пройдет компиляцию вполне успешно: // Это код Java class CompilesFine { private int f = 0; 1 Чтобы получить более четкое представление о разнице между подклассом и подтипом, обратитесь к статье глоссария о подтипах. 194 Глава 10 } • Композиция и наследование public int f() { return 1; } Но соответствующий класс в Scala не скомпилируется: class WontCompile { private var f = 0 // Не пройдет компиляцию, поскольку поле def f = 1 // и метод имеют одинаковые имена } В принципе, в Scala вместо четырех имеющихся в Java пространств имен для определений есть только два пространства. Четыре пространства имен в Java — это поля, методы, типы и пакеты. Напротив, двумя пространствами имен в Scala являются: значения (поля, методы, пакеты и объекты-одиночки); типы (имена классов и трейтов). Причина, по которой поля и методы в Scala помещаются в одно и то же про­ странство имен, заключается в предоставлении возможности переопределить методы без параметров в val-поля, чего нельзя сделать в Java1. 10.6. Определяем параметрические поля Рассмотрим еще раз определение класса ArrayElement, показанное в предыдущем разделе. В нем имеется параметр conts, единственное предназначение которого — его копирование в поле contents. Имя conts было выбрано для параметра, чтобы походило на имя поля contents, но не вступало с ним в конфликт имен. Это «код с душком» — признак того, что в вашем коде может быть некая совершенно не­ нужная избыточность и повторяемость. От этого кода сомнительного качества можно избавиться, скомбинировав па­ раметр и поле в едином определении параметрического поля, что и показано в ли­ стинге 10.5. Листинг 10.5. Определение contents в качестве параметрического поля class ArrayElement( val contents: Array[String] ) extends Element Обратите внимание: параметр contents имеет префикс val. Это сокращенная форма записи, определяющая одновременно параметр и поле с одним и тем же 1 Причиной того, что пакеты в Scala используют общее с полями и методами пространство имен, является стремление предоставить вам возможность получать доступ к импорту пакетов (а не только к именам типов), а также к полям и методам объектов-одиночек. Это тоже входит в перечень того, что невозможно сделать в Java. Подробности будут рассмотрены в разделе 13.3. 10.7. Вызываем конструктор суперкласса 195 именем. Если выразиться более конкретно, то класс ArrayElement теперь имеет поле contents (непереназначаемое), доступ к которому может быть получен за пределами класса. Поле инициализировано значением параметра. Похоже, будто бы класс был написан следующим образом: class ArrayElement(x123: Array[String]) extends Element { val contents: Array[String] = x123 } где x123 — произвольное свежее имя для параметра. Кроме того, можно поставить перед параметром класса префикс var, и тогда соответствующее поле станет переназначаемым. И наконец, подобным параме­ тризованным полям, как и любым другим членам класса, можно добавлять такие модификаторы, как private, protected1 или override. Рассмотрим, к примеру, следующие определения классов: class Cat { val dangerous = false } class Tiger( override val dangerous: Boolean, private var age: Int ) extends Cat Определение класса Tiger — сокращенная форма для следующего альтернатив­ ного определения класса с переопределяемым элементом dangerous и приватным элементом age: class Tiger(param1: Boolean, param2: Int) extends Cat { override val dangerous = param1 private var age = param2 } Оба элемента инициализируются соответствующими параметрами. Имена для этих параметров, param1 и param2, были выбраны произвольно. Главное, чтобы они не конфликтовали с какими-либо другими именами в пространстве имен. 10.7. Вызываем конструктор суперкласса Теперь вы располагаете полноценной системой из двух классов: абстрактного класса Element, который расширяется конкретным классом ArrayElement. Можно также наметить иные способы выражения элемента. Например, клиенту может по­ надобиться создать элемент разметки, содержащий один ряд, задаваемый строкой. Объектно-ориентированное программирование упрощает расширение системы новыми вариантами данных. Можно просто добавить подклассы. Например, в ли­ стинге 10.6 показан класс LineElement, расширяющий класс ArrayElement. 1 Модификатор protected, предоставляющий доступ к подклассам, будет подробно рас­ смотрен в главе 13. 196 Глава 10 • Композиция и наследование Листинг 10.6. Вызов конструктора суперкласса class LineElement(s: String) extends ArrayElement(Array(s)) { override def width = s.length override def height = 1 } Поскольку LineElement расширяет ArrayElement, а конструктор ArrayElement по­ лучает параметр (Array[String]), то LineElement нужно передать аргумент первич­ ному конструктору своего суперкласса. В целях вызова конструктора суперкласса аргумент или аргументы, которые нужно передать, просто помещаются в круглые скобки, стоящие за именем суперкласса. Например, класс LineElement передает аргумент Array(s) первичному конструктору класса ArrayElement, поместив его в круглые скобки и указав после имени суперкласса ArrayElement: ... extends ArrayElement(Array(s)) ... С появлением нового подкласса иерархия наследования для элементов разметки приобретает вид, показанный на рис. 10.2. Рис. 10.2. Схема классов для LineElement 10.8. Используем модификатор override Обратите внимание: определения width и height в LineElement имеют модификатор override. В разделе 6.3 он встречался в определении метода toString. В Scala такой модификатор требуется для всех элементов, переопределяющих конкретный член родительского класса. Если элемент является реализацией абстрактного элемента с тем же именем, то модификатор указывать не обязательно. Применять модифи­ катор запрещено, если член не переопределяется или не является реализацией какого-либо другого члена базового класса. Поскольку height и width в классе LineElement переопределяют конкретные определения в классе Element, то моди­ фикатор override указывать обязательно. Соблюдение этого правила дает полезную информацию для компилятора, которая помогает избежать некоторых трудноотлавливаемых ошибок и сделать развитие системы более безопасным. Например, если вы опечатались в названии 10.8. Используем модификатор override 197 метода или случайно указали для него не тот список параметров, то компилятор тут же отреагирует, выдав сообщение об ошибке: $ scalac LineElement.scala .../LineElement.scala:50: error: method hight overrides nothing override def hight = 1 ^ Соглашение о применении модификатора override приобретает еще большее значение, когда дело доходит до развития системы. Скажем, вы определили библио­ теку методов рисования двумерных фигур, сделали ее общедоступной, и она стала использоваться довольно широко. Посмотрев на эту библиотеку позже, вы захотели добавить к основному классу Shape новый метод с такой сигнатурой: def hidden(): Boolean Новый метод будут использовать различные методы рисования, чтобы опре­ делить необходимость отрисовки той или иной фигуры. Тем самым можно суще­ ственно ускорить работу, но сделать это, не рискуя вывести из строя клиентский код, невозможно. Кроме всего прочего, у клиента может быть определен подкласс класса Shape с другой реализацией hidden. Возможно, клиентский метод фактиче­ ски заставит объект-получатель просто исчезнуть вместо того, чтобы проверять, не является ли он невидимым. Две версии hidden переопределяют друг друга, по­ этому ваши методы рисования в итоге заставят объекты исчезать, что совершенно не соответствует задуманному! Эти «случайные переопределения» — наиболее распространенное проявление проблемы так называемого хрупкого базового класса. Проблема в том, что, если вы добавите новые члены в базовые классы (которые мы обычно называем супер­ классами), вы рискуете вывести из строя клиентский код. Полностью разрешить проблему хрупкого базового класса в Scala невозможно, но по сравнению с Java ситуация несколько лучше1. Если библиотека рисования и ее клиентский код соз­ даны в Scala, то у клиентской исходной реализации hidden не мог использоваться модификатор override, поскольку на момент его применения не могло быть другого метода с таким же именем. Когда вы добавите метод hidden во вторую версию вашего класса фигур, при повторной компиляции кода клиента будет выдана следующая ошибка: .../Shapes.scala:6: error: error overriding method hidden in class Shape of type ()Boolean; method hidden needs 'override' modifier def hidden(): Boolean = ^ То есть вместо неверного поведения ваш клиент получит ошибку в ходе компи­ ляции, и такой исход обычно более предпочтителен. 1 В Java 1.5 была введена аннотация @Override, работающая аналогично имеющемуся в Scala модификатору override, но, в отличие от Scala-модификатора override, применять ее не обязательно. 198 Глава 10 • Композиция и наследование 10.9. Полиморфизм и динамическое связывание В разделе 10.4 было показано, что переменная типа Element может ссылаться на объект типа ArrayElement. Этот феномен называется полиморфизмом, что означает «множество форм». В данном случае объекты типа Element могут иметь множество форм1. Ранее вам уже попадались две такие формы: ArrayElement и LineElement. Можно создать еще больше форм Element, определив новые подклассы класса Element. Например, можно определить новую форму Element с заданными шириной и вы­ сотой (width и height) и полностью заполненную заданным символом: class UniformElement( ch: Char, override val width: Int, override val height: Int ) extends Element { private val line = ch.toString * width def contents = Array.fill(height)(line) } Иерархия наследования для класса Element теперь приобретает вид, показанный на рис. 10.3. В результате Scala примет все следующие присваивания, поскольку тип выражения присваивания соответствует типу определяемой переменной: val val val val e1: ae: e2: e3: Element = new ArrayElement(Array("hello", "world")) ArrayElement = new LineElement("hello") Element = ae Element = new UniformElement('x', 2, 3) Рис. 10.3. Иерархия классов элементов разметки Если изучить иерархию наследования, то окажется, что в каждом из этих четы­ рех val-определений тип выражения справа от знака равенства принадлежит типу val-переменной, инициализируемой слева от знака равенства. Есть еще одна сторона вопроса: вызовы методов с переменными и выражения­ ми динамически связаны. Это значит, текущая реализация вызываемого метода 1 Эта разновидность полиморфизма называется полиморфизмом подтипов. Еще одна раз­ новидность полиморфизма в Scala, которая называется универсальным полиморфизмом, рассматривается в главе 19. 10.9. Полиморфизм и динамическое связывание 199 определяется во время выполнения программы на основе класса объекта, а не типа переменной или выражения. Чтобы продемонстрировать данное поведение, мы временно уберем все существующие члены из наших классов Element и до­ бавим к Element метод по имени demo. Затем переопределим demo в ArrayElement и LineElement, но не в UniformElement: abstract class Element { def demo() = { println("Вызвана реализация, определенная в Element") } } class ArrayElement extends Element { override def demo() = { println("Вызвана реализация, определенная в ArrayElement") } } class LineElement extends ArrayElement { override def demo() = { println("Вызвана реализация, определенная в LineElement") } } // UniformElement наследует demo из Element class UniformElement extends Element Если ввести данный код в интерпретатор, то можно будет определить метод, который получает объект типа Element и вызывает в отношении него метод demo: def invokeDemo(e: Element) = { e.demo() } Если передать методу invokeDemo объект типа ArrayElement, то будет показано сообщение, свидетельствующее о вызове реализации demo из класса ArrayElement, даже если типом переменной e, в отношении которой был вызван метод demo, яв­ лялся Element: scala> invokeDemo(new ArrayElement) Вызвана реализация, определенная в ArrayElement Аналогично, если передать invokeDemo объект типа LineElement, будет показано сообщение, свидетельствующее о вызове той реализации demo, которая была опре­ делена в классе LineElement: scala> invokeDemo(new LineElement) Вызвана реализация, определенная в LineElement Поведение при передаче объекта типа UniformElement может на первый взгляд показаться неожиданным, но оно вполне корректно: scala> invokeDemo(new UniformElement) Вызвана реализация, определенная в Element 200 Глава 10 • Композиция и наследование В классе UniformElement метод demo не переопределяется, поэтому в нем насле­ дуется реализация demo из его суперкласса Element. Таким образом, реализация, определенная в классе Element, — это правильная реализация вызова demo, когда классом объекта является UniformElement. 10.10. Объявляем финальные элементы Иногда при проектировании иерархии наследования нужно обеспечить невозмож­ ность переопределения элемента подклассом. В Scala, как и в Java, это делается путем добавления к элементу модификатора final. Как показано в листинге 10.7, модификатор final можно указать для метода demo класса ArrayElement. Листинг 10.7. Объявление финального метода class ArrayElement extends Element { final override def demo() = { println("Вызвана реализация, определенная в ArrayElement") } } При наличии данной версии в классе ArrayElement попытка переопределить demo в его подклассе LineElement не пройдет компиляцию: elem.scala:18: error: error overriding method demo in class ArrayElement of type ()Unit; method demo cannot override final member override def demo() = { ^ Порой вы должны быть уверены, что создать подкласс для класса в целом невоз­ можно. Для этого нужно просто сделать весь класс финальным, добавив к объявле­ нию класса модификатор final. Например, в листинге 10.8 показано, как должен быть объявлен финальный класс ArrayElement. Листинг 10.8. Объявление финального класса final class ArrayElement extends Element { override def demo() = { println("Вызвана реализация, определенная в ArrayElement") } } При наличии данной версии класса ArrayElement любая попытка определить подкласс не пройдет компиляцию: elem.scala: 18: error: illegal inheritance from final class ArrayElement class LineElement extends ArrayElement { ^ Теперь мы удалим модификаторы final и методы demo и вернемся к прежней реализации семейства классов Element. Далее в главе мы сконцентрируемся на за­ вершении создания работоспособной версии библиотеки разметки. 10.11. Используем композицию и наследование 201 10.11. Используем композицию и наследование Композиция и наследование — два способа определить новый класс в понятиях другого уже существующего класса. Если вы ориентируетесь преимущественно на повторное использование кода, то, как правило, предпочтение нужно отдавать композиции, а не наследованию. Только ему свойственна проблема хрупкого ба­ зового класса, вследствие которой можно ненароком сделать неработоспособными подклассы, внося изменения в суперкласс. Насчет отношения наследования нужно задаться лишь одним вопросом: не мо­ делируется ли им взаимоотношение типа is-a (является)1. Например, нетрудно будет заметить, что класс ArrayElement является разновидностью Element. Можно задаться еще одним вопросом: придется ли клиентам использовать тип подкласса в качестве типа суперкласса2. Применительно к классу ArrayElement не вызы­ вает никаких сомнений, что клиентам потребуется задействовать объекты типа ArrayElement в качестве объектов типа Element. Если задаться этими вопросами относительно отношений наследования, по­ казанных на рис. 10.3, то не покажутся ли вам какие-либо из них подозрительны­ ми? В частности, насколько для вас очевидно, что LineElement является (is-a) ArrayElement? Как вы думаете, понадобится ли когда-нибудь клиентам восполь­ зоваться типом LineElement в качестве типа ArrayElement? Фактически класс LineElement был определен как подкласс класса ArrayElement преимущественно для повторного использования имеющегося в ArrayElement определения contents. Поэтому, возможно, будет лучше определить LineElement в качестве прямого подкласса класса Element: class LineElement(s: String) extends Element { val contents = Array(s) override def width = s.length override def height = 1 } В предыдущей версии LineElement состоял в отношении наследования с ArrayElement, откуда наследовал метод contents. Теперь же у него отношение композиции с классом Array : в нем содержится ссылка на строковый мас­ сив из его собственного поля contents3. При наличии этой реализации класса LineElement иерархия наследования для Element приобретет вид, показанный на рис. 10.4. 1 Meyers S. Effective C++. — Addison-Wesley, 1991. 2 Эккель Б. Философия Java. — СПб.: Питер, 2021. 3 Класс ArrayElement также имеет отношение композиции с классом Array, поскольку его параметрическое поле contents содержит ссылку на строковый массив. Код для ArrayElement показан в листинге 10.5 (см. выше). Его отношение композиции пред­ ставлено в схеме классов в виде ромба, к примеру, на рис. 10.1 (см. выше). 202 Глава 10 • Композиция и наследование Рис. 10.4. Иерархия класса с пересмотренным определением подкласса LineElement 10.12. Реализуем методы above, beside и toString В качестве следующего шага в классе Element будет реализован метод above. По­ местить один элемент выше другого с помощью метода above означает объединить два значения содержимого элементов, представленного contents. Поэтому первый, черновой вариант метода above может иметь следующий вид: def above(that: Element): Element = new ArrayElement(this.contents ++ that.contents) Операция ++ объединяет два массива. Массивы в Scala представлены в виде массивов Java, но поддерживают значительно большее количество методов. В част­ ности, массивы в Scala могут быть преобразованы в экземпляры класса scala.Seq, которые представляют собой последовательные структуры и содержат несколько методов для доступа к последовательностям и их преобразования. В этой главе мы рассмотрим и некоторые другие методы, а развернутое представление о них дадим в главе 17. В действительности показанный ранее код нельзя считать достаточным, по­ скольку он не позволяет помещать друг на друга элементы разной ширины. Но чтобы в этом разделе ничего не усложнять, оставим все как есть и станем передавать в метод above только элементы одинаковой длины. В разделе 10.14 мы усовершенствуем его, чтобы клиенты могли с его помощью объединять элементы разной ширины. Следующим будет реализован метод beside. Чтобы поставить элементы рядом друг с другом, создадим новый элемент, в котором каждый ряд будет получен путем объединения соответствующих рядов двух элементов. Как и прежде, во избежание усложнений начнем с предположения, что высота двух элементов одинакова. Тогда стуктура метода beside приобретет такой вид: def beside(that: Element): Element = { val contents = new Array[String](this.contents.length) for (I <- 0 until this.contents.length) contents(i) = this.contents(i) + that.contents(i) new ArrayElement(contents) } 10.12. Реализуем методы above, beside и toString 203 Метод beside сначала выделяет новый массив contents и заполняет его объ­ единением соответствующих элементов массивов this.contents и that.contents. В итоге получается новый ArrayElement, имеющий новое содержимое contents. Хотя эта реализация beside вполне работоспособна, в ней используется импе­ ративный стиль программирования, о чем явно свидетельствует наличие цикла, обходящего элементы массивов по индексам. В альтернативном варианте метод можно сократить до одного выражения: new ArrayElement( for ( (line1, line2) <- this.contents zip that.contents ) yield line1 + line2 ) Здесь с помощью оператора zip массивы this.contents и that.contents преоб­ разуются в массив пар (так называются кортежи Tuple2). Оператор zip выбирает соответствующие элементы двух своих операндов и формирует массив пар. На­ пример, выражение: Array(1, 2, 3) zip Array("a", "b") будет вычислено в следующее: Array((1, "a"), (2, "b")) Если один из двух массивов-операндов длиннее другого, то оставшиеся эле­ менты оператор zip просто отбрасывает. В показанном ранее выражении третий элемент левого операнда, 3, не формирует пару результата, поскольку для него не находится соответствующий элемент в правом операнде. Затем выполняется обход элементов объединенного массива с помощью выра­ жения for. Здесь синтаксис for ((line1, line2) <- ...) позволяет указать имена обоих элементов пары в одном паттерне (то есть теперь line1 обозначает первый элемент пары, а line2 — второй). Имеющаяся в Scala система сопоставления с образцом (паттерном) будет подробнее рассмотрена в главе 15. А сейчас все это можно представить себе как способ определения для каждого шага итерации двух val-переменных: line1 и line2. У выражения for есть часть yield, и поэтому оно выдает (англ. yields) результат. Данный результат того же вида, что и перебираемый выражением объект (то есть данный массив). Каждый элемент — результат объединения соответствующих рядов: line1 и line2. Следовательно, конечный результат выполнения этого кода получается таким же, как и результат выполнения первой версии beside, но, по­ скольку в нем удалось обойтись без явной индексации массива, результат дости­ гается способом, при котором допускается меньше ошибок. Но вам все еще нужен способ отображения элементов. Как обычно, отображение выполняется с помощью определения метода toString, возвращающего элемент, отформатированный в виде строки. Его определение выглядит так: override def toString = contents mkString "\n" В реализации toString используется метод mkString, который определен для всех последовательностей, включая массивы. В разделе 7.8 было показано: такое 204 Глава 10 • Композиция и наследование выражение, как arr mkString sep, возвращает строку, состоящую из всех элемен­ тов массива arr. Каждый элемент отображается на строку путем вызова его метода toString. Между последовательными элементами строк вставляется разделитель sep. Следовательно, выражение contents mkString "\n" форматирует содержимое массива как строку, где каждый элемент массива появляется в собственном ряду. Обратите внимание: метод toString не требует указания пустого списка параме­ тров. Это соответствует рекомендациям по соблюдению принципа единообразного доступа, поскольку toString — чистый метод, не получающий никаких параметров. После добавления этих трех методов класс Element приобретет вид, показанный в листинге 10.9. Листинг 10.9. Класс Element с методами above, beside и toString abstract class Element { def contents: Array[String] def width: Int = if (height == 0) 0 else contents(0).length def height: Int = contents.length def above(that: Element): Element = new ArrayElement(this.contents ++ that.contents) def beside(that: Element): Element = new ArrayElement( for ( (line1, line2) <- this.contents zip that.contents ) yield line1 + line2 ) override def toString = contents mkString "\n" } 10.13. Определяем фабричный объект Теперь у вас есть иерархия классов для элементов разметки. Можно предоставить ее вашим клиентам как есть или выбрать технологию сокрытия иерархии за фа­ бричным объектом. В фабричном объекте содержатся методы, конструирующие другие объекты. За­ тем с помощью этих методов клиенты будут конструировать объекты вместо того, чтобы делать это, непосредственно используя ключевое слово new. Преимущество такого подхода заключается в возможности централизации создания объектов и в сокрытии способа представления объектов с помощью классов. Такое сокрытие сделает вашу библиотеку понятнее для клиентов, поскольку в открытом виде будет предоставлено меньше подробностей. Вдобавок оно обеспечит вам больше воз­ 10.13. Определяем фабричный объект 205 можностей вносить последующие изменения реализации библиотеки, не нарушая работу клиентского кода. Первая задача при конструировании фабрики для элементов разметки — выбор места, в котором должны располагаться фабричные методы. Чьими элементами они должны быть — объекта-одиночки или класса? Как должен быть назван со­ держащий их объект или класс? Существует множество возможностей. Самое простое решение — создать объект-компаньон класса Element и превратить его в фабричный объект для элементов разметки. Таким образом, клиентам нужно предоставить только комбинацию «класс — объект Element», а реализацию трех классов ArrayElement, LineElement и UniformElement можно скрыть. В листинге 10.10 представлена структура объекта Element, соответствующего этой схеме. В объекте Element содержатся три переопределяемых варианта метода elem, которые конструируют различный вид объекта разметки. Листинг 10.10. Фабричный объект с фабричными методами object Element { def elem(contents: Array[String]): Element = new ArrayElement(contents) def elem(chr: Char, width: Int, height: Int): Element = new UniformElement(chr, width, height) def elem(line: String): Element = new LineElement(line) } С появлением этих фабричных методов наметился смысл изменить реализа­ цию класса Element таким образом, чтобы в нем вместо явного создания новых экземпляров ArrayElement выполнялись фабричные методы elem. Чтобы вызвать фабричные методы, не указывая с ними имя объекта-одиночки Element, мы им­ портируем в верхней части кода исходный файл Element.elem. Иными словами, вместо вызова фабричных методов с помощью указания Element.elem внутри класса Element мы импортируем Element.elem, чтобы можно было просто вызвать фабричные методы по имени elem. Код класса Element после внесения изменений показан в листинге 10.11. Листинг 10.11. Класс Element, реорганизованный для использования фабричных методов import Element.elem abstract class Element { def contents: Array[String] def width: Int = if (height == 0) 0 else contents(0).length def height: Int = contents.length 206 Глава 10 • Композиция и наследование def above(that: Element): Element = elem(this.contents ++ that.contents) def beside(that: Element): Element = elem( for ( (line1, line2) <- this.contents zip that.contents ) yield line1 + line2 ) } override def toString = contents mkString "\n" Кроме того, благодаря наличию фабричных методов теперь подклассы ArrayEle­ ment, LineElement и UniformElement могут стать приватными, поскольку отпадет надобность непосредственного обращения к ним со стороны клиентов. В Scala классы и объекты-одиночки можно определять внутри других классов и объектоводиночек. Один из способов превратить подклассы класса Element в приватные — поместить их внутрь объекта-одиночки Element и объявить их там приватными. Классы по-прежнему будут доступны трем фабричным методам elem там, где в них есть надобность. Как это будет выглядеть, показано в листинге 10.12. Листинг 10.12. Сокрытие реализации с помощью использования приватных классов object Element { private class ArrayElement( val contents: Array[String] ) extends Element private class LineElement(s: String) extends Element { val contents = Array(s) override def width = s.length override def height = 1 } private class UniformElement( ch: Char, override val width: Int, override val height: Int ) extends Element { private val line = ch.toString * width def contents = Array.fill(height)(line) } def elem(contents: Array[String]): Element = new ArrayElement(contents) def elem(chr: Char, width: Int, height: Int): Element = new UniformElement(chr, width, height) } def elem(line: String): Element = new LineElement(line) 10.14. Методы heighten и widen 207 10.14. Методы heighten и widen Нам нужно внести еще одно, последнее усовершенствование. Версия Element, по­ казанная в листинге 10.11 (см. выше), не может всецело нас устроить, поскольку не позволяет клиентам помещать друг на друга элементы разной ширины или по­ мещать рядом друг с другом элементы разной высоты. Например, вычисление следующего выражения не будет работать корректно, так как второй ряд в объединенном элементе длиннее первого: new ArrayElement(Array("hello")) above new ArrayElement(Array("world!")) Аналогично этому вычисление следующего выражения не будет работать пра­ вильно из-за того, что высота первого элемента ArrayElement составляет два ряда, а второго — только один: new ArrayElement(Array("one", "two")) beside new ArrayElement(Array("one")) В листинге 10.13 показан приватный вспомогательный метод по имени widen, ко­ торый получает ширину и возвращает объект Element указанной ширины. Результат включает в себя содержимое этого объекта, которое для достижения нужной ширины отцентрировано за счет создания отступов справа и слева с помощью любого нужного для этого количества пробелов. В листинге также показан похожий метод heighten, выполняющий то же в вертикальном направлении. Метод widen вызывается методом above, чтобы обеспечить одинаковую ширину элементов, которые помещаются друг над другом. Аналогично этому метод heighten вызывается методом beside, чтобы обеспечить одинаковую высоту элементов, помещаемых рядом друг с другом. По­ сле внесения этих изменений библиотека разметки будет готова к использованию. Листинг 10.13. Класс Element с методами widen и heighten import Element.elem abstract class Element { def contents: Array[String] def width: Int = contents(0).length def height: Int = contents.length def above(that: Element): Element = { val this1 = this widen that.width val that1 = that widen this.width elem(this1.contents ++ that1.contents) } def beside(that: Element): Element = { val this1 = this heighten that.height val that1 = that heighten this.height elem( for ((line1, line2) <- this1.contents zip that1.contents) yield line1 + line2) } 208 Глава 10 • Композиция и наследование def widen(w: Int): Element = if (w <= width) this else { val left = elem(' ', (w – width) / 2, height) val right = elem(' ', w – width – left.width, height) left beside this beside right } def heighten(h: Int): Element = if (h <= height) this else { val top = elem(' ', width, (h – height) / 2) val bot = elem(' ', width, h – height – top.height) top above this above bot } } override def toString = contents mkString "\n" 10.15. Собираем все воедино Интересным способом применения почти всех элементов библиотеки разметки будет написание программы, рисующей спираль с заданным количеством ребер. Ее созданием займется программа Spiral, показанная в листинге 10.14. Листинг 10.14. Приложение Spiral import Element.elem object Spiral { val space = elem(" ") val corner = elem("+") def spiral(nEdges: Int, direction: Int): Element = { if (nEdges == 1) elem("+") else { val sp = spiral(nEdges – 1, (direction + 3) % 4) def verticalBar = elem('|', 1, sp.height) def horizontalBar = elem('-', sp.width, 1) if (direction == 0) (corner beside horizontalBar) above (sp beside space) else if (direction == 1) (sp above space) beside (corner above verticalBar) else if (direction == 2) (space beside sp) above (horizontalBar beside corner) else (verticalBar above corner) beside (space above sp) } } Резюме 209 } def main(args: Array[String]) = { val nSides = args(0).toInt println(spiral(nSides, 0)) } Поскольку Spiral является самостоятельным объектом с методом main, име­ ющим надлежащую сигнатуру, этот код можно считать приложением, написанным на Scala. Spiral получает один аргумент командной строки в виде целого числа и рисует спираль с указанным количеством граней. Например, можно нарисовать шестигранную спираль, как показано слева, и более крупную спираль, как показано справа. $ scala Spiral 6 +----| | +-+ | + | | | +---+ $ scala Spiral 11 +---------| | +------+ | | | | | +--+ | | | | | | | | ++ | | | | | | | +----+ | | | +--------+ $ scala Spiral 17 +---------------| | +------------+ | | | | | +--------+ | | | | | | | | | +----+ | | | | | | | | | | | | | ++ | | | | | | | | | | | | | | +--+ | | | | | | | | | | | +------+ | | | | | | | +----------+ | | | +--------------+ Резюме В этой главе мы рассмотрели дополнительные концепции объектно-ориентиро­ ванного программирования на языке Scala. Среди них — абстрактные классы, наследование и создание подтипов, иерархии классов, параметрические поля и переопределение методов. У вас должно было выработаться понимание способов создания в Scala оригинальных иерархий классов. А к работе с библиотекой рас­ кладки мы еще вернемся в главе 14. 11 Иерархия Scala После изучения в предыдущей главе подробностей наследования классов самое время немного отступить и посмотреть на иерархию классов Scala в целом. В Scala каждый класс наследуется от общего суперкласса по имени Any. Поскольку каждый класс является подклассом Any, то методы, определенные в классе Any, универсаль­ ны: их можно вызвать в отношении любого объекта. В самом низу иерархии в Scala также определяются довольно интересные классы Null и Nothing, которые, по сути, выступают в роли общих подклассов. Например, в то время, как Any — суперкласс для всех классов, Nothing — подкласс для любого класса. В данной главе мы про­ ведем экскурсию по имеющейся в Scala иерархии классов. 11.1. Иерархия классов Scala На рис. 11.1 в общих чертах показана иерархия классов Scala. На вершине иерархии находится класс Any; в нем определяются методы, в число которых входят: final def ==(that: Any): Boolean final def !=(that: Any): Boolean def equals(that: Any): Boolean def ##: Int def hashCode: Int def toString: String Все классы — наследники класса Any, поэтому каждый объект в программе на Scala можно подвергуть сравнению с помощью методов ==, != или equals, хеширо­ ванию с использованием методов ## или hashCode и форматированию, прибегнув к методу toString. Методы определения равенства и неравенства == и != объявлены в классе Any как final, следовательно, переопределить их в подклассах невозможно. Метод == — по сути, то же самое, что и метод equals, а метод != всегда является отрицанием метода equals1. Таким образом, отдельные классы могут перекроить 1 Единственный случай, когда использование == не приводит к непосредственному вы­ зову equals, относится к упакованным числовым классам Java, таким как Integer или Long. В Java new Integer(1) не эквивалентен new Long(1) даже в случае применения 11.1. Иерархия классов Scala 211 смысл значения метода == или!=, переопределив метод equals. Соответствующий пример будет показан в этой главе чуть позже. У корневого класса Any имеется два подкласса: AnyVal и AnyRef. Класс AnyVal является родительским для классов значений в Scala. Наряду с возможностью определять собственные классы значений (см. раздел 11.4), Scala имеет девять встроенных классов значений: Byte, Short, Char, Int, Long, Float, Double, Boolean и Unit. Первые восемь из них соответствуют примитивным типам Java, и их зна­ чения во время выполнения программы представляются в виде примитивных значений Java. Все экземпляры этих классов написаны в Scala в виде литералов. Например, 42 — экземпляр класса Int, 'x' — экземпляр Char, а false — экземпляр класса Boolean. Создать экземпляры этих классов, используя ключевое слово new, невозможно. Этому препятствует особый прием, состоящий в том, что все классы значений определены одновременно и как абстрактные, и как финальные. Поэтому, если воспользоваться следующим кодом: scala> new Int то будет получен такой результат: <console>:5: error: class Int is abstract; cannot be instantiated new Int ^ Еще один класс значений, Unit, примерно соответствует имеющемуся в Java типу void — он используется в качестве результирующего типа выполнения метода, который не возвращает содержательного результата. Как упоминалось в разделе 7.2, у Unit имеется единственное значение экземпляра, которое записывается как (). В соответствии с объяснениями, изложенными в главе 5, в классах значений в качестве методов поддерживаются обычные арифметические и логические (булевы) операторы. Например, у класса Int имеются методы + и *, а у класса Boolean — методы || и &&. Классы значений также наследуют все методы из класса Any. Это можно протестировать в интерпретаторе: scala> 42.toString res1: String = 42 scala> 42.hashCode res2: Int = 42 scala> 42 equals 42 res3: Boolean = true примитивных значений 1 == 1L. Поскольку Scala — более регулярный язык, чем Java, появилась необходимость скорректировать это несоответствие, задействовав для этих классов особую версию метода ==. Точно так же метод ## обеспечивает Scala-версию хеширования и похож на Java-метод hashCode, за исключением того, что для упакован­ ных числовых типов он всегда работает с методом ==. Например, для new Integer(1) и new Long(1) метод ## вычисляет один и тот же хеш, тогда как Java-методы hashCode вычисляют разный хеш-код. • Рис. 11.1. Иерархия классов Scala 212 Глава 11 Иерархия Scala 11.1. Иерархия классов Scala 213 Следует отметить, что пространство классов значений плоское: все классы зна­ чений являются подтипами scala.AnyVal, но не являются подклассами друг друга. Вместо этого между различными типами классов значений существует неявное преобразование типов. Например, экземпляр класса scala.Int, когда это требу­ ется, автоматически расширяется (путем неявного преобразования) в экземпляр класса scala.Long. Как упоминалось в разделе 5.10, неявное преобразование используется также для добавления дополнительных функциональных возможностей к типам значе­ ний. Например, тип Int поддерживает все показанные далее операции: scala> 42 max 43 res4: Int = 43 scala> 42 min 43 res5: Int = 42 scala> 1 until 5 res6: scala.collection.immutable.Range = Range 1 until 5 scala> 1 to 5 res7: scala.collection.immutable.Range.Inclusive = Range 1 to 5 scala> 3.abs res8: Int = 3 scala> (-3).abs res9: Int = 3 Работает это следующим образом: все методы min, max, until, to и abs опреде­ лены в классе scala.runtime.RichInt, а между классами Int и RichInt существует неявное преобразование. Оно применяется при вызове в отношении Int-объекта метода, который определен не в классе Int, а в классе RichInt. По аналогии с этим «классы-усилители» и неявные преобразования существуют и для других классов значений. Более подробно неявные преобразования будут рассматриваться в гла­ ве 21. Другой подкласс корневого класса Any — класс AnyRef . Это базовый класс всех ссылочных классов в Scala. Как упоминалось ранее, на платформе Java AnyRef фактически является псевдонимом класса java.lang.Object . Следовательно, все классы, написанные на Java и Scala, — наследники класса AnyRef1. Поэтому о java.lang.Object можно думать как о способе реализации AnyRef на платформе Java. Следовательно, хоть Object и AnyRef и можно взаимозаменяемо использовать в программах Scala на платформе Java, рекомендуемым стилем будет повсеместное применение AnyRef. 1 Причина существования псевдонима AnyRef, заменяющего использование имени java.lang.Object, заключается в том, что Scala изначально разрабатывался для работы как на платформе Java, так и на платформе .NET. На платформе .NET AnyRef был псевдонимом для System.Object. 214 Глава 11 • Иерархия Scala 11.2. Как реализованы примитивы Как все это реализовано? Фактически в Scala целочисленные значения хранятся так же, как и в Java, — в виде 32-разрядных слов. Это необходимо для эффективной работы виртуальной машины Java (JVM), а также обеспечения возможности со­ вместной работы с библиотеками Java. Такие стандартные операции, как сложение или умножение, реализуются в качестве примитивных операций. Однако Scala ис­ пользует «дублирующий» класс java.lang.Integer везде, где целое число должно выглядеть как (Java) объект. Так происходит, например, при вызове в отношении целого числа метода toString или присваивании целого числа переменной типа Any. При необходимости целочисленные значения типа Int явно преобразуются в упакованные целые числа типа java.lang.Integer. Это во многом походит на автоупаковку (auto-boxing) в Java 5, два процесса действительно очень похожи. Но все-таки есть одно коренное различие: упаковка в Scala гораздо менее заметна, чем упаковка в Java. Попробуйте выполнить в Java следующий код: // Это код на языке Java boolean isEqual(int x, int y) { return x == y; } System.out.println(isEqual(421, 421)); В результате, конечно же, будет получено значение true. А теперь измените типы аргументов isEqual на java.lang.Integer (или с аналогичным результатом на на Object): // Это код на языке Java boolean isEqual(Integer x, Integer y) { return x == y; } System.out.println(isEqual(421, 421)); Получите результат false! Оказывается, число 421 было упаковано дважды, поэтому аргументами для x и y стали два разных объекта. Применение == в от­ ношении ссылочных типов означает равенство ссылок, а Integer — ссылочный тип, вследствие чего в результате получается false. Это один из аспектов, сви­ детельствующий о том, что Java не является чистым объектно-ориентированным языком. Существует четко видимая разница между примитивными и ссылочными типами. Теперь попробуйте провести тот же самый эксперимент на Scala: scala> def isEqual(x: Int, y: Int) = x == y isEqual: (x: Int, y: Int)Boolean scala> isEqual(421, 421) res10: Boolean = true 11.2. Как реализованы примитивы 215 scala> def isEqual(x: Any, y: Any) = x == y isEqual: (x: Any, y: Any)Boolean scala> isEqual(421, 421) res11: Boolean = true Операция равенства в Scala разработана так, чтобы быть понятной относительно представления типа. Для типов значений (числовых или логических) это вполне естественное равенство. Для ссылочных типов, отличающихся от упакованных числовых типов Java, == рассматривается в качестве псевдонима метода equals, унаследованного от класса Object. Данный метод изначально определен в целях выявления равенства ссылок, но во многих подклассах переопределяется для реализации их естественных представлений о равенстве. Это также означает, что в Scala вы никогда не попадете в хорошо известную в Java ловушку, касающуюся сравнения строк. В Scala оно работает вполне корректно: scala> val x = "abcd".substring(2) x: String = cd scala> val y = "abcd".substring(2) y: String = cd scala> x == y res12: Boolean = true В Java результатом сравнения x с y будет false. В таком случае программисты вы­ нуждены пользоваться методом equals, но данную тонкость нетрудно упустить из виду. Может сложиться и такая ситуация, при которой вместо равенства, опреде­ ленного пользователем, нужно проверить равенство ссылок. Так, в некоторых ситуациях, когда эффективность важнее всего, вы можете захотеть выполнить конструирование хеша (hash cons) некоторых классов и проверить их экземпляры на равенство ссылок1. Для таких случаев в классе AnyRef определен дополнитель­ ный метод eq, который не может быть переопределен и реализован как проверка равенства ссылок (то есть для ссылочных типов ведет себя подобно == в Java). Существует также отрицание eq, которое называется ne, например: scala> val x = new String("abc") x: String = abc scala> val y = new String("abc") y: String = abc 1 Вы можете выполнить конструирование хеша экземпляров класса путем кэширования всех созданных экземпляров в слабую коллекцию. Затем, как только потребуется новый экземпляр класса, сначала проверяется кэш. Если в нем уже есть элемент, равный тому, который вы намереваетесь создать, то можно повторно воспользоваться существующим экземпляром. В результате такой систематизации любые два экземпляра, равенство ко­ торых определяется с помощью метода equals(), также равны на основе равенства ссылок. 216 Глава 11 • Иерархия Scala scala> x == y res13: Boolean = true scala> x eq y re14: Boolean = false scala> x ne y res15: Boolean = true Более подробно равенство в Scala рассматривается в главе 30. 11.3. Низшие типы Внизу иерархии на рис. 11.1 (см. выше) показаны два класса: scala.Null и sca­ la.No­thing. Это особые типы, единообразно сглаживающие острые углы объектноориентированной системы типов в Scala. Класс Null — тип нулевой ссылки null: он представляет собой подкласс каждого ссылочного класса, то есть каждого класса, который сам является наследником класса AnyRef. Класс Null несовместим с типами значений. Нельзя, к примеру, присвоить значение null целочисленной переменной: scala> val i: Int = null ^ error: an expression of type Null is ineligible for implicit conversion Тип Nothing находится в самом низу иерархии классов Scala: он представляет собой подтип любого другого типа. Но значений этого типа вообще не существует. А зачем нужен тип без значений? Как говорилось в разделе 7.4, Nothing исполь­ зуется, в частности, для того, чтобы сигнализировать об аварийном завершении операции. Например, в объекте sys стандартной библиотеки Scala есть метод error, име­ ющий такое определение: def error(message: String): Nothing = throw new RuntimeException(message) Возвращаемым типом метода error является Nothing, что говорит пользователю о ненормальном возвращении из метода (вместо этого метод сгенерировал исклю­ чение). Поскольку Nothing — подтип любого другого типа, то методы, подобные error, допускают весьма гибкое использование, например: def divide(x: Int, y: Int): Int = if (y != 0) x / y else sys.error("деление на нуль невозможно") Ветка then данного условия, представленная выражением x / y, имеет тип Int, а ветка else, то есть вызов error, имеет тип Nothing. Поскольку Nothing — подтип Int, то типом всего условного выражения, как и требовалось, является Int. 11.4. Определение собственных классов значений 217 11.4. Определение собственных классов значений В разделе 11.1 говорилось, что в дополнение к встроенным классам значений мож­ но определять собственные. Как и экземпляры встроенных, экземпляры ваших классов значений будут, как правило, компилироваться в байт-код Java, который не задействует класс-оболочку. В том контексте, где нужна оболочка, например, при использовании обобщенного кода, значения будут упаковываться и распако­ вываться автоматически. Классами значений можно сделать только вполне определенные классы. Чтобы класс стал классом значений, он должен иметь только один параметр и не должен иметь внутри ничего, кроме def-определений. Более того, класс значений не мо­ жет расширяться никакими другими классами и в нем не могут переопределяться методы equals или hashCode. Чтобы определить класс значений, его нужно сделать подклассом класса AnyVal и поставить перед его единственным параметром префикс val . Пример класса значений выглядит так: class Dollars(val amount: Int) extends AnyVal { override def toString() = "$" + amount } В соответствии с описанием, приведенным в разделе 10.6, префикс val позволя­ ет иметь доступ к параметру amount как к полю. Например, следующий код создает экземпляр класса значений, а затем извлекает из него amount: scala> money: scala> res16: val money = new Dollars(1000000) Dollars = $1000000 money.amount Int = 1000000 В данном примере money ссылается на экземпляр класса значений. Эта пере­ менная в исходном коде Scala имеет тип Dollars, но скомпилированный байт-код Java будет напрямую использовать тип Int. В этом примере определяется метод toString, и компилятор понимает, когда его использовать. Именно поэтому вывод значения money дает результат $1000000, со знаком доллара, а вывод money.amount дает результат 1000000. Можно даже определить несколько типов значений, и все они будут опираться на одно и то же Int-значение, например: class SwissFrancs(val amount: Int) extends AnyVal { override def toString() = s"$amount CHF" } Хотя и Dollars, и SwissFrancs представлены в виде целых чисел, ничто не ме­ шает использовать их в одной и той же области видимости: scala> val dollars = new Dollars(1000) dollars: Dollars = $1000 scala> val francs = new SwissFrancs(1000) francs: SwissFrancs = 1000 CHF 218 Глава 11 • Иерархия Scala Уход от монокультурности типов Чтобы получить наибольшие преимущества от использования иерархии классов Scala, старайтесь для каждого понятия предметной области определять новый класс, несмотря на то что будет возможность повторно применять один и тот же класс для различных целей. Даже если он относится к так называемому крошечному (tiny) типу, не имеющему методов или полей, определение дополнительного класса поможет компилятору принести вам больше пользы. Предположим, вы написали некий код для генерации HTML. В HTML название стиля представлено в виде строки. То же самое касается и идентификаторов при­ вязки. Сам код HTML также является строкой, поэтому при желании представить все здесь перечисленное можно с помощью определения вспомогательного кода, используя строки наподобие этих: def title(text: String, anchor: String, style: String): String = s"<a id='$anchor'><h1 class='$style'>$text</h1></a>" В данной сигнатуре типа четыре строки! Такой строчно типизированный код с технической точки зрения является строго типизированным. Однако все, что находится здесь в поле зрения, относится к типу String , поэтому компилятор не может помочь вам отличить один элемент структуры от другого. Например, не сможет уберечь вас от следующего искажения структуры: scala> title("chap:vcls", "bold", "Value Classes") res17: String = <a id='bold'><h1 class='Value Classes'>chap:vcls</h1></a> Код HTML нарушен. При намерении вывести на экран текст Value Classes эта строка была использована в качестве класса стиля, а на экран был выведен текст chap.vcls, для которого предполагалась роль гипертекстовой ссылки. В доверше­ ние ко всему в качестве идентификатора гипертекстовой ссылки выступила строка bold, для которой предполагалась роль класса стиля. Несмотря на всю эту комедию ошибок, компилятор никак этому не воспротивился. Если определить для каждого понятия предметной области крошечный тип, то компилятор сможет принести больше пользы. Например, можно определить соб­ ственный небольшой класс для стилей, идентификаторов гипертекстовых ссылок, отображаемого текста и кода HTML. Поскольку эти классы имеют один параметр и не имеют элементов, то могут быть определены как классы значений: class class class class Anchor(val value: String) extends AnyVal Style(val value: String) extends AnyVal Text(val value: String) extends AnyVal Html(val value: String) extends AnyVal Наличие этих классов позволяет создать версию title , обладающую менее тривиальной сигнатурой типов наподобие такой: def title(text: Text, anchor: Anchor, style: Style): Html = new Html( s"<a id='${anchor.value}'>" + Резюме 219 s"<h1 class='${style.value}'>" + text.value + "</h1></a>" ) Теперь при попытке воспользоваться этой версией с аргументами, указанными в неверном порядке, компилятор сможет обнаружить ошибку, например: scala> title(new Anchor("chap:vcls"), new Style("bold"), new Text("Value Classes")) ^ error: type mismatch; found : Anchor required: Text ^ error: type mismatch; found : Style required: Anchor new Text("Value Classes")) ^ On line 2: error: type mismatch; found : Text required: Style Резюме В этой главе мы показали классы, находящиеся на самом верху и в самом низу иерархии классов Scala. Теперь, получив твердую базу знаний о наследовании в Scala, вы готовы понять композицию примесей. Следующая глава будет посвя­ щена трейтам. 12 Трейты Трейты в Scala являются фундаментальными повторно используемыми блоками кода. В трейте инкапсулируются определения тех методов и полей, которые затем могут повторно использоваться путем их примешивания в классы. В отличие от наследования классов, в котором каждый класс должен быть наследником только одного суперкласса, в класс может примешиваться любое количество трейтов. В этой главе мы покажем, как работают трейты. Далее рассмотрим два наиболее распространенных способа их применения: расширение «тонких» интерфейсов и превращение их в «толстые», а также определение наращиваемых модификаций. Здесь мы покажем, как используется трейт Ordered, и сравним механизм трейтов с множественным наследованием, имеющимся в других языках. 12.1. Как работают трейты Определение трейта похоже на определение класса, за исключением того, что в нем используется ключевое слово trait. Пример показан в листинге 12.1. Листинг 12.1. Определение трейта Philosophical trait Philosophical { def philosophize() = { println("На меня тратится память, следовательно, я существую!") } } Данный трейт называется Philosophical. В нем не объявлен суперкласс, сле­ довательно, как и у класса, у него есть суперкласс по умолчанию — AnyRef. В нем определяется один конкретный метод по имени philosophize. Это простой трейт, и его вполне достаточно, чтобы показать, как работают трейты. После того как трейт определен, он может быть примешан в класс с помощью ключевого слова: либо extends, либо with. Программисты, работающие со Scala, примешивают трейты, а не наследуют их, поскольку примешивание трейта весьма 12.1. Как работают трейты 221 отличается от множественного наследования, встречающегося во многих других языках. Этот вопрос рассматривается в разделе 12.6. Например, в листинге 12.2 показан класс, в который с помощью ключевого слова extends примешивается трейт Philosophical. Листинг 12.2. Примешивание трейта с использованием ключевого слова extends class Frog extends Philosophical { override def toString = "зеленая" } Примешивать трейт можно с помощью ключевого слова extends, в таком случае происходит неявное наследование суперкласса трейта. Например, в листинге 12.2 класс Frog (Лягушка) становится подклассом AnyRef (это суперкласс для трейта Philosophical) и примешивает в себя трейт Philosophical. Методы, унаследован­ ные от трейта, могут использоваться точно так же, как и методы, унаследованные от суперкласса. Рассмотрим пример: scala> val frog = new Frog frog: Frog = зеленая scala> frog.philosophize() На меня тратится память, следовательно, я существую! Трейт также определяет тип. Рассмотрим пример, в котором Philosophical ис­ пользуется как тип: scala> val phil: Philosophical = frog phil: Philosophical = зеленая scala> phil.philosophize() На меня тратится память, следовательно, я существую! Типом phil является Philosophical, то есть трейт. Таким образом, переменная phil может быть инициализирована любым объектом, в чей класс примешан трейт Philosophical. Если нужно примешать трейт в класс, который явно расширяет суперкласс, то ключевое слово extends используется для указания суперкласса, а для примеши­ вания трейта — ключевое слово with. Пример показан в листинге 12.3. Если нужно примешать сразу несколько трейтов, то дополнительные трейты указываются с помощью ключевого слова with. Например, располагая трейтом HasLegs, вы, как показано в листинге 12.4, можете примешать в класс Frog как трейт Philosophical, так и трейт HasLegs. Листинг 12.3. Примешивание трейта с использованием ключевого слова with class Animal class Frog extends Animal with Philosophical { override def toString = "зеленая" } 222 Глава 12 • Трейты Листинг 12.4. Примешивание нескольких трейтов class Animal trait HasLegs class Frog extends Animal with Philosophical with HasLegs { override def toString = "зеленая" } В показанных ранее примерах класс Frog наследовал реализацию метода philosophize из трейта Philosophical. В качестве альтернативного варианта метод philosophize в классе Frog может быть переопределен. Синтаксис выглядит точно так же, как и при переопределении метода, объявленного в суперклассе. Рассмо­ трим пример: class Animal class Frog extends Animal with Philosophical { override def toString = "зеленая" override def philosophize() = { println("Мне живется нелегко, потому что я " + toString + "!") } } В новое определение класса Frog по-прежнему примешивается трейт Philo­ sophical, поэтому его, как и раньше, можно использовать из переменной данного типа. Но, так как во Frog переопределено определение метода philosophize, которое было дано в трейте Philosophical, при вызове будет получено новое поведение: scala> val phrog: Philosophical = new Frog phrog: Philosophical = зеленая scala> phrog.philosophize() Мне нелегко живется, потому что я зеленая! Теперь можно прийти к философскому умозаключению, что трейты подобны Java-интерфейсам с конкретными методами, но фактически их возможности гораз­ до шире. Так, в трейтах можно объявлять поля и сохранять состояние. Фактически в определении трейта можно делать то же самое, что и в определении класса, и син­ таксис выглядит почти так же, но с двумя исключениями. Начнем с того, что в трейте не может быть никаких присущих классу параме­ тров (то есть параметров, передаваемых первичному конструктору класса). Иными словами, хотя у вас есть возможность определить класс таким вот образом: class Point(x: Int, y: Int) следующая попытка определить трейт окажется неудачной: trait NoPoint(x: Int, y: Int) // Не пройдет компиляцию Способ обойти это ограничение будет показан в разделе 20.5. Второе отличие классов от трейтов заключается в том, что в классах вызовы super имеют статическую привязку, а в трейтах — динамическую. Если в классе 12.2. Сравнение «тонких» и «толстых» интерфейсов 223 воспользоваться кодом super.toString, то вы будете точно знать, какая именно реа­ лизация метода будет вызвана. Но когда точно такой же код применяется в трейте, то вызываемая с помощью super реализация метода при определении трейта еще не определена. Вызываемая реализация станет определяться заново при каждом примешивании трейта в конкретный класс. Такое своеобразное поведение super является ключевым фактором, позволяющим трейтам работать в качестве наращиваемых модификаций, и рассматривается в разделе 12.5. А правила разрешения вызовов super будут изложены в разделе 12.6. 12.2. Сравнение «тонких» и «толстых» интерфейсов Чаще всего трейты используются для автоматического добавления к классу ме­ тодов в дополнение к тем методам, которые в нем уже имеются. То есть трейты способны расширить «тонкий» интерфейс, превратив его в «толстый». Противопоставление «тонких» интерфейсов «толстым» представляет собой компромисс, который довольно часто встречается в объектно-ориентированном проектировании. Это компромисс между теми, кто реализует интерфейс, и теми, кто им пользуется. В «толстых» интерфейсах имеется множество методов, обе­ спечивающих удобство применения для тех, кто их вызывает. Клиенты могут вы­ брать метод, целиком отвечающий их функциональным запросам. В то же время «тонкий» интерфейс имеет незначительное количество методов и поэтому проще обходится тем, кто их реализует. Но клиентам, обращающимся к «тонким» ин­ терфейсам, приходится создавать больше собственного кода. При более скудном выборе доступных для вызова методов им приходится выбирать то, что хотя бы в какой-то мере отвечает их потребностям, а чтобы использовать выбранный метод, им требуется создавать дополнительный код. Java-интерфейсы чаще «тонкие», чем «толстые». Например, интерфейс CharSe­ quence , появившийся в Java 1.4, — «тонкий», общий для всех строкоподобных классов, которые содержат последовательность символов. А вот как выглядит определение этого интерфейса, если его рассматривать в качестве Scala-трейта: trait def def def def } CharSequence { charAt(index: Int): Char length: Int subSequence(start: Int, end: Int): CharSequence toString(): String Хотя к любой последовательности символов, для которой предназначен интер­ фейс CharSequence, может применяться большинство из десятков методов, име­ ющихся в классе String, в Java-интерфейсе CharSequence объявляются всего лишь четыре метода. Если бы в CharSequence был включен полный интерфейс String, то это бы стало гораздо более весомой нагрузкой на реализаторов CharSequence. 224 Глава 12 • Трейты Каждому программисту, реализующему CharSequence в Java, пришлось бы опре­ делять десятки дополнительных методов. Поскольку в трейтах Scala могут со­ держаться конкретные методы, они делают «толстые» интерфейсы намного более удобными. Добавление в трейт конкретного метода уводит компромисс «тонкий — толстый» в сторону более «толстых» интерфейсов. В отличие от Java, добавление конкретного метода к Scala-трейту — одноразовое действие. Вам нужно единожды реализовать тот или иной метод, сделав это в самом трейте, вместо того чтобы во­ зиться с его повторной реализацией для каждого класса, в который примешивается трейт. Таким образом, создание «толстых» интерфейсов в Scala требует меньше работы, чем в языках без трейтов. Чтобы расширить интерфейс с помощью трейтов, просто определите трейт с небольшим количеством абстрактных методов — тонкую часть интерфейса трейта — и с потенциально большим количеством конкретных методов, реа­ лизованных в терминах абстрактных методов. Затем можно будет примешать расширяющий трейт в класс, реализовав тонкую часть интерфейса, и получить в результате класс, позволяющий обеспечить доступ ко всему доступному «тол­ стому» интерфейсу. 12.3. Пример: прямоугольные объекты В графических библиотеках часто содержится множество различных классов, представляющих что-либо прямоугольное. В качестве примеров можно привести окна, растровые изображения и области, выбираемые с помощью мыши. Чтобы такие прямоугольные объекты использовать с большим удобством, было бы не­ плохо, если бы библиотека предоставляла ответы на запросы о геометрических свойствах, таких как ширина (width), высота (height), левый край (left), правый край (right), верхний левый угол (topLeft) и т. д. Однако существует очень мно­ го таких методов, которые было бы хорошо иметь, поэтому на разработчиков библио­теки ложится тяжкий груз предоставить все эти методы для всех прямо­ угольных объектов из библиотеки Java. Если такая же библиотека была написана на Scala, то, напротив, ее создатели без особого труда могли бы предоставить все эти удобные методы каким угодно классам, воспользовавшись трейтами. Чтобы понять, как это делается, сначала представим, как бы все это могло вы­ глядеть, если не использовать трейты. Должны существовать какие-то основные геометрические классы наподобие точки — Point — и прямоугольника — Rectangle: class Point(val x: Int, val y: Int) class Rectangle(val topLeft: Point, val bottomRight: Point) { def left = topLeft.x def right = bottomRight.x def width = right - left // и множество других геометрических методов... } 12.3. Пример: прямоугольные объекты 225 Класс Rectangle получает в свой первичный конструктор две точки: координаты верхнего левого и нижнего правого углов. Затем с помощью простых вычислений, основанных на координатах этих двух точек, он реализует множество удобных методов, таких как left, right и width. Еще один класс, который может войти в состав графической библиотеки, — это двумерный графический виджет: abstract class Component { def topLeft: Point def bottomRight: Point def left = topLeft.x def right = bottomRight.x def width = right - left // и множество других геометрических методов... } Обратите внимание: определения left, right и width в обоих классах абсолютно одинаковы. Практически одинаковыми, с незначительными вариациями, они будут и в любых других классах прямоугольных объектов. Уменьшить эту повторяемость можно, прибегнув к расширяющему трейту. Он будет иметь два абстрактных метода: один станет возвращать верхнюю левую координату объекта, а другой — нижнюю правую. Затем в нем могут быть предо­ ставлены конкретные реализации всех других геометрических запросов. На что это будет похоже, показано в листинге 12.5. Листинг 12.5. Определение расширяющего трейта trait Rectangular { def topLeft: Point def bottomRight: Point def left = topLeft.x def right = bottomRight.x def width = right - left // и множество других геометрических методов... } Этот трейт можно примешать в класс Component, чтобы получить все геометри­ ческие методы, предоставляемые Rectangular: abstract class Component extends Rectangular { // другие методы... } Точно так же трейт может примешиваться в сам Rectangle: class Rectangle(val topLeft: Point, val bottomRight: Point) extends Rectangular { // другие методы... } 226 Глава 12 • Трейты Располагая этими определениями, можно создать объект Rectangle и вызвать в отношении него геометрические методы width и left: scala> val rect = new Rectangle(new Point(1, 1), new Point(10, 10)) rect: Rectangle = Rectangle@5f5da68c scala> rect.left res2: Int = 1 scala> rect.right res3: Int = 10 scala> rect.width res4: Int = 9 12.4. Трейт Ordered Сравнение — еще одна область, в которой может пригодиться «толстый» интерфейс. Сравнивая два упорядочиваемых объекта, было бы удобно воспользоваться вызо­ вом одного метода, чтобы выяснить результаты желаемого сравнения. Если нужно использовать сравнение «меньше», то предпочтительнее было бы вызвать <, а если требуется применить сравнение «меньше или равно» — то вызвать <=. С «тонким» интерфейсом можно располагать только методом <, и тогда временами приходи­ лось бы создавать код наподобие (x < y) || (x == y). «Толстый» интерфейс предо­ ставит вам все привычные операторы сравнения, позволяя напрямую создавать код вроде x <= y. Прежде чем посмотреть на трейт Ordered, представим, что можно сделать без него. Предположим, вы взяли класс Rational из главы 6 и добавили к нему опера­ ции сравнения. У вас должен получиться примерно такой код1: class Rational(n: Int, d: Int) { // ... def < (that: Rational) = this.numer * that.denom < that.numer * this.denom def > (that: Rational) = that < this def <= (that: Rational) = (this < that) || (this == that) def >= (that: Rational) = (this > that) || (this == that) } В данном классе определяются четыре оператора сравнения (<, >, <= и >=), и это классическая демонстрация стоимости определения «толстого» интерфейса. Сна­ чала обратите внимание на то, что три оператора сравнения определены на основе 1 Этот пример основан на использовании класса Rational, показанного в листинге 6.5, с ме­ тодами equals, hashCode и модификациями, гарантирующими положительный denom. 12.4. Трейт Ordered 227 первого оператора. Например, оператор > определен как противоположность опера­ тора <, а оператор <= определен буквально как «меньше или равно». Затем обратите внимание на то, что все три этих метода будут такими же для любого другого класса, объекты которого могут сравниваться друг с другом. В отношении оператора <= для рациональных чисел не прослеживается никаких особенностей. В контексте сравнения оператор <= всегда используется для обозначения «меньше или равно». В общем, в этом классе есть довольно много шаблонного кода, который будет точно таким же в любом другом классе, реализующем операции сравнения. Данная проблема встречается настолько часто, что в Scala предоставляется трейт, помогающий справиться с ее решением. Этот трейт называется Ordered. Что­ бы воспользоваться им, нужно заменить все отдельные методы сравнений одним методом compare. Затем на основе одного этого метода определить в трейте Ordered методы <, >, <= и >=. Таким образом, трейт Ordered позволит вам расширить класс методами сравнений с помощью реализации всего одного метода по имени compare. Если определить операции сравнения в Rational путем использования трейта Ordered, то код будет иметь следующий вид: class Rational(n: Int, d: Int) extends Ordered[Rational] { // ... def compare(that: Rational) = (this.numer * that.denom) - (that.numer * this.denom) } Нужно выполнить две задачи. Начнем с того, что в Rational примешивается трейт Ordered. В отличие от трейтов, которые встречались до сих пор, Ordered требует от вас при примешивании указать параметр типа. Параметры типов до главы 19 подробно рассматриваться не будут, а пока все, что нужно знать при при­ мешивании Ordered, сводится к следующему: фактически следует выполнять при­ мешивание Ordered[C], где C обозначает класс, элементы которого сравниваются. В данном случае в Rational примешивается Ordered[Rational]. Вторая задача, требующая выполнения, заключается в определении метода compare для сравнения двух объектов. Этот метод должен сравнивать получатель this с объектом, переданным методу в качестве аргумента. Возвращать он должен целочисленное значение, которое равно нулю, если объекты одинаковы, отрица­ тельное число, если получатель меньше аргумента, и положительное, если полу­ чатель больше аргумента. В данном случае метод сравнения класса Rational использует формулу, осно­ ванную на приведении чисел к общему знаменателю с последующим вычитанием получившихся числителей. Теперь при наличии этого примешивания и определе­ ния метода compare класс Rational имеет все четыре метода сравнения: scala> val half = new Rational(1, 2) half: Rational = 1/2 scala> val third = new Rational(1, 3) third: Rational = 1/3 228 Глава 12 • Трейты scala> half < third res5: Boolean = false scala> half > third res6: Boolean = true При каждой реализации класса с какой-либо возможностью упорядоченно­ сти путем сравнения нужно рассматривать вариант примешивания в него трейта Ordered. Если выполнить это примешивание, то пользователи класса получат бо­ гатый набор методов сравнений. Имейте в виду, что трейт Ordered не определяет за вас метод equals, поскольку не способен на это. Дело в том, что реализация equals в терминах compare требует проверки типа переданного объекта, а из-за удаления типов сам Ordered не может ее выполнить. Поэтому определять equals вам придется самим, даже если вы при­ мешиваете трейт Ordered. Как справиться с этой задачей, мы расскажем в главе 30. 12.5. Трейты как наращиваемые модификации Основное применение трейтов — превращение «тонкого» интерфейса в «толстый» — вы уже видели. Перейдем теперь ко второму по значимости способу использования трейтов — предоставлению классам наращиваемых модификаций. Трейты дают возможность изменять методы класса, позволяя вам наращивать их друг на друге. Рассмотрим в качестве примера наращиваемые модификации применительно к очереди целых чисел. В очереди будут две операции: put, помещающая целые числа в очередь, и get, извлекающая их из очереди. Очереди работают по принципу «первым пришел, первым ушел», поэтому целые числа извлекаются из очереди в том же порядке, в котором были туда помещены. Располагая классом, реализующим такую очередь, можно определить трейты для выполнения следующих модификаций: удваивание — удваиваются все целые числа, помещенные в очередь; увеличение на единицу — увеличиваются все целые числа, помещенные в оче­ редь; фильтрация — из очереди отфильтровываются все отрицательные целые числа. Эти три трейта представляют модификации, поскольку модифицируют пове­ дение соответствующего класса очереди, а не определяют полный класс очереди. Все три трейта также являются наращиваемыми. Можно выбрать любые из трех трейтов, примешать их в класс и получить новый класс, обладающий всеми вы­ бранными модификациями. В листинге 12.6 показан абстрактный класс IntQueue. В IntQueue имеются ме­ тод put, добавляющий к очереди новые целые числа, и метод get, возвращающий 12.5. Трейты как наращиваемые модификации 229 целые числа и удаляющий их из очереди. Основная реализация IntQueue, которая использует ArrayBuffer, показана в листинге 12.7. Листинг 12.6. Абстрактный класс IntQueue abstract class IntQueue { def get(): Int def put(x: Int): Unit } Листинг 12.7. Реализация класса BasicIntQueue с использованием ArrayBuffer import scala.collection.mutable.ArrayBuffer class BasicIntQueue extends IntQueue { private val buf = new ArrayBuffer[Int] def get() = buf.remove(0) def put(x: Int) = { buf += x } } В классе BasicIntQueue имеется приватное поле, содержащее буфер в виде массива. Метод get удаляет запись с одного конца буфера, а метод put добавляет элементы к другому его концу. Пример использования данной реализации выгля­ дит следующим образом: scala> val queue = new BasicIntQueue queue: BasicIntQueue = BasicIntQueue@23164256 scala> queue.put(10) scala> queue.put(20) scala> queue.get() res9: Int = 10 scala> queue.get() res10: Int = 20 Пока все вроде бы в порядке. Теперь посмотрим на использование трейтов для модификации данного поведения. В листинге 12.8 показан трейт, который удваивает целые числа по мере их помещения в очередь. Трейт Doubling имеет две интересные особенности. Первая заключается в том, что в нем объявляется супер­ класс IntQueue. Это объявление означает, что трейт может примешиваться только в класс, который также расширяет IntQueue. То есть Doubling можно примешивать в BasicIntQueue, но не в Rational. Листинг 12.8. Трейт наращиваемых модификаций Doubling trait Doubling extends IntQueue { abstract override def put(x: Int) = { super.put(2 * x) } } 230 Глава 12 • Трейты Вторая интересная особенность заключается в том, что у трейта имеется вызов super в отношении метода, объявленного абстрактным. Для обычных классов такие вызовы применять запрещено, поскольку во время выполнения они гарантиро­ ванно дадут сбой. Но для трейта такой вызов может действительно пройти успеш­ но. В трейте вызовы super динамически связаны, поэтому вызов super в трейте Doubling будет работать при условии, что трейт примешан после другого трейта или класса, в котором дается конкретное определение метода. Трейтам, которые реализуют наращиваемые модификации, зачастую нужен именно такой порядок. Чтобы сообщить компилятору, что это делается намеренно, подобные методы следует помечать модификаторами abstract override. Это со­ четание модификаторов позволительно только для членов трейтов, но не классов, и означает, что трейт должен быть примешан в некий класс, имеющий конкретное определение рассматриваемого метода. Вроде бы простой трейт, а сколько всего в нем происходит! Применение трейта выглядит следующим образом: scala> class MyQueue extends BasicIntQueue with Doubling defined class MyQueue scala> val queue = new MyQueue queue: MyQueue = MyQueue@44bbf788 scala> queue.put(10) scala> queue.get() res12: Int = 20 В первой строке этого сеанса работы с интерпретатором определяется класс MyQueue, который расширяет класс BasicIntQueueand, примешивая в него трейт Doubling. Мы помещаем в очередь число 10, но в результате примешивания трейта Doubling оно удваивается. При извлечении целого числа из очереди оно уже имеет значение 20. Обратите внимание: в MyQueue не определяется никакой новый код — просто объявляется класс и примешивается трейт. В подобной ситуации вместо опреде­ ления именованного класса код BasicIntQueue with Doubling может быть предо­ ставлен непосредственно с ключевым словом new. Работа такого кода показана в листинге 12.9. Листинг 12.9. Примешивание трейта при создании экземпляра с помощью ключевого слова new scala> val queue = new BasicIntQueue with Doubling queue: BasicIntQueue with Doubling = $anon$1@141f05bf scala> queue.put(10) scala> queue.get() res14: Int = 20 12.5. Трейты как наращиваемые модификации 231 Чтобы посмотреть, как нарастить модификации, нужно определить еще два модифицирующих трейта: Incrementing и Filtering. Реализация этих трейтов показана в листинге 12.10. Листинг 12.10. Трейты наращиваемых модификаций Incrementing и Filtering trait Incrementing extends IntQueue { abstract override def put(x: Int) = { super.put(x + 1) } } trait Filtering extends IntQueue { abstract override def put(x: Int) = { if (x >= 0) super.put(x) } } Теперь, располагая модифицирующими трейтами, можно выбрать, какой из них вам понадобится для той или иной очереди. Например, ниже показана очередь, в которой не только отфильтровываются отрицательные числа, но и ко всем со­ храняемым числам прибавляется единица: scala> val queue = (new BasicIntQueue with Incrementing with Filtering) queue: BasicIntQueue with Incrementing with Filtering... scala> queue.put(-1); queue.put(0); queue.put(1) scala> queue.get() res16: Int = 1 scala> queue.get() res17: Int = 2 Порядок примешивания играет существенную роль1. Конкретные правила даны в следующем разделе, но, грубо говоря, трейт, находящийся правее, вступает в силу первым. Когда метод вызывается в отношении экземпляра класса с примешанными трейтами, первым вызывается тот метод, который определен в самом правом трейте. Если этот метод выполняет вызов super, то вызывается метод, который определен в следующем трейте левее данного трейта, и т. д. В предыдущем примере сначала вызывается метод put трейта Filtering, следовательно, все начинается с того, что он удаляет отрицательные целые числа. Вторым вызывается метод put трейта Incrementing, следовательно, к оставшимся целым числам прибавляется единица. Если расположить трейты в обратном порядке, то сначала к целым числам будет прибавляться единица и только потом те целые числа, которые все же останутся отрицательными, будут удалены: scala> val queue = (new BasicIntQueue with Filtering with Incrementing) queue: BasicIntQueue with Filtering with Incrementing... 1 Поскольку трейт примешивается к классу, его можно называть также примесью. 232 Глава 12 • Трейты scala> queue.put(-1); queue.put(0); queue.put(1) scala> queue.get() res19: Int = 0 scala> queue.get() res20: Int = 1 scala> queue.get() res21: Int = 2 В общем, код, создаваемый в данном стиле, открывает перед вами широкие возможности для проявления гибкости. Примешивая эти три трейта в разных со­ четаниях и разном порядке следования, можно определить 16 различных классов. Весьма впечатляющая гибкость для столь незначительного объема кода, поэтому постарайтесь не проглядеть возможности организации кода в целях получения наращиваемых модификаций. 12.6. Почему не используется множественное наследование Трейты позволяют наследовать из множества похожих на классы конструкций, но имеют весьма важные отличия от множественного наследования, присутствующе­ го во многих языках программирования. Одно из отличий, интерпретация super, играет особенно важную роль. При использовании множественного наследования метод, вызванный с помощью super, может быть определен прямо там, где появ­ ляется этот вызов. При использовании трейтов вызываемый метод определяется путем линеаризации, то есть выстраивания в ряд классов и трейтов, примешанных в класс. Это то самое рассмотренное в предыдущем разделе отличие, которое по­ зволяет выполнять наращивание модификаций. Прежде чем рассмотреть линеаризацию, немного отвлечемся на то, как нара­ щиваемые модификации выполняются в языке с традиционным множественным наследованием. Представим следующий код, однако на этот раз интерпретируемый не как примешивание трейтов, а как множественное наследование: // Мысленный эксперимент с множественным наследованием val q = new BasicIntQueue with Incrementing with Doubling q.put(42) // Который из методов put будет вызван? Сразу возникает вопрос: который из методов put будет задействован в этом вызове? Возможно, вступят в силу правила, согласно которым победу одержит самый последний суперкласс. В таком случае будет вызван метод из Doubling. В данном методе будет удвоен его аргумент, сделан вызов super.put, и на этом все. Не произойдет никакого увеличения на единицу! Кроме того, если бы действовало правило, при котором побеждал бы первый суперкласс, то в получающейся очереди 12.6. Почему не используется множественное наследование 233 целые числа увеличивались бы на единицу, но не удваивались. То есть не срабаты­ вало бы никакое упорядочение. Можно подумать и о том, как предоставить программистам возможность точ­ но указывать при использовании вызова super, из какого именно суперкласса им нужен метод. Представим, к примеру, что есть следующий Scala-подобный код, в котором в super фигурирует точное указание на то, что метод вызывается как из Incrementing, так и из Doubling: // Мысленный эксперимент с множественным наследованием trait MyQueue extends BasicIntQueue with Incrementing with Doubling { def put(x: Int) = { Incrementing.super.put(x) // (на самом деле это не Scala) Doubling.super.put(x) } } Такой подход породит новые проблемы, самой малой из которых будет много­ словие. Получится, что метод put базового класса будет вызван дважды: один раз со значением, увеличенным на единицу, и один раз с удвоенным значением, — но никогда не будет вызван с увеличенным на единицу и удвоенным значением. При использовании множественного наследования данная задача просто не имеет правильного решения. Придется опять возвращаться к проектирова­ нию и реорганизовывать код. В отличие от этого, с решением на основе приме­ нения трейтов в Scala все предельно понятно. Вы просто примешиваете трейты Incrementing и Doubling, и имеющееся в Scala особое правило, которое касается применения super в трейтах, позволяет добиться всего, чего вы хотели. Нечто здесь очевидно отличается от традиционного множественного наследования, но что именно? Согласно уже данной подсказке ответом будет линеаризация. Когда с помощью ключевого слова new создается экземпляр класса, Scala берет класс со всеми его унаследованными классами и трейтами и располагает их в едином линейном порядке. Затем при любом вызове super внутри одного из таких классов вызывается тот метод, который идет следующим по порядку. Если во всех методах, кроме по­ следнего, присутствует вызов super, то получается наращивание. Описание конкретного порядка линеаризации дается в спецификации языка. Он сложноват, но вам нужно знать лишь главное: при любой линеаризации класс всегда следует впереди всех своих суперклассов и примешанных трейтов. Таким образом, при написании метода, содержащего вызов super, этот метод изменяет поведение суперкласса и примешанных трейтов, а не наоборот. ПРИМЕЧАНИЕ Далее в разделе описываются подробности линеаризации. Вы можете пропустить этот материал, если сейчас он вам неинтересен. 234 Глава 12 • Трейты Главные свойства выполняющейся в Scala линеаризации показаны в следу­ ющем примере. Предположим, у вас есть класс Cat (Кот) — наследник класса Animal (Животное) — и два супертрейта: Furry (Пушистый) и FourLegged (Четырехлапый). Трейт FourLegged расширяет еще один трейт — HasLegs (С лапами): class trait trait trait class Animal Furry extends Animal HasLegs extends Animal FourLegged extends HasLegs Cat extends Animal with Furry with FourLegged Иерархия наследования и линеаризация класса Cat показаны на рис. 12.1. На­ следование изображено с помощью традиционной нотации UML1: стрелки, на кон­ цах которых белые треугольники, обозначают наследование, указывая на супертип. Стрелки с затемненными не треугольными концами показывают линеаризацию. Они указывают направление, в котором будут разрешаться вызовы super. Рис. 12.1. Иерархия наследования и линеаризации класса Cat Линеаризация Cat вычисляется от конца к началу следующим образом. Последней часть линеаризации Cat — линеаризация его суперкласса Animal . Эта линеаризация копируется без каких-либо изменений. (Линеаризация каждого из этих типов показана в табл. 12.1.) Поскольку класс Animal не расширяет явным образом какой-либо суперкласс и в него не примешаны никакие супертрейты, то по умолчанию он расширяет класс AnyRef, который расширяет класс Any. Поэтому линеаризация класса Animal имеет следующий вид: Animal → AnyRef → Any Предпоследней является линеаризация первой примеси, трейта Furry, но все классы, которые уже присутствуют в линеаризации класса Animal, теперь не учи­ тываются, поэтому каждый класс в линеаризации Cat появляется только раз. Результат выглядит следующим образом: Furry → Animal → AnyRef → Any 1 Rumbaugh, J., Jacobson I., Booch G. The Unified Modeling Language Reference Manual. 2nd Ed. — Addison-Wesley, 2004. 12.7. Так все же с трейтами или без? 235 Всему этому предшествует линеаризация FourLegged, в которой также не учи­ тываются любые классы, которые уже были скопированы в линеаризациях супер­ класса или первого примешанного трейта: FourLegged → HasLegs → Furry → Animal → AnyRef → Any И наконец, первым в линеаризации класса Cat фигурирует сам этот класс: Cat → FourLegged → HasLegs → Furry → Animal → AnyRef → Any Когда любой из этих классов и трейтов вызывает метод через вызов super, вы­ зываться будет первая реализация, которая в линеаризации расположена справа от него. Таблица 12.1. Линеаризация типов в иерархии класса Cat Тип Линеаризация Animal Animal, AnyRef, Any Furry Furry, Animal, AnyRef, Any FourLegged FourLegged, HasLegs, Animal, AnyRef, Any HasLegs HasLegs, Animal, AnyRef, Any Cat Cat, FourLegged, HasLegs, Furry, Animal, AnyRef, Any 12.7. Так все же с трейтами или без? При реализации набора повторно используемого поведения придется решать, к чему прибегнуть: трейту или абстрактному классу. Золотого правила не суще­ ствует, но в этом разделе содержатся несколько полезных рекомендаций, к которым стоит прислушаться. Если поведение не будет повторно использовано, то создавайте конкретный класс, поскольку в нем вообще нет повторно используемого поведения. Если поведение может быть повторно использовано различными неродственными классами, то создайте трейт. Только трейты могут примешиваться в раз­ личные части иерархии классов. Если хотите наследовать поведение в коде Java, то задействуйте абстрактный класс. В Java не существует близкого аналога трейтам, поэтому унаследовать что-либо из трейта в классе Java вряд ли получится. А вот наследование из класса Scala ничем не отличается от наследования из класса Java. В качестве единственного исключения следует отметить, что трейт Scala, имеющий исклю­ чительно абстрактные элементы, преобразуется непосредственно в интерфейс Java. Поэтому можно спокойно определять такие трейты, даже притом, что на­ следование из них предполагается использовать в коде Java. Более подробную информацию о совместной работе кода на Java с кодом на Scala можно найти в главе 31. 236 Глава 12 • Трейты Если планируется распространение кода в скомпилированном виде и ожида­ ется, что сторонние группы разработчиков станут создавать классы, что-либо наследующие из него, то следует предпочесть абстрактные классы. Проблема в том, что при появлении в трейте нового элемента или удалении из него старого элемента все классы, являющиеся его наследниками, должны быть перекомпи­ лированы, даже если в них не было никаких изменений. Если сторонние клиен­ ты будут только вызывать поведение, а не наследовать его, то с использованием трейта не будет никаких проблем. Если вы не пришли к решению даже после того, как взвесили все ранее предо­ ставленные рекомендации, то начните с создания трейта. Позже вы всегда смо­ жете его изменить, а в целом использование трейта предоставляет вам больше возможностей. Резюме В этой главе мы показали работу трейтов и порядок их применения в нескольких часто встречающихся идиомах. Вы увидели, что трейты похожи на множественное наследование. Но благодаря тому, что в трейтах вызовы super интерпретируются с помощью линеаризации, удается не только избавиться от некоторых трудностей традиционного множественного наследования, но и воспользоваться наращивани­ ем модификаций поведения программы. Вдобавок мы рассмотрели трейт Ordered и изучили порядок создания собственных расширяющих трейтов. А теперь, усвоив все эти аспекты, нам предстоит вернуться немного назад и по­ смотреть на трейты в целом с другого ракурса. Трейты не просто поддерживают средства выражения, рассмотренные в данной главе, — они являются базовыми блоками кода, допускающими их повторное использование с помощью механизма наследования. Благодаря этому многие опытные программисты, которые работают на Scala, на ранних стадиях реализации начинают с трейтов. Любой трейт не может охватить всю концепцию, ограничиваясь лишь ее фрагментом. По мере того как конструкция приобретает все более четкие очертания, фрагменты путем примеши­ вания трейтов могут объединяться в более полноценные концепции. 13 Пакеты и импорты В ходе работы над программой, особенно объемной, важно свести к минимуму сцепление, то есть степень взаимозависимости различных частей программы. Низ­ кое сцепление (low coupling) сокращает риск того, что незначительное, казалось бы, безобидное изменение в одной части программы вызовет разрушительные по­ следствия для другой части. Один из способов сведения сцепления к минимуму — написание программы в модульном стиле. Программа разбивается на несколько меньших по размеру модулей, у каждого из которых есть внутренняя и внешняя части. При работе над внутренней частью модуля, то есть над его реализацией, нужно согласовывать действия только с другими программистами, работающими над созданием того же самого модуля. И лишь в случае необходимости внести из­ менения в его внешнюю часть, то есть в интерфейс, приходится координировать свои действия с разработчиками других модулей. В этой главе мы покажем конструкции, помогающие программировать в мо­ дульном стиле. Мы рассмотрим вопросы помещения кода в пакеты, создания имен, видимых при импортировании, и управления видимостью определений с помо­ щью модификаторов доступа. Эти конструкции сходны по духу с конструкциями, имеющимися в Java, за исключением некоторых различий, которые, как правило, выражаются в их более последовательном характере. Поэтому данную главу стоит прочитать даже тем, кто хорошо разбирается в Java. 13.1. Помещение кода в пакеты Код Scala размещается в глобальной иерархии пакетов платформы Java. Все пока­ занные до сих пор в этой книге примеры кода размещались в безымянных пакетах. Поместить код в именованные пакеты в Scala можно двумя способами. Первый — поместить содержимое всего файла в пакет, указав директиву package в самом начале файла (листинг 13.1). 238 Глава 13 • Пакеты и импорты Листинг 13.1. Помещение в пакет всего содержимого файла package bobsrockets.navigation class Navigator Указание директивы package в листинге 13.1 приводит к тому, что класс Navigator помещается в пакет по имени bobsrockets.navigation. По-видимому, это программные средства навигации, разработанные корпорацией Bob’s Rockets. ПРИМЕЧАНИЕ Поскольку код Scala — часть экосистемы Java, то тем пакетам Scala, которые выпускаются открытыми, рекомендуется соответствовать принятому в Java соглашению по присваиванию имени с обратным порядком следования доменных имен. Поэтому наиболее подходящим именем пакета для класса Navigator может быть com.bobsrockets.navigation. Но в данной главе мы отбросим com., чтобы было легче разобраться в примерах. Другой способ, позволяющий в Scala помещать код в пакеты, больше похож на использование пространств имен C#. За директивой package следует раздел в фи­ гурных скобках, в котором содержатся определения, помещаемые в пакет. Такой синтаксис называется пакетированием. Показанное в листинге 13.2 пакетирование имеет тот же эффект, что и код в листинге 13.1 (см. выше). Листинг 13.2. Длинная форма простого объявления пакетирования package bobsrockets.navigation { class Navigator } Кроме того, для таких простых примеров можно воспользоваться «синтакси­ ческим сахаром», показанным в листинге 13.1 (см. выше). Но лучше все же реа­ лизовать один из вариантов более универсальной системы записи, позволяющей разместить разные части файла в разных пакетах. Например, в тот же самый файл с исходным кодом можно включить тесты классов, но, как показано в листинге 13.3, поместить тесты в другой пакет. Листинг 13.3. Несколько пакетов в одном и том же файле package bobsrockets { package navigation { // в пакете bobsrockets.navigation class Navigator package tests { // в пакете bobsrockets.navigation.tests class NavigatorSuite } } } 13.2. Краткая форма доступа к родственному коду 239 13.2. Краткая форма доступа к родственному коду Представление кода в виде иерархии пакетов не только помогает просматривать код, но и сообщает компилятору, что части кода в одном и том же пакете как-то связаны между собой. В Scala эта родственность позволяет при доступе к коду, на­ ходящемуся в одном и том же пакете, применять краткие имена. В листинге 13.4 приведены три примера. В первом, согласно ожиданиям, к клас­ су можно обращаться из его собственного пакета, не указывая префикс. Именно поэтому new StarMap проходит компиляцию. Класс StarMap находится в том же са­ мом пакете по имени bobsrockets.navigation, что и выражение new, которое к нему обращается, поэтому указывать префикс в виде имени пакета не нужно. Листинг 13.4. Краткая форма обращения к классам и пакетам package bobsrockets { package navigation { class Navigator { // Указывать bobsrockets.navigation.StarMap не нужно val map = new StarMap } class StarMap } class Ship { // Указывать bobsrockets.navigation.Navigator не нужно val nav = new navigation.Navigator } package fleets { class Fleet { // Указывать bobsrockets.Ship не нужно def addShip() = { new Ship } } } } Во втором примере обращение к самому пакету может производиться из того же пакета, в котором он находится, без указания префикса. В листинге 13.4 показано, как создается экземпляр класса Navigator. Выражение new появляется в пакете bobsrockets, который содержится в пакете bobsrockets.navigation. Поэтому об­ ращение к последнему можно указывать просто как navigation. В третьем примере показано, что при использовании синтаксиса пакетирования с применением фигурных скобок все имена, доступные в пространстве имен вне па­ кета, доступны также и внутри него. Это обстоятельство позволяет в листинге 13.4 в addShip() создать новый экземпляр класса, воспользовавшись выражением new Ship. Метод определен внутри двух пакетов: внешнего bobsrockets и внутреннего bobsrockets.fleets. Поскольку к объекту Ship доступ можно получить во внешнем пакете, то из addShip() можно воспользоваться ссылкой на него. Следует заметить, что такая форма доступа применима только в том случае, если пакеты вложены друг в друга явным образом. Если в каждый файл будет поме­ щаться лишь один пакет, то, как и в Java, доступны будут только те имена, которые 240 Глава 13 • Пакеты и импорты определены в текущем пакете. В листинге 13.5 пакет bobsrockets.fleets был пере­ мещен на самый верхний уровень. Он больше не заключен в пакет bobsrockets, поэтому имена из bobsrockets в его пространстве имен отсутствуют. В результате использование выражения new Ship вызовет ошибку компиляции. Листинг 13.5. Обозначения, заключенные в пакеты, недоступны автоматически package bobsrockets { class Ship } package bobsrockets.fleets { class Fleet { // Не пройдет компиляцию! Ship вне области видимости. def addShip() = { new Ship } } } Если обозначение вложенности пакетов с использованием фигурных скобок приводит к неудобному для вас сдвигу кода вправо, то можно воспользоваться несколькими указаниями директивы package без фигурных скобок1. Например, в показанном далее коде класс Fleet определяется в двух вложенных пакетах bobsrockets и fleets точно так же, как это было сделано в листинге 13.4 (см. выше): package bobsrockets package fleets class Fleet { // Указывать bobsrockets.Ship не нужно def addShip() = { new Ship } } Важно знать еще об одной, последней особенности. Иногда в области види­ мости оказывается слишком много всего и имена пакетов скрывают друг друга. В листинге 13.6 пространство имен класса MissionControl включает три отдельных пакета по имени launch! Один пакет launch находится в bobsrockets.navigation, один — в bobsrockets и еще один — на верхнем уровне. А как тогда ссылаться на Booster1, Booster2 и Booster3? Листинг 13.6. Доступ к скрытым именам пакетов // в файле launch.scala package launch { class Booster3 } // в файле bobsrockets.scala package bobsrockets { package navigation { package launch { class Booster1 } class MissionControl { 1 Этот стиль, в котором используются несколько директив package без фигурных скобок, называется объявлением цепочки пакетов. 13.3. Импортирование кода 241 val booster1 = new launch.Booster1 val booster2 = new bobsrockets.launch.Booster2 val booster3 = new _root_.launch.Booster3 } } } package launch { class Booster2 } Проще всего обратиться к первому из них. Ссылка на само имя launch приведет вас к пакету bobsrockets.navigation.launch, поскольку этот пакет launch опре­ делен в ближайшей области видимости. Поэтому к первому из booster-классов можно обратиться просто как к launch.Booster1. Ссылка на второй подобный класс также указывается без каких-либо особенных приемов. Можно указать bobsrockets.launch.Booster2 и не оставить ни малейших сомнений о том, к какому из трех классов происходит обращение. Открытым остается лишь вопрос по поводу третьего класса booster: как обратиться к Booster3 при условии, что пакет на самом верхнем уровне перекрывается вложенными пакетами? Чтобы помочь справиться с подобной ситуацией, Scala предоставляет имя па­ кета _root_, являющегося внешним по отношению к любым другим создаваемым пользователем пакетам. Иначе говоря, каждый пакет верхнего уровня, который может быть создан, рассматривается как члены пакета _root_. Например, и launch, и bobsrockets в листинге 13.6 (см. выше) являются членами пакета _root_. В ре­ зультате этого _root_.launch позволяет обратиться к пакету launch самого верхнего уровня, а _root_.launch.Booster3 обозначает внешний класс booster. 13.3. Импортирование кода В Scala пакеты и их члены могут импортироваться с использованием директивы import. Затем ко всему, что было импортировано, можно получить доступ, указав простое имя, такое как File, не используя такое развернутое имя, как java.io.File. Рассмотрим, к примеру, код, показанный в листинге 13.7. Листинг 13.7. Превосходные фрукты от Боба, готовые к импорту package bobsdelights abstract class Fruit( val name: String, val color: String ) object Fruits { object Apple extends Fruit("apple", "red") object Orange extends Fruit("orange", "orange") object Pear extends Fruit("pear", "yellowish") val menu = List(Apple, Orange, Pear) } 242 Глава 13 • Пакеты и импорты Указание директивы import делает члены пакета или объект доступными по их именам, исключая необходимость ставить перед ними префикс с именем пакета или объекта. Рассмотрим ряд простых примеров: // простая форма доступа к Fruit import bobsdelights.Fruit // простая форма доступа ко всем членам bobsdelights import bobsdelights._ // простая форма доступа ко всем членам Fruits import bobsdelights.Fruits._ Первый пример относится к импортированию отдельно взятого Java-типа, а во втором показан Java-импорт до востребования (on-demand). Единственное отличие состоит в том, что импорт до востребования в Scala записывается с замыкающим знаком подчеркивания (_ ) вместо знака звездочки (* ). (Поскольку * является в Scala допустимым идентификатором!) Третья из показанных директив import относится к Java-импорту статических полей класса. Эти три директивы import дают представление о том, что можно делать с по­ мощью импортирования, но в Scala импортирование носит более универсальный характер. В частности, оно может быть где угодно, а не только в начале компилиру­ емого модуля. К тому же при импортировании можно ссылаться на произвольные значения. Например, возможен импорт, показанный в листинге 13.8. Листинг 13.8. Импортирование членов обычного объекта (не одиночки) def showFruit(fruit: Fruit) = { import fruit._ println(name + "s are " + color) } Метод showFruit импортирует все члены его параметра fruit, относящегося к типу Fruit. Следующая инструкция println может непосредственно ссылаться на name и color. Эти две ссылки — эквиваленты ссылок fruit.name и fruit.color. Такой синтаксис пригодится, в частности, при использовании объектов в качестве модулей. Соответствующее описание будет дано в главе 29. Гибкость импортирования в Scala Директива import работает в Scala намного более гибко, чем в Java. Эта гибкость характеризуется тремя принципиальными отличиями. Импорт кода в Scala: • может появляться где угодно; • позволяет, помимо пакетов, ссылаться на объекты (одиночки или обычные); • позволяет изменять имена или скрывать некоторые из импортированных членов. Еще один из факторов гибкости импорта кода в Scala заключается в возмож­ ности импортировать пакеты как таковые, без их непакетированного наполнения. 13.3. Импортирование кода 243 Смысл в этом будет только в том случае, если предполагается, что в пакете заключены другие пакеты. Например, в листинге 13.9 импортируется пакет java.util.regex. Благодаря этому regex можно использовать с указанием его простого имени. Чтобы обратиться к объекту-одиночке Pattern из пакета java.util.regex, мож­ но, как показано в данном листинге, просто воспользоваться идентификатором regex.Pattern. Листинг 13.9. Импортирование имени пакета import java.util.regex class AStarB { // обращение к java.util.regex.Pattern val pat = regex.Pattern.compile("a*b") } При импортировании кода Scala позволяет также переименовывать или скры­ вать члены. Для этого импорт заключается в фигурные скобки с указанием перед получившимся в результате блоком того объекта, из которого импортируются его члены. Рассмотрим несколько примеров: import Fruits.{Apple, Orange} Здесь из объекта Fruits импортируются только его члены Apple и Orange. import Fruits.{Apple => McIntosh, Orange} Здесь из объекта Fruits импортируются два члена, Apple и Orange. Но объект Apple переименовывается в McIntosh, поэтому к нему можно обращаться либо как Fruits.Apple, либо как McIntosh. Директива переименования всегда имеет вид <исходное_имя> => <новое_имя>. import java.sql.{Date => SDate} Здесь под именем SDate импортируется класс данных SQL, чтобы можно было в то же время импортировать обычный класс для работы с датами Java просто как Date. import java.{sql => S} Здесь под именем S импортируется пакет java.sql, чтобы можно было восполь­ зоваться кодом вида S.Date. import Fruits.{_} Здесь импортируются все члены объекта Fruits. Это означает то же самое, что и import Fruits._. import Fruits.{Apple => McIntosh, _} Здесь импортируются все члены объекта Fruits, но Apple переименовывается в McIntosh. import Fruits.{Pear => _, _} Здесь импортируются все члены объекта Fruits, за исключением Pear. Директи­ ва вида <исходное_имя> => _ исключает <исходное_имя> из импортируемых имен. 244 Глава 13 • Пакеты и импорты В определенном смысле переименование чего-либо в _ говорит о полном сокрытии переименованного члена. Это помогает избегать неоднозначностей. Предположим, имеется два пакета, Fruits и Notebooks, и в каждом из них определен класс Apple. Если нужно получить только ноутбук под названием Apple, а не фрукт, то можно воспользоваться двумя импортами по запросу: import Notebooks._ import Fruits.{Apple => _, _} Будут импортированы все члены Notebooks и все члены Fruits, за исключением Apple. Эти примеры демонстрируют поразительную гибкость, которую предлагает Scala в вопросах избирательного импортирования членов, возможно, даже под другими именами. Таким образом, директива import может состоять из следующих селекторов: простого имени x, которое включается в набор импортируемых имен; директивы переименования x => y. Член по имени x будет виден под именем y; директивы сокрытия x => _. Имя x исключается из набора импортируемых имен; элемента «поймать все» (catch-all) _ . Импортируются все члены, за исклю­ чением тех, которые были упомянуты в предыдущей директиве. Если указан элемент «поймать все», то в списке селекторов импортирования он должен стоять последним. Самые простые import-директивы, показанные в начале данного раздела, могут рассматриваться как специальные сокращения директив import с селекторами. На­ пример, import p._ — эквивалент import p.{_}, а import p.n — эквивалент import p.{n}. 13.4. Неявное импортирование Scala неявно добавляет импортируемый код в каждую программу. По сути, про­ исходит то, что произошло бы при добавлении в самое начало каждого исходного файла с расширением .scala следующих трех директив import: import java.lang._ // все из пакета java.lang import scala._ // все из пакета scala import Predef._ // все из пакета Predef В пакете java.lang содержатся стандартные классы Java. Он всегда неявно импортируется в исходные файлы Scala1. Неявное импортирование java.lang позволяет вам, например, использовать вместо java.lang.Thread просто иденти­ фикатор Thread. 1 Изначально имелась также реализация Scala на платформе .NET., где вместо этого им­ портировалось пространство имен System, .NET-аналог пакета java.lang. 13.5. Модификаторы доступа 245 Теперь уже вряд ли приходится сомневаться в том, что в пакете scala находится стандартная библиотека Scala, в которой содержатся многие самые востребованные классы и объекты. Поскольку пакет scala импортируется неявно, то можно, к при­ меру, вместо scala.List указать просто List. В объекте Predef содержится множество определений типов, методов и неявных преобразований, которые обычно используются в программах на Scala. Например, Predef импортируется неявно, поэтому можно вместо Predef.assert задействовать просто идентификатор assert. Эти три директивы import трактуются особым образом, позволяющим тому импорту, который указан позже, перекрывать указанный ранее. К примеру, класс StringBuilder определен как в пакете scala , так и, начиная с версии Java 1.5, в пакете java.lang. Импорт scala перекрывает импорт java.lang, по­ этому простое имя StringBuilder будет ссылаться на scala.StringBuilder, а не на java.lang.StringBuilder. 13.5. Модификаторы доступа Члены пакетов, классов или объектов могут быть помечены модификаторами доступа private и protected. Они ограничивают доступ к членам, позволяя обра­ щаться к ним только из определенных областей кода. Трактовка модификаторов доступа Scala примерно соответствует принятой в Java, но при этом имеет ряд весьма важных отличий, которые рассматриваются в данном разделе. Приватные члены Приватные члены трактуются в Scala точно так же, как и в Java. Член с пометкой private виден только внутри класса или объекта, в котором содержится его опре­ деление. В Scala это правило распространяется и на внутренние классы. Данная трактовка более последовательна, но отличается от принятой в Java. Рассмотрим пример, показанный в листинге 13.10. Листинг 13.10. Отличие приватного доступа в Scala от такого же доступа в Java class Outer { class Inner { private def f() = { println("f") } class InnerMost { f() // вполне допустимо } } (new Inner).f() // ошибка: доступ к f отсутствует } В Scala обращение (new Inner).f() недопустимо, поскольку приватное объявле­ ние f сделано в классе Inner, а попытка обращения делается не из данного класса. В отличие от этого первое обращение к f в классе InnerMost вполне допустимо, 246 Глава 13 • Пакеты и импорты поскольку содержится в теле класса Inner . В Java допустимы оба обращения, так как в данном языке разрешается обращение из внешнего класса к приватным членам его внутренних классов. Защищенные члены Доступ к защищенным членам в Scala также менее свободен, чем в Java. В Scala об­ ратиться к защищенному члену можно только из подклассов того класса, в котором был определен этот член. В Java обращение возможно и из других классов того же самого пакета. В Scala есть еще один способ достижения того же самого эффекта1, поэтому модификатор protected можно оставить без изменений. Защищенные виды доступа показаны в листинге 13.11. Листинг 13.11. Отличие защищенного доступа в Scala от такого же доступа в Java package p { class Super { protected def f() = { println("f") } } class Sub extends Super { f() } class Other { (new Super).f() // ошибка: доступ к f отсутствует } } В данном листинге обращение к f в классе Sub вполне допустимо, поскольку объявление f было сделано с модификатором protected в Super, а Sub — подкласс Super. В отличие от этого обращение к f в Other недопустимо, поскольку Other не является наследником Super. В Java последнее обращение все равно будет раз­ решено, так как Other находится в том же самом пакете, что и Sub. Публичные члены В Scala нет явного модификатора для публичных членов: любой член, не помечен­ ный как private или protected, является публичным. К публичным членам можно обращаться откуда угодно. Область защиты Модификаторы доступа в Scala могут дополняться спецификаторами. Модифика­ тор вида private[X] или protected[X] означает, что доступ закрыт или защищен вплоть до X, где X определяет некий внешний пакет, класс или объект-одиночку. 1 Используя спецификаторы, рассматриваемые ниже, в подразделе «Область защиты». 13.5. Модификаторы доступа 247 Специфицированные модификаторы доступа позволяют весьма четко обозна­ чить границы управления видимостью. В частности, они позволяют выразить поня­ тия доступности, имеющиеся в Java, такие как приватность пакета, защищенность пакета или закрытость вплоть до самого внешнего класса, которые невозможно выразить напрямую с помощью простых модификаторов, используемых в Scala. Но, помимо этого, они позволяют выразить правила доступности, которые не могут быть выражены в Java. В листинге 13.12 представлен пример с использованием множества специфи­ каторов доступа. Здесь класс Navigator помечен как private[bobsrockets]. Это значит, он имеет область видимости, охватывающую все классы и объекты, кото­ рые содержатся в пакете bobsrockets. В частности, доступ к Navigator разрешен в объекте Vehicle, поскольку Vehicle содержится в пакете launch, который, в свою очередь, содержится в пакете bobsrockets. В то же время весь код, находящийся за пределами пакета bobsrockets, не может получить доступ к классу Navigator. Листинг 13.12. Придание гибкости областям защиты с помощью спецификаторов доступа package bobsrockets package navigation { private[bobsrockets] class Navigator { protected[navigation] def useStarChart() = {} class LegOfJourney { private[Navigator] val distance = 100 } private[this] var speed = 200 } } package launch { import navigation._ object Vehicle { private[launch] val guide = new Navigator } } Этот прием особенно полезен при разработке крупных проектов, содержащих несколько пакетов. Он позволяет определять элементы, видимость которых рас­ пространяется на несколько подчиненных пакетов проекта, оставляя их невиди­ мыми для клиентов, являющихся внешними по отношению к данному проекту. Применить подобный прием в Java не представляется возможным. Там, сразу после перехода границы своего пакета, определение становится видимым по­ всеместно. Разумеется, действие спецификатора private может распространяться и на непосредственно окружающий пакет. В листинге 13.12 (см. выше) показан при­ мер модификатора доступа guide в объекте Vehicle. Такой модификатор доступа эквивалентен имеющемуся в Java доступу, ограниченному пределами одного пакета. Все спецификаторы также могут применяться к модификатору protected со значениями, аналогичными тем, с которыми они применяются к модификатору 248 Глава 13 • Пакеты и импорты private. То есть модификатор protected[X] в классе C позволяет получить доступ к определению с подобной пометкой во всех подклассах C, а также во внешнем пакете, классе или объекте с названием X. Например, метод useStarChart в при­ веденном выше листинге 13.12 доступен из всех подклассов Navigator, а также из всего кода, содержащегося во внешнем пакете navigation. В результате получается точное соответствие значению модификатора protected в Java. Спецификаторы модификатора private могут также ссылаться на окружающий (внешний) класс или объект. Например, показанная в листинге 13.12 (см. выше) переменная distance в классе LegOfJourney имеет пометку private[Navigator], сле­ довательно, видима из любого места в классе Navigator. Тем самым ей придаются такие же возможности видимости, как и приватным членам внутренних классов в Java. Модификатор private[C], где C — самый внешний класс, аналогичен про­ стому модификатору private в Java. И наконец, в Scala имеется также модификатор доступа, ограничивающий еще больше, чем private. Определение с пометкой private[this] доступно только вну­ три того же самого объекта, в котором содержится это определение. Такое опреде­ ление называется приватным внутри объекта (object-private). Например, в листин­ ге 13.12 (см. выше) приватным внутри объекта является определение speed в классе Navigator. Это значит, любое обращение должно быть сделано внутри не только класса Navigator, но и именно этого экземпляра класса Navigator. Таким образом, внутри класса Navigator будут вполне допустимы обращения speed и this.speed. Но следующее обращение не будет позволено, даже если появится внутри класса Navigator: val other = new Navigator other.speed // эта строка кода не пройдет компиляцию Элемент помечается модификатором private[this] с целью гарантировать, что он не будет виден из других объектов того же самого класса. Это может пригодиться для документирования. Иногда это также позволяет создавать более универсальные варианты аннотаций (подробности рассматриваются в разделе 19.7). В качестве резюме в табл. 13.1 приведен список действий спецификаторов модификатора private. В каждой строке показан модификатор private со спе­ цификатором и раскрыто его значение при применении в отношении переменной distance, объявленной в классе LegOfJourney в листинге 13.12 (см. выше). Таблица 13.1. Действия спецификаторов private в отношении LegOfJourney.distance Спецификатор Действие Без указания модификатора доступа Открытый доступ private[bobsrockets] Доступ в пределах внешнего пакета private[navigation] Аналог имеющейся в Java видимости в пределах пакета private[Navigator] Аналог имеющегося в Java модификатора private private[LegOfJourney] Аналог имеющегося в Scala модификатора private private[this] Доступ только из того же самого объекта 13.6. Объекты пакетов 249 Видимость и объекты-компаньоны В Java статические члены и члены экземпляра принадлежат одному и тому же классу, поэтому модификаторы доступа применяются к ним одинаково. Вы уже ви­ дели, что в Scala статических членов нет, вместо них может быть объект-компаньон, содержащий члены, существующие в единственном экземпляре. Например, в ли­ стинге 13.13 объект Rocket — компаньон класса Rocket. Листинг 13.13. Обращение к приватным членам класса- и объекта-компаньона class Rocket { import Rocket.fuel private def canGoHomeAgain = fuel > 20 } object Rocket { private def fuel = 10 def chooseStrategy(rocket: Rocket) = { if (rocket.canGoHomeAgain) goHome() else pickAStar() } def goHome() = {} def pickAStar() = {} } Что касается приватного или защищенного доступа, то в правилах доступа, дей­ ствующих в Scala, объектам- и классам-компаньонам даются особые привилегии. Класс делится всеми своими правами доступа со своим объектом-компаньоном, и наоборот. В частности, объект может обращаться ко всем приватным членам своего класса-компаньона точно так же, как класс может обращаться ко всем при­ ватным членам своего объекта-компаньона. Например, в показанном выше листинге 13.13 класс Rocket может обращаться к методу fuel, который объявлен приватным в объекте Rocket. Аналогично этому объ­ ект Rocket может обращаться к приватному методу canGoHomeAgain в классе Rocket. Одно из исключений, которое нарушает аналогию между Scala и Java, касается защищенных статических членов. Защищенный статический член Java-класса C может быть доступен во всех подклассах C. В отличие от этого в наличии защи­ щенного члена в объекте-компаньоне нет никакого смысла, поскольку у объектоводиночек нет никаких подклассов. 13.6. Объекты пакетов До сих пор единственным встречающимся вам кодом, добавляемым к пакетам, были классы, трейты и самостоятельные объекты. Они, несомненно, являются наиболее распространенными определениями, помещаемыми на самом верхнем уровне пакета. Но Scala не ограничивает вас только этим перечнем — любые виды 250 Глава 13 • Пакеты и импорты определений, которые можно помещать внутри класса, могут присутствовать и на верхнем уровне пакета. На самый верхний уровень пакета можно смело помещать любой вспомогательный метод, который хотелось бы иметь в области видимости всего пакета. Для этого поместите определение в объект пакета. В каждом пакете разрешает­ ся иметь один объект пакета. Любые определения, помещенные в объекте пакета, считаются членами самого пакета. Пример показан в листинге 13.14. В файле package.scala содержится объект пакета для пакета bobsdelights. Синтаксически объект пакета очень похож на одно из ранее показанных в данной главе пакетирований в фигурных скобках. Един­ ственное отличие заключается в том, что в него включено ключевое слово object. Это объект пакета, а не сам пакет. Содержимое, заключенное в фигурные скобки, может включать любые нужные вам определения. В данном случае объект пакета включает вспомогательный метод showFruit из листинга 13.8 (см. выше). Листинг 13.14. Объект пакета // в файле bobsdelights/package.scala package object bobsdelights { def showFruit(fruit: Fruit) = { import fruit._ println(name + "s are " + color) } } // в файле PrintMenu.scala package printmenu import bobsdelights.Fruits import bobsdelights.showFruit object PrintMenu { def main(args: Array[String]) = { for (fruit <- Fruits.menu) { showFruit(fruit) } } } При наличии этого определения любой другой код в любом пакете может импортировать метод точно так же, как мог бы импортировать класс. Например, в данном листинге показан самостоятельный объект PrintMenu, который находится в другом пакете. PrintMenu может импортировать вспомогательный метод showFruit аналогично тому, как импортировал бы класс Fruit. Забегая вперед, следует отметить, что есть и другие примеры использования объектов пакетов для тех разновидностей определений, которые еще не были вам показаны. Объекты пакетов часто используются для содержания псевдонимов типов, предназначенных для всего пакета (см. главу 20) и неявных преобразо­ ваний (см. главу 21). В пакете верхнего уровня scala имеется объект пакета, чьи определения доступны всему коду Scala. Объекты пакетов компилируются в фай­ Резюме 251 лы классов с именами package.class, размещаемыми в каталоге package, который они дополняют. Им будет полезно придерживаться того же соглашения, которое действует в отношении исходных файлов. Таким образом, исходный код объекта пакета bobsdelights из листинга 13.14 было бы разумно поместить в файл package. scala, размещаемый в каталоге bobsdelights. Резюме В данной главе мы показали основные конструкции, предназначенные для раз­ биения программы на пакеты. Благодаря этому вы имеете простую и полезную разновидность модульности и можете работать с весьма большими объемами кода, не допуская взимного влияния различных его частей. Существующая в Scala си­ стема по духу аналогична пакетированию, используемому в Java, но с некоторыми различиями: в Scala проявляется более последовательный или же более универ­ сальный подход. Забегая вперед: в главе 29 описана более гибкая система модулей, разбиваемая на пакеты. Данный подход позволяет разбивать код на несколько пространств имен и вдобавок дает модулям возможность выполнить взаимное наследование и пара­ метризацию. В следующей главе мы переключимся на утверждения и модульное тестирование. 14 Утверждения и тесты Утверждения и тесты — два важных способа проверки правильности поведения созданных вами программных средств. В данной главе мы покажем несколько вариантов для их создания и запуска, доступных в Scala. 14.1. Утверждения Утверждения в Scala создаются в виде вызовов предопределенного метода assert1. Выражение assert(condition) выдает ошибку AssertionError , если условие condition не соблюдается. Существует также версия assert, использующая два аргумента: выражение assert(condition, explanation) тестирует условие. При его не соблюдении оно выдает ошибку AssertionError, в сообщении о которой содер­ жится заданное объяснение explanation. Тип объяснения — Any, поэтому в качестве объяснения может передаваться любой объект. Чтобы поместить в AssertionError строковое объяснение, метод assert будет вызывать в отношении этого объекта метод toString. Например, в метод по имени above класса Element и показанный в листинге 10.13 (см. выше) assert можно поместить после вызовов widen, чтобы убедиться в одинаковой ширине расширенных элементов. Этот вариант показан в листинге 14.1. Листинг 14.1. Использование утверждения def above(that: Element): Element = { val this1 = this widen that.width val that1 = that widen this.width assert(this1.width == that1.width) elem(this1.contents ++ that1.contents) } Выполнить ту же задачу можно и по-другому: проверить ширину в конце мето­ да widen, непосредственно перед возвращением значения. Этого можно добиться, 1 Метод assert определен в объекте-одиночке Predef, элементы которого автоматически импортируются в каждый исходный файл программы на языке Scala. 14.2. Тестирование в Scala 253 сохранив результат в val -переменной, выполняя утверждение применительно к результату с последующим указанием val-переменной, чтобы результат возвра­ щался в том случае, если утверждение было успешно подтверждено. Но, как по­ казано в листинге 14.2, это можно сделать более выразительно, воспользовавшись довольно удобным методом ensuring из объекта-одиночки Predef. Листинг 14.2. Использование метода ensuring для утверждения результата выполнения функции private def widen(w: Int): Element = if (w <= width) this else { val left = elem(' ', (w - width) / 2, height) var right = elem(' ', w - width - left.width, height) left beside this beside right } ensuring (w <= _.width) Благодаря неявному преобразованию метод ensuring может использоваться с любым результирующим типом. В данном коде все выглядит так, словно ensuring вызван в отношении результата выполнения метода widen, имеющего тип Element. Однако на самом деле ensuring вызван в отношении типа, в который Element был автоматически преобразован. Метод ensuring получает один аргумент, функ­ цию-предикат, которая берет результирующий тип, возвращает булево значение и передает результат предикату. Если предикат возвращает true, то метод ensuring возвращает результат, в противном случае метод выдаст ошибку AssertionError. В данном примере предикат имеет вид w <= _.width. Знак подчеркивания явля­ ется заместителем для одного аргумента, который передается предикату, а именно результата типа Element, получаемого от метода widen. Если ширина, переданная в виде w методу widen, меньше ширины результата типа Element или равна ей, то предикат будет вычислен в true и результатом ensuring будет объект типа Element, в отношении которого он был вызван. Данное выражение в методе widen послед­ нее, поэтому его результат типа Element и будет значением, возвращаемым самим методом widen. Утверждения могут включаться и выключаться с помощью флагов командной строки JVM -ea и -da. Когда флаги включены, каждое утверждение служит не­ большим тестом, использующим фактические данные, вычисленные при выпол­ нении программы. Далее в главе мы сфокусируемся на написании внешних тестов, которые предоставляют собственные тестовые данные и выполняются независимо от приложения. 14.2. Тестирование в Scala Scala содержит много опций для тестирования, начиная с хорошо известного ин­ струментария Java, такого как JUnit и TestNG, и заканчивая инструментарием, написанным на Scala, например ScalaTest, specs2 и ScalaCheck. В оставшейся части главы мы дадим краткий обзор этого инструментария. Начнем со ScalaTest. 254 Глава 14 • Утверждения и тесты ScalaTest — наиболее гибкая среда тестирования в Scala, это средство можно легко настроить на решение различных задач. Гибкость ScalaTest означает, что ко­ манды могут использовать тот стиль тестирования, который более полно отвечает их потребностям. Например, для команд, знакомых с JUnit, наиболее знакомым и удобным станет стиль AnyFunSuite. Пример показан в листинге 14.3. Листинг 14.3. Написание тестов с использованием AnyFunSuite import org.scalatest.FunSuite import Element.elem class ElementSuite extends FunSuite { test("elem result should have passed width") { val ele = elem('x', 2, 3) assert(ele.width == 2) } } Центральная концепция в ScalaTest — набор, то есть коллекция тестов. Тестом может являться любой код с именем, который может быть запущен и завершен успешно или неуспешно, отложен или отменен. Центральный блок композиции в ScalaTest — трейт Suite. В нем объявляются методы жизненного цикла, опре­ деляющие исходный способ запуска тестов, который можно переопределить под нужные способы написания и запуска тестов. В ScalaTest предлагаются стилевые трейты, расширяющие Suite и переопре­ деляющие методы жизненного цикла для поддержания различных стилей тести­ рования. В этой среде также предоставляются примешиваемые трейты, которые переопределяют методы жизненного цикла таким образом, чтобы те отвечали конкретным потребностям тестирования. Классы тестов определяются сочетанием стиля Suite и примешиваемых трейтов, а тестовые наборы — путем составления экземпляров Suite. Пример стиля тестирования — AnyFunSuite, расширенный тестовым классом, показанным в листинге 14.3 (см. выше). Слово Fun в AnyFunSuite означает функ­ цию, а test является определенным в AnyFunSuite методом, который вызывается первичным конструктором ElementSuite. Вы указываете название теста в виде строки в круглых скобках, а сам код теста помещаете в фигурные скобки. Код теста является функцией, передаваемой методу test в виде параметра по имени, которая регистрирует его для последующего выполнения. Среда ScalaTest интегрирована в широко используемые инструментальные средства сборки, такие как sbt и Maven, и в IDE, например IntelliJIDEA и Eclipse. Suite можно запустить и непосредственно через приложение ScalaTest Runner или из интерпретатора Scala, просто вызвав в отношении него метод execute. Соответ­ ствующий пример выглядит так: scala> (new ElementSuite).execute() ElementSuite: - elem result should have passed width 14.3. Информативные отчеты об ошибках 255 Все стили ScalaTest, включая AnyFunSuite, предназначены для стимуляции на­ писания специализированных тестов с осмысленными названиями. Кроме того, все стили создают вывод, похожий на спецификацию, которая может облегчить общение между заинтересованными сторонами. Выбранным вами стилем предпи­ сывается только то, как будут выглядеть объявления ваших тестов. Все остальное в ScalaTest работает одинаково, независимо от выбранного стиля1. 14.3. Информативные отчеты об ошибках В тесте, показанном в листинге 14.3 (см. выше), предпринимается попытка создать элемент шириной, равной 2, и высказывается утверждение, что ширина получив­ шегося элемента действительно равна 2. Если утверждение не подтвердится, то отчет об ошибке будет включать имя файла и номер строки с неоправдавшимся утверждением, а также информативное сообщение об ошибке: scala> val width = 3 width: Int = 3 scala> assert(width == 2) org.scalatest.exceptions.TestFailedException: 3 did not equal 2 Чтобы обеспечить содержательные сообщения об ошибках при неверных утверждениях, ScalaTest в ходе компиляции анализирует выражения, переданные каждому вызову утверждения. Если вы хотите увидеть более подробную информа­ цию о неверных утверждениях, то можете воспользоваться имеющимся в ScalaTest средством Diagrams, сообщения об ошибках которого показывают схему выраже­ ния, переданного утверждению assert: scala> assert(List(1, 2, 3).contains(4)) org.scalatest.exceptions.TestFailedException: assert(List(1, | | | 1 List(1, 2, | 2 2, 3).contains(4)) | | | 3 false 4 3) Имеющиеся в ScalaTest методы assert не делают разницы в сообщениях об ошибках между фактическим и ожидаемым результатами. Они просто показывают, что левый операнд не равен правому, или показывают значения на схеме. Если нуж­ но подчеркнуть различия между фактическим и ожидаемым результатами, то мож­ но воспользоваться имеющимся в ScalaTest альтернативным методом assertResult: assertResult(2) { ele.width } 1 Более подробную информацию о ScalaTest можно найти на сайте www.scalatest.org. 256 Глава 14 • Утверждения и тесты С помощью данного выражения показывается, что от кода в фигурных скобках ожидается результат 2. Если в результате выполнения этого кода получится 3, то в отчете об ошибке тестирования будет показано сообщение Expected 2, but got 3 (Ожидалось 2, но получено 3). При необходимости проверить, выдает ли метод ожидаемое исключение, можно воспользоваться имеющимся в ScalaTest методом assertThrows: assertThrows[IllegalArgumentException] { elem('x', -2, 3) } Если код в фигурных скобках выдает не то исключение, которое ожидалось, или вообще не выдает его, то метод assertThrows тут же завершит свою работу и выдаст исключение TestFailedException. А отчет об ошибке будет содержать сообщение с полезной для вас информацией: Expected IllegalArgumentException to be thrown, but NegativeArraySizeException was thrown. Но если код завершится внезапной генерацией экземпляра переданного класса исключения, то метод assertThrows выполнится нормально. При необходимо­ сти провести дальнейшее исследование ожидаемого исключения можно вместо assertThrows воспользоваться методом перехвата intercept. Он работает анало­ гично методу asassertThrows, за исключением того, что при генерации ожидаемого исключения intercept возвращает это исключение: val caught = intercept[ArithmeticException] { 1 / 0 } assert(caught.getMessage == "/ by zero") Короче говоря, имеющиеся в ScalaTest утверждения стараются выдать полез­ ные сообщения об ошибках, способные помочь вам диагностировать и устранить проблемы в коде. 14.4. Использование тестов в качестве спецификаций В стиле тестирования при разработке через реализацию поведения (behavior-driven development, BDD) основной упор делается на написание легко воспринимаемых человеком спецификаций расширенного поведения кода и сопутствующих тестов, которые проверяют, свойственно ли коду такое поведение. В ScalaTest включены несколько трейтов, содействующих этому стилю тестирования. Пример использо­ вания одного такого трейта, AnyFlatSpec, показан в листинге 14.4. 14.4. Использование тестов в качестве спецификаций 257 Листинг 14.4. Спецификация и тестирование поведения с помощью AnyFlatSpec import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import Element.elem class ElementSpec extends AnyFlatSpec with Matchers { "A UniformElement" should "have a width equal to the passed value" in { val ele = elem('x', 2, 3) ele.width should be (2) } it should "have a height equal to the passed value" in { val ele = elem('x', 2, 3) ele.height should be (3) } it should "throw an IAE if passed a negative width" in { an [IllegalArgumentException] should be thrownBy { elem('x', -2, 3) } } } При использовании AnyFlatSpec тесты создаются в виде директив специ­фикации. Сначала в виде строки пишется название тестируемого субъекта ("A UniformElement" в листинге 14.4), затем should , или must , или can (что означает «обязан», или «должен», или «может» соответственно), потом строка, обозначающая характер поведения, требуемого от субъекта, а затем ключевое слово in. В фигурных скоб­ ках, стоящих после in, пишется код, тестирующий указанное поведение. В после­ дующих директивах, чтобы сослаться на самый последний субъект, можно напи­ сать it. При выполнении AnyFlatSpec этот трейт будет запускать каждую директиву спецификации в виде теста ScalaTest. AnyFlatSpec (и другие специфицирующие трейты, которые есть в ScalaTest) генерирует вывод, который при запуске чита­ ется как спецификация. Например, вот на что будет похож вывод, если запустить ElementSpec из листинга 14.4 в интерпретаторе: scala> (new ElementSpec).execute() A UniformElement - should have a width equal to the passed value - should have a height equal to the passed value - should throw an IAE if passed a negative width В листинге 14.4 также показан имеющийся в ScalaTest предметно-ориентиро­ ванный язык (domain-specific language, DSL) выявления соответствий. Примеши­ ванием трейта Matchers можно создавать утверждения, которые при чтении больше похожи на естественный язык. Имеющийся в ScalaTest DSL-язык предоставляет множество средств выявления соответствий, кроме этого, позволяет определять новые предоставленные пользователем средства выявления соответствий, содержа­ щие сообщения об ошибках. Такие средства, показанные в листинге 14.4, включают 258 Глава 14 • Утверждения и тесты синтаксис should be и an [...] should be thrownBy { ...}. Как вариант, если пред­ почтение отдается глаголу must, а не should, то можно примешать MustMatchers. Например, примешивание MustMatchers позволит вам создавать следующие вы­ ражения: result must be >= 0 map must contain key 'c' Если последнее утверждение не подтвердится, то будет показано сообщение об ошибке следующего вида: Map('a' -> 1, 'b' -> 2) did not contain key 'c' Среда тестирования specs2 — средство с открытым кодом, написанное на Scala Эриком Торребо (Eric Torreborre), — также поддерживает BDD-стиль тестирова­ ния, но с другим синтаксисом. Например, specs2 можно использовать для создания теста, показанного в листинге 14.5. Листинг 14.5. Спецификация и тестирование поведения с использованием среды specs2 import org.specs2._ import Element.elem object ElementSpecification extends Specification { "A UniformElement" should { "have a width equal to the passed value" in { val ele = elem('x', 2, 3) ele.width must be_==(2) } "have a height equal to the passed value" in { val ele = elem('x', 2, 3) ele.height must be_==(3) } "throw an IAE if passed a negative width" in { elem('x', -2, 3) must throwA[IllegalArgumentException] } } } В specs2, как и в ScalaTest, существует DSL-язык выявления соответствий. Некото­ рые примеры работы средств выявления соответствий, имеющихся в specs2, показаны в листинге 14.5 в строках, содержащих must be_== и must throwA1. Среду specs2 можно использовать в автономном режиме, но она также интегрируется со ScalaTest и JUnit, поэтому specs2-тесты можно запускать и с этими инструментами. Одной из основных идей BDD является то, что тесты могут помогать обме­ ниваться мнениями людям, принимающим решения о характере поведения про­ граммных средств, людям, разрабатывающим программные средства, и людям, определяющим степень завершенности и работоспособности программных средств. В этом ключе могут применяться любые стили, имеющиеся в ScalaTest или specs2, 1 Программное средство specs2 можно загрузить с сайта specs2.org. 14.5. Тестирование на основе свойств 259 однако в ScalaTest есть специально разработанный для этого стиль AnyFeatureSpec. Пример его использования показан в листинге 14.6. Листинг 14.6. Использование тестов для содействия обмену мнениями среди всех заинтересованных сторон import org.scalatest._ import org.scalatest.featurespec.AnyFeatureSpec class TVSetSpec extends AnyFeatureSpec with GivenWhenThen { Feature("TV power button") { Scenario("User presses power button when TV is off") { Given("a TV set that is switched off") When("the power button is pressed") Then("the TV should switch on") pending } } } Стиль AnyFeatureSpec разработан для того, чтобы направить в нужное русло обсуждений предназначение программных средств: вам следует выявить специфи­ ческие требования, а затем дать им точное определение в виде скриптов. Сосре­ доточиться на переговорах об особенностях отдельно взятых скриптов помогают методы Given, When и Then, предоставляемые трейтом GivenWhenThen. Вызов pending в самом конце показывает: и тест, и само поведение не реализованы, имеется лишь спецификация. Как только будут реализованы все тесты и конкретные действия, тесты будут пройдены и требования можно будет посчитать выполненными. 14.5. Тестирование на основе свойств Еще одним полезным средством тестирования для Scala является ScalaCheck — среда с открытым кодом, созданная Рикардом Нильсоном (Rickard Nilsson). Она позволяет указывать свойства, которыми должен обладать тестируемый код. Для каждого свойства ScalaCheck создает данные и выдает утверждения, проверяющие наличие тех или иных свойств. Пример использования ScalaCheck из ScalaTest AnyWordSpec, в который примешан трейт ScalaCheckPropertyChecks, показан в ли­ стинге 14.7. Листинг 14.7. Тестирование на основе свойств с помощью ScalaCheck import import import import org.scalatest.wordspec.AnyWordSpec org.scalatestplus.scalacheck.ScalaCheckPropertyChecks org.scalatest.matchers.must.Matchers._ Element.elem class ElementSpec extends AnyWordSpec with ScalaCheckPropertyChecks { "elem result" must { 260 Глава 14 } } • Утверждения и тесты "have passed width" in { forAll { (w: Int) => whenever (w > 0) { elem('x', w % 100, 3).width must equal (w % 100) } } } AnyWordSpec — класс стиля, имеющийся в ScalaTest. Трейт ScalaCheckPro­ pertyChecks предоставляет несколько методов forAll, позволяющих смешивать тесты на основе проверки наличия свойств с традиционными тестами на основе утверждений или выявления соответствий. В данном примере проверяется на­ личие свойства, которым должен обладать фабричный метод elem . Свойства ScalaCheck выражены в виде значений функций, получающих в качестве пара­ метров данные, необходимые для утверждений о наличии свойств. Эти данные будут генерироваться ScalaCheck. В свойстве, показанном в листинге 14.7, данны­ ми является целое число w, которое представляет ширину. Внутри тела функции показан следующий код: whenever (w > 0) { elem('x', w % 100, 3).width must equal (w % 100) } Директива whenever указывает на то, что при каждом вычислении левосто­ роннего выражения в true правостороннее также должно быть true. Таким об­ разом, в данном случае выражение в блоке должно быть true всякий раз, когда w больше нуля. А правостороннее будет выдавать true, если ширина, переданная фабричному методу elem, будет равна ширине объекта Element, возвращенного фабричным методом. При таком небольшом объеме кода ScalaCheck сгенерирует несколько пробных значений, проверяя каждое из них в поисках значения, для которого свойство не со­ блюдается. Если свойство соблюдается для каждого значения, испытанного с по­ мощью ScalaCheck, то тест будет пройден. В противном случае он будет завершен с генерацией исключения TestFailedException, которое содержит информацию, включающую значение, вызвавшее сбой. 14.6. Подготовка и проведение тестов Во всех средах, упомянутых в текущей главе, имеется механизм для подготовки и проведения тестов. В данном разделе будет дан краткий обзор того подхода, который используется в ScalaTest. Чтобы получить подробное описание любой из этих сред, нужно обратиться к их документации. Подготовка больших наборов тестов в ScalaTest проводится путем вложения Suite-наборов в Suite-наборы. При выполнении Suite-набор запустит не только свои тесты, но и все тесты вложенных в него Suite-наборов. Вложенные Suite- 14.6. Подготовка и проведение тестов 261 наборы, в свою очередь, выполнят тесты вложенных в них Suite-наборов и т. д. Таким образом, большой набор тестов будет представлен деревом Suite-объектов. При выполнении в этом дереве корневого Suite-объекта будут выполнены все имеющиеся в нем Suite-объекты. Наборы можно вкладывать вручную или автоматически. Чтобы вложить их вручную, вам нужно либо переопределить метод nestedSuites в своих Suite классах, либо передать предназначенные для вложения Suite-объекты в конструк­ тор класса Suites, который для этих целей предоставляет ScalaTest. Для автома­ тического вложения имена пакетов передаются имеющемуся в ScalaTest средству Runner, которое определит Suite-объекты автоматически, вложит их ниже корне­ вого Suite и выполнит корневой Suite. Имеющееся в ScalaTest приложение Runner можно вызвать из командной строки или через такие средства сборки, как sbt, maven или ant. Проще всего вызвать Runner в командной строке через приложение theorg.scalatest.run. Оно ожидает указания полного имени тестового класса. Например, для запуска тестового класса, показан­ ного выше, в листинге 14.6, его нужно скомпилировать с помощью такой команды: $ scalac -cp scalatest-app.jar:scalaxml.-jar TVSetSpec.scala Затем его можно будет запустить, воспользовавшись командой: $ scala -cp scalatest-app.jar:scalaxml.-jar org.scalatest.run TVSetSpec Используя флаг –cp, вы помещаете JAR-файлы scalatest-app и scala-xml в путь класса. (При скачивании в имена JAR-файлов будут включены встроенные номера версий.) Следующий элемент, org.scalatest.run, является полным именем при­ ложения. Scala запустит это приложение и передаст ему все остальные элементы командной строки в качестве аргументов. Аргумент TVSetSpec указывает на вы­ полняемый набор. Результат показан на рис. 14.1. Рис. 14.1. Вывод, полученный при запуске org.scalatest.run 262 Глава 14 • Утверждения и тесты Резюме В этой главе мы показали примеры примешивания утверждений непосредствен­ но в рабочий код, а также способы их внешней записи в тестах. Вы увидели, что программисты, работающие на Scala, могут воспользоваться преимуществами таких популярных средств тестирования от сообщества Java, как JUnit и TestNG, и более новыми средствами, разработанными исключительно для Scala, например ScalaTest, ScalaCheck и specs2. И утверждения, встроенные в код, и внешние тесты могут помочь повысить качество ваших программных продуктов. Понимая важ­ ность этих технологий, мы представили их в данной главе, немного отклонившись от учебного материала по Scala. А в следующей главе вернемся к учебнику по языку и рассмотрим весьма полезный аспект Scala — сопоставление с образцом. 15 Case-классы и сопоставление с образцом В данной главе вводятся понятия case-классов и сопоставления с образцом (pattern matching) — парной конструкции, способствующей созданию обычных, неинкапсу­ лированных структур данных. Особенно полезны эти две конструкции при работе с древовидными рекурсивными данными. Если вам уже приходилось программировать на функциональном языке, то, воз­ можно, с сопоставлением с образцом вы уже знакомы. А вот понятие case-классов будет для вас новым. Case-классы в Scala позволяют применять сопоставление с образцом к объектам и не требуют большого объема шаблонного кода. По сути, чтобы приспособить отдельно взятый класс к сопоставлению с образцом, нужно лишь добавить к нему ключевое слово case. Эту главу мы начнем с простого примера case-классов и сопоставления с об­ разцом. Затем дадим обзор всех видов поддерживаемых образцов (или паттернов), рассмотрим роль запечатанных классов (sealed classes), тип Option и покажем не­ которые неочевидные места в языке, где используется сопоставление с образцом. В заключение покажем более объемный и приближенный к реальному использо­ ванию пример сопоставления с образцом. 15.1. Простой пример Прежде чем вникать во все правила и нюансы сопоставления с образцом, есть смысл рассмотреть простой пример, дающий общее представление. Допустим, нужно написать библиотеку, которая работает с арифметическими выражениями и, возможно, является частью разрабатываемого предметно-ориентированного языка. Первым шагом к решению этой задачи будет определение входных данных. Чтобы ничего не усложнять, сконцентрируемся на арифметических выражениях, состоящих из переменных, чисел и унарных и бинарных операций. Все это выра­ жается иерархией классов Scala, показанной в листинге 15.1. 264 Глава 15 • Case-классы и сопоставление с образцом Листинг 15.1. Определение case-классов abstract class Expr case class Var(name: String) extends Expr case class Number(num: Double) extends Expr case class UnOp(operator: String, arg: Expr) extends Expr case class BinOp(operator: String, left: Expr, right: Expr) extends Expr Эта иерархия включает абстрактный базовый класс Expr с четырьмя подклас­ сами, по одному для каждого вида рассматриваемых выражений1. Тела всех пяти классов пусты. Как уже упоминалось, при желании в Scala пустые тела классов в фигурные скобки можно не заключать, следовательно, определение class C ана­ логично определению class C {}. Case-классы Еще одна особенность объявлений в листинге 15.1 (см. выше), которая заслуживает внимания, — наличие у каждого подкласса модификатора case. Классы с таким модификатором называются case-классами. Использование этого модификатора заставляет компилятор Scala добавлять к вашему классу некоторые синтаксиче­ ские удобства. Первое синтаксическое удобство заключается в том, что к классу добавляется фабричный метод с именем данного класса. Это означает, к примеру, что для соз­ дания var-объекта можно применить код Var("x"), не используя несколько более длинный вариант new Var("x"): scala> val v = Var("x") v: Var = Var(x) Особенно полезны фабричные методы благодаря их вложенности. Теперь код не загроможден ключевыми словами new, поэтому структуру выражения можно воспринять с одного взгляда: scala> val op = BinOp("+", Number(1), v) op: BinOp = BinOp(+,Number(1.0),Var(x)) Второе синтаксическое удобство заключается в том, что все аргументы в списке параметров case-класса автоматически получают префикс val, то есть сохраняются в качестве полей: scala> v.name res0: String = x scala> op.left res1: Expr = Number(1.0) 1 Вместо абстрактного класса можно было бы смоделировать корень этой иерархии классов в виде трейта. Моделирование в виде абстрактного класса может оказаться чуть-чуть эффективнее. 15.1. Простой пример 265 Третье удобство состоит в том, что компилятор добавляет к вашему классу «естественную» реализацию методов toString, hashCode и equals. Они будут за­ ниматься подготовкой данных к выводу, их хешированием и сравнением всего дерева, состоящего из класса, и (рекурсивно) всех его аргументов. Поскольку метод == в Scala всегда передает полномочия методу equals, то это значит, что элементы case-классов всегда сравниваются структурно: scala> println(op) BinOp(+,Number(1.0),Var(x)) scala> op.right == Var("x") res3: Boolean = true И наконец, чтобы создать измененные копии, компилятор добавляет к вашему классу метод copy. Он пригодится для создания нового экземпляра класса, анало­ гичного другому экземпляру, за исключением того, что будет отличаться одним или двумя атрибутами. Метод работает за счет использования именованных па­ раметров и параметров по умолчанию (см. раздел 8.8). Применение именованных параметров позволяет указать требуемые изменения. А для любого неуказанного параметра используется значение из старого объекта. Посмотрим в качестве при­ мера, как можно создать операцию, похожую на op во всем, кроме того, что будет изменен параметр operator: scala> op.copy(operator = "-") res4: BinOp = BinOp(-,Number(1.0),Var(x)) Все эти соглашения в качестве небольшого бонуса придают вашей работе мас­ су удобств. Нужно просто указать модификатор case, и ваши классы и объекты приобретут гораздо более оснащенный вид. Они станут больше за счет создания дополнительных методов и неявного добавления поля для каждого параметра конструктора. Но самым большим преимуществом case-классов является то, что они поддерживают сопоставления с образцом. Сопоставление с образцом Предположим, нужно упростить арифметические выражения только что представ­ ленных видов. Существует множество возможных правил упрощения. В качестве иллюстрации подойдут три следующих правила: UnOp("-", UnOp("-", е)) => е // двойное отрицание BinOp("+", е, Number(0)) => е // прибавление нуля BinOp("*", е, Number(1)) => е // умножение на единицу Как показано в листинге 15.2, чтобы в Scala сформировать ядро функции упро­ щения с помощью сопоставления с образцом, эти правила можно взять практически в неизменном виде. Показанную в листинге функцию simplifyTop можно исполь­ зовать следующим образом: scala> simplifyTop(UnOp("-", UnOp("-", Var("x")))) res4: Expr = Var(x) 266 Глава 15 • Case-классы и сопоставление с образцом Листинг 15.2. Функция simplifyTop, выполняющая сопоставление с образцом def simplifyTop(expr: Expr): Expr = expr match { case UnOp("-", UnOp("-", e)) => e // двойное отрицание case BinOp("+", e, Number(0)) => e // прибавление нуля case BinOp("*", e, Number(1)) => e // умножение на единицу case _ => expr } Правая часть simplifyTop состоит из выражения match, которое соответствует switch в Java, но записывается после выражения выбора. Иными словами, оно вы­ глядит как: выбор match { альтернативы } вместо: switch (выбор) { альтернативы } Сопоставление с образцом включает последовательность альтернатив, каждый из которых начинается с ключевого слова case. Каждая альтернатива состоит из паттерна и одного или нескольких выражений, которые будут вычислены при соответствии паттерну. Обозначение стрелки => отделяет паттерн от выражений. Выражение match вычисляется проверкой соответствия каждого из паттернов в порядке их написания. Выбирается первый же соответствующий паттерн, а так­ же выбирается и выполняется та часть, которая следует за обозначением стрелки. Паттерн-константа вида + или 1 соответствует значениям, равным константе в случае применения метода ==. Паттерн-переменная вида e соответствует лю­ бому значению. Затем переменная ссылается на это же значение в правой части условия case. Обратите внимание: в данном примере первые три альтернативы вычисляются в e, то есть в переменную, связанную внутри соответствующего пат­ терна. Подстановочный паттерн (_) также соответствует любому значению, но без представления имени переменной для ссылки на это значение. Стоит отметить, как в листинге 15.2 (см. выше) выражение match заканчивается условием case, которое применяется при отсутствии соответствующих паттернов и не предполагает ни­ каких действий с выражением. Вместо этого получается просто выражение expr, в отношении которого и выполняется сопоставление с образцом. Паттерн-конструктор выглядит как UnOp("-", e) . Он соответствует всем значениям типа UnOp, первый аргумент которых соответствует "-", а второй — e. Обратите внимание: аргументы конструктора сами являются паттернами. Это по­ зволяет составлять многоуровневые паттерны, используя краткую форму записи. Примером может послужить следующий паттерн: UnOp("-", UnOp("-", e)) Представьте попытку реализовать такую же функциональную возможность с помощью шаблона проектирования visitor!1 Практически так же трудно предста­ 1 Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Паттерны объектно-ориентированного проектирования. — СПб.: Питер, 2020. 15.2. Разновидности паттернов 267 вить реализацию такой же функциональной возможности в виде длинной последо­ вательности инструкций, проверок соответствия типам и явного приведения типов. Сравнение match со switch Выражения match могут быть представлены в качестве общих случаев switch выражений в стиле Java. В Java switch-выражение может быть вполне естественно представлено в виде match-выражения, где каждый паттерн является константой, а последний паттерн может быть подстановочным (который представлен в switchвыражении вариантом, используемым при отсутствии других соответствий). И тем не менее следует учитывать три различия. Во-первых, match является выражением языка Scala, то есть всегда вычисляется в значение. Во-вторых, приме­ няемые в Scala выражения альтернатив никогда не «выпадают» в следующий вари­ ант. В-третьих, если не найдено соответствие ни одному из паттернов, то выдается исключение MatchError. Следовательно, вам придется всегда обеспечивать охват всех возможных вариантов, даже если это будет означать вариант по умолчанию, в котором не делается ничего. Пример показан в листинге 15.3. Второй необходим, поскольку без него выра­ жение match выдаст исключение MatchError для любого expr-аргумента, не явля­ ющегося BinOp. В данном примере для этого второго варианта не указан никакой код, поэтому при его срабатывании ничего не произойдет. Результатом в любом случае будет unit-значение (), которое также будет результатом вычисления всего выражения match. Листинг 15.3. Сопоставление с образцом с пустым вариантом по умолчанию expr match { case BinOp(op, left, right) => println(s"$expr является бинарной операцией") case _ => } 15.2. Разновидности паттернов В предыдущем примере мы кратко описали некоторые разновидности паттернов. А теперь потратим немного времени на более подробное изучение каждого из них. Синтаксис паттернов довольно прост, поэтому не стоит особо переживать из-за него. Все паттерны выглядят точно так же, как и соответствующие им вы­ ражения. Например, если взять иерархию из листинга 15.1 (см. выше), то паттерн Var(x) соответствует любому выражению, содержащему переменную, с привяз­ кой x к имени переменной. Будучи использованным в качестве выражения, Var(x) с точно таким же синтаксисом воссоздает эквивалентный объект, предполагая, что идентификатор x уже привязан к имени переменной. В синтаксисе паттернов все прозрачно, поэтому главное, на что следует обратить внимание, — это какого вида паттерны можно применять. 268 Глава 15 • Case-классы и сопоставление с образцом Подстановочные паттерны Подстановочный паттерн (_) соответствует абсолютно любому объекту. Вы уже видели, как он используется в качестве общего паттерна, выявляющего все остав­ шиеся альтернативы: expr match { case BinOp(op, left, right) => println(s"$expr является бинарной операцией") case _ => // обработка общего варианта } Кроме того, подстановочные паттерны могут использоваться для игнорирования тех частей объекта, которые не представляют для вас интереса. Например, в пре­ дыдущем примере нас не интересует, что представляют собой элементы бинарной операции, в нем лишь проверяется, является ли она бинарной. Поэтому, как по­ казано в листинге 15.4, для элементов BinOp в коде также могут использоваться подстановочные паттерны. Листинг 15.4. Сопоставление с образцом с подстановочными паттернами expr match { case BinOp(_, _, _) => println(s"$expr является бинарной операцией") case _ => println("Это что-то другое") } Паттерны-константы Паттерн-константа соответствует только самому себе. В качестве константы может использоваться любой литерал. Например, паттернами-константами являются 5, true и "hello". В качестве константы может использоваться и любой val- или объект-одиночка. Так, объект-одиночка Nil является паттерном, соответству­ ющим только пустому списку. Некоторые примеры паттернов-констант показаны в листинге 15.5. Листинг 15.5. Сопоставление с образцом с использованием паттернов-констант def describe(x: Any) = x match { case 5 => "пять" case true => "правда" case "hello" => "привет!" case Nil => "пустой список" case _ => "что-то другое" } А вот как сопоставление с образцом, показанное в данном листинге, выглядит в действии: scala> describe(5) res6: String = пять 15.2. Разновидности паттернов 269 scala> describe(true) res7: String = правда scala> describe("hello") res8: String = привет! scala> describe(Nil) res9: String = пустой список scala> describe(List(1,2,3)) res10: String = что-то другое Патерны-переменные Паттерн-переменная соответствует любому объекту точно так же, как подстано­ вочный паттерн, но в отличие от него Scala привязывает переменную к объекту. Затем с помощью этой переменной можно в дальнейшем воздействовать на объект. Например, в листинге 15.6 показано сопоставление с образцом, имеющее специальный вариант для нуля и общий вариант для всех остальных значений. В общем варианте используется паттерн-переменная, и потому у него есть имя для переменной, независимо от того, что это на самом деле. Листинг 15.6. Сопоставление с образцом с использованием паттерна-переменной expr match { case 0 => "нуль" case somethingElse => "не нуль: " + somethingElse } Переменная или константа? У паттернов-констант могут быть символические имена. Вы уже это видели, когда в качестве образца использовался Nil. А вот похожий пример, где в сопоставлении с образцом задействуются константы E (2,71828...) и Pi (3,14159...): scala> import math.{E, Pi} import math.{E, Pi} scala> E match { case Pi => "математический казус? Pi = " + Pi case _ => "OK" } res11: String = OK Как и ожидалось, значение E не равно значению Pi, поэтому вариант «матема­ тический казус» не выбирается. А как компилятор Scala распознает, что Pi — это константа, импортированная из scala.math, а не переменная, обозначающая само значение селектора? Во из­ бежание путаницы в Scala действует простое лексическое правило: обычное имя, 270 Глава 15 • Case-классы и сопоставление с образцом начинающееся с буквы в нижнем регистре, считается переменной паттерна, а все другие ссылки считаются константами. Чтобы заметить разницу, создайте для pi псевдоним с первой буквой, указанной в нижнем регистре, и попробуйте в работе следующий код: scala> val pi = math.Pi pi: Double = 3.141592653589793 scala> E match { case pi => "математический казус? Pi = " + pi } res12: String = математический казус? Pi = 2.718281828459045 Здесь компилятор даже не позволит вам добавить вариант по умолчанию. Поскольку pi — паттерн-переменная, то будет соответствовать всем вводимым данным, поэтому до следующих вариантов дело просто не дойдет: scala> E match { case pi => "математический казус? Pi = " + pi case _ => "OK" } case pi => "математический казус? Pi = " + pi ^ On line 2: warning: patterns after a variable pattern cannot match (SLS 8.1.1) Но при необходимости для паттерна-константы можно задействовать имя, начинающееся с буквы в нижнем регистре; для этого придется воспользоваться одним из двух приемов. Если константа является полем какого-нибудь объекта, то перед ней можно поставить префикс-классификатор. Например, pi — паттернпеременная, а this.pi или obj.pi — константы, несмотря на то что их имена начи­ наются с букв в нижнем регистре. Если это не сработает (поскольку, скажем, pi — локальная переменная), то как вариант можно будет заключить имя переменной в обратные кавычки. Например, `pi` будет опять восприниматься как константа, а не как переменная: scala> E match { case `pi` => "математический казус? Pi = " + pi case _ => "OK" } res14: String = OK Как вы, наверное, заметили, использование для идентификаторов в Scala син­ таксиса с обратными кавычками во избежание в коде необычных обстоятельств преследует две цели. Здесь показано, что этот синтаксис может применяться для рассмотрения идентификатора с именем, начинающимся с буквы в нижнем реги­ стре, в качестве константы при сопоставлении с образцом. Ранее, в разделе 6.10, было показано, что этот синтаксис может использоваться также для трактовки ключевого слова в качестве обычного идентификатора. Например, в выражении writingThread.`yield`() слово yield трактуется как идентификатор, а не ключевое слово. 15.2. Разновидности паттернов 271 Паттерны-конструкторы Реальная эффективность сопоставления с образцом проявляется именно в кон­ структорах. Паттерн-конструктор выглядит как BinOp("+", e, Number(0)). Он со­ стоит из имени (BinOp), после которого в круглых скобках стоят несколько об­ разцов: "+", e и Number(0). При условии, что имя обозначает case-класс, такой паттерн показывает следующее: сначала проверяется принадлежность элемента к названному case-классу, а затем соответствие параметров конструктора объекта предоставленным дополнительным паттернам. Эти дополнительные паттерны означают, что в паттернах Scala поддержива­ ются глубкие сопоставления (deep matches). Такой паттерн проверяет не только предоставленный объект верхнего уровня, но и его содержимое на соответствие следующим паттернам. Дополнительные паттерны сами по себе могут быть пат­ тернами-конструкторами, поэтому их можно использовать для проверки объекта произвольной глубины. Например, паттерн, показанный в листинге 15.7, проверяет, что объект верхнего уровня относится к типу BinOp, третьим параметром его кон­ структора является число Number и значение поля этого числа — 0. Весь паттерн умещается в одну строку кода, хотя выполняет проверку на глубину в три уровня. Листинг 15.7. Сопоставление с образцом с использованием паттерна-конструктора expr match { case BinOp("+", e, Number(0)) => println("глубокое соответствие") case _ => } Паттерны-последовательности По аналогии с сопоставлением с case-классами можно сопоставлять с такими ти­ пами последовательностей, как List или Array. Допустимо воспользоваться тем же синтаксисом, но теперь в паттерне вы можете указать любое количество элементов. В листинге 15.8 показан паттерн для проверки того факта, что трехэлементный список начинается с нуля. Листинг 15.8. Паттерн-последовательность фиксированной длины expr match { case List(0, _, _) => println("соответствие найдено") case _ => } Если нужно сопоставить с последовательностью, не указывая ее длину, то в качестве последнего элемента паттерна-последовательности можно указать образец _*. Он имеет весьма забавный вид и соответствует любому количеству элементов внутри последовательности, включая нуль элементов. В листинге 15.9 показан пример, соответствующий любому списку, который начинается с нуля, независимо от длины этого списка. 272 Глава 15 • Case-классы и сопоставление с образцом Листинг 15.9. Паттерн-последовательность произвольной длины expr match { case List(0, _*) => println("соответствие найдено ") case _ => } Паттерны-кортежи Можно выполнять и сопоставление с кортежами. Паттерн вида (a, b, c) соответ­ ствует произвольному трехэлементному кортежу. Пример показан в листинге 15.10. Листинг 15.10. Сопоставление с образцом с использованием паттерна-кортежа def tupleDemo(expr: Any) = expr match { case (a, b, c) => println("соответствует " + a + b + c) case _ => } Если загрузить показанный в листинге 15.10 метод tupleDemo в интерпретатор и передать ему кортеж из трех элементов, то получится следующая картина: scala> tupleDemo(("a ", 3, "-tuple")) соответствует a 3-tuple Типизированные паттерны Типизированный паттерн (typed pattern) можно использовать в качестве удобного заменителя для проверок типов и приведения типов. Пример показан в листин­ ге 15.11. Листинг 15.11. Сопоставление с образцом с использованием типизированных паттернов def generalSize(x: Any) = x match { case s: String => s.length case m: Map[_, _] => m.size case _ => -1 } А вот несколько примеров использования generalSize в интерпретаторе Scala: scala> generalSize("abc") res16: Int = 3 scala> generalSize(Map(1 -> 'a', 2 -> 'b')) res17: Int = 2 scala> generalSize(math.Pi) res18: Int = -1 Метод generalSize возвращает размер или длину объектов различных типов. Типом его аргумента является Any, поэтому им может быть любое значение. Если 15.2. Разновидности паттернов 273 в качестве типа аргумента выступает String, то метод возвращает длину строки. Образец s: String является типизированным паттерном и соответствует каждому (ненулевому) экземпляру класса String. Затем на эту строку ссылается паттернпеременная s. Заметьте: даже притом, что s и x ссылаются на одно и то же значение, типом x является Any, а типом s — String. Поэтому в альтернативном выражении, соответ­ ствующем паттерну, можно воспользоваться кодом s.length, но нельзя — кодом x.length, поскольку в типе Any отсутствует член length. Эквивалентный, но более многословный способ достичь такого же результата сопоставления с типизированным образцом — использовать проверку типа с его последующим приведением. В Scala для этого применяется не такой синтаксис, как в Java. К примеру, чтобы проверить, относится ли выражение expr к типу String, используется такой код: expr.isInstanceOf[String] Для приведения того же выражения к типу String используется код: expr.asInstanceOf[String] Применяя проверку и приведение типа, можно переписать первый вариант предыдущего match-выражения, получив код, показанный в листинге 15.12. Листинг 15.12. Использование isInstanceOf и asInstanceOf (плохой стиль) if (x.isInstanceOf[String]) { val s = x.asInstanceOf[String] s.length } else ... Операторы isInstanceOf и asInstanceOf считаются предопределенными мето­ дами класса Any, получающими параметр типа в квадратных скобках. Фактически x.asInstanceOf[String] — частный случай вызова метода с явно заданным пара­ метром типа String. Как вы уже заметили, написание проверок и приведений типов в Scala страдает излишним многословием. Сделано это намеренно, поскольку подобная практика не приветствуется. Как правило, лучше воспользоваться сопоставлением с типи­ зированным образцом. В частности, подобный подход будет оправдан, если нужно выполнить две операции: проверку типа и его приведение, так как обе они будут сведены к единственному сопоставлению. Второй вариант match-выражения в листинге 15.11 содержит типизированный паттерн m: Map[_, _]. Он соответствует любому значению, являющемуся отображе­ нием каких-либо произвольных типов ключа и значения, и позволяет m ссылаться на это значение. Поэтому m.size имеет правильный тип и возвращает размер ото­ бражения. Знаки подчеркивания в типизированном паттерне1 подобны таким же знакам в подстановочных паттернах. Вместо них можно указывать переменные типа с символами в нижнем регистре. 1 В типизированном паттерне m: Map[_, _], часть "Map[_, _]" называется паттерном типа. 274 Глава 15 • Case-классы и сопоставление с образцом Затирание типов А можно ли также проводить проверку на отображение с конкретными типами элементов? Это пригодилось бы, скажем, для проверки того, является ли заданное значение отображением типа Int на тип Int. Попробуем: scala> def isIntIntMap(x: Any) = x match { case m: Map[Int, Int] => true case _ => false } case m: Map[Int, Int] => true ^ On line 2: warning: non-variable type argument Int in type pattern scala. collection.immutable.Map[Int,Int] (the underlying of Map[Int,Int]) is unchecked since it is eliminated by erasure В Scala точно так же, как и в Java, используется модель затирания обобщенных типов. Это значит, в ходе выполнения программы никакая информация об аргу­ ментах типов не сохраняется. Следовательно, способов определить в ходе выпол­ нения программы, создавался ли заданный Map-объект с двумя Int-аргументами, а не с аргументами других типов, не существует. Система может лишь определить, что значение является отображением (Map) неких произвольных параметров типа. Убедиться в таком поведении можно, применив isIntIntMap к различным экзем­ плярам класса Map: scala> isIntIntMap(Map(1 -> 1)) res19: Boolean = true scala> isIntIntMap(Map("abc" -> "abc")) res20: Boolean = true Первое применение возвращает true, что выглядит вполне корректно, но второе тоже возвращает true, и это может оказаться сюрпризом. Чтобы оповестить вас о возможном непонятном поведении программы в ходе ее выполнения, компиля­ тор выдает предупреждение о том, что не контролирует это поведение, похожее на показанные ранее. Единственное исключение из правила затирания — массивы, поскольку в Java, а также в Scala они обрабатываются особым образом. Тип элемента массива со­ храняется вместе со значением массива, поэтому к нему можно применить сопо­ ставление с образцом. Пример выглядит так: scala> def isStringArray(x: Any) = x match { case a: Array[String] => "yes" case _ => "no" } isStringArray: (x: Any)String scala> val as = Array("abc") as: Array[String] = Array(abc) 15.3. Ограждение образца 275 scala> isStringArray(as) res21: String = yes scala> val ai = Array(1, 2, 3) ai: Array[Int] = Array(1, 2, 3) scala> isStringArray(ai) res22: String = no Привязка переменной Кроме использования отдельно взятого паттерна-переменной можно также до­ бавить переменную к любому другому паттерну. Нужно просто указать имя пере­ менной, знак «собачка» (@), а затем паттерн. Это даст вам паттерн с привязанной переменной, то есть паттерн для выполнения обычного сопоставления с образцом с возможностью в случае совпадения присвоить переменной соответствующий объект, как и при использовании обычного паттерна-переменной. В качестве примера в листинге 15.13 показано сопоставление с образцом — по­ иск операции получения абсолютного значения, применяемой в строке дважды. Такое выражение можно упростить, однократно получив абсолютное значение. Листинг 15.13. Паттерн с привязкой переменной (посредством использования знака @) expr match { case UnOp("abs", e @ UnOp("abs", _)) => e case _ => } Пример, показанный в данном листинге, включает паттерн с привязкой перемен­ ной, где в качестве переменной выступает e, а в качестве паттерна — UnOp("abs", _). Если будет найдено соответствие всему паттерну, то часть, которая соответствует UnOp("abs", _), станет доступна как значение переменной e. Результатом вариан­ та будет просто e, поскольку у e такое же значение, что и у expr, но с меньшим на единицу количеством операций получения абсолютного значения. 15.3. Ограждение образца Иногда синтаксическое сопоставление с образцом является недостаточно точным. Предположим, перед вами стоит задача сформулировать правило упрощения, за­ меняющее выражение сложения с двумя одинаковыми операндами, такое как e + e, умножением на 2, например e * 2. На языке деревьев Expr выражение вида: BinOp("+", Var("x"), Var("x")) этим правилом будет превращено в следующее: BinOp("*", Var("x"), Number(2)) 276 Глава 15 • Case-классы и сопоставление с образцом Правило можно попробовать выразить следующим образом: scala> def simplifyAdd(e: Expr) = e match { case BinOp("+", x, x) => BinOp("*", x, Number(2)) case _ => e } case BinOp("+", x, x) => BinOp("*", x, Number(2)) ^ On line 2: error: x is already defined as value x Попытка будет неудачной, поскольку в Scala паттерны должны быть линейными: паттерн-переменная может появляться в образце только один раз. Но, как показано в листинге 15.14, соответствие можно переформулировать с помощью ограничителя паттернов (pattern guard). Листинг 15.14. Сопоставление с образцом с применением ограждения паттернов scala> def simplifyAdd(e: Expr) = e match { case BinOp("+", x, y) if x == y => BinOp("*", x, Number(2)) case _ => e } simplifyAdd: (e: Expr)Expr Ограждение паттерна указывается после образца и начинается с ключевого слова if . В качестве ограждения может использоваться произвольное булево выражение, которое обычно ссылается на переменные в образце. При наличии ограждения паттернов соответствие считается найденным, только если огражде­ ние вычисляется в true. Таким образом, первый вариант показанного ранее кода соответствует только бинарным операциям, имеющим два одинаковых операнда. А вот как выглядят некоторые другие огражденные паттерны: // соответствует только положительным целым числам case n: Int if 0 < n => ... // соответствует только строкам, начинающимся с буквы 'a' case s: String if s(0) == 'a' => ... 15.4. Наложение паттернов Паттерны применяются в порядке их указания. Версия метода simplify, показан­ ная в листинге 15.15, представляет собой пример, в котором порядок следования вариантов имеет значение. Листинг 15.15. Выражение сопоставления, в котором порядок следования вариантов имеет значение def simplifyAll(expr: Expr): Expr = expr match { case UnOp("-", UnOp("-", e)) => simplifyAll(e) // '-' является своей собственной обратной величиной case BinOp("+", e, Number(0)) => simplifyAll(e) // '0' — нейтральный элемент для '+' 15.5. Запечатанные классы 277 case BinOp("*", e, Number(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, добавив два дополнительных варианта для обычных унарных и би­ нарных выражений (четвертый и пятый варианты в листинге). В четвертом варианте используется паттерн 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 UnOp("-", UnOp("-", e)) => e ^ On line 3: warning: unreachable code simplifyBad: (expr: Expr)Expr 15.5. Запечатанные классы При написании сопоставления с образцом нужно удостовериться в том, что охва­ чены все возможные варианты. Иногда это можно сделать, добавив в конец match вариант по умолчанию, но данный способ применим, только когда есть вполне определенное поведение по умолчанию. А что делать, если его нет? Как узнать, что охвачены все варианты и нет опасности упустить что-либо? 278 Глава 15 • Case-классы и сопоставление с образцом Чтобы определить пропущенные в выражении match комбинации паттернов, можно обратиться за помощью к компилятору Scala. Для этого компилятор должен иметь возможность сообщить обо всех потенциальных вариантах. По сути, в Scala это сделать нереально, поскольку классы могут быть определены в любое время и в произвольных блоках компиляции. Например, ничто не помешает вам добавить к иерархии класса Expr пятый case-класс не в том блоке компиляции, в котором определены четыре других case-класса, а в другом. Альтернативой этому может стать превращение суперкласса ваших case-классов в запечатанный класс. У такого запечатанного класса не может быть никаких до­ полнительных подклассов, кроме тех, которые определены в том же самом файле. Особую пользу из этого можно извлечь при сопоставлении с образцом, поскольку запечатанность класса будет означать, что беспокоиться придется только по по­ воду тех подклассов, о которых вам уже известно. Более того, будет улучшена поддержка со стороны компилятора. При сопоставлении с образцом case-классам, являющимся наследниками запечатанного класса, компилятор в предупреждении отметит пропущенные комбинации паттернов. Если создается иерархия классов, предназначенная для сопоставления с об­ разцом, то нужно предусмотреть ее запечатанность. Чтобы это сделать, просто поставьте перед классом на вершине иерархии ключевое слово sealed. Програм­ мисты, использующие вашу иерархию классов, при сопоставлении с образцом будут чувствовать себя уверенно. Таким образом, ключевое слово sealed зачастую выступает лицензией на сопоставление с образцом. Пример, в котором Expr пре­ вращается в запечатанный класс, показан в листинге 15.16. Листинг 15.16. Запечатанная иерархия case-классов sealed abstract class Expr case class Var(name: String) extends Expr case class Number(num: 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 Number(_) => "число" case Var(_) => "переменная" } В результате будет получено следующее предупреждение компилятора: warning: match is not exhaustive! missing combination UnOp missing combination BinOp Оно сообщает о существовании риска генерации вашим кодом исключения MatchError, поскольку некоторые возможные паттерны (UnOp, BinOp) не обраба­ тываются. Предупреждение указывает на потенциальный источник сбоя в ходе 15.6. Тип Option 279 выполнения программы, поэтому обычно хорошо помогает при корректировке ее кода. Но порой можно столкнуться с ситуацией, в которой компилятор при выда­ че предупреждений проявляет излишнюю дотошность. Например, из контекста может быть известно, что показанный ранее метод describe будет применяться только к выражениям типа Number или Var, следовательно, исключение MatchError не станет генерироваться. Чтобы избавиться от предупреждения, к методу можно добавить третий вариант по умолчанию: def describe(e: Expr): String = e match { case Number(_) => "число" case Var(_) => "переменная" case _ => throw new RuntimeException // Не должно произойти } Решение вполне работоспособное, однако не идеальное. Вряд ли вас обрадует принуждение добавить код, который никогда не будет выполнен (по вашему мне­ нию), лишь для того, чтобы успокоить компилятор. Более экономной альтернативой станет добавление к селектору выражения со­ поставления с образцом аннотации @unchecked. Делается это следующим образом: def describe(e: Expr): String = (e: @unchecked) match { case Number(_) => "число" case Var(_) => "переменная" } Аннотации рассматриваются в главе 27. В общем, аннотации можно добавлять к выражению точно так же, как это делается при добавлении типа: нужно после выражения поставить двоеточие, знак «собачка» и указать название аннотации. Например, в данном случае к переменной e добавляется аннотация @unchecked, для чего используется код e: @unchecked. Аннотация @unchecked имеет особое значение для сопоставления с образцом. Если выражение селектора поиска со­ держит данную аннотацию, то исчерпывающая проверка последующих паттернов будет подавлена. 15.6. Тип Option Для необязательных значений в Scala имеется стандартный тип Option. Значение такого типа может быть двух видов: это либо Some(x), где x — реальное значение, либо None-объект, который представляет отсутствующее значение. Необязательные значения выдаются некоторыми стандартными операциями над коллекциями Scala. Например, метод get из Scala-класса Map производит Some(значение), если найдено значение, соответствующее заданному ключу, или None, если заданный ключ не определен в Map-объекте. Пример выглядит так: scala> val capitals = Map("France" -> "Paris", "Japan" -> "Tokyo") 280 Глава 15 • Case-классы и сопоставление с образцом capitals: scala.collection.immutable.Map[String,String] = Map(France -> Paris, Japan -> Tokyo) scala> capitals get "France" res23: Option[String] = Some(Paris) scala> capitals get "North Pole" res24: Option[String] = None Самый распространенный способ разобрать необязательные значения — ис­ пользовать сопоставление с образцом, например: scala> def show(x: Option[String]) = x match { case Some(s) => s case None => "?" } show: (x: Option[String])String scala> show(capitals get "Japan") res25: String = Tokyo scala> show(capitals get "France") res26: String = Paris scala> show(capitals get "North Pole") res27: String = ? Тип Option применяется в программах на языке Scala довольно часто. Его ис­ пользование можно сравнить с доминирующей в Java идиомой null, показывающей отсутствие значения. Например, метод get из java.util.HashMap возвращает либо значение, сохраненное в HashMap, либо null, если значение не было найдено. В Java такой подход работает, но, применяя его, легко допустить ошибку, поскольку на практике довольно трудно отследить, каким переменным в программе разрешено иметь значение null. В случае, когда переменной разрешено иметь значение null, вы должны вспом­ нить о ее проверке на наличие этого значения при каждом использовании. Если забыть выполнить эту проверку, то появится вероятность генерации в ходе выпол­ нения программы исключений NullPointerException. Подобные исключения могут генерероваться довольно редко, поэтому с выявлением ошибки при тестировании могут возникнуть затруднения. В Scala такой подход вообще не сработает, посколь­ ку этот язык позволяет сохранять типы значений в хеш-отображениях, а null не яв­ ляется допустимым элементом для типов значений. Например, HashMap[Int, Int] не может вернуть null, чтобы обозначить отсутствие элемента. Вместо этого в Scala для указания значения, которое может отсутствовать, применяется тип Option. По сравнению с подходом, используемым в Java, подход Scala к работе с необязательными значениями имеет ряд преимуществ. Во-первых, тем, кто читает код, намного понятнее, что переменная, типом которой является Option[String], — необязательная переменная String, а не переменная типа String, которая иногда может иметь значение null. Во-вторых, и это важнее всего, рассмо­ 15.7. Паттерны везде 281 тренные ранее ошибки программирования, связанные с использованием перемен­ ной, которая может иметь значение null, без предварительной проверки ее на null превращаются в Scala в ошибку типа. Если переменная имеет тип Option[String], то при попытке ее использования в качестве строки ваша программа на Scala не пройдет компиляцию. 15.7. Паттерны везде Паттерны можно использовать не только в отдельно взятых match-выражениях, но и во многих других местах программы на языке Scala. Рассмотрим несколько подобных мест применения паттернов. Паттерны в определениях переменных При определении val - или var -переменной вместо простых идентификаторов можно использовать паттерны. Например, можно, как показано в листинге 15.17, разобрать кортеж и присвоить каждую его часть собственной переменной. Листинг 15.17. Определение нескольких переменных с помощью одного присваивания scala> val myTuple = (123, "abc") myTuple: (Int, String) = (123,abc) scala> val (number, string) = myTuple number: Int = 123 string: String = abc Особенно полезной эта конструкция может быть при работе с case-классами. Если точно известен case-класс, с которым ведется работа, то вы можете разобрать его с помощью паттерна. Пример выглядит следующим образом: scala> val exp = new BinOp("*", Number(5), Number(1)) exp: BinOp = BinOp(*,Number(5.0),Number(1.0)) scala> val BinOp(op, left, right) = exp op: String = * left: Expr = Number(5.0) right: Expr = Number(1.0) Последовательности вариантов в качестве частично примененных функций Последовательность вариантов (то есть альтернатив), заключенную в фигурные скобки, можно задействовать везде, где может использоваться функциональный литерал. По сути, последовательность вариантов и есть функциональный литерал, 282 Глава 15 • Case-классы и сопоставление с образцом только более универсальный. Вместо единственной точки входа и списка параме­ тров последовательность вариантов имеет несколько точек входа, каждой из кото­ рых присущ собственный список параметров. Каждый вариант является точкой входа в функцию, а параметры указываются с помощью паттерна. Тело каждой точки входа — правосторонняя часть варианта. Простой пример выглядит следующим образом: val withDefault: Option[Int] => Int = { case Some(x) => x case None => 0 } В теле этой функции имеются два варианта. Первый соответствует Some и воз­ вращает число, находящееся внутри Some. Второй соответствует None и возвращает стандартное значение нуль. А вот как используется данная функция: scala> withDefault(Some(10)) res28: Int = 10 scala> withDefault(None) res29: Int = 0 Такая возможность особенно полезна для библиотеки акторов Akka, поскольку позволяет определить ее метод receive в виде серии вариантов: var sum = 0 def 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 } При компиляции этого кода компилятор вполне резонно выведет предупре­ ждение о том, что сопоставление с образцом не охватывает все возможные ва­ рианты: <console>:17: warning: match is not exhaustive! missing combination Nil 15.7. Паттерны везде 283 Функция справится со своей задачей, если ей передать список, состоящий из трех элементов, но не станет работать при передаче пустого списка: scala> second(List(5, 6, 7)) res24: Int = 6 scala> second(List()) scala.MatchError: List() at $anonfun$1.apply(<console>:17) at $anonfun$1.apply(<console>:17) Если нужно проверить, определена ли частично примененная функция, то сна­ чала следует сообщить компилятору: вы знаете, что работаете с частично приме­ ненными функциями. Тип List[Int] => Int включает все функции, получающие из целочисленных списков целочисленные значения независимо от того, частично они применяются или нет. Тип, который включает только частично примененные функции, которые получают из целочисленных списков целочисленные значения, записывается в виде PartialFunction[List[Int],Int]. Ниже представлен еще один вариант функции second, определенной с типом частично примененной функции: val second: PartialFunction[List[Int],Int] = { case x :: y :: _ => y } У частично примененных функций есть метод isDefinedAt, который может использоваться для тестирования того, определена ли функция в отношении конкретного значения. В данном случае функция определена для любого списка, состоящего по крайней мере из двух элементов: scala> second.isDefinedAt(List(5,6,7)) res30: Boolean = true scala> second.isDefinedAt(List()) res31: Boolean = 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 } } 284 Глава 15 • Case-классы и сопоставление с образцом Это преобразование осуществляется в том случае, когда в качестве объявляемо­ го типа функционального литерала выступает PartialFunction. Если объявляемый тип — просто Function1 или не указан, функциональный литерал вместо этого пре­ образуется в полноценную функцию. Вообще-то полноценными функциями нужно пробовать пользоваться везде, где только можно, поскольку использование частично примененных функций допуска­ ет возникновение ошибок времени выполнения, устранить которые компилятор вам не может помочь. Но иногда частично примененные функции приносят реальную пользу. Вам следует позаботиться о том, чтобы этим функциям не было предостав­ лено необрабатываемое значение. Как вариант, вы можете задействовать фреймворк, который допускает использование частично примененных функций и поэтому все­ гда перед вызовом функции выполняет проверку функцией isDefinedAt. Последнее проиллюстрировано приведенным ранее примером метода receive, где результатом выступает частично примененная функция с определением, данным в точности для тех сообщений, которые нужно обработать вызывающему коду. Паттерны в выражениях for Паттерны, как показано в листинге 15.18, можно использовать также в выраже­ ниях for. Это выражение извлекает все пары «ключ — значение» из отображения capitals (столицы). Каждая пара соответствует паттерну (country, city) (страна, город), который определяет две переменные: country и city. Листинг 15.18. Выражение for с паттерном-кортежем scala> for ((country, city) <- capitals) println("Столицей " + country + " является " + city) Столицей France является Paris Столицей Japan является Tokyo Паттерн пар, показанный в данном листинге, интересен, поскольку сопоставле­ ние с ним никогда не даст сбой. Конечно, capitals выдает последовательность пар, следовательно, можно быть уверенными, что каждая сгенерированная пара может соответствовать паттерну пар. Но с равной долей вероятности возможно, что паттерн не будет соответствовать сгенерированному значению. Именно такой случай показан в листинге 15.19. Листинг 15.19. Отбор элементов списка, соответствующих паттерну scala> val results = List(Some("apple"), None, Some("orange")) results: List[Option[String]] = List(Some(apple), None, Some(orange)) scala> for (Some(fruit) <- results) println(fruit) apple orange 15.8. Большой пример 285 В этом примере показано, что сгенерированные значения, не соответствующие паттерну, отбрасываются. Так, второй элемент None в получившемся списке не со­ ответствует паттерну Some(fruit), поэтому отсутствует в выводимой информации. 15.8. Большой пример После изучения различных форм паттернов может быть интересно посмотреть на их применение в более существенном примере. Предлагаемая задача заклю­ чается в написании класса, форматирующего выражения, который выводит арифметическое выражение в двумерной разметке. Такое выражение деления, как x / (x + 1), должно быть выведено вертикально — с числителем, показанным над знаменателем: x ----x + 1 В качестве еще одного примера ниже в двумерной разметке показано выраже­ ние ((a / (b * c) + 1 / n) / 3): a 1 ----- + b * c n --------3 Исходя из этих примеров, можно прийти к выводу, что манипулированием разметкой должен заняться класс — назовем его ExprFormatter, поэтому имеет смысл задействовать библиотеку разметки, разработанную в главе 10. Мы также используем семейство case-классов Expr, ранее уже встречавшееся в данной гла­ ве, и поместим в именованные пакеты как библиотеку разметки из главы 10, так и средство форматирования выражений. Полный код этого примера будет показан в листингах 15.20 и 15.21. Сначала полезно будет сосредоточиться на горизонтальной разметке. Структу­ рированное выражение: BinOp("+", BinOp("*", BinOp("+", Var("x"), Var("y")), Var("z")), Number(1)) должно привести к выводу (x + y) * z + 1. Обратите внимание на обязательность круглых скобок вокруг выражения x + y и их необязательность вокруг выраже­ ния (x + y) * z. Чтобы разметка получилась максимально разборчивой, следует стремиться к отказу от избыточных круглых скобок и обеспечить наличие всех обязательных. 286 Глава 15 • Case-классы и сопоставление с образцом Чтобы узнать, куда ставить круглые скобки, код должен быть в курсе относи­ тельной приоритетности каждого оператора; как следствие, неплохо было бы сна­ чала отрегулировать именно этот вопрос. Относительную приоритетность можно выразить непосредственно в виде литерала отображения следующей формы: Map( "|" -> 0, "||" -> 0, "&" -> 1, "&&" -> 1, ... ) Конечно, вам потребуется предварительно выполнить определенный объем вычислительной работы для назначения приоритетов. Удобнее будет просто опре­ делить группы операторов с нарастающим уровнем приоритета, а затем, исходя из этого, вычислить приоритет каждого оператора. Соответствующий код показан в листинге 15.20. Листинг 15.20. Верхняя половина средства форматирования выражений package org.stairwaybook.expr import org.stairwaybook.layout.Element.elem sealed abstract class Expr case class Var(name: String) extends Expr case class Number(num: Double) extends Expr case class UnOp(operator: String, arg: Expr) extends Expr case class BinOp(operator: String, left: Expr, right: Expr) extends Expr class ExprFormatter { // Содержит операторы в группах с нарастающей степенью приоритетности private val opGroups = Array( Set("|", "||"), Set("&", "&&"), Set("^"), Set("==", "!="), Set("<", "<=", ">", ">="), Set("+", "-"), Set("*", "%") ) // Отображение операторов на их степень приоритетности private val precedence = { val assocs = for { i <- 0 until opGroups.length op <- opGroups(i) } yield op -> i assocs.toMap } 15.8. Большой пример 287 private val unaryPrecedence = opGroups.length private val fractionPrecedence = -1 // продолжение в листинге 15.21... Переменная precedence — отображение операторов на уровень их приоритета, представленный целыми числами, начинающимися с нуля. Приоритет вычис­ ляется с использованием выражения for с двумя генераторами. Первый выдает каждый индекс i массива opGroups, второй — каждый оператор op, находящийся в opGroups(i). Для каждого такого оператора выражение for выдает привязку оператора op к его индексу i. В результате приоритетность оператора берется из его относительной позиции в массиве. Привязки записываются с помощью инфиксной стрелки, например op -> i. До сих пор привязки были показаны только как часть конструкций отображений, но они имеют значение и сами по себе. Фактически привязка op -> i есть не что иное, как пара (op, i). Теперь, зафиксировав уровень приоритета всех бинарных операторов, за ис­ ключением /, имеет смысл обобщить данную концепцию, охватив также унарные операторы. Уровень приоритета унарного оператора выше уровня приоритета лю­ бого бинарного оператора. Поэтому для переменной unaryPrecedence, показанной в листинге 15.20, устанавливается значение длины массива opGroups на единицу большее, чем уровень приоритета операторов * и %. Уровень приоритета оператора деления рассматривается не так, как параметр других операторов, поскольку для дробей используется вертикальная разметка. Но, конечно же, было бы удобно при­ своить оператору деления специальное значение уровня приоритета -1, поэтому переменная fractionPrecedence будет инициализирована значением -1, как было показано в листинге 15.20 (см. выше). После такой подготовительной работы можно приступать к написанию основ­ ного метода format. Этот метод получает два аргумента: выражение e, имеющее тип Expr, и уровень приоритета enclPrec того оператора, который непосредствен­ но заключен в данное выражение. (Если в выражении нет никакого оператора, то значение enclPrec должно быть нулевым.) Метод выдает элемент разметки, представленный в виде двумерного массива символов. В листинге 15.21 пока­ зана остальная часть класса ExprFormatter , включающая три метода. Первый, stripDot, — вспомогательный. Далее идет приватный метод format, выполняющий основную работу по форматированию выражений. Последний метод, который также называется format, представляет собой единственный публичный метод в библиотеке, получающий выражение для форматирования. Приватный метод format проделывает свою работу, выполняя сопоставление с образцом по раз­ новидностям выражения. У выражения match есть пять вариантов, каждый из которых будет рассмотрен отдельно. Листинг 15.21. Нижняя половина средства форматирования выражений // ...продолжение, начало в листинге 15.20 import org.stairwaybook.layout.Element 288 Глава 15 • Case-классы и сопоставление с образцом private def format(e: Expr, enclPrec: Int): Element = e match { case Var(name) => elem(name) case Number(num) => def stripDot(s: String) = if (s endsWith ".0") s.substring(0, s.length - 2) else s elem(stripDot(num.toString)) case UnOp(op, arg) => elem(op) beside format(arg, unaryPrecedence) case BinOp("/", left, right) => val top = format(left, fractionPrecedence) val bot = format(right, fractionPrecedence) val line = elem('-', top.width max bot.width, 1) val frac = top above line above bot if (enclPrec != fractionPrecedence) frac else elem(" ") beside frac beside elem(" ") case BinOp(op, left, right) => val opPrec = precedence(op) val l = format(left, opPrec) val r = format(right, opPrec + 1) val oper = l beside elem(" " + op + " ") beside r if (enclPrec <= opPrec) oper else elem("(") beside oper beside elem(")") } def format(e: Expr): Element = format(e, 0) } Первый вариант имеет следующий вид: case Var(name) => elem(name) Если выражение является переменной, то результатом станет элемент, сформи­ рованный из имени переменной. Второй вариант выглядит так: case Number(num) => def stripDot(s: String) = if (s endsWith ".0") s.substring(0, s.length - 2) else s elem(stripDot(num.toString)) Если выражение является числом, то результатом станет элемент, сформиро­ ванный из значения числа. Функция stripDot очистит изображение числа с пла­ вающей точкой, удалив из строки любой суффикс вида ".0". 15.8. Большой пример 289 А вот как выглядит третий вариант: case UnOp(op, arg) => elem(op) beside format(arg, unaryPrecedence) Если выражение представляет собой унарную операцию UnOp(op, arg), то ре­ зультат будет сформирован из операции op и результата форматирования аргумен­ та arg с самым высоким из возможных уровнем приоритета, имеющимся в данном окружении1. Это означает, что, если аргумент arg является бинарной операцией (но не делением), то всегда будет отображаться в круглых скобках. Четвертый вариант представлен следующим кодом: case BinOp("/", left, right) => val top = format(left, fractionPrecedence) val bot = format(right, fractionPrecedence) val line = elem('-', top.width max bot.width, 1) val frac = top above line above bot if (enclPrec != fractionPrecedence) frac else elem(" ") beside frac beside elem(" ") Если выражение имеет вид дроби, то промежуточный результат frac форми­ руется путем помещения отформатированных операндов left и right друг над другом с разделительным элементом в виде горизонтальной линии. Ширина гори­ зонтальной линии равна максимальной ширине отформатированных операндов. Промежуточный результат становится окончательным, если только дробь сама по себе не появляется в виде аргумента еще одной функции. В последнем случае по обе стороны frac добавляется по пробелу. Чтобы понять, зачем это делается, рас­ смотрим выражение (a / b) / c. Без коррекции по ширине при форматировании этого выражения получится следующая картинка: a b c Вполне очевидна проблема с разметкой: непонятно, где именно находится дробная черта верхнего уровня. Показанное ранее выражение может означать либо (a / b) / c, либо a / (b / c). Чтобы устранить неоднозначность, с обеих сторон раз­ метки вложенной дроби a / b нужно добавить пробелы. Тогда разметка станет однозначной: a b --c 1 Значение unaryPrecedence является самым высоким приоритетом из возможных, по­ скольку ему было присвоено значение, на единицу превышающее значения приоритета операторов * и %. 290 Глава 15 • Case-классы и сопоставление с образцом Пятый и последний вариант выглядит следующим образом: case BinOp(op, left, right) => val opPrec = precedence(op) val l = format(left, opPrec) val r = format(right, opPrec + 1) val oper = l beside elem(" " + op + " ") beside r if (enclPrec <= opPrec) oper else elem("(") beside oper beside elem(")") Этот вариант применяется ко всем остальным бинарным операциям. Он указан после варианта, который начинается со следующего кода: case BinOp("/", left, right) => ... поэтому понятно, что оператор op в паттерне BinOp(op, left, right) не может быть оператором деления. Чтобы форматировать такую бинарную операцию, нужно сначала отформатировать его операнды left и right . В качестве пара­ метра уровня приоритета для форматирования левого операнда используется opPrec оператора op, а для правого операнда берется уровень на единицу больше. Вдобавок такая схема обеспечивает правильную ассоциативность, выраженную круглыми скобками. Например, операция: BinOp("-", Var("a"), BinOp("-", Var("b"), Var("c"))) будет вполне корректно выражена с применением круглых скобок в виде a - (b - c). Затем с помощью выстраивания в линию операндов left и right, разделенных опе­ ратором, формируется промежуточный результат oper. Если уровень приоритета текущего оператора ниже уровня приоритета оператора, заключенного в скобки, то oper помещается между круглыми скобками, в противном случае возвращается в исходном виде. На этом разработка приватной функции format завершена. Остается только пу­ бличный метод format, который позволяет программистам, создающим клиентский код, форматировать высокоуровневые выражения, не прибегая к передаче аргумен­ та, содержащего уровень приоритета. Демонстрационная программа, с помощью которой проверяется ExprFormatter, показана в листинге 15.22. Листинг 15.22. Приложение, выполняющее вывод отформатированных выражений import org.stairwaybook.expr._ object Express extends App { val f = new ExprFormatter val e1 = BinOp("*", BinOp("/", Number(1), Number(2)), BinOp("+", Var("x"), Number(1))) val e2 = BinOp("+", BinOp("/", Var("x"), Number(2)), BinOp("/", Number(1.5), Var("x"))) Резюме 291 val e3 = BinOp("/", e1, e2) def show(e: Expr) = s"${println(f.format(e))}\n\n" for (e <- Array(e1, e2, e3)) show(e) } Обратите внимание: приложение вполне работоспособно даже при отсутствии в нем определения метода main, поскольку оно является наследником трейта App. Запустить программу Express можно командой: scala Express При этом будет получен следующий вывод: 1 - * (x + 1) 2 x 1.5 - + --2 x 1 - * (x + 1) 2 ----------x 1.5 - + --2 x Резюме В этой главе мы представили подробную информацию о case-классах и сопостав­ лении с образцом. Их применение позволяет получить преимущества в процессе использования ряда лаконичных идиом, которые обычно недоступны в объектноориентированных языках. Но реализованное в Scala сопоставление с образцом не ограничивается тем, что было показано в данной главе. Если нужно задейство­ вать сопоставление с образцом, но при этом нежелательно открывать доступ к ва­ шим классам, как делается в case-классах, то можно обратиться к экстракторам, рассматриваемым в главе 26. В следующей главе мы переключим свое внимание на списки. 16 Работа со списками Вероятно, наиболее востребованные структуры данных в программах на Scala — списки. В этой главе мы подробно разъясним, что это такое. В ней мы представим много общих операций, которые могут выполняться над списками. Кроме того, раскроем некоторые наиболее важные принципы проектирования программ, ра­ ботающих со списками. 16.1. Литералы списков Списки уже попадались в предыдущих главах, следовательно, вам известно, что список, содержащий элементы 'a', 'b' и 'c', записывается как List('a', 'b', 'c'). А вот другие примеры: val fruit = List("apples", "oranges", "pears") val nums = List(1, 2, 3, 4) val diag3 = List( List(1, 0, 0), List(0, 1, 0), List(0, 0, 1) ) val empty = List() Списки очень похожи на массивы, но имеют два важных отличия. Во-первых, списки являются неизменяемой структурой данных, то есть их элементы нельзя изменить путем присваивания. Во-вторых, списки имеют рекурсивную структуру (имеется в виду связанный список)1, а у массивов она линейная. 1 Графическое представление структуры списка типа List показано на рис. 22.2. 16.3. Создание списков 293 16.2. Тип List Как и массивы, списки однородны: у всех элементов списка один и тот же тип. Тип списка, имеющего элементы типа T, записывается как List[T]. Например, далее показаны те же четыре списка с явным указанием типов: val fruit: List[String] = List("apples", "oranges", "pears") val nums: List[Int] = List(1, 2, 3, 4) val diag3: List[List[Int]] = List( List(1, 0, 0), List(0, 1, 0), List(0, 0, 1) ) val empty: List[Nothing] = List() В Scala тип списка обладает ковариантностью. Этот значит, для каждой пары ти­ пов S и T, если S — подтип T, то List[S] — подтип List[T]. Например, List[String] — подтип List[Object]. И это вполне естественно, поскольку каждый список строк также может рассматриваться как список объектов1. Обратите внимание: типом пустого списка является List[Nothing] . В раз­ деле 11.3 уже было показано, что Nothing — это низший тип в иерархии классов Scala. Он является подтипом любого другого имеющегося в Scala типа. Списки ковариантны, отсюда следует, что List[Nothing] — подтип List[T] для любого типа T. Следовательно, пустой списочный объект, имеющий тип List[Nothing], также может рассматриваться в качестве объекта любого другого списочного типа, имеющего вид List[T]. Именно поэтому вполне допустимо написать такой код: // List() также относится к типу List[String]! val xs: List[String] = List() 16.3. Создание списков Все списки создаются из двух основных строительных блоков: Nil и :: (произ­ носится как «конс», от слова «конструировать»). Nil представляет собой пустой список. Инфиксный оператор конструирования :: обозначает расширение списка с начала. То есть запись x :: xs представляет собой список, первым элементом ко­ торого является x, за которым следует список xs (его элементы). Следовательно, предыдущие списочные значения можно определить так: val fruit = "apples" :: ("oranges" :: ("pears" :: Nil)) val nums = 1 :: (2 :: (3 :: (4 :: Nil))) 1 Более подробно ковариантность и другие разновидности вариантности рассмотрены в главе 19. 294 Глава 16 • Работа со списками val diag3 = (1 :: (0 :: (0 :: Nil))) :: (0 :: (1 :: (0 :: Nil))) :: (0 :: (0 :: (1 :: Nil))) :: Nil val empty = Nil Фактически предыдущие определения fruit, nums, diag3 и empty, выраженные в виде List(...), всего лишь оболочки, которые разворачиваются в эти определе­ ния. Например, применение List(1, 2, 3) приводит к созданию списка 1 :: (2 :: (3 :: Nil)). То, что операция :: заканчивается двоеточием, означает ее правую ассоциа­ тивность: A :: B :: C интерпретируется как A :: (B :: C). Поэтому круглые скобки в предыдущих определениях можно отбросить. Например: val nums = 1 :: 2 :: 3 :: 4 :: Nil будет эквивалентом предыдущего определения nums. 16.4. Основные операции над списками Все действия со списками можно свести к трем основным операциям: head возвращает первый элемент списка; tail возвращает список, состоящий из всех элементов, за исключением первого; isEmpty возвращает true, если список пуст. Эти операции определены как методы класса List . Некоторые примеры их использования показаны в табл. 16.1. Методы head и tail определены только для непустых списков. Будучи примененными к пустому списку, они генерируют ис­ ключение: scala> Nil.head java.util.NoSuchElementException: head of empty list Таблица 16.1. Основные операции над списками Что используется Что этот метод делает empty.isEmpty Возвращает true fruit.isEmpty Возвращает false fruit.head Возвращает "apples" fruit.tail.head Возвращает "oranges" diag3.head Возвращает List(1, 0, 0) В качестве примера того, как можно обрабатывать список, рассмотрим сорти­ ровку элементов списка чисел в возрастающем порядке. Один из простых способов выполнить эту задачу заключается в сортировке вставками, которая работает так: 16.5. Паттерны-списки 295 для сортировки непустого списка x :: xs сортируется остаток xs и первый элемент x вставляется в нужную позицию результата. Сортировка пустого списка выдает пустой список. Выраженный в виде кода Scala, алгоритм сортировки вставками выглядит следующим образом: def isort(xs: List[Int]): List[Int] = if (xs.isEmpty) Nil else insert(xs.head, isort(xs.tail)) def insert(x: Int, xs: List[Int]): List[Int] = if (xs.isEmpty || x <= xs.head) x :: xs else xs.head :: insert(x, xs.tail) 16.5. Паттерны-списки Разбирать списки можно и с помощью сопоставления с образцом. Паттерны-списки по порядку следования соответствуют выражениям списков. Используя паттерн вида List(...), можно либо сопоставить все элементы списка, либо разобрать список поэлементно, применив паттерны, составленные из оператора :: и константы Nil. Пример использования первой разновидности паттерна выглядит следующим образом: scala> val List(a, b, c) = fruit a: String = apples b: String = oranges c: String = pears Паттерн List(a, b, c) соответствует спискам длиной три элемента и при­ вязывает эти три элемента к паттернам-переменным a, b и c. Если количество элементов заранее не известно, то лучше вместо этого сопоставлять с помощью оператора ::. Например, паттерн a :: b :: rest соответствует спискам длиной два и более элемента: scala> val a :: b :: rest = fruit a: String = apples b: String = oranges rest: List[String] = List(pears) О сопоставлении с образцом объектов класса List Если провести беглый обзор возможных форм паттернов, рассмотренных в главе 15, то выяснится, что ничего похожего ни на List(...), ни на :: в определенных там разновидностях нет. Фактически List(...) — экземпляр определенного в библиотеке паттерна-экстрактора. Такие паттерны будут рассматриваться в главе 26. Конспаттерн x :: xs — особый случай паттерна инфиксной операции. В качестве выра­ жения инфиксная операция выступает эквивалентом вызова метода. Для паттернов 296 Глава 16 • Работа со списками действуют иные правила: в качестве паттерна такая инфиксная операция, как p op q, является эквивалентом op(p, q). То есть инфиксный оператор op рассматривается в качестве паттерна-конструктора. В частности, такой конс-паттерн, как x :: xs, рас­ сматривается как ::(x, xs). Это обстоятельство подсказывает, что должен быть класс по имени ::, соответству­ ющий паттерну-конструктору. Разумеется, такой класс существует — он называется scala.::, и это именно тот класс, который создает непустые списки. Следовательно, :: в Scala фигурирует дважды: как имя класса в пакете scala и как метод класса List. Задача метода :: — создать экземпляр класса scala.::. Более подробно реализация класса List рассматривается в главе 22. Извлечение части списков с помощью паттернов — альтернатива использова­ нию основных методов head, tail и isEmpty. Например, в коде ниже снова приме­ нена сортировка вставками, на этот раз записанная с сопоставлением с образцом: def isort(xs: List[Int]): List[Int] = xs match { case List() => List() case x :: xs1 => insert(x, isort(xs1)) } def insert(x: Int, xs: List[Int]): List[Int] = xs match { case List() => List(x) case y :: ys => if (x <= y) x :: xs else y :: insert(x, ys) } Зачастую применение к спискам сопоставления с образцом оказывается более понятным, чем их декомпозиция с помощью методов, поэтому данное сопоставле­ ние должно стать частью вашего инструментария для обработки списков. Вот и все, что нужно знать о списках в Scala, чтобы их правильно применять. Но существует также множество методов, которые вбирают в себя наиболее рас­ пространенные схемы проведения операций со списками. Эти методы делают программы обработки списков более лаконичными и зачастую более понятными. В следующих двух разделах мы представим наиболее важные методы, определен­ ные в классе List. 16.6. Методы первого порядка класса List В этом разделе мы рассмотрим большинство определенных в классе List методов первого порядка. Метод первого порядка не получает в качестве аргументов никаких функций. Мы также представим несколько рекомендованных приемов структури­ рования программ, работающих со списками, на двух примерах. 16.6. Методы первого порядка класса List 297 Конкатенация двух списков Операцией, похожей на :: , является конкатенация списков, записываемая в виде ::: . В отличие от операции :: операция ::: получает в качестве опе­ рандов два списка. Результатом выполнения кода xs ::: ys выступает новый список, содержащий все элементы списка xs, за которыми следуют все элементы списка ys. Рассмотрим несколько примеров: scala> List(1, 2) ::: List(3, 4, 5) res0: List[Int] = List(1, 2, 3, 4, 5) scala> List() ::: List(1, 2, 3) res1: List[Int] = List(1, 2, 3) scala> List(1, 2, 3) ::: List(4) res2: List[Int] = List(1, 2, 3, 4) Как и конс-оператор, конкатенация списков правоассоциативна. Такое выра­ жение, как: xs ::: ys ::: zs интерпретируется следующим образом: xs ::: (ys ::: zs) Принцип «разделяй и властвуй» Конкатенация (:::) реализована в виде метода класса List. Можно было бы также реализовать конкатенацию «вручную», используя сопоставление с образцом для списков. Будет поучительно попробовать сделать это самостоятельно, поскольку таким образом можно проследить общий путь реализации алгоритмов с помощью списков. Сначала мы остановимся на сигнатуре метода конкатенации, который назовем append. Чтобы не создавать большой путаницы, предположим, что append определен за пределами класса List, поэтому будет получать в качестве параме­ тров два конкатенируемых списка. Оба они должны быть согласованы по типу их элементов, но сам тип может быть произвольным. Все это можно обеспечить, задав append параметр типа1, представляющего тип элементов двух исходных списков: def append[T](xs: List[T], ys: List[T]): List[T] Чтобы спроектировать реализацию append, имеет смысл вспомнить принцип «разделяй и властвуй» для программ, работающих с рекурсивными структурами 1 Более подробно параметры типов будут рассмотрены в главе 19. 298 Глава 16 • Работа со списками данных, такими как списки. Многие алгоритмы для работы со списками сначала разбивают исходный список на простые блоки, используя сопоставление с образ­ цом. Это часть принципа, которая называется «разделяй». Затем конструируется результат для каждого варианта. Если результатом является непустой список, то некоторые из его частей можно сконструировать с помощью рекурсивных вызовов того же самого алгоритма. В этом будет заключаться часть принципа, называемая «властвуй». Чтобы применить этот принцип к реализации метода append, сначала стоит отве­ тить на вопрос: какой именно список нужно сопоставлять? Применительно к append данный вопрос не настолько прост, как для многих других методов, поскольку здесь имеется два варианта. Но следующая далее фаза «властвования» подсказывает: следует сконструировать список, состоящий из всех элементов обоих исходных списков. Списки конструируются из конца в начало, поэтому ys можно оставить нетронутым, а вот xs нужно разобрать и пристроить впереди ys. Таким образом, имеет смысл сконцентрироваться на xs как на источнике сопоставления с образцом. Самое частое сопоставление в отношении списков просто отличает пустой список от непустого. Итак, для метода append вырисовывается следующая схема: def append[T](xs: List[T], ys: List[T]): List[T] = xs match { case List() => ??? case x :: xs1 => ??? } Остается лишь заполнить два места, обозначенных ???1. Первое такое место — альтернатива для случая, когда входной список xs пуст. В таком случае результатом конкатенации будет второй список: case List() => ys Второе место, оставленное незаполненным, — альтернатива для случая, когда входной список xs состоит из некоего head-элемента x, за которым следует осталь­ ная часть xs1. В таком случае результатом тоже будет непустой список. Чтобы сконструировать непустой список, нужно знать, какой должна быть его «голова» (head), а каким — «хвост» (tail). Вам известно, что первый элемент получающегося в результате списка — x. Что касается остальных элементов, то их можно вычис­ лить, добавив второй список, ys, к оставшейся части первого списка, xs1. Это завершает проектирование и дает: def append[T](xs: List[T], ys: List[T]): List[T] = xs match { case List() => ys case x :: xs1 => x :: append(xs1, ys) } 1 ??? — это метод, который генерирует ошибку scala.NotImplementedError и имеет ре­ зультирующий тип Nothing. Он может применяться в качестве временной реализации в процессе разработки приложения. 16.6. Методы первого порядка класса List 299 Вычисление второй альтернативы — иллюстрация той части принципа, которая называется «властвуй»: сначала нужно продумать форму желаемого результата, за­ тем вычислить отдельные части этой формы, используя где возможно рекурсивные вызовы алгоритма. И наконец, сконструировать из этих частей вывод. Получение длины списка: length Метод length вычисляет длину списка: scala> List(1, 2, 3).length res3: Int = 3 Определение длины списков, в отличие от массивов, — довольно затратная опе­ рация. Чтобы ее выполнить, нужно пройти по всему списку в поисках его конца, и на это затрачивается время, пропорциональное количеству элементов списка. Поэтому вряд ли имеет смысл заменять xs.isEmpty выражением xs.length == 0. Результаты будут получены одинаковые, но второе выражение будет выполняться медленнее, в частности, если список xs имеет бо' льшую длину. Обращение к концу списка: init и last Вам уже известны основные операции head и tail, в результате выполнения кото­ рых извлекаются соответственно первый элемент списка и весь остальной список, за исключением первого элемента. У каждой из них есть обратная по смыслу операция: last возвращает последний элемент непустого списка, а init — список, состоящий из всех элементов, за исключением последнего: scala> val abcde = List('a', 'b', 'c', 'd', 'e') abcde: List[Char] = List(a, b, c, d, e) scala> abcde.last res4: Char = e scala> abcde.init res5: List[Char] = List(a, b, c, d) Подобно методам head и tail , эти методы, примененные к пустому списку, генерируют исключение: scala> List().init java.lang.UnsupportedOperationException: Nil.init at scala.List.init(List.scala:544) at ... scala> List().last java.util.NoSuchElementException: Nil.last at scala.List.last(List.scala:563) at ... 300 Глава 16 • Работа со списками В отличие от head и tail, на выполнение которых неизменно затрачивается одно и то же время, для вычисления результата методы init и last должны обойти весь список. То есть их выполнение требует времени, пропорционального длине списка. Неплохо было бы организовать ваши данные таким образом, чтобы основная часть обращений приходилась на головной, а не на последний элемент списка. Реверсирование списков: reverse Если в какой-то момент вычисления алгоритма требуется часто обращаться к концу списка, то порой более разумным будет сначала перестроить список в обратном порядке и работать уже с результатом. Получить такой список можно следующим образом: scala> abcde.reverse res6: List[Char] = List(e, d, c, b, a) Как и все остальные операции со списками, reverse создает новый список, а не изменяет тот, с которым работает. Поскольку списки неизменяемы, сделать это все равно бы было невозможно. Чтобы убедиться в этом, проверьте, что исходный список abcde после операции reverse не изменился: scala> abcde res7: List[Char] = List(a, b, c, d, e) Операции reverse, init и last подчиняются ряду законов, с помощью которых можно анализировать вычисления и упрощать программы. 1. Операция reverse является собственной инверсией: xs.reverse.reverse равно xs 2. Операция reverse превращает init в tail, а last в head, за исключением того, что все элементы стоят в обратном порядке: xs.reverse.init xs.reverse.tail xs.reverse.head xs.reverse.last равно равно равно равно xs.tail.reverse xs.init.reverse xs.last xs.head Реверсирование можно реализовать, воспользовавшись конкатенацией (:::), как в следующем методе по имени rev: def rev[T](xs: List[T]): List[T] = xs match { case List() => xs case x :: xs1 => rev(xs1) ::: List(x) } Но этот метод, вопреки предположениям, менее эффективен. Чтобы убедиться в высокой вычислительной сложности rev, представьте, будто список xs имеет 16.6. Методы первого порядка класса List 301 длину n. Обратите внимание: придется делать n рекурсивных вызовов rev. Каждый вызов, за исключением последнего, влечет за собой конкатенацию списков. На кон­ катенацию xs ::: ys затрачивается время, пропорциональное длине ее первого ар­ гумента xs. Следовательно, общая вычислительная сложность rev выражается так: n + (n - 1) + ... + 1 = (1 + n) * n / 2 Иными словами, rev имеет квадратичную вычислительную сложность по длине его входного аргумента. Если сравнить это обстоятельство со стандартным ревер­ сированием изменяемого связного списка, имеющего линейную вычислительную сложность, то оно вызывает разочарование. Но данная реализация rev — не самая лучшая из возможных. Пример, который начинается на с. 312, позволит вам уви­ деть, как можно ускорить все это. Префиксы и суффиксы: drop, take и splitAt Операции drop и take обобщают tail и init в том смысле, что возвращают произ­ вольные префиксы или суффиксы списка. Выражение xs take n возвращает первые n элементов списка xs. Если n больше xs.length, то возвращается весь список xs. Операция xs drop n возвращает все элементы списка xs, за исключением первых n элементов. Если n больше xs.length, то возвращается пустой список. Операция splitAt разбивает список по заданному индексу, возвращая пару из двух списков1. Она определяется следующим равенством: xs splitAt n равно (xs take n, xs drop n) Но операция splitAt избегает двойного прохода по элементам списка. Примеры применения этих трех методов выглядят следующим образом: scala> abcde take 2 res8: List[Char] = List(a, b) scala> abcde drop 2 res9: List[Char] = List(c, d, e) scala> abcde splitAt 2 res10: (List[Char], List[Char]) = (List(a, b),List(c, d, e)) Выбор элемента: apply и indices Произвольный выбор элемента поддерживается методом apply, но эта операция менее востребована, чем аналогичная операция для массивов: scala> abcde apply 2 // в Scala используется довольно редко res11: Char = c 1 Как уже упоминалось в разделе 10.12, понятие пары — неформальное название для Tuple2. 302 Глава 16 • Работа со списками Что же касается всех остальных типов, то подразумевается, что apply встав­ ляется в вызове метода, когда объект появляется в позиции функции. Поэтому показанную ранее строку кода можно сократить до следующей: scala> abcde(2) // в Scala используется довольно редко res12: Char = c Одной из причин того, что выбор произвольного элемента менее популярен для списков, чем для массивов, является то, что на выполнение кода xs(n) затрачива­ ется время, пропорциональное величине значения индекса n. Фактически метод apply определен сочетанием методов drop и head: xs apply n равно (xs drop n).head Из этого определения также становится понятно, что индексы списков, как и индексы массивов, задаются в диапазоне от 0 до длины списка минус 1. Метод indices возвращает список, состоящий из всех допустимых индексов заданно­ го списка: scala> abcde.indices res13: scala.collection.immutable.Range = Range(0, 1, 2, 3, 4) Линеаризация списка списков: flatten Метод flatten принимает список списков и линеаризирует его в единый список: scala> List(List(1, 2), List(3), List(), List(4, 5)).flatten res14: List[Int] = List(1, 2, 3, 4, 5) scala> fruit.map(_.toCharArray).flatten res15: List[Char] = List(a, p, p, l, e, s, o, r, a, n, g, e, s, p, e, a, r, s) Он применим только к тем спискам, все элементы которых являются списками. Попытка линеаризации других списков выдаст ошибку компиляции: scala> List(1, 2, 3).flatten <console>:8: error: No implicit view available from Int => scala.collection.IterableOnce[B]. List(1, 2, 3).flatten ^ Объединение в пары: zip и unzip Операция zip получает два списка и формирует список из пар их значений: scala> abcde.indices zip abcde res17: scala.collection.immutable.IndexedSeq[(Int, Char)] = Vector((0,a), (1,b), (2,c), (3,d), (4,e)) 16.6. Методы первого порядка класса List 303 Если списки разной длины, то все элементы без пары отбрасываются: scala> val zipped = abcde zip List(1, 2, 3) zipped: List[(Char, Int)] = List((a,1), (b,2), (c,3)) Особо полезен вариант объединения в пары списка с его индексами. Наиболее эффективно оно выполняется с помощью метода zipWithIndex, составляющего пары из каждого элемента списка и той позиции, в которой он появляется в этом списке. scala> abcde.zipWithIndex res18: List[(Char, Int)] = List((a,0), (b,1), (c,2), (d,3), (e,4)) Любой список кортежей можно превратить обратно в кортеж списков с помо­ щью метода unzip: scala> zipped.unzip res19: (List[Char], List[Int]) = (List(a, b, c),List(1, 2, 3)) Методы zip и unzip реализуют один из способов одновременной работы с не­ сколькими списками. Более эффективный способ сделать то же самое показан в разделе 16.9. Отображение списков: toString и mkString Операция toString возвращает каноническое строковое представление списка: scala> abcde.toString res20: String = List(a, b, c, d, e) Если требуется иное представление, то можно воспользоваться методом mkString. Операция xs mkString (pre, sep, post) задействует четыре операнда: отображаемый список xs , префиксную строку pre , отображаемую перед всеми элементами, строковый разделитель sep, отображаемый между последовательно выводимыми элементами, и постфиксную строку, отображаемую в конце. Результатом операции будет следующая строка: pre + xs(0) + sep + ...+ sep + xs(xs.length - 1) + post У метода mkString имеются два перегруженных варианта, которые позволяют отбрасывать некоторые или все его аргументы. Первый вариант получает только строковый разделитель: xs mkString sep равно xs mkString("", sep, "") Второй вариант позволяет опустить все аргументы: xs.mkString равно xs mkString "" Рассмотрим несколько примеров: scala> abcde.mkString("[", ",", "]") res21: String = [a,b,c,d,e] 304 Глава 16 • Работа со списками scala> abcde.mkString "" res22: String = abcde scala> abcde.mkString res23: String = abcde scala> abcde.mkString("List(", ", ", ")") res24: String = List(a, b, c, d, e) Есть также вариант mkString , называющийся addString ; он не возвращает созданную строку в качестве результата, а добавляет ее к объекту StringBuilder1: scala> val buf = new StringBuilder buf: StringBuilder = scala> abcde.addString(buf, "(", ";", ")") res25: StringBuilder = (a;b;c;d;e) Методы mkString и addString наследуются из супертрейта Iterable класса List, поэтому их можно применять ко всем другим коллекциям. Преобразование списков: iterator, toArray, copyToArray Чтобы выполнить преобразование данных между линейным миром массивов и ре­ курсивным миром списков, можно воспользоваться методом toArray в классе List и методом toList в классе Array: scala> val arr = abcde.toArray arr: Array[Char] = Array(a, b, c, d, e) scala> arr.toList res26: List[Char] = List(a, b, c, d, e) Есть также метод copyToArray, который копирует элементы списка в последо­ вательные позиции массива внутри некоего результирующего массива. Операция: xs.copyToArray(arr, start) копирует все элементы списка xs в массив arr, начиная с позиции start. Нужно обеспечить достаточную длину результирующего массива arr, чтобы в нем мог поместиться весь список. Рассмотрим пример: scala> val arr2 = new Array[Int](10) arr2: Array[Int] = Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) scala> List(1, 2, 3).copyToArray(arr2, 3) scala> arr2 res28: Array[Int] = Array(0, 0, 0, 1, 2, 3, 0, 0, 0, 0) 1 Имеется в виду класс scala.StringBuilder, а не java.lang.StringBuilder. 16.6. Методы первого порядка класса List 305 И наконец, если нужно получить доступ к элементам списка через итератор, то можно воспользоваться методом iterator: scala> val it = abcde.iterator it: Iterator[Char] = <iterator> scala> it.next res29: Char = a scala> it.next res30: Char = b Пример: сортировка слиянием Ранее представленная сортировка вставками записывается кратко, но эффектив­ ность ее невысока. Ее усредненная вычислительная сложность пропорциональна квадрату длины входного списка. Более эффективен алгоритм сортировки слиянием. УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ Этот пример — еще одна иллюстрация карринга и принципа «разделяй и властвуй». Кроме того, в нем говорится о вычислительной сложности алгоритма, что может оказаться полезным. Если же читать книгу нужно быстрее, то раздел 16.7 можно спокойно пропустить. Сортировка слиянием работает следующим образом: если список имеет один элемент или не имеет никаких элементов, то он уже отсортирован, поэтому может быть возвращен в исходном виде. Более длинные списки разбиваются на два под­ списка, в каждом из которых содержится около половины элементов исходного списка. Каждый подсписок сортируется с помощью рекурсивного вызова функции sort, затем два отсортированных списка объединяются в ходе операции слияния. Чтобы выполнить обобщенную реализацию сортировки слиянием, вам придется оставить публичными тип элементов сортируемого списка и функцию, которая будет использоваться для сравнения элементов. Максимально обобщенную функ­ цию можно получить, передав ей эти два элемента в качестве параметров. При этом получится реализация, показанная в листинге 16.1. Листинг 16.1. Функция сортировки слиянием объектов List def msort[T](less: (T, T) => Boolean) (xs: List[T]): List[T] = { def merge(xs: List[T], ys: List[T]): List[T] = (xs, ys) match { case (Nil, _) => ys case (_, Nil) => xs case (x :: xs1, y :: ys1) => 306 Глава 16 } } • Работа со списками if (less(x, y)) x :: merge(xs1, ys) else y :: merge(xs, ys1) val n = xs.length / 2 if (n == 0) xs else { val (ys, zs) = xs splitAt n merge(msort(less)(ys), msort(less)(zs)) } Вычислительная сложность msort — n log(n), где n — длина входного списка. Чтобы понять причину происходящего, следует отметить: и разбиение списка на два подсписка, и слияние двух отсортированных списков, требует времени, которое пропорционально длине аргумента list(s). Каждый рекурсивный вызов msort вполовину уменьшает количество элементов, используемых им в качестве вход­ ных данных. Поэтому производится примерно log(n) последовательных вызовов, выполняемых до тех пор, пока не будет достигнут базовый вариант для списков длиной 1. Но для более длинных списков каждый вызов порождает два последу­ ющих вызова. Если все это сложить вместе, получится, что на каждом уровне вы­ зова log(n) каждый элемент исходных списков примет участие в одной операции разбиения и одной операции слияния. Следовательно, каждый уровень вызова имеет общий уровень затрат, пропор­ циональный n. Поскольку число уровней вызова равно log(n), мы получаем общий уровень затрат, пропорциональный n log(n). Он не зависит от исходного распре­ деления элементов в списке, следовательно, в наихудшем варианте будет таким же, как уровень затрат в усредненном. Это свойство делает сортировку слиянием весьма привлекательным алгоритмом для сортировки списков. Пример использования msort выглядит следующим образом: scala> msort((x: Int, y: Int) => x < y)(List(5, 7, 1, 3)) res31: List[Int] = List(1, 3, 5, 7) Функция msort представляет собой классический образец карринга, рассмо­ тренный в разделе 9.3. Карринг упрощает специализацию функции для конкретных функций сравнения. Рассмотрим пример: scala> val intSort = msort((x: Int, y: Int) => x < y) _ intSort: List[Int] => List[Int] = <function1> Переменная intSort ссылается на функцию, которая получает список цело­ численных значений и сортирует их в порядке следования чисел. Как объяснялось в разделе 8.6, знак подчеркивания означает недостающий список аргументов. В дан­ ном случае в качестве недостающего аргумента фигурирует сортируемый список. А вот другой пример, который демонстрирует способ возможного определения функции, выполняющей сортировку списка целочисленных значений в обратном порядке следования чисел: scala> val reverseIntSort = msort((x: Int, y: Int) => x > y) _ reverseIntSort: (List[Int]) => List[Int] = <function> 16.7. Методы высшего порядка класса List 307 Поскольку функция сравнения уже представлена с помощью карринга, при вызове функций intSort или reverseIntSort нужно будет только предоставить сортируемый список. Рассмотрим несколько примеров: scala> val mixedInts = List(4, 1, 9, 0, 5, 8, 3, 6, 2, 7) mixedInts: List[Int] = List(4, 1, 9, 0, 5, 8, 3, 6, 2, 7) scala> intSort(mixedInts) res0: List[Int] = List(0, 1, 2, 3, 4, 5, 6, 7, 8, 9) scala> reverseIntSort(mixedInts) res1: List[Int] = List(9, 8, 7, 6, 5, 4, 3, 2, 1, 0) 16.7. Методы высшего порядка класса List У многих операций над списками схожая структура. Раз за разом используются несколько схем. К подобным примерам можно отнести какое-либо преобразование каждого элемента списка; проверку того, что свойство соблюдается для всех эле­ ментов списка; извлечение из списка элементов, удовлетворяющих неким критери­ ям; или объединение элементов списка с помощью того или иного оператора. В Java подобные схемы будут, как правило, созданы идиоматическими комбинациями циклов for или while. В Scala они могут быть выражены более коротко и непосред­ ственно за счет использования операторов высшего порядка1, которые реализуются в виде методов, определенных в классе List. Этим операторам высшего порядка и посвящен данный раздел. Отображения списков: map, flatMap и foreach Операция xs map f получает в качестве операндов список xs типа List[T] и функ­ цию f типа T => U. Она возвращает список, получающийся в результате примене­ ния f к каждому элементу списка xs, например: scala> List(1, 2, 3) map (_ + 1) res32: List[Int] = List(2, 3, 4) scala> val words = List("the", "quick", "brown", "fox") words: List[String] = List(the, quick, brown, fox) scala> words map (_.length) res33: List[Int] = List(3, 5, 5, 3) scala> words map (_.toList.reverse.mkString) res34: List[String] = List(eht, kciuq, nworb, xof) 1 Под операторами высшего порядка понимаются функции высшего порядка, использу­ емые в системе записи операторов. Как упоминалось в разделе 9.1, функция является функцией высшего порядка, если получает в качестве параметров одну функцию и более. 308 Глава 16 • Работа со списками Оператор flatMap похож на map, но в качестве правого операнда получает функ­ цию, возвращающую список элементов. Он применяет функцию к каждому эле­ менту списка и возвращает конкатенацию всех результатов выполнения функции. Разница между map и flatMap показана в следующем примере: scala> words map (_.toList) res35: List[List[Char]] = List(List(t, h, e), List(q, u, i, c, k), List(b, r, o, w, n), List(f, o, x)) scala> words flatMap (_.toList) res36: List[Char] = List(t, h, e, q, u, i, c, k, b, r, o, w, n, f, o, x) Как видите, там, где map возвращает список списков, flatMap возвращает единый список, в котором все элементы списков сконкатенированы. Различия и взаимодействие методов map и flatMap показаны также в следующем выражении, с помощью которого создается список всех пар (i, j), отвечающих условию 1 ≤ j < i < 5: scala> List.range(1, 5) flatMap ( i => List.range(1, i) map (j => (i, j)) ) res37: List[(Int, Int)] = List((2,1), (3,1), (3,2), (4,1), (4,2), (4,3)) Метод List.range является вспомогательным, создающим список из всех целых чисел в некотором диапазоне. В этом примере он используется дважды в целях соз­ дания списков целых чисел: в первый раз — списка целых чисел от 1 (включитель­ но) до 5 (не включительно), во второй — списка целых чисел от 1 до i для каждого значения i, взятого из первого списка. Метод map в данном выражении создает список кортежей (i, j), где j < i. Внешний метод flatMap в этом примере создает данный список для каждого i между 1 и 5, а затем конкатенирует все результаты. По-другому этот же список может быть создан с помощью выражения for: for (i <- List.range(1, 5); j <- List.range(1, i)) yield (i, j) Взаимодействие выражений for и операций со списками более подробно будет рассмотрено в главе 23. Третья map-подобная операция — foreach. Но, в отличие от map и flatMap, она получает в качестве правого операнда процедуру (функцию, результирующим типом которой является Unit). Она просто применяет процедуру к каждому эле­ менту списка. А сам результат операции также имеет тип Unit, то есть никакого результирующего списка не будет. В качестве примера рассмотрим краткий способ суммирования всех чисел списка: scala> var sum = 0 sum: Int = 0 scala> List(1, 2, 3, 4, 5) foreach (sum += _) scala> sum res39: Int = 15 16.7. Методы высшего порядка класса List 309 Фильтрация списков: filter, partition, find, takeWhile, dropWhile и span Операция xs filter p получает в качестве операндов список xs типа List[T] и функцию-предикат p типа T => Boolean. Эта операция выдает список всех элемен­ тов x из списка xs, для которых p(x) вычисляется в true, например: scala> List(1, 2, 3, 4, 5) filter (_ % 2 == 0) res40: List[Int] = List(2, 4) scala> words filter (_.length == 3) res41: List[String] = List(the, fox) Метод partition похож на метод filter, но возвращает пару списков. Один список содержит все элементы, для которых предикат вычисляется в true, а дру­ гой — все элементы, для которых предикат вычисляется в false. Он определяет­ ся равенством: xs partition p равно (xs filter p, xs filter (!p(_))) Пример его работы выглядит следующим образом: scala> List(1, 2, 3, 4, 5) partition (_ % 2 == 0) res42: (List[Int], List[Int]) = (List(2, 4),List(1, 3, 5)) Метод find тоже похож на метод filter, но возвращает только первый эле­ мент, который удовлетворяет условию заданного предиката, а не все такие элементы. Операция xs find p получает в качестве операндов список xs и преди­ кат p. Она возвращает Option. Если в списке xs есть элемент x, для которого p(x) вычисляется в true, то возвращается Some(x). В противном случае p вычисляется в false для всех элементов и возвращается None. Вот несколько примеров работы этого метода: scala> List(1, 2, 3, 4, 5) find (_ % 2 == 0) res43: Option[Int] = Some(2) scala> List(1, 2, 3, 4, 5) find (_ <= 0) res44: Option[Int] = None Операторы takeWhile и dropWhile также получают в качестве правого операнда предикат. Операция xs takeWhile p получает самый длинный префикс списка xs, в котором каждый элемент удовлетворяет условию предиката p. Аналогично этому операция xs dropWhile p удаляет самый длинный префикс из списка xs, в котором каждый элемент удовлетворяет условию предиката p. Ряд примеров использования этих методов выглядит следующим образом: scala> List(1, 2, 3, -4, 5) takeWhile (_ > 0) res45: List[Int] = List(1, 2, 3) scala> words dropWhile (_ startsWith "t") res46: List[String] = List(quick, brown, fox) 310 Глава 16 • Работа со списками Метод span объединяет takeWhile и dropWhile в одну операцию точно так же, как метод splitAt объединяет stake и drop. Он возвращает пару из двух списков, определяемых следующим равенством: xs span p равно (xs takeWhile p, xs dropWhile p) Как и splitAt, метод span избегает двойного прохода элементов списка: scala> List(1, 2, 3, -4, 5) span (_ > 0) res47: (List[Int], List[Int]) = (List(1, 2, 3),List(-4, 5)) Применение предикатов к спискам: forall и exists Операция xs forall p получает в качестве аргументов список xs и предикат p. Она возвращает результат true, если все элементы списка удовлетворяют условию пре­ диката p. Напротив, операция xs exists p возвращает true, если в xs есть хотя бы один элемент, удовлетворяющий условию предиката p. Например, чтобы опре­ делить наличие в матрице, представленной списком списков, строки, состоящей только из нулевых элементов, можно применить следующий код: scala> def hasZeroRow(m: List[List[Int]]) = m exists (row => row forall (_ == 0)) hasZeroRow: (m: List[List[Int]])Boolean scala> hasZeroRow(diag3) res48: Boolean = false Свертка списков: foldLeft и foldRight Еще один распространенный вид операции объединяет элементы списка с помощью оператора, например: sum(List(a, b, c)) равно 0 + a + b + c Это особый случай операции свертки: scala> def sum(xs: List[Int]): Int = xs.foldLeft(0)(_ + _) sum: (xs: List[Int])Int Аналогично этому: product(List(a, b, c)) равно 1 * a * b * c представляет собой особый случай этой операции свертки: scala> def product(xs: List[Int]): Int = xs.foldLeft(1)(_ * _) product: (xs: List[Int])Int Операция левой свертки xs.foldLeft(z)(op) задействует три объекта: начальное значение z, список xs и бинарную операцию op. Результат свертки — применение op 16.7. Методы высшего порядка класса List 311 между последовательно извлекаемыми элементами списка, где в качестве префикса выступает значение z, например: List(a, b, c).foldLeft(z)(op) равно op(op(op(z, a), b), c) Или в графическом представлении: Вот еще один пример, иллюстрирующий использование операции foldLeft. Чтобы объединить все слова в списке из строковых значений с пробелами между ними и пробелом в самом начале списка, можно задействовать следующий код: scala> words.foldLeft("")(_ + " " + _) res49: String = " the quick brown fox" Этот код выдаст лишний пробел в самом начале. Избавиться от него можно с помощью слегка видоизмененного варианта кода: scala> words.tail.foldLeft(words.head)(_ + " " + _) res50: String = the quick brown fox Операция foldLeft создает деревья операций с уклоном влево. По аналогии с этим оператор foldRight создает деревья операций с уклоном вправо, например: List(a, b, c).foldRight(z)(op) равно op(a, op(b, op(c, z))) Или в графическом представлении: Для ассоциативных операций левая и правая свертки абсолютно эквивалентны, но эффективность их применения может существенно различаться. Рассмотрим, к примеру, операцию, соответствующую методу flatten, которая конкатенирует все элементы в список списков. Она может быть реализована с помощью либо левой, либо правой свертки: def flattenLeft[T](xss: List[List[T]]) = xss.foldLeft(List[T]())(_ ::: _) def flattenRight[T](xss: List[List[T]]) = xss.foldRight(List[T]())(_ ::: _) 312 Глава 16 • Работа со списками Поскольку конкатенация списков xs ::: ys занимает время, пропорциональ­ ное длине его первого аргумента xs , то реализация в понятиях правой свертки в flattenRight более эффективна, чем реализация с применением левой свертки в flattenLeft. Дело в том, что flattenLeft(xss) копирует первый элемент списка xss.head n – 1 раз, где n — длина списка xss. Учтите, что обе версии flatten требуют аннотации типа в отношении пустого списка, который является начальным значением свертки. Это связано с ограниче­ ниями в имеющемся в Scala механизме вывода типов, который не в состоянии авто­ матически вывести правильный тип списка. При попытке игнорировать аннотацию будет выдано такое сообщение об ошибке: scala> def flattenRight[T](xss: List[List[T]]) = xss.foldRight(List())(_ ::: _) <console>:8: error: type mismatch; found : List[T] required: List[Nothing] xss.foldRight(List())(_ ::: _) ^ Чтобы понять, почему не был выполнен надлежащий вывод типа, нужно узнать о типах методов свертки и способах их реализации. Более подробно этот вопрос рассматривается в разделе 16.10. Пример: реверсирование списков с помощью свертки Ранее в этой главе была показана реализация метода реверсирования по имени rev, время работы которой имело квадратичную вычислительную сложность по длине реверсируемого списка. Теперь будет показана другая реализация метода реверсирования с линейной вычислительной сложностью. Идея состоит в том, чтобы воспользоваться операцией левой свертки, основанной на следующей схеме: def reverseLeft[T](xs: List[T]) = xs.foldLeft(стартовое_значение)(операция) Остается заполнить части стартовое_значение и операция. Собственно, вы можете попытаться вывести эти части из нескольких простых примеров. Чтобы правильно вывести стартовое_значение, можно начать с наименьшего потенциального списка, List(), и рассуждать следующим образом: List() равен (в понятиях свойств reverseLeft) reverseLeft(List()) равен (по схеме для reverseLeft) 16.7. Методы высшего порядка класса List 313 List().foldLeft(стартовое_значение)(операция) равен (по определению foldLeft) стартовое_значение Следовательно, стартовое_значение должно быть List(). Чтобы вывести второй операнд, в качестве примера можно взять следующий наименьший список. По­ скольку уже известно, что стартовое_значение — это List(), можно рассуждать так: List(x) равен (в понятиях свойств reverseLeft) reverseLeft(List(x)) равен (по схеме для reverseLeft со стартовым_значением = List()) List(x).foldLeft(List())(операция) равен (по определению foldLeft) операция (List(), x) Следовательно, операция(List(), x) — эквивалент List(x), что можно записать также в виде x :: List(). Это наводит на такую мысль: нужно взять в качестве опе­ рации оператор :: с его операндами, которые поменяли местами. (Иногда такую операцию называют «снок», ссылаясь на операцию ::, которую называют «конс».) И тогда мы приходим к следующей реализации метода reverseLeft: def reverseLeft[T](xs: List[T]) = xs.foldLeft(List[T]()) { (ys, y) => y :: ys } Чтобы заставить работать механизм вывода типов, здесь также в качестве аннотации типа требуется использовать код List[T]() . Если проанализиро­ вать вычислительную сложность reverseLeft, то можно прийти к выводу, что в нем n раз применяется постоянная по времени выполнения операция («снок»), где n — длина списка-аргумента. Таким образом, вычислительная сложность reverseLeft линейна. Сортировка списков: sortWith Операция xs sortWith before, где xs — это список, а before — функция, которая может использоваться для сравнения двух элементов, выполняет сортировку эле­ ментов списка xs. Выражение x before y должно возвращать true, если в желаемом порядке следования x должен стоять перед y, например: scala> List(1, -3, 4, 2, 6) sortWith (_ < _) res51: List[Int] = List(-3, 1, 2, 4, 6) scala> words sortWith (_.length > _.length) res52: List[String] = List(quick, brown, the, fox) 314 Глава 16 • Работа со списками Обратите внимание: sortWith выполняет сортировку слиянием подобно тому, как это делает алгоритм msort, показанный в последнем разделе. Но sortWith яв­ ляется методом класса List, а msort определен вне списков. 16.8. Методы объекта List До сих пор все показанные в этой главе операции реализовывались в качестве мето­ дов класса List, поэтому вызывались в отношении отдельно взятых списочных объ­ ектов. Существует также ряд методов в глобально доступном объекте scala.List, который является объектом-компаньоном класса List. Одни такие операции — это фабричные методы, создающие списки. Другие же — это операции, работающие со списками некоторых конкретных видов. В этом разделе будут представлены обе разновидности методов. Создание списков из их элементов: List.apply В книге уже несколько раз попадались литералы списков вида List(1, 2, 3). В их синтаксисе нет ничего особенного. Литерал вида List(1, 2, 3) — простое примене­ ние объекта List к элементам 1, 2, 3. То есть это эквивалент кода List.apply(1, 2, 3): scala> List.apply(1, 2, 3) res53: List[Int] = List(1, 2, 3) Создание диапазона чисел: List.range Метод range, который ранее подробно рассматривался при изучении методов map и flatmap, создает список, состоящий из диапазона чисел. Его самая простая форма, при которой создаются все числа, начиная с from и заканчивая until минус 1, — List.range(from, until). Следовательно, последнее значение, until, в диапазон не входит. Существует также версия range, получающая в качестве третьего параметра зна­ чение step. В результате выполнения этой операции получится список элементов, которые следуют друг за другом с указанным шагом, начиная с from. Указываемый шаг step может иметь положительное или отрицательное значение: scala> List.range(1, 5) res54: List[Int] = List(1, 2, 3, 4) scala> List.range(1, 9, 2) res55: List[Int] = List(1, 3, 5, 7) scala> List.range(9, 1, -3) res56: List[Int] = List(9, 6, 3) 16.8. Методы объекта List 315 Создание единообразных списков: List.fill Метод fill создает список, состоящий из нуля или более копий одного и того же элемента. Он получает два параметра: длину создаваемого списка и повторяемый элемент. Каждый параметр задается в отдельном списке: scala> List.fill(5)('a') res57: List[Char] = List(a, a, a, a, a) scala> List.fill(3)("hello") res58: List[String] = List(hello, hello, hello) Если методу fill дать более двух аргументов, то он будет создавать многомер­ ные списки, то есть списки списков, списки списков из списков и т. д. Дополни­ тельный аргумент помещается в первый список аргументов. scala> List.fill(2, 3)('b') res59: List[List[Char]] = List(List(b, b, b), List(b, b, b)) Табулирование функции: List.tabulate Метод tabulate создает список, элементы которого вычисляются согласно пре­ доставляемой функции. Аргументы у него такие же, как и у метода List.fill: в первом списке аргументов задается размерность создаваемого списка, а во втором дается описание элементов списка. Единственное отличие — элементы не фикси­ руются, а вычисляются из функции: scala> val squares = List.tabulate(5)(n => n * n) squares: List[Int] = List(0, 1, 4, 9, 16) scala> val multiplication = List.tabulate(5,5)(_ * _) multiplication: List[List[Int]] = List(List(0, 0, 0, 0, 0), List(0, 1, 2, 3, 4), List(0, 2, 4, 6, 8), List(0, 3, 6, 9, 12), List(0, 4, 8, 12, 16)) Конкатенация нескольких списков: List.concat Метод concat объединяет несколько списков элементов. Конкатенируемые списки предоставляются concat в виде непосредственных аргументов: scala> List.concat(List('a', 'b'), List('c')) res60: List[Char] = List(a, b, c) scala> List.concat(List(), List('b'), List('c')) res61: List[Char] = List(b, c) scala> List.concat() res62: List[Nothing] = List() 316 Глава 16 • Работа со списками 16.9. Совместная обработка нескольких списков Вы уже знакомы с методом zip, который создает список пар из двух списков, по­ зволяя работать с ними одновременно: scala> (List(10, 20) zip List(3, 4, 5)). map { case (x, y) => x * y } res63: List[Int] = List(30, 80) Метод map, примененный к спискам, прошедшим через zip, перебирает не от­ дельные элементы, а их пары. Первая пара содержит элементы, идущие первыми в каждом списке, вторая — элементы, идущие вторыми, и т. д. Количество пар определяется длиной списков. Обратите внимание: третий элемент второго списка отбрасывается. Метод zip объединяет только то количество элементов, которое совместно появляется во всех списках. Любые лишние элементы в конце отбра­ сываются. Один из недостатков работы с несколькими списками с помощью метода zip со­ стоит в том, что мы получаем промежуточный список (после вызова zip), который в конечном счете отбрасывается (при вызове метода map). Создание этого проме­ жуточного списка может потребовать существенных расходов, если у него много элементов. Еще один недостаток этого подхода — функция, которая передается методу map, принимает в качестве параметра кортеж, что исключает использование синтаксиса заместителей, продемонстрированного в разделе 8.5. Эти две проблемы решает метод lazyZip. По своему синтаксису он похож на zip: scala> (List(10, 20) lazyZip List(3, 4, 5)).map(_ * _) res63: List[Int] = List(30, 80) Разница между lazyZip и zip в том, что первый не возвращает коллекцию сразу (отсюда и префикс lazy — «ленивый»). Вместо этого вы получаете значение, предоставляющее методы (включая map) для работы с двумя списками, для которых метод zip выполнен отложенно. В приведенном выше примере вы можете видеть, как метод map принимает функцию с двумя параметрами (вместо одной пары), по­ зволяя нам использовать синтаксис заместителей. Существуют также обобщающие аналоги для методов exists и forall. Они по­ хожи на версии этих методов, предназначенные для работы с одним списком, но оперируют элементами не одного, а нескольких списков: scala> (List("abc", "de") lazyZip List(3, 2)). forall(_.length == _) res64: Boolean = true scala> (List("abc", "de") lazyZip List(3, 2)). exists(_.length != _) res65: Boolean = false УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ В последнем разделе этой главы дается информация об имеющемся в Scala алгоритме вывода типов. Если такие подробности сейчас вас не интересуют, то можете пропустить весь раздел и сразу перейти к резюме. 16.10. Осмысление имеющегося в Scala алгоритма вывода типов 317 16.10. Осмысление имеющегося в Scala алгоритма вывода типов Одно из отличий предыдущего использования sortWith и msort касается допусти­ мых синтаксических форм функции сравнения. Сравните этот диалог с интерпретатором: scala> msort((x: Char, y: Char) => x > y)(abcde) res66: List[Char] = List(e, d, c, b, a) со следующим: scala> abcde sortWith (_ > _) res67: List[Char] = List(e, d, c, b, a) Эти два выражения эквивалентны, но в первом используется более длинная форма функции сравнения с именованными параметрами и явно заданными типа­ ми. Во втором задействована более краткая форма, (_ > _), в которой вместо име­ нованных параметров стоят знаки подчеркивания. Разумеется, с методом sortWith вы можете применить также первую, более длинную форму сравнения. А вот с msort более краткая форма использоваться не может: scala> msort(_ > _)(abcde) <console>:12: error: missing parameter type for expanded function ((x$1, x$2) => x$1.$greater(x$2)) msort(_ > _)(abcde) ^ Чтобы понять, почему именно так происходит, следует знать некоторые по­ дробности имеющегося в Scala алгоритма вывода типов. Это поточный механизм. При использовании метода m(args) механизм вывода типов сначала проверяет, имеется ли известный тип у метода m. Если да, то именно он и применяется для вывода ожидаемого типа аргументов. Например, в выражении abcde.sortWith(_ > _) типом abcde является List[Char]. Таким образом, sortWith известен как метод, получающий аргумент типа (Char, Char) => Boolean и выдающий результат типа List[Char]. Поскольку типы параметров аргументов функции известны, то их не нужно записывать явным образом. По совокупности всего известного о методе sortWith механизм вывода типов может установить, что код (_ > _) нужно рас­ крыть в ((x: Char, y: Char) => x > y), где x и y — некие произвольные только что полученные имена. Теперь рассмотрим второй вариант, msort(_ > _)(abcde). Типом msort явля­ ется каррированный полиморфный тип метода, который принимает аргумент типа (T, T) => Boolean в функцию из List[T] в List[T], где T — некий пока еще неизвестный тип. Прежде чем он будет применен к своим аргументам, у метода msort должен быть создан экземпляр с параметром типа. Точный тип экземпляра msort в приложении еще неизвестен, поэтому он не мо­ жет быть использован для вывода типа своего первого аргумента. В этом случае ме­ ханизм вывода типов меняет свою стратегию: сначала он проверяет тип аргументов метода для определения экземпляра метода с подходящим типом. Но, когда перед 318 Глава 16 • Работа со списками ним стоит задача проверки типа функционального литерала в краткой форме за­ писи, (_ > _), он дает сбой из-за отсутствия информации о неявных типах заданных параметров функции, показанных знаками подчеркивания. Один из способов решить проблему — передать msort явно заданный тип па­ раметра: scala> msort[Char](_ > _)(abcde) res68: List[Char] = List(e, d, c, b, a) Экземпляр msort подходящего типа теперь известен, поэтому его можно ис­ пользовать для вывода типов аргументов. Еще одним потенциальным решением может стать перезапись метода msort таким образом, чтобы его параметры поме­ нялись местами: def msortSwapped[T](xs: List[T])(less: (T, T) => Boolean): List[T] = { // Та же реализация, что и у msort, // но с аргументами, которые поменялись местами } Теперь вывод типов будет выполнен успешно: scala> msortSwapped(abcde)(_ > _) res69: List[Char] = List(e, d, c, b, a) Получилось так, что механизм вывода типов воспользовался известным типом первого параметра abcde, чтобы определить параметр типа метода msortSwapped. Точный тип msortSwapped был известен, поэтому с его помощью может быть вы­ веден тип второго параметра, (_ > _). В общем, когда ставится задача вывести параметры типа полиморфного метода, механизм вывода типов принимает во внимание типы всех значений аргументов в первом списке параметров, игнорируя все аргументы, кроме этих. Поскольку msortSwapped — каррированный метод с двумя списками параметров, то не нужно обращать внимание на второй аргумент (то есть на функциональное значение), чтобы определить параметр типа метода. Эта схема вывода типов предлагает следующий принцип разработки библиотек: при разработке полиморфного метода, который получает некие нефункциональ­ ные аргументы и функциональный аргумент, этот функциональный аргумент в самом каррированном списке параметров нужно поставить на последнее место. Тогда экземпляр метода подходящего типа можно вывести из нефункциональных аргументов, и этот тип, в свою очередь, можно использовать для проверки типа функционального аргумента. Совокупный эффект будет состоять в том, что поль­ зователи метода получат возможность предоставить меньший объем информации и написания функциональных литералов более компактными способами. Теперь рассмотрим более сложный случай, касающийся операции свертки. Почему необходимо явно указывать параметр типа в выражении, подобном телу метода flattenRight, показанного на с. 312? xss.foldRight(List[T]())(_ ::: _) 16.10. Осмысление имеющегося в Scala алгоритма вывода типов 319 Тип метода flattenRight полиморфен в двух переменных типа. Если взять вы­ ражение: xs.foldRight(z)(op) то типом xs должен быть список какого-то произвольного типа A, скажем xs: List[A]. Начальное значение z может быть какого-нибудь другого типа B. Тогда операция op должна получать два аргумента типа, A и B, и возвращать результат типа B, то есть op: (A, B) => B. Тип значения z не связан с типом списка xs, поэтому у механизма вывода типов нет контекстной информации для z. Теперь рассмотрим выражение в ошибочной версии метода flattenRight, также показанного на с. 312: xss.foldRight(List())(_ ::: _) // этот код не пройдет компиляцию Начальное значение z в данной свертке — пустой список, List(), следовательно, при отсутствии дополнительной информации о типе его тип будет выведен как List[Nothing]. Исходя из этого, механизм вывода типов установит, что типом B в свертке будет являться List[Nothing]. Таким образом, для операции (_ ::: _) в свертке будет ожидаться следующий тип: (List[T], List[Nothing]) => List[Nothing] Конечно же, такой тип возможен для операции в данной свертке, но пользы от него никакой! Он сообщает, что операция всегда получает в качестве второго ар­ гумента пустой список и всегда в качестве результата выдает также пустой список. Иными словами, вопрос вывода типов на основе List() был решен слишком рано — он должен был выждать, пока не станет виден тип операции op. Следова­ тельно, весьма полезное в иных случаях правило о том, что для определения типа метода нужно принимать во внимание только первый список аргументов, будучи примененным к каррированному методу, становится камнем преткновения. В то же время, даже если бы это правило было смягчено, механизм вывода типов все равно не смог бы определиться с типом для операции op, поскольку ее типы параметров не приведены. Таким образом, создается ситуация, как в уловке-22, которую можно разрешить с помощью явной аннотации типа, получаемой от программиста1. Данный пример выявляет ряд ограничений локальной, поточной схемы вывода типов, имеющейся в Scala. В более глобальном механизме вывода типов в стиле Хиндли — Милнера (Hindley — Milner), используемом в таких функциональных языках, как ML или Haskell, подобных ограничений нет. Но по сравнению со сти­ лем Хиндли — Милнера имеющийся в Scala механизм вывода типов обходится с объектно-ориентированной системой подтипов намного изящнее. К счастью, ограничения проявляются только в некоторых крайних случаях, и обычно их без особого труда можно обойти, добавив явную аннотацию типа. 1 Уловка-22 (англ. Catch-22) — ситуация, возникающая в результате логического парадо­ кса между взаимоисключающими правилами и процедурами. В этой ситуации индивид, подпадающий под действие таких норм, не может их никак контролировать, так как попытка нарушить эти установки автоматически подразумевает их соблюдение. 320 Глава 16 • Работа со списками Добавление аннотаций типа пригодится также при отладке, когда вас поставят в тупик сообщения об ошибках типа, связанных с полиморфными методами. Если вы не уверены в причине возникновения конкретной ошибки типа, то нужно просто добавить некоторые аргументы типа или другие аннотации типа, в правильности которых вы не сомневаетесь. Тогда можно будет быстро понять, где реальный ис­ точник проблемы. Резюме В этой главе мы показали множество способов работы со списками. Рассмотрели основные операции, такие как head и tail; операции первого порядка, такие как reverse; операции высшего порядка, такие как map; и вдобавок полезные методы, определенные в объекте List. Попутно вы изучили принципы работы имеющегося в Scala механизма вывода типов. Списки в Scala — настоящая рабочая лошадка, поэтому, узнав, как с ними ра­ ботать, вы сможете извлечь для себя немалую выгоду. Именно с этой целью мы в данной главе погрузились в способы применения списков. Но списки — всего лишь одна из разновидностей коллекций, поддерживаемых в Scala. Тематика сле­ дующей главы будет скорее охватывающей, чем углубленной. В ней мы покажем вам порядок использования различных типов коллекций Scala. 17 Работа с другими коллекциями В Scala содержится весьма богатая библиотека коллекций. В этой главе мы рас­ скажем о наиболее часто используемых типах коллекций и операциях над ними. Более полный обзор доступных коллекций мы представим в главе 24, а в главе 25 покажем, как композиционные конструкции Scala применяются для предоставле­ ния насыщенного API. 17.1. Последовательности Типы последовательностей позволяют работать с группами данных, выстроенных по порядку. Поскольку элементы упорядочены, то можно запрашивать первый элемент, второй, сто третий и т. д. В этом разделе мы бегло пройдемся по наиболее важным последовательностям. Списки Возможно, самым важным типом последовательности, о котором следует знать, является класс List — неизменяемый связный список, подробно рассмотренный в предыдущей главе. Списки поддерживают быстрое добавление и удаление элементов в начало списка, но не позволяют получить быстрый доступ к произ­ вольным индексам, поскольку, чтобы это реализовать, требуется выполнить по­ следовательный обход всех элементов списка. Такое сочетание свойств может показаться странным, но оно попало в золо­ тую середину и неплохо функционирует во многих алгоритмах. Как следует из описаний, представленных в главе 15, быстрое добавление и удаление начальных элементов означает хорошую работу сопоставления с образцом. Неизменяемость списков помогает разрабатывать корректные эффективные алгоритмы, поскольку избавляет от необходимости создавать копии списков. 322 Глава 17 • Работа с другими коллекциями Краткий пример, показывающий способ инициализации списка и получения доступа к его голове и хвосту, выглядит так: scala> val colors = List("red", "blue", "green") colors: List[String] = List(red, blue, green) scala> colors.head res0: String = red scala> colors.tail res1: List[String] = List(blue, green) Чтобы освежить в памяти сведения о списках, обратитесь к шагу 8 в главе 3. А подробности использования списков можно найти в главе 16. Списки будут рассматриваться также в главе 22, которая дает представление о том, как именно они реализованы в Scala. Массивы Массивы позволяют хранить последовательность элементов и оперативно обра­ щаться к элементу, находящемуся в произвольной позиции, чтобы либо получить его, либо обновить; для этого используется индекс, отсчитываемый от нуля. Массив известной длины, для которого пока неизвестны значения элементов, создается следующим образом: scala> val fiveInts = new Array[Int](5) fiveInts: Array[Int] = Array(0, 0, 0, 0, 0) А вот как инициализируется массив, когда значения элементов известны: scala> val fiveToOne = Array(5, 4, 3, 2, 1) fiveToOne: Array[Int] = Array(5, 4, 3, 2, 1) Как уже упоминалось, получить доступ к элементам массивов в Scala можно, указав индекс в круглых, а не в квадратных, как в Java, скобках. Рассмотрим пример доступа к элементу массива и обновления элемента: scala> fiveInts(0) = fiveToOne(4) scala> fiveInts res3: Array[Int] = Array(1, 0, 0, 0, 0) Массивы в Scala представлены точно так же, как массивы в Java. Поэтому мож­ но абсолютно свободно использовать имеющиеся в Java методы, возвращающие массивы1. В предыдущих главах действия с массивами встречались уже много раз. Основы этих действий были рассмотрены в шаге 7 главы 3. Ряд примеров поэлементного 1 Разница вариантности массивов в Scala и в Java — то есть является ли Array[String] под­ типом Array[AnyRef — будет рассмотрена в разделе 19.3. 17.1. Последовательности 323 обхода массивов с помощью выражения for был показан в разделе 7.3. Массивы также занимают видное место в библиотеке двумерной разметки, представленной в главе 10. Буферы списков Класс List предоставляет быстрый доступ к голове и к хвосту списка, но не к его концу. Таким образом, при необходимости построить список с добавлением эле­ ментов в конец следует рассматривать возможность построить список в обратном порядке путем добавления элементов спереди. Затем, когда это будет сделано, нужно вызвать метод реверсирования reverse, чтобы получить элементы в требу­ емом порядке. Другой вариант, который позволяет избежать реверсирования, — использовать объект ListBuffer. Это содержащийся в пакете scala.collection.mutable изме­ няемый объект, который может помочь более эффективно строить списки, когда нужно добавлять элементы в их конец. Объект обеспечивает постоянное время выполнения операций добавления элементов как в конец, так и в начало списка. В конец списка элемент добавляется с помощью оператора +=1, а в начало — с по­ мощью оператора +=:. Когда построение будет завершено, можно получить список типа List, вызвав в отношении ListBuffer метод toList. Соответствующий пример выглядит так: scala> import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer scala> val buf = new ListBuffer[Int] buf: scala.collection.mutable.ListBuffer[Int] = ListBuffer() scala> buf += 1 res4: buf.type = ListBuffer(1) scala> buf += 2 res5: buf.type = ListBuffer(1, 2) scala> buf res6: scala.collection.mutable.ListBuffer[Int] = ListBuffer(1, 2) scala> 3 +=: buf res7: buf.type = ListBuffer(3, 1, 2) scala> buf.toList res8: List[Int] = List(3, 1, 2) Еще один повод использовать ListBuffer вместо List — возможность предот­ вратить потенциальное переполнение стека. Если можно создать список в нужном 1 Операторы += и +=: — псевдонимы для добавления в конец и в начало соответственно. 324 Глава 17 • Работа с другими коллекциями порядке, добавив элементы в его начало, но рекурсивный алгоритм, который по­ требуется, не является алгоритмом с хвостовой рекурсией, то вместо этого можно задействовать выражение for или цикл while и ListBuffer. Такой способ приме­ нения ListBuffer будет показан в разделе 22.2. Буферы массивов Объект ArrayBuffer похож на массив, за исключением того, что в дополнение ко всему здесь предоставляет возможность добавлять и удалять элементы в на­ чало и в конец последовательности. Доступны все те же операции, что и в классе Array, хотя выполняются они несколько медленнее, поскольку в реализации есть уровень-оболочка. На новые операции добавления и удаления затрачивается в среднем одно и то же время, но иногда требуется время, пропорциональное размеру, из-за реализации, требующей выделить новый массив для хранения со­ держимого буфера. Чтобы воспользоваться ArrayBuffer, нужно сначала импортировать его из па­ кета изменяемых коллекций: scala> import scala.collection.mutable.ArrayBuffer import scala.collection.mutable.ArrayBuffer При создании ArrayBuffer нужно указать параметр типа, а длину указывать не обязательно. По мере надобности ArrayBuffer автоматически установит вы­ деляемое пространство памяти: scala> val buf = new ArrayBuffer[Int]() buf: scala.collection.mutable.ArrayBuffer[Int] = ArrayBuffer() Добавить элемент в ArrayBuffer можно с помощью метода +=: scala> buf += 12 res9: buf.type = ArrayBuffer(12) scala> buf += 15 res10: buf.type = ArrayBuffer(12, 15) scala> buf res11: scala.collection.mutable.ArrayBuffer[Int] = ArrayBuffer(12, 15) Доступны все обычные методы работы с массивами. Например, можно запро­ сить у ArrayBuffer его длину или извлечь элемент по его индексу: scala> buf.length res12: Int = 2 scala> buf(0) res13: Int = 12 17.2. Множества и отображения 325 Строки (реализуемые через StringOps) Еще одной последовательностью, заслуживающей упоминания, является StringOps. В ней реализованы многие методы работы с последовательностями. Поскольку в Predef есть неявное преобразование из String в StringOps, то с любой строкой можно работать как с последовательностью. Вот пример: scala> def hasUpperCase(s: String) = s.exists(_.isUpper) hasUpperCase: (s: String)Boolean scala> hasUpperCase("Robert Frost") res14: Boolean = true scala> hasUpperCase("e e cummings") res15: Boolean = false В этом примере метод exists вызывается в отношении строки, которая в теле метода hasUpperCase называется s. В самом классе String не объявлено никакого метода по имени exists, поэтому компилятор Scala выполнит неявное преобразо­ вание s в StringOps, где такой метод есть. Метод exists считает строку последова­ тельностью символов и вернет значение true, если какой-либо из них относится к верхнему регистру1. 17.2. Множества и отображения В предыдущих главах, начиная с шага 10 в главе 3, уже были показаны основы множеств и отображений. Прочитав этот раздел, вы получите более глубокое представление о способах их использования и увидите несколько дополнительных примеров. Ранее мы уже говорили, что библиотека коллекций Scala предлагает как изме­ няемые, так и неизменяемые версии множеств и отображений. Иерархия множеств показана на рис. 3.2, а иерархия отображений — на рис. 3.3 (см. выше). Из этих схем следует, что простые имена Set и Map используются тремя трейтами и все они находятся в разных пакетах. По умолчанию, когда в коде используется Set или Map , вы получаете неиз­ меняемый объект. Если нужен изменяемый вариант, то следует применить явно указанное импортирование. К неизменяемым вариантам Scala предоставляет са­ мый простой доступ — в качестве небольшого поощрения за то, что предпочтение отдано им, а не их изменяемым аналогам. Доступ предоставляется через объект Predef, неявно импортируемый в каждый файл исходного кода на языке Scala. Соответствующие определения показаны в листинге 17.1. 1 Похожий пример представлен в главе 1. 326 Глава 17 • Работа с другими коллекциями Листинг 17.1. Исходные определения отображений map и множеств в set в Predef object Predef { type Map[A, +B] = collection.immutable.Map[A, B] type Set[A] = collection.immutable.Set[A] val Map = collection.immutable.Map val Set = collection.immutable.Set // ... } Имена Set и Map в качестве псевдонимов для более длинных полных имен трей­ тов неизменяемых множеств и отображений в Predef определяются с помощью ключевого слова type1. Чтобы ссылаться на объекты-одиночки для неизменяемых Set и Map, выполняется инициализация val-переменных с именами Set и Map. Сле­ довательно, Map является тем же, что и объект Predef.Map, который определен быть тем же самым, что и scala.collection.immutable.Map. Это справедливо как для типа Map, так и для объекта Map. Если нужно воспользоваться как изменяемыми, так и неизменяемыми множе­ ствами или отображениями, то одним из подходов является импортирование имен пакетов, в которых содержатся изменяемые варианты: scala> import scala.collection.mutable import scala.collection.mutable Можно продолжать ссылаться на неизменяемое множество, как и прежде Set, но теперь можно будет сослаться и на изменяемое множество, указав mutable.Set. Вот как выглядит соответствующий пример: scala> val mutaSet = mutable.Set(1, 2, 3) mutaSet: scala.collection.mutable.Set[Int] = Set(1, 2, 3) Использование множеств Ключевой характеристикой множества является то, что оно гарантирует наличие каждого объекта не более чем в одном экземпляре, согласно определению опера­ тора ==. В качестве примера воспользуемся множеством, чтобы вычислить коли­ чество уникальных слов в строке. Если указать в качестве разделителей слов пробелы и знаки пунктуации, то метод split класса String может разбить строку на слова. Для этого вполне до­ статочно применить регулярное выражение [ !,.]+: оно показывает, что строка должна быть разбита во всех местах, где есть один или несколько пробелов и/или знак пунктуации. scala> val text = "See Spot run. Run, Spot. Run!" text: String = See Spot run. Run, Spot. Run! 1 Более подробно ключевое слово type мы рассмотрим в разделе 20.6. 17.2. Множества и отображения 327 scala> val wordsArray = text.split("[ !,.]+") wordsArray: Array[String] = Array(See, Spot, run, Run, Spot, Run) Чтобы посчитать уникальные слова, их можно преобразовать, приведя их сим­ волы к единому регистру, а затем добавить во множество. Поскольку множества исключают дубликаты, то каждое уникальное слово будет появляться во множестве только раз. Сначала можно создать пустое множество, используя метод empty, предостав­ ляемый объектом-компаньоном Set: scala> val words = mutable.Set.empty[String] words: scala.collection.mutable.Set[String] = Set() Далее, просто перебирая слова с помощью выражения for, можно преобразовать каждое слово, приведя его символы к нижнему регистру, а затем добавить его в из­ меняемое множество, воспользовавшись оператором +=: scala> for (word <- wordsArray) words += word.toLowerCase scala> words res17: scala.collection.mutable.Set[String] = Set(see, run, spot) Таким образом, в тексте содержатся три уникальных слова: spot, run и see. Наиболее часто используемые методы, применяемые равно к изменяемым и неиз­ меняемым множествам, показаны в табл. 17.1. Таблица 17.1. Наиболее распространенные операторы для работы с множествами Что используется Что этот метод делает val nums = Set(1, 2, 3) Создает неизменяемое множество (nums.toString возвращает Set(1, 2, 3)) nums + 5 Добавляет элемент в неизменяемое множество (возвращает Set(1, 2, 3, 5)) nums – 3 Удаляет элемент из неизменяемого множества (возвращает Set(1, 2)) nums ++ List(5, 6) Добавляет несколько элементов (возвращает Set(1, 2, 3, 5, 6)) nums –– List(1, 2) Удаляет несколько элементов из неизменяемого множества (возвращает Set(3)) nums & Set(1, 3, 5, 7) Выполняет пересечение двух множеств (возвращает Set(1, 3)) nums.size Возвращает размер множества (возвращает 3) nums.contains(3) Проверка включения (возвращает true) import scala.collection.mutable Упрощает доступ к изменяемым коллекциям Продолжение 328 Глава 17 • Работа с другими коллекциями Таблица 17.1 (продолжение) Что используется Что этот метод делает val words = mutable.Set. empty[String] Создает пустое, изменяемое множество (words.toString возвращает Set()) words += "the" Добавляет элемент (words.toString возвращает Set(the)) words –= "the" Удаляет элемент, если он существует (words.toString возвращает Set()) words ++= List("do", "re", "mi") Добавляет несколько элементов (words.toString возвращает Set(do, re, mi)) words ––= List("do", "re") Удаляет несколько элементов (words.toString возвращает Set(mi)) words.clear Удаляет все элементы (words.toString возвращает Set()) Применение отображений Отображения позволяют связать значение с каждым элементом множества. Ото­ бражение и массив используются похожим образом, за исключением того, что вместо индексирования с помощью целых чисел, начинающихся с нуля, можно применить ключи любого вида. Если импортировать пакет с именем mutable, то можно создать пустое изменяемое отображение: scala> val map = mutable.Map.empty[String, Int] map: scala.collection.mutable.Map[String,Int] = Map() Учтите, что при создании отображения следует указать два типа. Первый тип предназначен для ключей отображения, а второй — для их значений. В данном случае ключами являются строки, а значениями — целые числа. Задание записей в отображении похоже на задание записей в массиве: scala> map("hello") = 1 scala> map("there") = 2 scala> map res20: scala.collection.mutable.Map[String,Int] = Map(hello -> 1, there -> 2) По аналогии с этим чтение отображения похоже на чтение массива: scala> map("hello") res21: Int = 1 Чтобы связать все воедино, рассмотрим метод, подсчитывающий количество появлений каждого из слов в строке: scala> def countWords(text: String) = { val counts = mutable.Map.empty[String, Int] 17.2. Множества и отображения 329 for (rawWord <- text.split("[ ,!.]+")) { val word = rawWord.toLowerCase val oldCount = if (counts.contains(word)) counts(word) else 0 counts += (word -> (oldCount + 1)) } counts } countWords: (text: String)scala.collection.mutable.Map[String,Int] scala> countWords("See Spot run! Run, Spot. Run!") res22: scala.collection.mutable.Map[String,Int] = Map(spot -> 2, see -> 1, run -> 3) Этот код работает благодаря тому, что используется изменяемое отображе­ ние по имени counts и отображается каждое слово на количество его появлений в тексте. Для каждого слова в тексте выполняется поиск предыдущего количества появлений слова и его увеличение на единицу, а затем в counts сохраняется новое значение количества. Обратите внимание: проверка того, встречалось ли это слово раньше, выполняется с помощью метода contains. Если counts.contains(word) не возвращает true, значит, слово еще не встречалось и за количество принима­ ется нуль. Многие из наиболее часто используемых методов работы как с изменяемыми, так и с неизменяемыми отображениями показаны в табл. 17.2. Таблица 17.2. Наиболее часто используемые операции для работы с отображениями Что используется Что этот метод делает val nums = Map("i" –> 1, "ii" –> 2) Создает неизменяемое отображение (nums.toString возвращает Map(i –> 1, ii –> 2)) nums + ("vi" –> 6) Добавляет запись в неизменяемое отображение (возвращает Map(i –> 1, ii –> 2, vi –> 6)) nums – "ii" Удаляет запись из неизменяемого отображения (возвращает Map(i –> 1)) nums ++ List("iii" –> 3, "v" –> 5) Добавляет несколько записей (возвращает Map(i –> 1, ii –> 2, iii –> 3, v –> 5)) nums –– List("i", "ii") Удаляет несколько записей из неизменяемого отображения (возвращает Map()) nums.size Возвращает размер отображения (возвращает 2) nums.contains("ii") Проверяет на включение (возвращает true) nums("ii") Извлекает значение по указанному ключу (возвращает 2) Продолжение 330 Глава 17 • Работа с другими коллекциями Таблица 17.2 (продолжение) Что используется Что этот метод делает nums.keys Возвращает ключи (возвращает результат итерации, выполненной над строками "i" и "ii") nums.keySet Возвращает ключи в виде множества (возвращает Set(i, ii)) nums.values Возвращает значения (возвращает Iterable над целыми числами 1 и 2) nums.isEmpty Показывает, является ли отображение пустым (возвращает false) import scala.collection.mutable Упрощает доступ к изменяемым коллекциям val words = mutable.Map. empty[String, Int] Создает пустое изменяемое отображение words += ("one" –> 1) Добавляет запись в отображение из ключа "one" и значения 1 (words.toString возвращает Map(one –> 1)) words –= "one" Удаляет запись из отображения, если она существует (words.toString возвращает Map()) words ++= List("one" –> 1, "two" –> 2, "three" –> 3) Добавляет записи в изменяемое отображение (words.to­ String возвращает Map(one –> 1, two –> 2, three –> 3)) words ––= List("one", "two") Удаляет несколько объектов (words.toString возвращает Map(three –> 3)) Множества и отображения, используемые по умолчанию Для большинства случаев реализаций изменяемых и неизменяемых множеств и отображений, предоставляемых Set(), scala.collection.mutable.Map() и тому подобными фабриками, наверное, вполне достаточно. Реализации, предостав­ ляемые этими фабриками, используют алгоритм ускоренного поиска, в котором обычно задействуется хеш-таблица, поэтому они могут быстро обнаружить наличие или отсутствие объекта в коллекции. Так, фабричный метод scala.collection.mutable.Set() возвращает scala.col­ lection.mutable.HashSet, внутри которого используется хеш-таблица. Анало­ гично этому фабричный метод scala.collection.mutable.Map() возвращает scala.collec­tion.mutable.HashMap. История с неизменяемыми множествами и отображениями несколько слож­ нее. Как показано в табл. 17.3, класс, возвращаемый фабричным методом sca­ la.collection.immutable.Set(), зависит, к примеру, от того, сколько элементов ему было передано. В целях достижения максимальной производительности для множеств, состоящих не более чем из пяти элементов, применяется специальный класс. Но при запросе множества из пяти и более элементов фабричный метод вернет реализацию, использующую хеш. 17.2. Множества и отображения 331 Таблица 17.3. Реализации используемых по умолчанию неизменяемых множеств Количество элементов Реализация 0 scala.collection.immutable.EmptySet 1 scala.collection.immutable.Set1 2 scala.collection.immutable.Set2 3 scala.collection.immutable.Set3 4 scala.collection.immutable.Set4 5 или более scala.collection.immutable.HashSet По аналогии с этим, как следует из данной таблицы, в результате выполнения фабричного метода scala.collection.immutable.Map() будет возвращен нуж­ ный класс в зависимости от того, сколько пар «ключ — значение» ему передано. Как и в случае с множествами, для того чтобы неизменяемые отображения с ко­ личеством элементов меньше пяти достигли максимальной производительности для отображения каждого конкретного размера, используется специальный класс. Но если отображение содержит пять и более пар «ключ — значение», то использу­ ется неизменяемый класс HashMap. В целях обеспечения максимальной производительности используемые по умолчанию реализации неизменяемых классов, показанные в табл. 17.3 и 17.4, работают совместно. Например, если добавляется элемент к EmptySet, то возвра­ щается Set1. Если добавляется элемент к этому Set1, то возвращается Set2. Если затем удалить элемент из Set2, то будет опять получен Set1. Таблица 17.4. Реализации используемых по умолчанию неизменяемых отображений Количество элементов Реализация 0 scala.collection.immutable.EmptyMap 1 scala.collection.immutable.Map1 2 scala.collection.immutable.Map2 3 scala.collection.immutable.Map3 4 scala.collection.immutable.Map4 5 или более scala.collection.immutable.HashMap Отсортированные множества и отображения Иногда может понадобиться множество или отображение, итератор которого возвращает элементы в определенном порядке. Для этого в библиотеке коллек­ ций Scala имеются трейты SortedSet и SortedMap. Они реализованы с помощью классов TreeSet и TreeMap, которые в целях хранения элементов в определенном порядке применяют красно-черное дерево (в случае TreeSet) или ключи (в случае 332 Глава 17 • Работа с другими коллекциями с TreeMap). Порядок определяется трейтом Ordered, неявный экземпляр которого должен быть определен для типа элементов н множества или типа ключей ото­ бражения. Эти классы поставляются в изменяемых и неизменяемых вариантах. Рассмотрим ряд примеров использования TreeSet: scala> import scala.collection.immutable.TreeSet import scala.collection.immutable.TreeSet scala> val ts = TreeSet(9, 3, 1, 8, 0, 2, 7, 4, 6, 5) ts: scala.collection.immutable.TreeSet[Int] = TreeSet(0, 1, 2, 3, 4, 5, 6, 7, 8, 9) scala> val cs = TreeSet('f', 'u', 'n') cs: scala.collection.immutable.TreeSet[Char] = TreeSet(f, n, u) А это ряд примеров использования TreeMap: scala> import scala.collection.immutable.TreeMap import scala.collection.immutable.TreeMap scala> var tm = TreeMap(3 -> 'x', 1 -> 'x', 4 -> 'x') tm: scala.collection.immutable.TreeMap[Int,Char] = Map(1 -> x, 3 -> x, 4 -> x) scala> tm += (2 -> 'x') scala> tm res30: scala.collection.immutable.TreeMap[Int,Char] = Map(1 -> x, 2 -> x, 3 -> x, 4 -> x) 17.3. Выбор между изменяемыми и неизменяемыми коллекциями При решении одних задач лучше работают изменяемые коллекции, а при решении других — неизменяемые. В случае сомнений лучше начать с неизменяемой коллек­ ции, а позже при необходимости перейти к изменяемой, поскольку разобраться в работе неизменяемых коллекций гораздо проще. Иногда может оказаться полезно двигаться в обратном направлении. Если код, использующий изменяемые коллекции, становится сложным и в нем трудно разо­ браться, то следует подумать, стоит ли заменить некоторые коллекции их неизме­ няемыми альтернативами. В частности, если вас волнует вопрос создания копий изменяемых коллекций только в нужных местах или вы слишком много думаете над тем, кто владеет изменяемой коллекцией или что именно она содержит, то имеет смысл заменить некоторые коллекции на их неизменяемые аналоги. Помимо того что в неизменяемых коллекциях потенциально легче разобраться, они, как правило, могут храниться более компактно, чем изменяемые, если коли­ 17.3. Выбор между изменяемыми и неизменяемыми коллекциями 333 чество хранящихся в них элементов невелико. Например, пустое изменяемое ото­ бражение в его представлении по умолчанию в виде HashMap занимает примерно 80 байт, и около 16 дополнительных байт требуется для добавления к нему каждой записи. А пустое неизменяемое отображение Map — это один объект, который со­ вместно используется всеми ссылками, и потому ссылаться на него можно, по сути, одним полем указателя. Более того, в настоящее время библиотека коллекций Scala хранит до четырех записей неизменяемых отображений и множеств в одном объекте, который в за­ висимости от количества хранящихся в коллекции записей обычно занимает от 16 до 40 байт1. Следовательно, для небольших множеств и отображений неизменяе­ мые версии занимают намного меньше места, чем изменяемые. С учетом того, что многие коллекции весьма невелики, переход на их неизменяемый вариант может существенно сэкономить пространство памяти и дать преимущества в произво­ дительности. Чтобы облегчить переход с неизменяемых на изменяемые коллекции и на­ оборот, Scala предоставляет немного «синтаксического сахара». Неизменяемые множества и отображения не поддерживают настоящий метод +=, однако в Scala дается полезная альтернативная интерпретация +=. Когда используется запись a += b и a не поддерживает метод по имени +=, Scala пытается интерпретировать эту запись как a = a + b. Например, неизменяемые множества не поддерживают оператор +=: scala> val people = Set("Nancy", "Jane") people: scala.collection.immutable.Set[String] = Set(Nancy, Jane) scala> people += "Bob" <console>:14: error: value += is not a member of scala.collection.immutable.Set[String] people += "Bob" ^ Но если объявить people в качестве var-, а не val-переменной, то коллекцию можно обновить с помощью операции += даже притом, что она неизменяемая. Сначала создается новая коллекция, а затем переменной people присваивается новое значение для ссылки на новую коллекцию: scala> var people = Set("Nancy", "Jane") people: scala.collection.immutable.Set[String] = Set(Nancy, Jane) scala> people += "Bob" scala> people res34: scala.collection.immutable.Set[String] = Set(Nancy, Jane, Bob) 1 Под одним объектом, как следует из табл. 17.3 и 17.4, понимается экземпляр одного из классов: от Set1 до Set4 или от Map1 до Map4. 334 Глава 17 • Работа с другими коллекциями После этой серии инструкций переменная people ссылается на новое неизме­ няемое множество, содержащее добавленную строку "Bob". Та же идея применима не только к методу +=, но и к любому другому методу, заканчивающемуся знаком =. Вот как тот же самый синтаксис используется с оператором -=, который удаляет элемент из множества, и с оператором ++=, добавляющим в множество коллекцию элементов: scala> people -= "Jane" scala> people ++= List("Tom", "Harry") scala> people res37: scala.collection.immutable.Set[String] = Set(Nancy, Bob, Tom, Harry) Чтобы понять, насколько это полезно, рассмотрим еще раз пример отображения Map из раздела 1.1: var capital = Map("US" -> "Washington", "France" -> "Paris") capital += ("Japan" -> "Tokyo") println(capital("France")) В этом коде используются неизменяемые коллекции. Если захочется попро­ бовать задействовать вместо них изменяемые коллекции, то нужно будет всего лишь импортировать изменяемую версию Map, переопределив таким образом вы­ полненный по умолчанию импорт неизменяемой версии Map: import scala.collection.mutable.Map // Единственное требуемое изменение! var capital = Map("US" -> "Washington", "France" -> "Paris") capital += ("Japan" -> "Tokyo") println(capital("France")) Так легко преобразовать удастся не все примеры, но особая трактовка методов, заканчивающихся знаком равенства, зачастую сокращает объем кода, который нуждается в изменениях. Кстати, эта трактовка синтаксиса работает не только с коллекциями, но и с лю­ быми разновидностями значений. Например, здесь она была использована в от­ ношении чисел с плавающей точкой: scala> var roughlyPi = 3.0 roughlyPi: Double = 3.0 scala> roughlyPi += 0.1 scala> roughlyPi += 0.04 scala> roughlyPi res40: Double = 3.14 17.4. Инициализация коллекций 335 Эффект от такого расширяющего преобразования похож на эффект, полу­ чаемый от операторов присваивания, использующихся в Java (+=, -=, *= и т. п.), однако носит более общий характер, поскольку преобразован может быть каждый оператор, заканчивающийся на =. 17.4. Инициализация коллекций Как уже было показано, наиболее широко востребованный способ создания и инициа­ лизации коллекции — передача исходных элементов фабричному методу, определен­ ному в объекте-компаньоне класса выбранной вами коллекции. Элементы просто помещаются в круглые скобки после имени объекта-компаньона, и компилятор Scala преобразует это в вызов метода apply в отношении объекта-компаньона: scala> List(1, 2, 3) res41: List[Int] = List(1, 2, 3) scala> Set('a', 'b', 'c') res42: scala.collection.immutable.Set[Char] = Set(a, b, c) scala> import scala.collection.mutable import scala.collection.mutable scala> mutable.Map("hi" -> 2, "there" -> 5) res43: scala.collection.mutable.Map[String,Int] = Map(hi -> 2, there -> 5) scala> Array(1.0, 2.0, 3.0) res44: Array[Double] = Array(1.0, 2.0, 3.0) Чаще всего можно позволить компилятору Scala вывести тип элемента коллек­ ции из элементов, переданных ее фабричному методу. Но иногда может понадобить­ ся создать коллекцию, указав притом тип, отличающийся от того, который выберет компилятор. Это особенно касается изменяемых коллекций. Рассмотрим пример: scala> import scala.collection.mutable import scala.collection.mutable scala> val stuff = mutable.Set(42) stuff: scala.collection.mutable.Set[Int] = Set(42) scala> stuff += "abracadabra" <console>:16: error: type mismatch; found : String("abracadabra") required: Int stuff += "abracadabra" ^ 336 Глава 17 • Работа с другими коллекциями Проблема здесь заключается в том, что переменной stuff был задан тип элемен­ та Int. Если вы хотите, чтобы типом элемента был Any, то это нужно указать явно, поместив тип элемента в квадратные скобки: scala> val stuff = mutable.Set[Any](42) stuff: scala.collection.mutable.Set[Any] = Set(42) Еще одна особая ситуация возникает при желании инициализировать коллек­ цию с помощью другой коллекции. Допустим, есть список, но нужно получить коллекцию TreeSet , содержащую элементы, которые находятся в нем. Список выглядит так: scala> val colors = List("blue", "yellow", "red", "green") colors: List[String] = List(blue, yellow, red, green) Передать список названий цветов фабричному методу для TreeSet невозможно: scala> import scala.collection.immutable.TreeSet import scala.collection.immutable.TreeSet scala> val treeSet = TreeSet(colors) <console>:16: error: No implicit Ordering defined for List[String]. val treeSet = TreeSet(colors) ^ Вместо этого вам нужно будет преобразовать список в TreeSet с помощью ме­ тода to: scala> val treeSet = colors to TreeSet treeSet: scala.collection.immutable.TreeSet[String] = TreeSet(blue, green, red, yellow) Метод to принимает в качестве параметра объект-компаньон коллекции. С его помощью вы можете преобразовать любую коллекцию в другую. Преобразование в массив или список Помимо универсального метода to для преобразования коллекции в другую про­ извольную коллекцию, вы также можете использовать более конкретные методы для преобразования в наиболее распространенные типы коллекций Scala. Как было показано ранее, чтобы инициализировать новый список с помощью другой коллекции, следует просто вызвать в отношении этой коллекции метод toList: scala> treeSet.toList res50: List[String] = List(blue, green, red, yellow) Или же, если нужен массив, вызвать метод toArray: scala> treeSet.toArray res51: Array[String] = Array(blue, green, red, yellow) 17.4. Инициализация коллекций 337 Обратите внимание: несмотря на неотсортированность исходного списка colors, элементы в списке, создаваемом вызовом toList в отношении TreeSet, стоят в алфавитном порядке. Когда в отношении коллекции вызывается toList или toArray, порядок следования элементов в списке, получающемся в результате, будет таким же, как и порядок следования элементов, создаваемый итератором на этой коллекции. Поскольку итератор, принадлежащий типу TreeSet[String], будет выдавать строки в алфавитном порядке, то они в том же порядке появятся и в списке, который создается в результате вызова toList в отношении объекта TreeSet. Разница между xs to List и xs.toList в том, что реализация toList может быть переопределена конкретным типом коллекции xs. Это делает преобразование ее элементов в список более эффективным по сравнению с реализацией по умол­ чанию, копирующей все элементы коллекции. Например, коллекция ListBuffer переопределяет метод toList с помощью реализации, которая имеет постоянные время выполнения и объем памяти. Но следует иметь в виду, что преобразование в списки или массивы, как пра­ вило, требует копирования всех элементов коллекции и потому для больших кол­ лекций может выполняться довольно медленно. Но иногда это все же приходится делать из-за уже существующего API. Кроме того, многие коллекции содержат всего несколько элементов, а при этом потери в скорости незначительны. Преобразования между изменяемыми и неизменяемыми множествами и отображениями Иногда возникает еще одна ситуация, требующая преобразования изменяемого множества либо отображения в неизменяемый аналог или наоборот. Выполнить эти задачи следует с помощью метода, показанного чуть ранее. Преобразование не­ изменяемого множества TreeSet из предыдущего примера в изменяемый и обратно в неизменяемый выполняется так: scala> import scala.collection.mutable import scala.collection.mutable scala> treeSet res52: scala.collection.immutable.TreeSet[String] = TreeSet(blue, green, red, yellow) scala> val mutaSet = treeSet to mutable.Set mutaSet: scala.collection.mutable.Set[String] = Set(red, blue, green, yellow) scala> val immutaSet = mutaSet to Set immutaSet: scala.collection.immutable.Set[String] = Set(red, blue, green, yellow) 338 Глава 17 • Работа с другими коллекциями Ту же технику можно применить для преобразований между изменяемыми и неизменяемыми отображениями: scala> val muta = mutable.Map("i" -> 1, "ii" -> 2) muta: scala.collection.mutable.Map[String,Int] = Map(ii -> 2,i -> 1) scala> val immu = muta to Map immu: scala.collection.immutable.Map[String,Int] = Map(ii -> 2, i -> 1) 17.5. Кортежи Согласно описанию, которое дано в шаге 9 в главе 3, кортеж объединяет фикси­ рованное количество элементов, позволяя выполнять их передачу в виде единого целого. В отличие от массива или списка, кортеж может содержать объекты раз­ личных типов. Вот как, к примеру, выглядит кортеж, содержащий целое число, строку и консоль: (1, "hello", Console) Кортежи избавляют вас от скуки, возникающей при определении упрощенных, насыщенных данными классов. Даже притом, что определить класс не составляет особого труда, все же требуется приложить определенное количество порой на­ прасных усилий. Кортежи избавляют вас от необходимости выбирать имя класса, область видимости, в которой определяется класс, и имена для членов класса. Если класс просто хранит целое число и строку, то добавление класса по имени AnIntegerAndAString особой ясности не внесет. Поскольку кортежи могут сочетать объекты различных типов, они не являются наследниками класса Iterable. Если потребуется сгруппировать ровно одно целое число и ровно одну строку, то понадобится кортеж, а не List или Array. Довольно часто кортежи применяются для возвращения из метода нескольких значений. Рассмотрим, к примеру, метод, который выполняет поиск самого длин­ ного слова в коллекции и возвращает наряду с ним его индекс: def longestWord(words: Array[String]): (String, Int) = { var word = words(0) var idx = 0 for (i <- 1 until words.length) if (words(i).length > word.length) { word = words(i) idx = i } (word, idx) } 17.5. Кортежи 339 А вот пример использования этого метода: scala> val longest = longestWord("The quick brown fox".split(" ")) longest: (String, Int) = (quick,1) Функция longestWord выполняет здесь два вычисления, получая при этом слово word, являющееся в массиве самым длинным, и его индекс idx. Во избежание усложнений в функции предполагается, что список имеет хотя бы одно слово, и она отдает предпочтение тому из одинаковых по длине слов, которое стоит в списке первым. Как только функция выберет, какое слово и какой индекс возвращать, она возвращает их вместе, используя синтаксис кортежа (word, idx). Доступ к элементам кортежа можно получить с помощью метода _1, чтобы полу­ чить первый элемент, метода _2, чтобы получить второй, и т. д.: scala> longest._1 res53: String = quick scala> longest._2 res54: Int = 1 Кроме того, значение каждого элемента кортежа можно присвоить собственной переменной1: scala> val (word, idx) = longest word: String = quick idx: Int = 1 scala> word res55: String = quick Кстати, если не поставить круглые скобки, то будет получен совершенно иной результат: scala> val word, idx = longest word: (String, Int) = (quick,1) idx: (String, Int) = (quick,1) Представленный синтаксис дает множественное определение одного и того же выражения. Каждая переменная инициализируется собственным вычислением выражения в правой части. В данном случае неважно, что это выражение вычис­ ляется в кортеж. Обе переменные инициализируются всем кортежем целиком. Ряд примеров, в которых удобно применять множественные определения, можно увидеть в главе 18. Следует заметить, что кортежи довольно просты в использовании. Они очень хорошо подходят для объединения данных, не имеющих никакого другого смысла, 1 Этот синтаксис является, по сути, особым случаем сопоставления с образцом, подробно рассмотренным в разделе 15.7. 340 Глава 17 • Работа с другими коллекциями кроме «А и Б». Но когда объединение имеет какое-либо значение или нужно до­ бавить к объединению некие методы, то лучше пойти дальше и создать класс. На­ пример, не стоит использовать кортеж из трех значений, чтобы объединить месяц, день и год, — нужно создать класс Date. Так вы явно обозначите свои намерения, благодаря чему код станет более понятным для читателей, и это позволит компи­ лятору и средствам самого языка помочь вам отловить возможные ошибки. Резюме В данной главе мы дали обзор библиотеки коллекций Scala и рассмотрели наибо­ лее важные ее классы и трейты. Опираясь на эти знания, вы сможете эффективно работать с коллекциями Scala и будете знать, что именно нужно искать в Scaladoc, когда возникнет необходимость в дополнительных сведениях. Более подробную информацию о коллекциях Scala можно найти в главах 24 и 25. А в следующей главе мы переключим внимание с библиотеки Scala на сам язык, и нам предстоит рассмотреть имеющуюся в Scala поддержку изменяемых объектов. 18 Изменяемые объекты В предыдущих главах в центре внимания были функциональные (неизменяемые) объекты. Дело в том, что идея использования объектов без какого-либо изменяе­ мого состояния заслуживала более пристального рассмотрения. Но в Scala также вполне возможно определять объекты с изменяемым состоянием. Подобные изменяемые объекты зачастую появляются естественным образом, когда нужно смоделировать объекты из реального мира, которые со временем подвергаются изменениям. В этой главе мы раскроем суть изменяемых объектов и рассмотрим синтакси­ ческие средства для их выражения, предлагаемые Scala. Кроме того, рассмотрим большой пример моделирования дискретных событий, в котором используются из­ меняемые объекты, а также описан внутренний предметно-ориентированный язык (domain-specific language, DSL), предназначенный для определения моделируемых цифровых электронных схем. 18.1. Что делает объект изменяемым Принципиальную разницу между чисто функциональным и изменяемым объекта­ ми можно проследить, даже не изучая реализацию объектов. При вызове метода или получении значения поля по указателю в отношении функционального объ­ екта вы всегда будете получать один и тот же результат. Например, если есть следующий список символов: val cs = List('a', 'b', 'c') то применение cs.head всегда будет возвращать 'a'. То же самое произойдет, даже если между местом определения cs и местом, где будет применено обращение cs.head, над списком cs будет проделано произвольное количество других операций. Что же касается изменяемого объекта, то результат вызова метода или об­ ращения к полю может зависеть от того, какие операции были ранее выполнены в отношении объекта. Хороший пример изменяемого объекта — банковский счет. Его упрощенная реализация показана в листинге 18.1. 342 Глава 18 • Изменяемые объекты Листинг 18.1. Изменяемый класс банковского счета class BankAccount { private var bal: Int = 0 def balance: Int = bal def deposit(amount: Int) = { require(amount > 0) bal += amount } def withdraw(amount: Int): Boolean = if (amount > bal) false else { bal -= amount true } } В классе BankAccount определяются приватная переменная bal и три публичных метода: balance возвращает текущий баланс, deposit добавляет к bal заданную сум­ му, withdraw предпринимает попытку вывести из bal заданную сумму, гарантируя при этом, что баланс не станет отрицательным. Возвращаемое withdraw значение, имеющее тип Boolean, показывает, были ли запрошенные средства успешно вы­ ведены. Даже если ничего не знать о внутренней работе класса BankAccount, все же можно сказать, что экземпляры BankAccounts являются изменяемыми объек­ тами: scala> val account = new BankAccount account: BankAccount = BankAccount@d504137 scala> account deposit 100 scala> account withdraw 80 res1: Boolean = true scala> account withdraw 80 res2: Boolean = false Обратите внимание: в двух последних операциях вывода средств в ходе работы с программой были возвращены разные результаты. По выполнении первой опера­ ции было возвращено значение true, поскольку на банковском счету содержался достаточный объем, позволяющий вывести средства. Вторая операция вывода средств была такой же, как и первая, однако по ее выполнении было возвращено значение false, поскольку баланс счета уменьшился настолько, что уже не мог покрыть запрошенные средства. Исходя из этого, мы понимаем, что банковским счетам присуще изменяемое состояние, так как в результате одной и той же опе­ рации в разное время получаются разные результаты. 18.2. Переназначаемые переменные и свойства 343 Можно подумать, будто изменяемость BankAccount априори не вызывает со­ мнений, поскольку в нем содержится определение var-переменной. Изменяемость и var-переменные обычно идут рука об руку, но ситуация не всегда бывает столь очевидной. Например, класс может быть изменяемым и без определения или насле­ дования каких-либо var-переменных, поскольку перенаправляет вызовы методов другим объектам, которые находятся в изменяемом состоянии. Может сложиться и обратная ситуация: класс содержит var-переменные и все же является чисто функ­ циональным. Как образец можно привести класс, кэширующий результаты дорогой операции в поле в целях оптимизации. Чтобы подобрать пример, предположим на­ личие неоптимизированного класса Keyed с дорогой операцией computeKey: class Keyed { def computeKey: Int = ... // Это займет некоторое время ... } При условии, что computeKey не читает и не записывает никаких var-переменных, эффективность Keyed можно увеличить, добавив кэш: class MemoKeyed extends Keyed { private var keyCache: Option[Int] = None override def computeKey: Int = { if (!keyCache.isDefined) keyCache = Some(super.computeKey) keyCache.get } } Использование MemoKeyed вместо Keyed может ускорить работу: когда результат выполнения операции computeKey будет запрошен повторно, вместо еще одного за­ пуска computeKey может быть возвращено значение, сохраненное в поле keyCache. Но за исключением такого ускорения поведение классов Keyed и MemoKeyed абсо­ лютно одинаково. Следовательно, если Keyed является чисто функциональным классом, то таковым будет и класс MemoKeyed, даже притом, что содержит перена­ значаемую переменную. 18.2. Переназначаемые переменные и свойства В отношении переназначаемой переменной допускается выполнение двух ос­ новных операции: получения ее значения или присваивания ей нового. В таких библиотеках, как JavaBeans, эти операции часто инкапсулированы в отдельные методы считывания (getter) и записи значения (setter), которые необходимо объ­ являть явно. В Scala каждая var-переменная представляет собой неприватный член како­ го-либо объекта, в отношении которого в нем неявно определены методы геттер и сеттер. Но названия таких методов отличаются от предписанных соглашениями Java. Метод получения значения (геттер) var-переменной x называется просто x, а метод присваивания значения (сеттер) — x_=. 344 Глава 18 • Изменяемые объекты Например, появляясь в классе, определение var-переменной: var hour = 12 вдобавок к переназначаемому полю создает геттер hour и сеттер hour_=. У поля всегда имеется пометка private[this], означающая, что значение для него может устанавливаться только из содержащего это поле объекта. В то же время геттер и сеттер обеспечивают исходной var -переменной некоторую видимость. Если var-переменная объявлена публичной (public), то таковыми же являются и геттер и сеттер. Если она является защищенной (protected), то и они тоже, и т. д. Рассмотрим, к примеру, класс Time , показанный в листинге 18.2, в котором определены две публичные var-переменные с именами hour и minute. Листинг 18.2. Класс с публичными var-переменными class Time { var hour = 12 var minute = 0 } Эта реализация в точности соответствует определению класса, показанного в листинге 18.3. В данном определении имена локальных полей h и m были выбраны произвольно, чтобы не конфликтовали с уже используемыми именами. Листинг 18.3. Как публичные var-переменные расширяются в геттер и сеттер class Time { private[this] var h = 12 private[this] var m = 0 def hour: Int = h def hour_=(x: Int) = { h = x } def minute: Int = m def minute_=(x: Int) = { m = x } } Интересным аспектом такого расширения var-переменных в геттер и сеттер является то, что вместо определения var -переменной можно также выбрать вариант непосредственного определения этих методов доступа. Он позволяет как угодно интерпретировать операции доступа к переменной и присваивания ей значения. Например, вариант класса Time, показанный в листинге 18.4, со­ держит необходимые условия, благодаря которым перехватываются все при­ сваивания недопустимых значений часам и минутам, хранящимся в переменных hour и minute. Листинг 18.4. Непосредственное определение геттера и сеттера class Time { private[this] var h = 12 private[this] var m = 0 18.2. Переназначаемые переменные и свойства 345 def hour: Int = h def hour_= (x: Int) = { require(0 <= x && x < 24) h = x } def minute = m def minute_= (x: Int) = { require(0 <= x && x < 60) m = x } } В некоторых языках для этих похожих на переменные величин, не являющихся простыми переменными из-за того, что их геттер и сеттер могут быть переопределе­ ны, имеются специальные синтаксические конструкции. Например, в C# эту роль играют свойства. По сути, принятое в Scala соглашение о постоянной интерпре­ тации переменной как имеющей пару геттер и сеттер предоставляет вам такие же возможности, что и свойства C#, но при этом не требует какого-то специального синтаксиса. Свойства могут иметь множество назначений. В примере, показанном в ли­ стинге 18.4 (см. выше), методы присваивания значений навязывают соблюдение конкретных условий, защищая таким образом переменную от присваивания ей недопустимых значений. Кроме того, свойства позволяют регистрировать все об­ ращения к переменной со стороны геттера и сеттера. Или же можно объединять переменные с событиями, например уведомляя с помощью методов-подписчиков о каждом изменении переменной (соответствующие примеры будут показаны в главе 35). Вдобавок возможно, а иногда и полезно определять геттер и сеттер без связан­ ных с ними полей. Например, в листинге 18.5 показан класс Thermometer, в котором инкапсулирована переменная temperature, позволяющая читать и обновлять ее значение. Температурные значения могут выражаться в градусах Цельсия или Фаренгейта. Этот класс позволяет получать и устанавливать значение температуры в любых единицах измерения. Листинг 18.5. Определение геттера и сеттера без связанного с ними поля class Thermometer { var celsius: Float = _ def fahrenheit = celsius * 9 / 5 + 32 def fahrenheit_= (f: Float) = { celsius = (f - 32) * 5 / 9 } override def toString = s"${fahrenheit}F/${celsius}C" } В первой строке тела этого класса определяется var-переменная celsius, в кото­ рой будет храниться значение температуры в градусах Цельсия. Для переменной 346 Глава 18 • Изменяемые объекты celsius изначально устанавливается значение по умолчанию: в качестве инициа­ лизирующего значения для нее устанавливается знак _. Точнее, инициализатором поля = _ данному полю присваивается нулевое значение. Суть нулевого значения зависит от типа поля. Для числовых типов это 0, для булевых — false, а для ссы­ лочных — null. Получается то же самое, что и при определении в Java некой пере­ менной без инициализатора. Учтите, что в Scala просто отбросить инициализатор = _ нельзя. Если исполь­ зовать код: var celsius: Float то получится объявление абстрактной, а не инициализированной переменной1. За определением переменной celsius следуют геттер по имени fahrenheit и сеттер fahrenheit_=, которые обращаются к той же температуре, но в градусах Фаренгейта. В листинге нет отдельного поля, содержащего значение текущей температуры в таких градусах. Вместо этого геттер и сеттер для значений в граду­ сах Фаренгейта выполняют автоматическое преобразование из градусов Цельсия и в них же соответственно. Пример взаимодействия с объектом Thermometer вы­ глядит следующим образом: scala> val t = new Thermometer t: Thermometer = 32.0F/0.0C scala> t.celsius = 100 mutated t.celsius scala> t res3: Thermometer = 212.0F/100.0C scala> t.fahrenheit = -40 mutated t.fahrenheit scala> t res4: Thermometer = -40.0F/-40.0C 18.3. Практический пример: моделирование дискретных событий Далее в главе на расширенном примере будут показаны интересные способы возможного сочетания изменяемых объектов с функциями, являющимися зна­ чениями первого класса. Речь идет о проектировании и реализации симулятора цифровых схем. Эта задача разбита на несколько подзадач, каждая из которых интересна сама по себе. 1 Абстрактные переменные будут рассматриваться в главе 20. 18.4. Язык для цифровых схем 347 Сначала мы покажем весьма лаконичный язык для цифровых схем. Его опреде­ ление подчеркнет общий метод встраивания предметно-ориентированных языков (domain-specific languages, DSL) в язык их реализации, подобный Scala. Затем представим простую, но всеобъемлющую среду для моделирования дискретных со­ бытий. Ее основной задачей будет являться отслеживание действий, выполняемых в ходе моделирования. И наконец, мы покажем, как структурировать и создавать программы дискретного моделирования. Цели создания таких программ — моде­ лирование физических объектов объектами-симуляторами и использование среды для моделирования физического времени. Этот пример взят из классического учебного пособия Structure and Interpretation of Computer Programs1. Наша ситуация отличается тем, что языком реализации является Scala, а не Scheme, и тем, что различные аспекты примера структурно вы­ делены в четыре программных уровня. Первый относится к среде моделирования, второй — к основному пакету моделирования схем, третий касается библиотеки определяемых пользователем электронных схем, а четвертый, последний уровень предназначен для каждой моделируемой схемы как таковой. Каждый уровень вы­ ражен в виде класса, и более конкретные уровни являются наследниками более общих. РЕЖИМ УСКОРЕННОГО ЧТЕНИЯ На разбор примера моделирования дискретных событий, представленного в данной главе, потребуется некоторое время. Если вы считаете, что его лучше было бы потратить на дальнейшее изучение самого языка Scala, то можете перейти к чтению следующей главы. 18.4. Язык для цифровых схем Начнем с краткого языка для описания цифровых схем, состоящих из проводников и функциональных блоков. По проводникам проходят сигналы, преобразованием которых занимаются функциональные блоки. Сигналы представлены булевыми значениями, где true используется для сигнала высокого уровня, а false — для сигнала низкого уровня. Основные функциональные блоки (или логические элементы) показаны на рис. 18.1. Блок «НЕ» выполняет инверсию входного сигнала. Блок «И» устанавливает на своем выходе конъюнкцию сигналов на входе. Блок «ИЛИ» устанавливает на своем выходе дизъюнкцию сигналов на входе. 1 Abelson H., Sussman G. J. Structure and Interpretation of Computer Programs. 2nd ed. — The MIT Press, 1996. 348 Глава 18 • Изменяемые объекты Рис. 18.1. Логические элементы Этих логических элементов вполне достаточно для построения всех остальных функциональных блоков. У логических элементов существуют задержки, следова­ тельно, сигнал на выходе элемента будет изменяться через некоторое время после изменения сигнала на его входе. Элементы цифровой схемы будут описаны с применением набора классов и функций Scala. Сначала создадим класс Wire для проводников. Их можно скон­ струировать следующим образом: val a = new Wire val b = new Wire val c = new Wire или то же самое, но покороче: val a, b, c = new Wire Затем понадобятся три процедуры, создающие логические элементы: def inverter(input: Wire, output: Wire) def andGate(a1: Wire, a2: Wire, output: Wire) def orGate(o1: Wire, o2: Wire, output: Wire) Необычно то, что в силу имеющегося в Scala функционального уклона логи­ ческие элементы в этих процедурах вместо возвращения в качестве результата сконструированных элементов конструируются в виде побочных эффектов. На­ пример, вызов inverter(a, b) помещает элемент «НЕ» между проводниками a и b. Получается, что данная конструкция, основанная на побочном эффекте, позволяет упростить постепенное создание все более сложных схем. Вдобавок притом, что имена большинства методов происходят от глаголов, имена этих методов проис­ ходят от существительных, показывающих, какой именно элемент создается. Тем самым отображается декларативная природа DSL-языка: он должен давать описа­ ние электронной схемы, а не выполняемых в ней действий. Из логических элементов могут создаваться более сложные функциональные блоки. Например, метод, показанный в листинге 18.6, создает полусумматор. Метод halfAdder получает два входных параметра, a и b, и выдает сумму s, определяемую как s = (a + b) % 2, и перенос в следующий разряд c, определяемый как c = (a + b) / 2. Схема полусумматора показана на рис. 18.2. Листинг 18.6. Метод halfAdder def halfAdder(a: Wire, b: Wire, s: Wire, c: Wire) = { val d, e = new Wire orGate(a, b, d) 18.4. Язык для цифровых схем 349 andGate(a, b, c) inverter(c, e) andGate(d, e, s) } Рис. 18.2. Схема полусумматора Обратите внимание: halfAdder является параметризованным функциональным блоком, как и три метода, составляющие логические элементы. Его можно исполь­ зовать для составления более сложных схем. Например, в листинге 18.7 определя­ ется полный одноразрядный сумматор (рис. 18.3), который получает два входных параметра, a и b, а также перенос из младшего разряда (carry-in) cin и выдает на выходе значение sum, определяемое как sum = (a + b + cin) % 2, и перенос в старший разряд (carry-out), определяемый как cout = (a + b + cin) / 2. Листинг 18.7. Метод fullAdder def fullAdder(a: Wire, b: Wire, cin: Wire, sum: Wire, cout: Wire) = { val s, c1, c2 = new Wire halfAdder(a, cin, s, c1) halfAdder(b, s, sum, c2) orGate(c1, c2, cout) } Рис. 18.3. Схема сумматора Класс Wire и функции inverter, andGate и orGate представляют собой крат­ кий язык, с помощью которого пользователи могут определять цифровые схемы. Это неплохой пример внутреннего DSL — предметно-ориентированного языка, 350 Глава 18 • Изменяемые объекты определенного в виде не какой-то самостоятельной реализации, а библиотеки в языке его реализации. Реализацию DSL-языка электронных логических схем еще предстоит разра­ ботать. Цель определения схемы средствами DSL — моделирование электронной схемы, поэтому вполне разумно будет основой реализации DSL сделать общий API для моделирования дискретных событий. В следующих двух разделах мы предста­ вим первый API моделирования, а затем в качестве надстройки над ним покажем реализацию DSL-языка электронных логических схем. 18.5. API моделирования API моделирования показан в листинге 18.8. Он состоит из класса Simulation в пакете org.stairwaybook.simulation. Наследниками этого класса являются конкретные библиотеки моделирования, дополняющие его предметно-ориенти­ рованную функциональность. В данном разделе представлены элементы класса Simulation. Листинг 18.8. Класс Simulation abstract class Simulation { type Action = () => Unit case class WorkItem(time: Int, action: Action) private var curtime = 0 def currentTime: Int = curtime private var agenda: List[WorkItem] = List() private def insert(ag: List[WorkItem], item: WorkItem): List[WorkItem] = { if (ag.isEmpty || item.time < ag.head.time) item :: ag else ag.head :: insert(ag.tail, item) } def afterDelay(delay: Int)(block: => Unit) = { val item = WorkItem(currentTime + delay, () => block) agenda = insert(agenda, item) } private def next() = { (agenda: @unchecked) match { case item :: rest => agenda = rest curtime = item.time 18.5. API моделирования 351 item.action() } } def run() = { afterDelay(0) { println("*** simulation started, time = " + currentTime + " ***") } while (!agenda.isEmpty) next() } } При моделировании дискретных событий действия, определенные пользо­ вателем, выполняются в указанные моменты времени. Действия, определенные конкретными подклассами моделирования, имеют один и тот же тип: type Action = () => Unit Эта инструкция определяет Action в качестве псевдонима типа процедуры, принимающей пустой список параметров и возвращающей тип Unit. Тип Action является членом типа класса Simulation. Его можно рассматривать как гораздо более легко читаемое имя для типа () => Unit. Члены типов будут подробно рас­ смотрены в разделе 20.6. Момент времени, в который выполняется действие, является моментом моде­ лирования — он не имеет ничего общего со временем «настенных часов». Моменты времени моделирования представлены просто как целые числа. Текущий момент хранится в приватной переменной: private var curtime: Int = 0 У переменной есть публичный метод доступа, извлекающий текущее время: def currentTime: Int = curtime Это сочетание приватной переменной с публичным методом доступа служит гарантией невозможности изменить текущее время за пределами класса Simulation. Обычно не нужно, чтобы моделируемые вами объекты манипулировали текущим временем, за исключением, возможно, случая, когда моделируется путешествие во времени. Действие, которое должно быть выполнено в указанное время, называется рабочим элементом. Рабочие элементы реализуются следующим классом: case class WorkItem(time: Int, action: Action) Класс WorkItem сделан case-классом, чтобы иметь возможность получить следу­ ющие синтаксические удобства: для создания экземпляров класса можно использо­ вать фабричный метод WorkItem и при этом без каких-либо усилий получить средства доступа к параметрам конструктора time и action. Следует также заметить, что класс WorkItem вложен в класс Simulation. Вложенные классы в Scala обрабатываются аналогично Java. Более подробно этот вопрос рассматривается в разделе 20.7. 352 Глава 18 • Изменяемые объекты В классе Simulation хранится план действий (agenda) всех остальных, еще не вы­ полненных рабочих элементов. Они отсортированы по моделируемому времени, в которое должны быть запущены: private var agenda: List[WorkItem] = List() Список agenda будет храниться в надлежащем отсортированном порядке благо­ даря использованию метода insert, который обновляет этот список. Вызов метода insert в качестве единственного способа добавить рабочий элемент к плану дей­ ствий можно увидеть в методе afterDelay: def afterDelay(delay: Int)(block: => Unit) = { val item = WorkItem(currentTime + delay, () => block) agenda = insert(agenda, item) } Как следует из названия, этот метод вставляет действие, задаваемое блоком, в план действий, планируя время задержки его выполнения delay после текущего момента моделируемого времени. Например, следующий вызов создаст новый рабочий элемент к выполнению в моделируемое время currentTime + delay: afterDelay(delay) { count += 1 } Код, предназначенный для выполнения, содержится во втором аргументе метода. Формальный параметр имеет тип => Unit, то есть это вычисление типа Unit, передаваемое по имени. Следует напомнить, что параметры, передаваемые по имени (by-name parameters), при передаче методу не вычисляются. Следова­ тельно, в показанном ранее вызове значение count будет увеличено на единицу, только когда среда моделирования вызовет действие, сохраненное в рабочем эле­ менте. Обратите внимание: afterDelay — каррированная функция. Это хороший пример разъясненного в разделе 9.5 принципа, согласно которому карринг может использоваться для выполнения вызовов методов, больше похожих на встроенный синтаксис языка. Созданный рабочий элемент еще нужно вставить в план действий. Это делается с помощью метода insert, в котором поддерживается предварительное условие отсортированности плана по времени: private def insert(ag: List[WorkItem], item: WorkItem): List[WorkItem] = { if (ag.isEmpty || item.time < ag.head.time) item :: ag else ag.head :: insert(ag.tail, item) } Ядро класса Simulation определяется методом run: def run() = { afterDelay(0) { println("*** simulation started, time = " + currentTime + " ***") } 18.5. API моделирования 353 while (!agenda.isEmpty) next() } Этот метод периодически берет первый элемент из плана действий, удаляет его из данного плана и выполняет. Он продолжает свою работу до тех пор, пока в плане не останется элементов для выполнения. Каждый шаг выполняется с помощью вызова метода next, имеющего такое определение: private def next() = { (agenda: @unchecked) match { case item :: rest => agenda = rest curtime = item.time item.action() } } В методе next текущий план действий разбивается с помощью сопоставления с образцом на первый элемент item и весь остальной список рабочих элементов rest. Первый элемент из текущего плана удаляется, моделируемое время curtime устанавливается на время рабочего элемента, и выполняется действие рабочего элемента. Обратите внимание: next может быть вызван, только если план действий еще не пуст. Варианта для пустого плана нет, поэтому при попытке запуска next в от­ ношении пустого списка agenda будет выдано исключение MatchError. В действительности же компилятор Scala обычно выдает предупреждение, что для списка не указан один из возможных паттернов: Simulator.scala:19: warning: match is not exhaustive! missing combination Nil agenda match { ^ one warning found В данном случае неуказанный вариант никакой проблемы не создает, поскольку известно, что next вызывается только в отношении непустого плана действий. По­ этому может возникнуть желание отключить предупреждение. Как было показано в разделе 15.5, это можно сделать, добавив к выражению селектора сопоставления с образцом аннотацию @unchecked. Именно поэтому в коде Simulation используется (agenda: @unchecked) match, а не agenda match. И это правильно. Объем кода для среды моделирования может показаться весьма скромным. Может возникнуть вопрос: а как эта среда вообще может под­ держивать содержательное моделирование, если всего лишь выполняет список рабочих элементов? В действительности же эффективность среды моделирования определяется тем фактом, что действия, сохраненные в рабочих элементах, в ходе своего выполнения могут самостоятельно добавлять следующие рабочие элементы в план действий. Тем самым открывается возможность получить из вычисления простых начальных действий довольно продолжительную симуляцию. 354 Глава 18 • Изменяемые объекты 18.6. Моделирование электронной логической схемы Следующим шагом станет использование среды моделирования в целях реализа­ ции предметно-ориентированного языка для логических схем, показанного в раз­ деле 18.4. Следует напомнить, что DSL логических схем состоит из класса для проводников и методов, создающих логические элементы «И», «ИЛИ» и «НЕ». Все это содержится в классе BasicCircuitSimulation, который расширяет среду моделирования. Он показан в листинге 18.9. Листинг 18.9. Первая половина класса BasicCircuitSimulation package org.stairwaybook.simulation abstract class BasicCircuitSimulation extends Simulation { def InverterDelay: Int def AndGateDelay: Int def OrGateDelay: Int class Wire { private var sigVal = false private var actions: List[Action] = List() def getSignal = sigVal def setSignal(s: Boolean) = if (s != sigVal) { sigVal = s actions foreach (_ ()) } def addAction(a: Action) = { actions = a :: actions a() } } def inverter(input: Wire, output: Wire) = { def invertAction() = { val inputSig = input.getSignal afterDelay(InverterDelay) { output setSignal !inputSig } } input addAction invertAction } def andGate(a1: Wire, a2: Wire, output: Wire) = { def andAction() = { 18.6. Моделирование электронной логической схемы 355 val a1Sig = a1.getSignal val a2Sig = a2.getSignal afterDelay(AndGateDelay) { output setSignal (a1Sig & a2Sig) } } a1 addAction andAction a2 addAction andAction } def orGate(o1: Wire, o2: Wire, output: Wire) = { def orAction() = { val o1Sig = o1.getSignal val o2Sig = o2.getSignal afterDelay(OrGateDelay) { output setSignal (o1Sig | o2Sig) } } o1 addAction orAction o2 addAction orAction } def probe(name: String, wire: Wire) = { def probeAction() = { println(name + " " + currentTime + " new-value = " + wire.getSignal) } wire addAction probeAction } } В классе BasicCircuitSimulation объявляются три абстрактных метода, представляющих задержки основных логических элементов: InverterDelay , AndGateDelay и OrGateDelay. Настоящие задержки на уровне этого класса неиз­ вестны, поскольку зависят от технологии моделируемых логических микросхем. Поэтому задержки в классе BasicCircuitSimulation остаются абстрактными, и их конкретное определение делегируется подклассам1. Далее мы рассмотрим реали­ зацию остальных членов класса BasicCircuitSimulation. Класс Wire Проводникам нужно поддерживать три основных действия: getSignal: Boolean возвращает текущий сигнал в проводнике; setSignal(sig: Boolean) выставляет сигнал проводника в sig; 1 Имена этих методов задержки начинаются с прописных букв, поскольку представляют собой константы. Но это методы, и они могут быть переопределены в подклассах. Как те же вопросы решаются с помощью val-переменных, мы покажем в разделе 20.3. 356 Глава 18 • Изменяемые объекты addAction(p: Action) прикрепляет указанную процедуру p к действиям про­ водника. Замысел заключается в том, чтобы все процедуры действий, прикре­ пленные к какому-либо проводнику, выполнялись всякий раз, когда сигнал на проводнике изменяется. Как правило, действия добавляются к проводнику подключенными к нему компонентами. Прикрепленное действие выполняется в момент его добавления к проводнику, а после этого всякий раз при изменении сигнала в проводнике. Реализация класса Wire имеет следующий вид: class Wire { private var sigVal = false private var actions: List[Action] = List() def getSignal = sigVal def setSignal(s: Boolean) = if (s != sigVal) { sigVal = s actions foreach (_ ()) } } def addAction(a: Action) = { actions = a :: actions a() } Состояние проводника формируется двумя приватными переменными. Пере­ менная sigVal представляет текущий сигнал, а переменная actions — процедуры действий, прикрепленные в данный момент к проводнику. В реализациях методов представляет интерес только та часть, которая относится к методу setSignal: когда сигнал проводника изменяется, в переменной sigVal сохраняется новое значение. Кроме того, выполняются все действия, прикрепленные к проводнику. Обратите внимание на используемую для этого сокращенную форму синтаксиса: выражение actions foreach (_ ()) вызывает применение функции _ () к каждому элемен­ ту в списке действий. В соответствии с описанием, приведенным в разделе 8.5, функция _ () является сокращенной формой записи для f => f (), то есть получает функцию (назовем ее f) и применяет ее к пустому списку параметров. Метод inverter Единственный результат создания инвертора — то, что действие устанавливается на его входном проводнике. Данное действие вызывается при его установке, а за­ тем всякий раз при изменении сигнала на входе. Эффект от действия заключается в установке выходного значения (с помощью setSignal) на отрицание его входного значения. Поскольку у логического элемента «НЕ» имеется задержка, это измене­ ние должно наступить только по прошествии определенного количества единиц 18.6. Моделирование электронной логической схемы 357 моделируемого времени, хранящегося в переменной InverterDelay, после измене­ ния входного значения и выполнения действия. Эти обстоятельства подсказывают следующий вариант реализации: def inverter(input: Wire, output: Wire) = { def invertAction() = { val inputSig = input.getSignal afterDelay(InverterDelay) { output setSignal !inputSig } } input addAction invertAction } Эффект метода inverter заключается в добавлении действия invertAction к input. При вызове данного действия берется входной сигнал и устанавлива­ ется еще одно действие, инвертирующее выходной сигнал в плане действий моделирования. Это другое действие должно быть выполнено по прошествии того количества единиц времени моделирования, которое хранится в перемен­ ной InverterDelay. Обратите внимание на то, как для создания нового рабочего элемента, предназначенного для выполнения в будущем, в методе используется метод afterDelay. Методы andGate и orGate Реализация моделирования логического элемента «И» аналогична реализации мо­ делирования элемента «НЕ». Цель — выставить на выходе конъюнкцию его входных сигналов. Это должно произойти по прошествии того количества единиц времени моделирования, которое хранится в переменной AndGateDelay, после изменения любого из его двух входных сигналов. Стало быть, подойдет следующая реализация: def andGate(a1: Wire, a2: Wire, output: Wire) = { def andAction() = { val a1Sig = a1.getSignal val a2Sig = a2.getSignal afterDelay(AndGateDelay) { output setSignal (a1Sig & a2Sig) } } a1 addAction andAction a2 addAction andAction } Эффект от вызова метода andGate заключается в добавлении действия andAction к обоим входным проводникам, a1 и a2. При вызове данного действия берутся оба входных сигнала и устанавливается еще одно действие, которое выдает выходной сигнал в виде конъюнкции обоих входных сигналов. Это другое действие должно быть выполнено по прошествии того количества единиц времени моделирования, которое хранится в переменной AndGateDelay. Учтите, что при смене любого сиг­ нала на входных проводниках выход должен вычисляться заново. Именно поэтому 358 Глава 18 • Изменяемые объекты одно и то же действие andAction устанавливается на каждом из двух входных про­ водников, a1 и a2. Метод orGate реализуется аналогичным образом, за исключением того, что моделирует логическую операцию «ИЛИ», а не «И». Вывод симуляции Для запуска симулятора нужен способ проверки изменения сигналов на провод­ никах. Чтобы выполнить эту задачу, можно смоделировать действие проверки (пробы) проводника: def probe(name: String, wire: Wire) = { def probeAction() = { println(name + " " + currentTime + " new-value = " + wire.getSignal) } wire addAction probeAction } Эффект от процедуры probe заключается в установке на заданный проводник действия probeAction. Как обычно, установленное действие выполняется всякий раз при изменении сигнала на проводнике. В данном случае он просто выводит на стандартное устройство название проводника (которое передается probe в каче­ стве первого параметра), а также текущее моделируемое время и новое значение проводника. Запуск симулятора После всех этих приготовлений настало время посмотреть на симулятор в дей­ ствии. Для определения конкретной симуляции нужно выполнить наследование из класса среды моделирования. Чтобы увидеть кое-что интересное, будет создан абстрактный класс моделирования, расширяющий BasicCircuitSimulation и содер­ жащий определения методов для полусумматора и сумматора в том виде, в котором они были представлены в листингах 18.6 и 18.7 соответственно (см. выше). Этот класс, который будет назван CircuitSimulation, показан в листинге 18.10. Листинг 18.10. Класс CircuitSimulation package org.stairwaybook.simulation abstract class CircuitSimulation extends BasicCircuitSimulation { def halfAdder(a: Wire, b: Wire, s: Wire, c: Wire) = { val d, e = new Wire orGate(a, b, d) andGate(a, b, c) inverter(c, e) andGate(d, e, s) } 18.6. Моделирование электронной логической схемы 359 def fullAdder(a: Wire, b: Wire, cin: Wire, sum: Wire, cout: Wire) = { val s, c1, c2 = new Wire halfAdder(a, cin, s, c1) halfAdder(b, s, sum, c2) orGate(c1, c2, cout) } } Конкретная модель логической схемы будет объектом-наследником класса CircuitSimulation. Этому объекту по-прежнему необходимо зафиксировать за­ держки на логических элементах в соответствии с технологией реализации мо­ делируемой логической микросхемы. И наконец, понадобится также определить конкретную моделируемую схему. Эти шаги можно проделать в интерактивном режиме в интерпретаторе Scala: scala> import org.stairwaybook.simulation._ import org.stairwaybook.simulation._ Сначала займемся задержками логических элементов. Определим объект (на­ зовем его MySimulation), предоставляющий несколько чисел: scala> object MySimulation extends CircuitSimulation { def InverterDelay = 1 def AndGateDelay = 3 def OrGateDelay = 5 } defined module MySimulation Поскольку предполагается периодически получать доступ к элементам объекта MySimulation, импортирование этого объекта укоротит последующий код: scala> import MySimulation._ import MySimulation._ Далее займемся схемой. Определим четыре проводника и поместим пробы на два из них: scala> val input1, input2, sum, carry = new Wire input1: MySimulation.Wire = BasicCircuitSimulation$Wire@111089b input2: MySimulation.Wire = BasicCircuitSimulation$Wire@14c352e sum: MySimulation.Wire = BasicCircuitSimulation$Wire@37a04c carry: MySimulation.Wire = BasicCircuitSimulation$Wire@1fd10fa scala> probe("sum", sum) sum 0 new-value = false scala> probe("carry", carry) carry 0 new-value = false 360 Глава 18 • Изменяемые объекты Обратите внимание: пробы немедленно выводят выходные данные. Дело в том, что каждое действие, установленное на проводнике, первый раз выполняется при его установке. Теперь определим подключение к проводникам полусумматора: scala> halfAdder(input1, input2, sum, carry) И наконец, установим один за другим сигналы на двух входящих проводниках на true и запустим моделирование: scala> input1 setSignal true scala> run() *** simulation started, time = 0 *** sum 8 new-value = true scala> input2 setSignal true scala> run() *** simulation started, time = 8 *** carry 11 new-value = true sum 15 new-value = false Резюме В данной главе мы собрали воедино две на первый взгляд несопоставимые техно­ логии: изменяемое состояние и функции высшего порядка. Изменяемое состояние было использовано для моделирования физических объектов, состояние которых со временем изменяется. Функции высшего порядка были применены в среде моделирования в целях выполнения действий в указанные моменты моделируе­ мого времени. Они также были использованы в моделировании логических схем в качестве триггеров, связывающих действия с изменениями состояния. Попутно мы показали простой способ определить предметно-ориентированный язык в виде библиотеки. Вероятно, для одной главы этого вполне достаточно. Если вас привлекла эта тема, то можете попробовать создать дополнительные примеры моделирования. Можно объединить полусумматоры и сумматоры для формирования более крупных схем или на основе ранее определенных логических элементов разработать новые схемы и смоделировать их. В главе 19 мы рассмотрим имеющуюся в Scala параметризацию типов и покажем еще один пример, который сочетает в себе функциональный и императивный подходы, дающие весьма не­ плохое решение. 19 Параметризация типов В этой главе мы рассмотрим детали параметризации типов в Scala. Попутно про­ демонстрируем несколько техник скрытия информации, представленных в гла­ ве 13, на конкретном примере: проектирования класса для чисто функциональных очередей. Параметризация типов и скрытие информации рассматриваются вместе, поскольку скрытие может пригодиться для получения более общих вариантов аннотаций параметризации типов. Параметризация типов позволяет создавать обобщенные классы и трейты. Например, множества имеют обобщенный характер и получают параметр типа: они определяются как Set[T]. В результате любой отдельно взятый экземпляр множе­ ства может иметь тип Set[String], Set[Int] и т. д., но должен быть множеством чего-либо. В отличие от языка Java, в котором разрешено использовать сырые типы (raw types), Scala требует указывать параметры типа. Вариантность определяет взаимоотношения наследования параметризованных типов, к примеру таких, при которых Set[String] является подтипом Set[AnyRef]. Глава состоит из трех частей. В первой разрабатывается структура данных для чисто функциональных очередей. Во второй разрабатываются технологические приемы скрытия внутреннего представления деталей этой структуры. В третьей объясняется вариантность параметров типов и то, как она взаимодействует со скрытием информации. 19.1. Функциональные очереди Функциональная очередь представляет собой структуру данных с тремя опера­ циями: head — возвращает первый элемент очереди; tail — возвращает очередь без первого элемента; enqueue — возвращает новую очередь с заданным элементом, добавленным в ее конец. 362 Глава 19 • Параметризация типов В отличие от изменяемой функциональная очередь не изменяет свое содер­ жимое при добавлении элемента. Вместо этого возвращается новая очередь, со­ держащая элемент. В данной главе наша цель — создать класс по имени Queue, работающий так: scala> val q = Queue(1, 2, 3) q: Queue[Int] = Queue(1, 2, 3) scala> val q1 = q enqueue 4 q1: Queue[Int] = Queue(1, 2, 3, 4) scala> q res0: Queue[Int] = Queue(1, 2, 3) Будь у Queue изменяемая реализация, операция enqueue в показанной ранее второй строке ввода повлияла бы на содержимое q: по сути, после этой операции оба результата, и q1, и исходная очередь q, будут содержать последовательность 1, 2, 3, 4. А для функциональной очереди добавленное значение обнаруживается только в результате q1, но не в очереди q, в отношении которой выполнялась операция. Кроме того, чистые функциональные очереди слегка похожи на списки. Обе эти коллекции имеют так называемую абсолютно постоянную структуру данных, где старые версии остаются доступными даже после расширений или модификаций. Обе они поддерживают операции head и tail. Но список растет, как правило, с начала с помощью операции ::, а очередь — с конца с помощью операции enqueue. Как добиться эффективной реализации очереди? В идеале функциональная (неизменяемая) очередь не должна иметь высоких издержек, существенно больших по сравнению с императивной (изменяемой) очередью. То есть все три операции: head, tail и enqueue — должны выполняться за постоянное время. Одним из простых подходов к реализации функциональной очереди станет использование списка в качестве типа представления. Тогда head и tail — просто аналогичные операции над списком, а enqueue — конкатенация. В таком варианте получится следующая реализация: class def def def } SlowAppendQueue[T](elems: List[T]) { // Неэффективное решение head = elems.head tail = new SlowAppendQueue(elems.tail) enqueue(x: T) = new SlowAppendQueue(elems ::: List(x)) Проблемной в данной реализации является операция enqueue. На нее уходит время, пропорциональное количеству элементов, хранящихся в очереди. Если требуется постоянное время добавления, то можно также попробовать изменить в списке, представляющем очередь, порядок следования элементов на обратный, чтобы последний добавляемый элемент стал в списке первым. Тогда получится такая реализация: class SlowHeadQueue[T](smele: List[T]) { // Неэффективное решение // smele — это реверсированный elems def head = smele.last 19.1. Функциональные очереди 363 } def tail = new SlowHeadQueue(smele.init) def enqueue(x: T) = new SlowHeadQueue(x :: smele) Теперь у операции enqueue постоянное время выполнения, а вот у head и tail — нет. На их выполнение теперь уходит время, пропорциональное количеству эле­ ментов, хранящихся в очереди. При изучении этих двух примеров реализация, в которой на все три операции будет затрачиваться постоянное время, не представляется такой уж простой. И дей­ ствительно, возникают серьезные сомнения в возможности подобной реализации! Но, воспользовавшись сочетанием двух операций, можно подойти к желаемому результату очень близко. Замысел состоит в представлении очереди в виде двух списков: leading и trailing. Список leading содержит элементы, которые распо­ лагаются от конца к началу, а элементы списка trailing следуют из начала в конец очереди, то есть в обратном порядке. Содержимое всей очереди в любой момент времени равно коду leading ::: trailing.reverse. Теперь, чтобы добавить элемент, следует просто провести конс-операцию в от­ ношении списка trailing, воспользовавшись оператором ::, и тогда операция enqueue будет выполняться за постоянное время. Это значит, если изначально пустая очередь выстраивается на основе последовательно проведенных операций enqueue, то список trailing будет расти, а список leading останется пустым. Затем перед выполнением первой операции head или tail в отношении пустого списка leading весь список trailing копируется в leading в обратном порядке следования элементов. Это делается с помощью операции по имени mirror. Реализация очере­ дей с использованием данного подхода показана в листинге 19.1. Листинг 19.1. Базовая функциональная очередь class Queue[T]( private val leading: List[T], private val trailing: List[T] ) { private def mirror = if (leading.isEmpty) new Queue(trailing.reverse, Nil) else this def head = mirror.leading.head def tail = { val q = mirror new Queue(q.leading.tail, q.trailing) } } def enqueue(x: T) = new Queue(leading, x :: trailing) Какова вычислительная сложность этой реализации очереди? Операция mirror может занять время, пропорциональное количеству элементов очереди, но только 364 Глава 19 • Параметризация типов при условии, что список leading пуст. Если же нет, то возврат из метода происхо­ дит немедленно. Поскольку head и tail вызывают mirror, то их вычислительная сложность также может иметь линейную зависимость от размера очереди. Но чем длиннее становится очередь, тем реже вызывается mirror. И действительно, допустим, есть очередь длиной n с пустым списком leading. Тогда операции mirror придется скопировать в обратном порядке список длиной n. Однако следующий раз, когда операции mirror придется делать что-либо, наступит только по опустошении списка leading, что произойдет после n операций tail. То есть вы можете расплатиться за каждую из этих n операций tail одной n-ной от вычислительной сложности операции mirror, что означает постоянный объем работы. При условии, что операции head, tail и enqueue используются примерно с одинаковой частотой, амортизированная вычислительная сложность является, таким образом, константой для каждой операции. Следовательно, функциональные очереди асимптотически так же эффективны, как и изменяемые очереди. Но этот аргумент следует сопроводить некоторыми оговорками. Во-первых, речь шла только об асимптотическом поведении, а постоянно действующие фак­ торы могут несколько различаться. Во-вторых, аргумент основывается на том, что операции head, tail и enqueue вызываются с примерно одинаковой частотой. Если операция head вызывается намного чаще, чем остальные две, то данный аргумент утратит силу, поскольку каждый вызов head может повлечь за собой весьма затрат­ ную реорганизацию списка с помощью операции mirror. Негативных последствий, названных во второй оговорке, можно избежать, поскольку есть возможность сконструировать функциональные очереди таким образом, что в последователь­ ности операций head реорганизация потребуется только для первой из них. Как это делается, мы покажем в конце главы. 19.2. Скрытие информации Теперь по эффективности реализация класса Queue, показанная в листинге 19.1 (см. выше), нас вполне устраивает. Конечно, вы можете возразить, что за эту эф­ фективность пришлось расплачиваться неоправданно подробной детализацией. Глобально доступный конструктор Queue получает в качестве параметров два списка, в одном из которых элементы следуют в обратном порядке, что вряд ли совпадает с интуитивным представлением об очереди. Нам нужен способ, позво­ ляющий скрыть этот конструктор от кода клиента. Некоторые способы решения данной задачи на Scala мы покажем в текущем разделе. Приватные конструкторы и фабричные методы В Java конструктор можно скрыть, объявив его приватным. В Scala первичный конструктор не имеет явно указываемого определения — подразумевается, что он автоматически определяется с параметрами и телом класса. Тем не менее, как 19.2. Скрытие информации 365 показано в листинге 19.2, скрыть первичный конструктор можно, добавив перед списком параметров класса модификатор private. Листинг 19.2. Скрытие первичного конструктора путем превращения его в приватный class Queue[T] private ( private val leading: List[T], private val trailing: List[T] ) Модификатор private, указанный между именем класса и его параметрами, служит признаком того, что конструктор класса Queue является приватным: доступ к нему можно получить только изнутри самого класса и из его объекта-компаньона. Имя класса Queue по-прежнему остается публичным, поэтому вы можете исполь­ зовать его в качестве типа, но не можете вызвать его конструктор: scala> new Queue(List(1, 2), List(3)) ^ error: constructor Queue in class Queue cannot be accessed in object $iw Теперь, когда первичный конструктор класса Queue уже не может быть вызван из кода клиента, нужен какой-то другой способ создания новых очередей. Одна из возможностей — добавить вспомогательный конструктор: def this() = this(Nil, Nil) Этот конструктор, показанный в предыдущем примере, создает пустую очередь. В качестве уточнения он может получать список исходных элементов очереди: def this(elems: T*) = this(elems.toList, Nil) Следует напомнить, что в соответствии с описанием из раздела 8.8 T* — форма записи для повторяющихся параметров. Еще одна возможность состоит в добавлении фабричного метода, создающего очередь из последовательности исходных элементов. Прямой путь сделать это — определить объект Queue, который имеет такое же имя, как и определяемый класс, и, как показано в листинге 19.3, содержит метод apply. Листинг 19.3. Фабричный метод apply в объекте-компаньоне object Queue { // Создает очередь с исходными элементами xs def apply[T](xs: T*) = new Queue[T](xs.toList, Nil) } Помещая этот объект в тот же самый исходный файл, в котором находится определение класса Queue, вы превращаете объект в объект-компаньон этого класса. В разделе 13.5 было показано: объект-компаньон имеет такие же права доступа, что и его класс. Поэтому метод apply в объекте Queue может создать новый объект Queue, несмотря на то что конструктор класса Queue является приватным. Обратите внимание: по причине вызова фабричным методом метода apply кли­ енты могут создавать очереди с помощью выражений вида Queue(1, 2, 3). Данное выражение разворачивается в Queue.apply(1, 2, 3), поскольку Queue — объект, 366 Глава 19 • Параметризация типов заменяющий функцию. В результате этого клиенты видят Queue в качестве глобально определенного фабричного метода. На самом же деле в Scala нет методов с глобаль­ ной областью видимости, поскольку каждый метод должен быть заключен в объект или класс. Но, используя методы по имени apply внутри глобальных объектов, вы можете поддерживать схемы, похожие на вызов глобальных методов. Альтернативный вариант: приватные классы Приватные конструкторы и приватные члены класса — всего лишь один из спосо­ бов скрыть инициализацию и представить класс. Еще один более радикальный спо­ соб — скрытие самого класса и экспорт только трейта, показывающего публичный интерфейс класса. Код реализации такой конструкции представлен в листинге 19.4. В нем показан трейт Queue, в котором объявляются методы head, tail и enqueue. Все три метода реализованы в подклассе QueueImpl, который является приватным подклассом внутри класса объекта Queue. Тем самым клиентам открывается до­ ступ к той же информации, что и раньше, но с помощью другого приема. Вместо скрытия отдельно взятых конструкторов и методов в этой версии скрывается весь реализующий очереди класс. Листинг 19.4. Абстракция типа для функциональных очередей trait def def def } Queue[T] { head: T tail: Queue[T] enqueue(x: T): Queue[T] object Queue { def apply[T](xs: T*): Queue[T] = new QueueImpl[T](xs.toList, Nil) private class QueueImpl[T]( private val leading: List[T], private val trailing: List[T] ) extends Queue[T] { def mirror = if (leading.isEmpty) new QueueImpl(trailing.reverse, Nil) else this def head: T = mirror.leading.head def tail: QueueImpl[T] = { val q = mirror new QueueImpl(q.leading.tail, q.trailing) } 19.3. Аннотации вариантности 367 def enqueue(x: T) = new QueueImpl(leading, x :: trailing) } } 19.3. Аннотации вариантности Элемент Queue согласно определению в листинге 19.4 — трейт, но не тип, поскольку получает параметра типа. В результате вы не можете создавать переменные типа Queue: scala> def doesNotCompile(q: Queue) = {} ^ error: class Queue takes type parameters Вместо этого Queue позволяет указывать параметризованные типы, такие как Queue[String], Queue[Int] или Queue[AnyRef]: scala> def doesCompile(q: Queue[AnyRef]) = {} doesCompile: (q: Queue[AnyRef])Unit Таким образом, Queue — трейт, а Queue[String] — тип. Queue также называют конструктором типа, поскольку вы можете сконструировать тип с его участием, указав параметр типа. (Это аналогично конструированию экземпляра объекта с ис­ пользованием самого обычного конструктора с указанием параметра значения.) Конструктор типа Queue генерирует семейство типов, включающее Queue[Int], Queue[String] и Queue[AnyRef]. Можно также сказать, что Queue — обобщенный трейт. (Классы и трейты, кото­ рые получают параметры типа, являются обобщенными, а вот типы, генерируемые ими, являются параметризованными, а не обобщенными.) Понятие «обобщен­ ный» означает, что вы определяете множество конкретных типов, используя один обобщенно написанный класс или трейта. Например, трейт Queue в листинге 19.4 (см. выше) определяет обобщенную очередь. Конкретными очередями будут Queue[Int], Queue[String] и т. д. Сочетание параметров типа и системы подтипов вызывает ряд интересных вопросов. Например, существуют ли какие-то особые подтиповые отношения между членами семейства типов, генерируемого Queue[T]? Конкретнее говоря, следует ли рассматривать Queue[String] как подтип Queue[AnyRef]? Или в более широком смысле если S — подтип T, то следует ли рассматривать Queue[S] как подтип Queue[T]? Если да, то можно сказать, что трейт Queue ковариантный (или «гибкий») в своем параметре типа T. Или же, поскольку у него всего один параметр типа, можно просто сказать, что Queue-очереди ковариантны. Такая ковариант­ ность Queue будет означать, к примеру, что вы можете передать Queue[String] ранее показанному методу doesCompile, который принимает параметр значения типа Queue[AnyRef]. 368 Глава 19 • Параметризация типов Интуитивно все это выглядит хорошо, поскольку очередь из String-элементов похожа на частный случай очереди из AnyRef-элементов. Но в Scala у обобщенных типов изначально имеется нонвариантная (или жесткая) подтипизация. То есть при использовании трейта Queue, определенного в показанном выше листинге 19.4, очереди с различными типами элементов никогда не будут состоять в подтиповых отношениях. Queue[String] не будет использоваться как Queue[AnyRef]. Но полу­ чить ковариантность (гибкость) подтипизации очередей можно следующим из­ менением первой строчки определения Queue: trait Queue[+T] { ... } Указанный знак плюс (+) в качестве префикса формального параметра типа показывает, что подтипизация в этом параметре ковариантна (гибка). Добавляя этот единственный знак, вы сообщаете Scala о необходимости, к примеру, рас­ сматривать тип Queue[String] как подтип Queue[AnyRef]. Компилятор проверит факт определения Queue в соответствии со способом, предполагаемым подобной подтипизацией. Помимо префикса +, существует префикс -, который показывает контравариантность подтипизации. Если определение Queue имеет вид: trait Queue[-T] { ... } и если тип T — подтип типа S, то это будет означать, что Queue[S] — подтип Queue[T] (что в случае с очередями было бы довольно неожиданно!). Ковариантность, кон­ травариантность или нонвариантость параметра типа называются вариантностью параметров. Знаки + и -, которые могут размещаться рядом с параметрами типа, называются аннотациями вариантности. В чисто функциональном мире многие типы по своей природе ковариантны (гибки). Но ситуация становится иной, как только появляются изменяемые данные. Чтобы выяснить, почему так происходит, рассмотрим показанный в листинге 19.5 простой тип одноэлементных ячеек, допускающих чтение и запись. Листинг 19.5. Нонвариантный (жесткий) класс Cell class Cell[T](init: T) { private[this] var current = init def get = current def set(x: T) = { current = x } } Показанный здесь тип Cell объявлен нонвариантным (жестким). Ради аргу­ ментации представим на время, что Cell был объявлен ковариантным, то есть был объявлен класс Cell[+T], и этот код передан компилятору Scala. (Этого делать не стоит, и скоро мы объясним почему.) Значит, можно сконструировать следу­ ющую проблематичную последовательность инструкций: val c1 = new Cell[String]("abc") val c2: Cell[Any] = c1 c2.set(1) val s: String = c1.get 19.3. Аннотации вариантности 369 Если рассмотреть строки по отдельности, то все они выглядят вполне нор­ мально. В первой строке создается строковая ячейка, которая сохраняется в valпеременной по имени c1. Во второй строке определяется новая val-переменная c2, имеющая тип Cell[Any], которая инициализируется значением переменной c1. Кажется, все в порядке, поскольку экземпляры класса Cell считаются ковариантны­ ми. В третьей строке для c2 устанавливается значение 1. С этим тоже все в порядке, так как присваиваемое значение 1 — экземпляр, относящийся к объявленному для c2 типу элемента Any. И наконец, в последней строке значение элемента c1 при­ сваивается строковой переменной. Здесь нет ничего странного, поскольку с обеих сторон выражения находятся значения одного и того же типа. Но если взять все в совокупности, то эти четыре строки в конечном счете присваивают целочисленное значение 1 строковому значению s. Это явное нарушение целостности типа. Какая из операций вызывает сбой в ходе выполнения кода? Видимо, вторая, в которой используется ковариантная подтипизация. Все другие операции слиш­ ком простые и базовые. Стало быть, ячейка Cell, хранящая значение типа String, не является также ячейкой Cell, хранящей значение типа Any, поскольку есть вещи, которые можно делать с Cell из Any, но нельзя делать с Cell из String. К примеру, в отношении Cell из String нельзя использовать set с Int-аргументом. Получается, если передать ковариантную версию Cell компилятору Scala, то будет выдана ошибка компиляции: Cell.scala:7: error: covariant type T occurs in contravariant position in type T of value x def set(x: T) = current = x ^ Вариантность и массивы Интересно сравнить данное поведение с массивами в Java. В принципе, массивы подобны ячейкам, за исключением того, что могут иметь несколько элементов. При этом массивы в Java считаются ковариантными. Пример, аналогичный работе с ячейкой, можно проверить в работе с массивами Java: // Это код Java String[] a1 = { "abc" }; Object[] a2 = a1; a2[0] = new Integer(17); String s = a1[0]; Если запустить данный пример, то окажется, что он пройдет компиляцию. Но в ходе выполнения, когда элементу a2[0] будет присваиваться значение Integer, программа сгенерирует исключение ArrayStore: Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer at JavaArrays.main(JavaArrays.java:8) 370 Глава 19 • Параметризация типов Дело в том, что в Java сохраняется тип элемента массива во время выполнения программы. При каждом обновлении элемента массива новое значение элемента проверяется на принадлежность к сохраненному типу. Если оно не является эк­ земпляром этого типа, то выдается исключение ArrayStore. Может возникнуть вопрос: а почему в Java принята такая на вид небезопасная и затратная конструкция? Отвечая на данный вопрос, Джеймс Гослинг (James Gosling), основной изобретатель языка Java, говорил, что создатели хотели получить простые средства обобщенной обработки массивов. Например, им хо­ телось получить возможность писать метод сортировки всех элементов массива с использованием следующей сигнатуры, которая получает массив из Objectэлементов: void sort(Object[] a, Comparator cmp) { ... } Ковариантность массивов требовалась для того, чтобы этому методу сортировки можно было передавать массивы произвольных ссылочных типов. Разумеется, по­ сле появления в Java обобщенных типов (дженериков) подобный метод сортировки уже мог быть написан с параметрами типа, поэтому необходимость в ковариант­ ности массивов отпала. Сохранение ее до наших дней обусловлено соображениями совместимости. Scala старается быть чище, чем Java, и не считает массивы ковариантными. Вот что получится, если перевести первые две строки кода примера с массивом на язык Scala: scala> val a1 = Array("abc") a1: Array[String] = Array(abc) scala> val a2: Array[Any] = a1 ^ error: type mismatch; found : Array[String] required: Array[Any] Note: String <: Any, but class Array is invariant in type T. В данном случае получилось, что Scala считает массивы нонвариантными (жест­ кими), следовательно, Array[String] не считается соответствующим Array[Any]. Но иногда необходимо организовать взаимодействие между имеющимися в Java устаревшими методами, которые используют в качестве средства эмуляции обоб­ щенного массива Object-массив. Например, может возникнуть потребность вы­ звать метод сортировки наподобие того, который был рассмотрен ранее в отноше­ нии String-массива, передаваемого в качестве аргумента. Чтобы допустить такую возможность, Scala позволяет выполнять приведение массива из элементов типа T к массиву элементов любого из супертипов T: scala> val a2: Array[Object] = a1.asInstanceOf[Array[Object]] a2: Array[Object] = Array(abc) Приведение типов во время компиляции не вызывает никаких возражений и всегда будет успешно работать во время выполнения программы, поскольку 19.4. Проверка аннотаций вариантности 371 положенная в основу работы JVM-модель времени выполнения рассматривает массивы в качестве ковариантных точно так же, как это делается в языке Java. Но впоследствии, как и на Java, может быть получено исключение ArrayStore. 19.4. Проверка аннотаций вариантности Рассмотрев несколько примеров ненадежности вариантности, можно задать во­ прос: какие именно определения классов следует отвергать, а какие — принимать? До сих пор во всех нарушениях целостности типов фигурировали переназначаемые поля или элементы массивов. В то же время чисто функциональная реализация очередей представляется хорошим кандидатом на ковариантность. Но в следу­ ющем примере показано, что ситуацию ненадежности можно подстроить даже при отсутствии переназначаемого поля. Чтобы выстроить пример, предположим: очереди из показанного выше листин­ га 19.4 определены как ковариантные. Затем создадим подкласс очередей с указан­ ным типом Int и переопределим метод enqueue: class StrangeIntQueue extends Queue[Int] { override def enqueue(x: Int) = { println(math.sqrt(x)) super.enqueue(x) } } Перед выполнением добавления метод enqueue в подклассе StrangeIntQueue вы­ водит на стандартное устройство квадратный корень из своего (целочисленного) аргумента. Теперь можно создать контрпример из двух строк кода: val x: Queue[Any] = new StrangeIntQueue x.enqueue("abc") Первая из этих двух строк вполне допустима, поскольку StrangeIntQueue — под­ класс Queue[Int], и если предполагается ковариантность очередей, то Queue[Int] является подтипом Queue[Any]. Вполне допустима и вторая строка, так как Stringзначение можно добавлять в Queue[Any]. Но если взять их вместе, то у этих двух строк проявляется не имеющий никакого смысла эффект применения метода из­ влечения квадратного корня к строке. Это явно не те простые изменяемые поля, которые делают ковариантные поля ненадежными. Проблема носит более общий характер. Получается, как только обоб­ щенный параметр типа появляется в качестве типа параметра метода, содержащие данный метод класс или трейт в этом параметре типа могут не быть ковариантными. Для очередей метод enqueue нарушает это условие: class Queue[+T] { def enqueue(x: T) = ... } 372 Глава 19 • Параметризация типов При запуске модифицированного класса очередей, подобного показанному ранее, в компиляторе Scala последний выдаст следующий результат: Queues.scala:11: error: covariant type T occurs in contravariant position in type T of value x def enqueue(x: T) = ^ Переназначаемые поля — частный случай правила, которое не позволяет пара­ метрам типа, имеющим аннотацию +, использоваться в качестве типов параметра метода. Как упоминалось в разделе 18.2, переназначаемое поле var x: T рассма­ тривается в Scala как геттер def x: T и как сеттер def x_=(y: T). Как видите, сеттер имеет параметр поля типа T. Следовательно, этот тип не может быть ковариантным. УСКОРЕННЫЙ РЕЖИМ ЧТЕНИЯ Далее в этом разделе мы рассмотрим механизм, с помощью которого компилятор Scala проверяет аннотацию вариантности. Если данные подробности вас пока не интересуют, то можете смело переходить к разделу 19.5. Следует усвоить главное: компилятор Scala будет проверять любую аннотацию вариантности, которую вы укажете в отношении параметров типа. Например, при попытке объявить ковариантный параметр типа (путем добавления знака +), способного вызвать потенциальные ошибки в ходе выполнения программы, программа откомпилирована не будет. Чтобы проверить правильность аннотаций вариантности, компилятор Scala классифицирует все позиции в теле класса или трейта как положительные, отрицательные или нейтральные. Под позицией понимается любое место в теле класса или трейта (далее в тексте будет фигурировать только термин «класс»), где может использоваться параметр типа. Например, позицией является каждый параметр значения в методе, поскольку у параметра значения метода есть тип. Следователь­ но, в этой позиции может применяться параметр типа. Компилятор проверяет каждое использование каждого из имеющихся в классе параметров типа. Параметры типа, для аннотации которых применяется знак +, могут быть задействованы только в положительных позициях, а параметры, для аннотации которых используется знак -, могут применяться лишь в отрицатель­ ных. Параметр типа, не имеющий аннотации вариантности, может использоваться в любой позиции. Таким образом, он является единственной разновидностью па­ раметров типа, которая применима в нейтральных позициях тела класса. В целях классификации позиций компилятор начинает работу с объявления параметра типа, а затем идет через более глубокие уровни вложенности. Позиции на верхнем уровне объявляемого класса классифицируются как положительные. По умолчанию позиции на более глубоких уровнях вложенности классифици­ руются таким же образом, как и охватывающие их уровни, но есть небольшое количество исключений, в которых классификация меняется. Позиции параме­ тров значений метода классифицируются по перевернутой схеме относительно позиций за пределами метода, там положительная классификация становится 19.5. Нижние ограничители 373 отрицательной, отрицательная — положительной, а нейтральная классификация так и остается нейтральной. Текущая классификация действует по перевернутой схеме в отношении не толь­ ко позиций параметров значений методов, но и параметров типа методов. В зависи­ мости от вариантности соответствующего параметра типа классификация иногда бывает перевернута в имеющейся в типе позиции аргумента типа, например, это касается Arg в C[Arg]. Если параметр типа у C аннотирован с помощью знака +, то классификация остается такой же. Если с помощью знака -, то классификация определяется по перевернутой схеме. При отсутствии у параметра типа у C анно­ тации вариантности текущая классификация изменяется на нейтральную. В качестве слегка надуманного примера рассмотрим следующее определение класса, в котором несколько позиций аннотированы согласно их классификации с помощью обозначений ^+ (для положительных) или ^- (для отрицательных): abstract class Cat[-T, +U] { def meow[W^-](volume: T^-, listener: Cat[U^+, T^-]^-) : Cat[Cat[U^+, T^-]^-, U^+]^+ } Позиции параметра типа W и двух параметров значений, volume и listener, по­ мечены как отрицательные. Если посмотреть на результирующий тип метода meow, то позиция первого аргумента, Cat[U, T], помечена как отрицательная, поскольку первый параметр типа у Cat, T, аннотирован с помощью знака -. Тип U внутри этого аргумента опять имеет положительную позицию (после двух перевертываний), а тип T внутри этого аргумента остается в отрицательной. Из всего вышесказанного можно сделать вывод, что отследить позиции вариант­ ностей очень трудно. Поэтому помощь компилятора Scala, проделывающего эту работу за вас, несомненно, приветствуется. После вычисления классификации компилятор проверяет, используется ли каждый параметр типа только в позициях, которые имеют соответствующую клас­ сификацию. В данном случае T используется лишь в отрицательных позициях, а U — лишь в положительных. Следовательно, класс Cat типизирован корректно. 19.5. Нижние ограничители Вернемся к классу Queue. Вы видели, что прежнее определение Queue[T], показан­ ное в листинге 19.4, не может быть превращено в ковариантное в отношении T, поскольку T фигурирует в качестве типа параметра метода enqueue и находится в отрицательной позиции. К счастью, существует способ выйти из этого положения: можно обобщить enqueue, превратив данный метод в полиморфный (то есть предоставить самому методу enqueue параметр типа), и воспользоваться нижним ограничителем его параметра типа. Новая формулировка Queue с реализацией этой идеи показана в листинге 19.6. 374 Глава 19 • Параметризация типов Листинг 19.6. Параметр типа с нижним ограничителем class Queue[+T] (private val leading: List[T], private val trailing: List[T] ) { def enqueue[U >: T](x: U) = new Queue[U](leading, x :: trailing) // ... } В новом определении enqueue дается параметр типа U, и с помощью синтаксиса U >: T тип T определяется как нижний ограничитель для U. В результате от типа U требуется, чтобы он был супертипом для T1. Теперь параметр для enqueue имеет тип U, а не T, а возвращаемое значение метода теперь не Queue[T], а Queue[U]. Предположим, есть класс Fruit , имеющий два подкласса: Apple и Orange . С новым определением класса Queue появилась возможность добавить Orange в Queue[Apple]. Результатом будет Queue[Fruit]. В этом пересмотренном определении enqueue типы используются правильно. Интуитивно понятно, что если T — более конкретный тип, чем ожидалось (напри­ мер, Apple вместо Fruit), то вызов enqueue все равно будет работать, поскольку U (Fruit) по-прежнему будет супертипом для T (Apple)2. Возможно, новое определение enqueue лучше старого, поскольку имеет более обобщенный характер. В отличие от старой версии новое определение позволяет добавлять в очередь с элементами типа T элементы произвольного супертипа U. Результат получается типа Queue[U]. Наряду с ковариантностью очереди это позво­ ляет получить правильную разновидность гибкости для моделирования очередей из различных типов элементов вполне естественным образом. Это показывает, что в совокупности аннотации вариантности и нижние огра­ ничители — хорошо сыгранная команда. Они являются хорошим примером разработки, управляемой типами, где типы интерфейса управляют его детальным проектированием и ее реализацией. В случае с очередями высока вероятность того, что вы не стали бы продумывать улучшенную реализацию enqueue с нижним ограничителем. Но у вас могло созреть решение сделать очереди ковариантными, в случае чего компилятор указал бы для enqueue на ошибку вариантности. Ис­ правление ошибки вариантности путем добавления нижнего ограничителя придает enqueue большую обобщенность, а очереди в целом делает более полезными. Вдобавок это наблюдение — главная причина того, почему в Scala предпо­ читаема вариантность по месту объявления (declaration-site variance), а не ва­ риантность по месту использования (use-site variance), встречающаяся в Java в подстановочных символах (wildcards). В случае вариантности по месту ис­ пользования вы разрабатываете класс самостоятельно. А вот клиентам данного класса придется вставлять подстановочные символы, и если они сделают это 1 Отношения супертипов и подтипов рефлексивны. Это значит, тип является одновремен­ но супертипом и подтипом по отношению к себе. Даже притом, что T — нижняя граница для U, T все же можно передавать методу enqueue. 2 С технической точки зрения произошедшее — переворот для нижних границ. Пара­ метр типа U находится в отрицательной позиции (один переворот), а нижняя граница (>: T) — в положительной (два переворота). 19.6. Контравариантность 375 неправильно, то применить некоторые важные методы экземпляра станет не­ возможно. Вариантность — дело непростое, пользователи зачастую понимают ее неправильно и избегают ее, полагая, что подстановочные символы и дженерики для них слишком сложны. При использовании вариантности по месту объявле­ ния ваши намерения выражаются для компилятора, который выполнит двойную проверку, чтобы убедиться, что метод, который вам нужно сделать доступным, будет действительно доступен. 19.6. Контравариантность До сих пор во всех представленных в данной главе примерах встречалась либо кова­ риантность, либо нонвариантность. Но бывают такие обстоятельства, при которых вполне естественно выглядит и контравариантность. Рассмотрим, к примеру, трейт каналов вывода, показанный в листинге 19.7. Листинг 19.7. Контравариантный канал вывода trait OutputChannel[-T] { def write(x: T): Unit } Здесь трейт OutputChannel определен с контравариантностью, указанной для T. Следовательно, получается, что канал вывода для AnyRef является подтипом ка­ нала вывода для String. Хотя на интуитивном уровне это может показаться непо­ нятным, в действительности здесь есть определенный смысл. Понять, почему так, можно, рассмотрев возможные действия с OutputChannel[String]. Единственная поддерживаемая операция — запись в него значения типа String. Аналогичная операция может быть выполнена также в отношении OutputChannel[AnyRef] . Следовательно, вполне безопасно будет вместо OutputChannel[String] подставить OutputChannel[AnyRef]. В отличие от этого подставить OutputChannel[String] туда, где требуется OutputChannel[AnyRef], будет небезопасно. В конце концов, на OutputChannel[AnyRef] можно отправить любой объект, а OutputChannel[String] требует, чтобы все записываемые значения были строками. Эти рассуждения указывают на общий принцип разработки систем типов: впол­ не безопасно предположить, что тип T — подтип типа U, если значение типа T можно подставить там, где требуется значение типа U. Это называется принципом подстановки Лисков. Он соблюдается, если T поддерживает те же операции, что и U, и все принадлежащие T операции требуют меньшего, а предоставляют большее, чем соот­ ветствующие операции в U. В случае с каналами вывода OutputChannel[AnyRef] мо­ жет быть подтипом OutputChannel[String], поскольку в обоих типах поддержива­ ется одна и та же операция write, и она требует меньшего в OutputChannel[AnyRef], чем в OutputChannel[String]. Меньшее означает следующее: от аргумента в первом случае требуется только, чтобы он был типа AnyRef, а вот во втором случае от него требуется, чтобы он был типа String. Иногда в одном и том же типе смешиваются ковариантность и контравари­ антность. Известный пример — функциональные трейты Scala. Например, при 376 Глава 19 • Параметризация типов написании функционального типа A => B Scala разворачивает этот код, приводя его к виду Function1[A, B]. Определение Function1 в стандартной библиотеке исполь­ зует как ковариантность, так и контравариантность: в листинге 19.8 показано, что трейт Function1 контравариантен в аргументе функции типа S и ковариантен в ре­ зультирующем типе T. Принцип подстановки Лисков здесь не нарушается, посколь­ ку аргументы — это то, что требуется, а вот результаты — то, что предоставляется. Листинг 19.8. Ковариантность и контравариантность Function1 trait Function1[-S, +T] { def apply(x: S): T } Рассмотрим в качестве примера приложение, показанное в листинге 19.9. Здесь класс Publication содержит одно параметрическое поле title типа String. Класс Book расширяет Publication и пересылает свой строковый параметр title кон­ структору своего суперкласса. В объекте-одиночке Library определяются набор книг books и метод printBookList, получающий функцию info, у которой есть тип Book => AnyRef. Иными словами, типом единственного параметра printBookList яв­ ляется функция, которая получает один аргумент типа Book и возвращает значение типа AnyRef. В приложении Customer определяется метод getTitle, получающий в качестве единственного своего параметра значение типа Publication и возвраща­ ющий значение типа String, которое содержит название переданной публикации Publication. Листинг 19.9. Демонстрация вариантности параметра типа функции class Publication(val title: String) class Book(title: String) extends Publication(title) object Library { val books: Set[Book] = Set( new Book("Programming in Scala"), new Book("Walden") ) def printBookList(info: Book => AnyRef) = { for (book <- books) println(info(book)) } } object Customer extends App { def getTitle(p: Publication): String = p.title Library.printBookList(getTitle) } Теперь посмотрим на последнюю строку в объекте Customer. В ней вызывается принадлежащий Library метод printBookList, которому в инкапсулированном в значение функции виде передается getTitle: Library.printBookList(getTitle) 19.6. Контравариантность 377 Эта строка кода проходит проверку на соответствие типу даже притом, что String, результирующий тип выполнения функции, является подтипом AnyRef, типом результата параметра info метода printBookList. Данный код проходит компиляцию, поскольку результирующие типы функций объявлены ковариантны­ ми (+T в листинге 19.8). Если заглянуть в тело printBookList, то можно получить представление о том, почему в этом есть определенный смысл. Метод printBookList последовательно перебирает элементы своего списка книг и вызывает переданную ему функцию в отношении каждой книги. Он передает AnyRef-результат, возвращенный info, методу println, который вызывает в от­ ношении этого результата метод toString и выводит на стандартное устройство возвращенную им строку. Данный процесс будет работать со String-значениями, а также с любыми другими подклассами AnyRef, в чем, собственно, и заключается смысл ковариантности результирующих типов функций. Теперь рассмотрим параметр типа той функции, которая была передана ме­ тоду printBookList. Хотя тип параметра, принадлежащего функции info, объяв­ лен как Book, функция getTitle при ее передаче в этот метод получает значение типа Publication, а этот тип является для Book супертипом. Все это работает, по­ скольку, хотя типом параметра метода printBookList является Book, телу метода printBookList будет разрешено только передать значение типа Book в функцию. А ввиду того, что параметром типа функции getTitle является Publication, телу этой функции будет лишь разрешено обращаться к его параметру p, относящему­ ся к элементам, объявленным в классе Publication. Любой метод, объявленный в классе Publication, доступен также в его подклассе Book, поэтому все должно работать, в чем, собственно, и заключается смысл контравариантности типов результатов функций. Графическое представление всего вышесказанного можно увидеть на рис. 19.1. Рис. 19.1. Ковариантность и контравариантность в параметрах типа функции Код в представленном выше листинге 19.9 проходит компиляцию, поскольку Publication => String является подтипом Book => AnyRef, что и показано в центре схемы. Результирующий тип Function1 определен в качестве ковариантного, и по­ тому показанное в правой части схемы отношение наследования двух результиру­ ющих типов имеет то же самое направление, что и две функции, показанные в цен­ тре. В отличие от этого, поскольку тип параметра функции Function1 определен в качестве контравариантного, отношение наследования двух типов параметров, показанное в левой части схемы, имеет направление, обратное направлению от­ ношения наследования двух функций. 378 Глава 19 • Параметризация типов 19.7. Приватные данные объекта У показанного ранее класса Queue есть проблема; она состоит в том, что операция mirror будет многократно копировать список trailing в список leading, если метод head вызывается несколько раз подряд в отношении списка, когда список leading пуст. Неэкономного копирования можно избежать, добавив некоторые рациональ­ ные побочные эффекты. В листинге 19.10 представлена новая реализация Queue, выполняющая не более одной коррекции списка leading за счет списка trailing для любой последовательности операций head. Листинг 19.10. Оптимизированная функциональная очередь class Queue[+T] private ( private[this] var leading: List[T], private[this] var trailing: List[T] ) { private def mirror() = if (leading.isEmpty) { while (!trailing.isEmpty) { leading = trailing.head :: leading trailing = trailing.tail } } def head: T = { mirror() leading.head } def tail: Queue[T] = { mirror() new Queue(leading.tail, trailing) } def enqueue[U >: T](x: U) = new Queue[U](leading, x :: trailing) } В отличие от прежней версии, теперь leading и trailing являются переназнача­ емыми переменными, а mirror не возвращает новую очередь, а создает в отношении текущей очереди в качестве побочного эффекта перевернутую копию из trailing в leading. По отношению к реализации Queue этот побочный эффект носит чисто внутренний характер: поскольку leading и trailing — приватные переменные, кли­ ентам Queue данный эффект не виден. Следовательно, согласно терминологии, уста­ новленной в главе 18, новая версия Queue все еще определяет чисто функциональные объекты, несмотря на то что теперь в них содержатся переназначаемые поля. Прохождение этого кода через имеющийся в Scala механизм проверки типов может вызвать удивление. Все-таки у очередей теперь есть два переназначаемых поля ковариантного параметра типа T. Не нарушаются ли здесь правила вариант­ 19.8. Верхние ограничители 379 ности? Может быть, нарушение и существовало бы, если бы не маленькая деталь: у leading и trailing имеется модификатор private[this], стало быть, они объ­ явлены приватными. Как упоминалось в разделе 13.5, к приватным элементам есть доступ только из того объекта, в котором они определены. Получается, что обращение к пере­ менным из такого объекта не вызывает проблем с вариантностью. Интуитивно понятное объяснение состоит в том, что для создания обстоятельств, при которых вариантность вызовет ошибки типа, нужно иметь ссылку на объект, содержащий статически более слабый тип, чем тот, с которым был определен объект. Что же касается обращений к приватным значениям объекта, то создать подобные обстоя­ тельства невозможно. Для приватных определений объекта в используемых в Scala правилах про­ верки вариантности есть исключение. Когда параметр типа с аннотацией либо +, либо – находится только в тех позициях, которые имеют такую же классификацию вариантности, такие определения пропускаются. Следовательно, код, показанный выше в листинге 19.10, компилируется без ошибок. В то же время, если в двух модификаторах private пропустить квалификатор [this], будут показаны две ошибки типа: Queues.scala:1: error: covariant type T occurs in contravariant position in type List[T] of parameter of setter leading_= class Queue[+T] private (private var leading: List[T], ^ Queues.scala:1: error: covariant type T occurs in contravariant position in type List[T] of parameter of setter trailing_= private var trailing: List[T]) { ^ 19.8. Верхние ограничители В листинге 16.1 (см. выше) была показана предназначенная для списков функция сортировки слиянием, получавшая в качестве своего первого аргумента функцию сравнения, а в качестве второго, каррированного — сортируемый список. Еще один способ, который может вам пригодиться для организации подобной функ­ ции сортировки, заключается в требовании того, чтобы тип списка примешивал трейт Ordered. Как упоминалось в разделе 12.4, примешивание Ordered к классу и реализация в Ordered одного абстрактного метода, compare, позволит клиентам сравнивать экземпляры класса с помощью операторов <, >, <= и >=. В качестве при­ мера в листинге 19.11 показан трейт Ordered, примешанный к классу Person. В результате двух людей можно сравнивать так: scala> val robert = new Person("Robert", "Jones") robert: Person = Robert Jones 380 Глава 19 • Параметризация типов scala> val sally = new Person("Sally", "Smith") sally: Person = Sally Smith scala> robert < sally res0: Boolean = true Листинг 19.11. Класс Person, к которому примешан трейт Ordered class Person(val firstName: String, val lastName: String) extends Ordered[Person] { def compare(that: Person) = { val lastNameComparison = lastName.compareToIgnoreCase(that.lastName) if (lastNameComparison != 0) lastNameComparison else firstName.compareToIgnoreCase(that.firstName) } override def toString = firstName + " " + lastName } Листинг 19.12. Функция сравнения с верхним ограничителем def orderedMergeSort[T <: Ordered[T]](xs: List[T]): List[T] = { def merge(xs: List[T], ys: List[T]): List[T] = (xs, ys) match { case (Nil, _) => ys case (_, Nil) => xs case (x :: xs1, y :: ys1) => if (x < y) x :: merge(xs1, ys) else y :: merge(xs, ys1) } val n = xs.length / 2 if (n == 0) xs else { val (ys, zs) = xs splitAt n merge(orderedMergeSort(ys), orderedMergeSort(zs)) } } Чтобы выставить требование о примешивании Ordered в тип списка, переданно­ го вашей новой функции сортировки, следует задействовать верхний ограничитель. Он указывается так же, как нижний, за исключением того, что вместо обозначе­ ния >:, используемого для нижних ограничителей, применяется, как показано выше, в листинге 19.12, обозначение <:. Используя синтаксис T <: Ordered[T], вы показываете, что параметр типа T име­ ет верхний ограничитель Ordered[T]. Это значит, что тип элемента, переданного orderedMergeSort, должен быть подтипом Ordered. Следовательно, List[Person] можно передать orderedMergeSort, поскольку Person примешивает Ordered. Резюме 381 Рассмотрим, к примеру, следующий список: scala> val people = List( new Person("Larry", "Wall"), new Person("Anders", "Hejlsberg"), new Person("Guido", "van Rossum"), new Person("Alan", "Kay"), new Person("Yukihiro", "Matsumoto") ) people: List[Person] = List(Larry Wall, Anders Hejlsberg, Guido van Rossum, Alan Kay, Yukihiro Matsumoto) Поскольку тип элемента этого списка Person примешивает Ordered[People] (и по­ этому является его подтипом), список можно передать методу orderedMergeSort: scala> val sortedPeople = orderedMergeSort(people) sortedPeople: List[Person] = List(Anders Hejlsberg, Alan Kay, Yukihiro Matsumoto, Guido van Rossum, Larry Wall) А теперь следует заметить, что, хоть функция сортировки, показанная в том же листинге 19.12, и служит неплохой иллюстрацией верхних ограничителей, в дей­ ствительности это не самый универсальный способ в Scala для разработки функции сортировки, получающей преимущества от трейта Ordered. Так, функцию orderedMergeSort нельзя использовать для сортировки списка целых чисел, поскольку класс Int не является подтипом Ordered[Int]: scala> val wontCompile = orderedMergeSort(List(3, 2, 1)) <console>:5: error: inferred type arguments [Int] do not conform to method orderedMergeSort's type parameter bounds [T <: Ordered[T]] val wontCompile = orderedMergeSort(List(3, 2, 1)) ^ В целях получения более универсального решения в разделе 21.6 будет показан порядок использования неявных параметров и контекстных ограничителей. Резюме В этой главе мы показали ряд техник, применяемых для скрытия информации: при­ ватные конструкторы, фабричные методы, абстракцию типов и приватные члены объекта. Кроме этого, продемонстрировали способы указать вариантность типов данных и объяснили, что вариантность означает для реализации класса. И наконец, показали две техники, помогающие получить гибкие аннотации вариантности: нижние ограничители для параметров типов методов и аннотации private[this] для локальных полей и методов. 20 Абстрактные члены Член класса или трейта называется абстрактным, если у него нет в классе полного определения. Реализовывать абстрактные элементы предполагается в подклассах того класса, в котором они объявлены. Воплощение этой идеи можно найти во многих объектно-ориентированных языках. Например, в Java можно объявить абстрактные методы. В Scala тоже, что было показано в разделе 10.2. Но Scala этим не ограничивается, и в нем данная идея реализуется самым универсальным образом: в качестве членов классов и трейтов можно объявлять не только методы, но и абстрактные поля и даже абстрактные типы. В этой главе мы рассмотрим все четыре разновидности абстрактных членов: valи var-переменные, методы и типы. Попутно изучим предварительно инициализиро­ ванные поля, ленивые val-переменные, типы, зависящие от пути, и перечисления. 20.1. Краткий обзор абстрактных членов В следующем трейте объявляется по одному абстрактному члену каждого вида: аб­ страктный тип (T), метод (transform), val-переменная (initial) и var-переменная (current): trait Abstract { type T def transform(x: T): T val initial: T var current: T } Конкретная реализация трейта Abstract нуждается в заполнении определений для каждого из его абстрактных членов. Пример реализации, предоставляющий эти определения, выглядит так: class Concrete extends Abstract { type T = String def transform(x: String) = x + x val initial = "hi" var current = initial } 20.3. Абстрактные val-переменные 383 Реализация придает конкретное значение типу T, определяя его в качестве псев­ донима типа String. Операция transform конкатенирует предоставленную ей стро­ ку с нею же самой, а для initial, как и для current, устанавливается значение "hi". Указанные примеры дают вам первое приблизительное представление о том, ка­ кие разновидности абстрактных членов существуют в Scala. Далее мы рассмотрим подробности, касающиеся этих членов, и объясним, для чего могут пригодиться эти новые формы абстрактных членов, а также члены-типы в целом. 20.2. Члены-типы В примере, приведенном в предыдущем разделе, было показано, что понятие «абстрактный тип» в Scala означает объявление типа (с ключевым словом type) в качестве члена класса или трейта без указания определения. Абстрактными мо­ гут быть и сами классы, а трейты по определению абстрактные, однако ни один из них не является в Scala тем, что называют абстрактным типом. Абстрактный тип в Scala всегда выступает членом какого-либо класса или трейта, как тип T в трейте Abstract. Неабстрактный (или конкретный) член-тип, такой как тип T в классе Concrete, можно представить себе в качестве способа определения нового имени или псевдонима для типа. К примеру, в классе Concrete типу String дается псевдоним T. В ре­ зультате везде, где в определении класса Concrete появляется T, подразумевается String. Сюда включаются преобразования типов параметров и результирующих типов, как исходных, так и текущих, в которых при их объявлении в супертрейте Abstract упоминается T. Следовательно, когда в классе Concrete реализуются эти методы, такие обозначения T интерпретируются как String. Один из поводов использовать член-тип — определение краткого описательного псевдонима для типа, чье имя длиннее или значение менее понятно, чем у псевдо­ нима. Такие члены-типы могут сделать понятнее код класса или трейта. Другое основное применение членов-типов — объявление абстрактного типа, который должен быть определен в подклассе. Более подробно этот вариант использования, продемонстрированный в предыдущем разделе, мы рассмотрим чуть позже в дан­ ной главе. 20.3. Абстрактные val-переменные Объявление абстрактной val-переменной выглядит следующим образом: val initial: String Val-переменной даются имя и тип, но не присваивается значение. Оно долж­ но быть предоставлено конкретным определением val-переменной в подклассе. Напри­ мер, в классе Concrete для реализации val-переменной используется такой код: val initial = "hi" 384 Глава 20 • Абстрактные члены Объявление в классе абстрактной val-переменной применяется, когда в этом классе еще неведомо нужное ей значение, но известно, что переменная в каждом экземпляре класса получит неизменяемое значение. Объявление абстрактной val-переменной напоминает объявление абстрактного метода без параметров: def initial: String Клиентский код будет ссылаться как на val-переменную, так и на метод абсо­ лютно одинаково (то есть obj.initial). Но если initial является абстрактной val-переменной, то клиенту гарантируется, что obj.initial будет при каждом обращении выдавать одно и то же значение. Если initial — абстрактный метод, то данная гарантия соблюдаться не будет, поскольку в таком случае метод initial можно реализовать с помощью конкретного метода, возвращающего при каждом своем вызове разные значения. Иными словами, val-переменная ограничивает свою допустимую реализацию: любая реализация должна быть определением val-переменной — она не может быть var- или def-определением. А вот объявления абстрактных методов можно реализовать как конкретными определениями методов, так и конкретными опре­ делениями var-переменных. Если взять абстрактный класс Fruit, показанный в листинге 20.1, то класс Apple будет допустимой реализацией подкласса, а класс BadApple — нет. Листинг 20.1. Переопределение абстрактных val-переменных и методов без параметров abstract class Fruit { val v: String // 'v' – значение (value) def m: String // 'm' – метод (method) } abstract class Apple extends Fruit { val v: String val m: String // Нормально воспринимаемое переопределение 'def' в 'val' } abstract class BadApple extends Fruit { def v: String // ОШИБКА: переопределять 'val' в 'def' нельзя def m: String } 20.4. Абстрактные var-переменные Как и для абстрактной val-переменной, для абстрактной var-переменной объ­ являются только имя и тип, но не начальное значение. Например, в листинге 20.2 показан трейт AbstractTime, в котором объявляются две абстрактные переменные с именами hour и minute. 20.5. Инициализация абстрактных val-переменных 385 Листинг 20.2. Объявление абстрактных var-переменных trait AbstractTime { var hour: Int var minute: Int } Что означают такие абстрактные var-переменные, как hour и minute? В раз­ деле 18.2 было показано, что var-переменные, объявленные в качестве членов класса, оснащаются геттером и сеттером. Это справедливо и для абстрактных var-переменных. Если, к примеру, объявляется абстрактная var-переменная по имени hour, то подразумевается, что для нее объявляется абстрактный геттер hour и абстрактный сеттер hour_= . Тем самым не определяется никакое пере­ назначаемое поле, а конкретная реализация абстрактной var-переменной будет выполнена в подклассах. Например, определение AbstractTime, показанное выше, в листинге 20.2, абсолютно эквивалентно определению, показанному в листин­ ге 20.3. Листинг 20.3. Расширение абстрактных var-переменных в геттеры и сеттеры trait def def def def } AbstractTime { hour: Int hour_=(x: Int): Unit minute: Int minute_=(x: Int) : Unit // // // // get-метод set-метод get-метод set-метод для для для для 'hour' 'hour' 'minute' 'minute' 20.5. Инициализация абстрактных val-переменных Иногда абстрактные val-переменные играют роль, аналогичную роли параметров суперкласса: они позволяют предоставить в подклассе подробности, не указанные в суперклассе. На практике это обстоятельство играет важную роль для трейтов, поскольку у них нет конструкторов, которым можно передавать параметры. Таким образом, обычный принцип параметризации трейта работает через абстрактные val-переменные, реализованные в подклассах. Рассмотрим в качестве примера переформулировку класса Rational из главы 6, который был показан в листинге 6.5, в трейт: trait RationalTrait { val numerArg: Int val denomArg: Int } У класса Rational из главы 6 были два параметра: n для числителя рациональ­ ного числа и d для его знаменателя. Представленный здесь трейт RationalTrait 386 Глава 20 • Абстрактные члены определяет вместо них две абстрактные val-переменные: numerArg и denomArg. Чтобы создать конкретный экземпляр этого трейта, нужно реализовать определе­ ния абстрактных val-переменных, например: new RationalTrait { val numerArg = 1 val denomArg = 2 } Здесь появляется ключевое слово new, после которого в фигурных скобках стоит тело трейта RationalTrait. Это выражение выдает экземпляр анонимного класса, примешивающего трейт и определяемого телом. Создание экземпляра данного анонимного класса дает эффект, аналогичный созданию экземпляра с помощью кода new Rational(1, 2). Но аналогия здесь неполная. Есть небольшое различие, касающееся порядка, в котором инициализируются выражения. При записи следующего кода: new Rational(expr1, expr2) два выражения, expr1 и expr2, вычисляются перед инициализацией класса Rational, следовательно, значения expr1 и expr2 доступны для инициализации класса Rational. С трейтами складывается обратная ситуация. При записи кода: new RationalTrait { val numerArg = expr1 val denomArg = expr2 } выражения expr1 и expr2 вычисляются как часть инициализации анонимного клас­ са, но анонимный класс инициализируется после RationalTrait. Следовательно, значения numerArg и denomArg в ходе инициализации RationalTrait недоступны (точнее говоря, выбор любого значения даст значение по умолчанию для типа Int, то есть нуль). Для представленного ранее определения RationalTrait это не про­ блема, поскольку при инициализации трейта значения numerArg или denomArg не используются. Но проблема возникает в варианте RationalTrait, показанном в листинге 20.4, где определяются нормализованные числитель и знаменатель. Листинг 20.4. Трейт, использующий абстрактные val-переменные trait RationalTrait { val numerArg: Int val denomArg: Int require(denomArg != 0) private val g = gcd(numerArg, denomArg) val numer = numerArg / g val denom = denomArg / g private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) override def toString = s"$numer/$denom" } 20.5. Инициализация абстрактных val-переменных 387 При попытке создать экземпляр этого трейта с какими-либо выражениями для числителя и знаменателя, не являющимися простыми литералами, выдается исключение: scala> val x = 2 x: Int = 2 scala> new RationalTrait { val numerArg = 1 * x val denomArg = 2 * x } java.lang.IllegalArgumentException: requirement failed at scala.Predef$.require(Predef.scala:280) at RationalTrait.$init$(<console>:4) ... 28 elided Исключение в этом примере было сгенерировано потому, что при инициализа­ ции класса RationalTrait у denomArg сохранилось исходное нулевое значение, из-за чего вызов require завершился сбоем. В данном примере показано, что порядок инициализации для параметров класса и абстрактных полей разный. Аргумент параметра класса вычисляется до его пере­ дачи конструктору класса (если только это не параметр, передаваемый по имени). А вот реализация определения val-переменной, которая находится в подклассе, вычисляется только после инициализации суперкласса. Теперь вы понимаете, почему поведение абстрактных val-переменных отлича­ ется от поведения параметров, и было бы неплохо узнать, что с этим делать. Полу­ чится ли определить RationalTrait, который можно надежно инициализировать, не опасаясь, что возникнут ошибки из-за неинициализированных полей? В дей­ ствительности в Scala предлагается два альтернативных решения этой проблемы: предынициализируемые поля и ленивые val-переменные. Эти решения рассматри­ ваются в остальной части раздела. Предынициализируемые поля Первое решение — предынициализируемые поля — позволяет инициализировать поле подкласса до вызова суперкласса. Для этого нужно просто поместить определе­ ние поля в фигурные скобки перед вызовом конструктора класса. В качестве приме­ ра в листинге 20.5 показана еще одна попытка создания экземпляра RationalTrait. Из этого примера видно, что инициализирующая часть стоит перед упоминанием супертрейта RationalTrait. Они разделены ключевым словом with. Листинг 20.5. Предынициализируемые поля в выражении анонимного класса scala> new { val numerArg = 1 * x val denomArg = 2 * x } with RationalTrait res1: RationalTrait = 1/2 388 Глава 20 • Абстрактные члены Сфера применения предынициализируемых полей не ограничивается аноним­ ными классами, они могут использоваться также в объектах или именованных подклассах. Соответствующие примеры показаны в листингах 20.6 и 20.7. Из них видно, что в каждом случае секция предварительной инициализации ставится по­ сле ключевого слова extends, относящегося к определяемому объекту или классу. Класс RationalClass, показанный в листинге 20.7, иллюстрирует общую схему доступности параметров класса для инициализации супертрейта. Листинг 20.6. Предынициализируемые поля в определении объекта object twoThirds extends { val numerArg = 2 val denomArg = 3 } with RationalTrait Листинг 20.7. Предынициализируемые поля в определении класса class RationalClass(n: Int, d: Int) extends { val numerArg = n val denomArg = d } with RationalTrait { def + (that: RationalClass) = new RationalClass( numer * that.denom + that.numer * denom, denom * that.denom ) } Поскольку инициализация полей происходит до того, как вызывается кон­ структор суперкласса, их инициализаторы не могут ссылаться на создаваемый объект. Следовательно, если такой инициализатор ссылается на this, то происхо­ дит обращение к объекту, который содержит класс, или к уже созданному объекту, а не к создаваемому. Рассмотрим пример: scala> new { val numerArg = 1 val denomArg = this.numerArg * 2 } with RationalTrait On line 3: error: value numerArg is not a member of object $iw val denomArg = this.numerArg * 2 ^ Данный пример не проходит компиляцию, поскольку ссылка this.numerArg нацеливалась на поле numerArg в объекте, содержащем new (в качестве которого в данном случае выступал синтезированный объект по имени $iw , в который интерпретатор помещает строки пользовательского ввода). Итак, еще раз: преды­ нициализируемые поля ведут себя в этом смысле подобно аргументам конструк­ тора класса. 20.5. Инициализация абстрактных val-переменных 389 Ленивые val-переменные Предынициализируемые поля могут применяться для точной имитации поведения инициализации аргументов конструктора класса. Но иногда лучше позволить самой системе разобраться, как и что должно быть проинициализировано. Добиться этого можно с помощью определения ленивой val-переменной. Если перед определением val-переменной поставить модификатор lazy, то выражение инициализации справа будет вычисляться только при первом использовании val-переменной. Определим, к примеру, объект Demo с val-переменной: scala> object Demo { val x = { println("initializing x"); "done" } } defined object Demo Теперь сначала сошлемся на Demo, а затем на Demo.x: scala> Demo initializing x res3: Demo.type = Demo$@2129a843 scala> Demo.x res4: String = done Как видите, на момент использования объекта Demo его поле x становится про­ инициализированным. Инициализация x составляет часть инициализации Demo. Но ситуация изменится, если определить поле x как lazy: scala> object Demo { lazy val x = { println("initializing x"); "done" } } defined object Demo scala> Demo res5: Demo.type = Demo$@1a18e68a scala> Demo.x initializing x res6: String = done Теперь инициализация Demo не включает инициализацию x. Она будет отложена до первого использования x. Это похоже на ситуацию определения x в качестве метода без параметров с помощью ключевого слова def. Но в отличие от def лени­ вая val-переменная никогда не вычисляется более одного раза. Фактически после первого вычисления ленивой val-переменной результат вычисления сохраняется, чтобы его можно было применить повторно при последующем использовании той же самой val-переменной. При изучении данного примера создается впечатление, что объекты, подобные Demo, сами ведут себя как ленивые val-переменные, поскольку инициализируются 390 Глава 20 • Абстрактные члены по необходимости при их первом использовании. Так и есть. Действительно, опре­ деление объекта может рассматриваться как сокращенная запись для определения ленивой val-переменной с анонимным классом, в котором описывается содержимое объекта. Используя ленивые val-переменные, можно переделать RationalTrait , как показано в листинге 20.8. В новом определении трейта все конкретные поля опре­ делены как lazy. Есть еще одно изменение, касающееся предыдущего определения RationalTrait, показанного выше, в листинге 20.4. Данное изменение заключает­ ся в том, что условие require было перемещено из тела трейта в инициализатор приватного поля g , вычисляющий наибольший общий делитель для numerArg и denomArg. После внесения этих изменений при инициализации LazyRationalTrait делать больше ничего не нужно, поскольку весь код инициализации теперь являет­ ся правосторонней частью ленивой val-переменной. Таким образом, теперь вполне безопасно инициализировать абстрактные поля LazyRationalTrait после того, как класс уже определен. Листинг 20.8. Инициализация трейта с ленивыми val-переменными trait LazyRationalTrait { val numerArg: Int val denomArg: Int lazy val numer = numerArg / g lazy val denom = denomArg / g override def toString = s"$numer/$denom" private lazy val g = { require(denomArg != 0) gcd(numerArg, denomArg) } private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) } Рассмотрим пример: scala> val x = 2 x: Int = 2 scala> new LazyRationalTrait { val numerArg = 1 * x val denomArg = 2 * x } res7: LazyRationalTrait = 1/2 Какая-либо предварительная инициализация здесь не нужна. Проследим по­ следовательность инициализации, приводящей к тому, что в показанном ранее коде на стандартное устройство будет выведена строка 1/2. 1. Создается новый экземпляр LazyRationalTrait, и запускается код инициали­ зации LazyRationalTrait. Этот код пуст — ни одно из полей LazyRationalTrait еще не проинициализировано. 20.5. Инициализация абстрактных val-переменных 391 2. С помощью вычисления выражения new определяется первичный конструктор анонимного подкласса. Данная процедура включает в себя инициализацию numerArg значением 2 и инициализацию denomArg значением 4. 3. Далее интерпретатор в отношении создаваемого объекта вызывает метод toString, чтобы получившееся значение можно было бы вывести на стандартное устройство. 4. Метод toString, определенный в трейте LazyRationalTrait, выполняет первое обращение к полю numer, что вызывает вычисление инициализатора. 5. Инициализатор поля numer обращается к приватному полю g ; таким обра­ зом, следующим вычисляется g. При этом вычислении происходит обращение к numerArg и denomArg, которые были определены на шаге 2. 6. Метод toString обращается к значению denom, что вызывает вычисление denom. При этом происходит обращение к значениям denomArg и g. Инициализатор поля g заново уже не вычисляется, поскольку был вычислен на шаге 5. 7. Создается и выводится строка результата 1/2. Обратите внимание: в классе LazyRationalTrait определение g появляется в тексте кода после определений в нем numer и denom. Несмотря на это, ввиду того что все три значения ленивые, g инициализируется до завершения инициализации numer и denom. Тем самым демонстрируется важное свойство ленивых val-переменных: поря­ док следования их определений в тексте кода не играет никакой роли, поскольку инициализация значений выполняется по требованию. Стало быть, ленивые valпеременные могут освободить вас как программиста от необходимости обдумывать порядок расстановки определений val -переменных, чтобы гарантировать, что к моменту востребованности все будет определено. Но данное преимущество сохраняется до тех пор, пока инициализация ленивых val-переменных не производит никаких побочных эффектов, а также не зависит от них. Если есть побочные эффекты, то порядок инициализации становится зна­ чимым. И тогда могут возникнуть серьезные трудности в отслеживании порядка запуска инициализационного кода, как было показано в предыдущем примере. Сле­ довательно, ленивые val-переменные — идеальное дополнение к функциональным объектам, в которых порядок инициализации не имеет значения до тех пор, пока все в конечном счете не будет проинициализировано. А вот для преимущественно императивного кода эти переменные подходят меньше. Ленивые функциональные языки Scala — далеко не первый язык, использующий идеальную пару ленивых определений и функционального кода. Существует целая категория ленивых языков функциональ­ ного программирования, в которых каждое значение и каждый параметр инициали­ зируются лениво. Яркий представитель этого класса языков — Haskell. 392 Глава 20 • Абстрактные члены 20.6. Абстрактные типы В начале этой главы в качестве объявления абстрактного типа мы показали код type T. Далее мы рассмотрим, что означает такое объявление абстрактного типа и для чего оно может пригодиться. Как и все остальные объявления абстракций, объявление абстрактного типа — заместитель для чего-либо, что будет конкретно определено в подклассах. В данном случае это тип, который будет определен ниже по иерархии классов. Следовательно, обозначение T ссылается на тип, который на момент его объявления еще неизвестен. Разные подклассы могут обеспечивать различные реализации T. Рассмотрим широко известный пример, в который абстрактные типы вписы­ ваются вполне естественно. Предположим, что получена задача смоделировать привычный рацион животных. Начать можно с определения класса питания Food и класса животных Animal с методом питания eat: class Food abstract class Animal { def eat(food: Food): Unit } Затем можно попробовать создать специализацию этих двух классов в виде класса коров Cow, питающихся травой Grass: class Grass extends Food class Cow extends Animal { override def eat(food: Grass) = {} // Этот код не пройдет компиляцию } Но при попытке компиляции этих новых классов будут получены следующие ошибки: BuggyAnimals.scala:7: error: class Cow needs to be abstract, since method eat in class Animal of type (Food)Unit is not defined class Cow extends Animal { ^ BuggyAnimals.scala:8: error: method eat overrides nothing override def eat(food: Grass) = {} ^ Дело в том, что метод eat в классе Cow не переопределяет метод eat класса Animal, поскольку типы их параметров различаются: в классе Cow это Grass, а в клас­ се Animal это Food. Некоторые считают, что в отклонении этих двух классов виновата слиш­ ком строгая система типов. Они говорят, что должно быть допустимо специа­ лизировать параметр метода в подклассе. Но если бы классы были позволены в том виде, в котором написаны, вы быстро оказались бы в весьма небезопасной ситуации. 20.6. Абстрактные типы 393 К примеру, механизму проверки типов будет передан следующий скрипт: class Food abstract class Animal { def eat(food: Food): Unit } class Grass extends Food class Cow extends Animal { override def eat(food: Grass) = {} // Этот код не пройдет компиляцию, } // но если бы это случилось... class Fish extends Food val bessy: Animal = new Cow bessy eat (new Fish) // ...коров можно было бы накормить рыбой. Если снять ограничения, то программа пройдет компиляцию, поскольку коровы из класса Cow — животные из класса Animal, а у класса Animal есть метод кормления eat, который принимает любую разновидность питания Food, включая рыбу, то есть Fish. Но коровы не едят рыбу! Вместо этого вам нужно применить более точное моделирование. Животные из класса Animal потребляют (eat) питание Food, но какое именно питание потребляет каждое животное, зависит от самого животного. Это довольно четко можно выра­ зить с помощью абстрактного типа, что и показано в листинге 20.9. Листинг 20.9. Моделирование подходящего питания с помощью абстрактных типов class Food abstract class Animal { type SuitableFood <: Food def eat(food: SuitableFood): Unit } С новым определением класса животное Animal может потреблять только то питание, которое ему подходит. Какое именно питание будет подходящим, нельзя определить на уровне класса Animal. Поэтому подходящее питание SuitableFood моделируется в виде абстрактного типа. У него есть верхний ограничитель Food, что выражено условием <: Food. Это значит, что любая конкретная реализация SuitableFood (в подклассе класса Animal) должна быть подклассом Food. К примеру, реализовать SuitableFood классом IOException не получится. После определения Animal можно, как показано в листинге 20.10, перейти к коровам. Класс Cow устанавливает в качестве подходящего для коров питания SuitableFood траву Grass, а также определяет конкретный метод eat для данной разновидности питания. Листинг 20.10. Реализация абстрактного типа в подклассе class Grass extends Food class Cow extends Animal { type SuitableFood = Grass override def eat(food: Grass) = {} } 394 Глава 20 • Абстрактные члены Эти новые определения класса компилируются без ошибок. При попытке запу­ стить с новыми определениями класс контрпримера про коров, которые едят рыбу (cows that eat fish), будут получены следующие ошибки компиляции: scala> class Fish extends Food defined class Fish scala> val bessy: Animal = new Cow bessy: Animal = Cow@4df50829 scala> bessy eat (new Fish) ^ error: type mismatch; found : Fish required: bessy.SuitableFood 20.7. Типы, зависящие от пути Еще раз посмотрим на последнее сообщение об ошибке. Нас интересует тип, тре­ бующийся для метода eat: bessy.SuitableFood. Указание типа состоит из ссылки на объект, bessy, за которой следует поле типа объекта, SuitableFood. Тем самым показывается, что объекты в Scala в качестве членов могут иметь типы. Смысл bessy.SuitableFood — «тип SuitableFood, являющийся членом объекта, на который ссылается bessy, или, иначе, тип питания, подходящего для bessy. Тип вида bessy.SuitableFood называется типом, зависящим от пути (pathdependent type). Слово «путь» здесь означает ссылку на объект. Это может быть единственное имя, такое как bessy, или более длинный путь доступа, такой как farm.barn.bessy, где все составляющие, farm, barn и bessy, — переменные (или имена объектов-одиночек), которые ссылаются на объекты. Термин «тип, зависящий от пути» подразумевает, что тип зависит от пути; и в целом, различные пути дают начало разным типам. Например, предположим, что для определения классов собачьего питания DogFood и собак Dog используется следующий код: class DogFood extends Food class Dog extends Animal { type SuitableFood = DogFood override def eat(food: DogFood) = {} } При попытке накормить собаку едой для коров ваш код не пройдет компиляцию: scala> val bessy = new Cow bessy: Cow = Cow@6740b169 20.7. Типы, зависящие от пути 395 scala> val lassie = new Dog lassie: Dog = Dog@31419d4a scala> lassie eat (new bessy.SuitableFood) ^ error: type mismatch; found : Grass required: DogFood Проблема заключается в том, что типом объекта SuitableFood, переданного методу eat, выступает bessy.SuitableFood, а он несовместим с параметром типа eat, которым является lassie.SuitableFood. В случае с двумя собаками из класса Dog ситуация другая. Поскольку в классе Dog тип SuitableFood определен в качестве псевдонима для класса DogFood, то типы SuitableFood двух представителей класса Dog по факту одинаковы. В результате экземпляр класса Dog, называемый lassie, фактически может питаться тем, что подходит другому экземпляру класса Dog, который мы назовем bootsie: scala> val bootsie = new Dog bootsie: Dog = Dog@2740c1e scala> lassie eat (new bootsie.SuitableFood) Тип, зависящий от пути, напоминает синтаксис для типа внутреннего класса в Java, но есть существенное различие: в типе, зависящем от пути, называется внешний объект, а в типе внутреннего класса — внешний класс. Типы внутренних классов в стиле Java могут быть выражены и в Scala, но записываются по-другому. Рассмотрим два класса, наружный Outer и внутренний Inner: class Outer { class Inner } В Scala вместо применяемого в Java выражения Outer.Inner к внутреннему классу обращаются с помощью выражения Outer#Inner. Синтаксис с использова­ нием точки (.) зарезервирован для объектов. Представим, к примеру, что создаются экземпляры двух объектов типа Outer: val o1 = new Outer val o2 = new Outer Здесь o1.Inner и o2.Inner — два типа, зависящие от пути, и это разные типы. Оба они соответствуют более общему типу Outer#Inner (являются его подти­ пами), который представляет класс Inner с произвольным внешним объектом типа Outer. В отличие от этого тип o1.Inner ссылается на класс Inner с конкретным внешним объектом, на который ссылается o1. Точно так же тип o2.Inner ссылается на класс Inner с другим конкретным внешним объектом, на который ссылается o2. 396 Глава 20 • Абстрактные члены В Scala, как и в Java, экземпляры внутреннего класса содержат ссылку на экзем­ пляр охватывающего их внешнего класса. Это, к примеру, позволяет внутреннему классу обращаться к членам его внешнего класса. Таким образом, невозможно создать экземпляр внутреннего класса, не имея какого-либо способа указать эк­ земпляр внешнего класса. Один из способов заключается в создании экземпляра внутреннего класса внутри тела внешнего класса. В подобном случае будет ис­ пользован текущий экземпляр внешнего класса (в ссылке на который можно задействовать this). Еще один способ заключается в использовании типа, зависящего от пути. Например, в типе o1.Inner присутствует название конкретного внешнего объекта, поэтому можно создать его экземпляр: scala> new o1.Inner res11: o1.Inner = Outer$Inner@2e13c72b Получившийся внутренний объект будет содержать ссылку на свой внешний объект, то есть на объект, на который ссылается o1. В отличие от этого, поскольку тип Outer#Inner не содержит названия какого-либо конкретного экземпляра класса Outer, создать экземпляр данного класса невозможно: scala> new Outer#Inner ^ error: Outer is not a legal prefix for a constructor 20.8. Уточняющие типы Когда класс является наследником другого класса, первый класс называют номинальным подтипом другого класса. Этот подтип номинальный, поскольку у каж­ дого типа есть имя и имена явным образом объявлены имеющими отношение подтипирования. Кроме того, в Scala дополнительно поддерживается структурное подтипирование, где отношение подтипирования возникает просто потому, что у двух типов есть совместимые элементы. Для получения в Scala структурно­ го подтипирования нужно задействовать имеющиеся в данном языке уточня­ ющие типы. Обычно удобнее применять номинальное подтипирование, поэтому в любой новой конструкции нужно сначала попробовать воспользоваться именно им. Имя — один краткий идентификатор, следовательно, короче явного перечисления типов членов. Кроме того, структурное подтипирование зачастую более гибко, чем требуется. Тем не менее оно имеет свои преимущества. Одно из них заключается в том, что иногда действительно не нужно ничего определять в виде типов, кро­ ме членов самого класса. Предположим, к примеру, что необходимо определить класс пастбища Pasture, который может содержать животных, поедающих траву. Одним из вариантов может быть определение трейта для животных, питающихся 20.9. Перечисления 397 травой, — AnimalThatEatsGrass и его примешивание в каждый класс, где он при­ меняется. Но это будет слишком многословным решением. В классе Cow уже есть объявление, что это животное и оно ест траву, а теперь придется еще раз объявлять, что это и животное, поедающее траву. Вместо определения AnimalThatEatsGrass можно воспользоваться уточняющим типом. Просто запишите основной тип, Animal, а за ним последовательность членов, перечисленных в фигурных скобках. Члены в фигурных скобках представляют дальнейшие указания, или, если хотите, уточнения, типов элементов из основного класса. Вот как записывается тип «животное, поедающее траву»: Animal { type SuitableFood = Grass } Теперь, имея в своем распоряжении этот тип, класс пастбища можно записать следующим образом: class Pasture { var animals: List[Animal { type SuitableFood = Grass }] = Nil // ... } 20.9. Перечисления Как интересный вариант применения типов, зависящих от пути, можно рассмо­ треть имеющуюся в Scala поддержку перечислений. В некоторых других языках, включая Java и C#, перечисления фигурируют в качестве встроенных в язык конструкций для определения новых типов. Scala не нуждается в специальном синтаксисе для перечислений. Вместо этого в стандартной библиотеке языка есть класс scala.Enumeration. В целях создания нового перечисления определяется объект, расширяющий этот класс. В примере ниже показано определение нового перечисления под на­ званием Colors: object Color extends Enumeration { val Red = Value val Green = Value val Blue = Value } Scala позволяет также сократить ряд последовательных определений val- или var-переменных, у которых правая сторона выражений одинаковая. В качестве эквивалента показанного ранее кода можно сделать следующую запись: object Color extends Enumeration { val Red, Green, Blue = Value } 398 Глава 20 • Абстрактные члены В этом определении объекта предоставляются три значения: Color.Red, Co­ lor.Green и Color.Blue. Кроме того, все, что имеется в Color, можно импортировать с помощью такого кода: import Color._ а затем просто использовать имена Red, Green и Blue. Но каким будет тип этих значений? Класс Enumeration определяет внутренний класс по имени Value, а метод без параметров с таким же именем Value возвращает новый экземпляр этого класса. Иными словами, типом такого значения, как Color.Red, выступает Color.Value, который является типом всех перечисляемых значений, определенных в объекте Color. Это тип, зависящий от пути, где Color — путь, а Value — зависящий от него тип. Важно, что это совершенно новый тип, отличающийся от других типов. В частности, если определить еще одно перечисление, такое как это: object Direction extends Enumeration { val North, East, South, West = Value } то Direction.Value будет отличаться от Color.Value, поскольку та часть, которая относится к пути, у этих двух типов разная. Имеющийся в Scala класс Enumeration предлагает также многие другие свойства, которые можно найти в конструкциях перечислений других языков. Со значениями перечислений можно связать имена, воспользовавшись для этого другим перегру­ женным вариантом метода Value: object Direction extends Enumeration { val North = Value("North") val East = Value("East") val South = Value("South") val West = Value("West") } Значения в перечислении можно перебрать через множество, которое возвра­ щает имеющийся в перечислении метод values: scala> for (d <- Direction.values) print(d + " ") North East South West Значения в перечислении нумеруются с нуля, а номер значения в перечислении можно определить с помощью имеющегося в нем метода id: scala> Direction.East.id res14: Int = 1 Можно пойти и другим путем — от неотрицательного целого числа, которое служит этому номеру идентификатором в перечислении: scala> Direction(1) res15: Direction.Value = East 20.10. Практический пример: работа с валютой 399 Этого должно быть вполне достаточно, чтобы приступить к работе с перечисле­ ниями. Дополнительные сведения можно получить в комментариях Scaladoc для класса scala.Enumeration. 20.10. Практический пример: работа с валютой Далее в главе рассмотрен практический пример, объясняющий порядок использо­ вания в Scala абстрактных типов. При этом будет поставлена задача разработать класс Currency . Обычный его экземпляр будет представлять денежную сумму в долларах, евро, йенах и некоторых других валютах. Он позволит совершать с валютой ряд арифметических операций. Например, можно будет сложить две суммы в одной и той же валюте. Или умножить текущую сумму на коэффициент процентной ставки. Эти соображения приводят к следующей первой конструкции класса валют: // Первая (нерабочая) конструкция класса Currency abstract class Currency { val amount: Long def designation: String override def toString = s"$amount $designation" def + (that: Currency): Currency = ... def * (x: Double): Currency = ... } Поле amount (сумма) в классе валют — количество представляемых ею валют­ ных единиц. Оно имеет тип Long, то есть представляемая сумма денежных средств может быть очень крупной, сравнимой с рыночной капитализацией Google или Apple. Здесь поле оставлено абстрактным в ожидании своего определения, когда в подклассе зайдет речь о конкретной сумме. Наименование валюты designation — строка, которая идентифицирует эту валюту. Метод toString класса Currency показывает сумму и наименование валюты. Он будет выдавать результат следу­ ющего вида: 79 USD 11000 Yen 99 Euro И наконец, имеются методы + для сложения сумм в валюте и * для умножения суммы в валюте на число с плавающей точкой. Конкретное значение в валюте можно создать, предоставив конкретные значения суммы и наименования валюты: new Currency { val amount = 79L def designation = "USD" } 400 Глава 20 • Абстрактные члены Эта конструкция не вызовет нареканий, если задумано моделирование с исполь­ зованием лишь одной валюты, например только долларов или только евро. Но она не будет работать при необходимости иметь дело сразу с несколькими валютами. Предположим, выполняется моделирование долларов и евро в качестве двух под­ классов класса валюты: abstract class Dollar extends Currency { def designation = "USD" } abstract class Euro extends Currency { def designation = "Euro" } На первый взгляд все выглядит вполне разумно. Но данный код позволит скла­ дывать доллары с евро. Результатом такого сложения будет тип Currency. Но это будет весьма забавная валюта — смесь евро и долларов. Вместо этого нужно полу­ чить более специализированную версию метода +. При его реализации в классе Dollar он должен получать аргументы типа Dollar и выдавать результат типа Dollar; при реализации в классе Euro — получать аргументы типа Euro и выдавать результат типа Euro. Следовательно, тип метода сложения будет изменяться в за­ висимости от того, в каком классе находится. И все же хотелось бы создать метод сложения единожды, а не делать это при каждом новом определении валюты. Чтобы помочь справиться с подобными ситуациями, Scala предоставляет весьма простую технологию. Если к моменту определения класса что-то еще неизвестно, то нужно сделать это «что-то» абстрактным. Технология применима как к значениям, так и к типам. В случае с валютами точные типы аргументов и результирующие типы метода сложения неизвестны, следовательно, являются подходящими кан­ дидатами для того, чтобы стать абстрактными. Это привело бы к следующей предварительной версии кода класса AbstractCur­ rency: // Вторая (все еще несовершенная) конструкция класса Currency abstract class AbstractCurrency { type Currency <: AbstractCurrency val amount: Long def designation: String override def toString = s"$amount $designation" def + (that: Currency): Currency = ... def * (x: Double): Currency = ... } Единственное отличие от прежней ситуации заключается в том, что класс теперь называется AbstractCurrency и содержит абстрактный тип Currency, представля­ ющий стоящую под вопросом реальную валюту. Каждому конкретному подклассу AbstractCurrency придется фиксировать тип Currency, чтобы обозначать конкрет­ ный подкласс как таковой, тем самым затягивая узел. Вот как, к примеру, выглядит новая версия класса Dollar, которая теперь рас­ ширяет класс AbstractCurrency: 20.10. Практический пример: работа с валютой 401 abstract class Dollar extends AbstractCurrency { type Currency = Dollar def designation = "USD" } Данная конструкция вполне работоспособна, но по-прежнему далека от совер­ шенства. Есть проблема, скрывающаяся за многоточиями, которые показывают в классе AbstractCurrency пропущенные определения методов + и *. В частности, как в этом классе должен быть реализован метод сложения? Нетрудно вычислить правильную сумму в новой валюте как this.amount + that.amount, но как преоб­ разовать сумму в валюту нужного типа? Можно попробовать применить следующий код: def + (that: Currency): Currency = new Currency { val amount = this.amount + that.amount } Но он не пройдет компиляцию: error: class type required def + (that: Currency): Currency = new Currency { ^ Одно из ограничений в трактовке в Scala абстрактных типов заключается в невозможности создать экземпляр абстрактного типа, а также в невозможности абстрактного типа играть роль супертипа для другого класса1. Следовательно, компилятор будет отвергать код показанного здесь примера, в котором предпри­ нимается попытка создать экземпляр Currency. Но эти ограничения можно обойти, используя фабричный метод. Вместо того чтобы создавать экземпляр абстрактного класса, напрямую объявите абстрактный метод, который делает это. Затем там, где абстрактный тип устанавливается в какойлибо конкретный тип, нужно предоставить конкретную реализацию фабричного метода. Для класса AbstractCurrency все вышесказанное будет выглядеть так: abstract class AbstractCurrency { type Currency <: AbstractCurrency // абстрактный тип def make(amount: Long): Currency // фабричный метод ... // вся остальная часть определения класса } Подобную конструкцию, конечно, можно заставить работать, но выглядит она как-то подозрительно. Зачем помещать фабричный метод внутрь класса AbstractCurrency? Это выглядит довольно сомнительно как минимум по двум причинам. Во-первых, если есть некая сумма в валюте (скажем, один доллар), то есть и возможность нарастить сумму в той же валюте, используя следующий код: myDollar.make(100) // здесь еще сто! 1 В последнее время появились многообещающие исследования виртуальных классов, которые позволили бы сделать это, но пока такие классы в Scala не поддерживаются. 402 Глава 20 • Абстрактные члены В эпоху цветных ксероксов данный скрипт может стать заманчивым, но сле­ дует надеяться, что никто не сможет проделывать это слишком долго, не будучи пойманным за руку. Во-вторых, этот код, если у вас уже есть ссылка на объект Currency, позволяет создавать дополнительные объекты Currency. Но как полу­ чить первый объект данной валюты Currency? Вам понадобится другой метод создания, выполняющий практически ту же работу, что и make. То есть вы стол­ кнулись со случаем дублирования кода, являющимся верным признаком «кода с душком». Решение, конечно же, будет заключаться в перемещении абстрактного типа и фа­ бричного класса за пределы класса AbstractCurrency. Нужно создать другой класс, содержащий класс AbstractCurrency, тип Currency и фабричный метод make. Назовем этот класс CurrencyZone: abstract class CurrencyZone { type Currency <: AbstractCurrency def make(x: Long): Currency abstract class AbstractCurrency { val amount: Long def designation: String override def toString = s"$amount $designation" def + (that: Currency): Currency = make(this.amount + that.amount) def * (x: Double): Currency = make((this.amount * x).toLong) } } Пример конкретизации CurrencyZone — объект US, который можно определить следующим образом: object US extends CurrencyZone { abstract class Dollar extends AbstractCurrency { def designation = "USD" } type Currency = Dollar def make(x: Long) = new Dollar { val amount = x } } Здесь US — объект, расширяющий CurrencyZone . В нем определяется класс Dollar, являющийся подклассом AbstractCurrency. Следовательно, тип денежных единиц в этой зоне — доллар США, US.Dollar. Объект US также устанавливает, что тип Currency будет псевдонимом для Dollar, и предоставляет реализацию фабрич­ ного метода make для возвращения суммы в долларах. Конструкция вполне работоспособна. Нужно лишь добавить несколько уточ­ нений. Первое из них касается разменных монет. До сих пор каждая валюта из­ мерялась в целых единицах: долларах, евро или йенах. Но у большинства валют имеются разменные монеты, например, в США есть доллары и центы. Наиболее простой способ моделировать центы — использовать поле amount в US.Currency, представленное в центах, а не в долларах. Чтобы вернуться к доллару, будет полез­ 20.10. Практический пример: работа с валютой 403 но ввести в класс CurrencyZone поле CurrencyUnit, содержащее одну стандартную единицу в данной валюте: abstract class CurrencyZone { ... val CurrencyUnit: Currency } Как показано в листинге 20.11, в объекте US могут быть определены величины Cent, Dollar и CurrencyUnit. Это определение объекта похоже на предыдущее, за исключением того, что в него добавлены три новых поля. Поле Cent представляет сумму 1 US.Currency. Это объект, аналогичный одноцентовой монете. Поле Dollar представляет сумму 100 US.Currency. Следовательно, объект US теперь определяет имя Dollar двумя способами. Тип Dollar, определенный абстрактным внутренним классом по имени Dollar, представляет общее название валюты Currency, дей­ ствительное в валютной зоне US. В отличие от этого, значение Dollar, на которое ссылается val-поле по имени Dollar, представляет 1 доллар США, аналогичный однодолларовой купюре. Третье определение поля CurrencyUnit указывает на то, что стандартная денежная единица в зоне США — доллар, Dollar, то есть значение Dollar, на которое ссылается поле, не является типом Dollar. Листинг 20.11. Зона валюты США object US extends CurrencyZone { abstract class Dollar extends AbstractCurrency { def designation = "USD" } type Currency = Dollar def make(cents: Long) = new Dollar { val amount = cents } val Cent = make(1) val Dollar = make(100) val CurrencyUnit = Dollar } Метод toString в классе Currency также нуждается в адаптации для воспри­ ятия разменных монет на счету. Например, сумма 10 долларов 23 цента должна выводиться как десятичное число: 10.23 USD. Чтобы добиться этого результата, принадлежащий Currency метод toString можно реализовать следующим образом: override def toString = ((amount.toDouble / CurrencyUnit.amount.toDouble) formatted ("%." + decimals(CurrencyUnit.amount) + "f") + " " + designation) Здесь formatted является методом, доступным в Scala в нескольких классах, включая Double1. Метод formatted возвращает строку, полученную в результате 1 Чтобы обеспечить доступность метода formatted, в Scala используются обогащающие оболочки, рассмотренные в разделе 5.10. 404 Глава 20 • Абстрактные члены форматирования исходной строки, в отношении которой он был вызван, в соот­ ветствии со строкой форматирования, переданной ему в виде его правого операнда. Синтаксис строк форматирования, передаваемых методу formatted, аналогичен синтаксису, используемому для Java-метода String.format. Например, строка форматирования %.2f форматирует число с двумя зна­ ками после точки. Строка форматирования, примененная в показанном ра­ нее методе toString , собирается путем вызова метода decimals в отношении CurrencyUnit.amount. Данный метод возвращает число десятичных знаков десятич­ ной степени за вычетом единицы. Например, decimals(10) — это 1, decimals(100) — это 2 и т. д. Метод decimals реализован в виде простой рекурсии: private def decimals(n: Long): Int = if (n == 1) 0 else 1 + decimals(n / 10) В листинге 20.12 показаны некоторые другие валютные зоны. В качестве еще одного уточнения к модели можно добавить свойство обмена валют. Сначала, как показано в листинге 20.13, можно создать объект Converter, содержащий приме­ няемые обменные курсы валют. Затем к классу Currency можно добавить метод обмена, from , который выполняет конвертацию из заданной исходной валюты в текущий объект Currency: def from(other: CurrencyZone#AbstractCurrency): Currency = make(math.round( other.amount.toDouble * Converter.exchangeRate (other.designation)(this.designation))) Листинг 20.12. Валютные зоны для Европы и Японии object Europe extends CurrencyZone { abstract class Euro extends AbstractCurrency { def designation = "EUR" } type Currency = Euro def make(cents: Long) = new Euro { val amount = cents } val Cent = make(1) val Euro = make(100) val CurrencyUnit = Euro } object Japan extends CurrencyZone { abstract class Yen extends AbstractCurrency { def designation = "JPY" } type Currency = Yen def make(yen: Long) = new Yen { val amount = yen } val Yen = make(1) val CurrencyUnit = Yen } 20.10. Практический пример: работа с валютой 405 Листинг 20.13. Объект converter с отображением курсов обмена object Converter { var exchangeRate = Map( "USD" -> Map("USD" -> "JPY" -> "EUR" -> Map("USD" -> "JPY" -> "JPY" -> Map("USD" -> "JPY" -> "CHF" -> Map("USD" -> "JPY" -> ) } 1.0 , "EUR" -> 0.7596, 1.211 , "CHF" -> 1.223), 1.316 , "EUR" -> 1.0 , 1.594 , "CHF" -> 1.623), 0.8257, "EUR" -> 0.6272, 1.0 , "CHF" -> 1.018), 0.8108, "EUR" -> 0.6160, 0.982 , "CHF" -> 1.0 ) Метод from получает в качестве аргумента произвольную валюту. Это выражено его формальным типом параметра CurrencyZone#AbstractCurrency, который по­ казывает, что переданный как other аргумент должен быть типа AbstractCurrency в некоторой произвольной и неизвестной валютной зоне CurrencyZone. Результат метода — перемножение суммы в другой валюте с курсом обмена между другой и текущей валютами1. Финальная версия класса CurrencyZone показана в листинге 20.14. Класс можно протестировать в командной оболочке Scala. Предполагается, что класс CurrencyZone и все конкретные объекты CurrencyZone определены в пакете org.stairwaybook.currencies. Сперва нужно импортировать в командную оболоч­ ку org.stairwaybook.currencies._. Затем можно будет выполнить ряд обменных операций с валютой: scala> Japan.Yen from US.Dollar * 100 res16: Japan.Currency = 12110 JPY scala> Europe.Euro from res16 res17: Europe.Currency = 75.95 EUR scala> US.Dollar from res17 res18: US.Currency = 99.95 USD Листинг 20.14. Полный код класса CurrencyZone abstract class CurrencyZone { type Currency <: AbstractCurrency def make(x: Long): Currency abstract class AbstractCurrency { val amount: Long def designation: String 1 Кстати, если вы полагаете, что сделка по японской йене будет неудачной, то курсы обмена валют основаны на числовых показателях в их CurrencyZone. Таким образом, 1.211 — курс обмена центов США на японскую йену. 406 Глава 20 • Абстрактные члены def + (that: Currency): Currency = make(this.amount + that.amount) def * (x: Double): Currency = make((this.amount * x).toLong) def - (that: Currency): Currency = make(this.amount - that.amount) def / (that: Double) = make((this.amount / that).toLong) def / (that: Currency) = this.amount.toDouble / that.amount def from(other: CurrencyZone#AbstractCurrency): Currency = make(math.round( other.amount.toDouble * Converter.exchangeRate (other.designation)(this.designation))) private def decimals(n: Long): Int = if (n == 1) 0 else 1 + decimals(n / 10) override def toString = ((amount.toDouble / CurrencyUnit.amount.toDouble) formatted ("%." + decimals(CurrencyUnit.amount) + "f") + " " + designation) } val CurrencyUnit: Currency } Из факта получения почти такого же значения после трех конвертаций следует, что у нас весьма выгодные курсы обмена! Кроме того, можно нарастить значение в некоторой валюте: scala> US.Dollar * 100 + res18 res19: US.Currency = 199.95 USD В то же время складывать суммы разных валют нельзя: scala> US.Dollar + Europe.Euro ^ error: type mismatch; found : Europe.Euro required: US.Currency (which expands to) US.Dollar Абстракция типов выполняет свою работу, не позволяя складывать два зна­ чения в разных единицах измерения (в данном случае валютах). Она мешает нам выполнять необоснованные вычисления. Неверные преобразования между раз­ личными единицами могут показаться тривиальными недочетами, но способны привести к весьма серьезным системным сбоям. Например, к аварии спутника Mars Climate Orbiter 23 сентября 1999 года, вызванной тем, что одна команда инженеров использовала метрическую систему мер, а другая — систему мер, при­ нятую в Великобритании. Если бы единицы измерения были запрограммированы Резюме 407 так же, как сделано с валютой в текущей главе, то данная ошибка была бы выявлена во время простого запуска кода на компиляцию. Вместо этого она стала причиной аварии космического аппарата после почти десятимесячного полета. Резюме В Scala предлагается рационально структурированная и самая общая поддержка объектно-ориентированной абстракции. При этом допускается применение не толь­ ко абстрактных методов, но и значений, переменных и типов. В данной главе мы показали способы извлечь преимущества из использования абстрактных членов класса. С их помощью реализуется простой, но весьма эффективный принцип структурирования систем: все неизвестное при разработке класса нужно пре­ вращать в абстрактные члены. Тогда система типов задаст направление развитию вашей модели точно так же, как вы увидели в примере с валютой. И неважно, что именно будет неизвестно: тип, метод, переменная или значение. Все это в Scala можно объявить абстрактным. 21 Неявные преобразования и параметры Между вашим кодом и библиотеками других разработчиков существует прин­ ципиальная разница: свой код при желании можно изменить или расширить, но если нужно воспользоваться чьими-либо библиотеками, то зачастую приходится принимать их такими, какие они есть. Чтобы облегчить решение этой проблемы, в языках программирования появился ряд конструкций. В Ruby есть модули, а Smalltalk позволяет пакетам добавлять в классы друг друга. Конечно, это очень эффективно, но и довольно опасно, поскольку дает возможность изменять пове­ дение класса для всего приложения, о тех или иных частях которого вы можете ничего не знать. В C# 3.0 имеются статически расширяемые методы, имеющие более локальную, но и более ограниченную природу, позволяющую добавить к классу только методы, но не поля и не позволяющую реализовывать в классе новые интерфейсы. В Scala в ответ на это были реализованы неявные преобразования и параметры. Они позволяют сделать существующие библиотеки гораздо более приятными для работы, давая возможность не указывать скучные, очевидные подробности, затмевающие интересные части вашего кода. При разумном использовании этого свойства получается код, сфокусированный на оригинальных, нетривиальных частях вашей программы. В этой главе мы покажем, как работают неявные пре­ образования и параметры, и рассмотрим некоторые наиболее распространенные способы их применения. 21.1. Неявные преобразования Прежде чем углубиться в подробности неявных преобразований, рассмотрим ти­ пичный пример их применения. Они зачастую полезны при работе с двумя боль­ шими частями программного средства, которые разрабатывались без учета друг друга. В каждой библиотеке имеются собственные способы программирования некоего замысла, являющиеся, по сути, одним и тем же. Неявные преобразования 21.1. Неявные преобразования 409 помогают сократить количество необходимых явных преобразований одного типа в другой. В целях реализации кросс-платформенных пользовательских интерфейсов в Java включена библиотека под названием Swing. Одна из выполняемых ею задач заключается в обработке событий от операционной системы, преобразовании их в независимые от используемой платформы объекты событий и передаче послед­ них тем частям приложения, которые называются слушателями событий (event listeners). Если бы Swing создавалась с прицелом на Scala, то слушатели событий, вероятно, были бы представлены функциональным типом. Тогда вызывающие объекты могли бы использовать синтаксис функционального литерала в каче­ стве облегченного способа, призванного указать то, что должно произойти для определенного класса событий. Поскольку в Java отсутствуют функциональные литералы, то в Swing применяется следующая наилучшая альтернатива — вну­ тренний класс, который реализует интерфейс, состоящий из одного метода. В случае со слушателями действий (action listeners) интерфейс называется ActionListener. Если программа на Scala, которая применяет Swing, не применяет неявное пре­ образование, то должна, подобно программе на Java, воспользоваться внутренними классами. Рассмотрим пример, создающий кнопку и «вешающий» на нее слушатель действия. Этот слушатель вызывается при нажатии кнопки, после чего выводится строка "pressed!": val button = new JButton button.addActionListener( new ActionListener { def actionPerformed(event: ActionEvent) = { println("pressed!") } } ) Представленный код очень невыразительный и неинформативный. То, что данный слушатель, — это ActionListener, метод обратного вызова называется actionPerformed и аргумент — это ActionEvent , подразумевается для любого аргумента addActionListener. Единственная новая информация здесь — выпол­ няемый код, а именно вызов функции println. Эта новая информация просто глушится шаблонным кодом. Читатели кода должны обладать пронзительным орлиным взглядом, чтобы пробиться сквозь шум и обнаружить информатив­ ную часть. Более подходящая для Scala версия получит в качестве аргумента функцию, существенно сокращая объем шаблонного кода: button.addActionListener( // Несоответствие типов! (_: ActionEvent) => println("pressed!") ) 410 Глава 21 • Неявные преобразования и параметры В данном виде этот код работать не будет1. Метод addActionListener требует слушателя действий, а получает функцию. Но используя неявное преобразование, этот код можно привести в рабочее состояние. Сначала нужно создать неявное преобразование между двумя типами. Это пре­ образование из функций в слушатели действий выглядит так: implicit def function2ActionListener(f: ActionEvent => Unit) = new ActionListener { def actionPerformed(event: ActionEvent) = f(event) } Это метод с одним аргументом, получающий функцию и возвращающий слуша­ тель действий. Как и любой другой метод с одним аргументом, его можно вызвать напрямую, а его результат — передать другому выражению: button.addActionListener( function2ActionListener( (_: ActionEvent) => println("pressed!") ) ) Этот фрагмент уже лучше версии с внутренним классом. Обратите внимание, как необоснованно большой объем шаблонного кода был сведен к замене функцио­ нальным литералом и вызову метода. Улучшить код удалось благодаря исполь­ зованию неявного преобразования. Поскольку метод function2ActionListener помечен ключевым словом implicit, то может быть опущен, и компилятор вставит его автоматически. В результате получится следующий код: // Теперь все работает button.addActionListener( (_: ActionEvent) => println("pressed!") ) Этот код работает благодаря тому, что компилятор сначала пробует скомпи­ лировать все как есть, но видит ошибку типа. Прежде чем окончательно сдать­ ся, он ищет неявное преобразование, которое смогло бы исправить ситуацию. В данном случае находит function2ActionListener, пробует применить данный метод преобразования, видит, что тот работает, и движется дальше. Здесь ком­ пилятор старается сделать так, чтобы разработчик мог проигнорировать еще одну волокитную деталь. Что попробовать: слушатель действий или функцию события? Либо одно, либо другое сработает, и компилятор использует то, что подходит больше. В данном разделе мы продемонстрировали силу неявных преобразований и то, как они позволяют вам приукрасить существующие библиотеки. В следующих раз­ делах мы рассмотрим правила, которые определяют, когда выполняются неявные преобразования и как они будут найдены. 1 В разделе 31.5 мы объясним, что этот код будет работать в Scala 2.12. 21.2. Правила для неявных преобразований 411 21.2. Правила для неявных преобразований К неявным определениям относятся те, которые компилятору разрешено встав­ лять в программу для устранения любой из ее ошибок типов. Например, если x + y не проходит проверку типов, то компилятор может изменить этот код, чтобы полу­ чилось convert(x) + y, где под convert понимается некое доступное неявное преоб­ разование. Если convert переделывает x в нечто имеющее метод +, то это изменение может исправить программу, чтобы она прошла проверку типов и выполнялась корректно. Если же convert — всего лишь простая функция преобразования, то избавление от нее исходного кода может сделать его более понятным. Неявные преобразования регулируются общими правилами, представленными ниже. Правило маркировки: доступны только определения с пометкой implicit. Ключевое слово implicit используется в целях маркировки того объявления, которое компилятор может применять в качестве неявного. Им можно помечать любое объявление переменной, функции или объекта. Пример объявления не­ явной функции выглядит следующим образом1: implicit def intToString(x: Int) = x.toString Если convert имеет пометку implicit, то компилятор всего лишь превратит x + y в convert(x) + y. Тем самым устраняется путаница, которая может возникнуть при выборе компилятором случайных функций, оказавшихся в области видимости, и вставке их в качестве преобразований. Свой выбор компилятор будет делать, исходя только лишь из определений, имеющих явную пометку implicit. Правило области видимости: вставляемое неявное преобразование должно находиться в области видимости в качестве простого идентификатора или быть связанным с исходным или целевым типом преобразования. Компи­ лятор Scala будет рассматривать только те неявные преобразования, которые находятся в области видимости. Поэтому, чтобы обеспечить доступность неяв­ ного преобразования, нужно неким образом поместить его в область видимости. Более того, за единственным исключением, неявное преобразование должно находиться в области видимости в качестве простого идентификатора. Ком­ пилятор не станет вставлять преобразование в форме someVariable.convert. Например, он не станет расширять x + y в someVariable.convert(x) + y. Если нужно сделать идентификатор someVariable.convert доступным в качестве неявного преобразования, то его нужно будет импортировать, что сделает его доступным в качестве простого идентификатора. После импортирования 1 В качестве неявных параметров могут использоваться переменные и объекты-одиноч­ ки с пометкой implicit. Этот вариант использования мы рассмотрим в данной главе чуть позже. 412 Глава 21 • Неявные преобразования и параметры компилятор сможет свободно применить его как convert(x) + y. Фактически в библиотеки зачастую включают объект Preamble, содержащий ряд полезных неявных преобразований. После этого в коде, который задействует библиотеку, можно будет для обращения к имеющимся в библиотеке неявным преобразова­ ниям однократно применить выражение import Preamble._. В правиле простого идентификатора есть одно исключение. Компилятор будет искать неявные определения в объекте-компаньоне исходного или ожидаемого целевого типа преобразования. Например, при попытке передать объект типа Dollar методу, получающему Euro, исходным типом является Dollar, а целевым типом — Euro. Поэтому можно запаковать неявное преобразование из Dollar в Euro в объект-компаньон любого класса, Dollar или Euro. Пример, в котором неявное преобразование помещено в объект-компаньон класса Dollar, выглядит следующим образом: object Dollar { implicit def dollarToEuro(x: Dollar): Euro = ... } class Dollar { ... } В данном случае преобразование dollarToEuro считается ассоциированным с ти­ пом Dollar. Компилятор найдет такое ассоциированное преобразование всякий раз, когда возникает необходимость выполнить преобразование из экземпляра типа Dollar. Отдельно импортировать преобразование в вашу программу нет никакой необходимости. Правило области видимости помогает рассуждать модульно. Когда вы читаете код файла, все, что вам нужно для понимания других файлов, либо должно быть импортировано, либо на это должна быть явная ссылка с полным указанием имени. Это, по крайней мере, также важно для неявных преобразований, как для явно написанного кода. Если бы неявные преобразования распространялись на всю систему, то, чтобы понять код файла, вам нужно было бы знать о каждом неявном преобразовании, используемом в программе! Правило «по одному»: может быть вставлено только одно неявное преобразование. Компилятор никогда не станет преобразовывать x + y в convert1(con­ vert2(x)) + y. Подобные действия приведут к резкому увеличению времени компиляции ошибочного кода и увеличат разницу между тем, что пишет про­ граммист, и тем, что фактически делает программа. Чтобы сохранить здравый смысл, компилятор не вставляет дополнительные неявные преобразования, будучи уже на полпути к применению другого неявного преобразования. Но это ограничение можно обойти за счет неявных параметров неявного преобразова­ ния, речь о которых пойдет чуть позже. Правило «сначала явное»: когда код в том виде, в котором он записан, про- ходит проверку типов, не делаются попытки применения неявных преобразований. Компилятор не станет изменять уже работающий код. Следствие данного правила — возможность заменить неявное преобразование явно указанным. 21.2. Правила для неявных преобразований 413 Это удлиняет код, но устраняет его очевидную двусмысленность. В каждом отдельно взятом случае можно менять один вариант на другой. Если код стано­ вится повторяющимся и многословным, то от однообразия поможет избавиться неявное преобразование. Если же краткость кода делает его непонятным, то преобразования можно указать явно. Количество неявных преобразований, которые вы оставляете компилятору, — вопрос стиля. Названия неявных преобразований Неявные преобразования могут носить произвольные имена. Имя неявного пре­ образования играет роль только в двух ситуациях: если вы хотите указать его явно в применении метода и если нужно определить, какие неявные преобразования доступны в том или ином месте программы. Чтобы проиллюстрировать вторую ситуацию, предположим, что есть объект с двумя неявными преобразованиями: object MyConversions { implicit def stringWrapper(s: String): IndexedSeq[Char] = ... implicit def intToString(x: Int): String = ... } В создаваемом приложении вам следует воспользоваться преобразованием stringWrapper и совсем не нужно, чтобы целочисленные значения автоматически конвертировались в строки с помощью преобразования intToString. Этого можно добиться импортированием только одного преобразования из двух: import MyConversions.stringWrapper ... // код воспользуется stringWrapper В данном примере было важно, чтобы у неявных преобразований были имена, поскольку это единственный способ выполнить импортирование выборочно, при­ менив только одно из двух преобразований. Где применяются неявные преобразования Неявные преобразования применяются в языке в трех местах: в преобразованиях в ожидаемый тип, в преобразованиях получателя при выборе и в неявных параме­ трах. Неявные преобразования в ожидаемый тип позволяют использовать один тип в том контексте, в котором ожидается другой тип. Например, можно располагать значением типа String и иметь желание передать его методу, требующему значе­ ния типа IndexedSeq[Char]. Преобразования получателя позволяют адаптировать получатель вызова метода (то есть объект, в отношении которого метод был вы­ зван), если метод непригоден для исходного типа. Примером может послужить выражение "abc".exists, которое в результате преобразования приобретает вид stringWrapper("abc").exists, поскольку метод exists недоступен в классе Strings, 414 Глава 21 • Неявные преобразования и параметры но доступен в классе IndexedSeqs. Что же касается неявных параметров, то они обычно служат в целях предоставления вызываемой функции более подробной информации о том, что именно нужно вызывающему объекту. Неявные параметры особенно хорошо подходят для использования с обобщенными функциями, где вызываемая функция порой вообще ничего не знает о типе одного или несколь­ ких аргументов. Все эти разновидности неявных преобразований мы рассмотрим в следующих разделах. 21.3. Неявное преобразование в ожидаемый тип Неявное преобразование в ожидаемый тип — первое место, в котором компилятор будет использовать неявные преобразования. Правило простое. Там, где компиля­ тор видит X, а нужен Y, он станет искать неявную функцию, выполняющую преоб­ разование X в Y. Например, обычно значение типа Double не может применяться в качестве целочисленного значения, поскольку будет потеряна точность: scala> val i: Int = 3.5 <console>:7: error: type mismatch; found : Double(3.5) required: Int val i: Int = 3.5 ^ Но чтобы сгладить ситуацию, можно определить неявное преобразование: scala> implicit def doubleToInt(x: Double) = x.toInt doubleToInt: (x: Double)Int scala> val i: Int = 3.5 i: Int = 3 Компилятор видит значение типа Double, а именно 3.5, в контексте, требующем Int. Прежде компилятор усмотрел бы в этом обычную ошибку несоответствия типов. Но теперь он не сдается сразу, а ищет средство реализовать неявное преоб­ разование Double в Int. В данном случае он находит такое средство doubleToInt, поскольку оно находится в области видимости в качестве простого идентифи­ катора. (Вне интерпретатора doubleToInt можно ввести в область видимости с помощью импортирования или, возможно, наследования.) Затем компилятор автоматически вставляет вызов doubleToInt. Скрытым образом код приобретает следующий вид: val i: Int = doubleToInt(3.5) Это действительно неявное преобразование. Оно не запрашивается в явном виде. Вместо этого doubleToInt помечается в качестве доступного неявного преоб­ разования с помощью ввода его в область видимости в качестве простого иденти­ фикатора, а затем компилятор автоматически использует его при необходимости конвертировать из Double в Int. 21.4. Преобразование получателя 415 Преобразование Double -значений в Int -значения может вызвать некоторое недоумение, поскольку сомнительна сама идея наличия чего-то, что скрыто вызы­ вает потерю точности. То есть на самом-то деле мы не рекомендуем использовать подобное преобразование. Куда больше смысла можно усмотреть в обратном — в переходе от более ограниченного типа к более общему. Например, Int-значение можно преобразовать без потери точности в Double-значение, следовательно, в пре­ образовании Int в Double есть смысл. Фактически именно так и происходит. В объ­ екте scala.Predef, который неявно импортируется в каждую программу на Scala, определено неявное преобразование, выполняющее конвертацию более «мелкого» типа в более «крупный». Например, в Predef можно найти такое преобразование: implicit def int2double(x: Int): Double = x.toDouble Вот почему в Scala Int-значения могут сохраняться в переменных типа Double. В системе типов специальных правил для этого нет, это просто неявное преоб­ разование1. 21.4. Преобразование получателя Неявное преобразование применяется также к получателю вызова метода — объ­ екту, в отношении которого вызывается метод. Этот вид неявного преобразова­ ния используется в двух основных вариантах. Первый заключается в том, что преобразования получателя позволяют более гладко интегрировать новый класс в существующую иерархию классов. А второй состоит в поддержке возможности написания внутри базового языка предметно-ориентированных языков (domainspecific languages, DSL). Чтобы посмотреть все это в действии, предположим, что вы записали код obj.doIt, а у obj нет элемента по имени doIt. Прежде чем сдаться, компилятор попытается вставить преобразования. В данном случае преобразование следует применить к получателю, obj. Компилятор будет действовать так, словно ожида­ емый тип obj включает в себя свойство «содержит член по имени doIt». Данный тип «содержит doIt» не есть обычный тип Scala, но концептуально является типом, и поэтому в данном случае компилятор вставит неявное преобразование. Взаимодействие с новыми типами Как уже упоминалось, один из основных вариантов применения преобразова­ ния получателя заключается в разрешении более гладкой интеграции новых типов с уже существующими. В частности, подобные преобразования позволяют разрешить создателям клиентских программ воспользоваться экземплярами 1 Но внутренний код компилятора Scala станет рассматривать преобразование особым образом, переводя его в специальный байт-код i2d. Следовательно, скомпилированный образ будет таким же, как и в Java. 416 Глава 21 • Неявные преобразования и параметры существующих типов, словно те являются экземплярами ваших новых типов. Возь­ мем, к примеру, класс Rational, показанный в листинге 6.5 (см. выше). Рассмотрим еще раз фрагмент этого класса: class Rational(n: Int, d: Int) { ... def + (that: Rational): Rational = ... def + (that: Int): Rational = ... } В классе Rational имеются два перегруженных варианта метода +, получающих в качестве аргументов соответственно Rational- и Int-значения. Следовательно, можно сложить либо два рациональных числа, либо рациональное число с целым: scala> val oneHalf = new Rational(1, 2) oneHalf: Rational = 1/2 scala> oneHalf + oneHalf res0: Rational = 1/1 scala> oneHalf + 1 res1: Rational = 3/2 А как будут обстоять дела с выражением наподобие 1 + oneHalf? Это доволь­ но каверзное выражение, поскольку у получателя, 1, нет подходящего метода +. Следовательно, при выполнении следующего кода возникнет ошибка: scala> 1 + oneHalf <console>:6: error: overloaded method value + with alternatives (Double)Double <and> ... cannot be applied to (Rational) 1 + oneHalf ^ Чтобы разрешить применение подобной смешанной арифметики, нужно опре­ делить неявное преобразование из Int в Rational: scala> implicit def intToRational(x: Int) = new Rational(x, 1) intToRational: (x: Int)Rational При использовании этого преобразования получатель проделает следующий трюк: scala> 1 + oneHalf res2: Rational = 3/2 Здесь компилятор Scala скрыто сначала пытается проверить соответствие типов в отношении выражения 1 + oneHalf в его неизменном виде. Проверка проходит неудачно, поскольку в Int есть несколько методов +, однако ни один из них не при­ нимает аргумент типа Rational. Затем компилятор ищет неявное преобразование из Int в другой тип, у которого есть метод +, применимый к значениям типа Rational. 21.4. Преобразование получателя 417 Он находит ваше преобразование и применяет его, в результате чего получается следующее: intToRational(1) + oneHalf В данном случае компилятор находит функцию неявного преобразования, по­ скольку ее определение было введено в интерпретатор, который поместил это опре­ деление в область видимости для оставшегося сеанса работы с интерпретатором. Имитация нового синтаксиса Другой основной вариант использования неявных преобразований — имитация до­ бавления нового синтаксиса. Вспомним, что отображение типа Map можно создать с помощью следующего синтаксиса: Map(1 -> "one", 2 -> "two", 3 -> "three") А вас не интересовало, как поддерживается ->? Это ведь не синтаксис! Элемент вида -> — метод класса ArrowAssoc, который определен внутри стандартной пре­ амбулы Scala (scala.Predef). В ней также определяется неявное преобразование из Any в ArrowAssoc. При использовании кода 1 -> "one" компилятор вставляет преоб­ разование из 1 в ArrowAssoc, чтобы можно было найти метод ->. Соответствующие определения выглядят так: package scala object Predef { class ArrowAssoc[A](x: A) { def -> [B](y: B): Tuple2[A, B] = Tuple2(x, y) } implicit def any2ArrowAssoc[A](x: A): ArrowAssoc[A] = new ArrowAssoc(x) ... } Такая схема обогащающих оболочек часто встречается в библиотеках, которые предоставляют языку расширения, похожие на синтаксис. Поэтому, как только встретится эта схема, нужно быть готовыми ее распознать. Момент обнаружения того, что некий объект вызывает методы, не существующие в классе-получателе, означает, скорее всего, использование неявных преобразований. Аналогично если встретится класс, имя которого означает обогащение чего-либо — RichSomething (например, RichInt или RichBoolean), то в нем, вероятно, к типу Something добав­ ляются методы, похожие на синтаксис. Здесь эти схемы обогащающих оболочек для базовых типов, рассмотренных в главе 5, уже встречались вам. Теперь можно увидеть, что обогащающие оболочки применяются более широко, зачастую позволяя использовать внутренние DSLязыки, определяемые в виде библиотеки, в то время как программистам на других языках может понадобиться разработка внешних DSL-языков. 418 Глава 21 • Неявные преобразования и параметры Неявные классы Неявные классы были добавлены в Scala 2.10 с целью облегчить создание обо­ гащающих классов-оболочек. Неявным называется класс, перед определением которого ставится ключевое слово implicit. Для любого такого класса компилятор создает неявное преобразование из параметра конструктора класса в сам класс. Если планируется использовать класс для паттерна обогащающей оболочки, то такое преобразование — именно то, что нужно. Предположим, к примеру, что имеется класс по имени Rectangle для представ­ ления ширины и высоты прямоугольника на экране: case class Rectangle(width: Int, height: Int) Может возникнуть желание прибегнуть к паттерну обогащающей оболочки, чтобы упростить представление при весьма частом использовании этого класса. Один из способов решения данной задачи выглядит так: implicit class RectangleMaker(width: Int) { def x(height: Int) = Rectangle(width, height) } В показанном ранее коде в необычной манере определяется класс RectangleMa­ ker. Вдобавок ко всему он вызывает автоматическое создание следующего преоб­ разования: // Создается автоматически implicit def RectangleMaker(width: Int) = new RectangleMaker(width) В результате можно создавать точки, помещая x между двумя целочисленными значениями: scala> val myRectangle = 3 x 4 myRectangle: Rectangle = Rectangle(3,4) А вот как это работает: поскольку в типе Int нет метода по имени x, то компиля­ тор станет искать неявное преобразование из Int в нечто имеющее данный метод. Он найдет созданное преобразование RectangleMaker, а в RectangleMaker имеется метод по имени x. Компилятор вставляет вызов этого преобразования, затем вы­ зывает в отношении x проверку соответствия типов и делает то, что нужно. Авантюристам следует иметь в виду: не стоит обольщаться насчет того, что указать ключевое слово implicit можно перед определением любого класса. Это не так. В качестве неявного нельзя использовать case-класс, и его конструк­ тор может иметь только один параметр. Кроме того, неявный класс должен раз­ мещаться внутри какого-либо другого объекта — класса или трейта. На практике, поскольку неявные классы применяются в качестве обогащающих оболочек для добавления новых методов в существующий класс, эти ограничения не име­ ют значения. 21.5. Неявные параметры 419 21.5. Неявные параметры Осталось еще одно место, куда компилятор помещает неявные преобразования, — это список аргументов. Компилятор иногда заменяет код вида someCall(a) кодом вида someCall(a)(b) или код SomeClass(a) — кодом new SomeClass(a)(b), добавляя тем самым пропущенный список параметров, чтобы завершить вызов функции. Предоставляется не просто последний параметр, а весь последний каррированный список параметров. Например, если someCall с пропущенным списком параме­ тров получает три параметра, то компилятор может вместо someCall(a) вста­ вить someCall(a)(b, c, d). Чтобы воспользоваться этой возможностью, пометку implicit при своем определении должны иметь не только вставляемые идентифи­ каторы, такие как b, c и d в (b, c, d), но и последний список параметров в someCall или определение someClass. Рассмотрим простой пример. Предположим, имеется класс PreferredPrompt, в котором инкапсулирована строка приглашения к вводу, используемая в оболочке (такая как, скажем, "$ " или "> "), предпочитаемая пользователем: class PreferredPrompt(val preference: String) Предположим вдобавок, что имеется объект Greeter с методом greet , при­ нимающим два списка параметров. Первый список параметров получает строку с именем пользователя, а второй — PreferredPrompt: object Greeter { def greet(name: String)(implicit prompt: PreferredPrompt) = { println("Welcome, " + name + ". The system is ready.") println(prompt.preference) } } Последний список параметров имеет метку implicit, означающую, что он мо­ жет предоставляться в неявном режиме. Но при этом по-прежнему сохраняется возможность явно указать символ приглашения к вводу с помощью такого кода: scala> val bobsPrompt = new PreferredPrompt("relax> ") bobsPrompt: PreferredPrompt = PreferredPrompt@714d36d6 scala> Greeter.greet("Bob")(bobsPrompt) Welcome, Bob. The system is ready. relax> Чтобы позволить компилятору предоставить параметр в неявном режиме, сна­ чала нужно определить переменную ожидаемого типа, которым в данном случае будет PreferredPrompt. Сделать это можно, к примеру, в объекте предпочтений: object JoesPrefs { implicit val prompt = new PreferredPrompt("Yes, master> ") } 420 Глава 21 • Неявные преобразования и параметры Заметьте, что у самой val-переменной есть метка implicit. Не будь ее, компи­ лятор не стал бы использовать эту переменную для предоставления пропущенного списка параметров. Он также не стал бы ее задействовать, если бы она, как показано в следующем примере, отсутствовала в области видимости в качестве простого идентификатора: scala> Greeter.greet("Joe") <console>:13: error: could not find implicit value for parameter prompt: PreferredPrompt Greeter.greet("Joe") ^ Но после ее введения в область видимости с помощью ключевого слова import компилятор воспользуется ею, чтобы предоставить пропущенный список параметров: scala> import JoesPrefs._ import JoesPrefs._ scala> Greeter.greet("Joe") Welcome, Joe. The system is ready. Yes, master> Обратите внимание на то, что ключевое слово implicit применяется ко всему списку параметров, а не к отдельным параметрам. В листинге 21.1 показан при­ мер, в котором последний параметр списка метода greet, принадлежащего объекту Greeter, к тому же имеющему метку implicit, содержит два параметра: prompt типа PreferredPrompt и drink типа PreferredDrink. Листинг 21.1. Неявный список параметров с несколькими параметрами class PreferredPrompt(val preference: String) class PreferredDrink(val preference: String) object Greeter { def greet(name: String)(implicit prompt: PreferredPrompt, drink: PreferredDrink) = { println("Welcome, " + name + ". The system is ready.") print("But while you work, ") println("why not enjoy a cup of " + drink.preference + "?") println(prompt.preference) } } object JoesPrefs { implicit val prompt = new PreferredPrompt("Yes, master> ") implicit val drink = new PreferredDrink("tea") } В объекте-одиночке JoesPrefs объявляются две неявные val -переменные: prompt типа PreferredPrompt и drink , типа PreferredDrink. Но, как и прежде, 21.5. Неявные параметры 421 поскольку их нет в области видимости в качестве простых идентификаторов, то они не будут использоваться для заполнения пропущенного списка параметров для greet: scala> Greeter.greet("Joe") <console>:19: error: could not find implicit value for parameter prompt: PreferredPrompt Greeter.greet("Joe") ^ Ввести обе неявные val-переменные в область видимости можно, воспользо­ вавшись ключевым словом import: scala> import JoesPrefs._ import JoesPrefs._ Теперь и prompt , и drink находятся в области видимости в форме простых идентификаторов, поэтому их можно использовать для предоставления в качестве неявного списка параметров: scala> Greeter.greet("Joe")(prompt, drink) Welcome, Joe. The system is ready. But while you work, why not enjoy a cup of tea? Yes, master> А поскольку теперь соблюдены все правила использования неявных параме­ тров, то можно задействовать альтернативный вариант и позволить компилятору Scala предоставить вам prompt и drink, пропустив для этого указание последнего списка параметров: scala> Greeter.greet("Joe") Welcome, Joe. The system is ready. But while you work, why not enjoy a cup of tea? Yes, master> По поводу предыдущего примера следует заметить: в нем String в качестве типа переменной prompt или переменной drink не используется, даже притом, что в конечном итоге каждая из них через свои поля preference предоставляет значение типа String. Поскольку компилятор выбирает параметры путем поиска совпадения типов параметров с типом значений в области видимости, то неявные параметры имеют редкие или специальные типы, для которых случайные совпадения мало­ вероятны. Например, типы PreferredPrompt и PreferredDrink в представленном выше листинге 21.1 были определены исключительно для того, чтобы послужить в качестве типов неявных параметров. Поэтому вряд ли неявные переменные данных типов находились бы в области видимости, если бы не предполагалось их использование в качестве неявных параметров для Greeter.greet. У неявных параметров есть еще один нюанс: они, пожалуй, наиболее часто при­ меняются для предоставления информации о типе, упомянутом явным образом в предшествующем списке параметров, как и классы типа языка Haskell. 422 Глава 21 • Неявные преобразования и параметры Рассмотрим в качестве примера функцию maxListOrdering, показанную в ли­ стинге 21.2, которая возвращает максимальный элемент из переданного списка. Листинг 21.2. Функция с верхним ограничителем def maxListOrdering[T](elements: List[T]) (ordering: Ordering[T]): T = elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxListOrdering(rest)(ordering) if (ordering.gt(x, maxRest)) x else maxRest } Сигнатура maxListOrdering похожа на сигнатуру orderedMergeSort, показан­ ную в листинге 19.12 (см. выше): в качестве своих аргументов функция получает List[T] и теперь получает дополнительный аргумент типа Ordering[T] . Этот дополнительный аргумент указывает, какой порядок следует задействовать при сравнении элементов типа T. Таким образом, данная версия может применяться для типов, не имеющих встроенного механизма выстраивания по порядку. Кроме того, она может использоваться для типов, имеющих встроенное упорядочение, но время от времени требующих некоего другого варианта упорядочения. Это более универсальная, но и более громоздкая в использовании версия. Теперь вызывающий объект должен указать явное упорядочение, даже если в ка­ честве T выступает нечто относящееся к типам String или Int, явно имеющим исходные механизмы упорядочения. Чтобы сделать новый метод более удобным в применении, неплохо было бы превратить второй аргумент в неявный. Данный подход показан в листинге 21.3. Листинг 21.3. Функция с неявным параметром def maxListImpParm[T](elements: List[T]) (implicit ordering: Ordering[T]): T = elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxListImpParm(rest)(ordering) if (ordering.gt(x, maxRest)) x else maxRest } В данном примере параметр ordering служит для описания упорядочения зна­ чений типа T. В теле maxListImpParm это упорядочение используется в двух местах: в рекурсивном вызове maxListImpParm и в выражении if, которое проверяет, боль­ ше ли элемент head списка, чем максимальный элемент всего остального списка. 21.5. Неявные параметры 423 Функция maxListImpParm — пример использования неявного параметра в целях предоставления дополнительной информации о типе, упомянутом в явном виде в предшествующем списке параметров. Если говорить точнее, то подразумевае­ мый параметр ordering, относящийся к типу Ordering[T], предоставляет больше информации о типе T — в данном случае о том, как следует выстраивать по порядку значения, относящиеся к данному типу. Он упомянут в List[T] — типе элементов параметра, который появляется в предшествующем списке параметров. Элементы при любом вызове maxListImpParm всегда должны предоставляться в явном виде, поэтому компилятор к моменту компиляции будет знать, что такое T, и сможет определить, доступно ли неявное определение типа Ordering[T]. Если да, то ком­ пилятор сможет передать второй список параметров, ordering, воспользовавшись механизмом предоставления неявных параметров. Данная схема получила настолько широкое распространение, что стандартная библиотека Scala предоставляет неявные методы упорядочения для множества самых востребованных типов. Благодаря этому метод maxListImpParm можно ис­ пользовать при работе с различными типами: scala> maxListImpParm(List(1,5,10,3)) res9: Int = 10 scala> maxListImpParm(List(1.5, 5.2, 10.7, 3.14159)) res10: Double = 10.7 scala> maxListImpParm(List("one", "two", "three")) res11: String = two В первом случае компилятор вставит упорядочение для Int-значений, во вто­ ром — для Double-значений, а в третьем — для String-значений. Правило стиля для неявных параметров Согласно правилу стиля для типов неявных параметров лучше воспользовать­ ся типом, названным особым образом. Например, переменные prompt и drink в предыдущем примере относились не к типу String, а к типам PreferredPrompt и PreferredDrink соответственно. В качестве контрпримера рассмотрим возмож­ ность того, что функцию maxListImpParm можно записать с помощью такой сигна­ туры типа: def maxListPoorStyle[T](elements: List[T]) (implicit orderer: (T, T) => Boolean): T Но чтобы воспользоваться данной версией функции, вызывающему объекту придется предоставить параметр orderer , который относится к типу (T, T) => Boolean. Это довольно общий тип, включающий любую функцию, выдающую на основе двух значений типа T результат типа Boolean. Из него абсолютно неясно предназначение данного типа — это может быть проверка на равенство, на то, что одно значение больше или меньше другого, или же нечто иное. 424 Глава 21 • Неявные преобразования и параметры В настоящем коде для maxListImpParm , который приведен в листинге 21.3 (см. выше), показан более удачный стиль. В нем используется параметр ordering, относящийся к типу Ordering[T] . Слово Ordering в наименовании типа явно указывает на цель применения неявного параметра: он предназначен для упо­ рядочения элементов типа T. Поскольку этот тип упорядочения указан явно, то добавление в стандартную библиотеку неявных предоставителей этого типа не вы­ зовет никаких проблем. Но представьте хаос, который возникнет при добавлении в стандартную библиотеку неявного преобразования типа (T, T) => Boolean, когда компилятор начнет разбрасывать его в чьем-то коде. В результате будет получен код, который пройдет компиляцию и сможет запускаться на выполнение, но про­ верки в отношении пар элементов станут выполняться беспорядочно! Стало быть, нужно придерживаться следующего правила стиля: использовать в типе неявного параметра хотя бы одно имя, определяющее его роль. 21.6. Контекстные ограничители В предыдущем примере возможность применения неявного преобразования была показана, но не реализована. Следует заметить, что при использовании неявного преобразования в качестве параметра компилятор не только попытается предоставить этот параметр с неявным значением, но и воспользуется им в качестве доступного неявного элемента в теле метода (листинг 21.4)! Таким образом, первое использование ordering внутри тела метода можно опустить. Листинг 21.4. Функция, использующая неявный параметр внутри себя def maxList[T](elements: List[T]) (implicit ordering: Ordering[T]): T = elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxList(rest) // Здесь (ordering) подразумевается if (ordering.gt(x, maxRest)) x // А этот ordering по-прежнему else maxRest // указан явно } Начав анализировать код, показанный в листинге 21.4, компилятор увидит, что типы не совпадают. Выражению maxList(rest) предоставлен только один список параметров, а maxList требует два списка. Второй список параметров является неявным, и потому компилятор не спешит сдаваться на проверке соответствия типов. Вместо этого он ищет неявный параметр подходящего типа, в данном случае Ordering[T]. Что касается нашего кода, то компилятор находит такой параметр и перезаписывает вызов в maxList(rest)(ordering), после чего код успешно про­ ходит проверку соответствия типов. 21.6. Контекстные ограничители 425 Существует также способ исключить второе использование ordering. Для него применяется следующий метод, определение которого находится в стандартной библиотеке: def implicitly[T](implicit t: T) = t Эффект от вызова implicitly[Foo] заключается в том, что компилятор станет искать неявное определение типа Foo. Затем он вызывает в отношении этого объ­ екта неявный метод, который, в свою очередь, возвращает объект обратно. Таким образом, можно воспользоваться кодом implicitly[Foo] везде, где нужно найти неявный объект типа Foo в текущей области видимости. Например, в листинге 21.5 показано применение implicitly[Ordering[T]] в целях извлечения параметра ordering по его типу. Листинг 21.5. Функция, используемая неявно def maxList[T](elements: List[T]) (implicit ordering: Ordering[T]): T = elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxList(rest) if (implicitly[Ordering[T]].gt(x, maxRest)) x else maxRest } Присмотритесь к последней версии maxList повнимательнее. В ней в тексте метода нет ни одного упоминания о параметре ordering. Второй параметр можно также назвать comparator. def maxList[T](elements: List[T]) (implicit comparator: Ordering[T]): T = // то же самое тело... По той же причине работает и эта версия: def maxList[T](elements: List[T]) (implicit iceCream: Ordering[T]): T = // то же самое тело... Поскольку данная схема получила широкое распространение, в Scala допуска­ ется не указывать имя этого параметра и сократить заголовок метода, используя контекстные ограничители. В этом случае можно будет записывать сигнатуру maxList, как показано в листинге 21.6. Синтаксис [T : Ordering] является кон­ текстным ограничителем с двойным назначением. Во-первых, он вводит в каче­ стве обычного параметр типа T. Во-вторых, добавляет неявный параметр типа Ordering[T]. В предыдущих версиях maxList этот параметр назывался ordering, но применение контекстного ограничителя не позволяет узнать, какой имен­ но параметр будет вызываться. Как было показано ранее, зачастую знать это и не нужно. 426 Глава 21 • Неявные преобразования и параметры Листинг 21.6. Функция с контекстным ограничителем def maxList[T : Ordering](elements: List[T]): T = elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxList(rest) if (implicitly[Ordering[T]].gt(x, maxRest)) x else maxRest } На интуитивном уровне контекстный ограничитель можно воспринимать как высказывание о параметре типа. В момент записи [T <: Ordered[T]] говорится, что T — тип Ordered[T]. В отличие от этого при записи [T : Ordering] о том, что такое T, столь подробно не сообщается — говорится о наличии некой формы упоря­ дочения, связанной с T. Таким образом, контекстный ограничитель обладает доста­ точной гибкостью. Он позволяет использовать код, требующий упорядочения, или любое другое свойство типа, не прибегая к необходимости изменять определение данного типа. 21.7. Когда применяются множественные преобразования Может случиться так, что в области видимости будут находиться сразу несколь­ ко неявных преобразований, каждое из которых должно работать. В большинстве случаев Scala в подобной ситуации отказывается вставлять преобразование. Неявные преобразования хорошо работают, когда преобразование остается аб­ солютно очевидным и схематически чистым. Если применяется сразу несколько преобразований, то выбор не столь очевиден. Рассмотрим простой пример. В нем есть метод, получающий последователь­ ность, и два преобразования: одно превращает целочисленное значение в диапазон, второе превращает целочисленное значение в список цифр: scala> def printLength(seq: Seq[Int]) = println(seq.length) printLength: (seq: Seq[Int])Unit scala> implicit def intToRange(i: Int) = 1 to i intToRange: (i: Int)scala.collection.immutable.Range.Inclusive scala> implicit def intToDigits(i: Int) = i.toString.toList.map(_.toInt) intToDigits: (i: Int)List[Int] scala> printLength(12) 21.7. Когда применяются множественные преобразования 427 <console>:26: error: type mismatch; found : Int(12) required: Seq[Int] Note that implicit conversions are not applicable because they are ambiguous: both method intToRange of type (i: Int)scala.collection.immutable.Range.Inclusive and method intToDigits of type (i: Int)List[Int] are possible conversion functions from Int(12) to Seq[Int] printLength(12) ^ Здесь, несомненно, есть неоднозначность. Преобразование целочисленного значения в последовательность цифр коренным образом отличается от его пре­ образования в диапазон. В данном случае программист должен указать, какому из преобразований предназначено стать неявным. Вплоть до выхода Scala 2.7 на этом вся история и заканчивалась. В случае применения сразу нескольких неявных преобразований компилятор отказывался делать выбор между ними. Возникала такая же ситуация, как и с перегрузкой методов. При попытке вызова foo(null) и наличии двух различных перегруженных методов foo, допускающих null, ком­ пилятор отвергал код. Он говорил, что цель вызова метода неоднозначна. В Scala 2.8 это правило было смягчено. Если одно из доступных преобразова­ ний конкретнее других, то компилятор выберет его. Замысел таков: при наличии достаточных оснований полагать, что программист всегда выбрал бы только одно из имеющихся преобразований, ему не требуется записывать это преобразование в явном виде. Ведь у правила перегрузки методов имеется точно такое же посла­ бление. Можем продолжить рассмотрение предыдущего примера. При условии, что один из доступных методов foo получает значение типа String, а другие получают значение типа Any, выбор будет за версией, получающей String. Вполне очевидно, что этот вариант более конкретен. Уточним сказанное. Одно неявное преобразование будет конкретнее другого, если к нему применимо одно из следующих утверждений: тип аргумента первого преобразования — подтип второго; оба преобразования являются методами, и класс, в который входит первое, рас­ ширяет класс, в который входит второе. Мотивом для возвращения к этому вопросу и пересмотра правила было улуч­ шение взаимодействия между коллекциями Java, коллекциями Scala и строками. Рассмотрим простой пример: val cba = "abc".reverse Какой тип будет выведен для cba ? Интуитивно понятно, что тип должен быть String. Реверсирование строки должно порождать другую строку, верно? Но в Scala 2.7 получалось так, что строка "abc" превращалась в коллекцию Scala. Реверсирование коллекции Scala порождало коллекцию Scala, следовательно, типом cba будет коллекция. Вдобавок там происходило неявное преобразование 428 Глава 21 • Неявные преобразования и параметры обратно в строку, но это не решало все проблемы. К примеру, в версии, предшеству­ ющей Scala 2.8, выражение "abc" == "abc".reverse.reverse вычислялось в false! При использовании Scala 2.8 типом cba является String. Старое неявное пре­ образование в коллекцию Scala (которое теперь называется WrappedString) со­ храняется, но предоставляет более конкретное преобразование из String в новый тип под названием StringOps. В данном типе имеется множество таких методов, как reverse, но вместо коллекции они возвращают значение типа String. Преоб­ разование в StringOps определено непосредственно в Predef, а преобразование в коллекцию Scala — в новом классе LowPriorityImplicits, который расширяется Predef. Там, где есть выбор из этих двух преобразований, компилятор выбирает преобразование в StringOps, поскольку оно определено в подклассе того класса, где определено другое преобразование. 21.8. Отладка неявных преобразований Неявные преобразования — довольно эффективный инструмент языка Scala, но иногда с ним трудно справиться должным образом. В этом разделе содержится ряд советов по отладке кода, использующего неявные элементы. Временами можно удивиться, почему компилятор не находит неявноео преоб­ разование, которое, на ваш взгляд, должен применить. В таком случае помогает явное указание преобразования. Если компилятор к тому же выдает сообщение об ошибке, то причина, по которой компилятор не может применить ваше неявное преобразование, известна. Предположим, например, что wrapString была ошибочно выбрана для преоб­ разования из String- в List-значения, а не в IndexedSeq-значения. Тогда может возникнуть вопрос, почему не работает следующий код: scala> val chars: List[Char] = "xyz" <console>:24: error: type mismatch; found : String("xyz") required: List[Char] val chars: List[Char] = "xyz" ^ И опять понять, что именно пошло не так, поможет явное указание преобразо­ вания wrapString: scala> val chars: List[Char] = wrapString("xyz") <console>:24: error: type mismatch; found : scala.collection.immutable.WrappedString required: List[Char] val chars: List[Char] = wrapString("xyz") ^ Его использование позволит выяснить причину ошибки: у wrapString невер­ ный тип возвращаемого значения. Возможно также, что вставка явного указания 21.8. Отладка неявных преобразований 429 на преобразование приведет к устранению ошибки. В этом случае становится из­ вестно, что применению неявного преобразования помешало нарушение одного из дополнительных правил (правило области видимости). Иногда при отладке программы вам может быть полезно видеть, какие имен­ но неявные преобразования вставляет компилятор. Для этого пригодится ключ запуска компилятора -Xprint:typer. При запуске scalac с этим ключом компи­ лятор покажет, как выглядит код после добавления механизмом проверки соот­ ветствия типов всех неявных преобразований. Пример показан в листингах 21.7 и 21.8. Если посмотреть на последнюю инструкцию каждого из этих листингов, то можно увидеть, что второй список параметров для enjoy, который был пропущен в листинге 21.7, enjoy("reader"), был вставлен компилятором, что и показано в листинге 21.8: Mocha.this.enjoy("reader")(Mocha.this.pref) Листинг 21.7. Пример кода, использующего неявный параметр object Mocha extends App { class PreferredDrink(val preference: String) implicit val pref = new PreferredDrink("mocha") def enjoy(name: String)(implicit drink: PreferredDrink) = { print("Welcome, " + name) print(". Enjoy a ") print(drink.preference) println("!") } enjoy("reader") } Листинг 21.8. Пример кода после проверки соответствия типов и вставки неявных преобразований $ scalac -Xprint:typer mocha.scala [[syntax trees at end of typer]] // Исходный код на Scala source: mocha.scala package <empty> { final object Mocha extends java.lang.Object with Application with ScalaObject { // ... private[this] val pref: Mocha.PreferredDrink = new Mocha.this.PreferredDrink("mocha"); implicit <stable> <accessor> def pref: Mocha.PreferredDrink = Mocha.this.pref; def enjoy(name: String) (implicit drink: Mocha.PreferredDrink): Unit = { scala.this.Predef.print("Welcome, ".+(name)); scala.this.Predef.print(". Enjoy a "); 430 Глава 21 • Неявные преобразования и параметры scala.this.Predef.print(drink.preference); scala.this.Predef.println("!") }; Mocha.this.enjoy("reader")(Mocha.this.pref) } } Можете отважиться и на попытку применить команду scala -Xprint:typer, чтобы получить интерактивную оболочку, которая выводит исходный код, ис­ пользуемый внутри интерпретатора, после набора текста. Резюме Неявные преобразования — довольно эффективный инструмент Scala, значительно сокращающий объем исходного кода. В этой главе мы рассмотрели действующие в Scala правила, которые относятся к неявным преобразованиям, и ряд наиболее распространенных в программировании ситуаций, когда, используя неявные пре­ образования, можно получить весомые преимущества. В качестве предостережения следует заметить: неявные преобразования при слишком частом использовании могут запутать код. Поэтому, прежде чем до­ бавлять новое неявное преобразование, задайтесь вопросом: нельзя ли получить аналогичный эффект с помощью других средств, таких как наследование, компо­ зиция примесей или перегрузка методов? Если ни одно из них не подойдет и у вас возникнет ощущение, что ваш код все еще загроможден и избыточен, то улучшить ситуацию сможет применение неявных преобразований. 22 Реализация списков Списки в данной книге встречались повсеместно. Класс List, вероятно, — один из самых востребованных в Scala типов структурированных данных. Использование списков мы показали в главе 16. А в текущей главе мы сорвем покровы и объясним саму суть реализации списков в Scala. Разбираться во внутренней работе класса List полезно по нескольким причи­ нам. Так вы более четко представляете относительную эффективность операций со списками, и это помогает быстро создавать компактный код, использующий списки. Кроме того, освоите набор методов, который можно применить при разработке собственных библиотек. И наконец, класс List — это пример искусного примене­ ния имеющихся в Scala системы типов в целом и концепции универсального типа в частности. Следовательно, изучение класса List позволит углубить ваши знания в данных областях. 22.1. Принципиальный взгляд на класс List Списки не встроены в Scala — они определяются с помощью абстрактного класса List из пакета scala.collection.immutable с двумя подклассами для :: и Nil. В этой главе мы представим беглый обзор класса List. В данном разделе приведем несколько упрощенную версию класса по сравнению с его реальной реализацией в стандартной библиотеке Scala, рассматриваемой ниже, в разделе 22.3: package scala.collection.immutable sealed abstract class List[+A] { Класс List — абстрактный, следовательно, определить элементы путем вы­ зова пустого конструктора List невозможно. Например, выражение new List будет запрещенным. У класса есть параметр типа A. Знак + перед указанием этого параметра типа выступает признаком ковариантности списков, рассмотренным в главе 19. 432 Глава 22 • Реализация списков Благодаря этому свойству переменной типа List[Any] можно присвоить зна­ чение типа List[Int]: scala> val xs = List(1, 2, 3) xs: List[Int] = List(1, 2, 3) scala> var ys: List[Any] = xs ys: List[Any] = List(1, 2, 3) Все операции со списками можно определить в понятиях трех основных мето­ дов: def isEmpty: Boolean def head: A def tail: List[A] Все эти три метода являются абстрактными методами, принадлежащими классу List. Они определены в подчиненном объекте Nil и в подклассе ::. Иерархия для List показана на рис. 22.1. Рис. 22.1. Иерархия классов для списков Scala Объект Nil Объект Nil определяет пустой список. Его определение показано в листинге 22.1. Данный объект — наследник типа List[Nothing]. Благодаря ковариантности это означает, что Nil совместим с каждым экземпляром типа List. Листинг 22.1. Определение объекта-одиночки Nil case object Nil extends List[Nothing] { override def isEmpty = true def head: Nothing = throw new NoSuchElementException("head of empty list") def tail: List[Nothing] = throw new NoSuchElementException("tail of empty list") } В объекте Nil очень просто реализуются три абстрактных метода класса List: метод isEmpty возвращает true, а оба метода, head и tail, генерируют исключения. 22.1. Принципиальный взгляд на класс List 433 Обратите внимание: генерация исключения — не только обоснованное, но и един­ ственно возможное действие для метода head: поскольку Nil — список List из ничего, Nothing, то результирующим типом head должен быть Nothing. Значения подобного типа не существует, значит, метод head не может вернуть обычное зна­ чение. Ему приходится выполнять возвращение ненормальным образом — через генерацию исключения1. Класс :: Класс :: (произносится «конс», от слова construct — «конструировать») представ­ ляет непустые списки. Он назван так, чтобы поддержать сопоставление с образцом с инфиксом ::. В разделе 16.5 было показано: каждая инфиксная операция в пат­ терне рассматривается как применение конструктора инфиксного оператора к его аргументам. Следовательно, схема x :: xs рассматривается как ::(x, xs), где :: является case-классом. Определение case-класса :: выглядит так: final case def head def tail override } class ::[A](hd: A, tl: List[A]) extends List[A] { = hd = tl def isEmpty: Boolean = false Реализация класса :: весьма проста. Он получает два конструируемых параме­ тра, hd и tl, представляющих «голову» и «хвост» создаваемого списка. Определения методов head и tail просто возвращают соответствующий пара­ метр. Фактически эту схему можно сократить за счет разрешения параметрам непо­ средственно реализовывать методы head и tail суперкласса List, как в следующем эквивалентном, но более лаконичном определении класса ::: final case class ::[A](head: A, tail: List[A]) extends List[A] { override def isEmpty: Boolean = false } Данный код работает благодаря тому, что каждый параметр case-класса явля­ ется еще и полем класса (это похоже на объявление параметра с префиксом val). Вспомним, в разделе 20.3 мы говорили: Scala позволяет выполнять реализацию абстрактных методов, не имеющих параметров, таких как head или tail, с помощью поля. Следовательно, показанный ранее код напрямую использует параметры head и tail в качестве реализаций абстрактных методов head и tail, унаследованных из класса List. 1 Точнее говоря, типы также позволяли бы методу head всегда вместо генерации исклю­ чения входить в бесконечный цикл, но это явно не то, что нужно. 434 Глава 22 • Реализация списков Еще несколько методов Все остальные методы класса List могут быть написаны с использованием трех основных методов, например: def length: Int = if (isEmpty) 0 else 1 + tail.length или def drop(n: Int): List[A] = if (isEmpty) Nil else if (n <= 0) this else tail.drop(n - 1) или def map[B](f: A => B): List[B] = if (isEmpty) Nil else f(head) :: tail.map(f) Создание списка Методы создания списка :: и ::: имеют особое свойство. Их имена заканчиваются двоеточием, поэтому они привязаны к своему правому операнду. То есть такая опе­ рация, как x :: xs, рассматривается как вызов метода xs.::(x), а не как вызов метода x.::(xs). Фактически выражение x.::(xs) не имело бы никакого смысла, посколь­ ку x относится к типу элементов списка, который может быть каким угодно, поэтому предположить, что в данном типе будет реализован метод ::, невозможно. Поэтому методу :: нужно получить значение элемента и выдать новый список. Каков же обязательный тип значения элемента? Возможно, вам захочется сказать в ответ, что он должен быть таким же, как и тип элементов списка, однако на самом деле он имеет более ограниченную природу, чем требуется. Чтобы понять причину этого, рассмотрим иерархию следующего класса: abstract class Fruit class Apple extends Fruit class Orange extends Fruit В листинге 22.2 показано, что произойдет при создании списка фруктов (fruits). Листинг 22.2. Добавление элемента супертипа в список подтипов scala> val apples = new Apple :: Nil apples: List[Apple] = List(Apple@e885c6a) scala> val fruits = new Orange :: apples fruits: List[Fruit] = List(Orange@3f51b349, Apple@e885c6a) 22.1. Принципиальный взгляд на класс List 435 Значение apples (яблоки) согласно ожиданиям рассматривается как список, состоящий из элементов типа Apple. Но определение фруктов показывает, что сохраняется возможность добавить к этому списку элемент другого типа. Типом элемента получающегося списка объявляется Fruit — наиболее точный общий супертип для типа элементов исходного списка, то есть Apple, и типа добавляемого элемента, то есть Orange. Такая гибкость достигается за счет определения метода :: (cons), показанного в листинге 22.3. Листинг 22.3. Определение метода :: (cons) в классе List def ::[B >: A](x: B): List[B] = new scala.::(x, this) Обратите внимание: сам по себе этот метод полиморфен, поскольку получает параметр типа по имени B. Кроме того, на B наложены ограничения в выражении [B >: A] и он должен быть супертипом того типа A, к которому относятся элементы списка. Требуется, чтобы добавляемый элемент относился к типу B, а получаемый результат — к типу List[B]. С помощью формулировки ::, показанной в листинге 22.3, можно проверить, как определение fruits, представленное в листинге 22.2, срабатывает в зависи­ мости от типа: в этом определении параметр типа B класса :: конкретизируется в Fruit. Условие по нижнему ограничителю B удовлетворено, поскольку список apples относится к типу List[Apple] , а Fruit является супертипом для типа Apple . Аргументом метода :: выступает new Orange , который соответствует типу Fruit . Поэтому применение метода не приводит к нарушению соответ­ ствия типов, а его результирующим типом является List[Fruit]. На рис. 22.2 показана структура списков, получающихся в результате выполнения кода из листинга 22.2. Рис. 22.2. Структура списков Scala, показанных в листинге 22.2 Фактически полиморфное определение метода :: с нижним ограничителем A не только приносит определенные удобства, но и необходимо для представления определения класса List в корректном в отношении типов виде. Это происходит благодаря тому, что списки типа List определены как ковариантные. 436 Глава 22 • Реализация списков Предположим на минуту, что у метода :: есть следующее определение: // Мысленный эксперимент (в неработоспособном виде) def ::(x: A): List[A] = new scala.::(x, this) В главе 19 мы показали, что параметры метода считаются контравариантной позицией, следовательно, элементы списка типа A находятся в контравариант­ ной позиции в определении выше. Но тогда класс List не может быть объявлен ковариантным по A. Таким образом, нижний ограничитель [B >: A] убивает одним выстрелом двух зайцев: устраняет проблему несоответствия типов и приводит к по­ вышению гибкости использования метода ::. Как показано в листинге 22.4, метод конкатенации списков ::: определен по аналогии с определением метода ::. Листинг 22.4. Определение метода ::: в классе List def :::[B >: A](prefix: List[B]): List[B] = if (prefix.isEmpty) this else prefix.head :: prefix.tail ::: this Как и метод cons , метод конкатенации полиморфен. Результирующий тип расширяется по мере необходимости в целях включения типов всех элементов списка. Нужно еще раз отметить, что порядок следования аргументов между инфиксной операцией и явным вызовом метода меняется на противоположный. Имена методов ::: и :: заканчиваются двоеточием, поэтому оба они привязаны к правому аргументу и обладают правой ассоциативностью. Например, в части else определения метода :::, показанного выше в листинге 22.4, содержатся инфиксные операции как для оператора ::, так для и оператора :::. Эти инфиксные операции можно расширить в следующие эквивалентные вы­ зовы методов: prefix.head :: prefix.tail ::: this равно (поскольку :: и ::: обладают правой ассоциативностью) prefix.head :: (prefix.tail ::: this) равно (поскольку :: привязывается к правому операнду) (prefix.tail ::: this).::(prefix.head) равно (поскольку ::: привязывается к правому операнду) this.:::(prefix.tail).::(prefix.head) 22.2. Класс ListBuffer Обычный паттерн доступа к списку является рекурсией. Например, чтобы уве­ личить на единицу значение каждого элемента списка, не прибегая к методу map, можно воспользоваться следующим кодом: def incAll(xs: List[Int]): List[Int] = xs match { case List() => List() case x :: xs1 => x + 1 :: incAll(xs1) } 22.2. Класс ListBuffer 437 У этого программного паттерна есть один недостаток: он не является хвостовой рекурсией. Следует заметить, что показанный ранее рекурсивный вызов incAll происходит внутри операции ::. Поэтому каждый рекурсивный вызов требует нового стекового фрейма. На современных виртуальных машинах это означает, что применять incAll к спискам, количество элементов которых существенно превышает диапазон от 30 000 до 50 000, невозможно. А жаль. А как бы вы написали версию incAll, спо­ собную работать со списками произвольного размера (насколько это позволил бы объем динамической области памяти)? Одним из подходов может стать применение цикла: for (x <- xs) // ?? Но что должно быть помещено в тело цикла? Ведь incAll создает список, по­ мещая элементы перед результатом рекурсивного вызова, а циклу нужно добавлять новые элементы в конец итогового списка. Возможность использования оператора добавления к списку ::: представляется слишком неэффективной: var result = List[Int]() // весьма неэффективный подход for (x <- xs) result = result ::: List(x + 1) result Это абсолютно неэффективный код. Поскольку на выполнение операции ::: затрачивается время, пропорциональное длине ее первого операнда, то на всю опе­ рацию уйдет время, пропорциональное квадрату длины списка. А это совершенно неприемлемо. Более удачной альтернативой будет использование буфера списка, позво­ ляющего аккумулировать элементы списка. Для этого нужно применить такую операцию, как buf += elem, добавляющую элемент elem в конец буфера списка buf. Закончив добавлять элементы буфера списка, можно превратить буфер в обычный список, воспользовавшись операцией toList. ListBuffer — класс, который находится в пакете scala.collection.mutable. Чтобы пользоваться только простым именем, можно импортировать ListBuffer из его пакета: import scala.collection.mutable.ListBuffer Теперь с помощью буфера списка тело incAll можно записать так: val buf = new ListBuffer[Int] for (x <- xs) buf += x + 1 buf.toList Это весьма эффективный способ создания списков. Фактически реализация буфера списка организована таким образом, чтобы на обе операции: добавле­ ния (+=) и превращения в список (toList) — уходило постоянное, весьма незна­ чительное время. 438 Глава 22 • Реализация списков 22.3. Класс List на практике Реализация методов работы со списками, рассмотренная в разделе 22.1, имеет весь­ ма лаконичный и понятный код, но страдает от проблемы переполнения так же, как и реализация incAll без хвостовой рекурсии. Поэтому в большинстве методов в реальной реализации класса List используется не рекурсия, а буферы списков. Так, в листинге 22.5 показана реальная реализация метода map класса List. Листинг 22.5. Определение метода map класса List final override def map[B](f: A => B): List[B] = { if (this eq Nil) Nil else { val h = new ::[B](f(head), Nil) var t: ::[B] = h var rest = tail while (rest ne Nil) { val nx = new ::(f(rest.head), Nil) t.next = nx t = nx rest = rest.tail } h } } Эта пересмотренная реализация проходит по элементам списка с помощью простого, но эффективного цикла. Не менее эффективной будет и реализация с использованием хвостовой рекурсии, но общая рекурсивная реализация станет работать медленнее. Обратите внимание, как список выстраивается «слева на­ право» (в отличие от применения обычного конс-оператора, который добавляет элементы с левой стороны списка) с помощью строки кода: t.next = nx Данная строка изменяет конец списка. Чтобы понять, как все это работает, еще раз взглянем на класс ::, создающий непустые списки. На практике он не вполне соответствует своему идеализированному определению, которое было дано в раз­ деле 22.1. Реальное определение больше похоже на то, которое показано в ли­ стинге 22.6. Как видите, он имеет еще одну особенность: аргумент next является var -переменной! Это значит, «хвост» после создания списка можно изменять. Но поскольку у переменной next имеется модификатор private[scala], то к ней можно обращаться только из пакета scala. Клиентский код, находящийся за преде­ лами данного пакета, не сможет читать переменную next и записывать в нее. Листинг 22.6. Определение подкласса :: класса List final case class :: [A](override val head: A, private[scala] var next: List[A]) extends List[A] { def isEmpty: Boolean = false def tail: List[A] = next } 22.3. Класс List на практике 439 В примере, ввиду того что класс ListBuffer содержится в подпакете scala.col­ lection.mutable пакета scala, ListBuffer может обращаться к полю next ячейки cons. Фактически элементы буфера списка представлены в виде списка, и добав­ ление новых элементов включает изменение поля next последней ячейки :: в этом списке. Таким образом, ListBuffer — еще один эффективный способ построить список слева направо. Начало кода класса ListBuffer выглядит так: package scala.collection.mutable class ListBuffer[A] extends Buffer[A] { private var first: List[A] = Nil private var last0: ::[A] = null private var aliased: Boolean = false ... Здесь видны три приватных поля, характеризующих ListBuffer: first указывает на список всех элементов, хранящихся в буфере списка; last0 указывает на последнюю ячейку :: в этом буфере списка; aliased показывает, был ли превращен буфер списка в список с помощью toList. Операция toList очень проста: override def toList: List[A] = { aliased = nonEmpty first } Она возвращает список элементов, на который ссылается first, а также при­ сваивает переменной aliased значение true, если список не пуст. Следовательно, операция toList весьма эффективна, поскольку не копирует список, сохраненный в ListBuffer. А что произойдет, если список после выполнения операции toList будет дополнен еще раз? Разумеется, список, возвращенный из toList, должен быть неизменяемым. Но добавление к элементу last0 приведет к изменению спи­ ска, на который ссылается first. Чтобы поддержать корректность операций с буфером списка, нужно вместо этого работать со свежим списком. Данная задача в реализации операции addOne решается в первой же строке: def addOne(elem: A): this.type = { if (aliased) copyElems() val last1 = new ::[A](elem, Nil) if (first.isEmpty) first = last1 else last0.next = last1 last0 = last1 this } Как видите, операция addOne копирует список, на который указывает first, если переменная aliased имеет значение true. Следовательно, в итоге без издержек не обходится. Если понадобится перейти от списков, допускающих расширение, 440 Глава 22 • Реализация списков к неизменяемым спискам, то без копирования ничего не получится. Но реализация ListBuffer выполнена таким образом, что копирование необходимо только для буферов списков, расширение которых продолжилось после того, как они пре­ вратились в списки. На практике подобное случается очень редко. В большинстве случаев использования буферов списков элементы добавляются постепенно, а за­ тем в конце выполняется всего одна операция toList. Тогда в копировании нет необходимости. 22.4. Внешняя функциональность В предыдущем разделе мы рассмотрели основные элементы реализации в Scala классов List и ListBuffer. Мы показали, что снаружи списки имеют чисто функ­ циональную природу, но внутри у них есть императивная реализация, которая использует буферы списков. Это обычная стратегия в программировании на Scala, заключающаяся в стремлении добиться сочетания чистоты с эффективностью путем тщательного ограничения последствий нечистых операций. У вас может возникнуть вопрос: зачем так рьяно настаивать на чистоте? По­ чему бы просто не открыть определение списков, сделав изменяемым поле tail, а может быть, и поле head? Недостаток подобного подхода заключается в том, что программы станут значительно менее надежными. Обратите внимание: при создании списков с помощью оператора :: многократно используется «хвост» создаваемого списка. Поэтому при написании кода: val ys = 1 :: xs val zs = 2 :: xs «хвосты» списков ys и zs являются общими — они указывают на одну и ту же структуру данных. Это важно с точки зрения эффективности. Если бы список xs копировался всякий раз, когда к нему добавляется новый элемент, то работа вы­ полнялась бы намного медленнее. Поскольку совместное использование получило весьма широкое распространение, изменение элементов списка, будь оно воз­ можным, было бы крайне опасной операцией. К примеру, если путем написания следующего кода в показанном ранее фрагменте понадобится выполнить усечение списка ys до его первых двух элементов: ys.drop(2).tail = Nil // Сделать это в Scala нельзя! то в качестве побочного эффекта будут усечены также списки zs и xs. Совершенно очевидно, что отслеживать места изменений крайне трудно. Имен­ но поэтому для списков в Scala выбраны их широкомасштабное совместное при­ менение и отказ от изменений. Если пожелаете, то класс ListBuffer по-прежнему позволяет создавать списки постепенно, в императивном стиле. Но поскольку буферы списков не являются списками, то типы разделяют изменяемые буферы списков и неизменяемые списки. Резюме 441 В дизайне классов List и ListBuffer в Scala происходят действия, очень по­ хожие на те, которые выполняются в принадлежащей Java паре классов String и StringBuffer. Это не случайно. В обеих ситуациях разработчики стремились создать чистые неизменяемые структуры данных, но при этом представить эф­ фективный способ постепенного создания таких структур. Для строк Java и Scala методы StringBuffer (или в Java 5 — StringBuilder) предоставляют способ посте­ пенного создания строки. В списках Scala у вас есть выбор: можно либо создавать списки постепенно, добавляя элементы к началу списка с помощью операции ::, либо задействовать буфер списка для добавления элементов к его концу. Какому из них отдать предпочтение, зависит от конкретной ситуации. Обычно операция :: хорошо поддается рекурсивным алгоритмам в стиле «разделяй и властвуй». Буферы списков часто используются в более традиционном стиле, основанном на применении циклов. Резюме В этой главе мы показали реализацию списков в Scala. Список — наиболее вос­ требованная структура данных в Scala, имеющая почти совершенную реализацию. Оба подкласса класса List, Nil и ::, являются case-классами. Но вместо рекурсив­ ной обработки данной структуры многие основные методы работы со списками задействуют ListBuffer. В свою очередь, класс ListBuffer реализован настолько аккуратно, что может эффективно создавать списки, не выделяя для своей работы дополнительную память. Снаружи он функциональный, но внутри использует изменяемость для ускорения обычной работы, когда буфер списка сбрасывается после вызова метода toList. Теперь, после изучения всех этих особенностей, классы списков вам знакомы как снаружи, так и изнутри, а кроме этого, вы освоили пару хитрых приемов их реализации. 23 Возвращение к выражениям for В главе 16 мы показали, что функции высшего порядка, такие как map, flatMap и filter, предоставляют весьма эффективные конструкции для работы со спи­ сками. Но иногда уровень абстракции, требующийся этим функциям, усложняет понимание кода программы. Рассмотрим пример. Предположим, имеется список людей, каждый элемент которого определен как экземпляр класса Person. У класса Person есть поля, по­ казывающие имя человека, его пол: мужской или женский — и его детей. Определение класса имеет следующий вид: scala> case class Person(name: String, isMale: Boolean, children: Person * ) А вот как выглядит список людей, взятых для примера: val val val val lara = Person("Lara", false) bob = Person("Bob", true) julie = Person("Julie", false, lara, bob) persons = List(lara, bob, julie) Теперь предположим, что в списке нужно найти имена всех пар матерей и их детей. Используя map, flatMap и filter, можно сформулировать следующий запрос: scala> persons filter (p => !p.isMale) flatMap (p => (p.children map (c => (p.name, c.name)))) res0: List[(String, String)] = List((Julie,Lara), (Julie,Bob)) Данный пример можно слегка оптимизировать, используя вместо filter вызов метода withFilter. Это позволит избавиться от создания промежуточной структу­ ры данных для людей женского пола: scala> persons withFilter (p => !p.isMale) flatMap (p => (p.children map (c => (p.name, c.name)))) res1: List[(String, String)] = List((Julie,Lara), (Julie,Bob)) Несмотря на то что эти запросы со своей задачей успешно справляются, для на­ писания и понимания они не так уж просты. А есть ли более простой способ? Да. 23.1. Выражения for 443 Помните выражения for из раздела 7.3? Используя выражение for, тот же самый пример можно записать следующим образом: scala> for (p <- persons; if !p.isMale; c <- p.children) yield (p.name, c.name) res2: List[(String, String)] = List((Julie,Lara), (Julie,Bob)) Результат выполнения этого выражения будет точно таким же, как и результат выполнения предыдущего. Более того, многим читателям кода выражение for бу­ дет понятнее предыдущего запроса, в котором использовались функции высшего порядка map, flatMap и withFilter. Но может показаться, что последние два запроса отличаются друг от друга не слишком сильно. Фактически окажется, что компилятор Scala переведет второй запрос в первый. В более широком смысле компилятор переводит все выражения, выдающие результат с помощью инструкции yield, в сочетание вызовов функций высшего порядка map, flatMap и withFilter. Все циклы for без инструкции yield переводятся в меньший по объему набор функций высшего порядка, в котором присутствуют только функции withFilter и foreach. Сначала в данной главе мы рассмотрим точные правила написания выражений for. Затем покажем, как с их помощью упростить решение комбинаторных задач. И наконец, вы узнаете, как выполняется перевод выражений for и как в результате этого выражения for могут способствовать развитию языка Scala в новых областях применения. 23.1. Выражения for В общем выражение for выглядит так: for ( seq ) yield expr где seq — последовательность из генераторов, определений и фильтров с точками с за­ пятой между стоящими друг за другом элементами. Выражение for показано в ниже: for (p <- persons; n = p.name; if (n startsWith "To")) yield n Это выражение for содержит один генератор, одно определение и один фильтр. Как упоминалось в разделе 7.3, последовательность можно заключить вместо круглых скобок в фигурные. Тогда точки с запятой можно будет не ставить: for { p <- persons // генератор n = p.name // определение if (n startsWith "To") // фильтр } yield n Генератор имеет следующую форму: pat <- expr 444 Глава 23 • Возвращение к выражениям for Выражение expr обычно возвращает список, хотя чуть позже мы покажем, что это обстоятельство можно обобщить. Образец pat сопоставляется по порядку со всеми элементами списка. При успешной проверке переменные в образце при­ вязываются к соответствующим частям элемента, как описано в главе 15. Но если проверка завершается неудачно, то никакая ошибка MatchError не выдается. Вместо этого элемент просто выбрасывается из итерации. В наиболее распространенном варианте применения образец pat представлен простой переменной x, как в выражении x <- expr. В данном случае переменная x просто используется при последовательном переборе всех элементов, возвращае­ мых выражением expr. Определение имеет форму: pat = expr Оно привязывает образец pat к переменной выражения expr. Таким образом, дает такой же эффект, что и определение val-переменной: val x = expr В наиболее распространенном варианте использования образец также пред­ ставлен простой переменной x (например, x = expr). Это определяет x как имя для значения expr. Фильтр имеет форму: if expr Здесь expr является выражением, имеющим тип Boolean. Фильтр выбрасывает из итерации все элементы, для которых выражение expr возвращает false. Каждое выражение for начинается с генератора. Если в выражении имеется сразу несколько генераторов, то последующие генераторы изменяются быстрее предыдущих. Это легко проверить с помощью следующего простого теста: scala> for (x <- List(1, 2); y <- List("one", "two")) yield (x, y) res3: List[(Int, String)] = List((1,one), (1,two), (2,one), (2,two)) 23.2. Задача N ферзей Выражениям for в качестве подходящей области применения лучше всего подходят комбинаторные головоломки. Пример такой головоломки — задача восьми ферзей: на стандартной шахматной доске нужно расставить восемь ферзей таким образом, чтобы ни один из них не мог бить любого другого (ферзь может бить другую фи­ гуру, если они находятся на одной вертикали, горизонтали или диагонали). Чтобы найти решение этой задачи, ее проще рассматривать в общем виде на шахматной доске произвольного размера. Тогда задача будет заключаться в размещении N ферзей на доске из N × N клеток, где N имеет произвольное значение. Начнем нумеровать клетки с единицы, чтобы верхняя левая клетка доски размером N × N имела координаты (1, 1), а нижняя правая — (N, N). 23.2. Задача N ферзей 445 Обратите внимание: для решения задачи N ферзей нужно поставить по ферзю на каждую горизонталь. Таким образом, можно последовательно расставлять ферзей по горизонталям, каждый раз проверяя, не находится ли только что поставленная на доску фигура под ударом любых других ранее поставленных ферзей. В ходе проверки может оказаться, что ферзю, которого нужно поставить на горизонталь k, будут грозить на всех полях этой горизонтали ферзи, установленные на горизонта­ лях от 1 до k – 1. В таком случае следует прекратить данную часть проверки, чтобы продолжить с другой расстановкой ферзей на вертикалях от 1 до k – 1. При императивном решении этой задачи ферзей будут ставить по одному, пере­ мещая их по доске. Но, похоже, найти схему, которая реально перепробует все воз­ можности, нелегко. Более функциональный подход представляет собой решение непосредственно в виде значения. Решение состоит из списка координат, по одному для каждого поставленного на доску ферзя. Но следует заметить, что найти за один шаг полноценное решение не удастся. Оно должно выстраиваться постепенно, по мере расстановки ферзей по последовательным горизонталям. Это наводит на мысль о применении рекурсивного алгоритма. Предположим, что уже сгенерированы все решения по размещению k ферзей на доске размером N × N клеток, где k < N. Каждое такое решение можно представить в виде списка длиной k координат строк и столбцов (row, column), где и row, и column — числа в диапазоне от 1 до N. Эти списки частных решений удобнее будет рассматривать в виде стеков, где координаты ферзя в строке k следуют первыми в списке, за ними идут координаты ферзя в строке k – 1 и т. д. На дне стека находятся координаты ферзя, помещенного на первую строку доски. Решение целиком представлено в виде списка списков, по одному элементу для каждого решения. Теперь, чтобы поставить следующего ферзя на строку k + 1, генерируются все возможные расширения каждого предыдущего решения еще на одного ферзя. Это приводит к появлению еще одного списка, который состоит из списков реше­ ний, на этот раз длиной k + 1. Процесс продолжается до момента получения всех решений для шахматной доски размером N × N. Этот алгоритмический замысел воплотился в функции placeQueens: def queens(n: Int): List[List[(Int, Int)]] = { def placeQueens(k: Int): List[List[(Int, Int)]] = if (k == 0) List(List()) else for { queens <- placeQueens(k - 1) column <- 1 to n queen = (k, column) if isSafe(queen, queens) } yield queen :: queens placeQueens(n) } Внешняя функция queens в показанной ранее программе просто вызывает placeQueens с размером доски n, используемым в качестве аргумента. Задача функ­ ции-приложения placeQueens(k) — генерирование в списке всех частных решений 446 Глава 23 • Возвращение к выражениям for длиной k. Каждый элемент списка — одно решение, представленное списком дли­ ной k. Следовательно, placeQueens возвращает список списков. Если параметр k для placeQueens равен нулю, то это значит, что нужно сгенери­ ровать все решения, помещая нуль ферзей на нуль строк. Есть только одно такое решение: не ставить ни одного ферзя. Оно представлено пустым списком. Следова­ тельно, если параметр k равен нулю, то placeQueens возвращает List(List()) — спи­ сок, который состоит из одного элемента, являющегося пустым списком. Обратите внимание: это весьма отличается от пустого списка List(). Если placeQueens воз­ вращает List(), то это означает отсутствие решений, а не единственное решение, состоящее из не поставленного на доску ферзя. В иных случаях, когда k ≠ 0, вся работа placeQueens выполняется в выражении for. Первый генератор этого выражения for обходит все решения по расстановке на доске k - 1 ферзей. Второй генератор обходит все возможные столбцы column, на которые можно поставить k-го ферзя. Третья часть выражения for определяет по­ зицию нового ферзя как пару, состоящую из строки k и каждого сгенерированного столбца column. Четвертая часть выражения for является фильтром, проверяющим с помощью метода isSafe, не находится ли новый ферзь под ударом предыдущих ферзей (определение isSafe будет рассмотрено чуть позже). Если новый ферзь не находится под ударом любых других ферзей, то может быть сформирована часть частного решения, стало быть, placeQueens с помощью выраже­ ния queen :: queens генерирует новое решение. Если новый ферзь не находится в без­ опасной от удара позиции, то фильтр возвращает false и решение не генерируется. Осталось только определить метод isSafe, используемый для проверки того, не находится ли рассматриваемый ферзь под ударом от любого другого элемента списка queens. Определение имеет следующий вид: def isSafe(queen: (Int, Int), queens: List[(Int, Int)]) = queens forall (q => !inCheck(queen, q)) def inCheck(q1: (Int, Int), q2: (Int, Int)) = q1._1 == q2._1 || // та же строка q1._2 == q2._2 || // тот же столбец (q1._1 - q2._1).abs == (q1._2 - q2._2).abs // по диагонали Метод isSafe выражает безопасность ферзя относительно какой-либо из других ферзей. Метод inCheck показывает, что ферзи q1 и q2 находятся под ударом друг друга. Он возвращает true в одном из трех случаев. 1. Если два ферзя имеют одну и ту же координату строки. 2. Если два ферзя имеют одну и ту же координату столбца. 3. Если два ферзя стоят на одной и той же диагонали (то есть разница между их строками и разница между их столбцами одинакова). Первый случай, когда два ферзя имеют одинаковую координату строки, в дан­ ном приложении невозможен, поскольку метод placeQueens уже позаботился о по­ мещении каждого ферзя на другую строку. Следовательно, эту проверку можно убрать, не изменяя функциональности программы. 23.3. Выполнение запросов с помощью выражений for 447 23.3. Выполнение запросов с помощью выражений for Форма записи выражения for, по сути, эквивалентна общим операциям языков запросов к базам данных. Возьмем, к примеру, некую базу данных по имени books, представляющую собой список книг, в котором класс Book определен так: case class Book(title: String, authors: String* ) Вот как выглядит небольшой фрагмент базы данных, представленный в виде списка в памяти: val books: List[Book] = List( Book( "Structure and Interpretation of Computer Programs", "Abelson, Harold", "Sussman, Gerald J." ), Book( "Principles of Compiler Design", "Aho, Alfred", "Ullman, Jeffrey" ), Book( "Programming in Modula-2", "Wirth, Niklaus" ), Book( "Elements of ML Programming", "Ullman, Jeffrey" ), Book( "The Java Language Specification", "Gosling, James", "Joy, Bill", "Steele, Guy", "Bracha, Gilad" ) ) Чтобы найти названия всех книг, автор которых носит фамилию Gosling, тре­ буется следующий код: scala> for (b <- books; a <- b.authors if a startsWith "Gosling") yield b.title res4: List[String] = List(The Java Language Specification) А чтобы найти названия всех книг, в которых встречается строка Program, тре­ буется такой код: scala> for (b <- books if (b.title indexOf "Program") >= 0) yield b.title res5: List[String] = List(Structure and Interpretation of Computer Programs, Programming in Modula-2, Elements of ML Programming) 448 Глава 23 • Возвращение к выражениям for Чтобы в базе данных найти фамилии всех авторов, написавших хотя бы две книги, требуется следующий код: scala> for (b1 <- books; b2 <- books if b1 != b2; a1 <- b1.authors; a2 <- b2.authors if a1 == a2) yield a1 res6: List[String] = List(Ullman, Jeffrey, Ullman, Jeffrey) Последнее решение все же далеко от совершенства, поскольку в полученном в результате списке авторы будут фигурировать по нескольку раз. Нужно удалить повторяющихся авторов из списка. Сделать это позволяет функция, показанная ниже: scala> def removeDuplicates[A](xs: List[A]): List[A] = { if (xs.isEmpty) xs else xs.head :: removeDuplicates( xs.tail filter (x => x != xs.head) ) } removeDuplicates: [A](xs: List[A])List[A] scala> removeDuplicates(res6) res7: List[String] = List(Ullman, Jeffrey) Стоит отметить, что последнее выражение в методе removeDuplicates можно выразить эквивалентно с помощью for: xs.head :: removeDuplicates( for (x <- xs.tail if x != xs.head) yield x ) 23.4. Трансляция выражений for Каждое выражение for можно выразить в понятиях трех функций высшего поряд­ ка: map, flatMap и withFilter. В этом разделе рассматривается схема трансляции, которая также используется компилятором Scala. Трансляция выражений for с одним генератором Предположим сначала, что у нас есть простое выражение for: for ( x <- expr1 ) yield expr2 где x — переменная. Выражение транслируется в следующее выражение: expr1.map( x => expr2 ) 23.4. Трансляция выражений for 449 Трансляция выражений for, начинающихся с генератора и фильтра Теперь рассмотрим выражения for, сочетающие в себе ведущий генератор с не­ которыми другими элементами. Выражение for следующей формы: for ( x <- expr1 if expr2 ) yield expr3 транслируется в выражение: for ( x <- expr1 withFilter ( x => expr2 )) yield expr3 Эта трансляция выдает еще одно выражение for, которое короче исходного на один элемент, поскольку элемент if транслируется в применение withFilter к первому выражению генератора. Затем трансляция продолжается в отношении второго выражения, и в итоге получается такой код: expr1 withFilter ( x => expr2 ) map ( x => expr3 ) Точно такая же схема применима, если есть другие элементы, следующие за фильтром. Если seq является произвольной последовательностью генераторов, определений и фильтров, то выражение: for ( x <- expr1 if expr2; seq) yield expr3 транслируется в выражение: for ( x <- expr1 withFilter expr2; seq) yield expr3 Затем процесс трансляции продолжается в отношении второго выражения, которое снова короче исходного на один элемент. Трансляция выражений for, начинающихся с двух генераторов Следующий случай относится к обработке выражений for, начинающихся с двух генераторов, как в: for ( x <- expr1; y <- expr2; seq) yield expr3 Здесь также предположим, что seq — произвольная последовательность генера­ торов, определений и фильтров. Фактически элемент seq также может быть пустым, и тогда после expr2 точки с запятой может не быть. Схема трансляции в любом случае будет одинаковой. Показанное ранее выражение for транслируется в вы­ ражение с применением метода flatMap: expr1.flatMap( x => for ( y <- expr2; seq) yield expr3 ) На сей раз получается еще одно выражение for, которое находится в переданном методу flatMap функциональном значении. Это выражение for, которое опять проще исходного на один элемент, транслируется с применением тех же самых правил. 450 Глава 23 • Возвращение к выражениям for Трех показанных схем достаточно для трансляции всех выражений for, содер­ жащих лишь генераторы и фильтры, где генераторы привязывают только простые переменные. Возьмем, к примеру, запрос из раздела 23.3 «найти фамилии всех авторов, написавших хотя бы две книги»: for (b1 <- books; b2 <- books if b1 != b2; a1 <- b1.authors; a2 <- b2.authors if a1 == a2) yield a1 Этот запрос транслируется в следующую комбинацию map/flatMap/withFilter: books flatMap (b1 => books withFilter (b2 => b1 != b2) flatMap (b2 => b1.authors flatMap (a1 => b2.authors withFilter (a2 => a1 == a2) map (a2 => a1)))) Представленная до сих пор схема трансляции пока не справляется с генера­ торами, привязывающими не простые переменные, а целые паттерны. Она также пока не охватывает определения. Эти два аспекта будут рассмотрены в следующих двух подразделах. Трансляция паттернов в генераторах Если в левой части генератора находится не простая переменная, а паттерн pat, то схема трансляции усложняется. Тот случай, когда в выражении for имеется привязка кортежа переменных, обрабатывается довольно просто. При этом при­ меняется почти такая же схема, как и при одной переменной. Выражение for, имеющее следующую форму: for (( x1 , ..., xn ) <- expr1 ) yield expr2 транслируется в выражение: expr1.map { case ( x1 , ..., xn ) => expr2 } Ситуация усложняется, если в левой части генератора находится не одна пере­ менная и не кортеж переменных, а произвольный паттерн pat . В таком случае выражение: for (pat <- expr1 ) yield expr2 транслируется в выражение: expr1 withFilter { case pat => true case _ => false } map { case pat => expr2 } 23.4. Трансляция выражений for 451 То есть сначала генерируемые элементы фильтруются, а затем отображаются только те из них, которые соответствуют pat. Тем самым гарантируется, что гене­ ратор сопоставления с образцом никогда не выдаст ошибку MatchError. В представленной здесь схеме рассматриваются только случаи, когда выражение for содержит лишь один генератор сопоставления с образцом. Аналогичные пра­ вила применяются и при наличии в выражении for других генераторов, фильтров или определений. Поскольку эти дополнительные правила не помогают лучше понять тему, здесь они не рассматриваются. Любопытные читатели могут найти их в Scala Language Specification1. Трансляция определений И последняя недостающая ситуация — когда в выражении for содержатся встро­ енные определения. Рассмотрим типичный вариант: for (x <- expr1 ; y = expr2; seq) yield expr3 Предположим в очередной раз, что элемент seq (возможно, пустой) является последовательностью генераторов, определений и фильтров. Это выражение транс­ лируется в следующее выражение: for ((x, y) <- for (x <- expr1 ) yield (x, expr2); seq) yield expr3 Как видите, expr 2 вычисляется при каждой генерации нового значения x . Необходимость проводить многократное вычисление обусловливается тем, что expr2 может ссылаться на x и поэтому при изменении значения x должно быть вы­ числено заново. Вы как программист можете прийти к заключению, что встраивать определения в выражения for, которые не ссылаются на переменные, привязан­ ные к какому-нибудь предшествующему генератору, вряд ли разумно, поскольку многократные вычисления таких выражений повлекут за собой существенные издержки. Например, вместо: for (x <- 1 to 1000; y = expensiveComputationNotInvolvingX) (затратное вычисление, не использующее x) yield x * y во многих случаях лучше воспользоваться следующим кодом: val y = expensiveComputationNotInvolvingX (затратное вычисление, не использующее x) for (x <- 1 to 1000) yield x * y 1 Odersky M. The Scala Language Specification, Version 2.9. EPFL, May 2011 [Электронный ресурс] / M. Odersky. — Режим доступа: www.scalalang.org/docu/manuals.html. — Дата доступа: 20.04.2014. 452 Глава 23 • Возвращение к выражениям for Трансляция, применяемая для циклов В предыдущих подразделах было показано, как транслируется выражение for, содержащее оператор yield. А как насчет циклов, которые просто дают побочный эффект, не возвращая вообще ничего? Их трансляция выполняется аналогично, но является более простой, чем для выражений for. В принципе, там, где предыдущая схема трансляции применила map или flatMap, в схеме трансляции для циклов for используется просто foreach. Например, выражение: for (x <- expr1) body транслируется в: expr1 foreach ( x => body) Более объемным примером будет выражение: for (x <- expr1; if expr2; y <- expr3) body Оно транслируется в следующий код: expr1 withFilter (x => expr2) foreach (x => expr3 foreach (y => body)) Например, следующее выражение суммирует все элементы матрицы, представ­ ленной в виде списка списков: var sum = 0 for (xs <- xss; x <- xs) sum += x Этот цикл транслируется в два вложенных применения foreach: var sum = 0 xss foreach (xs => xs foreach (x => sum += x)) 23.5. «Мы пойдем другим путем» В предыдущем разделе мы показали, что выражения for могут транслироваться в использование функций высшего порядка: map, flatMap и withFilter. Но можно пойти и в обратном направлении и каждое применение map, flatMap или withFilter представить в виде выражения for. Рассмотрим реализацию этих трех методов в понятиях выражений for. Методы содержатся в объекте Demo, чтобы их можно было отличить от стандартных опера­ ций над объектами типа List. Точнее, все три функции получают в качестве пара­ метра значение типа List, но схема трансляции будет успешно работать и с другими типами коллекций: 23.6. Обобщение for 453 object Demo { def map[A, B](xs: List[A], f: A => B): List[B] = for (x <- xs) yield f(x) def flatMap[A, B](xs: List[A], f: A => List[B]): List[B] = for (x <- xs; y <- f(x)) yield y def filter[A](xs: List[A], p: A => Boolean): List[A] = for (x <- xs if p(x)) yield x } Думаю, у вас не вызовет удивления то, что трансляция выражения for, исполь­ зованного в теле метода Demo.map, превратится в вызов map класса List. По аналогии с этим Demo.flatMap и Demo.filter будут переведены в flatMap и withFilter класса List. Таким образом, эта небольшая демонстрация показывает, что выражения for действительно эквивалентны по своей выразительности применению функций map, flatMap и withFilter. 23.6. Обобщение for Поскольку трансляция выражений for зависит только от наличия методов map, flatMap и withFilter, то систему записи выражений for можно применить к боль­ шому классу типов данных. Вам уже встречалось применение выражений for в отношении списков и мас­ сивов. Их поддержка обусловливается тем, что для списков, как и для масси­ вов, определены операции map, flatMap и withFilter. Поскольку для них также определен метод foreach, то при работе с этими типами данных доступны и ци­ клы for. Помимо списков и массивов, в стандартной библиотеке Scala есть множество других типов, поддерживающих те же четыре метода, а следовательно, позволя­ ющих использовать выражения for. В качестве примеров можно назвать диапазо­ ны, итераторы, потоки и все реализации множеств. Кроме того, вполне возможно путем определения всех необходимых методов получить поддержку выражений for для ваших собственных типов данных. Поддержка всего диапазона выражений for и циклов for в качестве методов вашего типа данных возможна с помощью определения методов map, flatMap, withFilter и foreach. Но можно также опреде­ лить подмножество этих методов и, соответственно, получить подмножество всех потенциальных выражений и циклов for. При этом нужно придерживаться следующих строгих правил. Если в вашем типе определяется лишь метод map, то можно будет применять выражения for, содержащие только один генератор. Если наряду с map определяется flatMap, то можно будет применять выражения for, содержащие несколько генераторов. 454 Глава 23 • Возвращение к выражениям for Если определяется метод foreach, то можно будет применять циклы for (как с одним, так и с несколькими генераторами). Если определяется метод withFilter, то можно будет применять выражение фильтра, где выражение for начинается с if. Трансляция выражений for происходит до проверки типов. Тем самым допуска­ ется максимальная гибкость, поскольку необходимо, чтобы проверку типов прошел лишь результат расширения выражения for. Для самих выражений for правила типизации в Scala не определяются, кроме того, не требуется наличие какой-либо конкретной сигнатуры типов в методах map, flatMap, withFilter или foreach. Тем не менее существует стандартная ситуация, отражающая наиболее рас­ пространенный замысел, вкладываемый в методы высшего порядка, в которые транслируются выражения for. Предположим, имеется некий параметризованный класс C, который, как правило, будет представлять некую разновидность коллекции. Тогда вполне естественно будет выбрать для map, flatMap, withFilter и foreach следующие сигнатуры типов: abstract class C[A] { def map[B](f: A => B): C[B] def flatMap[B](f: A => C[B]): C[B] def withFilter(p: A => Boolean): C[A] def foreach(b: A => Unit): Unit } То есть функция map получает функцию, отображающую элементы коллекции типа A к некоторому другому типу B. Она возвращает новую коллекцию того же вида C, но с элементами типа B. Метод flatMap получает функцию f от типа A к не­ кой коллекции C, состоящей из элементов типа B, и возвращает коллекцию C из B. Метод withFilter получает функцию-предикат от типа элементов коллекции A к Boolean. Он возвращает коллекцию того же типа, что и та, в отношении которой он был вызван. И наконец, метод foreach получает функцию от A к Unit и возвра­ щает результат типа Unit. В показанном ранее классе C метод withFilter возвращает новую коллекцию того же класса. Это значит, при каждом вызове withFilter создается новый объ­ ект типа C, совпадающего с тем типом, с которым будет работать фильтр. Теперь в трансляции выражений for за любым вызовом withFilter всегда будет следо­ вать вызов одного из трех других методов. Поэтому объект, создаваемый методом withFilter, будет сразу же разобран одним из других методов. Если объекты клас­ са C велики (например, это длинные последовательности), то может потребоваться обойти создание такого промежуточного объекта. В качестве стандартного приема можно позволить методу withFilter возвращать не объект C, а просто объект-обо­ лочку, который помнит, что элементы перед последующей обработкой должны быть отфильтрованы. Концентрируясь только на первых трех функциях класса C, нужно отметить сле­ дующие факты. В функциональном программировании существует общее понятие «монада», с помощью которого можно объяснить большое количество типов с вы­ Резюме 455 числениями, начиная от коллекций и заканчивая, если называть лишь некоторые из них, вычислениями с состоянием и вводом-выводом, обратными вычислениями и транзакциями. Функции map, flatMap и withFilter можно сформулировать в по­ нятиях монад, и если сделать это, то все сведется к тому, что у них будут точно такие же типы, что и здесь. Более того, каждую монаду можно охарактеризовать с помощью map, flatMap и withFilter, если прибавить блочный конструктор, создающий монаду из зна­ чения элемента. В объектно-ориентированных языках этот блочный конструктор является просто конструктором экземпляра или фабричным методом. Поэтому методы map, flatMap и withFilter могут рассматриваться как объектно-ориенти­ рованные версии функциональной концепции монад. Поскольку выражения for эквивалентны применению этих трех методов, то могут рассматриваться в качестве синтаксиса для монад. Все вышеприведенное говорит о том, что концепция выражения for более уни­ версальна, чем просто обход элементов коллекции, и это действительно так. Напри­ мер, выражения for также играют важную роль в асинхронном вводе-выводе или могут использоваться в качестве альтернативной формы записи для опциональных значений. В библиотеках Scala следите за появлениями map, flatMap и withFilter: там, где они присутствуют, сами собой напрашиваются выражения for, которые могут стать весьма лаконичным способом работы с элементами типа. Резюме В этой главе мы предложили заглянуть во внутреннюю кухню выражений и ци­ клов for. Вы узнали, что они транслируются в применение стандартного набора методов высшего порядка. В результате было показано, что выражения for фак­ тически имеют намного более универсальное применение, чем простой обход элементов коллекций, и что вы можете создавать в целях их поддержки собствен­ ные классы. 24 Углубленное изучение коллекций В Scala включена весьма элегантная и эффективная библиотека коллекций. На первый взгляд API коллекций представляется незаметным, однако вызван­ ные им изменения в вашем стиле программирования могут быть весьма суще­ ственными. Зачастую это похоже на работу на высоком уровне с основными строительными блоками программы, представляющими собой скорее коллекции, чем их элементы. Этот новый стиль программирования требует некоторой адап­ тации. К счастью, ее облегчает ряд привлекательных свойств коллекций Scala. Они просты в использовании, лаконичны в описании, безопасны, быстры в работе и универсальны. Простота использования. Небольшого словаря, содержащего от 20 до 50 ме­ тодов, вполне достаточно для решения основного набора задач всего за пару операций. Не нужно морочить голову сложными цикличными структурами или рекурсиями. Стабильные коллекции и операции без побочных эффектов означают, что вам не следует опасаться случайного повреждения существу­ ющих коллекций новыми данными. Взаимовлияние итераторов и обновления коллекций исключено. Лаконичность. То, что раньше занимало от одного до нескольких циклов, те­ перь можно выразить всего одним словом. Можно выполнять функциональные операции, задействуя упрощенный синтаксис, и без особых усилий комбиниро­ вать операции таким образом, чтобы в результате получалось нечто похожее на обычные алгебраические формулы. Безопасность. Чтобы разобраться в этом вопросе, нужен определенный опыт. Природа коллекций Scala с их статической типизацией и функциональностью означает, что подавляющее большинство потенциальных ошибок отлавливается еще во время компиляции. Это достигается благодаря следующему: yy сами операции над коллекциями используются довольно широко, а следо­ вательно, прошли проверку временем; 24.1. Изменяемые и неизменяемые коллекции 457 yy использование операций над коллекциями делает ввод и вывод явным в виде параметров функций и результатов; yy эти явные входные и выходные данные являются предметом для статиче­ ской проверки типов. Суть заключается в том, что большинство случаев неверного использования кода проявится в виде ошибок типа. И вовсе не редкость, когда программы, состоящие из нескольких сотен строк кода, запускаются с первой же попытки. Скорость. Операции с коллекциями, имеющиеся в библиотеках, уже настро­ ены и оптимизированы. В результате этого использование коллекций обычно отличается высокой эффективностью. Тщательно настроенные структуры данных и операции могут улучшить ситуацию, но в то же время, принимая ре­ шения, далекие от оптимальных, можно ее значительно ухудшить. Более того, коллекции были адаптированы под параллельную обработку на многоядерных системах. Параллельно обрабатываемые коллекции поддерживают те же са­ мые операции, что и последовательно обрабатываемые, поэтому изучать новые операции и переписывать код не нужно. Последовательно обрабатываемые коллекции можно превратить в параллельно обрабатываемые, просто вызвав метод par. Универсальность. Коллекции реализуют одни и те же операции над любым типом там, где в этих операциях есть определенный смысл. Следовательно, используя сравнительно небольшой словарь операций, вы приобретаете множество возможностей. Например, концептуально строка является после­ довательностью символов. Следовательно, в коллекциях Scala строки под­ держивают все операции с последовательностями. То же самое справедливо и для массивов. В этой главе мы даем углубленное описание API имеющихся в Scala классов коллекций с точки зрения их использования. Краткий тур по библиотекам коллек­ ций мы совершили в главе 17. В этой главе нам предстоит поучаствовать в более подробном путешествии, в ходе которого мы покажем все классы коллекций и все определенные в них методы, то есть представим все сведения, необходимые для ис­ пользования коллекций Scala. Забегая вперед: для тех, кто собирается заниматься реализацией новых типов коллекций, в главе 25 основное внимание мы уделим вопросам архитектуры библиотек и возможностям их расширения. 24.1. Изменяемые и неизменяемые коллекции Как вам уже известно, в целом коллекции в Scala подразделяются на изменяемые и неизменяемые. Изменяемые могут обновляться или расширяться на месте. Это значит, что вы можете изменять, добавлять или удалять элементы коллекции 458 Глава 24 • Углубленное изучение коллекций как побочный эффект. Неизменяемые коллекции, напротив, всегда сохраняют не­ изменный вид. Но все же есть операции, имитирующие добавление, удаление или обновление, но каждая из таких операций возвращает новую коллекцию, оставляя старую без изменений. Все классы коллекций находятся в пакете scala.collection или в одном из его подпакетов: mutable, immutable и generic. Большинство классов коллекций, востребованных в клиентском коде, существуют в трех вариантах, которые имеют разные характеристики в смысле возможности изменения. Эти три варианта на­ ходятся в пакетах scala.collection, scala.collection.immutable и scala.collec­ tion.mutable. Коллекция в пакете scala.collection.immutable гарантированно неизме­ няемая для всех. Такая коллекция после создания всегда остается неизменной. Поэтому можно полагаться на то, что многократные обращения к одному и тому же значению коллекции в разные моменты времени всегда будут давать коллекцию с теми же элементами. Коллекция в пакете scala.collection.mutable известна тем, что имеет ряд операций, изменяющих коллекцию на месте. Эти операции позволяют вам созда­ вать код для самостоятельного изменения коллекции. Но при этом нужно четко понимать, что существует вероятность обновления из других частей исходного кода, и защищаться от нее. Коллекции в пакете scala.collection могут быть как изменяемыми, так и не­ изменяемыми. Например, scala.collection.IndexedSeq[T] является супертрейтом как для scala.collection.immutable.IndexedSeq[T], так и для его изменяемого брата scala.collection.mutable.IndexedSeq[T]. В целом корневые коллекции в пакете scala.collection поддерживают трансформирующие операции, кото­ рые влияют на целую коллекцию, такие как map и filter. Неизменяемые кол­ лекции в пакете scala.collection.immutable обычно дополняют операциями добавления и удаления отдельных значений, а изменяемые коллекции в пакете scala.collection.mutable дополняются некоторыми модифицирующими опера­ циями с побочными эффектами. Между корневыми и неизменяемыми коллекциями есть еще одно различие: клиенты неизменяемых коллекций получают гарантии того, что никто не сможет внести в коллекцию изменения, а клиенты корневых коллекций знают только то, что не могут изменить коллекцию самостоятельно. Даже если статический тип такой коллекции не предоставляет операций для ее изменения, все же существует вероятность того, что в процессе выполнения программы она получит тип из­ меняемой коллекции, предоставив другим клиентам возможность вносить в нее изменения. По умолчанию в Scala всегда выбираются неизменяемые коллекции. Например, если просто написать Set без какого-либо префикса или ничего не импортируя, то будет получено неизменяемое множество, а если написать Iterable — то неизменя­ емый объект с возможностью обхода его элементов, поскольку имеются привязки по умолчанию, импортируемые из пакета scala . Чтобы получить изменяемые 24.2. Согласованность коллекций 459 версии по умолчанию, следует явно указать collection.mutable.Set или collec­ tion.mu­table.Iterable. Последний пакет в иерархии коллекций — collection.generic. В нем содер­ жатся строительные блоки для абстрагирования поверх конкретных коллекций. Впрочем, постоянные пользователи коллекций должны ссылаться на классы в па­ кете generic только в крайних случаях. 24.2. Согласованность коллекций Наиболее важные классы коллекций показаны на рис. 24.1. Iterable Seq Set IndexedSeq ArraySeq Vector ArrayDeque (mutable) Queue (mutable) Stack (mutable) Range NumericRange LinearSeq List LazyList Queue (immutable) Buffer ListBuffer ArrayBuffer SortedSet TreeSet HashSet (mutable) LinkedHashSet HashSet (immutable) BitSet EmptySet, Set1, Set2, Set3, Set4 Map SortedMap TreeMap HashMap (mutable) LinkedHashMap (mutable) HashMap (immutable) VectorMap (immutable) EmptyMap, Map1, Map2, Map3, Map4 Рис. 24.1. Иерархия коллекций 460 Глава 24 • Углубленное изучение коллекций Эти классы имеют много общего. Например, каждая разновидность коллекции может быть создана с использованием единообразного синтаксиса, заключа­ ющегося в записи названия класса коллекции, за которым стоят элементы данной коллекции: Iterable("x", "y", "z") Map("x" -> 24, "y" -> 25, "z" -> 26) Set(Color.Red, Color.Green, Color.Blue) SortedSet("hello", "world") Buffer(x, y, z) IndexedSeq(1.0, 2.0) LinearSeq(a, b, c) Тот же принцип применяется и к конкретным реализациям коллекций: List(1, 2, 3) HashMap("x" -> 24, "y" -> 25, "z" -> 26) Методы toString для всех коллекций выводят информацию, аналогичную по­ казанной ранее, с именем типа, за которым следуют значения элементов коллекции, заключенные в круглые скобки. Все коллекции поддерживают API, предоставля­ емый Iterable, но все их методы возвращают собственный класс, а не корневой класс Iterable. Например, у метода map класса List тип возвращаемого значения List, у метода map класса Set тип возвращаемого значения Set. То есть статический возвращаемый тип этих методов абсолютно точен: scala> List(1, 2, 3) map (_ + 1) res0: List[Int] = List(2, 3, 4) scala> Set(1, 2, 3) map (_ * 2) res1: scala.collection.immutable.Set[Int] = Set(2, 4, 6) Эквивалентность для всех классов коллекций также организована единообраз­ но: более подробно этот вопрос рассматривается в разделе 24.12. Большинство классов, показанных на рис. 24.1 (см. выше), существует в трех вариантах: корневом, изменяемом и неизменяемом (root, mutable и immutable). Единственное исключение — трейт Buffer, существующий только в виде изменя­ емой коллекции. Далее в главе все эти классы мы рассмотрим поочередно. 24.3. Трейт Iterable На вершине иерархии коллекций находится трейт Iterable[A], где A — тип эле­ ментов коллекции. Все методы в этом трейте определены в терминах абстрактного метода iterator, который возвращает элементы коллекции один за другим. def iterator: Iterator[A] 24.3. Трейт Iterable 461 Классам коллекций, реализующим Iterable, нужно определить только этот метод, а все остальные методы могут быть унаследованы из Iterable. В Iterable также определяется много конкретных методов (перечислены ниже, в табл. 24.1). Их можно разбить на следующие категории. Операции итерирования — foreach, grouped, sliding — перебирают все эле­ менты коллекции в порядке, который определяет ее итератор. Методы grouped и sliding возвращают итераторы, которые, в свою очередь, возвращают не от­ дельные элементы, а, скорее, подпоследовательности элементов исходной коллекции. Максимальный размер этих подпоследовательностей указывается в качестве аргумента для этих методов. Метод grouped делит полученные элементы на инкременты, а sliding формирует по ним скользящее окно. Раз­ ницу между ними должен наглядно продемонстрировать следующий сеанс интерпретатора: scala> val xs = List(1, 2, 3, 4, 5) xs: List[Int] = List(1, 2, 3, 4, 5) scala> val git = xs grouped 3 git: Iterator[List[Int]] = non-empty iterator scala> git.next() res2: List[Int] = List(1, 2, 3) scala> git.next() res3: List[Int] = List(4, 5) scala> val sit = xs sliding 3 sit: Iterator[List[Int]] = non-empty iterator scala> sit.next() res4: List[Int] = List(1, 2, 3) scala> sit.next() res5: List[Int] = List(2, 3, 4) scala> sit.next() res6: List[Int] = List(3, 4, 5) Сложение — ++ (псевдоним: concat) — складывает две коллекции вместе или добавляет все элементы итератора в коллекцию. Операции отображения — map, flatMap и collect создают новую коллекцию путем применения некой функции к элементам коллекции. Операции преобразования — toIndexedSeq, toIterable, toList, toMap, toSeq, toSet и toVector — превращают коллекцию Iterable в неизменяемую. Все эти преобразования возвращают объект-получатель, если он уже соответствует требуемому типу коллекции. Например, применение toList к списку выдаст сам 462 Глава 24 • Углубленное изучение коллекций список. Методы toArray и toBuffer возвращают новую изменяемую коллекцию, даже если объект-получатель соответствует. Метод to можно использовать для преобразования в любую другую коллекцию. Операция копирования — copyToArray . Как следует из названия, копирует элементы коллекции в массив. Операции с размером — isEmpty , nonEmpty , size , knownSize , sizeCompare и sizeIs — имеют отношение к размеру коллекции. Чтобы вычислить коли­ чество элементов коллекции, в некоторых случаях может потребоваться со­ вершить обход, например, в случае List. В других случаях коллекция может содержать бесконечное количество элементов, например LazyList.from(0). Методы knownSize, sizeCompare и sizeIs предоставляют информацию о коли­ честве элементов, обходя как можно меньшее количество элементов. Операции извлечения элементов — head , last , headOption , lastOption и find — выбирают первый или последний элемент коллекции или же первый элемент, соответствующий условию. Однако следует заметить, что не все кол­ лекции имеют четко определенное значение первого и последнего. Например, HashSet может хранить элементы в соответствии с их хеш-ключами, и порядок их следования от запуска к запуску может изменяться. В таком случае для разных запусков программы первый элемент HashSet также может быть раз­ ным. Коллекция считается упорядоченной, если всегда выдает свои элементы в одном и том же порядке. Большинство коллекций упорядочены, однако не­ которые, такие как HashSet, таковыми не являются, отказ от упорядочения позволяет им быть более эффективными. Упорядочение зачастую необхо­ димо, чтобы получать воспроизводимые тесты и способствовать отладке. Поэтому коллекции Scala предоставляют упорядоченные альтернативы для всех типов коллекций. Например, упорядоченная альтернатива для HashSet — LinkedHashSet. Операции извлечения подколлекций — takeWhile, tail, init, slice, take, drop, filter, dropWhile, filterNot и withFilter — возвращают подколлекцию, опре­ деляемую диапазоном индексов или предикатом. Операции подразделения — groupBy, groupMap, groupMapReduce, splitAt, span, partition и partitionMap — разбивают элементы переданной коллекции на не­ сколько подколлекций. Операции тестирования элементов — exists, forall и count — тестируют эле­ менты коллекции на соответствие заданному предикату. Операции свертки — foldLeft, foldRight, reduceLeft, reduceRight — применяют бинарные операции к следующим друг за другом элементам. Операции специализированных сверток — sum, product, min и max — работают с коллекциями конкретных типов (numeric или comparable). 24.3. Трейт Iterable 463 Операции со строками — mkString и addString — предоставляют альтернатив­ ные способы преобразования коллекции в строку. Операция представления — view — это коллекция, которая вычисляется лениво. Больше о представлениях вы узнаете из раздела 24.13. Таблица 24.1. Операции в трейте Iterable Что собой представляет Результат работы Абстрактный метод xs.iterator Итератор, который возвращает каждый элемент в xs Итерирование xs foreach f Применяет функцию f к каждому элементу xs. Вызов f нужен только для получения побочных эффектов; по сути, foreach отбрасывает любой результат функции f xs grouped size Итератор, который возвращает блоки коллекции фиксированного размера xs sliding size Итератор, который возвращает скользящее окно по элементам этой коллекции Сложение xs ++ ys (или xs concat ys) Коллекция, состоящая из элементов xs и ys. Элемент ys — это коллекция IterableOnce, то есть Iterable или Iterator Отображение xs map f Коллекция, получающаяся в результате применения функции f к каждому элементу в xs xs flatMap f Коллекция, получающаяся в результате применения функции f, результатом которой является коллекция, к каждому элементу в xs, и объединяющая результаты xs collect f Коллекция, получающаяся в результате применения частично примененной функции f к каждому элементу в xs, для которого она определена, и объединяющая результаты Преобразование xs.toArray Превращает коллекцию в массив xs.toList Превращает коллекцию в список xs.toIterable Превращает коллекцию в итерируемую коллекцию xs.toSeq Превращает коллекцию в последовательность Продолжение 464 Глава 24 • Углубленное изучение коллекций Таблица 24.1 (продолжение) Что собой представляет Результат работы xs.toIndexedSeq Превращает коллекцию в проиндексированную последовательность xs.toSet Превращает коллекцию во множество xs.toMap Превращает коллекцию пар «ключ — значение» в отображение xs to SortedSet Обобщенная операция преобразования, которая принимает в качестве параметра фабрику коллекций Копирование xs copyToArray(arr, s, len) Копирует максимум len элементов в массиве arr, начиная с индекса s. Последние два аргумента необязательны Получение информации о размере xs.isEmpty Проверяет, является ли коллекция пустой xs.nonEmpty Проверяет, содержит ли коллекция элементы xs.size Количество элементов в коллекции xs.knownSize Количество элементов, если его можно вычислить за постоянное время. В противном случае –1 xs sizeCompare ys Возвращает отрицательное значение, если коллекция xs короче ys, положительное, если длиннее, и 0, если обе коллекции имеют одинаковый размер. Работает даже для бесконечных коллекций xs.sizeIs < 42, xs.sizeIs != 42 и т. д. Сравнивает размер коллекции с заданным значением, перебирая как можно меньше элементов Извлечение элементов xs.head Первый элемент коллекции (или какой-нибудь элемент, если порядок следования элементов не определен) xs.headOption Первый элемент xs в Option-значении или None, если xs пуст xs.last Последний элемент коллекции (или какой-нибудь элемент, если порядок следования элементов не определен) xs.lastOption Последний элемент xs в Option-значении параметра или None, если xs пуст xs find p Option-значение, содержащее первый элемент в xs, удовлетворяющий условию p, или None, если такого элемента нет Создание подколлекций xs.tail Вся коллекция, за исключением xs.head xs.init Вся коллекция, за исключением xs.last xs.slice(from, to) Коллекция, состоящая из элементов в некотором диапазоне индексов xs (от from включительно до to не включительно) 24.3. Трейт Iterable 465 Что собой представляет Результат работы xs take n Коллекция, состоящая из первых n элементов xs (или n каких-либо произвольных элементов, если порядок следования элементов не определен) xs drop n Вся коллекция xs за вычетом тех элементов, которые получаются при использовании выражения xs take n xs takeWhile p Самый длинный префикс элементов в коллекции, каждый из которых соответствует условию p xs dropWhile p Коллекция без самого длинного префикса элементов, каждый из которых соответствует условию p xs takeRight n Коллекция, состоящая из заключительных n элементов xs (или произвольных n элементов, если порядок не определен) xs dropRight n Вся коллекция, кроме xs takeRight n xs filter p Коллекция, состоящая только из тех элементов xs, которые соответствуют условию p xs withFilter p Нестрогий фильтр коллекции. Все операции в отношении фильтруемых элементов будут применяться только к тем элементам xs, для которых условие p вычисляется в true xs filterNot p Коллекция, состоящая из тех элементов xs, которые не соответствуют условию p Слияние xs zip ys Итерируемые пары соответствующих элементов из xs и ys xs lazyZip ys Значение, предоставляющее методы для поэлементной работы с коллекциями xs и ys. См. раздел 16.9 xs.zipAll(ys, x, y) Итерируемые пары соответствующих элементов из xs и ys; если одна из последовательностей короче, то расширяется за счет добавления элементов x или y xs.zipWithIndex Итерируемые пары элементов из xs с их индексами Разбиение xs splitAt n Разбивает xs по позиции на пару коллекций (xs take n, xs drop n) xs span p Разбивает xs в соответствии с предикатом на пару коллекций (xs takeWhile p, xs.dropWhile p) xs partition p Разбивает xs на пару коллекций, в одной из которых находятся все элементы, удовлетворяющие условию p, а в другой — все элементы, не удовлетворяющие этому условию (xs filter p, xs.filterNot p) Продолжение 466 Глава 24 • Углубленное изучение коллекций Таблица 24.1 (продолжение) Что собой представляет Результат работы xs partitionMap f Преобразует каждый элемент xs в значение Either[X, Y] и разбивает результат на две коллекции: одна с элементами из Left, а другая — с элементами из Right xs groupBy f Разбивает xs на отображение коллекций в соответствии с функцией-дискриминатором f xs.groupMap(f)(g) Превращает xs в отображение коллекций в соответствии с функцией-дискриминатором f и применяет функцию преобразования g к каждому элементу каждой коллекции xs.groupMapReduce(f)(g)(h) Превращает xs в отображение коллекций в соответствии с функцией-дискриминатором f, применяет функцию преобразования g к каждому элементу каждой коллекции и сводит каждую коллекцию к единственному значению, объединяя ее элементы с помощью функции h Состояние элементов xs forall p Булево значение, показывающее, соблюдается ли условие p для всех элементов xs xs exists p Булево значение, показывающее, соблюдается ли условие p для каких-либо элементов xs xs count p Количество элементов в xs, удовлетворяющих условию p Свертка xs.foldLeft(z)(op) Применяет бинарную операцию op к последовательным элементам xs, продвигаясь слева направо, начиная с z xs.foldRight(z)(op) Применяет бинарную операцию op к последовательным элементам xs, продвигаясь справа налево, начиная с z xs reduceLeft op Применяет бинарную операцию op к последовательным элементам xs непустой коллекции xs, продвигаясь слева направо xs reduceRight op Применяет бинарную операцию op к последовательным элементам xs непустой коллекции xs, продвигаясь справа налево Специализированная свертка xs.sum Сумма числовых элементов коллекции xs xs.product Произведение числовых элементов коллекции xs xs.min Минимальное значение упорядочиваемых элементов коллекции xs xs.max Максимальное значение упорядочиваемых элементов коллекции xs 24.4. Трейты последовательностей Seq, IndexedSeq и LinearSeq 467 Что собой представляет Результат работы Строки xs addString (b, start, sep, end) Добавляет строку к строковому буферу b типа StringBuilder со всеми элементами xs с разделителями sep, заключенными между строками start и end. Аргументы start, sep и end являются необязательными xs mkString (start, sep, end) Превращает коллекцию в строку со всеми элементами xs с разделителями sep, заключенными между строками start и end. Аргументы start, sep и end являются необязательными Представления xs.view Создает представление xs Подкатегории Iterable В иерархии наследования ниже Iterable находятся три трейта: Seq, Set и Map. Трейты Seq и Map объединяет то, что они реализуют трейт PartialFunction1 с ме­ тодами apply и isDefinedAt. Но способы реализации PartialFunction у каждого трейта свои. Для последовательностей apply — позиционное индексирование, в котором элементы всегда нумеруются с нуля. То есть Seq(1, 2, 3)(1) == 2. Для множеств apply — проверка на принадлежность. Например, Set('a', 'b', 'c')('b') == true, а Set()('a') == false. И наконец, для отображений apply — средство выбора. Например, Map('a' ‑> 1, 'b' -> 10, 'c' -> 100)('b') == 10. Более подробно каждый из этих видов коллекций мы рассмотрим в следующих трех разделах. 24.4. Трейты последовательностей Seq, IndexedSeq и LinearSeq Трейт Seq представляет последовательности. Последовательностью называется разновидность итерируемой коллекции, у которой есть длина и все элементы кото­ рой имеют фиксированные индексированные позиции, отсчет которых начинается с нуля. Операции с последовательностями (сведены в табл. 24.2 ниже) разбиваются на следующие категории. Операции индексирования и длины — apply, isDefinedAt , length , indices lengthCompare и lengthIs. Для Seq операция apply означает индексирование, 1 Частично определенные функции описаны в разделе 15.7. 468 Глава 24 • Углубленное изучение коллекций поэтому последовательность типа Seq[T] является частично примененной функ­ цией, которая получает аргумент типа Int (индекс) и выдает элемент последова­ тельности типа T. Иными словами, Seq[T] расширяет PartialFunction[Int, T]. Элементы последовательности индексируются от нуля до длины последова­ тельности length за вычетом единицы. Метод length, применяемый в отноше­ нии последовательностей, — псевдоним общего для коллекций метода size. Метод lengthCompare позволяет сравнивать длины двух последовательностей, даже если одна из них имеет бесконечную длину. Метод lengthIs — псевдоним метода sizeIs. Операции поиска индекса — indexOf , lastIndexOf , indexOfSlice , lastIn­ dexOfSlice, indexWhere, lastIndexWhere, segmentLength и prefixLength — воз­ вращают индекс элемента, равного заданному значению или соответствующего некоему условию. Операции добавления — +: (псевдоним: prepended ), ++: (псевдоним: pre­ pendedAll), :+ (псевдоним: appended), :++ (псевдоним: appendedAll) и padTo — возвращают новые последовательности, получаемые путем добавления к началу или концу последовательности. Операции обновления — updated и patch — возвращают новую последова­ тельность, получаемую путем замены некоторых элементов исходной после­ довательности. Операции сортировки — sorted, sortWith и sortBy — сортируют элементы по­ следовательности в соответствии с различными критериями. Операции реверсирования — reverse и reverseIterator — обрабатывают или возвращают элементы последовательности в обратном порядке, с последнего до первого. Операции сравнения — startsWith , endsWith , contains , corresponds , con­ tainsSlice и search — определяют соотношение двух последовательностей или ищут элемент в последовательности. Операции над множествами — intersect, diff, distinct и distinctBy — вы­ полняют над элементами двух последовательностей операции, подобные опе­ рациям с множествами, или удаляют дубликаты. Для изменяемой последовательности предлагается дополнительный ме­ тод добавления update с побочным эффектом, который позволяет элементам последовательности обновляться. Вспомним: в главе 3 мы уже говорили, что синтаксис вида seq(idx) = elem — не более чем сокращение для выражения seq.update(idx, elem). Обратите внимание на разницу между методами update и updated. Первый изменяет элемент последовательности на месте и доступен только для изменяемых последовательностей. Второй доступен для всех по­ следовательностей и всегда вместо изменения исходной возвращает новую по­ следовательность. 24.4. Трейты последовательностей Seq, IndexedSeq и LinearSeq 469 Таблица 24.2. Операции в трейте Seq Что собой представляет Результат работы Индексирование и длина xs(i) (Или после раскрытия xs apply i) элемент xs с индексом i xs isDefinedAt i Проверяет, содержится ли i в xs.indices xs.length Длина последовательности (то же самое, что и size) xs.lengthCompare len Возвращает отрицательное значение Int, если длина xs меньше len, положительное, если больше, и 0, если они равны. Работает даже для бесконечных коллекций xs xs.indices Диапазон индексов xs от 0 до xs.length – 1 Поиск индекса xs indexOf x Индекс первого элемента в xs, равного значению x (существует несколько вариантов) xs lastIndexOf x Индекс последнего элемента в xs, равного значению x (существует несколько вариантов) xs indexOfSlice ys Первый индекс xs при условии, что следующие друг за другом элементы, начинающиеся с элемента с этим индексом, составляют последовательность ys xs lastIndexOfSlice ys Последний индекс xs при условии, что следующие друг за другом элементы, начинающиеся с элемента с этим индексом, составляют последовательность ys xs indexWhere p Индекс первого элемента в xs, удовлетворяющего условию p (существует несколько вариантов) xs segmentLength (p, i) Длина самого длинного непрерывного сегмента элементов в xs, начинающегося с xs(i), который удовлетворяет условию p Добавление x +: xs (или xs prepended x) Новая последовательность со значением x, добавленным в начало xs ys ++: xs (или xs prependedAll ys) Новая последовательность, состоящая из всех элементов ys, добавленных в начало xs xs :+ x (или xs appended x) Новая последовательность со значением x, добавленным в конец xs xs :++ ys (или xs appendedAll ys) Новая последовательность, состоящая из всех элементов ys, добавленных в конец xs. То же самое, что xs ++ ys xs padTo (len, x) (или xs prepended x) Последовательность, получающаяся при добавлении значения x к xs, до тех пор пока длина не достигнет значения len Обновление xs.patch(i, ys, r) Последовательность, получающаяся путем замены r элементов последовательности xs, начиная с i, элементами последовательности ys Продолжение 470 Глава 24 • Углубленное изучение коллекций Таблица 24.2 (продолжение) Что собой представляет Результат работы xs.updated(i, x) Копия xs, в которой элемент с индексом i заменяется значением x xs(i) = x (Или после раскрытия xs.update(i, x), которая доступна только для изменяемых последовательностей mutable.Seq) изменяет значение элемента xs с индексом i на значение x Сортировка xs.sorted Новая последовательность, полученная путем сортировки элементов xs в порядке следования элементов типа xs xs sortWith lessThan Новая последовательность, полученная путем сортировки элементов xs с использованием в качестве операции сравнения lessThan (меньше чем) xs sortBy f Новая последовательность, полученная путем сортировки элементов xs. Сравнение двух элементов выполняется путем применения к ним функции f и сравнения результатов Реверсирование xs.reverse Последовательность из элементов xs, следующих в обратном порядке xs.reverseIterator Итератор, выдающий все элементы xs в обратном порядке Сравнение xs sameElememts ys Проверяет, содержат ли xs и ys одинаковые элементы в одном и том же порядке xs startsWith ys Проверяет, не начинается ли xs с последовательности ys (имеется несколько вариантов) xs endsWith ys Проверяет, не заканчивается ли xs последовательностью ys (имеется несколько вариантов) xs contains x Проверяет, имеется ли в xs элемент, равный x xs search x Проверяет, содержит ли отсортированная коллекция xs элемент, равный x, и делает это потенциально эффективнее, чем xs contains x xs containsSlice ys Проверяет, имеется ли в xs непрерывная последовательность, равная ys xs.corresponds(ys)(p) Проверяет, удовлетворяют ли соответствующие элементы xs и ys бинарному предикату p Операции над множествами xs intersect ys Пересечение множеств, состоящих из элементов последовательностей xs и ys, которое сохраняет порядок следования элементов в xs xs diff ys Разность множеств, состоящих из элементов последовательностей xs и ys, которое сохраняет порядок следования элементов в xs 24.4. Трейты последовательностей Seq, IndexedSeq и LinearSeq 471 Что собой представляет Результат работы xs.distinct Часть последовательности xs, не содержащая дубликатов xs.distinctBy f Подпоследовательность xs, в которой после применения преобразующей функции f нет повторяющихся элементов У каждого трейта Seq есть два подтрейта: LinearSeq и IndexedSeq. Они не до­ бавляют никаких новых операций, но каждый их них предлагает разные характе­ ристики производительности. У линейной последовательности (linear sequence) есть эффективные операции head и tail, а у индексированной — эффективные операции apply, length и (если последовательность изменяемая) update. В List зачастую применяется линейная последовательность, как и в LazyList. Предста­ вители двух часто используемых индексированных последовательностей — Array и ArrayBuffer. Класс Vector обеспечивает весьма интересный компромисс между индексированным и последовательным доступом. У него практически постоянные линейные издержки как на индексированный, так и на последовательный доступ. Поэтому векторы служат хорошей основой для смешанных схем доступа, в которых используется как индексированный, так и последовательный доступ. Более по­ дробно мы рассмотрим векторы в разделе 24.7. Изменяемый подтрейт IndexedSeq добавляет операции для преобразования име­ ющихся элементов. В отличие от map и sort, которые доступны в Seq, эти операции, представленные в табл. 24.3, не возвращают новый экземпляр коллекции. Таблица 24.3. Операции в трейте mutable.IndexedSeq Что собой представляет Результат работы Добавление xs mapInPlace f Преобразует все элементы xs, применяя к каждому из них функцию f xs.sortInPlace() Сортирует элементы xs на месте xs sortInPlaceBy f Сортирует элементы xs на месте и в соответствии с порядком, который определяется путем применения функции f к каждому элементу xs sortInPlaceWith c Сортирует элементы xs на месте в соответствии с функцией сравнения c Буферы Важная подкатегория изменяемых последовательностей — буферы. Они позволяют не только обновлять существующие элементы, но и вставлять и удалять элементы, а также с высокой эффективностью добавлять новые элементы в конец буфера. Принципиально новые методы, поддерживаемые буфером, — += (псевдоним: append) и ++= (псевдоним: appendAll) для добавления элементов в конец буфера, 472 Глава 24 • Углубленное изучение коллекций +=: (псевдоним: prepend) и ++=: (псевдоним: prependAll) для добавления элемен­ тов в начало буфера, insert и insertAll для вставки элементов, а также remove, -= (псевдоним: subtractOne) и --= (псевдоним: subtractAll) для удаления элементов. Все эти операции сведены в табл. 24.4. Таблица 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 в то место в буфере, на которое указывает индекс i buf insertAll (i, xs) Вставляет все элементы xs в то место в буфере, на которое указывает индекс i buf.padToInPlace(n, x) Добавляет в буфер элементы x, пока общее количество его элементов не достигнет n Удаление buf –= x (или buf subtractOne x) Удаляет из буфера элемент x buf ––= x (или buf subtractAll xs) Удаляет из буфера все элементы xs buf remove i Удаляет из буфера элемент с индексом i buf remove (i, n) Удаляет из буфера n элементов, начиная с элемента с индексом i buf trimStart n Удаляет из буфера первые n элементов buf trimEnd n Удаляет из буфера последние n элементов buf.clear() Удаляет из буфера все элементы Замена buf.patchInPlace(i, xs, n) Заменяет (максимум) n элементов буфера элементами из xs, начиная с индекса i Копирование buf.clone Новый буфер с теми же элементами, что и в buf 24.5. Множества 473 Наиболее часто используются две реализации буферов: ListBuffer и ArrayBuffer. Исходя из названий, ListBuffer базируется на классе List и поддерживает высо­ коэффективное преобразование своих элементов в список, а ArrayBuffer базиру­ ется на массиве и может быть быстро превращен в массив. Наметки реализации ListBuffer мы показали в разделе 22.2. 24.5. Множества Коллекции Set — это итерируемые Iterable-коллекции, которые не содержат по­ вторяющихся элементов. Общие операции над множествами сведены в табл. 24.5, в табл. 24.6 показаны операции для неизменяемых множеств, а в табл. 24.7 — опе­ рации для изменяемых множеств. Операции разбиты на следующие категории. Операции проверки — contains, apply и subsetOf. Метод contains показывает, содержит ли множество заданный элемент. Метод apply для множества является аналогом contains, поэтому set(elem) — то же самое, что и set contains elem. Следовательно, множества могут также использоваться в качестве тестовых функций, возвращающих true для содержащихся в них элементов, например: scala> val fruit = Set("apple", "orange", "peach", "banana") fruit: scala.collection.immutable.Set[String] = Set(apple, orange, peach, banana) scala> fruit("peach") res7: Boolean = true scala> fruit("potato") res8: Boolean = false Операции добавления — + (псевдоним: incl) и ++ (псевдоним: concat) — до­ бавляют во множество один и более элементов, возвращая в качестве результата новое множество. Операции удаления — - (псевдоним: excl) и -- (псевдоним: removedAll) — уда­ ляют из множества один и более элементов, возвращая новое множество. Операции над множествами для объединения, пересечения и разности мно­ жеств. Существуют в двух формах: текстовом и символьном. К текстовым от­ носятся версии intersect, union и diff, а к символьным — &, | и & ~. Оператор ++, наследуемый Set из Iterable, может рассматриваться в качестве еще одного псевдонима union или |, за исключением того, что ++ получает IterableOnceаргумент, а union и | получают множества. Неизменяемые множества предлагают методы добавления и удаления элемен­ тов путем возвращения новых множеств, которые сведены в табл. 24.6. У изменяемых множеств есть методы, которые добавляют, удаляют и обновляют элементы, которые сведены в табл. 24.7. 474 Глава 24 • Углубленное изучение коллекций Таблица 24.5. Операции в трейте Set Что собой представляет Результат работы Проверка xs contains x Проверяет, является ли x элементом xs xs(x) Делает то же самое, что и xs contains x xs subsetOf ys Проверяет, является ли xs подмножеством ys Удаление xs.empty Пустое множество того же класса, что и xs Бинарные операции xs & ys (или xs intersect ys) Пересечение множеств xs и ys xs | ys (или xs union ys) Объединение множеств xs и ys xs & ~ ys (или xs diff ys) Разность множеств xs и ys Таблица 24.6. Операции в трейте immutable.Set Что собой представляет Результат работы Добавление xs + x (или xs incl x) Множество, содержащее все элементы xs и элемент x xs ++ ys (или xs concat ys) Множество, содержащее все элементы xs и все элементы ys Удаление xs – x (или xs excl x) Множество, содержащее все элементы xs, кроме x xs –– ys (или xs removedAll ys) Множество, содержащее все элементы xs, кроме элементов множества ys Таблица 24.7. Операции в трейте mutable.Set Что собой представляет Результат работы Добавление xs += x (или xs addOne x) Добавляет элемент x во множество xs как побочный эффект и возвращает само множество xs xs ++= ys (или xs addAll ys) Добавляет все элементы ys во множество xs как побочный эффект и возвращает само множество xs xs add x Добавляет элемент x в xs и возвращает true, если x прежде не был во множестве, или false, если уже был 24.5. Множества 475 Что собой представляет Результат работы Удаление 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, которые удовлетворяют условию p xs.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: scala> var s = Set(1, 2, 3) s: scala.collection.immutable.Set[Int] = Set(1, 2, 3) scala> s += 4; s -=2 scala> s res10: scala.collection.immutable.Set[Int] = Set(1, 3, 4) В этом примере в отношении var-переменной типа immutable.Set используются методы += и -=. Согласно объяснениям, которые были даны на шаге 10 в главе 3, инструкции вида s += 4 — это сокращенная форма записи для s = s + 4. Следова­ тельно, в их выполнении участвует еще один метод +, применяемый в отношении множества s, а затем результат присваивается переменной s. А теперь рассмотрим аналогичную работу в интерпретаторе с изменяемым множеством: scala> val s = collection.mutable.Set(1, 2, 3) s: scala.collection.mutable.Set[Int] = Set(1, 2, 3) 476 Глава 24 • Углубленное изучение коллекций scala> s += 4 res11: s.type = Set(1, 2, 3, 4) scala> s -= 2 res12: s.type = Set(1, 3, 4) Конечный эффект очень похож на предыдущий диалог с интерпретатором: на­ чинаем мы с множеством Set(1, 2, 3), а заканчиваем с множеством Set(1, 3, 4). Но даже притом, что инструкции выглядят такими же, как и раньше, они вы­ полняют несколько иные действия. Теперь инструкция s += 4 вызывает метод += в отношении значения s, которое представляет собой изменяемое множество, вы­ полняя изменения на месте. Аналогично этому инструкция s -= 2 теперь вызывает в отношении этого же множества метод -=. Сравнение этих двух диалогов позволяет выявить весьма важный принцип. Зачастую можно заменить изменяемую коллекцию, хранящуюся в val-переменной, неизменяемой коллекцией, хранящейся в var-переменной, и наоборот. Это работает по крайней мере до тех пор, пока нет псевдонимов ссылок на коллекцию, позволя­ ющих заметить, обновилась она на месте или была создана новая коллекция. Изменяемые множества также предоставляют в качестве вариантов += и -= методы add и remove. Разница в том, что методы add и remove возвращают булев результат, показывающий, возымела ли операция эффект над множеством. В текущей реализации по умолчанию изменяемого множества его элементы хранятся с помощью хеш-таблицы. В реализации по умолчанию неизменяемых множеств используется представление, которое адаптируется к количеству эле­ ментов множества. Пустое множество представляется в виде простого объектаодиночки. Множества размером до четырех элементов представляются в виде одиночного объекта, сохраняющего все элементы как поля. Все неизменяемые множества, имеющие большие размеры, реализуются в виде префиксных дере­ вьев на основе сжатых хеш-массивов (compressed hash-array mapped prefix trie, CHAMP)1. Последствия применения таких вариантов представления заключаются в том, что для множеств небольших размеров с количеством элементов, не превышающим четырех, неизменяемые множества получаются более компактными и более эффек­ тивными в работе, чем изменяемые. Поэтому, если предполагается, что множество будет небольшим, попробуйте сделать его неизменяемым. 24.6. Отображения Коллекции типа Map представляют собой Iterable-коллекции, состоящие из пар «ключ — значение», которые также называются отображениями или ассоциациями. Как говорилось в разделе 21.4, имеющийся в Scala объект Predef предлагает неявное преобразование, позволяющее использовать запись вида ключ –> значение в каче­ 1 Префиксные деревья на основе сжатых хеш-массивов описываются в разделе 24.7. 24.6. Отображения 477 стве альтернативы синтаксиса для пары вида (ключ, значение). Таким образом, выражение Map("x" ‑> 24, "y" ‑> 25, "z" ‑> 26) означает абсолютно то же самое, что и выражение Map(("x", 24), ("y", 25), ("z", 26)), но читается легче. Основные операции над отображениями, сведенные в табл. 24.8, похожи на ана­ логичные операции над множествами. Неизменяемые отображения поддерживают дополнительные операции добавления и удаления, которые возвращают новые отображения, как показано в табл. 24.9. Изменяемые отображения дополнительно поддерживают операции, перечисленные в табл. 24.10. Операции над отображения­ ми разбиваются на следующие категории. Операции поиска — apply, get, getOrElse, contains и isDefinedAt — превра­ щают отображения в частично примененные функции от ключей к значениям. Основной метод поиска для отображений выглядит так: def get(key): Option[Value] Операция m get key проверяет, содержит ли отображение ассоциацию для заданного ключа. Будучи в наличии, такая ассоциация возвращает значение ассоциации в виде объекта типа Some. Если такой ключ в отображении не опре­ делен, то get возвращает None. В отображениях также определяется метод apply, возвращающий значение, непосредственно ассоциированное с заданным клю­ чом, без его инкапсуляции в Option. Если ключ в отображении не определен, то выдается исключение. Операции добавления и обновления — + (псевдоним: updated), ++ (псевдоним: concat) updateWith, и updatedWith — позволяют добавлять к отображению новые привязки или изменять уже существующие. Операции удаления — - (псевдоним: removed) и -- (псевдоним: removedAll) — позволяют удалять привязки из отображения. Операции создания подколлекций — keys, keySet, keysIterator, valuesIterator и values — возвращают по отдельности ключи и значения отображений в раз­ личных формах. Операции преобразования — filterKeys и mapValues — создают новое отображе­ ние путем фильтрации и преобразования привязок существующего отображения. Таблица 24.8. Операции в трейте Map Что собой представляет Результат работы Поиск ms get k Значение Option, связанное с ключом k в отображении ms, или None, если ключ не найден ms(k) (Или после раскрытия ms apply k) значение, связанное с ключом k в отображении ms, или выдает исключение, если ключ не найден Продолжение 478 Глава 24 • Углубленное изучение коллекций Таблица 24.8 (продолжение) Что собой представляет Результат работы ms getOrElse (k, d) Значение, связанное с ключом k в отображении ms, или значение по умолчанию d, если ключ не найден ms contains k Проверяет, содержится ли в ms отображение для ключа k ms isDefinedAt k То же, что и contains Создание подколлекций ms.keys Iterable-коллекция, содержащая каждый ключ, имеющийся в ms ms.keySet Множество, содержащее каждый ключ, имеющийся в ms ms.keysIterator Итератор, выдающий каждый ключ, имеющийся в ms ms.values Iterable-коллекция, содержащая каждое значение, связанное с ключом в ms ms.valuesIterator Итератор, выдающий каждое значение, связанное с ключом в ms Преобразование ms.view filterKeys p Представление отображения, содержащее только те отображения в ms, в которых ключ удовлетворяет условию p ms.view mapValues f Представление отображения, получающееся в результате применения функции f к каждому значению, связанному с ключом в ms Таблица 24.9. Операции в трейте immutable.Map Что собой представляет Результат работы Добавление и обновление ms + (k –> v) (или ms.updated(k, v)) Отображение, содержащее все ассоциации ms, а также ассоциацию k –> v ключа k со значением v ms ++= kvs (или ms.concat(kvs)) Отображение, содержащее все ассоциации ms, а также все пары «ключ — значение» из kvs ms.updatedWith(k)(f) Отображение с добавлением, обновлением или удалением привязки для ключа k. Функция f принимает в качестве параметра значение, связанное в настоящий момент с ключом k (или None, если такой привязки нет), и возвращает новое значение (или None для удаления привязки) Удаление ms – k (или ms removed k) Отображение, содержащее все ассоциации ms, за исключением тех, которые относятся к ключу k ms –– ks (или ms removedAll ks) Отображение, содержащее все ассоциации ms, за исключением тех, ключи которых входят в ks 24.6. Отображения 479 Таблица 24.10. Операции в трейте mutable.Map Что собой представляет Результат работы Добавление и обновление ms(k) = v (Или после раскрытия ms.update(k, v) ) добавляет в качестве побочного эффекта ассоциацию ключа k со значением v к отображению ms, перезаписывая все ранее имевшиеся ассоциации k ms += (k -> v) Добавляет в качестве побочного эффекта ассоциацию ключа k со значением v к отображению ms и возвращает само отображение ms ms ++= kvs Добавляет в качестве побочного эффекта все ассоциации, имеющиеся в kvs, к ms и возвращает само отображение ms ms.put(k, v) Добавляет к ms ассоциацию ключа k со значением v и возвращает как Option любое значение, ранее связанное с k ms getOrElseUpdate (k, d) Если ключ k определен в отображении ms, то возвращает связанное с ним значение. В противном случае обновляет ms ассоциацией k –> d и возвращает d 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 любое значение, ранее связанное с k ms filterInPlace p Сохраняет в ms только те ассоциации, у которых ключ удовлетворяет условию p ms.clear() Удаляет из ms все ассоциации Преобразование и клонирование ms mapValuesInPlace f Выполняет преобразование всех связанных значений в отображении ms с помощью функции f ms.clone() Возвращает новое изменяемое отображение с такими же ассоциациями, как и в ms Операции добавления и удаления для отображений — зеркальные отраже­ ния таких же операций для множеств. Неизменяемое отображение может быть преобразовано с помощью операций +, - и updated. Для сравнения, изменяемое 480 Глава 24 • Углубленное изучение коллекций отображение m можно обновить «на месте» двумя способами: m(key) = value и m += (key -> value). Изменяемые отображения также поддерживают вариант m.put(key, value), который возвращает значение Option, содержащее то, что пре­ жде ассоциировалось с ключом, или None, если ранее такой ключ в отображении отсутствовал. Метод getOrElseUpdate пригодится для обращения к отображениям там, где они действуют в качестве кэша. Скажем, у вас есть весьма затратное вычисление, запускаемое путем вызова функции f: scala> def f(x: String) = { println("taking my time."); Thread.sleep(100) x.reverse } f: (x: String)String Далее предположим, что у f нет побочных эффектов, поэтому ее повторный вызов с тем же самым аргументом всегда будет выдавать тот же самый результат. В таком случае можно сберечь время, сохранив ранее вычисленные привязки аргумента и результата выполнения f в отображении, и вычислять результат вы­ полнения f, только если результат для аргумента не был найден в отображении. Можно сказать, что отображение — это кэш для вычислений функции f: scala> val cache = collection.mutable.Map[String, String]() cache: scala.collection.mutable.Map[String,String] = Map() Теперь можно создать более эффективную кэшированную версию функции f: scala> def cachedF(s: String) = cache.getOrElseUpdate(s, f(s)) cachedF: (s: String)String scala> cachedF("abc") taking my time. res16: String = cba scala> cachedF("abc") 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 result } 24.7. Конкретные классы неизменяемых коллекций 481 24.7. Конкретные классы неизменяемых коллекций В Scala на выбор предлагается множество конкретных классов неизменяемых кол­ лекций. Друг от друга они отличаются трейтами, которые реализуют (отображения, множества, последовательности), тем, могут ли они быть бесконечными, и скоро­ стью выполнения различных операций. Начнем с обзора наиболее востребованных типов неизменяемых коллекций. Списки Списки относятся к конечным неизменяемым последовательностям. Они обе­ спечивают постоянное время доступа к своим первым элементам, а также ко всей остальной части списка, и имеют постоянное время выполнения операций cons для добавления нового элемента в начало списка. Многие другие операции имеют линейную зависимость времени выполнения от длины списка. Более подробно списки рассматриваются в главах 16 и 22. Ленивые списки Ленивые списки похожи на списки, за исключением того, что их элементы вы­ числяются лениво (или отложенно). Вычисляться будут только запрошенные элементы. Поэтому ленивые списки могут быть бесконечно длинными. Во всем остальном они имеют такие же характеристики производительности, что и списки. В то время как списки конструируются с помощью оператора :: , ленивые конструируются с помощью похожего оператора #::. Пример ленивого списка, содержащего целые числа 1, 2 и 3, выглядит следующим образом: scala> val str = 1 #:: 2 #:: 3 #:: LazyList.empty str: scala.collection.immutable.LazyList[Int] = LazyList(<not computed>) «Голова» этого ленивого списка — 1, а «хвост» — 2 и 3. Но никакие элементы здесь не выводятся, поскольку список еще не вычислен! Ленивые списки объявле­ ны как вычисляющиеся лениво, и метод toString, вызванный в отношении такого списка, заботится о том, чтобы не навязывать лишние вычисления. Далее показан более сложный пример. В нем вычисляется ленивый список, содержащий последовательность чисел Фибоначчи с заданными двумя числами. Каждый элемент последовательности Фибоначчи есть сумма двух предыдущих элементов в серии: scala> def fibFrom(a: Int, b: Int): LazyList[Int] = a #:: fibFrom(b, a + b) fibFrom: (a: Int, b: Int)LazyList[Int] 482 Глава 24 • Углубленное изучение коллекций Эта функция кажется обманчиво простой. Понятно, что первый элемент после­ довательности — a, за ним стоит последовательность Фибоначчи, начинающаяся с b, затем — элемент со значением a + b. Самое сложное здесь — вычислить данную последовательность, не вызвав бесконечную рекурсию. Если функция вместо #:: использует оператор ::, то каждый вызов функции будет приводить к еще одно­ му вызову, что выльется в бесконечную рекурсию. Но поскольку применяется оператор #::, правая часть выражения не вычисляется до тех пор, пока не будет востребована. Вот как выглядят первые несколько элементов последовательности Фибоначчи, начинающейся с двух заданных чисел: scala> val fibs = fibFrom(1, 1).take(7) fibs: scala.collection.immutable.LazyList[Int] = LazyList(<not computed>) scala> fibs.toList res23: List[Int] = List(1, 1, 2, 3, 5, 8, 13) Неизменяемые ArraySeq Списки очень эффективны в алгоритмах, которые работают исключительно с на­ чальными элементами. Получение, добавление и удаление начала списка занимает постоянное время. А вот получение или изменения последующих элементов — это операции линейного времени, зависящие от длины списка. В результате список может быть не самым оптимальным вариантом для алгоритмов, которые не огра­ ничиваются обработкой начальных элементов. Последовательный массив (ArraySeq ) — это неизменяемая последователь­ ность на основе приватного массива, которая решает проблему неэффективного произвольного доступа в списках. Последовательные массивы позволяют полу­ чить любой элемент коллекции за постоянное время. Благодаря этому вы можете не ограничиваться работой только с начальными элементами. Время получения элемента не зависит от его местоположения, и потому ArraySeq может оказаться эффективней списков в некоторых алгоритмах. С другой стороны, тип ArraySeq основан на Array, поэтому добавление элемен­ тов в начало занимает линейное время, а не постоянное, как в случае со списками. Более того, на всякое добавление или обновление одного элемента в ArraySeq уходит линейное время, поскольку при этом копируется весь внутренний массив. Векторы List и ArraySeq — структуры данных, эффективные для одних вариантов исполь­ зования и неэффективные для других. Например, добавление элемента в начало List занимает постоянное время, а в ArraySeq — линейное. С другой стороны, ин­ дексированный доступ занимает постоянное время в ArraySeq и линейное в List. 24.7. Конкретные классы неизменяемых коллекций 483 Вектор обеспечивает хорошую производительность для всех своих операций. Доступ и обновление любых элементов вектора занимает «эффективно постоянное время», как описано ниже. Эти операции выполняются медленнее, чем получение начала списка или чтение элементов в последовательном массиве, но время их вы­ полнения остается постоянным. В результате алгоритмы, использующие векторы, не должны ограничиваться доступом или обновлением только к началу последо­ вательности. Они могут обращаться к элементам и обновлять их в произвольном месте и поэтому могут быть гораздо более удобными в написании. Создаются и изменяются векторы точно так же, как и любые другие последо­ вательности: scala> val vec = scala.collection.immutable.Vector.empty vec: scala.collection.immutable.Vector[Nothing] = Vector() scala> val vec2 = vec :+ 1 :+ 2 vec2: scala.collection.immutable.Vector[Int] = Vector(1, 2) scala> val vec3 = 100 +: vec2 vec3: scala.collection.immutable.Vector[Int] = Vector(100, 1, 2) scala> vec3(0) res24: Int = 100 Для представления векторов используются широкие неглубокие деревья. Каж­ дый узел дерева содержит до 32 элементов вектора или до 32 других узлов дерева. Векторы, содержащие до 32 элементов, могут быть представлены одним узлом. Векторы с количеством элементов до 32 · 32 = 1024 могут быть представлены в виде одного направления. Двух переходов от корня дерева к конечному элементу доста­ точно для векторов, имеющих до 215 элементов, трех — для векторов с 220, четырех — для векторов с 225 элементов, а пяти — для векторов с количеством элементов до 230. Следовательно, для всех векторов разумного размера выбор элемента требует до пяти обычных доступов к массиву. Именно это мы имели в виду, когда написали «эффективно постоянное время». Векторы неизменяемы, следовательно, внести в элемент вектора изменение на месте невозможно. Но метод updated позволяет создать новый вектор, отлича­ ющийся от заданного только одним элементом: scala> val vec = Vector(1, 2, 3) vec: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3) scala> vec updated (2, 4) res25: scala.collection.immutable.Vector[Int] = Vector(1, 2, 4) scala> vec res26: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3) Последняя строка данного кода показывает, что вызов метода updated никак не повлиял на исходный вектор vec. Функциональное обновление векторов также занимает «эффективно постоянное время». Обновить элемент в середине вектора 484 Глава 24 • Углубленное изучение коллекций можно с помощью копирования узла, содержащего элемент, и каждого указыва­ ющего на него узла, начиная с корня дерева. Это значит, функциональное обновле­ ние создает от одного до пяти узлов, каждый из которых содержит до 32 элементов или поддеревьев. Конечно, это гораздо затратнее обновления на месте в изменяе­ мом массиве, но все же намного дешевле копирования всего вектора. Векторы сохраняют разумный баланс между быстрым произвольным доступом и быстрыми произвольными функциональными обновлениями, поэтому в насто­ ящий момент они представляют исходную реализацию неизменяемых индексиро­ ванных последовательностей: scala> collection.immutable.IndexedSeq(1, 2, 3) res27: scala.collection.immutable.IndexedSeq[Int] = Vector(1, 2, 3) Неизменяемые очереди В очередях используется принцип «первым пришел, первым вышел». Упрощенная реализация неизменяемых очередей рассматривалась в главе 19. Создать пустую неизменяемую очередь можно следующим образом: scala> val empty = scala.collection.immutable.Queue[Int]() empty: scala.collection.immutable.Queue[Int] = Queue() Добавить элемент в неизменяемую очередь можно с помощью метода enqueue: scala> val has1 = empty.enqueue(1) has1: scala.collection.immutable.Queue[Int] = Queue(1) Чтобы добавить в очередь сразу несколько элементов, следует вызвать метод enqueueAll с коллекцией в качестве его аргументов: scala> val has123 = has1.enqueueAll(List(2, 3)) has123: scala.collection.immutable.Queue[Int] = Queue(1, 2, 3) Чтобы удалить элемент из начала очереди, следует воспользоваться методом dequeue: scala> val (element, has23) = has123.dequeue element: Int = 1 has23: scala.collection.immutable.Queue[Int] = Queue(2, 3) Обратите внимание: dequeue возвращает пару, состоящую из удаленного эле­ мента и оставшейся части очереди. Диапазоны Диапазон представляет собой упорядоченную последовательность целых чисел с одинаковым интервалом между ними. Например, 1, 2, 3 является диапазоном точно так же, как и 5, 8, 11, 14. Чтобы создать в Scala диапазон, следует вос­ пользоваться предопределенными методами to и by . Рассмотрим несколько примеров: 24.7. Конкретные классы неизменяемых коллекций 485 scala> 1 to 3 res31: scala.collection.immutable.Range.Inclusive = Range(1, 2, 3) scala> 5 to 14 by 3 res32: scala.collection.immutable.Range = Range(5, 8, 11, 14) Если нужно создать диапазон, исключая его верхний предел, то следует вместо метода to воспользоваться методом-помощником until: scala> 1 until 3 res33: scala.collection.immutable.Range = Range(1, 2) Диапазоны представлены постоянным объемом памяти, поскольку могут быть определены всего тремя числами: их началом, их концом и значением шага. Благо­ даря такому представлению большинство операций над диапазонами выполняется очень быстро. Сжатые коллекции HAMT Хеш-извлечения (hash-tries1) — стандартный способ эффективной реализации неизменяемых множеств и отображений. Сжатые коллекции HAMT2 — разновид­ ность хеш-извлечений в JVM, которая оптимизирует размещение элементов и следит за тем, чтобы деревья были представлены каноничным и компактным образом. Их представление похоже на векторы тем, что в них также используются де­ ревья, где у каждого узла имеется 32 элемента или 32 поддерева, но выбор осуще­ ствляется на основе хеш-кода. Например, самые младшие пять разрядов хеш-кода ключа используются в целях поиска заданного ключа в отображении для выбора первого поддерева, следующие пять — для выбора следующего поддерева и т. д. Процесс выбора прекращается, как только у всех элементов, хранящихся в узле, будет хеш-код, отличающийся от всех остальных в разрядах, выбранных до сих пор. Таким образом, не обязательно используются все разряды хеш-кода. Хеш-извлечения позволяют достичь хорошего баланса между достаточно бы­ стрым поиском и достаточно эффективными функциональными вставками (+) и удалениями (-). Именно поэтому в Scala они положены в основу реализаций по умолчанию неизменяемых отображений и множеств. Фактически для неизменя­ емых множеств и отображений, содержащих менее пяти элементов, в Scala пред­ усматривается дальнейшая оптимизация. Множества и отображения, содержащие от одного до четырех элементов, хранятся как отдельные объекты, содержащие эти элементы (или в случае с отображениями — пары «ключ — значение») в виде полей. Пустое изменяемое множество и пустое изменяемое отображение во всех случаях являются объектами-одиночками — не нужно дублировать для них место хранения, поскольку пустые неизменяемые множество или отображение всегда будут оставаться пустыми. 1 Trie происходит от слова retrieval («извлечение») и произносится как «три» или «трай». 2 Steindorfer M. J., Vinju J. J. Optimizing hash-array mapped tries for fast and lean immutable JVM collections // ACM SIGPLAN Notices. Vol. 50. ACM, 2015. P. 783–800. 486 Глава 24 • Углубленное изучение коллекций Красно-черные деревья Форма сбалансированных двоичных деревьев — красно-черные деревья, в ко­ торых одни узлы обозначены как красные, а другие — как черные. Операции над ними, как и над любыми другими сбалансированными двоичными деревьями, гарантированно завершаются за время, экспоненциально зависящее от разме­ ра дерева. Scala предоставляет реализации множеств и отображений, во внутреннем пред­ ставлении которых используется красно-черное дерево. Доступ к ним осуществля­ ется по именам TreeSet и TreeMap: scala> val set = collection.immutable.TreeSet.empty[Int] set: scala.collection.immutable.TreeSet[Int] = TreeSet() scala> set + 1 + 3 + 3 res34: scala.collection.immutable.TreeSet[Int] = TreeSet(1, 3) Кроме того, красно-черные деревья в Scala — стандартный механизм реализации SortedSet, поскольку предоставляют весьма эффективный итератор, возвраща­ ющий все элементы множества в отсортированном виде. Неизменяемые битовые множества Битовое множество представляет коллекцию небольших целых чисел в виде битов большего целого числа. Например, битовое множество, содержащее 3, 2 и 0, будет представлено в двоичном виде как целое число 1101, которое в десятичном виде является числом 13. Во внутреннем представлении битовые множества используют массив из 64-раз­ рядных Long-значений. Первое Long-значение в массиве предназначено для целых чисел от 0 до 63, второе — от 64 до 127 и т. д. Таким образом, битовые множества очень компактны, поскольку самое большое целое число во множестве чуть меньше нескольких сотен или около того. Операции над битовыми множествами выполняются очень быстро. Проверка на присутствие занимает постоянное время. На добавление элемента во множество уходит время, пропорциональное количеству Long-значений в массиве битового множества, которых обычно немного. Некоторые примеры использования битового множества выглядят следующим образом: scala> val bits = scala.collection.immutable.BitSet.empty bits: scala.collection.immutable.BitSet = BitSet() scala> val moreBits = bits + 3 + 4 + 4 moreBits: scala.collection.immutable.BitSet = BitSet(3, 4) scala> moreBits(3) res35: Boolean = true 24.7. Конкретные классы неизменяемых коллекций 487 scala> moreBits(0) res36: Boolean = false Векторные отображения VectorMap (векторное отображение) представляет отображение с использованием как вектора (для ключей), так и HashMap. Оно предоставляет итератор, возвраща­ ющий все элементы в том порядке, в котором они были вставлены. scala> val vm = scala.collection.immutable.VectorMap.empty[Int, String] vm: scala.collection.immutable.VectorMap[Int,String] = VectorMap() scala> val vm1 = vm + (1 -> "one") vm1: scala.collection.immutable.VectorMap[Int,String] = VectorMap(1 -> one) scala> val vm2 = vm1 + (2 -> "two") vm2: scala.collection.immutable.VectorMap[Int,String] = VectorMap(1 -> one, 2 -> two) scala> vm2 == Map(2 -> "two", 1 -> "one") res29: Boolean = true В первых строчках видно, что содержимое VectorMap сохраняет порядок вставки, а последняя строчка показывает, что векторные отображения можно сравнивать с другими видами отображений и в ходе этого сравнения не учитывается порядок следования элементов. Списочные отображения Списочное отображение представляет собой отображение в виде связанного списка пар «ключ — значение». Как правило, операции над списочными отображениями могут потребовать перебора всего списка. В связи с этим такие операции зани­ мают время, пропорциональное размеру отображения. Списочные отображения не нашли в Scala широкого применения, поскольку работа со стандартными не­ изменяемыми отображениями почти всегда выполняется быстрее. Единственно возможное положительное отличие наблюдается в том случае, если по какой-то причине отображение сконструировано так, что первые элементы списка выбира­ ются гораздо чаще других элементов. scala> val map = collection.immutable.ListMap( 1 -> "one", 2 -> "two") map: scala.collection.immutable.ListMap[Int,String] = Map(1 -> one, 2 -> two) scala> map(2) res37: String = "two" 488 Глава 24 • Углубленное изучение коллекций 24.8. Конкретные классы изменяемых коллекций Изучив наиболее востребованные из имеющихся в стандартной библиотеке Scala классы неизменяемых коллекций, рассмотрим классы изменяемых коллекций. Буферы массивов Буферы массивов уже встречались нам в разделе 17.1. В таком буфере содержатся массив и его размер. Большинство операций над буферами массивов выполня­ ются с такой же скоростью, что и над массивами, поскольку эти операции просто обращаются к обрабатываемому массиву и вносят в него изменения. Кроме того, у буфера массива могут быть данные, весьма эффективно добавляемые в его ко­ нец. Добавление записи в буфер массива занимает амортизированно постоянное время. Из этого следует, что буферы массивов хорошо подходят для эффективного создания больших коллекций, когда новые элементы всегда добавляются в конец. Вот как выглядят некоторые примеры их применения: scala> val buf = collection.mutable.ArrayBuffer.empty[Int] buf: scala.collection.mutable.ArrayBuffer[Int] = ArrayBuffer() scala> buf += 1 res38: buf.type = ArrayBuffer(1) scala> buf += 10 res39: buf.type = ArrayBuffer(1, 10) scala> buf.toArray res40: Array[Int] = Array(1, 10) Буферы списков В разделе 17.1 нам встречались и буферы списков. Буфер списка похож на буфер массива, за исключением того, что внутри него используется не массив, а связанный список. Если сразу после создания буфер предполагается превращать в список, то лучше воспользоваться буфером списка, а не буфером массива. Вот как выглядит соответствующий пример1: scala> val buf = collection.mutable.ListBuffer.empty[Int] buf: scala.collection.mutable.ListBuffer[Int] = ListBuffer() 1 Появляющийся в ответах интерпретатора в этом и в нескольких других примерах раздела buf.type является синглтон-типом. Как будет сказано в разделе 29.6 , buf.type означает, что переменная содержит именно тот объект, на который указывает buf. 24.8. Конкретные классы изменяемых коллекций 489 scala> buf += 1 res41: buf.type = ListBuffer(1) scala> buf += 10 res42: buf.type = ListBuffer(1, 10) scala> buf.toList res43: List[Int] = List(1, 10) Построители строк По аналогии с тем, что буфер массива используется для создания массивов, а бу­ фер списка — для создания списков, построитель строк применяется для создания строк. Построители строк используются настолько часто, что заранее импортиру­ ются в пространство имен по умолчанию. Создать их можно с помощью простого выражения new StringBuilder: scala> val buf = new StringBuilder buf: StringBuilder = scala> buf += 'a' res44: buf.type = a scala> buf ++= "bcdef" res45: buf.type = abcdef scala> buf.toString res46: String = abcdef ArrayDeque ArrayDeque — изменяемая последовательность, которая поддерживает эффективное добавление элементов в начало и в конец. Внутри она использует массив изменя­ емого размера. Если вам нужно вставлять элементы в начало или в конец буфера, то используйте ArrayDeque вместо ArrayBuffer. Очереди Наряду с неизменяемыми очередями в Scala есть и изменяемые. Используются они аналогично, но для добавления элементов вместо enqueue применяются опе­ раторы += и ++=. Кроме того, метод dequeue будет просто удалять из изменяемой очереди головной элемент и возвращать его. Примеры использования даны ниже: scala> val queue = new scala.collection.mutable.Queue[String] queue: scala.collection.mutable.Queue[String] = Queue() 490 Глава 24 • Углубленное изучение коллекций scala> queue += "a" res47: queue.type = Queue(a) scala> queue ++= List("b", "c") res48: queue.type = Queue(a, b, c) scala> queue res49: scala.collection.mutable.Queue[String] = Queue(a, b, c) scala> queue.dequeue res50: String = a scala> queue res51: scala.collection.mutable.Queue[String] = Queue(b, c) Стеки Scala предоставляет изменяемый стек. Ниже представлен пример: scala> val stack = new scala.collection.mutable.Stack[Int] stack: scala.collection.mutable.Stack[Int] = Stack() scala> stack.push(1) res52: stack.type = Stack(1) scala> stack res53: scala.collection.mutable.Stack[Int] = Stack(1) scala> stack.push(2) res54: stack.type = Stack(2, 1) scala> stack res55: scala.collection.mutable.Stack[Int] = Stack(2, 1) scala> stack.top res56: Int = 2 scala> stack res57: scala.collection.mutable.Stack[Int] = Stack(2, 1) scala> stack.pop res58: Int = 2 scala> stack res59: scala.collection.mutable.Stack[Int] = Stack(1) Обратите внимание: в Scala нет поддержки неизменяемых стеков, поскольку данная функциональность уже реализована в списках. Операция push над неиз­ меняемым стеком аналогична выражению a :: для списка. Операция pop — то же самое, что вызвать head и tail для списка. 24.8. Конкретные классы изменяемых коллекций 491 Изменяемые ArraySeq Последовательный массив — это изменяемая последовательность фиксированного размера, которая хранит свои элементы внутри Array[AnyRef]. В Scala он реализо­ ван в виде класса ArraySeq. Данный класс обычно используется в случаях, когда вам нужен производитель­ ный массив и притом необходимо создавать обобщенные экземпляры последова­ тельности с элементами, тип которых не известен заранее и не может быть получен во время выполнения с помощью ClassTag. С этими проблемами, которые присущи массивам, вы познакомитесь чуть позже, в разделе 24.9. Хеш-таблицы Хеш-таблица сохраняет свои элементы в образующем ее массиве, помещая каждый в позицию в массиве, определяемую хеш-кодом этого элемента. На добавление элемента в хеш-таблицу всегда уходит одно и то же время, если только в массиве нет еще одного элемента с точно таким же хеш-кодом. Поэтому, пока помещенные в хеш-таблицу элементы имеют хорошее распределение хеш-кодов, работа с ней выполняется довольно быстро. По этой причине типы изменяемых отображений и множеств по умолчанию в Scala основаны на хеш-таблицах. Хеш-множества и хеш-отображения используются точно так же, как и любые другие множества или отображения. Ниже представлены некоторые простые примеры: scala> val map = collection.mutable.HashMap.empty[Int,String] map: scala.collection.mutable.HashMap[Int,String] = Map() scala> map += (1 -> "make a web site") res60: map.type = Map(1 -> make a web site) scala> map += (3 -> "profit!") res61: map.type = Map(1 -> make a web site, 3 -> profit!) scala> map(1) res62: String = make a web site scala> map contains 2 res63: Boolean = false Конкретный порядок обхода элементов хеш-таблицы не гарантируется. Выполня­ ется простой обход элементов массива, на котором основана хеш-таблица, в поряд­ ке расположения его элементов. Чтобы получить гарантированный порядок обхода, следует воспользоваться связанными хеш-отображением или множеством вместо обычных. Связанные хеш-отображение или множество почти аналогичны обыч­ ным хеш-отображению или множеству, но включают связанный список элементов в порядке их добавления. Обход элементов такой коллекции всегда выполняется в том же порядке, в котором они добавлялись в нее изначально. 492 Глава 24 • Углубленное изучение коллекций Слабые хеш-отображения Слабое хеш-отображение представляет собой особую разновидность хеш-отобра­ жения, в которой сборщик мусора не следует по ссылкам от отображения к храня­ щимся в нем ключам. Это значит, ключ и связанное с ним значение исчезнут из отображения, если на данный ключ нет другой ссылки. Слабые хеш-отображени