16 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Изменяемость | Read-only против immutable, защитное копирование и персистентные коллекции |
|
List в Kotlin - read-only, а не immutable. Это разные вещи, и их смешение - источник самых неприятных ошибок с коллекциями.
read-only (List) |
immutable | |
|---|---|---|
| изменить через эту ссылку | нельзя | нельзя |
| может измениться вообще | да | нет |
| гарантия | интерфейс без мутаторов | сам объект неизменяем |
| есть в Kotlin stdlib | да | нет (только внешняя библиотека) |
Почему read-only не immutable
fun main() {
val mutable = mutableListOf(1, 2)
val readOnly: List<Int> = mutable // тот же объект, другой интерфейс
mutable.add(3)
println(readOnly) // [1, 2, 3] — изменился под вами
}
MutableList наследует List, поэтому любая изменяемая коллекция подходит там, где ожидается List. Приведение типа объект не копирует.
Это создаёт конкретную проблему в API:
class Repository4 {
private val items = mutableListOf(1, 2, 3)
fun getItems(): List<Int> = items // утечка: вернули ссылку на изменяемое
fun add(x: Int) { items.add(x) }
}
fun main() {
val repo = Repository4()
val snapshot = repo.getItems()
println(snapshot) // [1, 2, 3]
repo.add(4)
println(snapshot) // [1, 2, 3, 4] — «снимок» изменился
}
Клиент получил List и вправе считать, что значение стабильно - например, положить его в Map как ключ или обойти в другом потоке. Ни то ни другое здесь не безопасно.
Защитное копирование
class Repository5 {
private val items = mutableListOf(1, 2, 3)
fun getItems(): List<Int> = items.toList() // копия
fun add(x: Int) { items.add(x) }
}
fun main() {
val repo = Repository5()
val snapshot = repo.getItems()
repo.add(4)
println("$snapshot / ${repo.getItems()}") // [1, 2, 3] / [1, 2, 3, 4]
}
toList() создаёт новый ArrayList. Цена - O(n) и лишний объект; выгода - значение, на которое можно полагаться.
Варианты копирования:
fun main() {
val source = mutableListOf(1, 2, 3)
println(source.toList()::class.simpleName) // ArrayList
println(source.toMutableList()::class.simpleName) // ArrayList
println(source.toSet()::class.simpleName) // LinkedHashSet
println(ArrayList(source)::class.simpleName) // ArrayList
println(List(source.size) { source[it] }) // [1, 2, 3]
}
Когда копировать:
- коллекция уходит за границу класса или модуля и должна быть стабильной
- коллекция попадает в
Map/Setкак ключ или элемент - значение читается из другого потока
Когда не копировать:
- коллекция создана тут же и больше нигде не хранится
- вызывающий - код того же класса
- коллекция огромная, а изменения точно не будет (документируйте это)
Неизменяемость на уровне содержимого
Даже честная копия списка не защищает от изменения элементов:
class Item(var name: String)
fun main() {
val items = listOf(Item("a"), Item("b"))
val copy = items.toList()
items[0].name = "изменено"
println(copy[0].name) // изменено — копия поверхностная
}
toList() копирует только ссылки. Настоящая защита - неизменяемые элементы:
data class ItemRo(val name: String)
fun main() {
val items = listOf(ItemRo("a"))
// items[0].name = "x" // ошибка: val
val changed = items.map { it.copy(name = "изменено") }
println("${items[0].name} / ${changed[0].name}") // a / изменено
}
Это общее правило: неизменяемость полезна, когда она рекурсивна. List<T> с изменяемым T даёт лишь половину гарантии.
val и изменяемость
Два независимых понятия, которые постоянно путают:
fun main() {
val a = mutableListOf(1) // ссылку сменить нельзя, содержимое — можно
a.add(2)
println(a) // [1, 2]
// a = mutableListOf(3) // ошибка
var b = listOf(1) // ссылку сменить можно, содержимое — нельзя
b = listOf(1, 2)
println(b) // [1, 2]
// b.add(3) // ошибка
}
val |
var |
|
|---|---|---|
List |
ничего не изменить | можно заменить целиком |
MutableList |
можно менять содержимое | и то и другое |
Самый частый правильный выбор - val + List. Подробнее: Переменные
Функциональное обновление
Вместо изменения на месте - новое значение:
fun main() {
val original = listOf(1, 2, 3)
println(original + 4) // [1, 2, 3, 4]
println(original - 2) // [1, 3]
println(original.map { it * 10 }) // [10, 20, 30]
println(original.filter { it != 2 }) // [1, 3]
println(original.toMutableList().apply { this[0] = 99 }) // [99, 2, 3]
println(original) // [1, 2, 3] — не тронут
}
Для Map то же:
fun main() {
val config = mapOf("host" to "localhost", "port" to "8080")
println(config + ("port" to "9090")) // {host=localhost, port=9090}
println(config - "port") // {host=localhost}
println(config + mapOf("tls" to "on")) // {host=localhost, port=8080, tls=on}
}
Цена - копирование на каждую операцию. Для небольших коллекций и нечастых обновлений это правильный выбор по умолчанию: такой код проще отлаживать, потому что значение не меняется за спиной.
Изменяемое состояние внутри, read-only снаружи
Рабочий шаблон для класса с коллекцией:
class Playlist {
private val _tracks = mutableListOf<String>()
val tracks: List<String> get() = _tracks.toList() // копия при чтении
fun add(track: String) { _tracks.add(track) }
fun size() = _tracks.size
}
fun main() {
val p = Playlist()
p.add("песня 1")
val snapshot = p.tracks
p.add("песня 2")
println("$snapshot / ${p.tracks}") // [песня 1] / [песня 1, песня 2]
}
Если копия на каждое чтение слишком дорога, альтернатива - возвращать _tracks напрямую и документировать, что значение живое. Тогда тип должен быть осмысленным: List здесь обманывает читателя, и честнее отдавать Sequence или явный метод snapshot().
Неизменяемость и многопоточность
fun main() {
val shared = mutableListOf(1, 2, 3)
// Небезопасно: обход в одном потоке + add в другом = ConcurrentModificationException
// Безопасно: работать с копией
val safe = shared.toList()
println(safe.sum()) // 6
val sync = java.util.Collections.synchronizedList(mutableListOf(1, 2))
println(sync.size) // 2 — но обход всё равно нужно синхронизировать вручную
val concurrent = java.util.concurrent.CopyOnWriteArrayList(listOf(1, 2))
println(concurrent.size) // 2
}
Неизменяемое значение потокобезопасно по определению - именно поэтому в конкурентном коде предпочитают read-only копии, а не блокировки. Подробнее: Разделяемое состояние
Персистентные коллекции
В stdlib настоящих immutable-коллекций нет. Их даёт библиотека kotlinx.collections.immutable:
// Требует зависимости org.jetbrains.kotlinx:kotlinx-collections-immutable.
// val list: PersistentList<Int> = persistentListOf(1, 2, 3)
// val updated = list.add(4) // новый список, старый цел
// println("$list / $updated") // [1, 2, 3] / [1, 2, 3, 4]
//
// Тип ImmutableList<T> гарантирует, что коллекция не изменится вообще,
// а PersistentList<T> добавляет эффективное «обновление» без полного копирования.
Отличие от toList(): персистентная коллекция при добавлении переиспользует внутреннюю структуру, поэтому add стоит O(log n), а не O(n). Это имеет смысл, когда обновления частые, а коллекция большая.
[!info] Отличия от Java
Java Kotlin Collections.unmodifiableList(x)тип List<T>List.copyOf(x)(Java 10+)x.toList()List.of(1, 2)- immutablelistOf(1, 2)- read-onlyнарушение видно в рантайме видно при компиляции final Listval listGuava ImmutableListkotlinx.collections.immutable
unmodifiableListиListрешают одну задачу по-разному. Java-обёртка - объект-декоратор: тип остаётсяList, методaddесть, но бросает исключение в рантайме. Kotlin убирает метод из интерфейса, поэтому ошибка видна компилятору и обёртка не нужна:List<Integer> ro = Collections.unmodifiableList(src); ro.add(1); // компилируется, падает в рантаймеНо у Java-обёртки есть преимущество, которого нет у Kotlin: она действительно запрещает изменение через эту ссылку даже при касте. Kotlin-ский
Listкаст кMutableListможет пережить, если под нимArrayList.
List.ofв Java сильнее, чемlistOfв Kotlin.List.of(1, 2)создаёт по-настоящему неизменяемую коллекцию: любая попытка изменения, в том числе из другого места, бросит исключение.listOf(1, 2)даёт неизменяемую реализацию только случайно - это деталь, на которую нельзя полагаться, потому чтоlistOfна самом деле описывает интерфейс, а не гарантию объекта.Защитное копирование нужно в обоих языках.
private final Listв Java иprivate valв Kotlin одинаково не защищают содержимое. Разница в том, что в Kotlin ошибку легче не заметить: типListв сигнатуре выглядит как обещание неизменяемости, которого он не даёт.Сравнить: Инкапсуляция в Java
[!warning] Подводные камни Возврат
MutableListпод типомList- утечка:class Leaky2 { private val items = mutableListOf(1, 2) fun items(): List<Int> = items } fun main() { val l = Leaky2() val view = l.items() @Suppress("UNCHECKED_CAST") (view as MutableList<Int>).add(3) // клиент изменил приватное состояние println(l.items()) // [1, 2, 3] }Каст сработал, потому что под ссылкой настоящий
ArrayList. Защита -toList().
toList()копирует поверхностно - элементы остаются общими. Для вложенных структур нужна глубокая копия:fun main() { val nested = listOf(mutableListOf(1), mutableListOf(2)) val shallow = nested.toList() nested[0].add(99) println(shallow[0]) // [1, 99] — внутренний список общий val deep = nested.map { it.toList() } nested[0].add(100) println(deep[0]) // [1, 99] — а этот цел }
valне делает коллекцию неизменяемой - только ссылку.val items = mutableListOf<Int>()изменяется свободно.Из Java read-only коллекция изменяема:
fun provide(): List<Int> = mutableListOf(1, 2) fun main() { println(provide()) } // [1, 2]Java-код получит
java.util.Listи вызоветaddбез ошибок компиляции. Для API, доступного из Java, копируйте или используйтеCollections.unmodifiableList. Подробнее: Kotlin из JavaИзменяемый объект как ключ
Mapтеряется:data class MutableKey(var id: Int) fun main() { val key = MutableKey(1) val map = mutableMapOf(key to "значение") key.id = 2 println(map[key]) // null — хеш изменился println(map.size) // 1 — запись на месте, но недостижима }
+=наvar list: List<T>в цикле - это O(n²):fun main() { var slow = listOf<Int>() repeat(1000) { slow += it } // 1000 копирований списка println(slow.size) // 1000 val fast = buildList { repeat(1000) { add(it) } } println(fast.size) // 1000, одна коллекция }
ConcurrentModificationExceptionпри изменении во время обхода:fun main() { val list = mutableListOf(1, 2, 3) try { for (x in list) if (x == 2) list.remove(x) } catch (e: ConcurrentModificationException) { println("нельзя изменять во время обхода") } list.removeAll { it == 2 } // так правильно println(list) // [1, 3] }Обёртка
synchronizedListне делает обход потокобезопасным - атомарны только отдельные методы. Итерацию нужно оборачивать вsynchronizedвручную.
Далее: Операции