14. Паттерн "Итератор". Компараторы. Фреймворк Streams API.

Представим себе класс Cart, который хранит набор положенных в корзину товаров и класс Order, в задачу которого входит формирование заказа из помещенных в корзину товаров. Для упрощения примера, представим себе, что "корзина" реализована обычным массивом и можем содержать не более 5 элементов.

Давайте внимательно посмотрим на метод makeOrder(). Он получает на вход объект Cart, после чего он должен взять каждое наименование и цену товара и, допустим, добавить его в базу данных. Как это сделать?

Самый очевидный способ реализовать перебор товаров - создадим геттер для поля items в классе Cart, после чего в методе makeOrder() с помощью цикла обойдем все элементы массива.

Подобное решение задачи является неправильным с точки зрения ООП:

  • нарушается принцип инкапсуляции, так как вы показываете внутреннюю структуру объекта. Сторонний класс может случайно или специально структуру и тогда объектом нельзя будет пользоваться;

  • класс Order теперь зависит от внутренней структуры класса Cart и, если мы каким-то образом изменим внутреннюю структуру класса (например, мы захотим вместо массива использовать какую-то коллекцию), или с помощью наследования модифицируем существующий класс - нам необходимо будет переписывать метод makeOrder();

  • мы устанавливаем сильную связь между логически разными классами, которые выполняют разные функции в приложении. Это нарушает принцип модульности.

Таким образом, нам необходимо решить следующую задачу: нам необходимо обойти все элементы "корзины" и добавить их в базу данных, но мы не должны показать внутреннюю структуру объекта типа Cart, класс Order должен работать успешно при любых изменениях внутри класса Cart.

Для решения этой задачи можно воспользоваться часто применяемым и очень простым паттерном проектирования, который называется "Итератор" (от относитс к категории паттернов поведения).

Краткое описание паттерна можно сформулировать так: итератор - это объект, который предоставляет интерфейс последовательного доступа к содержимому составных объектов, не раскрывая другим классам его внутренней структуры.

Давайте напишем свою версию этого паттерна. Прежде всего, нам необходимо описать интерфейс "Итератор", который будет описывать методы итератора.

Так как наш итератор может работать с разными элементами внутри составных объектов, то сделаем его обобщенным. Метод next() при каждом вызове возвращает следующий элемент составного объекта. То есть, если мы последовательно будем вызывать метод next(), то он будет возвращать первый, второй, третий и так далее элементы составного объекта.

Метод hasNext() возвращает нам сведения о том - есть ли еще эломенты, которые мы не обошли с помощью метода next(). Если впереди еще есть элементы - метод вернет true, если элементы закончились - метод вернет false.

Итак, мы описали интерфейс для итератора, теперь необходимо создать класс, который будет реализовывать интерфейс итератора.

Для этого необходимо создать так называемый внутренний класс.

В классе Cart создадим метод getIterator(), который будет создавать объект внутреннего класса CartIterator и возвращать его.

Теперь вернемся к классу Order. В методе makeOrder() получим итератор и проитерируем товары в корзине.

В результате:

  • класс Order не имеет доступа внутренней структуре объекта типа Cart;

  • класс Cart может спокойно изменить свою структуру - достаточно лишь переписать методы внутреннего класса CartIterator, чтобы реализовать методы итератора с учетом изменений структуры класса Cart;

  • класс Order будет продолжать нормальную работу, если внутренняя структура класса Cart изменится - главное, чтобы были корректно реализованы методы итератора;

  • вы можете предусмотреть несколько реализаций итератора и метод getIterator() может возвращать ту или иную реализацию интерфейса (например, вы можете предусмотреть несколько итераторов для обхода бинарного дерева - каждый итератор будет реализовывать свой способ обхода).

В реальности нам нет нужды свой интерфейс итератора - правильно будет воспользоваться стандартным интрефейсом java.util.Iterator и java.lang.Iterable. Это "стандартные" интерфейсы итератора, который все используют и в 99.9% случаях вы тоже должны его использовать.

Переделаем наш код для использования стандартных интерфейсов. Для начала перепишем внутренний класс CartIterator, чтобы он реализовывал интерфейс java.util.Iterator. Он мало чем отличается от нашего "самодельного" интерфейса Итератора. Минимально необходимо реализовать методы next() и hasNext(). Для полной реализации интерфейса необходимо дополнительно реализовать методы remove() и forEachRemaining().

Далее следует указать, что наш класс Cart может предоставить итератор для последовательного доступа к элементам составного объекта. Для этого укажем, что наш класс реализует интерфейс Iterable<Item>. Для минимальной реализации интерфейса Iterable необходимо реализовать метод Iterator<T> iterator(), который возвращает объект класса, реализующего интерфейс java.util.Iterator.

