В данной статье постараюсь собрать всю информацию о коллекциях в 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
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()
отдаёт нам только два первых элемента. Особое внимание уделите тому факту, что все эти операции были вызваны до обращения к коллекции (предпоследняя строчка в логе).
Визуально это выглядит следующим образом:
А теперь сделаем то же самое, но с последовательностью
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 дерева, вычисление останавливается.
Визуально это будет выглядеть следующим образом:
Пример с коллекцией выполнился за 15 шагов, с последовательностью - за 9 шагов. Это вовсе не означает, что теперь везде нужно использовать последовательности вместо коллекций. Необходимость их использования стоит рассматривать только в тех случаях, когда вам требуется хранить / обрабатывать большой объём данных. К тому же при работе с последовательностью нужно грамотно создавать цепочки операций, чтобы экономить ресурсы. В противном случае коллекции выигрывают по производительности.
Наткнулась на красивую визуализацию с анимацией, которая показывает разницу между коллекцией и последовательностью в использовании операций.
Функции коллекций
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 - статья от Александра Климова про ассоциативные списки.