Статьи Kotlin. Коллекции
Post
Cancel

Kotlin. Коллекции

В данной статье постараюсь собрать всю информацию о коллекциях в Kotlin.


Общая информация

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

В Kotlin есть три типа коллекций:

  • List или список.
  • Set или множество / набор / сет.
  • Map или словарь / ассоциативный список / мапа.

При этом существует строгое разграничение между изменяемой (mutable) коллекцией и неизменяемой (read-only). Поэтому в Kotlin есть также два типа интерфейсов, на основе которых создаются коллекции:

  • read-only интерфейсы - дают доступ только для чтения, т.е. такие коллекции нельзя изменять. К ним относятся Set, List, Map, Collection.
  • mutable интерфейсы - дают доступ и для чтения, и для записи в коллекцию. К ним относятся MutableSet, MutableList, MutableMap, MutableCollection.

List

Список - это упорядоченная коллекция. Каждое значение, помещённое в список, называется элементом, к которому можно обращаться по индексу - целому числу, отражающему положение элемента в списке. Индексы начинаются с 0 (0 - индекс первого элемента) и заканчиваются индексом последнего элемента в списке - (list.size - 1).
Список может содержать сколько угодно одинаковых элементов - дублей (в том числе null).

В Kotlin есть два типа интерфейсов, на основе которых создаются списки:

  • List - неизменяемый список, предоставляет операции и функции для обращения к элементам.
  • MutableList - изменяемый список, предоставляет всё, что есть в List + операции и функции для изменения элементов.
1
2
3
4
5
val trees = listOf("Сосна", "Берёза", "Дуб") // неизменяемый список
trees.add("Ясень") // ошибка

val mutableTrees = mutableListOf("Сосна", "Берёза", "Дуб") // изменяемый список
mutableTrees.add("Ясень") // всё ок

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

1
2
val mutableTrees = ArrayList<String>()
mutableTrees.add("Ясень")

Set

Множество - это коллекция уникальных элементов. Это означает, что сет не может содержать дублей. Обратите внимание, что null - это тоже уникальный элемент. Очень удачный пример сета - алфавит.

В Kotlin есть два типа интерфейсов, на основе которых создаются сеты:

  • Set - неизменяемый сет, предоставляет операции и функции для обращения к элементам.
  • MutableSet - изменяемый сет, предоставляет всё, что есть в Set + операции и функции для изменения элементов.
1
2
3
4
5
val trees = setOf("Сосна", "Берёза", "Дуб") // неизменяемый сет
trees.add("Ясень") // ошибка

val mutableTrees = mutableSetOf("Сосна", "Берёза", "Дуб") // изменяемый сет
mutableTrees.add("Сосна") // проигнорируется

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

Например, по умолчанию реализацией сета является LinkedHashSet, который сохраняет порядок вставки элементов.

1
2
3
4
5
val numbers = setOf(1, 2, 3, 4)  // по умолчанию LinkedHashSet
val numbersBackwards = setOf(4, 3, 2, 1)

println(numbers.first() == numbersBackwards.first()) // false
println(numbers.first() == numbersBackwards.last()) // true

Но также существует HashSet, который не сохраняет порядок вставки элементов.

И LinkedHashSet, и HashSet можно создать напрямую.

1
2
3
4
5
val linkedHashSet = LinkedHashSet<String>()
linkedHashSet.add("Дуб")

val hashSet = HashSet<String>()
hashSet.add("Ясень")

Map

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

Также обратите внимание на то, что хоть мапа и является одним из типов коллекций в Kotlin, но она не является наследников интерфейса Collection. Поэтому если увидите в документации запись функции, где видно, что она доступна для Collection, то знайте, что мапа её не поддерживает.

В Kotlin есть два типа интерфейсов, на основе которых создаются мапы:

  • Map - неизменяемая мапа, предоставляет операции и функции для обращения к записям.
  • MutableMap - изменяемая мапа, предоставляет всё, что есть в Map + операции и функции для изменения записей.