Таким образом, мы указали, что наш класс Cart может вернуть объект итератора для последовательного доступа к элементам внутренней структуры. Реализация интерфейса Iterable<T> часто бывает полезна: некоторые методы в стандартных библиотеках Java принимают на входы объекты интерфейсного типа Iterable<T>, кроме того, вы теперь можете обойти элементы внутри Cart с помощью цикла конструкции for-each:

Итераторы в коллекциях

Поддержка итераторов - необъемлемая часть работы с коллекциями. В интерфейсе Collection определен метод iterator(), который возвращает объект итератора. Таким образом, все коллекции, которые реализуют интерфейс Collection, имеют реализованный класс итератора.

Работа с итератором коллекции ничем не отличается от использования итератора в нашем примере:

  1. запросить у Collection итератор посредством метода iterator(). Полученный итератор готов вернуть начальный элемент последовательности;

  2. получить следующий элемент последовательности вызовом метода next();

  3. проверить, есть ли еще объекты в последовательности (метод hasNext());

  4. удалить из последовательности последний элемент, возвращаемый итератором, методом remove().

Також, коллекции, реализующие интерфейс List, имеют еще один итератор, который определен в интерфейсе java.util.ListIterator. Интерфейс ListIterator отличается от Iterator тем, что имеет дополнительные методы, а также позволяет двигаться по коллекции в обоих направлениях (Iterator позволяет двигаться только вперед).

Подробнее про ListIterator смотрите здесь

В версии Java 8 появился еще один тип итератора - Spliterator. Он позволяет разбивать коллекцию на части, что позволяет обрабатывать коллекции параллельно в несколько потоков. На данном этапе концепция сплитератора может показаться вам непонятной, поэтому оставим его за скобками.

Подробнее можете прочитать здесь.

Компараторы

Классы TreeSet и TreeMap сохраняют элементы в отсортированном порядке. Однако понятие "порядок сортировки" точно определяют применяемый ими компаратор. По умолчанию эти классы сохраняют элементы, используя то, что в Java называется естественным упорядочением, то есть ожидаемым упорядочением, когда после a следует b, после 1 следует 2 и так далее.

Создадим класс Person и попробуем поместить объекты этого класса в коллекцию. Запустим код и с удивлением обнаружим, что произошла ошибка при выполнении программы.

Почему произошла ошибка и что означает? Текст ошибки говорит о том, что тип Person не может быть преобразован в тип Comparable.

Происходит следующее - чтобы мы могли использовать коллекции типа TreeSet или TreeMap или чтобы можно было сортировать элементы в коллекции - мы должны написать код для сравнения объектов, а он сейчас отсутствует и коллекция TreeSet не может поместить элемент в дерево.

Для того, чтобы объекты класса Person можно было сравнить и сортировать, класс должен реализовать интерфейс Comparable<E>.

Для реализации интерфейса нам необходимо переопределить метод compareTo(). На вход метода подается второй объект для сравнения. Первым объектом служит текущий объект, у которого этот метод вызывается.

Если первый (текущий) объект меньше, то метод должен возвращать отрицательное число, если объекты равны - возвращается 0, если текущий объект больше, метод возвращает положительное число. Перепишем метод, чтобы было яснее

Добавим в класс Person метод toString() и напишем небольшую демонстрационную программу

Обратите внимание, что коллекция TreeSet отсортировала значения по возрастанию согласно той логике, чтобы мы прописали в методе compareTo(). Такой способ задания порядка сортировки удобен и прост, но он имеет недостатки:

  • что делать, если необходимо во время работы программы изменить порядок упорядочения объектов?

  • мы не можем изменить порядок сортировки объектов стандартных классов вроде класса String (от этих классов нельзя наследоваться);

  • мы не можем изменить порядок сортировки уже кем-то написанных классов (наследоваться от существующего класса с целью переопределить метод compareTo() - не лучшая идея).

Для реализации компаратора, нам, в самом простом случае, необходимо переопределить метод compareTo(), который принимает на вход два объекта класса Person. Логика работы метода не отличается от метода compareTo() - если первый объект меньше - метод возвращает отрицательное число, если первый объект больше - метод возвращает положительное число, если объекты равны - метод возвращает 0.

В данном случае наш компаратор сравнивает объекты по полю lastName. Объект класса компаратора передается коллекции TreeSet при создании объекта коллекции. Напишем небольшую демонстрационную программу.

Мы видим, что элементы были упорядочены по полю lastName. Изменим коллекцию на List и отсортируем список с помощью компаратора

Можно заметить, что в первом случае порядок элементов в списке соответствует порядку добавления этих элементов. После сортировки порядок элементов был изменен -мы отсортировали элементы в списке по полю lastName. Вместо отдельных классов, для реализации компаратора можно использовать соответствующие лямбда-выражения.

