Статьи Утилиты Telegram YouTube RuTube Отзывы

Работа с коллекциями

2 ноября 2023

Тэги: Collections, Java, Kotlin, Stream API.

Содержание

  1. Создаём класс-сущность
  2. Создание коллекции
  3. Перебор всех элементов коллекции
  4. Преобразование элементов списка
  5. Фильтрация элементов списка
  6. Первый и последний элемент списка
  7. Прямая и обратная сортировка элементов
  8. Объединение нескольких строк в одну
  9. Поиск максимальных и минимальных значений
  10. Вычисление суммы и среднего значения
  11. Преобразование List в Map
  12. Группировка элементов
  13. Преобразование двумерного списка в одномерный
  14. Преобразование List в Set
  15. Поиск элементов в коллекции
  16. Групповая проверка условий
  17. Выводы

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

Создаём класс-сущность

Для того, чтобы нам было от чего отталкиваться, создадим класс-сущность. Это класс, предназначенный для хранения данных. Часто в приложениях поля таких классов мапятся один к одному на поля таблицы в базе данных. Для примера рассмотрим сущность «Город», у которой есть два атрибута: название и количество проживающих в нём людей.

На старых версиях Java такой класс выглядел бы следующим образом:

public class City {

    private final String name;
    private final int population;

    public City(String name, int population) {
        this.name = name;
        this.population = population;
    }

    public String getName() {
        return name;
    }

    public int getPopulation() {
        return population;
    }

    @Override
    public String toString() {
        return "City{" +
                "name='" + name + '\'' +
                ", population=" + population +
                '}';
    }
}

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

С появлением Java 16 для этих целей мы можем использовать класс типа record.

public record City(
        String name,
        int population
) {
}

Компилятор автоматически сгенерит для него конструктор, стандартные методы equals(), hashCode(), toString() и get-методы для каждого поля. record тоже является неизменямым. Значения полей можно установить только через конструктор в момент инициализации.

На kotlin аналогичный класс объявляется следующим образом:

data class City(
    val name: String,
    val population: Int
)

Все классы на kotlin полностью совместимы на уровне байт-кода с Java-классами.

Создание коллекции

Создадим список (List) из четырёх объектов типа City. На Java, начиная с 9-ой версии, мы можем сделать это с помощью метода List.of():

List<City> cities = List.of(
        new City("Париж", 2_148_327),
        new City("Москва", 12_678_079),
        new City("Берлин", 3_644_826),
        new City("Мадрид", 3_266_126)
);

А на kotlin вот так (ключевое слово new не требуется):

val cities = listOf(
    City("Париж", 2_148_327),
    City("Москва", 12_678_079),
    City("Берлин", 3_644_826),
    City("Мадрид", 3_266_126)
)

Метод расширения listOf() возвращает объект класса List. Причём это опять-таки неизменяемый объект, то есть в него нельзя добавлять или удалять элементы. Если же нам нужен изменяемый список, то мы могли бы воспользоваться методом mutableListOf(), который вернёт нам объект класса MutableList.

И Java, и kotlin позволяют разделять десятичные разряды в числах при помощи символа нижнего подчёркивания. Делается это исключительно для удобства чтения и на сами значения никак не влияет.

Перебор всех элементов коллекции

Помимо традиционного перебора элементов через цикл foreach (например, в целях логирования), Java позволяет сделать это в функциональном стиле через метод forEach():

cities.forEach(c -> System.out.println(c.name()));

Тут мы при помощи лямбда-выражения выводим в консоль название каждого города. В kotlin это делается аналогично:

cities.forEach { println(it.name) }

Имя переменной it представляет собой имя по умолчанию для единственного параметра ламбда-выражения. Вы также можете явно задать имя по аналогии с Java. Но если у вас окажется несколько вложенных лямбда-выражений, тогда явное именование их параметров становится обязательным.

Если вам помимо самого элемента нужен ещё и его индекс в коллекции, то используйте метод forEachIndexed(). Тогда в лямбде нужно будет в явном виде указывать два параметра вместо одного дефолтного it: индекс и элемент.

cities.forEachIndexed { index, city -> println("$index: ${city.name}") }

Здесь мы выводим индекс элемента, а затем после двоеточия название города. В kotlin символ доллара позволяет подставлять значение переменной прямо в строку. Если нужно встроить не объект целиком, а одно из его полей, тогда помимо доллара выражение нужно взять в фигурные скобки.

Преобразование элементов списка

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

List<String> cityNames = cities.stream()
        .map(City::name)
        .toList();

Тут мы сначала преобразуем нашу коллекцию в стрим, затем делаем преобразование с помощью метода map() и затем преобразуем стрим в новый список с помощью метода toList(). На kotlin такое же преобразование выглядит следующим образом:

// более краткая запись: cities.map { it.name }
val cityNames = cities.map { c -> c.name }

Тут метод также называется map().

Фильтрация элементов списка

Давайте теперь создадим новый список, в котором будут только города с населением более трёх миллионов человек. Код на Java:

cities.stream()
        .filter(c -> c.population() >= 3_000_000)
        .toList();

Код на kotlin:

cities.filter { it.population >= 3_000_000 }

Как видите, в обоих случаях ключевым является метод filter(). Только в kotlin он уже возвращает готовую коллекцию, тогда как на Java возвращается стрим, который затем мы преобразуем с помощью toList().

Первый и последний элемент списка

По аналогии с фильтрацией можно сделать поиск первого элемента из списка. В Java 21 нам поможет метод getFirst(). Если же список пустой, то этот метод кидает исключение NoSuchElementException. Поэтому можно использовать более универсальный findFirst() из стримов:

// если список не пустой, начиная с Java 21
City first = cities.getFirst();

// список может быть пустой
Optional<City> first = cities.stream().findFirst();

Он возвращает объект типа Optional, т.к. коллекция может быть пустой и тогда Optional также будет пустым. В kotlin нет Optional, а вместо этого используется nullable-тип, т.е. компилятор контролирует, может ли ваш тип когда-либо принять значение null или не может. Признаком допустимости null является знак вопроса после имени соответствующего типа. Ниже для наглядности тип возвращаемого значения указан в явном виде после двоеточия. Обычно компилятор kotlin сам выводит тип, поэтому явное указание излишне.

// кидает исключение для пустого списка
val nonNullableFirst: City = cities.first()

// возвращает null в случае пустого списка
val nullableFirst: City? = cities.firstOrNull()

В случае пустого списка firstOrNull() вернёт null, тогда как метод first() выбросит исключение. Поэтому метод firstOrNull() в общем случае использовать предпочтительнее.

Помимо получения самого первого элемента списка вы можете выбрать первый элемент, удовлетворяющий определённым условиям. Например, первый город, имя которого начинается на букву «М». На Java мы просто скомбинируем два уже известных нам метода:

cities.stream()
        .filter(c -> c.name().startsWith("М"))
        .findFirst();

А на kotlin метод firstOrNull() и другие ему подобные принимают в качестве параметра предикат (условие фильтрации):

cities.firstOrNull { it.name.startsWith("М") }

Опять же, код получился более компактным.

Kotlin также предоставляет методы last() и lastOrNull(), которые возвращают не первый, а последний элемент списка. В остальном принцип их работы такой же, как и у выше рассмотренных методов.

Начиная с Java 21 нам также доступен метод getLast(), возвращающий последний элемент, если список не пустой.

Прямая и обратная сортировка элементов

Теперь давайте отсортируем наши города по названию в обратном алфавитном порядке. То есть от «Я» до «А». Код на Java:

List<City> sortedCities = cities.stream()
        .sorted(Comparator.comparing(City::name, Comparator.reverseOrder()))
        .toList(); // Париж, Москва, Мадрид, Берлин

В метод sorted() мы передаём компаратор, который содержит в себе информацию, по какому полю сортируем (поле name) и каков порядок сортировки (обратный). Для прямой сортировки нужно использовать Comparator.naturalOrder() или же вообще не указывать второй параметр. Затем создаём новый отсортированный список. То же самое на kotlin записывается более компактно:

// Париж, Москва, Мадрид, Берлин
val sortedCities = cities.sortedByDescending { it.name }

Как видите, для удобства есть специальный метод расширения sortedByDescending(). В виде лямбды в него нужно лишь передать поле, по которому производится сортировка. Для прямой сортировки используйте метод sortedBy().

Объединение нескольких строк в одну

Очень полезно бывает объединить несколько строк в одну, поместив между ними запятую. Давайте объединим названия городов. В Java для этого есть метод String.join().

// "Париж, Москва, Берлин, Мадрид"
String citiesString = String.join(", ", cityNames);

В kotlin есть похожий метод joinToString():

// "Париж, Москва, Берлин, Мадрид"
val citiesString = cityNames.joinToString(separator = ", ")

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

Поиск максимальных и минимальных значений

Давайте найдём город с самым большим населением. Нетрудно догадаться, что среди наших исходных данных это Москва. В Java мы воспользуемся методом max() и методом Comparator.comparing() для указания того поля, по которому происходит сравнение:

Optional<City> mostPopulatedCity = cities.stream()
        .max(Comparator.comparing(City::population)); // Москва

В kotlin такая же выборка делается c помощью метода maxByOrNull():

val mostPopulatedCity = cities.maxByOrNull { it.population } // Москва

