Files
obsidian/Kotlin/Коллекции/Изменяемость.md
T

16 KiB
Raw Blame History

title, description, tags
title description tags
Изменяемость Read-only против immutable, защитное копирование и персистентные коллекции
kotlin
collections

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) - immutable listOf(1, 2) - read-only
нарушение видно в рантайме видно при компиляции
final List val list
Guava ImmutableList kotlinx.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 вручную.

Далее: Операции