Потоки данных. Использование Stream API.

Главной особенностью потокового API является способность выполнять очень сложные операции поиска, фильтрации и преобразования и иного манипулирования данными. Используя потоковый API, можно, например, сформировать последовательность действий, подобных запросам базы данных на языке SQL. Кроме этого, работа потокового API выполняется параллельно, а, следовательно, повышается производительность, особенно при обработке крупных потоков данных.

Потоки данных позволяют выполнять вычисления на более высоком концептуальном уровне, чем коллекции. С помощью потока данных можно указать, что и как именно требуется сделать с данными, а планирование операций предоставить конкретной реализации.

В Stream API определен ряд потоковых интерфейсов, входящих в состав пакета java.util.stream. В основе их иерархии лежит интерфейс BaseStream.

Производными от интерфейса BaseStream являются несколько типов интерфейсов. Наиболее употребительными из них является интерфейс Stream.

Поток данных - канал передачи данных, последовательность объектов. В самом потоке данные не хранятся, а только перемещаются и, возможно, фильтруются, сортируются или обрабатываются иным образом в ходе этого процесса. Как правило, действие потока данных не видоизменяет их источник.

Рассмотрим пример. Допустим, что требуетя подсчитать все длинные слова в книге. Сначала считаем файл и выделим отдельные слова в коллекцию:

Теперь попробуем посчитать все слова, длина которых больше 8 символов:

При использовании потокового API, подсчет осуществляется следующим образом:

Достаточно заменить метод stream() на метод parallelStream(), чтобы организовать параллельное выполнение операций фильтрации и подсчета слов:

Потоки данных действуют по принципу "что делать", а не "как это сделать". В рассматриваемом примере мы описываем, что нужно сделать: получить длинные слова и подсчитать их. При этом, мы не указываем, в каком порядке или потоке исполнения это должно произойти.

Потоки данных имеют свои особенности:

  1. поток данных не сохраняет свои элементы. Они могут сохраняться в основной коллекции или формироваться по требованию;

  2. потоковые операции не изменяют их источник. Например, метод filter() не удаляет элементы из нового потока данных, а выдает новый поток, в котором они отсутствуют;

  3. потоковые операции выполняются по требованию (так называемая "ленивая инициализация", lazy initialization). Это означает, что они не выполняются до тех пор, пока не потребуется результат.

Вернемся к примеру. Методы stream() и parallelStream() выдают поток данных для списка слов words. А метод filter() возвращает другой поток данных, содержащий только те слова, длина которых больше 8 символов. И наконец, метод count() сводит этот поток данных в конечный результат.

Такая последовательность операций весьма характерна для обращения с поткоками данных. Конвейер операций организуется в следующие три стадии:

  1. создание потока данных (метод stream());

  2. указание промежуточных операций (intermediate operations) для преобразования исходного потока данных в другие потоки, возможно, в несколько этапов (метод filter());

  3. выполнение конечной операции (terminal operation) для получения результата (метод count());

Конечная операция потребляет поток данных и дает конечный результат, например, находит минимальное значение в потоке данных или выполняет некоторое действие, как это делает метод forEach(). Если поток данных потреблен, он не может быть использован повторно.

Промежуточная операция производит поток данных и служит для создания конвейера для выполнения последовательности действий.

Создание потока данных

Получить поток данных можно самыми разными способами. Вероятно, самый распространенный способ - получение потока данных из коллекции. Любую коллекцию можно преобразовать в поток данных методом stream() из интерфейса Collection.

Поток данных можно получить и из массива. Это можно сделать несколькими способами, например, с помощью класса Arrays и с помощью класса Stream.

Вы также можете сгенировать поток с помощью метода generate() или iterate(). Обратите внимание, что метод generate() возвращает бесконечный поток, поэтому его бывает полезно ограничить с помощью промежуточной операции limit()

Также следует помнить, что в библиотеках Java существует много методов, которые могут вернуть потоки данных. Например, можно получить поток строк из класса Pattern или поток строк из файла с помощью метода класса Files

Промежуточные операции

В результате выполнения промежуточных операций осуществляется преобразование потока данных в другой поток, элементы которого являются производными от элементов исходного потока.

На примерах выше мы рассматривали несколько таких операций, например, метод filter(), в результате которого получается новый поток данных с элементами, удовлетворяющими определенному условию. Рассмотрим еще один пример с использованием операции filter():

Нередко значения в потоке данных требуются каким-то образом преобразовать. Для этой цели можно воспользоваться методом map(), передав ему функцию, которая и выполняет нужное преобразование. Например

При вызове метода map() передаваемая ему функция применяется к каждому элементу потока данных, в результате чего образуется новый поток данных с полученными результатами.

