Stream API
Название интерфейса намекает на потоки ввода-вывода или на многопоточность, но в Stream API «поток» — это просто набор данных, а интерфейс позволяет нам обрабатывать эти данные с помощью нужных нам функций. Функции передаются в виде лямбда-выражений в качестве параметров, а обработка выполняется последовательно. В Stream API такая последовательность называется «конвейером». Подбирая нужные функции для обработки данных, мы строим конвейер — гибкий, быстрый и легко расширяемый. Конвейеры состоят из методов интерфейса, а сами методы делятся на три типа: генераторы, фильтры и коллекционеры. Но давайте взглянем на простой пример.
List<String> myList = Arrays.asList("Привет","мир","!","Я","родился","!");myList.stream().filter(s -> s.length()>4).forEach(System.out::println);
Первая строка довольно проста и просто создает список строк myList с использованием метода asList класса-помощника Arrays. Но вторая строка интереснее! В Java 8 списки и множества получили новый метод stream(). Его вызов создает обычный поток данных. Можно назвать этот метод “генератором потока”. Теперь у нас есть поток, и мы можем преобразовать его в конвейер, добавив фильтр! Метод filter() является фильтром! Точнее, их правильнее называть промежуточными методами или методами конвейера, поскольку не все из них имеют слово “фильтр” в названии! Как я говорил ранее, в качестве параметра конвейерному методу я передал лямбда-выражение. Мне не нужно объявлять функциональный интерфейс или выполнять еще какие-либо действия, сам метод ожидает лямбда-выражение в качестве параметра! У него уже есть данные, остается описать, что с ними делать. Однако для лямбда-выражений существуют два ограничения. Во-первых, они должны быть невмешивающимися (non-interfering), т. е. не менять исходных данных. Во-вторых, они не должны запоминать состояние (stateless), т.е. не зависеть от порядка выполнения, от внешних переменных и от всего внешнего пространства в целом! В этом примере я создал поток из списка, но можно создавать потоки и другими методами.
Пример:
Arrays.asList(1, 2, 3).stream();// Метод asList теперь сам умеет создавать потоки данных!Stream.of(3, 2, 1);
Вы можете создавать потоки с помощью Stream.of, что иногда может быть более удобным. Однако это еще не все, поскольку Stream API также позволяет создавать специализированные потоки для работы с примитивными типами: IntStream, LongStream и DoubleStream. Поток IntStream можно использовать подобно обычному циклу for(;;), используя метод range.
IntStream.range(1, 4);
У таких, вспомогательных, потоков есть пару дополнительных методов таких как sum() и average().
Пример:
IntStream.range(1, 4).average().ifPresent(System.out::println);
Выводит в консоли среднее значение чисел от 1 до 3. Разобравшись с источниками, переходим к следующему этапу. Важно не только создать поток, но и уметь с ним работать. Здесь мы рассмотрим конвейерные методы, созданные специально для обработки данных в потоке. И здесь есть одна особенность. Они работают по принципу “ленивой” обработки данных! Я буду обращать ваше внимание на эту особенность по мере чтения лекции, чтобы вы могли оценить все преимущества такого подхода. Один Stream может иметь сколько угодно конвейерных операций, и только одну терминальную. Терминальными я буду называть методы-коллекторы по той же причине, по которой конвейерные методы лучше называть промежуточными. Не все коллекторы содержат в своем названии слово “collect”!). Третью часть лекции мы посвятим терминальным методам, но если Stream с конвейерными операциями не завершить терминальной операцией, никаких действий выполнено не будет. Это связано с тем, что я как программист не просил программу сохранять результаты ее работы. Зачем выполнять работу, если результат не будет использован? Это одна из ключевых особенностей “ленивого” подхода. До тех пор пока мы не перейдем к рассмотрению терминальных методов, будем использовать forEach() в качестве примера.
Пример:
public static void main(String[] args) {List<String> list = Arrays.asList("Не", "заменят", "край", "родимый","Никакие", "чудеса!", "Только", "здесь", "всё", "так", "любимо","Реки", "горы", "и", "леса");list.stream().filter(n -> n.length() <= 4).filter(n -> n.contains("е")).forEach(n -> System.out.print(n + " "));}
Мы подготовили список данных в первой строке и создали поток со встроенным фильтром во второй строке. Фильтр проверяет длину строки, которая должна быть не менее четырех букв. В терминальном методе мы просто выводим результат в консоль. Результат верен только для слов длиной больше четырех символов. Однако мы написали исходный код не очень аккуратно. Обычно при использовании Stream API вызовы методов размещаются на отдельных строках.
Пример:
List<String> list = Arrays.asList("Не", "заменят", "край", "родимый","Никакие", "чудеса!", "Только", "здесь", "всё", "так", "любимо","Реки", "горы", "и", "леса.");list.stream().filter(n -> n.length()>4).forEach(n -> System.out.print(n+" "));//Вот так код выглядит лаконичней! Усложню выборку ещё одним фильтром.System.out.print("\n");list.stream().filter(n -> n.length()>4).filter(c -> c.toLowerCase().contains("ч")).forEach(n -> System.out.print(n+" "));
Мы добавили одну строку, и все слова, которые не содержат букву “ч”, исчезли. Здесь есть одна особенность: в лямбде мы использовали метод toLowerCase(), который возвращает строку в нижнем регистре, но результат остался в исходном регистре. Дело в том, что filter, как и следует из его названия, не изменяет исходные данные, а только выбирает необходимые, удовлетворяющие условию, описанному в лямбде. Это очень удобно, в частности, тем, что при написании условий можно не беспокоиться о целостности исходных данных. Следующим идет конвейерный метод skip, который позволяет пропустить заданное количество первых элементов потока
Пример:
list.stream().skip(list.size()/2).forEach(n -> System.out.print(n+" "));
Мы пропустили первую половину данных потока и работать начали только со второй! Давайте объединим filter и skip
Пример:
list.stream().skip(list.size()/2).filter(n -> n.length() >4).filter(n -> n.toLowerCase().contains("а")).forEach(n -> System.out.print(n+" "));
В данном случае в результате будет только слово "леса"! Ибо во второй половине текста только оно выполняет поставленные условия. Следующим конвейерным методом будет limit. Это skip наоборот! Ограничивает обработку указанным количеством первых элементов.
Пример:
list.stream().limit(list.size()/2).filter(n -> n.length() >4).filter(n -> n.toLowerCase().contains("а")).forEach(n -> System.out.print(n+" "));
Тот же код, но skip поменяем на limit. Ну и результат совсем другой! Теперь выборка из первой половины. Метод простой поэтому переходим к следующему. Distinct, тут тоже всё просто, он пропускает поток без повторов.
Пример:
List<String> list = Arrays.asList("а", "б", "а", "в", "а", "г", "а", "д");list.stream().distinct().forEach(n -> System.out.print(n));//абвгд
Тут всё просто - никаких параметров, и в результате мы получаем "абвгд", без повторных вхождений! Еще один конвейерный метод называется sorted и возвращает поток, отсортированный по возрастанию.
Пример:
list.stream().sorted((s, t1) -> t1.length() - s.length()).forEach(System.out::println);
Здесь я хочу обратить ваше внимание на компаратор. Его поведение определяется лямбда-выражением. В данном примере это простейшая однострочная функция, но вы не ограничены такой реализацией!
list.stream().sorted((s, t1) -> {int tmp = t1.length() - s.length();if (tmp < 0) return 1;else if (tmp > 0) return -1;return 0;}).forEach(System.out::println);
Я немного усложнил компаратор! Сделал его многострочным и появились фигурные скобки и появилась необходимость писать return своими руками! Но синтаксис, даже в таком не сложном примере всё же проще и лаконичнее чем в реализации без стримов! Ещё один конвейерный метод и называется он mаp! Этот метод проходит по всем элементам и возвращает в поток данные изменённые по логике его лямбда выражения. Он, в соответствии концепции non-interfering не изменяет исходные данные, только данные потока.
Пример:
List<String> list = Arrays.asList("Привет", "Как дела?", "Пропеллер!", "никель");list.stream().map(n -> n.length()).forEach(System.out::println);
Я немного усложнил компаратор, сделав его многострочным. Теперь появились фигурные скобки, и вам нужно самостоятельно написать return. Однако, даже в этом несложном примере, синтаксис остается более простым и лаконичным, чем в варианте без стримов. Следующий метод конвейера называется map. Этот метод проходит через все элементы и возвращает в поток измененные данные в соответствии с логикой его лямбда-выражения. Он не меняет исходные данные, изменяя только данные потока, в соответствии с концепцией non-interefering.
Пример:
List<String> myList = Arrays.asList("Привет","мир","!","Я","родился","!");System.out.println(myList.stream().filter(s -> s.length()>4).findFirst());//Optional[Привет]
И результатом будет строка в консоли: Optional[Привет] Метод вывел слово "Привет", так как это первое попавшееся в потоке слово удовлетворяющее условию. В данном случае метод findFirst() можно было бы заменить конструкцией.
Пример:
myList.stream().filter(s -> s.length()>4).limit(1).forEach(n -> System.out.print(n));// Привет
Результат будет тот же. Но конвейер длиннее и соответственно медленнее. Есть ещё один похожий метод. findAny(). Он, как и предыдущий, возвращает один результат из потока но не обязательно первый в списке а первый обработанный. Это ещё одна отсылка к так называемой ленивой обработке.
Пример:
System.out.println(myList.stream().filter(s -> s.length()>4).findAny());
Терминальные методы findAny и findFirst возвращают результатом экземпляр класса Optional, поэтому и в консоли мы видим "Optional[Привет]". Это не всегда удобно. В основном, мы хотим увидеть массивы или списки. Для этого есть другой метод, и называется он collect! С его помощью мы можем представить данные в виде нужных нам структур данных.
Пример:
List<String> myList = Arrays.asList("Привет", "мир", "!", "Я", "родился", "!", "Море","Поле");List<String> tmpList = myList.stream().sorted((s, t1) -> {int tmp = t1.length() - s.length();if (tmp < 0) return 1;else if (tmp > 0) return -1;return 0;}).collect(Collectors.toList());
В методе collect я использую параметр toList, который является частью вспомогательного класса Collectors. Когда данные проходят через конвейер, они собираются в список для дальнейшей работы с ними. Я рассказал вам о некоторых возможностях стримов и показал на практике, как они работают. В более сложном примере я описал каждый шаг подробно. Monthes — это просто перечисление месяцев для удобного вывода. PersonID — это простой класс, описывающий сотрудника с закрытыми полями: id, ФИО, датой рождения, зарплатой и геттерами. В методе main инициализируется список personIDS и генерируется случайная зарплата. Затем создается поток данных из списка personIDS и преобразуется в поток дат рождения с использованием лямбда-метода. Метод collect с параметром Collectors.toList собирает все полученные данные в список, который сохраняется в result.
Пример:
List<String> tmpList = personIDS.stream().filter(n -> n.getDATE().compareTo(new Date(1995, Calendar.JANUARY, 1))>0).map(PersonID::getDOB).collect(Collectors.toList());System.out.println(tmpList);
В этом примере я добавил только фильтр. Я выбираю из потока только тех, кто родился после 1 января 1995 года. Из исходного списка в 12 человек были выбраны только 6. Можно усложнить задачу, добавив еще один фильтр.
Пример:
List<String> tmpList = personIDS.stream().filter(n -> n.getDATE().compareTo(new Date(1995, Calendar.JANUARY, 1))>0).sorted((a,b)-> (int) (a.getSalary()-b.getSalary())).map(PersonID::getFIO).collect(Collectors.toList());
К фильтру и сортировке добавился еще один метод - map. Теперь мы собираем имена сотрудников и сортируем их по уровню зарплаты. Результат - список сотрудников с именами, родившимися после 1995 года, отсортированными по зарплате.
Пример:
List<String> tmpList = personIDS.stream().filter(n -> n.getDATE().compareTo(new Date(1995, Calendar.JANUARY, 1))>0).sorted((a,b)-> (int) (a.getSalary()-b.getSalary())).map(n -> n.getFIO() + " (" + n.getDOB() + ") " + n.getSalary()).limit(5).collect(Collectors.toList());tmpList.forEach(n-> System.out.println(n));
На этот раз я внес небольшие изменения. В методе map я добавил метод limit(5), который ограничивает список первыми пятью элементами. Мы берем список объектов, отбираем только те, которые соответствуют определенному условию, затем сортируем полученный список по одному из полей объекта и, наконец, создаем новый список с описаниями объектов и также ограничиваем его первыми пятью элементами. Весь этот процесс можно выполнить без стримов, но Stream API и лямбда-выражения упрощают эту задачу. Лямбда-выражения позволяют по-новому подойти к написанию кода. Имея поток данных, мы можем прогнать его через различные функции, изменять его и все это в рамках компактного и понятного синтаксиса.
public class Product {private final String name;private int cost;public Product(String name, int cost) {this.name = name;this.cost = cost;}public String getName() {return name;}public int getCost() {return cost;}public void setCost(int cost) {this.cost = cost;}@Overridepublic String toString() {return "Product{" +"name='" + name + '\'' +", cost=" + cost +'}';
public class Main {
public static void main(String[] args) {
List<Product> products = Arrays.asList(new Product("Молоко", 78),
new Product("Сметана", 90), new Product("Хлеб", 40), new Product("Кола", 80));
products.stream().map(product -> new Product(product.getName(), product.getCost() + 25))
.filter(product -> product.getCost() < 100)
.forEach(System.out::println);
}
}