Значения и ссылки
В Котлине существует два способа хранения переменных (параметров) в памяти 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
}