Следующая операция - операция limit() возвращает поток данных, оканчивающийся после n элементов или по завершению исходного потока данных, если тот короче. Данный метод особенно удобен для ограничения бесконечных потоков данных до определенной длины. Так, в следующей строке кода получается новый поток данных, состоящий из 100 произвольных целых чисел

Метод skip() выполняет противоположную операцию - отбрасывает первые n элементов

Статический метод concat() позволяет соединить два потока данных. Первый из этих потоков не должен быть бесконечным

Метод distinct() возвращает поток данных, в котором исключены дубликаты.

Для сортирвоки потоков данных имеется несколько вариантов метода sorted(). Один из них служит для обработки потоков данных, состоящих из элементов типа Comparable, а другой принимает в качестве параметра компаратор типа Comparator

Как и во всех остальных потоковых преобразованиях, метод sorted() выдает новый поток данных, элементы которого происходят из исходного потока и располагаются в отсортированном порядке. Метод sorted() удобно применять в том случае, если процесс сортировки является частью поточного конвейера.

Метод peek() выдает другой поток данных с теми же элементами, что и у исходного потока, но при добавлении эелмента в новый поток вызывается лямбда-выражение, указанное в качестве аргумента метода peek().

Отличие метода peek() от метода forEach() является то, что метод метод peek() является промежуточной операцией и используется в целях отладки, а операций forEach() являетя конечной операцией, которая запускает конвейер и используется для получения результата работы потока.

Конечные операции

Конечные операции часто называют методами сведения. Они выполняют конечные операции, сводя поток данных к непотоковому значению, которое может быть далее использовано в программе. С некоторыми из них вы уже знакомы: count(), forEach() и collect().

Метод count(), как уже было сказано, возвращает количество элементов в результирующем потоке. Приведем еще один пример

К числу других простых методов сведения относятся методы max() и min(), возвращающие наибольшее и наименьшее значения соответственно.

Обратите внимание, что эти методы возвращают значение типа Optional<T>, которое заключает в себе ответ на запрос или обозначают, что запрашиваемые данные отсутствуют, поскольку поток оказался пустым. Раньше в подобных случаях возвращалось пустое значение null. Но это могло привести к исключениям в связи с пустыми указателями в не полностью протестированной системе. Тип Optional удобнее для обозначения остутствующего возвращаемого значения.

Метод findFirst() возвращает первое значение из непустой коллекции. Зачастую он применяется вместе с методом filter().

Если же потребуется любое совпадение, а не только первое, то следует воспользоваться методом findAny(). Это оказывается эффективным при распараллеливании потока данных, поскольку поток может известить о любом обнаруженном в нем совпадении, вместо того чтобы ограничиваться только первым совпадением.

Если же потребуется только выяснить, имеется ли вообще совпадение, то следует воспользоваться методом anyMatch()

Имеются также методы allMatch() и noneMatch(), возвращающие логическое значение true, если с предикатом совпадают все элементы в потоке данных или не совпадает ни один из его элементов соответственно. Эти методы также выгодно выполнять в параллельном режиме.

По завершении обработки потока данных нередко требуется просмотреть полученные результаты. С этой целью можно использовать метод forEach(), чтобы применить функцию к каждому элементу.

В параллельном потоке данных метод forEach() выполняет обход элементов в произвольном порядке. Если же требуется обработать данные в потоковом порядке, то следует вызвать метод forEachOrdered(), что может снизить производительность вычислений.

Но чаще всего требуется накапливать результаты в структуре данных (то есть, создать массив или коллекцию, которая будет содержать элементы итогового потока).

Если нужно получить результирующий массив, то можно воспользоваться методом toArray(). Создать обобщенный массив выполнения невозможно, и поэтому в результате вызова stream.toArray() возвращается массив типа Object[]. Если же требуется массив нужного типа, то этому методу следует передать конструктор такого массива.

Для накопления элементов потока данных с другой целью имеется метод collect, принимающий экземпляр класса, реализующего интерфейс Collector. В частности, класс Collectors предоставляет немало фабричных методов для наиболее полезных коллекторов. Для накопления потока данных в списке или множестве достаточно сделать один из следующих вызовов

Если же требуется конкретная разновидность получаемого множества, то нужно сделать следующий вызов:

Для накопления символьных строк, сцепляя их, используется следующие вызовы

Если результаты обработки потока данных требуется свести к сумме, среднему, максимуму или минимуму, можно воспользоваться методами типа summarizing()

Для накопления результатов в словарях используется метод Collectors.toMap() принимающий в качестве двух своих аргументов функции, чтобы получить ключи и значения их отображения. Процесс накопления данных в словарях имеет много нюансов, в рамках данного курса рассматриваются только простые случаи.

Last updated