Для поиска минимального элемента в обоих случаях нужно поменять «max» на «min», то есть воспользоваться методами min() и minByOrNull() соответственно. Тогда в нашей выборке самым маленьким городом окажется Париж.

Вычисление суммы и среднего значения

Теперь давайте посчитаем сколько всего людей живёт в наших городах вместе взятых. То есть посчитаем сумму по полю population. В Java нам нужно будет получить IntStream при помощи метода mapToInt():

int totalPopulation = cities.stream()
        .mapToInt(City::population)
        .sum();

Метод IntStream.sum() всегда возвращает целочисленное значение, т.к. даже в случае пустой коллекции сумма просто будет равна нулю.

Теперь давайте вычислим среднее значение населения. Тут уже чуть сложнее, потому что во-первых, среднее значение имеет тип Double, а во-вторых, его может и не быть в случае, если коллекция пуста:

OptionalDouble averagePopulation = cities.stream()
        .mapToInt(City::population)
        .average();

Также класс IntStream предлагает метод summaryStatistics(), который позволяет получить все основные статистические параметры разом:

IntSummaryStatistics statistics = cities.stream()
        .mapToInt(City::population).summaryStatistics();
// count=4, sum=21737358, min=2148327, average=5434339.500000, max=12678079

Объект IntSummaryStatistics содержит пять полей:

  • количество элементов
  • сумма
  • минимум
  • максимум
  • среднее значение.

Теперь давайте найдём вычислим суммарное и среднее население с использованием kotlin:

val totalPopulation = cities.sumOf { it.population } // или cities.map { it.population }.sum()
val averagePopulation = cities.map { it.population }.average() // среднее значение

Метод sumOf() принимает в качестве параметра поле, по которому нужно выполнить суммирование и возвращает целочисленную сумму. Этот метод равносилен комбинации методов map() и sum(). Метод average() возвращает среднее значение в виде Double, причём в случае пустого списка метод вернёт специальное значение Double.NAN («not a number»).

Преобразование List в Map

Теперь давайте превратим наш список городов в мапу, где ключом будет название города. После этого мы сможем быстро находить нужный город в нашей коллекции по его имени. На Java это делается через метод Collectors.toMap():

Map<String, City> citiesByName = cities.stream()
        .collect(Collectors.toMap(City::name, Function.identity()));

В первом параметре мы указываем, какое поле должно стать ключом, а во втором – что брать в качестве значения. Function.identity() возвращает элемент списка целиком. На kotlin для этого есть специальный метод associateBy():

val citiesByName = cities.associateBy { it.name }

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

Map<String, Integer> nameToPopulation = cities.stream()
        .collect(Collectors.toMap(City::name, City::population));

А на kotlin вот так:

val nameToPopulation = cities.associate { it.name to it.population }

Обратите внимание, что to в данном случае не ключевое слово языка, а лишь обычная функция, помеченная ключевым словом infix. Этот модификатор позволяет записывать вызов функций в таком красивом виде, а по факту данный вызов полностью эквивалентен вызову it.name.to(it.population). Механизм инфиксной записи открывает довольно широкие возможности для создания синтаксисов, ориентированных на конкретную предметную область.

В итоге функция to() возвращает объект Pair, который состоит из двух полей first и second. Затем метод associate() преобразует список таких объектов в мапу.

Группировка элементов

Теперь давайте создадим не просто мапу, а такую мапу, в которой ключом будет первая буква имени города, а значением – список всех городов, начинающихся на эту букву. Иными словами, сделаем группировку по первой букве имени города. В наших исходных данных есть два города, начинающихся на одну букву, следовательно по ключу «М» у нас должен быть список из двух элементов.

Map<Character, List<City>> citiesByFirstLetter = cities.stream()
        .collect(Collectors.groupingBy(c -> c.name().charAt(0)));

В Java мы используем метод Collectors.groupingBy(), в который указываем правило формирования ключа будущей мапы. Логика его заключается в том, чтобы брать первый символ в названии города. На kotlin это записывается с помощью метода groupBy():

val citiesByFirstLetter = cities.groupBy { it.name.first() }

Уже знакомый нам метод first(), применённый к строке, возвращает её первый символ.

Преобразование двумерного списка в одномерный

Теперь выполним обратную задачу: объединим несколько списков разного размера в один общий список. Предположим, у нас имеется список, каждый элемент которого является списком строк. Тогда мы можем получить один «плоский» список с помощью метода flatMap(). Код на Java:

List<List<String>> letterLists = List.of(
        List.of("a", "b", "c"),
        List.of("d"),
        List.of("e", "f")
);
List<String> plainLetters = letterLists.stream()
        .flatMap(Collection::stream)
        .toList(); // [a, b, c, d, e, f]

