Память

Предпосылки: Функции (вызов функций, локальные переменные, область видимости), Коллекции (массивы, хеши).

Коллекции | Объектно-ориентированное программирование

В прошлой заметке про коллекции массивы и хеши выглядели как обычные значения. Но с ними быстро появляется странность:

orders = [1200, 900, 1400]
copy = orders
copy[3] = 1800
 
puts orders    # [1200, 900, 1400, 1800]

Почему изменился orders, если меняли copy? Чтобы это понять, нужна модель памяти. Без неё человеку трудно отличить две независимые структуры данных от двух имён для одного и того же значения в памяти.

В этой заметке Ruby нужен как компактная запись примеров. Та же модель проявляется и в других языках: C показывает адреса явно, Ruby прячет их за обычным присваиванием, Rust добавляет поверх этого правила владения.

Имена, ссылки и данные

Для массивов, хешей и других составных данных в этой заметке удобно держать одну модель: переменная хранит не весь набор данных внутри себя, а способ добраться до него.

orders ----\
           >  [1200, 900, 1400]
copy   ----/

После copy = orders обе переменные указывают на один и тот же массив. Поэтому copy[3] = 1800 меняет те же данные, которые видны и через orders.

Проблема здесь не в процессоре. Проблема в том, что без такой модели памяти легко ошибиться при чтении кода: кажется, что переменных две, а данных на самом деле один набор.

Стек: где живут локальные имена вызова

Когда вызывается функция, программе нужно где-то держать параметры и локальные имена именно этого вызова. Для этого используют стек.

def add(a, b)
  result = a + b
  result
end
 
def main
  x = 10
  y = 20
  sum = add(x, y)
  puts sum
end
main():
  Стек: [ main: x=10, y=20 ]
 
main() вызывает add(10, 20):
  Стек: [ main: x=10, y=20 | add: a=10, b=20, result=30 ]
 
add() возвращает 30:
  Стек: [ main: x=10, y=20, sum=30 ]

Пока работает add, у программы есть локальные имена a, b и result. После возврата из add они исчезают. Стек хорош именно для таких краткоживущих вещей: параметров, временных значений и порядка возврата из вызовов.

Куча: где живут данные дольше вызова

Одних только локальных имён недостаточно. Массивы, хеши и другие составные данные часто должны переживать отдельный вызов функции, поэтому сами данные обычно хранятся отдельно от стека, в куче (heap — область без строгого порядка, в отличие от стопки стека).

имя в стеке ----\
                 > ссылка -> данные в куче
другое имя -----/

Поэтому массив, на который указывают orders и copy, не исчезает вместе с каким-то одним именем. Исчезнуть должны все ссылки на него, только тогда память под эти данные можно считать больше не нужной.

Две разные операции: переназначить имя и изменить общие данные

Самая частая путаница появляется при вызове функций. Важно различать две операции:

  • переназначить локальное имя на другие данные;
  • изменить уже существующие общие данные.
def replace_list(list)
  list = [0]
end
 
def append_to_list(list)
  list << 4    # << добавляет элемент в конец массива
end
 
numbers = [1, 2, 3]
 
replace_list(numbers)
puts numbers          # [1, 2, 3]
 
append_to_list(numbers)
puts numbers          # [1, 2, 3, 4]

В replace_list имя list просто начинает указывать на новый массив [0]. У вызывающего кода имя numbers всё ещё указывает на старые данные.

В append_to_list меняются сами данные, на которые указывают и list, и numbers. Поэтому изменение видно снаружи.

То же различие видно и на маленьких неизменяемых значениях:

def double(n)
  n = n * 2
end
 
x = 5
double(x)
puts x    # 5

Здесь тоже происходит переназначение локального имени n, а не изменение значения у вызывающего кода.

Одна модель, разные языковые формы

Ruby. Ссылки обычно скрыты. Программист пишет copy = orders, а модель совместного доступа остаётся за обычным присваиванием.

C. Адреса видны прямо в коде. Указатель — это значение, которое хранит адрес участка памяти:

int salary = 1500;
int *p = &salary;
printf("%d\n", *p);
*p = 2000;
printf("%d\n", salary);

&salary означает «взять адрес salary». *p означает «прочитать или изменить значение по этому адресу».

Rust. Сам адрес обычно не показывают напрямую, но язык жёстко отвечает на другой вопрос: кто сейчас отвечает за это значение.

fn main() {
    let s = String::from("hello");
    let t = s;
    println!("{}", t);
}

После let s = String::from("hello") строкой владеет s. После let t = s владение переходит к t, поэтому s больше нельзя использовать. Когда t выйдет из области видимости, строка будет освобождена.

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

Кто освобождает память

После того как данные перестали быть нужны, остаётся ещё один вопрос: кто обязан их освободить.

C: ручное управление. Программист сам выделяет память и сам же обязан её освободить.

int *arr = malloc(5 * sizeof(int));
arr[0] = 10;
free(arr);

Если забыть free, память остаётся занятой. Если вызвать free, а потом продолжить пользоваться старым адресом, программа начинает читать или писать уже не свои данные.

Ruby: сборщик мусора. Ruby освобождает данные автоматически: когда на них больше никто не ссылается, их позже найдёт и удалит сборщик мусора (garbage collector). Программисту не нужно помнить про free, но рантайм периодически тратит время на поиск неиспользуемых данных.

Rust: освобождение через владение. Когда владелец значения выходит из области видимости, память освобождается автоматически. Программист не пишет free, но и не может произвольно дублировать доступ к изменяемым данным: язык следит за этим заранее.

Общая модель одна и та же: у программы есть имена, данные и время жизни данных. Языки отличаются тем, насколько явно показывают адреса и кто несёт ответственность за освобождение памяти и изменение общих структур.

Ссылки и мутация объясняют, почему copy[3] = 1800 из начала заметки затронул orders. Но они же создают следующий вопрос: если одни и те же данные разделяют несколько частей программы, кто следит за тем, чтобы их не сломали? Это вопрос организации кода — ответ даёт объектно-ориентированное программирование.

Sources

  • Kernighan, B. & Ritchie, D., 1988, The C Programming Language. Prentice Hall.
  • Klabnik, S. & Nichols, C., 2023, The Rust Programming Language. No Starch Press.
  • Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.

Коллекции | Объектно-ориентированное программирование