Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Что такое владение?

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

Безопасность — это отсутствие неопределённого поведения

Начнём с примера. Эта программа безопасна для выполнения:

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    let x = true;
    read(x);
}

Мы можем сделать эту программу небезопасной, переместив вызов read перед определением x:

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    read(x); // oh no! x isn't defined!
    let x = true;
}

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

Вторая программа небезопасна, потому что read(x) ожидает, что x имеет значение типа bool, но x ещё не имеет значения.

Когда такая программа выполняется интерпретатором, чтение x до её определения вызывает исключение, например NameError в Python или ReferenceError в JavaScript. Но исключения имеют стоимость. Каждый раз, когда интерпретируемая программа читает переменную, интерпретатор должен проверить, определена ли эта переменная.

Цель Rust — компилировать программы в эффективные бинарные файлы, требующие как можно меньше проверок во время выполнения. Поэтому Rust не проверяет во время выполнения, определена ли переменная перед её использованием. Вместо этого Rust проверяет на этапе компиляции. Если вы попытаетесь скомпилировать небезопасную программу, вы получите ошибку:

error[E0425]: cannot find value `x` in this scope
 --> src/main.rs:8:10
  |
8 |     read(x); // oh no! x isn't defined!
  |          ^ not found in this scope

Вы, вероятно, интуитивно понимаете, что хорошо, когда Rust обеспечивает, чтобы переменные были определены перед использованием. Но почему? Чтобы обосновать это правило, нужно спросить: что произойдёт, если Rust разрешит скомпилировать отвергнутую программу?

Сначала рассмотрим, как компилируется и выполняется безопасная программа. На компьютере с процессором архитектуры x86 Rust генерирует следующий ассемблерный код для функции main в безопасной программе (см. полный ассемблерный код здесь):

main:
    ; ...
    mov     edi, 1
    call    read
    ; ...

Примечание: если вы не знакомы с ассемблерным кодом, это нормально! Этот раздел содержит несколько примеров ассемблера, чтобы показать, как Rust работает на самом деле. Для понимания Rust обычно не нужно знать ассемблер.

Этот ассемблерный код:

  • Перемещает число 1, представляющее true, в регистр (вид переменной в ассемблере) под названием edi.
  • Вызывает функцию read, которая ожидает, что её первый аргумент y будет в регистре edi.

Если бы небезопасную функцию разрешили скомпилировать, её ассемблерный код мог бы выглядеть так:

main:
    ; ...
    call    read
    mov     edi, 1    ; mov после call
    ; ...

Эта программа небезопасна, потому что read ожидает, что edi будет логическим значением, то есть числом 0 или 1. Но edi может быть чем угодно: 2, 100, 0x1337BEEF. Когда read попытается использовать свой аргумент y для любой цели, это немедленно вызовет НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ!

Rust не определяет, что происходит, если попытаться выполнить if y { .. }, когда y не равно true или false. Это поведение, или то, что происходит после выполнения инструкции, является неопределённым. Может произойти что-то, например:

  • Код выполняется без сбоев, и никто не замечает проблемы.
  • Код немедленно падает из-за ошибки сегментации или другой ошибки операционной системы.
  • Код выполняется без сбоев, пока злоумышленник не создаст правильный ввод, чтобы удалить вашу рабочую базу данных, перезаписать резервные копии и украсть ваши обеденные деньги.

Фундаментальная цель Rust — гарантировать, что ваши программы никогда не имеют неопределённого поведения. Вот что значит “безопасность”. Неопределённое поведение особенно опасно для низкоуровневых программ с прямым доступом к памяти. Около 70% сообщённых уязвимостей безопасности в низкоуровневых системах вызвано повреждением памяти, что является одной из форм неопределённого поведения.

Вторичная цель Rust — предотвращать неопределённое поведение на этапе компиляции, а не выполнения. У этой цели две мотивации:

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

Rust не может предотвратить все ошибки. Если приложение предоставляет публичный и неаутентифицированный endpoint /delete-production-database, то злоумышленнику не нужна подозрительная инструкция if для удаления базы данных. Но защиты Rust всё ещё могут сделать программы безопаснее по сравнению с использованием языка с меньшим количеством защит, как показала, например, команда Android от Google.

Владение как дисциплина для безопасности памяти

Поскольку безопасность — это отсутствие неопределённого поведения, а владение связано с безопасностью, нам нужно понять владение с точки зрения неопределённых поведений, которые оно предотвращает. Справочник Rust содержит большой список “Поведения, считающегося неопределённым”. Пока мы сосредоточимся на одной категории: операции с памятью.

Память — это пространство, где хранятся данные во время выполнения программы. Есть много способов думать о памяти:

  • Если вы не знакомы с системным программированием, вы можете думать о памяти на высоком уровне, например “память — это ОЗУ в моём компьютере” или “память — это то, что заканчивается, если я загружаю слишком много данных”.
  • Если вы знакомы с системным программированием, вы можете думать о памяти на низком уровне, например “память — это массив байтов” или “память — это указатели, которые я получаю от malloc”.