Каждый элемент, являющийся вложенным списком, мы преобразуем в стрим. Эти стримы объединяются в один общий и затем результирующий стрим преобразуется в новый список.

В kotlin также есть метод flatMap(), но если нам не требуется выполнять дополнительных преобразований над элементами, то воспользуемся его более кратким эквивалентом flatten():

val letterLists = listOf(
    listOf("a", "b", "c"),
    listOf("d"),
    listOf("e", "f")
)
val plainLetters = letterLists.flatten() // [a, b, c, d, e, f]

Преобразование List в Set

Коллекция типа «множество» (set) отличается от простого списка тем, что в нём содержатся только уникальные значения. Если мы сначала создадим список с дублями, а затем преобразуем его в Set, то в результате получим новую коллекцию с меньшим количеством элементов и все они будут уникальными.

Давайте создадим сначала список из дней недели, в котором будут дубли. Затем преобразуем его в множество с уникальными элементами (множество дней недели, которые считаются выходными). В Java это можно сделать так:

// список с дублями
List<String> days = List.of("суббота", "воскресенье", "суббота");

// быстрый поиск
Set<String> fastHolidays = new HashSet<>(days);

// сохранение порядка элементов
Set<String> orderedHolidays = new LinkedHashSet<>(days);

Как я рассказывал в статье Коллекции: list, set, map, в Java (а, значит, и в Kotlin) есть несколько реализаций интерфейса Set. В данном случае я привёл две из них: HashSet и LinkedHashSet. Первую следует использовать тогда, когда нам требуется искать в ней элементы и порядок нам не важен, а вторую – когда мы будем где-либо показывать это множество, т.к. в нём сохранится порядок следования элементов из исходной коллекции.

На kotlin создание этих множеств будет выглядеть следующим образом:

val days = listOf("суббота", "воскресенье", "суббота") // список с дублями
val fastHolidays = days.toHashSet() // быстрый поиск
val orderedHolidays = days.toSet() // сохранение порядка элементов

Метод toHashSet() ожидаемо возвращает именно HashSet, оптимизированный для поиска значений. А метод toSet() по факту возвращает именно LinkedHashSet, если в исходной коллекции больше одного элемента. То есть он возвращает множество, которое сохранит порядок следования элементов из исходной коллекции.

Поиск элементов в коллекции

Поиск элементов по значению можно выполнять в любом виде коллекций (list, set, map), но лучше всего для этого подходят именно множества (set). Сказанное далее технически применимо к любой коллекции, но быстрее всего будет работать именно в HashSet. Поэтому возьмём коллекцию fastHolidays из предыдущего примера и проверим, является ли понедельник выходным днём.

В Java для таких проверок используется метод contains():

boolean isHoliday = fastHolidays.contains("понедельник"); // false

В результате мы ожидаемо получим false, т.к. «понедельник – день тяжёлый» и выходным не является. В kotlin эквивалентная проверка выглядит следующим образом:

val isHoliday = fastHolidays.contains("понедельник")
// или более краткая форма
val isHoliday = "понедельник" in fastHolidays

Вторая форма использует выражение in, но это не более чем синтаксический сахар, т.к. благодаря соглашениям об именовании методов kotlin всегда будет вызывать метод contains(), когда встречает выражение in. На мой взгляд, второй вариант гораздо более читаемый и понятный.

Групповая проверка условий

Иногда бывает необходимо проверить какое-то условие над всеми элементами коллекции сразу. Например, у нас есть множество целых чисел и мы хотим узнать, есть ли среди них положительные числа? Все ли они положительны? И наконец, мы хотим убедиться, что среди них нет нуля. Пример на Java:

Set<Integer> numbers = Set.of(-2, -1, 3, 4, 5);
boolean hasPositive = numbers.stream().anyMatch(n -> n > 0); // true
boolean allPositive = numbers.stream().allMatch(n -> n > 0); // false
boolean withoutZero = numbers.stream().noneMatch(n -> n == 0); // true

В этом нам помогают методы стримов anyMatch(), allMatch() и noneMatch(), которые принимают условия проверки в виде лямбды. В результате этих проверок мы узнаём, что среди наших чисел есть положительные, однако не все, и среди них действительно нет нуля.

То же самое можно написать и на kotlin с использованием методов any(), all() и none() соответственно:

val numbers = setOf(-2, -1, 3, 4, 5)
val hasPositive = numbers.any { it > 0 } // true
val allPositive = numbers.all { it > 0 } // false
val withoutZero = numbers.none { it == 0} // true

Важно также отметить, что и в случае метода allMatch(), и в случае метода all(), вызов на пустой коллекции с любым предикатом всегда вернёт true.

Выводы

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


См. также


Комментарии

Добавить комментарий

×

devmark.ru