1
2
3
4
5
6
// числа - это ключи, деревья - значения
val map = mapOf(1 to "Сосна", 2 to "Берёза", 3 to "Дуб") // неизменяемая мапа
map.put(4, "Ясень") // ошибка

val mutableMap = mutableMapOf(1 to "Сосна", 2 to "Берёза", 3 to "Дуб") // изменяемая мапа
mutableMap.put(4, "Ясень")

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

1
2
3
4
5
val linkedHashMap = LinkedHashMap<Int, String>()
linkedHashMap.put(1, "Дуб")

val hashMap = HashMap<Int, String>()
hashMap.put(1, "Ясень")

Sequences

Sequences или последовательности - ещё один тип контейнера в Kotlin, но он не является коллекцией. Однако общая концепция такая же, как и у коллекций, поэтому решила добавить их в эту статью для ознакомления.

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

Отличия коллекции от последовательности.

  1. Если обработка коллекции состоит из нескольких шагов, то они выполняются немедленно.

    Последовательность же по возможности выполняет обработку “лениво” - это означает, что все вычисления фактически происходят только тогда, когда запрашивается результат их выполнения.

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

    Последовательность же берёт, допустим, первый элемент и прогоняет его по всем шагам, затем берёт второй и прогоняет его. И так до тех пор, пока не будет получен результат. Т.е. последовательность может остановить обработку на втором элементе, если в результате его обработки было получено требуемое значение.

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Пример работы коллекции

data class Tree(
    val name: String,
    val age: Int
)

...

val trees = listOf(
    Tree(name = "Сосна", age = 6),
    Tree(name = "Берёза", age = 2),
    Tree(name = "Дуб", age = 20),
    Tree(name = "Ясень", age = 15),
    Tree(name = "Клён", age = 9)
)

val sortedTrees = trees
    .filter { tree ->
        println("filter: $tree")
        tree.age > 5
    } // отбираем деревья старше 5 лет
    .map { tree ->
        println("map: $tree")
        tree.name.uppercase()
    } // трансформируем тип Tree в String с заглавными буквами
    .take(2) // берём только первые два элемента

println("Первые два дерева старше 5 лет:")
println(sortedTrees)

// То, что будет в логе
filter: Tree(name=Сосна, age=6)
filter: Tree(name=Берёза, age=2)
filter: Tree(name=Дуб, age=20)
filter: Tree(name=Ясень, age=15)
filter: Tree(name=Клён, age=9)
map: Tree(name=Сосна, age=6)
map: Tree(name=Дуб, age=20)
map: Tree(name=Ясень, age=15)
map: Tree(name=Клён, age=9)
Первые два дерева старше 5 лет:
[СОСНА, ДУБ]

Из лога видно, что сначала для всех элементов выполнилась функция filter(), затем оставшиеся модифицируются с помощью map() и в конце функция take() отдаёт нам только два первых элемента. Особое внимание уделите тому факту, что все эти операции были вызваны до обращения к коллекции (предпоследняя строчка в логе).

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

List Processing

А теперь сделаем то же самое, но с последовательностью

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Пример работы последовательности

val trees = sequenceOf(
    Tree(name = "Сосна", age = 6),
    Tree(name = "Берёза", age = 2),
    Tree(name = "Дуб", age = 20),
    Tree(name = "Ясень", age = 15),
    Tree(name = "Клён", age = 9)
)

val sortedTrees = trees
    .filter { tree ->
        println("filter: $tree")
        tree.age > 5
    } // отбираем деревья старше 5 лет
    .map { tree ->
        println("map: $tree")
        tree.name.uppercase()
    } // трансформируем тип Tree в String с заглавными буквами
    .take(2) // берём только первые два элемента

println("Первые два дерева старше 5 лет:")
println(sortedTrees.toList())