Обе эти модели памяти действительны, но они не являются полезными способами думать о том, как работает Rust. Высокоуровневая модель слишком абстрактна, чтобы объяснить, как работает Rust. Вам нужно будет понять концепцию указателя, например. Низкоуровневая модель слишком конкретна, чтобы объяснить, как работает Rust. Rust не позволяет вам интерпретировать память как массив байтов, например.

Rust предоставляет особый способ думать о памяти. Владение — это дисциплина для безопасного использования памяти в рамках этого способа мышления. Остальная часть этой главы объяснит модель памяти Rust.

Переменные живут в стеке

Вот программа, похожая на ту, что вы видели в разделе 3.3, которая определяет число n и вызывает функцию plus_one для n. Под программой — новый вид диаграммы. Эта диаграмма визуализирует содержимое памяти во время выполнения программы в трёх отмеченных точках.

Переменные живут в кадрах. Кадр — это сопоставление переменных со значениями в пределах одной области видимости, такой как функция. Например:

  • Кадр для main в точке L1 содержит n = 5.
  • Кадр для plus_one в L2 содержит x = 5.
  • Кадр для main в точке L3 содержит n = 5; y = 6.

Кадры организованы в стек текущих вызываемых функций. Например, в L2 кадр для main находится над кадром для вызываемой функции plus_one. После того, как функция возвращается, Rust освобождает кадр функции. (Освобождение также называется освобождением или удалением, и мы используем эти термины взаимозаменяемо.) Эта последовательность кадров называется стеком, потому что самый недавний добавленный кадр всегда является следующим освобождаемым кадром.

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

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

Значение a копируется в b, и a остаётся неизменным, даже после изменения b.

Box’ы живут в куче

Однако копирование данных может занимать много памяти. Например, вот немного другая программа. Эта программа копирует массив с 1 миллионом элементов:

Заметьте, что копирование a в b приводит к тому, что кадр main содержит 2 миллиона элементов.

Чтобы передать доступ к данным без их копирования, Rust использует указатели. Указатель — это значение, описывающее расположение в памяти. Значение, на которое указывает указатель, называется объектом указателя. Один из распространённых способов создать указатель — выделить память в куче. Куча — это отдельная область памяти, где данные могут существовать неограниченно долго. Данные в куче не привязаны к конкретному кадру стека. Rust предоставляет конструкцию под названием Box для размещения данных в куче. Например, мы можем обернуть массив из миллиона элементов в Box::new так:

Заметьте, что теперь существует только один массив за раз. В L1 значение a — это указатель (представлен точкой со стрелкой) на массив внутри кучи. Инструкция let b = a копирует указатель из a в b, но данные, на которые указывает указатель, не копируются. Обратите внимание, что a теперь серое, потому что оно было перемещено — мы увидим, что это значит, через мгновение.

Rust не разрешает ручное управление памятью

Управление памятью — это процесс выделения памяти и освобождения памяти. Другими словами, это процесс поиска неиспользуемой памяти и последующего возврата этой памяти, когда она больше не используется. Кадры стека автоматически управляются Rust. Когда функция вызывается, Rust выделяет кадр стека для вызываемой функции. Когда вызов заканчивается, Rust освобождает кадр стека.

Как мы видели выше, данные в куче выделяются при вызове Box::new(..). Но когда данные в куче освобождаются? Представьте, что у Rust есть функция free(), которая освобождает выделение в куче. Представьте, что Rust позволяет программисту вызывать free когда угодно. Такой “ручной” способ управления памятью легко приводит к ошибкам. Например, мы могли бы прочитать указатель на освобождённую память:

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

Здесь мы выделяем массив в куче. Затем вызываем free(b), которая освобождает память кучи для b. Поэтому значение b — это указатель на недействительную память, которую мы представляем иконкой “⦻”. Неопределённое поведение ещё не произошло! Программа всё ещё безопасна в L2. Не обязательно проблема иметь недействительный указатель.

Неопределённое поведение происходит, когда мы пытаемся использовать указатель, читая b[0]. Это попытка доступа к недействительной памяти, что может привести к падению программы. Или хуже, это может не привести к падению и вернуть произвольные данные. Поэтому эта программа небезопасна.

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

Владелец Box’а управляет освобождением

Вместо этого Rust автоматически освобождает память кучи для box’а. Вот почти правильное описание политики Rust для освобождения box’ов:

Принцип освобождения Box’а (почти правильно): Если переменная связана с box’ом, когда Rust освобождает кадр переменной, тогда Rust освобождает память кучи box’а.

Например, давайте проследим программу, которая выделяет и освобождает box:

В L1, перед вызовом make_and_drop, состояние памяти — это просто кадр стека для main. Затем в L2, во время вызова make_and_drop, a_box указывает на 5 в куче. Как только make_and_drop завершается, Rust освобождает его кадр стека. make_and_drop содержит переменную a_box, поэтому Rust также освобождает данные в куче в a_box. Поэтому куча пуста в L3.

Память кучи для box’а была успешно управлена. Но что, если мы злоупотребим этой системой? Возвращаясь к нашему предыдущему примеру, что происходит, когда мы связываем две переменные с box’ом?

fn main() {
let a = Box::new([0; 1_000_000]);
let b = a;
}

