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