автордың кітабын онлайн тегін оқу Современный язык Java. Лямбда-выражения, потоки и функциональное программирование
Научный редактор М. Матвеев
Переводчик И. Пальти
Технический редактор Н. Гринчик
Литературный редактор Н. Гринчик
Художники А. Барцевич, С. Заматевская
Корректоры Е. Павлович, Е. Рафалюк-Бузовская
Верстка О. Богданович, Н. Гринчик
Рауль-Габриэль Урма, Марио Фуско, Алан Майкрофт
Современный язык Java. Лямбда-выражения, потоки и функциональное программирование. — СПб.: Питер, 2021.
ISBN 978-5-4461-0997-5
© ООО Издательство "Питер", 2021
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Предисловие
В 1998 году, когда мне было всего восемь лет, я открыл свою первую книгу по программированию — она была посвящена JavaScript и HTML. Я и не подозревал, что это простое действие перевернет всю мою жизнь — я открыл для себя мир языков программирования и всех тех потрясающих вещей, которые можно сделать с их помощью. Я попался на крючок. Время от времени я по-прежнему узнаю о новых возможностях языков программирования, которые воскрешают во мне то чувство восхищения, поскольку позволяют писать более аккуратный и лаконичный код, причем вдвое быстрее. Надеюсь, обсуждаемые в данной книге нововведения Java версий 8, 9 и 10, привнесенные из функционального программирования, будут так же вдохновлять вас.
Наверное, вам интересно, как появилась эта книга вообще и второе ее издание в частности?
Что ж, в 2011 году Брайан Гётц (Brian Goetz) — архитектор языка Java в Oracle — публиковал различные предложения по добавлению лямбда-выражений в Java, желая вовлечь в этот процесс сообщество разработчиков. Они вновь разожгли мой интерес, и я начал продвигать эти идеи, организуя семинары по Java 8 на различных конференциях разработчиков, а также читать лекции студентам Кембриджского университета.
К апрелю 2013 года мой издатель из Manning прислал мне по электронной почте письмо, где спросил, не хочу ли я написать книгу о лямбда-выражениях в Java 8. На тот момент я был всего лишь скромным соискателем степени PhD на втором году обучения, и это предложение показалось мне несвоевременным, поскольку могло негативно отразиться на моем графике подачи диссертации. С другой стороны, carpe diem1. Мне казалось, что написание небольшой книги требует не так уж много времени (и только позднее я понял, как сильно ошибался!). В итоге я обратился за советом к своему научному руководителю профессору Алану Майкрофту (Alan Mycroft), который, как оказалось, был не против поддержать меня в такой авантюре (даже предложил помочь с этой не относящейся к моей диссертации работой, за что я навеки его должник). А несколько дней спустя мы встретили знакомого евангелиста Java 8 Марио Фуско (Mario Fusco), обладавшего богатым опытом и хорошо известного своими докладами по функциональному программированию на крупных конференциях разработчиков.
Мы быстро поняли, что, объединив наши усилия и столь разнородный опыт, можем написать не просто небольшую книгу по лямбда-выражениям Java 8, а книгу, которая, надеемся, будет полезной сообществу Java-разработчиков и через пять и даже десять лет. У нас появилась уникальная возможность подробно обсудить множество вопросов, важных для Java-разработчиков, и распахнуть двери в совершенно новую вселенную: функциональное программирование.
Наступил 2018 год, и оказалось, что было продано 20 000 экземпляров первого издания, версия Java 9 только появилась, вот-вот должна была появиться версия Java 10, и время притупило воспоминания о множестве долгих ночей редактирования книги. Итак, вот оно — второе издание «Современного языка Java», охватывающее Java 8, Java 9 и Java 10! Надеемся, оно вам понравится!
Рауль-Габриэль Урма (Raoul-Gabriel Urma),
Cambridge Spark
1 В переводе с лат. «лови день». — Примеч. пер.
К апрелю 2013 года мой издатель из Manning прислал мне по электронной почте письмо, где спросил, не хочу ли я написать книгу о лямбда-выражениях в Java 8. На тот момент я был всего лишь скромным соискателем степени PhD на втором году обучения, и это предложение показалось мне несвоевременным, поскольку могло негативно отразиться на моем графике подачи диссертации. С другой стороны, carpe diem1. Мне казалось, что написание небольшой книги требует не так уж много времени (и только позднее я понял, как сильно ошибался!). В итоге я обратился за советом к своему научному руководителю профессору Алану Майкрофту (Alan Mycroft), который, как оказалось, был не против поддержать меня в такой авантюре (даже предложил помочь с этой не относящейся к моей диссертации работой, за что я навеки его должник). А несколько дней спустя мы встретили знакомого евангелиста Java 8 Марио Фуско (Mario Fusco), обладавшего богатым опытом и хорошо известного своими докладами по функциональному программированию на крупных конференциях разработчиков.
В переводе с лат. «лови день». — Примеч. пер.
Благодарности
Эта книга не появилась бы на свет без поддержки множества замечательных людей, в числе которых:
• наши близкие друзья и просто люди, на добровольных началах дававшие ценные отзывы и предложения: Ричард Уолкер (Richard Walker), Ян Сагановски (Jan Saganowski), Брайан Гётц (Brian Goetz), Стюарт Маркс (Stuart Marks), Джем Редиф (Cem Redif), Пол Сандоз (Paul Sandoz), Стивен Колбурн (Stephen Colebourne), Иньиго Медиавилла (Íñigo Mediavilla), Аллабакш Асадулла (Allahbaksh Asadullah), Томаш Нуркевич (Tomasz Nurkiewicz) и Михаэль Мюллер (Michael Müller);
• участники программы раннего доступа от издательства Manning (MEAP), публиковавшие свои комментарии на онлайн-форуме автора;
• рецензенты, дававшие полезные отзывы во время написания книги: Антонио Маньяни (Antonio Magnaghi), Брент Стэйнз (Brent Stains), Франциска Мейер (Franziska Meyer), Фуркан Камачи (Furkan Kamachi), Джейсон Ли (Jason Lee), Джорн Динкла (Jörn Dinkla), Лочана Меникараччи (Lochana Menikarachchi), Маюр Патил (Mayur Patil), Николаос Кайнтанцис (Nikolaos Kaintantzis), Симоне Борде (Simone Bordet), Стив Роджерс (Steve Rogers), Уилл Хейворт (Will Hayworth) и Уильям Уилер (William Wheeler);
• Кевин Харрельд (Kevin Harreld) — наш редактор-консультант из издательства Manning, терпеливо отвечавший на все наши вопросы и решавший наши проблемы. Он подробно консультировал нас при написании книги и вообще поддерживал, как только мог;
• Дэннис Селинджер (Dennis Selinger) и Жан-Франсуа Морен (Jean-François Morin), вычитавшие рукопись перед сдачей в верстку, а также Эл Шерер (Al Scherer), консультировавший нас по техническим вопросам во время работы над книгой.
Рауль-Габриэль Урма
Прежде всего я хотел бы поблагодарить родителей за их бесконечную любовь и поддержку на протяжении всей моей жизни. Моя маленькая мечта о написании книги стала реальностью! Кроме того, хотелось бы выразить бесконечную признательность Алану Майкрофту, моему соавтору и научному руководителю, за его доверие и поддержку. Хотел бы также поблагодарить моего соавтора Марио Фуско, разделившего со мной это приключение. Наконец, спасибо моим друзьям и наставникам за их полезные советы и моральную поддержку: Софии Дроссопулу (Sophia Drossopoulou), Эйдену Рошу (Aidan Roche), Алексу Бакли (Alex Buckley), Хаади Джабадо (Haadi Jabado) и Гаспару Робертсону (Jaspar Robertson). Вы крутые!
Марио Фуско
Я хотел бы в первую очередь поблагодарить свою жену Марилену, благодаря безграничному терпению которой мне удалось сосредоточиться на написании этой книги, и нашу дочь Софию, поскольку создаваемый ею бесконечный хаос помогал мне творчески отвлекаться от книги. Как вы узнаете во время чтения, София преподала нам один урок — о различиях между внутренней и внешней итерацией, — как это может сделать только двухлетняя девочка. Хотел бы также поблагодарить Рауля-Габриэля Урму и Алана Майкрофта, с которыми я разделил (большую) радость и (маленькие) огорчения от написания этой книги.
Алан Майкрофт
Я хотел бы выразить благодарность моей жене Хилари и остальным членам моей семьи, терпеливо выносившим мою постоянную занятость. Я хотел бы также поблагодарить своих коллег и студентов, которые на протяжении многих лет учили меня, как нужно учить, Марио и Рауля за эффективное соавторство и, в частности, Рауля — за умение столь обходительно требовать «следующий кусочек текста к пятнице».
Об этой книге
Грубо говоря, появление новых возможностей Java 8 вместе с менее явными изменениями в Java 9 — самые масштабные изменения в языке Java за 21 год, прошедший с момента выпуска Java 1.0. Ничего не было удалено, так что весь существующий код на Java сохранит работоспособность, но новые возможности предлагают мощные новые идиомы и паттерны проектирования, благодаря которым код становится чище и лаконичнее. Сначала у вас может возникнуть мысль: «Ну зачем они опять меняют мой язык?» Но потом, после начала работы с новыми функциями, вы обнаружите, что благодаря им пишете более краткий и чистый код вдвое быстрее, чем раньше, — и осознаете, что уже не хотите возвращаться к «старому Java».
Второе издание этой книги, «Современный язык Java. Лямбда-выражения, потоки и функциональное программирование», рассчитано на то, чтобы помочь вам преодолеть этап «в принципе, звучит неплохо, но все такое новое и непривычное» и начать ориентироваться в новых функциях как рыба в воде.
«Может, и так, — наверное, думаете вы, — но разве лямбды и функциональное программирование не из того разряда вещей, о которых бородатые теоретики рассуждают в своих башнях из слоновой кости?» Возможно, но в Java 8 предусмотрен ровно такой баланс идей, чтобы извлечь выгоду способами, понятными обычному Java-программисту. И эта книга как раз описывает Java с точки зрения обычного программиста, лишь с редкими отступлениями в стиле «Откуда это взялось?».
«“Лямбда-выражения” — звучит как какая-то тарабарщина!» Да, но они позволяют писать лаконичные программы на языке Java. Многие из вас сталкивались с обработчиками событий и функциями обратного вызова: когда регистрируется объект, содержащий метод, который должен вызываться в случае какого-либо события. Лямбда-выражения значительно расширяют сферу использования подобных функций в Java. Попросту говоря, лямбда-выражения и их спутники — ссылки на методы — обеспечивают возможность максимально лаконичной их передачи в качестве аргументов для выполнения кода или методов без отрыва от какой-либо совершенно иной деятельности. В книге вы увидите, что эта идея встречается гораздо чаще, чем вы могли себе представить: от простой параметризации метода сортировки кодом для сравнения и до сложных запросов к коллекциям данных с помощью новых Stream API (API обработки потоков данных).
«Потоки данных — что это такое?» Это замечательная новая возможность Java 8. Потоки ведут себя подобно коллекциям, но обладают несколькими преимуществами перед последними, позволяя использовать новые стили программирования. Во-первых, если вам доводилось программировать на языках запросов баз данных, например SQL, то вы знаете, что они позволяют описывать запросы несколькими строками вместо множества строк на Java. Поддержка потоков данных в Java 8 дает возможность использовать подобный сжатый код в стиле запросов баз данных — но с синтаксисом Java и без необходимости каких-либо знаний о базах данных! Во-вторых, потоки устроены так, что их данные не обязательно должны находиться в памяти (или даже обрабатываться) одновременно. А значит, появляется возможность обработки потоков данных настолько больших, что они не помещаются в памяти компьютера. Кроме того, Java 8 умеет оптимизировать операции над потоками данных лучше, чем операции над коллекциями: например, может группировать несколько операций над одним потоком так, что данные требуется обходить только один раз, а не несколько. Более того, Java может автоматически параллелизовать потоковые операции (в отличие от операций над коллекциями).
«А функциональное программирование? Что это такое?» Это еще один стиль программирования, как и объектно-ориентированное программирование, но ориентированный на применение функций в качестве значений, как мы упоминали выше, говоря про лямбда-выражения.
Версия Java 8 хороша тем, что включает в привычный синтаксис Java множество замечательных идей из функционального программирования. Благодаря удачным проектным решениям можно рассматривать функциональное программирование в Java 8 как дополнительный набор паттернов проектирования и идиом, позволяющих быстрее писать более чистый и лаконичный код. Можете рассматривать его как расширение своего арсенала разработчика.
И да, помимо этих функциональных возможностей, основанных на глобальных концептуальных новшествах в Java, мы расскажем о множестве других удобных функций и новинок Java 8, например о методах с реализацией по умолчанию, новых классах Optional и CompletableFuture, а также о новом API для работы с датой и временем (Date and Time API).
Вдобавок Java 9 привносит еще несколько новшеств: новую систему модулей, поддержку реактивного программирования с помощью Flow API и другие разнообразные усовершенствования.
Но погодите, это же только введение, дальнейшие подробности вы узнаете из самой книги.
Структура издания
Книга делится на шесть частей: «Основы», «Функциональное программирование с помощью потоков», «Эффективное программирование с помощью потоков и лямбда-выражений», «Java на каждый день», «Расширенная конкурентность в языке Java» и «Функциональное программирование и эволюция языка Java». Мы настоятельно советуем вам прочитать сначала главы из первых двух частей (причем по порядку, поскольку многие из обсуждаемых понятий основываются на материале предыдущих глав), оставшиеся же четыре части можно читать относительно независимо друг от друга. В большинстве глав есть несколько контрольных заданий для лучшего усвоения вами материала.
В первой части книги приводятся основные сведения, необходимые для знакомства с новыми идеями, появившимися в Java 8. К концу первой части вы будете знать, что такое лямбда-выражения, и научитесь писать код, лаконичный и в то же время достаточно гибкий для адаптации к меняющимся требованиям.
• В главе 1 мы резюмируем основные изменения в Java (лямбда-выражения, ссылки на методы, потоки данных и методы с реализацией по умолчанию) и подготовим фундамент для остальной части книги.
• Из главы 2 вы узнаете о параметризации поведения — важном для Java 8 паттерне разработки программного обеспечения, лежащем в основе лямбда-выражений.
• Глава 3 подробно объясняет понятия лямбда-выражений и ссылок на методы, здесь приводятся примеры кода и контрольные задания.
Вторая часть книги представляет собой углубленное исследование нового Stream API, позволяющего писать мощный код для декларативной обработки коллекций данных. К концу второй части вы полностью разберетесь, что такое потоки данных и как их использовать в своем коде для лаконичной и эффективной обработки коллекций данных.
• В главе 4 вы познакомитесь с понятием потока данных и увидите, чем он отличается от коллекции.
• Глава 5 подробно описывает потоковые операции, с помощью которых можно выражать сложные запросы для обработки данных. Вы встретите в ней множество паттернов, касающихся фильтрации, срезов, сопоставления с шаблоном, отображения и свертки.
• Глава 6 посвящена сборщикам данных — функциональной возможности Stream API, с помощью которой можно выражать еще более сложные запросы для обработки данных.
• Из главы 7 вы узнаете, как организовать автоматическое распараллеливание потоков данных и в полной мере использовать многоядерную архитектуру своей машины. Кроме того, мы расскажем вам, как избежать многочисленных подводных камней и применять параллельные потоки правильно и эффективно.
В третьей части книги рассмотрены различные функции Java 8 и Java 9, которые помогут вам эффективнее использовать язык и обогатить вашу базу кода современными идиомами. Поскольку мы нацелены на использование более продвинутых концепций программирования, то для понимания изложенного далее в книге материала не требуется знаний этих методик.
• Глава 8 написана специально для второго издания, в ней мы изучаем коллекцию API дополнений (Collection API Enhancements) Java 8 и Java 9. Глава охватывает вопросы использования фабрик коллекции и новых идиоматических паттернов для работы со списками и множествами (коллекциями List и Set), а также идиоматических паттернов для ассоциативных массивов (коллекций Map).
• Глава 9 посвящена вопросам усовершенствования существующего кода с помощью новых возможностей Java 8 и включает несколько полезных примеров. Кроме того, в ней мы исследуем такие жизненно важные методики разработки программного обеспечения, как применение паттернов проектирования, рефакторинг, тестирование и отладка.
• Глава 10 также написана специально для второго издания. В ней обсуждается использование API на основе предметно-ориентированного языка (DSL). Это многообещающий метод проектирования API, популярность которого неуклонно растет. Его уже можно встретить в таких интерфейсах Java, как Comparator, Stream и Collector.
Четвертая часть книги посвящена различным новым возможностям Java 8 и Java 9, касающимся упрощения написания кода и повышения его надежности. Мы начнем с двух API, появившихся в Java 8.
• В главе 11 описывается класс java.util.Optional, позволяющий проектировать более совершенные API, а заодно и снизить количество исключений, связанных с указателями на null.
• Глава 12 посвящена изучению Date and Time API (API даты и времени), который существенно лучше предыдущих API для работы с датами/временем, подверженных частым ошибкам.
• В главе 13 вы прочитаете, что такое методы с реализацией по умолчанию, как развивать с их помощью API с сохранением совместимости, а также познакомитесь с некоторыми паттернами и узнаете правила эффективного использования методов с реализацией по умолчанию.
• Глава 14 написана специально для второго издания, в ней изучается система модулей Java — масштабное нововведение Java 9, позволяющее разбивать огромные системы на хорошо документированные модули вместо «беспорядочного набора пакетов».
В пятой части книги исследуются способы структурирования параллельных программ на языке Java — более продвинутые, чем простая параллельная обработка потоков данных, с которой вы к тому времени уже познакомитесь в главах 6 и 7.
• Глава 15 появилась во втором издании и демонстрирует идею асинхронных API «с высоты птичьего полета» — включая понятие будущего (Future) и протокол публикации-подписки (Publish-Subscribe), лежащий в основе реактивного программирования и включенный в Flow API Java 9.
• Глава 16 посвящена классу CompletableFuture, с помощью которого можно выражать сложные асинхронные вычисления декларативным образом — аналогично Stream API.
• Глава 17 тоже появилась только во втором издании, в ней подробно изучается Flow API Java 9 с упором на пригодный для практического использования реактивный код.
В шестой, заключительной, части этой книги мы немного отступим от темы, чтобы предложить вам руководство по написанию эффективных программ в стиле функционального программирования на языке Java, а также сравнить возможности Java 8 с возможностями языка Scala.
• Глава 18 предлагает полное руководство по функциональному программированию, знакомит с его терминологией и объясняет, как писать программы в функциональном стиле на языке Java.
• Глава 19 охватывает более продвинутые методики функционального программирования, включая каррирующие неизменяемые структуры данных, ленивые списки и сопоставление с шаблоном. Материал этой главы представляет собой смесь практических методик, которые можно использовать в своем коде, и теоретической информации, направленной на расширение ваших познаний как программиста.
• Глава 20 продолжает тему сравнения возможностей Java 8 с возможностями языка Scala — языка, который, как и Java, основан на использовании JVM. Scala быстро развивается, угрожая занять часть ниши Java в экосистеме языков программирования.
• В главе 21 мы еще раз обсудим наш путь изучения Java 8 и свою аргументацию в пользу функционального программирования. В дополнение мы поговорим о возможных будущих усовершенствованиях и замечательных новых возможностях Java, которые могут появиться после Java 8, Java 9 и небольших добавлений в Java 10.
Наконец, в книге есть четыре приложения, охватывающие несколько дополнительных тем, связанных с Java 8. В приложении A рассматриваются несколько небольших функций языка Java 8, которые не обсуждаются в основной части книги. В приложении Б приведен обзор остальных важных дополнений библиотеки Java, которые могут быть вам полезны. Приложение В продолжает часть II и посвящено продвинутым возможностям использования потоков данных. Приложение Г изучает вопросы реализации лямбда-выражений внутри компилятора Java.
О коде
Весь исходный код в этой книге, как в листингах, так и в представленных фрагментах, набран таким моноширинным шрифтом, позволяющим отличить его от остального текста. Большинство листингов снабжено пояснениями, подчеркивающими важные понятия.
Все примеры из книги и инструкции по их запуску доступны в репозитории GitHub (https://github.com/java-manning/modern-java), а также на сайте издателя по адресу http://www.manning.com/books/modern-java-in-action.
Форум для обсуждения книги
На странице https://forums.manning.com/forums/modern-java-in-action вы найдете информацию о том, как попасть на форум издательства Manning после регистрации. Там можно оставлять свои комментарии по поводу данной книги, задавать технические вопросы и получать помощь от авторов и других пользователей. Узнать больше о форумах издательства Manning и правилах поведения на них можно на странице https://forums.manning.com/forums/about.
Издательство Manning взяло на себя обязательство по предоставлению места, где читатели могут конструктивно пообщаться как друг с другом, так и с авторами книги. Но оно не может гарантировать присутствия на форуме авторов, участие которых в обсуждениях остается добровольным (и неоплачиваемым). Мы советуем вам задавать авторам интересные и трудные вопросы, чтобы их интерес не угас! Как форум, так и архивы предыдущих обсуждений будут доступны на сайте издательства до тех пор, пока книга находится в продаже.
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.
Об авторах
Рауль-Габриэль Урма — генеральный директор и один из основателей компании Cambridge Spark (Великобритания), ведущего образовательного сообщества для исследователей данных и разработчиков. Рауль был избран одним из участников программы Java Champions в 2017 году. Он работал в компаниях Google, eBay, Oracle и Goldman Sachs. Защитил диссертацию по вычислительной технике в Кембриджском университете. Кроме того, он имеет диплом магистра технических наук Имперского колледжа Лондона, окончил его с отличием и получил несколько премий за рационализаторские предложения. Рауль представил более 100 технических докладов на международных конференциях.
Марио Фуско — старший инженер-разработчик программного обеспечения в компании Red Hat, участвующий в разработке ядра Drools — механизма обработки правил JBoss. Обладает богатым опытом разработки на языке Java, участвовал (зачастую в качестве ведущего разработчика) во множестве корпоративных проектов в различных отраслях промышленности, начиная от СМИ и заканчивая финансовым сектором. В числе его интересов функциональное программирование и предметно-ориентированные языки. На основе этих двух увлечений он создал библиотеку lambdaj с открытым исходным кодом, желая разработать внутренний DSL Java для работы с коллекциями и сделать возможным применение некоторых элементов функционального программирования в Java.
Алан Майкрофт — профессор на факультете компьютерных наук Кембриджского университета, где он преподает с 1984 года. Он также сотрудник колледжа Робинсона, один из основателей Европейской ассоциации языков и систем программирования, а также один из основателей и попечителей Raspberry Pi Foundation. Имеет ученые степени в области математики (Кембридж) и компьютерных наук (Эдинбург). Алан — автор более 100 научных статей. Был научным руководителем для более 20 кандидатов на соискание PhD. Его исследования в основном касаются сферы языков программирования и их семантики, оптимизации и реализации. Какое-то время он работал в AT&T Laboratories и научно-исследовательском отделе Intel, а также участвовал в основании компании Codemist Ltd., выпустившей компилятор языка C для архитектуры ARM под названием Norcroft.
Иллюстрация на обложке
Рисунок на обложке называется «Одеяния военного мандарина в Китайской Тартарии, 1700 г.». Одеяния мандарина причудливо разукрашены, он носит меч, а также лук и колчан со стрелами. Если присмотреться к его поясу, можно заметить там греческую букву «лямбда» (намек на один из рассматриваемых в этой книге вопросов), аккуратно добавленную нашим дизайнером. Эта иллюстрация позаимствована из четырехтомника Томаса Джеффериса (Thomas Jefferys) A Collection of the Dresses of Different Nations, Ancient and Modern («Коллекция платья разных народов, старинного и современного»), изданного в Лондоне в 1757–1772 годах. На титульном листе говорится, что это гравюра, раскрашенная вручную с применением гуммиарабика.
Томаса Джеффериса (1719–1771) называли географом при короле Георге III. Он был английским картографом и ведущим поставщиком карт своего времени. Он чертил и печатал карты для правительства и других государственных органов. На его счету целый ряд коммерческих карт и атласов, в основном Северной Америки. Занимаясь картографией в разных уголках мира, он интересовался местными традиционными нарядами, которые впоследствии были блестяще переданы в данной коллекции. В конце XVIII века заморские путешествия для собственного удовольствия были относительно новым явлением, поэтому подобные коллекции пользовались популярностью, позволяя получить представление о жителях других стран как настоящим туристам, так и «диванным» путешественникам.
Разнообразие иллюстраций в книгах Джеффериса — яркое свидетельство уникальности и оригинальности народов мира в то время. С тех пор тенденции в одежде сильно изменились, а региональные и национальные различия, такие значимые 200 лет назад, постепенно сошли на нет. В наши дни бывает сложно отличить друг от друга жителей разных континентов. Если взглянуть на это с оптимистической точки зрения, мы пожертвовали культурным и внешним разнообразием в угоду более насыщенной личной жизни или более разнообразной и интересной интеллектуальной и технической деятельности.
В то время как большинство компьютерных изданий мало чем отличаются друг от друга, компания Manning выбирает для своих книг обложки, основанные на богатом региональном разнообразии, которое Джефферис воплотил в иллюстрациях два столетия назад. Это ода находчивости и инициативности современной компьютерной индустрии.
Часть I. Основы
В первой части книги приводятся основы, необходимые для понимания новых идей, появившихся в Java 8. К концу первой части вы будете знать, что такое лямбда-выражения, и научитесь писать код, лаконичный и в то же время достаточно гибкий для адаптации к меняющимся требованиям.
• В главе 1 мы резюмируем основные изменения в Java (лямбда-выражения, ссылки на методы, потоки данных и методы с реализацией по умолчанию) и подготовим фундамент для материала, изложенного в остальной части книги.
• Из главы 2 вы узнаете о параметризации поведения — важном для Java 8 паттерне разработки программного обеспечения, лежащем в основе лямбда-выражений.
• Глава 3 подробно рассказывает про понятия лямбда-выражений и ссылок на методы, приводятся прмеры кода и контрольные задания.
Глава 1. Java 8, 9, 10 и 11: что происходит?
В этой главе
• Почему язык Java продолжает меняться.
• Изменение идеологии вычислений.
• Обстоятельства, требующие эволюции Java.
• Новые базовые возможности Java 8 и Java 9.
С тех пор как в 1996 году вышла версия 1.0 набора инструментов для разработки приложений на языке Java (Java Development Kit, JDK), у Java появилось множество приверженцев и активных пользователей среди студентов, менеджеров по проектам и программистов. Java — весьма выразительный язык, который продолжает использоваться как для больших, так и для маленьких проектов. Его развитие (в виде добавления новых возможностей) от Java 1.1 (1997) до Java 7 (2011) носило очень продуманный характер. Версия Java 8 вышла в марте 2014 года, Java 9 — в сентябре 2017-го, Java 10 — в марте 2018-го, а Java 11 — в сентябре 2018-го. Вопрос вот в чем: как эти изменения касаются вас?
1.1. Итак, о чем вообще речь?
Мы полагаем, что версия Java 8 подверглась в определенных смыслах наиболее серьезным изменениям за всю историю языка Java (внесенные в Java 9 изменения, касающиеся производительности, важны, но не столь серьезны, как вы увидите далее в этой главе, а Java 10 привнесла лишь небольшие усовершенствования правил вывода типов). Хорошая новость: благодаря этим изменениям написание программ упрощается. Например, вместо написания вот такого многословного кода (для сортировки по весу списка яблок из объекта inventory):
Collections.sort(inventory, new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
в Java 8 можно написать более лаконичный код, намного яснее отражающий формулировку задачи:
Его можно прочитать как «отсортировать список, сравнивая яблоки по весу». Пока не задумывайтесь насчет этого кода. Далее в книге мы объясним, что он делает и как подобный код писать.
Оказало свое влияние и аппаратное обеспечение: компьютеры стали многоядерными — процессор вашего ноутбука или стационарного компьютера наверняка содержит четыре или более ядра. Но большинство существующих программ на языке Java используют только одно из этих ядер, в то время как остальные три простаивают (или тратят лишь малую толику своих вычислительных возможностей на поддержание работы операционной системы или антивируса).
До Java 8 эксперты обычно говорили, что для применения этих ядер придется воспользоваться многопоточностью. Проблема в том, что работать с потоками сложно и чревато ошибками. Развитие языка Java всегда шло в направлении упрощения использования параллельного программирования и снижения количества ошибок. В Java 1.0 были потоки, блокировки и даже модель памяти — рекомендуемая практика на тот момент — но гарантировать надежность использования этих примитивов в проектных командах неспециалистов оказалось слишком сложной задачей. В Java 5 появились стандартные блоки промышленного уровня, такие как пулы потоков и потокобезопасные коллекции. В Java 7 появился фреймворк fork/join (ветвления-объединения), благодаря чему параллелизм стал более достижимой, но все еще трудной задачей. Параллелизм в Java 8 еще больше упрощается, но все равно необходимо следовать определенным правилам, о которых вы узнаете из этой книги.
В Java 9 появляется возможность дальнейшего структурирования параллельного выполнения программ — реактивное программирование. Хотя и ориентированное скорее на профессионалов, оно стандартизирует использование таких наборов инструментов для работы с реактивными потоками данных, как RxJava и Akka, все чаще применяемых в системах с высокой степенью параллелизации.
На предыдущих двух пожеланиях (более лаконичный код и упрощение использования многоядерных процессоров) выросла вся согласованная система Java 8. Мы начнем с того, что рассмотрим ее концепции «с высоты птичьего полета» (очень кратко, но, надеемся, достаточно полно, чтобы вас заинтриговать):
• Stream API;
• способы передачи кода в методы;
• методы с реализацией по умолчанию в интерфейсах.
Java 8 предоставляет новый API (Stream API), поддерживающий множество параллельных операций обработки данных и чем-то напоминающий языки запросов баз данных — нужные действия выражаются в высокоуровневом виде, а реализация (в данном случае библиотека Streams) выбирает оптимальный низкоуровневый механизм выполнения. В результате вам не нужно использовать в коде ключевое слово synchronized, которое не только часто приводит к возникновению ошибок, но и расходует на многоядерных CPU больше ресурсов, чем можно предположить2.
Со слегка ревизионистской точки зрения добавление потоков в Java 8 можно считать непосредственной причиной двух других новшеств этой версии: методики лаконичной передачи кода в методы (ссылки на методы, лямбда-выражения) и методов с реализацией по умолчанию в интерфейсах.
Но если рассматривать передачу кода в методы просто как следствие появления Streams, то сфера возможного ее применения в Java 8 сужается. Эта возможность означает новый лаконичный способ выражения параметризации поведения (behavior parameterization). Допустим, вам нужно написать два метода, различающихся лишь несколькими строками кода. Теперь можно просто передать код отличающихся частей в качестве аргумента (такой способ программирования лаконичнее, понятнее и менее подвержен ошибкам, по сравнению с традиционным методом копирования и вставки). Специалист может отметить, что до Java 8 параметризацию поведения реально было реализовать с помощью анонимных классов, но пусть приведенный в начале этой главы пример, демонстрирующий большую лаконичность кода в случае Java 8, скажет сам за себя в смысле ясности кода.
Возможность Java 8 передавать код в методы (а также возвращать его и включать в структуры данных) позволяет использовать целый диапазон дополнительных приемов, которые обычно объединяют под названием функционального программирования (functional programming). В двух словах, подобный код, который в мире функционального программирования называют функциями, можно передавать куда-либо и сочетать между собой, создавая мощные идиомы программирования, которые будут встречаться вам в виде Java-кода на протяжении всей этой книги.
В следующем разделе мы начнем с обсуждения (краткого) причин эволюции языков программирования, а затем рассмотрим базовые возможности Java 8. Далее мы познакомим вас с функциональным программированием, пользоваться которым стало проще благодаря этим новым возможностям и которое поддерживает новые архитектуры компьютеров. Если вкратце, то в разделе 1.2 обсуждается процесс эволюции языков и ранее отсутствовавшие в языке Java концепции, облегчающие использование многоядерного параллелизма. Раздел 1.3 рассказывает, чем замечательна новая идиома программирования — передача кода в методы в Java 8, а раздел 1.4 делает то же самое для потоков данных — нового способа представления в Java 8 последовательных данных и указания на возможность их параллельной обработки. Раздел 1.5 объясняет, как появившиеся в Java 8 методы с реализацией по умолчанию делают эволюцию интерфейсов и их библиотек задачей менее хлопотной и требующей меньшего количества перекомпиляций; также в нем рассказывается о появившихся в Java 9 модулях, с помощью которых можно описывать компоненты больших Java-систем более понятным образом, чем «JAR-файл с пакетами». Наконец, раздел 1.6 заглядывает в будущее функционального программирования на Java и других Java-подобных языках, использующих JVM. Таким образом, в этой главе мы познакомим вас с концепциями, которые будут подробно описаны в оставшейся части книги. В добрый путь!
1.2. Почему Java все еще меняется
В 1960-х годах начался поиск идеального языка программирования. Питер Лэндин (Peter Landin), известный программист того времени, в 1966 году в своей исторической статье3 отметил, что на тот момент уже существовало 700 языков программирования, и привел рассуждения на тему, какими будут следующие 700, включая доводы в пользу функционального программирования в стиле, аналогичном стилю Java 8.
Спустя многие тысячи языков программирования теоретики пришли к заключению, что языки программирования подобны экосистемам: новые языки возникают и вытесняют старые, если только последние не эволюционируют. Мы все мечтаем об идеальном универсальном языке, но на практике для конкретных сфер лучше подходят определенные языки. Например, C и C++ остаются популярными языками создания операционных систем и различных встраиваемых систем благодаря малым требованиям к ресурсам, хотя в них нет типобезопасности. Отсутствие типобезопасности может приводить к внезапным сбоям программ и возникновению открытых для вирусов уязвимостей; ничего удивительного, что Java и C# вытеснили C и C++ во многих типах приложений, где допустим дополнительный расход ресурсов.
Занятость ниши обычно расхолаживает соперников. Переход на новый язык программирования и набор программных средств зачастую оказывается слишком затратной вещью ради какой-то одной возможности, но новинки постепенно заменяют существующие языки, если те, конечно, не эволюционируют достаточно быстро. Читатели постарше, наверное, могут вспомнить целый список подобных канувших в Лету языков, на которых когда-то приходилось писать программы: Ada, Algol, COBOL, Pascal, Delphi и SNOBOL — и это лишь неполный список.
Вы программист на языке Java, успешно захватившем (вытесняя соперничавшие с ним языки) большую нишу вычислительных задач на период более 20 лет. Выясним, какие причины к этому привели.
1.2.1. Место языка Java в экосистеме языков программирования
Java весьма успешно стартовал. С самого начала он был хорошо продуманным объектно-ориентированным языком программирования с множеством удобных библиотек. В нем уже с первого дня была ограниченная поддержка конкурентности и встроенная поддержка потоков выполнения и блокировок (и пророческое признание — в виде аппаратно независимой модели памяти — того факта, что конкурентные потоки на многоядерных процессорах могут вести себя не так, как на одноядерных). Кроме того, решение компилировать код Java в байт-код (код для виртуальной машины, который скоро будут поддерживать все браузеры) привело к тому, что Java стал оптимальным языком для интернет-апплетов (помните, что такое апплеты?). Конечно, существует опасность, что виртуальную машину Java (JVM) и ее байт-код сочтут более важными, чем сам язык Java, и в определенных приложениях Java будет заменен одним из его языков-соперников, например Scala, Groovy или Kotlin, также работающими в JVM. Многие недавние усовершенствования JVM (например, новая инструкция байт-кода invokedynamic в JDK7) направлены на обеспечение беспроблемной работы подобных языков-соперников Java на JVM и их взаимодействия с Java. Java также успешно проявил себя в различных задачах встроенных вычислений в устройствах (от смарт-карт, тостеров и ТВ-приставок до тормозных систем автомобилей).
| Как Java вошел в нишу общего программирования Популярность объектно-ориентированного программирования (ООП) в 1990-х возросла по двум причинам: его строгие правила инкапсуляции снизили количество проблем разработки программного обеспечения (ПО) по сравнению с языком C; а в качестве ментальной модели оно с легкостью отражало модель программирования WIMP (https://ru.wikipedia.org/wiki/WIMP) Windows 95 и более поздних версий. Если вкратце, то любая сущность представляет собой объект; щелчок кнопкой мыши приводит к отправке обработчику сообщения о событии (вызову метода clicked объекта Mouse). Модель «напиши один раз, запускай сколько угодно раз» языка Java и умение ранних версий браузеров безопасно выполнять Java-апплеты обеспечили ему свою нишу в вузах, выпускники которых затем стали разработчиками в промышленных компаниях. Хотя сначала многие сопротивлялись из-за дополнительных затрат ресурсов на выполнение кода Java по сравнению с C/C++, но машины становились все быстрее, а ценность рабочего времени программистов увеличивалась. Язык C# от Microsoft еще более упрочнил положение объектно-ориентированной модели в стиле Java. |
Но климат в экосистеме языков программирования меняется, программисты все чаще имеют дело с так называемыми большими данными (наборы данных, чей размер исчисляется в терабайтах и более) и хотят эффективно использовать для их обработки многоядерные компьютеры и кластеры компьютеров. А это означает необходимость параллельной обработки, которая ранее не была сильной стороной Java. Возможно, вы уже слышали о разработках из других областей программирования (например, о созданной Google технологии Map-Reduce или относительном упрощении манипулирования данными с помощью языков запросов баз данных вроде SQL), позволяющих справляться с большими объемами данных и многоядерными CPU.
Рисунок 1.1 демонстрирует всю экосистему языков в графическом виде: можете считать ландшафт пространством вычислительных задач, а преобладающую на конкретном участке почвы растительность — наиболее популярным языком для решения конкретного типа задач. Изменение климата соответствует представлению о том, что новое аппаратное обеспечение и новые тенденции программирования (например, закрадывающаяся в голову программиста мысль: «Почему я не могу программировать в стиле SQL?») постепенно делают различные языки предпочтительными для новых проектов, подобно тому как благодаря глобальному потеплению виноград теперь растет в более высоких широтах. Но отмечается некоторое запаздывание — многие старые фермеры продолжают выращивать традиционные культуры. Подводя итог вышесказанному: новые языки программирования появляются и становятся все популярнее потому, что быстро приспособились к изменениям климата.
Рис. 1.1. Экосистема языков программирования и изменения климата
Главная польза от новшеств Java 8 для программиста: дополнительные утилиты и концепции для более быстрого (или, что важнее, с применением более лаконичного и лучше сопровождаемого способа) решения новых и существующих вычислительных задач. Хотя эти концепции для Java в новинку, они уже доказали свою пригодность в нишевых языках, предназначенных скорее для научных исследований. В следующих разделах мы проиллюстрируем и разовьем идеи, лежащие в основе трех подобных концепций, приведших к разработке таких возможностей Java 8, которые способствовали использованию параллелизма и повышению лаконичности кода. Мы познакомим вас с ними несколько в иной очередности, чем они будут приводиться в остальной части книги, чтобы провести аналогию с Unix-системами и продемонстрировать зависимости типа «нужно это, потому что то», характерные для многоядерного параллелизма Java 8.
| Еще один фактор, меняющий климат для Java Архитектура больших систем — один из факторов, меняющих климат. Сегодня большие системы обычно включают взятые откуда-либо крупные подсистемы, которые, в свою очередь, часто создаются на основе компонентов от других поставщиков. Хуже всего, что эти компоненты и их интерфейсы также развиваются. Для решения этой проблемы Java 8 и Java 9 предоставляют методы с реализацией по умолчанию и модули, способствующие такому стилю архитектуры. |
В следующих подразделах мы рассмотрим три концепции, обусловившие архитектуру Java 8.
1.2.2. Потоковая обработка
Первая из этих концепций — потоковая обработка (stream processing). Если не вдаваться в подробности, то поток данных (stream) — это последовательность элементов данных, генерируемых в общем случае по одному. Программа может, например, читать данные из входного потока по одному элементу и аналогично записывать элементы в выходной поток. Выходной поток одной программы вполне может служить входным потоком другой.
В качестве примера из практики можно привести Unix или Linux, в которых многие программы читают данные из стандартного потока ввода (stdin в Unix и C, System.in в языке Java), производят над ними какие-либо действия и записывают результаты этих действий в стандартный поток вывода (stdout в Unix и C, System.out в языке Java). Небольшое пояснение: команда cat операционной системы Unix создает поток данных путем конкатенации двух файлов, tr преобразует символы из потока, sort сортирует строки потока, а команда tail-3 возвращает три последние строки из потока. Командная строка Unix предоставляет возможность связывать подобные программы цепочкой с помощью символов конвейера (|), например:
cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
Эта команда (при условии, что файлы file1 и file2 содержат по одному слову на строку) выводит из указанных файлов три последних (в лексикографическом порядке) слова, предварительно преобразовав их в нижний регистр. При этом говорится, что sort получает на входе поток строк4 и генерирует на выходе другой поток (отсортированный), как показано на рис. 1.2. Отметим, что в операционной системе Unix эти команды (cat, tr, sort и tail) выполняются конкурентным образом, так что команда sort может приступить к обработке нескольких первых строк, хотя cat и tr еще не завершили работу. Можно привести аналогию из области машиностроения. Представьте конвейер сборки автомобилей с потоком машин, выстроенных в очередь между пунктами сборки, каждый из которых получает на входе машину, вносит в нее изменения и передает на следующий пункт для дальнейшей обработки; обработка в отдельных пунктах обычно выполняется конкурентно, несмотря на то что конвейер сборки физически последователен.
Рис. 1.2. Обработка потока данных командами Unix
В Java 8 в пакете java.util.stream появляется основанный на этой идее Stream API; объект Stream<T> представляет собой последовательность элементов типа T. Можете пока считать его необычным итератором. В Stream API есть множество методов, допускающих создание сложных конвейеров путем связывания их цепочкой, аналогично тому, как в предыдущем примере связывались команды Unix.
Главный вывод из этого: теперь появляется возможность создавать программы на Java 8 на более высоком уровне абстракции и мыслить в терминах преобразования потока одних элементов в поток других (аналогично написанию запросов баз данных), а не элементов поодиночке. Еще одно преимущество Java 8: возможность прозрачного выполнения конвейера потоковых операций несколькими ядрами CPU, работающими над непересекающимися подмножествами входных данных — параллелизм практически даром вместо кропотливой работы с использованием потоков выполнения. Мы рассмотрим Stream API Java 8 подробнее в главах 4–7.
1.2.3. Передача кода в методы и параметризация поведения
Вторая из добавленных в Java 8 концепций — возможность передачи в API фрагментов кода. Это звучит очень абстрактно. Если говорить в терминах примера с Unix-командами, то речь может идти, скажем, о передаче команде sort информации о пользовательском способе сортировки. Хотя команда sort поддерживает параметры командной строки, позволяющие выполнять сортировки различных видов (например, сортировку в обратном порядке), эти возможности ограничены.
Допустим, у вас имеется коллекция идентификаторов счетов-фактур в таком формате: 2013UK0001, 2014US0002 и т. д. Первые четыре цифры означают год, следующие две — код страны, а последние четыре — идентификатор клиента. Вы хотели бы отсортировать эти идентификаторы счетов-фактур по году или, например, по идентификатору покупателя или даже коду страны. По существу, вам хотелось бы передать команде sort в качестве аргумента информацию о задаваемом пользователем порядке сортировки в виде отдельного фрагмента кода.
Если провести прямую параллель с Java, то требуется сообщить методу sort о необходимости выполнять сравнение с учетом заданного пользователем порядка. Вы могли написать метод compareUsingCustomerId для сравнения двух идентификаторов счетов-фактур, но в предшествующих Java 8 версиях не было возможности передать его другому методу! Можно создать объект типа Comparator для передачи методу sort, как мы показывали в начале этой главы, но это потребовало бы большого количества кода и только затуманивало бы простую идею переиспользования существующего поведения. В Java 8 появляется возможность передавать методы (ваш код) в качестве аргумента другим методам. Эта идея проиллюстрирована на рис. 1.3, в основе которого лежит рис. 1.2. С теоретической точки зрения это параметризация поведения (behavior parameterization). Почему она так важна? Потому, что в основе Stream API лежит идея передачи кода для параметризации поведения операций, подобно вышеприведенной передаче метода compareUsingCustomerId для параметризации поведения метода sort.
Рис. 1.3. Передача метода compareUsingCustomerId в виде аргумента методу sort
В разделе 1.3 мы вкратце опишем, как это работает, но придержим подробности до глав 2 и 3. Главы 18 и 19 посвящены более продвинутому применению этой возможности — с помощью методик функционального программирования.
1.2.4. Параллелизм и разделяемые изменяемые данные
Третья концепция носит менее явный характер и обусловлена фразой «параллелизм практически даром», упомянутой в нашем обсуждении потоковой обработки. Чем же приходится пожертвовать? Вам придется несколько изменить способ написания кода поведения, передаваемого методам потоков. Сначала эти изменения могут доставить небольшие неудобства, но они вам понравятся, как только вы к ним привыкнете. Вы должны обеспечить безопасность конкурентного выполнения передаваемого поведения в различных частях входных данных. Обычно это означает написание кода, который не обращается к разделяемым изменяемым данным. Иногда такие функции называют чистыми (pure functions), или функциями без побочных эффектов (side-effect-free functions), или функциями без сохранения состояния (stateless functions). Мы обсудим их подробнее в главах 18 и 19.
Вышеупомянутый параллелизм возможен, только если несколько копий фрагмента кода могут работать независимо друг от друга. Если производится запись в разделяемую переменную или объект, то работать ничего не будет. Ведь что случится, если двум процессам понадобится изменить эту разделяемую переменную одновременно? В разделе 1.4 приведено более подробное пояснение со схемой, и далее в книге мы еще поговорим о таком стиле написания программ.
Потоки данных Java 8 позволяют удобнее использовать параллелизм, чем существующий Thread API, поскольку можно нарушить правило отсутствия разделяемых изменяемых данных с помощью ключевого слова synchronized, хотя это приведет к неправильному использованию абстракции, оптимизированной в расчете на это правило. Применение synchronized в многоядерной системе требует гораздо больше ресурсов, чем можно предположить, поскольку синхронизация приводит к обязательному выполнению кода последовательно, что противоречит самой идее параллелизма.
Две из этих концепций (отсутствие разделяемых изменяемых данных и возможность передачи методов и функций, то есть кода, другим методам) — краеугольные камни парадигмы функционального программирования (functional programming), которую мы обсудим подробно в главах 18 и 19. Напротив, в парадигме императивного программирования (imperative programming) программа обычно описывается на языке последовательности операторов, изменяющих состояние. Требование отсутствия разделяемых изменяемых данных означает, что метод прекрасно описывается тем, как он преобразует аргументы в результаты; другими словами, этот метод ведет себя как математическая функция, у него нет (видимых) побочных эффектов.
1.2.5. Язык Java должен развиваться
Вы уже встречались с эволюцией языка Java. Например, появление обобщенных типов данных и использование List<String> вместо List поначалу могло вас раздражать. Но теперь вы уже знакомы с подобным стилем программирования и его преимуществами (перехват большего количества ошибок во время компиляции и повышение удобочитаемости кода, поскольку известно, из чего состоит данный список).
Благодаря другим изменениям стало проще выражать часто встречающиеся сущности (например, цикл for-each вместо шаблонного кода для Iterator). Основные изменения в Java 8 отражают переход от ориентации на традиционные объекты, часто связанной с изменением существующих значений, к многообразию функционального программирования (ФП). При ФП главное — что вы хотели бы сделать в общих чертах (например, создать значение, отражающее все маршруты транспорта из пункта А в пункт Б со стоимостью, не превышающей заданной), оно отделяется от способа достижения цели (например, просмотра структуры данных с модификацией определенных компонентов). Может показаться, что традиционное объектно-ориентированное и функциональное программирование, будучи противоположностями, конфликтуют. Но идея заключается именно в том, чтобы взять от обеих парадигм программирования лучшее и получить оптимальный инструмент для своей задачи. Мы обсудим это подробнее в разделах 1.3 и 1.4.
Из этого можно сделать следующий вывод: языки программирования должны эволюционировать, чтобы не отставать от изменений аппаратного обеспечения и ожиданий программистов (если вы все еще сомневаетесь в этом, задумайтесь, что COBOL когда-то считался одним из важнейших с коммерческой точки зрения языков программирования). Чтобы выжить, Java должен эволюционировать путем добавления новых возможностей. Такая эволюция бесцельна, если эти новые возможности не применяются, так что, используя Java 8, вы тем самым защищаете свой образ жизни как Java-программиста. Кроме того, нам кажется, что вам понравятся новые возможности Java 8. Спросите кого угодно из тех, кто уже использовал Java 8, хотели бы они вернуться к старым версиям? Вдобавок новые возможности Java 8 могут, говоря в терминах аналогии с экосистемой, помочь Java завоевать территории вычислительных задач, занятых ныне другими языками программирования, так что спрос на программистов со знанием Java 8 еще возрастет.
Далее мы по очереди познакомим вас с новыми возможностями Java 8, указывая, в каких главах соответствующие концепции будут описаны подробнее.
1.3. Функции в Java
Слово «функция» (function) в языках программирования часто используется как синоним метода (method), особенно статического метода; помимо использования в качестве математической функции — функции без побочных эффектов. К счастью, как вы можете видеть, эти виды использования функций в случае Java 8 практически совпадают.
В Java 8 функции становятся новой разновидностью значений, что дает возможность использования потоков данных (см. раздел 1.4), которые служат для параллельного программирования на многоядерных процессорах. Мы начнем с того, что продемонстрируем полезность функций как значений самих по себе.
Программы на языке Java могут оперировать различными видами значений. Во-первых, существуют значения примитивных типов, например 42 (типа int) и 3.14 (типа double). Во-вторых, значения могут быть объектами (точнее, ссылками на объекты). Единственный способ получить такое значение — использовать ключевое слово new, возможно, посредством фабричного метода или библиотечной функции; ссылки на объекты указывают на экземпляры (instances) классов. В качестве примеров можно привести "abc" (типа String), new Integer(1111) (типа Integer) и результат явного вызова конструктора для HashMap: new HashMap<Integer, String>(100). Даже массивы являются объектами. Так в чем же проблема?
Чтобы ответить на этот вопрос, нужно отметить, что главная задача любого языка программирования — работа со значениями, которые, следуя исторической традиции языков программирования, называются полноправными значениями (или полноправными гражданами в терминологии движения в защиту гражданских прав в 1960-х годах в США). Другие структуры языков программирования, которые, может, и помогают выражать структуру значений, но которые нельзя передавать во время выполнения программы, — граждане «второго сорта». Перечисленные выше значения — полноправные граждане Java, а различные другие понятия Java, например методы и классы, — примеры граждан «второго сорта». Методы прекрасно подходят для описания классов, из которых можно, в свою очередь, получить значения путем создания экземпляров, но ни те ни другие сами по себе значениями не являются. Важно ли это? Да, оказывается, что возможность передавать методы во время выполнения программы (которая делает их полноправными гражданами) полезна при программировании, так что создатели Java 8 добавили в язык возможность явного выражения этого. Кстати, вы можете задаться вопросом: хорошая ли вообще идея превращать граждан «второго сорта», например классы, в полноправные значения? Эта дорога уже проторена различными языками программирования, в частности Smalltalk и JavaScript.
1.3.1. Методы и лямбда-выражения как полноправные граждане
Проведенные в других языках программирования, например Scala и Groovy, эксперименты показали, что возможность использования таких сущностей, как методы, в качестве полноправных значений упрощает программирование, расширяя доступный разработчикам набор инструментов. А когда программисты привыкают к этой замечательной возможности, то просто отказываются использовать языки, в которых она отсутствует! Создатели Java 8 решили позволить методам быть значениями — чтобы вам было проще писать программы. Более того, методы-значения из Java 8 закладывают фундамент для различных других возможностей Java 8 (например, потоков данных).
Первая из новых возможностей Java 8, с которой мы вас познакомим, — ссылки на методы (method references). Представьте себе, что вам нужно отфильтровать все скрытые файлы в каталоге. Для начала необходимо написать метод, который бы получал на входе объект File и возвращал информацию о том, скрытый ли этот файл. К счастью, в классе File такой метод уже есть — он называется isHidden. Его можно рассматривать как функцию, принимающую на входе объект File и возвращающую boolean. Но для выполнения фильтрации необходимо обернуть ее в объект FileFilter и передать его в метод File.listFiles:
Фу! Просто ужасно. Хотя этот фрагмент кода содержит только три значащие строки, все они трудны для понимания. Ведь каждый из нас помнит, как говорил при первом знакомстве с таким кодом: «Неужели это нужно делать именно так?» У вас уже есть метод isHidden, так зачем писать лишний код, обертывать его в класс FileFilter, создавать экземпляр этого класса?.. А затем, что до Java 8 это был единственный выход.
С появлением Java 8 можно переписать вышеприведенный код так:
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
Ух ты! Правда, круто? Мы просто передали функцию isHidden в метод listFiles с помощью появившегося в Java 8 синтаксиса ссылки на метод :: (означающего «использовать данный метод как значение»). Отметим также, что мы воспользовались для методов словом «функция». Мы объясним позднее, как это работает. Теперь наш код — и это плюс — стал намного ближе к формулировке нашей задачи.
Приоткроем вам завесу тайны: методы больше не значения «второго сорта». Аналогично созданию ссылки на объект (object references) для его передачи (а ссылки на объекты создаются с помощью ключевого слова new), при написании File::isHidden в Java 8 создается ссылка на метод (method reference), которую тоже можно передавать. Эту концепцию мы подробно обсудим в главе 3. А поскольку метод содержит код (исполняемое тело метода), то с помощью ссылок на методы можно передавать код, как показано на рис. 1.3. Рисунок 1.4 иллюстрирует эту концепцию. В следующем разделе мы покажем вам конкретный пример — выбор яблок из объекта inventory.
Рис. 1.4. Передача ссылки на метод File::isHidden в метод listFiles
Лямбда-выражения: анонимные функции. В Java 8 не только поименованные методы являются полноправными значениями, но и предлагается расширенное представление о функциях как значениях, включая лямбда-выражения (lambdas, анонимные функции)5. Например, теперь можно написать (int x) -> x + 1, что будет значить: «функция, которая, будучи вызванной с аргументом x, возвращает значение x + 1». Вы можете задаться вопросом, зачем это нужно, ведь можно определить метод add1 в классе MyMathsUtils и написать MyMathsUtils::add1! Да, так можно поступить, но новый синтаксис лямбда-выражений оказывается намного лаконичнее в тех случаях, когда подходящего метода и класса нет. Лямбда-выражения подробно обсуждаются в главе 3. Об использующих их программах говорят, что они написаны в стиле функционального программирования; эта фраза означает «программы, в которых функции передаются как полноправные значения».
1.3.2. Передача кода: пример
Рассмотрим пример того, как эта возможность упрощает написание программ (обсуждается подробнее в главе 2). Весь код примеров можно найти в репозитории GitHub, а также скачать с сайта книги. Обе ссылки вы найдете по адресу http://www.manning.com/books/modern-java-in-action. Пускай у вас есть класс Apple с методом getColor и переменной inventory, содержащей список Apples. Допустим, вам нужно отобрать все зеленые яблоки (в данном случае с помощью перечисляемого типа Color, включающего значения GREEN (Зеленый) и RED (Красный)) и вернуть их в виде списка. Для описания этой концепции часто используется слово «фильтр» (filter). До появления Java 8 вы могли бы написать, скажем, следующий метод filterGreenApples:
Но затем, возможно, кому-нибудь понадобится список тяжелых яблок (скажем, тяжелее 150 г), так что вам скрепя сердце придется написать следующий метод (вероятно, даже путем копирования и вставки):
Всем известно про опасности, которые несет метод копирования и вставки применительно к разработке программного обеспечения (выполненные в одном месте обновления и исправления ошибок не переносятся в другое место), и, в конце концов, эти два метода различаются только одной строкой: условным оператором внутри конструкции if, выделенным жирным шрифтом. Если бы различие между двумя вызовами методов в выделенном коде заключалось только в диапазоне допустимого веса яблока, то можно было бы передавать нижнюю и верхнюю границы диапазона в виде аргументов метода filter — скажем, (150,1000), чтобы выбрать тяжелые яблоки (тяжелее 150 г), и (0,80), чтобы выбрать легкие (легче 80 г).
Но, как мы уже упоминали, в Java 8 можно передавать в виде аргумента код условия, что позволяет избежать дублирования кода в методе filter. Теперь можно написать следующее:
Воспользоваться этим методом можно, выполнив либо вызов:
filterApples(inventory, Apple::isGreenApple);
либо вызов:
filterApples(inventory, Apple::isHeavyApple);
Мы поясним, как это работает, в следующих двух главах. Главный вывод из изложенного: в Java 8 можно передавать методы как значения.
| Что представляет собой интерфейс Predicate В вышеприведенном коде метод Apple::isGreenApple (принимающий в качестве параметра объект типа Apple и возвращающий значение типа boolean) передается в функцию filterApples, которая ожидает на входе параметр типа Predicate<Apple>. Слово «предикат» (predicate) часто используется в математике для обозначения сущностей, напоминающих функции, которые принимают значение в качестве аргумента и возвращают true или false. Как вы увидите далее, в Java 8 можно было бы написать и Function<Apple, Boolean> — это более привычная форма для тех из наших читателей, кому в школе рассказывали про функции, а не о предикатах, но форма Predicate<Apple> — общепринятая (и немного более эффективная, поскольку не требует преобразования примитивного типа boolean в объект типа Boolean). |
1.3.3. От передачи методов к лямбда-выражениям
Передавать методы как значения, безусловно, удобно, хотя весьма досаждает необходимость описывать даже короткие методы, такие как isHeavyApple и isGreenApple, которые используются только один-два раза. Но Java 8 решает и эту проблему. В нем появилась новая нотация (анонимные функции, называемые также лямбда-выражениями), благодаря которой можно написать просто:
filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()) );
или:
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
или даже:
filterApples(inventory, (Apple a) -> a.getWeight() < 80 ||
RED.equals(a.getColor()) );
Используемый однократно метод не требуется описывать; код становится четче и понятнее, поскольку не нужно тратить время на поиски по исходному коду, чтобы понять, какой именно код передается.
Но если размер подобного лямбда-выражения превышает несколько строк (и его поведение сразу не понятно), лучше воспользоваться вместо анонимной лямбды ссылкой на метод с наглядным именем. Понятность кода — превыше всего.
Создатели Java 8 могли на этом практически завершить свою работу и, наверное, так бы и поступили, если бы не многоядерные процессоры. Как вы увидите, функциональное программирование в представленном нами объеме обладает весьма широкими возможностями. Язык Java мог бы в качестве вишенки на торте добавить обобщенный библиотечный метод filter и несколько родственных ему, например:
static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);
Вам не пришлось бы даже писать самим такие методы, как filterApples, поскольку даже предыдущий вызов:
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
можно было бы переписать в виде обращения к библиотечному методу filter:
filter(inventory, (Apple a) -> a.getWeight() > 150 );
Но из соображений оптимального использования параллелизма создатели Java не пошли по этому пути. Вместо этого Java 8 включает новый Stream API (потоков данных, сходных с коллекциями), содержащий обширный набор подобных filter-операций, привычных функциональным программистам (например, map и reduce), а также методы для преобразования коллекций в потоки данных и наоборот. Этот API мы сейчас и обсудим.
1.4. Потоки данных
Практически любое приложение Java создает и обрабатывает коллекции. Однако работать с коллекциями не всегда удобно. Например, представьте себе, что вам нужно отфильтровать из списка транзакции на крупные суммы, а затем сгруппировать их по валюте. Для реализации подобного запроса по обработке данных вам пришлось бы написать огромное количество стереотипного кода, как показано ниже:
Помимо прочего, весьма непросто понять с первого взгляда, что этот код делает, из-за многочисленных вложенных операторов управления потоком выполнения.
С помощью Stream API можно решить ту же задачу следующим образом:
Не стоит переживать, если этот код пока кажется вам своего рода магией. Главы 4–7 помогут вам разобраться со Stream API. Пока стоит отметить лишь, что этот API позволяет обрабатывать данные несколько иначе, чем Collection API. При использовании коллекции вам приходится организовывать цикл самостоятельно: пройтись по всем элементам, обрабатывая их по очереди в цикле for-each. Подобная итерация по данным называется внешней итерацией (external iteration). При использовании же Stream API думать о циклах не нужно. Вся обработка данных происходит внутри библиотеки. Мы будем называть это внутренней итерацией (internal iteration).
Вторая основная проблема при работе с коллекциями: как обработать список транзакций, если их очень много? Одноядерный процессор не сможет обработать такой большой объем данных, но, вероятно, процессор вашего компьютера — многоядерный. Оптимально было бы разделить объем работ между ядрами процессора, чтобы уменьшить время обработки. Теоретически при наличии восьми ядер обработка данных должна занять в восемь раз меньше времени, поскольку они работают параллельно.
| Многоядерные компьютеры Все новые настольные компьютеры и ноутбуки — многоядерные. Они содержат не один, а несколько процессоров (обычно называемых ядрами). Проблема в том, что обычная программа на языке Java использует лишь одно из этих ядер, а остальные простаивают. Аналогично во многих компаниях для эффективной обработки больших объемов данных применяются вычислительные кластеры (computing clusters) — компьютеры, соединенные быстрыми сетями. Java 8 продвигает новые стили программирования, чтобы лучше использовать подобные компьютеры. Поисковый движок Google — пример кода, который слишком велик для работы на отдельной машине. Он читает все страницы в Интернете и создает индекс соответствия всех встречающихся на каждой странице слов адресу (URL) этой страницы. Далее при поиске в Google по нескольким словам ПО может воспользоваться этим индексом для быстрой выдачи набора страниц, содержащих эти слова. Попробуйте представить себе реализацию этого алгоритма на языке Java (даже в случае гораздо меньшего индекса, чем у Google, вам придется использовать все процессорные ядра вашего компьютера). |
1.4.1. Многопоточность — трудная задача
Проблема в том, что реализовать параллелизм, прибегнув к многопоточному (multithreaded) коду (с помощью Thread API из предыдущих версий Java), — довольно непростая задача. Лучше воспользоваться другим способом, ведь потоки выполнения могут одновременно обращаться к разделяемым переменным и изменять их. В результате данные могут меняться самым неожиданным образом, если их использование не согласовано, как полагается6. Такая модель сложнее для понимания7, чем последовательная, пошаговая модель. Например, одна из возможных проблем с двумя (не синхронизированными должным образом) потоками выполнения, которые пытаются прибавить число к разделяемой переменной sum, приведена на рис. 1.5.
Рис. 1.5. Возможная проблема с двумя потоками выполнения, пытающимися прибавить число к разделяемой переменной sum. Результат равен 105 вместо ожидаемых 108
Java 8 решает обе проблемы (стереотипность и малопонятность кода обработки коллекций, а также трудности использования многоядерности) с помощью Stream API (java.util.stream). Первый фактор, определивший архитектуру этого API, — существование множества часто встречающихся паттернов обработки данных (таких как filterApples из предыдущего раздела или операции, знакомые вам по языкам запросов вроде SQL), которые разумно было бы поместить в библиотеку: фильтрация данных по какому-либо критерию (например, выбор тяжелых яблок), извлечение (например, извлечение значения поля «вес» из всех яблок в списке) или группировка данных (например, группировка списка чисел по отдельным спискам четных и нечетных чисел) и т. д. Второй фактор — часто встречающаяся возможность распараллеливания подобных операций. Например, как показано на рис. 1.6, фильтрацию списка на двух процессорах можно осуществить путем его разбиения на две половины, одну из которых будет обрабатывать один процессор, а вторую — другой. Это называется шагом ветвления (forking step)
Пока мы скажем только, что новый Stream API ведет себя аналогично Collection API: оба обеспечивают доступ к последовательностям элементов данных. Но запомните: Collection API в основном связан с хранением и доступом к данным, а Stream API — с описанием производимых над данными вычислений. Важнее всего то, что Stream API позволяет и поощряет параллельную обработку элементов потока. Хотя на первый взгляд это может показаться странным, но часто самый быстрый способ фильтрации коллекции (например, использовать filterApples из предыдущего раздела для списка) — преобразовать ее в поток данных, обработать параллельно и затем преобразовать обратно в список. Мы снова скажем «параллелизм практически даром» и покажем вам, как отфильтровать тяжелые яблоки из списка последовательно и как — параллельно, с помощью потоков данных и лямбда-выражения.
Рис. 1.6. Ветвление filter на два процессора и объединение результатов
Вот пример последовательной обработки:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
А вот пример параллельной обработки:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
| Параллелизм в языке Java и отсутствие разделяемого изменяемого состояния Параллелизм в Java всегда считался непростой задачей, а ключевое слово synchronized — источником потенциальных ошибок. Так что же за чудодейственное средство появилось в Java 8? |
| На самом деле чудодейственных средств два. Во-первых, библиотека, обеспечивающая секционирование — разбиение больших потоков данных на несколько маленьких, для параллельной обработки. Во-вторых, параллелизм «практически даром» с помощью потоков возможен только в том случае, если методы, передаваемые библиотечным методам, таким как filter, не взаимодействуют между собой (например, через изменяемые разделяемые объекты). Но оказывается, что это ограничение представляется программистам вполне естественным (см. в качестве примера наш Apple::isGreenApple). Хотя основной смысл слова «функциональный» во фразе «функциональное программирование» — «использующий функции в качестве полноправных значений», у него часто есть оттенок «без взаимодействия между компонентами во время выполнения». |
В главе 7 вы найдете более подробное обсуждение параллельной обработки данных в Java 8 и вопросов ее производительности. Одна из проблем, с которыми столкнулись на практике разработчики Java, несмотря на все ее замечательные новинки, касалась эволюции существующих интерфейсов. Например, метод Collections.sort относится к интерфейсу List, но так и не был в него включен. В идеале хотелось бы иметь возможность написать list.sort(comparator) вместо Collections.sort(list, comparator). Это может показаться тривиальным, но до Java 8 для обновления интерфейса необходимо было обновить все реализующие его классы — просто логистический кошмар! Данная проблема решена в Java 8 с помощью методов с реализацией по умолчанию.
1.5. Методы с реализацией по умолчанию и модули Java
Как мы уже упоминали ранее, современные системы обычно строятся из компонентов — возможно, приобретенных на стороне. Исторически в языке Java не было поддержки этой возможности, за исключением JAR-файлов, хранящих набор Java-пакетов без четкой структуры. Более того, развивать интерфейсы, содержащиеся в таких пакетах, было непросто — изменение Java-интерфейса означало необходимость изменения каждого реализующего его класса. Java 8 и 9 вступили на путь решения этой проблемы.
Во-первых, в Java 9 есть система модулей и синтаксис для описания модулей, содержащих наборы пакетов, а контроль видимости и пространств имен значительно усовершенствован. Благодаря модулям у простого компонента типа JAR появляется структура, как для пользовательской документации, так и для автоматической проверки (мы расскажем об этом подробнее в главе 14). Во-вторых, в Java 8 появились методы с реализацией по умолчанию для поддержки изменяемых интерфейсов. Мы рассмотрим их подробнее в главе 13. Знать про них важно, ведь они часто будут встречаться вам в интерфейсах, но, поскольку относительно немногим программистам приходится самим писать методы с реализацией по умолчанию, а также поскольку они скорее способствуют эволюции программ, а не помогают написанию какой-либо конкретной программы, мы расскажем здесь о них кратко, на примере.
В разделе 1.4 приводился следующий пример кода Java 8:
List<Apple> heavyApples1 =
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
List<Apple> heavyApples2 =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
С этим кодом есть проблема: до Java 8 в интерфейсе List<T> не было методов stream или parallelStream — как и в интерфейсе Collection<T>, который он реализует, — поскольку эти методы еще не придумали. А без них такой код не скомпилируется. Простейшее решение для создателей Java 8, какое вы могли бы использовать для своих интерфейсов, — добавить метод stream в интерфейс Collection и его реализацию в класс ArrayList.
Но это стало бы настоящим кошмаром для пользователей, ведь множество разных фреймворков коллекций реализуют интерфейсы из Collection API. Добавление нового метода в интерфейс означает необходимость реализации его во всех конкретных классах. У создателей языка нет контроля над существующими реализациями интерфейса Collection, так что возникает дилемма: как развивать опубликованные интерфейсы, не нарушая при этом существующие реализации?
Решение, предлагаемое Java 8, заключается в разрыве последнего звена этой цепочки: отныне интерфейсы могут содержать сигнатуры методов, которые не реализуются в классе-реализации. Но кто же тогда их реализует? Отсутствующие тела методов описываются в интерфейсе (поэтому и называются реализациями по умолчанию), а не в реализующем классе.
Благодаря этому у разработчика интерфейса появляется способ увеличить интерфейс, выйдя за пределы изначально планировавшихся методов и не нарушая работу существующего кода. Для этого в Java 8 в спецификациях интерфейсов можно использовать уже существующее ключевое слово default.
Например, в Java 8 можно вызвать метод sort непосредственно для списка. Это возможно благодаря следующему методу с реализацией по умолчанию из интерфейса List, вызывающему статический метод Collections.sort:
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
Это значит, что конкретные классы интерфейса List не обязаны явным образом реализовывать метод sort, в то время как в предыдущих версиях Java без такой реализации они бы не скомпилировались.
Но подождите секундочку. Один класс может реализовывать несколько интерфейсов, так? Не будут ли несколько реализаций по умолчанию в нескольких интерфейсах означать возможность своего рода множественного наследования в Java? Да, до некоторой степени. В главе 13 мы обсудим правила, предотвращающие такие проблемы, как печально известная проблема ромбовидного наследования (diamond inheritance problem) в языке C++.
1.6. Другие удачные идеи, заимствованные из функционального программирования
В предыдущих разделах мы познакомили вас с двумя основными идеями функционального программирования, которые ныне стали частью Java: с использованием методов и лямбда-выражений как полноправных значений, а также с идеей эффективного и безопасного параллельного выполнения функций и методов при условии отсутствия изменяемого разделяемого состояния. Обе эти идеи были воплощены в описанном выше новом Stream API.
В распространенных функциональных языках (SML, OCaml, Haskell) есть и другие конструкции, помогающие программистам в их работе. В их числе возможность обойтись без null за счет более явных типов данных. Тони Хоар (Tony Hoare), один из корифеев теории вычислительной техники, сказал на презентации во время конференции QCon в Лондоне в 2009 году:
«Я считаю это своей ошибкой на миллиард долларов: изобретение ссылки на null в 1965-м... Я поддался искушению включить в язык ссылку на null просто потому, что ее реализация была так проста».
В Java 8 появился класс Optional<T>, который при систематическом использовании помогает избегать исключений, связанных с указателем на null. Это объект-контейнер, который может содержать или не содержать значение. В Optional<T> есть методы для обработки явным образом варианта отсутствия значения, в результате чего можно избежать исключений, связанных с указателем на null. С помощью системы типов он дает возможность отметить, что у переменной может потенциально отсутствовать значение. Мы обсудим класс Optional<T> подробнее в главе 11.
Еще одна идея — (структурное) сопоставление с шаблоном8, используемое в математике. Например:
f(0) = 1
в противном случае f(n) = n*f(n-1)
В Java для этого можно написать оператор if-then-else или switch. Другие языки продемонстрировали, что в случае более сложных типов данных сопоставление с шаблоном позволяет выражать идеи программирования лаконичнее по сравнению с оператором if-then-else. Для таких типов данных в качестве альтернативы if-then-else можно также использовать полиморфизм и переопределение методов, но дискуссия относительно того, что уместнее, все еще продолжается9. Мы полагаем, что и то и другое — полезные инструменты, которые не будут лишними в вашем арсенале. К сожалению, Java 8 не полностью поддерживает сопоставление с шаблоном, хотя в главе 19 мы покажем, как его можно выразить на этом языке. В настоящий момент обсуждается предложение по расширению Java — добавление в будущие версии языка поддержки сопоставления с шаблоном (см. http://openjdk.java.net/jeps/305). Тем временем в качестве иллюстрации приведем пример на языке программирования Scala (еще один Java-подобный язык, использующий JVM и послуживший источником вдохновения для некоторых аспектов эволюции Java; см. главу 20). Допустим, вы хотите написать программу для элементарных упрощений дерева, представляющего арифметическое выражение. В Scala можно написать следующий код для декомпозиции объекта типа Expr, служащего для представления подобных выражений, с последующим возвратом другого объекта Expr:
Синтаксис expr match языка Scala соответствует switch (expr) в языке Java. Пока не задумывайтесь над этим кодом — мы расскажем о сопоставлении с шаблоном подробнее в главе 19. Сейчас можете считать сопоставление с шаблоном просто расширенным вариантом switch, способным в то же время разбивать тип данных на компоненты.
Но почему нужно ограничивать оператор switch в Java простыми типами данных и строками? Функциональные языки программирования обычно позволяют использовать switch для множества других типов данных, включая возможность сопоставления с шаблоном (в коде на языке Scala это делается с помощью операции match). Для прохода по объектам семейства классов (например, различных компонентов автомобиля: колес (Wheel), двигателя (Engine), шасси (Chassis) и т. д.) с применением какой-либо операции к каждому из них в объектно-ориентированной архитектуре часто применяется паттерн проектирования «Посетитель» (Visitor). Одно из преимуществ сопоставления с шаблоном — возможность для компилятора выявлять типичные ошибки вида: «Класс Brakes — часть семейства классов, используемых для представления компонентов класса Car. Вы забыли обработать его явным образом».
Главы 18 и 19 представляют собой полное введение в функциональное программирование и написание программ в функциональном стиле на языке Java 8, включая описание набора имеющихся в его библиотеке функций. Глава 20 развивает эту тему сравнением возможностей Java 8 с возможностями языка Scala — языка, в основе которого также лежит использование JVM и который эволюционировал настолько быстро, что уже претендует на часть ниши Java в экосистеме языков программирования. Мы разместили этот материал ближе к концу книги, чтобы вам было понятнее, почему были добавлены те или иные новые возможности Java 8 и Java 9.
| Возможности Java 8, 9, 10 и 11: с чего начать? В Java 8, как и в Java 9, были внесены значительные изменения. Но повседневную практику Java-программистов в масштабе мелких фрагментов кода, вероятно, сильнее всего затронут дополнения, внесенные в восьмую версию: идея передачи метода или лямбда-выражения быстро становится жизненно важным знанием в Java. Напротив, усовершенствования Java 9 расширяют возможности описания и использования крупных компонентов, идет ли речь о модульном структурировании системы или импорте набора инструментов для реактивного программирования. Наконец, Java 10 вносит намного меньшие изменения, по сравнению с предыдущими двумя версиями, — они состоят из правил вывода типов для локальных переменных, которые мы вкратце обсудим в главе 21, где также упомянем связанный с ними расширенный синтаксис для аргументов лямбда-выражений, который введен в Java 11. Java 11 был выпущен в сентябре 2018 года и включает обновленную асинхронную клиентскую библиотеку HTTP (http://openjdk.java.net/jeps/321), использующую новые возможности Java 8 и 9 (подробности см. в главах 15–17) — CompletableFuture и реактивное программирование. |
Резюме
• Всегда учитывайте идею экосистемы языков программирования и следующее из нее бремя необходимости для языков эволюционировать или увядать. Хотя на текущий момент Java более чем процветающий язык, можно вспомнить и другие процветавшие языки, такие как COBOL, которые не сумели эволюционировать.
• Основные новшества Java 8 включают новые концепции и функциональность, облегчающую написание лаконичных и эффективных программ.
• Возможности многоядерных процессоров плохо использовались в Java до версии 8.
• Функции стали полноправными значениями; методы теперь можно передавать в виде функциональных значений. Обратите также внимание на возможность написания анонимных функций (лямбда-выражений).
• Концепция потоков данных Java 8 во многом обобщает понятие коллекций, но зачастую позволяет получить более удобочитаемый код и обрабатывать элементы потока параллельно.
• Программирование с использованием крупномасштабных компонентов, а также эволюция интерфейсов системы исторически плохо поддерживались языком Java. Методы с реализацией по умолчанию появились в Java 8. Они позволяют расширять интерфейс без изменения всех реализующих его классов.
• В числе других интересных идей из области функционального программирования — обработка null и сопоставление с шаблоном.
2 У многоядерных CPU есть отдельный кэш (быстрая память) для каждого ядра процессора. Для блокировки нужна синхронизация этих кэшей, требующая относительно медленного взаимодействия между ядрами по протоколу, сохраняющему когерентность кэша.
3 Landin P.J. The Next 700 Programming Languages. CACM 9(3): 157–165, март 1966 [Электронный ресурс]. — Режим доступа: http://www.math.bas.bg/bantchev/place/iswim/next700.pdf.
4 Приверженцы строгости формулировок сказали бы «поток символов», но для понятности проще считать, что команда sort упорядочивает строки.
5 Названы в честь греческой буквы λ. Хотя сам этот символ в Java не используется, название осталось.
6 Обычно это делают с помощью ключевого слова synchronized, но если указать его не там, где нужно, то может возникнуть множество ошибок, которые трудно обнаружить. Параллелизм на основе потоков данных Java 8 поощряет программирование в функциональном стиле, при котором synchronized используется редко; упор делается на секционировании данных, а не на координации доступа к ним.
7 А вот и одна из причин, побуждающих язык эволюционировать!
8 Эту фразу можно толковать двояко. Одно из толкований знакомо нам из математики и функционального программирования, в соответствии с ним функция определяется операторами case, а не с помощью конструкции if-then-else. Второе толкование относится к фразам типа «найти все файлы вида 'IMG*.JPG' в заданном каталоге» и связано с так называемыми регулярными выражениями.
9 Введение в эту дискуссию можно найти в статье «Википедии», посвященной «проблеме выражения» (expression problem — термин, придуманный Филом Уэдлером (Phil Wadler)).
Проблема в том, что реализовать параллелизм, прибегнув к многопоточному (multithreaded) коду (с помощью Thread API из предыдущих версий Java), — довольно непростая задача. Лучше воспользоваться другим способом, ведь потоки выполнения могут одновременно обращаться к разделяемым переменным и изменять их. В результате данные могут меняться самым неожиданным образом, если их использование не согласовано, как полагается6. Такая модель сложнее для понимания7, чем последовательная, пошаговая модель. Например, одна из возможных проблем с двумя (не синхронизированными должным образом) потоками выполнения, которые пытаются прибавить число к разделяемой переменной sum, приведена на рис. 1.5.
Эта команда (при условии, что файлы file1 и file2 содержат по одному слову на строку) выводит из указанных файлов три последних (в лексикографическом порядке) слова, предварительно преобразовав их в нижний регистр. При этом говорится, что sort получает на входе поток строк4 и генерирует на выходе другой поток (отсортированный), как показано на рис. 1.2. Отметим, что в операционной системе Unix эти команды (cat, tr, sort и tail) выполняются конкурентным образом, так что команда sort может приступить к обработке нескольких первых строк, хотя cat и tr еще не завершили работу. Можно привести аналогию из области машиностроения. Представьте конвейер сборки автомобилей с потоком машин, выстроенных в очередь между пунктами сборки, каждый из которых получает на входе машину, вносит в нее изменения и передает на следующий пункт для дальнейшей обработки; обработка в отдельных пунктах обычно выполняется конкурентно, несмотря на то что конвейер сборки физически последователен.
В 1960-х годах начался поиск идеального языка программирования. Питер Лэндин (Peter Landin), известный программист того времени, в 1966 году в своей исторической статье3 отметил, что на тот момент уже существовало 700 языков программирования, и привел рассуждения на тему, какими будут следующие 700, включая доводы в пользу функционального программирования в стиле, аналогичном стилю Java 8.
У многоядерных CPU есть отдельный кэш (быстрая память) для каждого ядра процессора. Для блокировки нужна синхронизация этих кэшей, требующая относительно медленного взаимодействия между ядрами по протоколу, сохраняющему когерентность кэша.
В Java для этого можно написать оператор if-then-else или switch. Другие языки продемонстрировали, что в случае более сложных типов данных сопоставление с шаблоном позволяет выражать идеи программирования лаконичнее по сравнению с оператором if-then-else. Для таких типов данных в качестве альтернативы if-then-else можно также использовать полиморфизм и переопределение методов, но дискуссия относительно того, что уместнее, все еще продолжается9. Мы полагаем, что и то и другое — полезные инструменты, которые не будут лишними в вашем арсенале. К сожалению, Java 8 не полностью поддерживает сопоставление с шаблоном, хотя в главе 19 мы покажем, как его можно выразить на этом языке. В настоящий момент обсуждается предложение по расширению Java — добавление в будущие версии языка поддержки сопоставления с шаблоном (см. http://openjdk.java.net/jeps/305). Тем временем в качестве иллюстрации приведем пример на языке программирования Scala (еще один Java-подобный язык, использующий JVM и послуживший источником вдохновения для некоторых аспектов эволюции Java; см. главу 20). Допустим, вы хотите написать программу для элементарных упрощений дерева, представляющего арифметическое выражение. В Scala можно написать следующий код для декомпозиции объекта типа Expr, служащего для представления подобных выражений, с последующим возвратом другого объекта Expr:
Landin P.J. The Next 700 Programming Languages. CACM 9(3): 157–165, март 1966 [Электронный ресурс]. — Режим доступа: http://www.math.bas.bg/bantchev/place/iswim/next700.pdf.
Обычно это делают с помощью ключевого слова synchronized, но если указать его не там, где нужно, то может возникнуть множество ошибок, которые трудно обнаружить. Параллелизм на основе потоков данных Java 8 поощряет программирование в функциональном стиле, при котором synchronized используется редко; упор делается на секционировании данных, а не на координации доступа к ним.
А вот и одна из причин, побуждающих язык эволюционировать!
Приверженцы строгости формулировок сказали бы «поток символов», но для понятности проще считать, что команда sort упорядочивает строки.
Названы в честь греческой буквы λ. Хотя сам этот символ в Java не используется, название осталось.
Эту фразу можно толковать двояко. Одно из толкований знакомо нам из математики и функционального программирования, в соответствии с ним функция определяется операторами case, а не с помощью конструкции if-then-else. Второе толкование относится к фразам типа «найти все файлы вида 'IMG*.JPG' в заданном каталоге» и связано с так называемыми регулярными выражениями.
Введение в эту дискуссию можно найти в статье «Википедии», посвященной «проблеме выражения» (expression problem — термин, придуманный Филом Уэдлером (Phil Wadler)).
Еще одна идея — (структурное) сопоставление с шаблоном8, используемое в математике. Например:
Java 8 предоставляет новый API (Stream API), поддерживающий множество параллельных операций обработки данных и чем-то напоминающий языки запросов баз данных — нужные действия выражаются в высокоуровневом виде, а реализация (в данном случае библиотека Streams) выбирает оптимальный низкоуровневый механизм выполнения. В результате вам не нужно использовать в коде ключевое слово synchronized, которое не только часто приводит к возникновению ошибок, но и расходует на многоядерных CPU больше ресурсов, чем можно предположить2.
Проблема в том, что реализовать параллелизм, прибегнув к многопоточному (multithreaded) коду (с помощью Thread API из предыдущих версий Java), — довольно непростая задача. Лучше воспользоваться другим способом, ведь потоки выполнения могут одновременно обращаться к разделяемым переменным и изменять их. В результате данные могут меняться самым неожиданным образом, если их использование не согласовано, как полагается6. Такая модель сложнее для понимания7, чем последовательная, пошаговая модель. Например, одна из возможных проблем с двумя (не синхронизированными должным образом) потоками выполнения, которые пытаются прибавить число к разделяемой переменной sum, приведена на рис. 1.5.
Лямбда-выражения: анонимные функции. В Java 8 не только поименованные методы являются полноправными значениями, но и предлагается расширенное представление о функциях как значениях, включая лямбда-выражения (lambdas, анонимные функции)5. Например, теперь можно написать (int x) -> x + 1, что будет значить: «функция, которая, будучи вызванной с аргументом x, возвращает значение x + 1». Вы можете задаться вопросом, зачем это нужно, ведь можно определить метод add1 в классе MyMathsUtils и написать MyMathsUtils::add1! Да, так можно поступить, но новый синтаксис лямбда-выражений оказывается намного лаконичнее в тех случаях, когда подходящего метода и класса нет. Лямбда-выражения подробно обсуждаются в главе 3. Об использующих их программах говорят, что они написаны в стиле функционального программирования; эта фраза означает «программы, в которых функции передаются как полноправные значения».
Глава 2. Передача кода и параметризация поведения
В этой главе
• Адаптация к меняющимся требованиям.
• Параметризация поведения.
• Анонимные классы.
• Первое знакомство с лямбда-выражениями.
• Примеры из практики: Comparator, Runnable и GUI.
Одна из широко известных проблем в сфере разработки программного обеспечения заключается в том, что, независимо от ваших действий, требования пользователя к ПО все равно поменяются. Представьте себе приложение для фермера, который хотел бы лучше ориентироваться в своих запасах. Например, он может захотеть иметь возможность искать в своих запасах все зеленые яблоки. Но на следующий же день передумать и сказать: «Знаешь, на самом деле мне хотелось бы найти и все яблоки тяжелее 150 г». А еще через два дня он вернется и добавит: «Было бы также неплохо найти все яблоки, которые зеленые и тяжелее 150 г». Как справиться со всеми этими постоянно меняющимися требованиями? В идеале желательно минимизировать выполняемую работу. Кроме того, хотелось бы простоты реализации и сопровождения новой функциональности в долгосрочной перспективе.
Параметризация поведения (behavior parameterization) — паттерн разработки программного обеспечения, нацеленный на решение проблемы частых изменений требований. Если вкратце, параметризация поведения означает обеспечение доступности блока кода без его выполнения. Этот блок кода может вызываться далее другими частями приложения, то есть его выполнение можно отложить. В качестве примера можно привести передачу блока кода в виде аргумента методу, который выполнит этот блок позднее. В результате поведение метода параметризуется этим блоком кода. Например, при обработке коллекции может понадобиться написать метод, который:
• выполняет какое-либо действие над каждым элементом списка;
• выполняет какое-либо другое действие по окончании обработки списка;
• выполняет какое-нибудь еще действие в случае ошибки.
Вот это и есть параметризация поведения. Проведем аналогию: ваш сосед по комнате знает дорогу к супермаркету и обратно. Вы можете попросить его купить некоторые продукты: хлеб, сыр и вино, что эквивалентно вызову метода goAndBuy с передачей списка продуктов в качестве аргумента. Но в один прекрасный день вы, будучи еще в офисе, хотите попросить его выполнить нечто, что он никогда раньше не делал, — забрать посылку на почте. Вам нужно передать ему список указаний: пойти на почту, поговорить с менеджером, назвать номер почтового отправления и забрать посылку. Вы можете отправить ему список указаний, которым ему нужно будет следовать, по электронной почте. Это несколько сложнее, чем покупка продуктов, и эквивалентно передаче методу goAndDo нового варианта поведения в виде аргумента.
Мы начнем эту главу с демонстрации примера повышения приспособляемости кода к меняющимся требованиям. А затем на основе этих знаний покажем, как использовать параметризацию поведения, на нескольких примерах из реальной практики. Возможно, вы уже применяли паттерн параметризации поведения при использовании существующих классов и API Java для сортировки списка, фильтрации названий файлов, указания экземпляру Thread выполнить определенный блок кода или даже для обработки событий GUI. Как вы скоро поймете, исторически этот паттерн требует в Java написания объемного кода. Но отныне, в Java 8, эта проблема решена благодаря лямбда-выражениям. В главе 3 мы покажем, как создавать лямбда-выражения, где их использовать и как повысить с их помощью лаконичность своего кода.
2.1. Как адаптироваться к меняющимся требованиям
Писать код, способный справляться с меняющимися требованиями, — непростая задача. Рассмотрим пример, который мы будем постепенно совершенствовать, попутно демонстрируя рекомендуемые практики для повышения гибкости кода. В контексте приложения для учета запросов на ферме нам нужно реализовать функциональность фильтрации зеленых яблок из списка. Проще не бывает, правда?
2.1.1. Первая попытка: фильтрация зеленых яблок
Пусть, как и в главе 1, различные цвета яблок представлены у нас с помощью перечисляемого типа Color:
enum Color { RED, GREEN }
Первое приходящее на ум решение может быть таким:
Выделенный жирным шрифтом фрагмент кода содержит условие для выбора зеленых яблок. Можно считать, что перечисляемый тип Color с набором цветов, например GREEN, у нас есть. Но фермер вдруг передумал и хочет теперь фильтровать и красные яблоки. Что делать? Простейшим решением было бы скопировать ваш метод, переименовав копию в filterRedApples, и изменить условный оператор if так, чтобы выбирать красные яблоки. Однако такой подход окажется неудачным, если требования снова изменятся и фермер захочет выбирать яблоки нескольких цветов. Рекомендуемая практика такова: старайтесь использовать абстрагирование везде, где замечаете, что пишете практически идентичный код.
2.1.2. Вторая попытка: параметризация цвета
Как же избежать дублирования большей части кода метода filterGreenApples при создании filterRedApples? Вы можете добавить в свой метод еще один параметр, чтобы параметризовать цвет яблок и повысить приспособляемость кода к подобным изменениям требований:
public static List<Apple> filterApplesByColor(List<Apple> inventory,
Color color) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if ( apple.getColor().equals(color) ) {
result.add(apple);
}
}
return result;
}
Теперь вы можете полностью удовлетворить пожелания вашего фермера, вызывая метод следующим образом:
List<Apple> greenApples = filterApplesByColor(inventory, GREEN);
List<Apple> redApples = filterApplesByColor(inventory, RED);
...
Слишком просто, да? Немного усложним пример. Фермер вернулся и сказал вам: «Было бы просто чудесно различать тяжелые и легкие яблоки. Тяжелые яблоки обычно весят больше 150 г».
Но вы, как опытный разработчик, заранее предположили, что фермер может захотеть различать яблоки по весу. И создали следующий метод с дополнительным параметром для учета веса яблок:
public static List<Apple> filterApplesByWeight(List<Apple> inventory,
int weight) {
List<Apple> result = new ArrayList<>();
For (Apple apple: inventory){
if ( apple.getWeight() > weight ) {
result.add(apple);
}
}
return result;
}
Это неплохое решение, но обратите внимание, что вам пришлось продублировать большую часть реализации обхода списка и применения критерия фильтрации. Это плохо, поскольку нарушает принцип DRY (Don’t Repeat Yourself — «не повторяйся») разработки программного обеспечения. Ведь если вам нужно будет изменить обход фильтра для повышения производительности, придется модифицировать реализации всех методов вместо одного. Слишком большие затраты, если говорить об объеме работы.
Конечно, можно объединить обработку цвета и веса в один метод filter. Но как тогда отличить, по какому атрибуту необходимо фильтровать? Можно добавить флаг, чтобы различать запросы на фильтрацию по цвету и по весу.
Никогда так не делайте! Вскоре мы объясним почему.
2.1.3. Третья попытка: фильтрация по всем возможным атрибутам
Неуклюжая попытка слияния всех атрибутов могла бы выглядеть вот так:
А использовать этот метод можно было бы следующим, весьма неудачным образом:
List<Apple> greenApples = filterApples(inventory, GREEN, 0, true);
List<Apple> heavyApples = filterApples(inventory, null, 150, false);
...
Это исключительно неудачное решение. Во-первых, клиентский код выглядит ужасно. Что означают true и false? Кроме того, это решение плохо адаптируется к смене требований. Что, если фермеру понадобится фильтрация по различным атрибутам яблок, например по размеру, форме, месту происхождения и т. д.? Кроме того, фермеру могут понадобиться более сложные запросы с комбинацией атрибутов, например тяжелые зеленые яблоки. Вам придется написать или несколько дублирующихся методов filter, или один огромный запутанный метод. Пока мы параметризовали метод filterApples значениями типов String, Integer, перечисляемым типом и boolean. Для некоторых четко сформулированных задач этого достаточно. Но в данном случае нам нужен более продвинутый способ передачи методу filterApples критериев выбора яблок. В следующем разделе мы расскажем, как достичь необходимой адаптивности с помощью параметризации поведения.
2.2. Параметризация поведения
В предыдущем разделе вы видели, что для адаптации к меняющимся требованиям нужен способ более продвинутый, чем добавление множества параметров метода. Отступим на шаг назад и найдем оптимальный уровень абстракции. Одно из возможных решений состоит в моделировании критериев отбора — это работа с объектами-яблоками и возврат boolean в зависимости от каких-либо атрибутов объекта Apple. Например, зеленое ли яблоко? Весит ли яблоко более 150 г? Это называется предикатом, или условием (функцией, возвращающей булево значение). Опишем интерфейс для моделирования критериев отбора:
public interface ApplePredicate{
boolean test (Apple apple);
}
Теперь мы можем объявить несколько реализаций интерфейса ApplePredicate, которые будут соответствовать различным критериям отбора, как показано в следующем фрагменте кода (и проиллюстрировано на рис. 2.1):
Эти критерии можно рассматривать как различные варианты поведения метода filter. Фактически речь идет о реализации паттерна проектирования «Стратегия» (см. https://ru.wikipedia.org/wiki/Стратегия_(шаблон_проектирования)), который позволяет описать семейство алгоритмов, инкапсулируя каждый из них (называемый стратегией), и выбирать нужный во время выполнения. В данном случае семейство алгоритмов — ApplePredicate со стратегиями AppleHeavyWeightPredicate и AppleGreenColorPredicate.
Рис. 2.1. Различные стратегии выбора объектов Apple
Но как воспользоваться различными реализациями ApplePredicate? Для этого нужно, чтобы метод filterApples принимал на входе объекты ApplePredicate для последующей проверки того, соответствует ли конкретный объект Apple условию. Параметризация поведения — это способность метода принимать в качестве параметров множество вариантов поведения, а затем, опираясь на них, воплощать различные последовательности операций.
Чтобы добиться такого эффекта в текущем примере, мы добавим в метод filterApples параметр для объекта ApplePredicate. Это действие дает вам как разработчику программного обеспечения огромное преимущество: теперь вы можете отделить логику организации цикла по коллекции внутри метода filterApples от варианта поведения, используемого для каждого элемента этой коллекции (в данном случае предиката).
2.2.1. Четвертая попытка: фильтрация по абстрактному критерию
Наш модифицированный метод filter, использующий ApplePredicate, выглядит следующим образом:
Передача кода/поведения
Стоит остановиться на минуту и отпраздновать наши достижения. Гибкость кода намного выше, чем при нашей первой попытке, вдобавок его намного легче читать и использовать! Вы можете теперь создавать различные объекты ApplePredicate и передавать их в метод filterApples. Абсолютная гибкость! Например, если фермер попросит вас найти все красные яблоки тяжелее 150 г, вам достаточно будет написать класс, реализующий ApplePredicate соответствующим образом. Наш код уже достаточно гибок для любой смены требований, касающейся атрибутов Apple:
public class AppleRedAndHeavyPredicate implements ApplePredicate {
public boolean test(Apple apple){
return RED.equals(apple.getColor())
&& apple.getWeight() > 150;
}
}
List<Apple> redAndHeavyApples =
filterApples(inventory, new AppleRedAndHeavyPredicate());
Это по-настоящему круто: поведение метода filterApples зависит теперь от передаваемого вами в него через объект ApplePredicate кода. Мы параметризовали поведение метода filterApples!
Обратите внимание, что в предыдущем примере важен только код реализации метода test, как показано на рис. 2.2; именно он определяет поведение метода filterApples. К сожалению, в силу того, что метод filterApples может принимать в качестве параметра только объекты, приходится оборачивать данный код в объект ApplePredicate. Это схоже с передачей встраиваемого кода, поскольку мы передаем булево выражение через объект, реализующий метод test. В разделе 2.3 (а более подробно — в главе 3) вы увидите, что благодаря лямбда-выражениям можно непосредственно передать методу filterApples выражение RED.equals(apple.getColor()) && apple.getWeight() > 150 без необходимости описывать несколько классов ApplePredicate. Это повышает лаконичность кода.
Рис. 2.2. Параметризация поведения метода filterApples и передача ему различных стратегий фильтрации
Различные варианты поведения, один параметр
Как мы уже поясняли ранее, параметризация поведения хороша тем, что позволяет отделять логику организации цикла по коллекции от используемого для каждого из элементов коллекции варианта поведения. В результате появляется возможность переиспользования метода с различными вариантами поведения для решения разных задач, как показано на рис. 2.3. Именно поэтому параметризация поведения — полезный инструмент, который пригодится вам для создания гибких API.
Рис. 2.3. Параметризация поведения метода filterApples и передача ему различных стратегий фильтрации
Чтобы проверить свое умение использовать параметризацию поведения, попробуйте выполнить контрольное задание 2.1!
| Контрольное задание 2.1. Напишите гибкий метод prettyPrintApple Напишите метод prettyPrintApple, который принимает на входе список List объектов типа Apple и который можно параметризовать несколькими способами, для генерации на основе объекта apple результата в виде String (что-то вроде множества специализированных методов toString). Например, от метода prettyPrintApple можно потребовать вывести только вес каждого яблока, а можно выводить информацию по каждому яблоку отдельно, указывая, тяжелое оно или легкое. Решение аналогично вышеприведенным примерам фильтрации. Чтобы упростить вам задачу, приведем черновик метода prettyPrintApple: public static void prettyPrintApple(List<Apple> inventory, ???) { for(Apple apple: inventory) { |
| String output = ???.???(apple); System.out.println(output); } } Ответ: во-первых, вам понадобится способ выразить такое поведение, при котором метод получает на входе объект Apple и возвращает результат в виде отформатированной строки. Вы уже делали нечто подобное, когда создавали интерфейс ApplePredicate: public interface AppleFormatter { String accept(Apple a); } Теперь вы можете выражать поведение для различных видов форматирования посредством реализаций интерфейса AppleFormatter: public class AppleFancyFormatter implements AppleFormatter { public String accept(Apple apple) { String characteristic = apple.getWeight() > 150 ? "heavy" : "light"; return "A " + characteristic + " " + apple.getColor() +" apple"; } } public class AppleSimpleFormatter implements AppleFormatter { public String accept(Apple apple) { return "An apple of " + apple.getWeight() + "g"; } } Наконец, вам нужно, чтобы объекты типа AppleFormatter принимались методом prettyPrintApple и использовались внутри него. Для этого необходимо добавить в метод prettyPrintApple соответствующий параметр: public static void prettyPrintApple(List<Apple> inventory, AppleFormatter formatter) { for(Apple apple: inventory) { String output = formatter.accept(apple); System.out.println(output); } } Готово! Теперь можно передавать различные варианты поведения в метод prettyPrintApple. А добились мы этого, создав экземпляры реализаций интерфейса AppleFormatter и передав их в качестве аргументов методу prettyPrintApple: prettyPrintApple(inventory, new AppleFancyFormatter()); В результате выполнения этого кода будет выведен результат наподобие следующего: A light green apple A heavy red apple ... Или попробуйте вот такое: prettyPrintApple(inventory, new AppleSimpleFormatter()); |
| В результате выполнения этого кода будет выведен примерно такой результат: An apple of 80g An apple of 155g ... Как видите, существует возможность абстрагировать поведение и обеспечить адаптацию кода к изменению требований, но при этом приходится писать слишком много кода для объявления различных классов, экземпляры которых создаются лишь один раз. Посмотрим, как исправить эту ситуацию. |
2.3. Делаем код лаконичнее
Всем известно, что громоздких конструкций следует избегать. На текущий момент при необходимости передать новое поведение методу filterApples нам приходится объявлять несколько классов, реализующих интерфейс ApplePredicate, а затем создавать несколько объектов ApplePredicate, используемых лишь один раз, как показано в итоговом листинге 2.1. Код получается очень громоздким, а его написание занимает много времени!
Листинг 2.1. Параметризация поведения: фильтрация яблок с помощью предикатов