Массивированный массив теперь связан как с a, так и с b. Согласно нашему “почти правильному” принципу, Rust попытается освободить память кучи box’а дважды от имени обеих переменных. Это тоже неопределённое поведение!

Чтобы избежать этой ситуации, мы наконец приходим к владению. Когда a связывается с Box::new([0; 1_000_000]), мы говорим, что a владеет box’ом. Инструкция let b = a перемещает владение box’ом из a в b. Учитывая эти концепции, политика Rust для освобождения box’ов более точно описывается так:

Принцип освобождения Box’а (полностью правильно): Если переменная владеет box’ом, когда Rust освобождает кадр переменной, тогда Rust освобождает память кучи box’ом.

В примере выше b владеет box’ом с массивом. Поэтому когда область видимости заканчивается, Rust освобождает box только один раз от имени b, а не a.

Коллекции используют Box’ы

Box’ы используются структурами данных Rust1 такими как Vec, String и HashMap для хранения переменного числа элементов. Например, вот программа, которая создаёт, перемещает и изменяет строку:

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

  1. В L1 строка “Ferris” была выделена в куче. Её владеет first.
  2. В L2 была вызвана функция add_suffix(first). Это перемещает владение строкой из first в name. Данные строки не копируются, но указатель на данные копируется.
  3. В L3 функция name.push_str(" Jr.") изменяет размер выделения кучи для строки. Это делает три вещи. Во-первых, создаётся новое большее выделение. Во-вторых, в новое выделение записывается “Ferris Jr.”. В-третьих, освобождается исходная память кучи. first теперь указывает на освобождённую память.
  4. В L4 кадр для add_suffix исчез. Эта функция вернула name, передавая владение строкой full.

Переменные нельзя использовать после перемещения

Программа со строкой помогает проиллюстрировать ключевой принцип безопасности для владения. Представьте, что first используется в main после вызова add_suffix. Мы можем смоделировать такую программу и увидеть неопределённое поведение, которое возникает:

first указывает на освобождённую память после вызова add_suffix. Поэтому чтение first в println! было бы нарушением безопасности памяти (неопределённое поведение). Помните: проблема не в том, что first указывает на освобождённую память. Проблема в том, что мы попытались использовать first после того, как она стала недействительной.

К счастью, Rust откажется компилировать эту программу, выдавая следующую ошибку:

error[E0382]: borrow of moved value: `first`
 --> test.rs:4:35
  |
2 |     let first = String::from("Ferris");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let full = add_suffix(first);
  |                           ----- value moved here
4 |     println!("{full}, originally {first}"); // first is now used here
  |                                   ^^^^^ value borrowed here after move

Давайте пройдёмся по шагам этой ошибки. Rust говорит, что first перемещена при вызове add_suffix(first) в строке 3. Ошибка уточняет, что first перемещена, потому что имеет тип String, который не реализует Copy. Мы обсудим Copy скоро — вкратце, вы не получили бы эту ошибку, если бы использовали i32 вместо String. Наконец, ошибка говорит, что мы используем first после перемещения (она “заимствована”, что мы обсуждаем в следующем разделе).

Итак, если вы перемещаете переменную, Rust не даст вам использовать эту переменную позже. В более общем смысле, компилятор будет применять этот принцип:

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

Теперь вы должны начать видеть связь между владением, перемещениями и безопасностью. Перемещение владения данными в куче избегает неопределённого поведения из-за чтения освобождённой памяти.

Клонирование избегает перемещений

Один из способов избежать перемещения данных — клонировать их, используя метод .clone(). Например, мы можем исправить проблему безопасности в предыдущей программе с клоном:

Заметьте, что в L1 first_clone не “поверхностно” скопировал указатель в first, а вместо этого “глубоко” скопировал данные строки в новое выделение кучи. Поэтому в L2, пока first_clone была перемещена и сделана недействительной add_suffix, исходная переменная first остаётся неизменной. Безопасно продолжать использовать first.

Резюме

Владение в первую очередь является дисциплиной управления кучей:2

  • Все данные в куче должны принадлежать ровно одной переменной.
  • Rust освобождает данные в куче, когда их владелец выходит из области видимости.
  • Владение может быть передано через перемещения, которые происходят при присваиваниях и вызовах функций.
  • Данные в куче могут быть доступны только через их текущего владельца, а не через предыдущего владельца.

Мы подчёркивали не только как работают гарантии Rust, но и почему они избегают неопределённого поведения. Когда вы получаете сообщение об ошибке от компилятора Rust, легко расстроиться, если вы не понимаете, почему Rust жалуется. Эти концептуальные основы должны помочь вам интерпретировать сообщения об ошибках Rust. Они также должны помочь вам проектировать более идиоматичные API.


  1. Эти структуры данных не используют буквальный тип Box. Например, String реализован с Vec, а Vec реализован с RawVec, а не с Box. Но типы вроде RawVec всё ещё похожи на box: они владеют памятью в куче.

  2. В другом смысле владение — это дисциплина управления указателями. Но мы ещё не описали, как создавать указатели куда-либо, кроме кучи. Мы дойдём до этого в следующем разделе.