4.5. Основы Kotlin. Хранение данных в памяти компьютера

Значения и ссылки

В Котлине существует два способа хранения переменных (параметров) в памяти JVM: хранение значений и хранение ссылок. В любом из этих способов для переменной выделяется ячейка памяти, размер которой зависит от типа переменной, но не превышает 8 байт.

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

При хранении ссылок в ячейку переменной помещается ссылка, при этом значение (содержимое) переменной хранится в специальном участке памяти JVM — куче (heap). Каждому используемому участку памяти кучи соответствует определённый номер, и как раз этот номер и используется в качестве ссылки. То есть, при хранении ссылок для чтения значения переменной необходимо выполнить не одно, а два действия:

  • прочитать номер участка в куче из ячейки переменной;
  • по этому номеру обратиться к куче и прочитать значение переменной.

Хранение ссылок используется для всех составных и нестандартных типов, в частности, для строк, массивов, списков. При изменении переменной в результате выполнения оператора вроде v = …​ изменяется ссылка. Например:

fun foo() {
    // [1, 2, 3] хранится в участке кучи с номером 1, a хранит номер 1
    val a = listOf(1, 2, 3)
    // [4, 5] хранится в участке кучи с номером 2, b хранит номер 2
    var b = listOf(4, 5)
    // Присваивание ссылок: b теперь хранит номер 1
    b = a
}

Обратите внимание, что после выполнения трёх приведённых операторов в участке кучи с номером 2 хранится список [4, 5], но ни одна переменная не хранит ссылку на этот участок. Подобный участок через некоторое время будет найден и уничтожен специальной программой JVM — сборщиком мусора, он же Garbage Collector.

Такие типы, как String или List, не предполагают возможность изменения содержимого переменной. Опять-таки при попытке выполнить оператор вида s = …​ изменится ссылка. Например:

fun foo() {
    // Alpha: участок с номером 1
    val a = "Alpha"
    // Beta: участок с номером 2
    var b = "Beta"
    // Тоже номер 2
    val c = b
    // Формируем Alpha + Beta = AlphaBeta: участок с номером 3
    b = a + b
}

При сложении a и b будет создана новая строка AlphaBeta и размещена в участке памяти с номером 3. После этого номер 3 будет записан в переменную b. Отметьте, что c по-прежнему хранит номер 2, а a — номер 1.

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

fun foo() {
    // Участок с номером 1
    val a = mutableListOf(1, 2, 3)
    // Тоже номер 1
    val b = a
    // Изменение содержимого участка с номером 1: теперь это [1, 2, 5]
    b[2] = 5
    println(a[2]) // 5 (!)
}

После выполнения оператора b[2] = 5 участок памяти с номером 1 будет хранить список [1, 2, 5]. Поскольку в переменной a хранится тот же номер 1, то вывод на консоль a[2] приведёт к выводу числа 5, хотя раньше этот элемент списка хранил значение 3.

Подобный принцип используют и функции, имеющие параметр с типом MutableList:

fun invertPositives(list: MutableList<Int>) {
    for (i in 0..list.size - 1) {
        val element = list[i]
        if (element > 0) {
            list[i] = -element
        }
    }
}

fun test() {
    // Участок номер 1
    val a = mutableListOf(1, -2, 3)
    invertPositives(a)
    println(a) // [-1, -2, -3]
}

При вызове invertPositives номер 1 будет переписан из аргумента a в параметр list. После этого функция invertPositives изменит содержимое списка, используя данный номер, и вызов println(a) выведет [-1, -2, -3] на консоль.

Таким образом, имея дело с типами, хранящимися по ссылке (чаще говорят проще — ссылочные типы), стоит различать действия со ссылками и действия со значениями. К примеру, присваивание name = …​ — это всегда действие со ссылкой. С другой стороны, вызов функции вроде list.isEmpty() или индексация вроде list[i]list[j] = i — это действия с содержимым, причём, некоторые из этих действий только читают содержимое переменной, а некоторые другие — изменяют его.

С учётом этого различия в Котлине определено две разных операции сравнения на равенство: уже известная нам ==и новая ===. Операция a == b — это сравнение содержимого на равенство, которое обычно выполняется с помощью вызова функции a.equals(b) — про неё мы поговорим в разделе 9. Операция a === b — это сравнение ссылок на равенство, для которого не имеет значения, одинаковое содержимое у переменных или нет, важно только, чтобы оно находилось в участке кучи с одинаковым номером. Например:

fun foo() {
    val a = listOf(1, 2)
    val b = listOf(1, 2)
    println(a == b) // true
    println(a === b) // false
}

Здесь a и b имеют одно и то же содержимое, но находятся в участках кучи с разными номерами. Операция !=обратна операции == (сравнение содержимого на неравенство), а операция !==, соответственно — обратна операции === (сравнение ссылок на неравенство).

Важно также, что сравнение содержимого на равенство не реализовано для массивов Array, и поэтому для них операции == и === эквивалентны. Это одна из причин, по которой следует использовать списки вместо массивов, где это возможно. Пример:

fun foo() {
    val a = arrayOf(1, 2)
    val b = arrayOf(1, 2)
    println(a == b) // false (!)
    println(a === b) // false
}

Добавить комментарий