// То, что будет в логе
Первые два дерева старше 5 лет:
filter: Tree(name=Сосна, age=6)
map: Tree(name=Сосна, age=6)
filter: Tree(name=Берёза, age=2)
filter: Tree(name=Дуб, age=20)
map: Tree(name=Дуб, age=20)
[СОСНА, ДУБ]

Как видно из лога, функции filter() и map() вызываются в момент обращения к последовательности (первая строчка лога). Второй момент - порядок вызова функций. Если дерево соответствует условию фильтра (старше 5 лет), то оно сразу модифицируется в функции map(). Как только были отобраны и модифицированы 2 дерева, вычисление останавливается.

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

Sequence Processing

Пример с коллекцией выполнился за 15 шагов, с последовательностью - за 9 шагов. Это вовсе не означает, что теперь везде нужно использовать последовательности вместо коллекций. Необходимость их использования стоит рассматривать только в тех случаях, когда вам требуется хранить / обрабатывать большой объём данных. К тому же при работе с последовательностью нужно грамотно создавать цепочки операций, чтобы экономить ресурсы. В противном случае коллекции выигрывают по производительности.


Наткнулась на красивую визуализацию с анимацией, которая показывает разницу между коллекцией и последовательностью в использовании операций.

Animated gif


Функции коллекций

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

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

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


Изменяем неизменяемое

Хоть в документации и сказано, что List - это неизменяемая коллекция, на деле это не совсем так. Давайте рассмотрим простой пример.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun start(list: List<*>) {
    println(list::class.java)
    println("до изменения $list")
    tryToModifyList(list)
    println("после изменения $list")
}

fun <T> tryToModifyList(list: List<T>) {
    if (list.size > 1 && list is MutableList<T>) {
        val item = list[0]
        list[0] = list[1]
        list[1] = item
    }
}

У нас есть метод start(), который выводит в лог информацию о типе списка, а также о его содержимом до и после изменений. Также в этом методе вызывается другой метод - tryToModifyList(), который пытается поменять местами первые два элемента.

Попробуем создать MutableList и передать его в метод start().

1
2
3
4
5
6
7
8
9
fun main(args: Array<String>) {
    val list = mutableListOf(1, 2, 3, 4)
    start(list)
}

// Результат
class java.util.ArrayList
до изменения [1, 2, 3, 4]
после изменения [2, 1, 3, 4]

В данном случае результат предсказуемый: мы создали изменяемый список и нам удалось поменять первые элементы местами.

Теперь же давайте создадим List и также передадим его в метод start().

1
2
3
4
5
6
7
8
9
fun main(args: Array<String>) {
    val list = listOf(1, 2, 3, 4)
    start(list)
}

// Результат
class java.util.ArrayList
до изменения [1, 2, 3, 4]
после изменения [2, 1, 3, 4]

Несмотря на то, что мы создали неизменяемый список, нам удалось поменять в нём элементы местами. Почему так происходит? Ответ находится в первой строчке полученного результата:
class java.util.ArrayList. И List, и MutableList имеют одинаковую реализацию - ArrayList.

ArrayList - это изменяемый список из java (если точнее, то это оболочка над массивом, чтобы представить его в виде списка). ArrayList позволяет читать и записывать значения в определённую позицию исходного массива, но не позволяет изменять размер массива. Поэтому мы можем использовать метод set(), однако методы add() и remove() будут бросать исключение.

Более подробно об этом можно почитать в статье.


Полезные ссылки

Collections overview - общий обзор из официальной документации по коллекциям в Kotlin. Там же есть более подробные статьи на эту тему.
Коллекции. Общий обзор - неофициальный перевод документации на русский язык.
Learn Kotlin by Example - помощь в разборе коллекций и их функций на примерах (на английском).
Sequence - статья от Александра Климова про последовательности.
Коллекции. List (Списки) - статья от Александра Климова, обзор основных функций коллекций.
Set (Множество) - статья от Александра Климова про множества.
Map - статья от Александра Климова про ассоциативные списки.

This post is licensed under CC BY 4.0 by the author.