Переезд из Java в Kotlin: как забрать коллекции с собой
Меня зовут Борис Николаев, и в первой статье на Хабре хочу сравнить Java и Kotlin при работе с коллекциями. Она будет полезна всем, кто планирует перебираться в Kotlin и не хочет долго осваиваться.
В течение последних лет Kotlin становится всё более и более популярным. Многие начинают осваивать его, уже имея за плечами какой-то бэкграунд на Java, поэтому в данной статье мне хотелось бы привести сравнение кода на Java и на Kotlin. Чтобы наши примеры были более наглядными, рассмотрим различные операции над коллекциями, потому что без них не обходится ни одно приложение.
Пункт отправления: «класс-сущность»
Чтобы нам было над чем производить манипуляции, создадим класс-сущность — он предназначен для хранения данных. Часто такие классы точно соответствуют полям таблицы. Например, возьмём сущность «Город», у которой есть два поля — название и количество жителей.
На 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-классами. Такие классы автоматически определяют помимо геттеров также методы equals(), hashCode() и toString().
Следующая остановка — «создание коллекции»
В качестве примера используем коллекцию типа «список» (List) из четырёх экземпляров класса City. На Java, начиная с 9-ой версии, это делается с помощью метода List.of()
:
var cities = List.of(
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()
относится к стандартной библиотеке Kotlin и упрощает создание коллекций. Данный метод добавлен к интерфейсу List. Причём сам список — опять-таки неизменяемый объект: в него нельзя добавлять или удалять из него элементы. Если же нам требуется менять содержимое списка, то мы могли бы воспользоваться методом mutableListOf()
, который вернёт нам объект типа MutableList.
Перебор всех элементов коллекции
Помимо стандартного перебора элементов через цикл 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
Он возвращает объект класса Optional, так как коллекция может быть пустой, и тогда Optional также будет пустым. В Kotlin нет Optional, а вместо этого можно использовать nullable-тип: котлиновский компилятор проверит, может ли наш тип когда-либо принять значение null или не может. Признаком допустимости null с точки зрения синтаксиса является знак вопроса после имени класса. Ниже для наглядности тип возвращаемого значения указан в явном виде после двоеточия. Обычно компилятор Kotlin сам выводит тип, поэтому в реальных системах явное указание типа излишне.
// возвращает null в случае пустого списка
val nullableFirst: City? = cities.firstOrNull()
// выбрасывает исключение для пустого списка
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 эквивалентная выборка делается с помощью метода 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). Механизм инфиксной записи открывает довольно широкие возможности для создания синтаксисов, ориентированных на конкретную предметную область (domain specific language, или DSL).
Функция 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 letterLists = List.of(
List.of("a", "b", "c"),
List.of("d"),
List.of("e", "f")
);
var plainLetters = letterLists.stream()
.flatMap(Collection::stream)
.collect(Collectors.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 это можно сделать так:
// список с дублями
var days = List.of("суббота", "воскресенье", "суббота");
// быстрый поиск
var fastHolidays = new HashSet<>(days);
// сохранение порядка элементов
var orderedHolidays = new LinkedHashSet<>(days);
В Java (а значит, и в Kotlin) есть несколько реализаций интерфейса Set. В данном случае я привёл две из них: HashSet и LinkedHashSet. Первую реализацию следует использовать тогда, когда нам требуется искать в ней элементы и порядок нам не важен, а вторую — когда мы будем где-либо отображать это множество, так как в нём сохранится порядок следования элементов из исходной коллекции.
На Kotlin создание этих множеств будет выглядеть следующим образом:
// список с дублями
val days = listOf("суббота", "воскресенье", "суббота")
// быстрый поиск
val fastHolidays = days.toHashSet()
// сохранение порядка элементов
val orderedHolidays = days.toSet()
Метод toHashSet()
ожидаемо возвращает именно HashSet, оптимизированный для быстрого поиска значений. А метод toSet()
по факту возвращает именно LinkedHashSet, если в исходной коллекции было больше одного элемента. То есть он возвращает множество, которое сохранит порядок следования элементов из исходной коллекции.
Поиск элементов в коллекции
Поиск элементов по значению можно выполнять в любом виде коллекций (list, set, map). Сказанное далее технически применимо к любой коллекции, но быстрее всего будет работать именно в HashSet. Поэтому возьмём коллекцию fastHolidays из предыдущего примера и проверим, является ли понедельник выходным днём.
В Java для таких проверок используется метод contains()
:
var isHoliday = fastHolidays.contains("понедельник");
// false
В результате мы ожидаемо получим false, ведь «понедельник — день тяжёлый», и выходным не является.
В Kotlin эквивалентная проверка выглядит следующим образом:
val isHoliday = fastHolidays.contains("понедельник")
// или более краткая форма
val isHoliday = "понедельник" in fastHolidays
Вторая форма использует выражение in, но это не более чем синтаксический сахар, так как благодаря соглашениям об именовании методов Kotlin всегда будет вызывать метод contains()
, когда встречает выражение in. На мой взгляд, второй вариант гораздо более читаемый и понятный.
Групповая проверка условий
Иногда бывает необходимо проверить какое-то условие над всеми элементами коллекции сразу. Например, у нас есть множество целых чисел, и мы хотим узнать, есть ли среди них положительные числа? Все ли они положительны? И наконец, мы хотим убедиться, что среди них нет нуля. Пример на Java:
var numbers = Set.of(-2, -1, 3, 4, 5);
var hasPositive = numbers.stream().anyMatch(n -> n > 0); // true
var allPositive = numbers.stream().allMatch(n -> n > 0); // false
var 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 при работе с коллекциями
Как видите, с точки зрения синтаксиса, операции над коллекциями в Kotlin всегда гораздо более компактны и читаемы, нежели на Java. Важно заметить, что при этом нет накладных расходов в плане производительности, потому что в Kotlin используются те же классы коллекций из Java, но при этом они дополнены большим количеством вспомогательных методов расширения. В результате работать с коллекциями в Kotlin всегда легко и приятно.
Если вы можете предложить более оптимальные решения для примеров, рассмотренных в статье, пишите об этом в комментариях. Обсудим вместе!