Статьи


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

Вернуться назад

28 сентября 2020

Тэги: Collections Stream API Java 11 Java Kotlin

В этой статье хотелось бы сделать шпаргалку для тех, кто только начинает осваивать 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 +
                '}';
    }
}

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

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

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

Все классы на kotlin полностью совместимы на уровне байт-кода с Java-классами. Data-классы автоматически определяют помимо геттеров также методы equals(), hashCode() и toString().

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

Создадим список (List) из четырёх объектов типа City. На Java мы можем сделать это с помощью метода Arrays.asList():

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

А на kotlin вот так:

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.getName()));

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

cities.forEach { println(it.name) }

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

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

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

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

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

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

var cityNames = cities.stream()
        .map(City::getName)
        .collect(Collectors.toList());

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

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

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

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

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

cities.stream()
        .filter(c -> c.getPopulation() >= 3_000_000)
        .collect(Collectors.toList());

Код на kotlin:

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

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

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

По аналогии с фильтрацией можно сделать поиск первого элемента из списка. В Java нам поможет метод findFirst():

var first = cities.stream().findFirst(); // возвращает тип Optional<City>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

var mostPopulatedCity = cities.stream()
        .max(Comparator.comparing(City::getPopulation)); // Москва

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

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

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

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

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

var totalPopulation = cities.stream().mapToInt(City::getPopulation).sum(); // int

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

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

var averagePopulation = cities.stream().mapToInt(City::getPopulation).average(); // OptionalDouble

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

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

Объект IntSummaryStatistics содержит пять полей: количество элементов, сумма элементов, минимальное, максимальное и среднее значения.

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

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

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

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

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

var citiesByName = cities.stream()
        .collect(Collectors.toMap(City::getName, Function.identity()));

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

val citiesByName = cities.associateBy { it.name }

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

var nameToPopulation = cities.stream()
        .collect(Collectors.toMap(City::getName, City::getPopulation));

А на kotlin вот так:

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

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

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

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

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

var citiesByFirstLetter = cities.stream()
        .collect(Collectors.groupingBy(c -> c.getName().charAt(0)));

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

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

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

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

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

var flattenCities = citiesByFirstLetter.values().stream()
        .flatMap(Collection::stream)
        .collect(Collectors.toList());

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

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

val flattenCities = citiesByFirstLetter.values.flatten()

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

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

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

var days = Arrays.asList("суббота", "воскресенье", "суббота"); // список с дублями
var fastHolidays = new HashSet<>(days); // быстрый поиск
var 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():

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

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

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

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

Выводы

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