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

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

Все программы во время выполнения используют память компьютера и используют разные подходы для управления своей памятью. В одних языках программирования для этой цели используют систему сборки мусора (garbage collection, GC) постоянно следящую за памятью программы, которая больше не используется программой. В других языках программист должен сам явно запрашивать и освобождать память. Rust же использует третий подход: память управляется с помощью системы владения с набором правил, которые компилятор проверяет только во время компиляции программы. Ни одно из правил владения не замедляет выполнение программы.

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

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

Стек и куча (heap)

Во многих языках программирования вам обычно не приходится часто думать о памяти в стеке или памяти в куче. Для системного языка программирование место хранения переменной (на стеке или в куче) имеет большее влияния на то, как язык ведёт себя и почему необходимо принимать определённые решения. Позже в данной главе будет описана основная часть правил владения относительно стека и кучи, здесь же в целях подготовки к основной статье представлено краткое объяснение.

Стек и куча являются частями памяти компьютера, которая доступна вашему коду во время выполнения, но они структурированы по разному. Стек сохраняет значения в порядке получения данных и удаляет их в обратном порядке. Такого рода концепция известна как последний зашёл, первый вышел (last in, first out). Думайте о стеке как о стопке тарелок: при добавлении тарелок вы размещаете их сверху стопки, а когда тарелка нужна берете её сверху. Добавление и удаление тарелок из середины или снизу запрещено и не работает! Добавление данных называется помещением в стек (pushing onto), а удаление называется извлечением из стека (popping off).

Все данные сохраняемые в стеке должны быть известны и иметь фиксированный размер. Данные с неизвестным размером во время компиляции или размером, который может изменится в ходе выполнения программы должны сохраняться в куче. Куча является менее организованной: при размещении данных в куче запрашивается определённое количество памяти. Операционная система находит пустой участок кучи, являющийся достаточно большим, помечает его как используемый и возвращает указатель, который является адресом данного участка. Данный процесс называется выделением в куче и иногда сокращённо называется просто выделение (allocating). Размещение значений в стеке не считается выделением. По причине того, что указатель имеет известный, фиксированный размер, его можно сохранить в стеке, но когда вам нужны сами данные необходимо проследовать по указателю.

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

Размещение в стек происходит быстрее, чем выделение в куче, потому что операционная система никогда не делает поиска места для хранения новых данных. Местом размещения всегда является верхушка стека. Выделение памяти в куче требует больше работы, потому что операционная система должна сначала найти достаточно большой участок памяти для хранения данных и затем выполнить резервирование, чтобы подготовится к следующему выделению.

Доступ данных в куче является более медленным, чем в стеке, потому что необходимо сначала проследовать по указателю для получения данных. Современные процессоры работают быстрее, если они меньше "прыгают" по памяти. Продолжая аналогию, представьте официанта в ресторане, который принимает заказы с нескольких столов по мере их поступления. Для официанта наиболее эффективным было бы получить сразу все заказы со стола и уже затем перейти к следующему столу, не возвращаясь к гостю с первого стола который вдруг вспомнил, что ему ещё надо заказать десерт. Принимать один заказ со стола A, затем со стола B, а затем снова со стола A и снова со стола B будет гораздо более медленным процессом. Кроме того, процессор может лучше выполнить работу, если оперирует данными которые расположены в памяти близко к другим данным (подобно тому как в стеке), а не далеко (как это может быть в куче). Выделение большого количества памяти в куче также занимает время.

При вызове функции значения передаваемые в неё (потенциально включая и указатели на данные в куче) и локальные переменные функции размещаются в стеке. Когда функция завершается, эти значения извлекаются из стека.

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

Правила владения

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

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

Область видимости переменной

Мы уже видели как область видимости работает на примере Rust программы в Главе 2. После прохождения базового синтаксиса, мы не будем включать в примеры код функции fn main() {, так что если вы будете следовать примерам, вам нужно будет поместить следующие примеры внутрь функции main самостоятельно. В результате наш пример будут немного короче и мы сможем фокусироваться на деталях, а не на шаблонном коде.

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


#![allow(unused)]
fn main() {
let s = "hello";
}

Переменная s ссылается на строковый литерал и значение данной переменной жёстко задано в коде программы. Переменная считается действительной с момента её объявления до конца текущей области видимости. В листинге 4-1 есть комментарии с аннотациями где переменная s является действительной.

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

Листинг 4-1: переменная и область видимости в которой она действительна

Другими словами, здесь есть два важных момента:

  • когда переменная s появляется в области видимости, она считается действительной,
  • она остаётся действительной до момента выхода за границы этой области.

На этом этапе объяснения, взаимосвязь между областями действия и допустимостью переменных аналогична той, что существует в других языках программирования. Теперь мы будем опираться на это понимание, введя тип String.

Тип данных String

Для иллюстрации правил владения нужен тип данных более сложный чем, те которые были в разделе "Типы данных" Главы 3. Типы описанные ранее, являются типами сохраняемыми в стеке и извлекаемые из него, когда их область видимости заканчивается. Но мы хотим рассмотреть данные сохранённые в куче и разобраться в том как Rust определяет, в какой момент нужно очищать эти данные.

Воспользуемся типом String в качестве примера и сконцентрируемся на частях String относящихся ко владению. Данные аспекты применимы и для более сложных типов данных, не важно предоставлены ли они из стандартной библиотеки или созданы вами. Мы ещё обсудим более детально тип String в Главе 8.

Мы видели строковые литералы в которых значение строки жёстко закодировано в программе. Строковые литералы удобны, но не подходят для любой ситуации в которой мы бы хотели использовать текст. Одна из причин - неизменяемость данных литерала. Другая причина в том, что не любое строковое значение может быть известным при написании кода: например, что если хочется получить ввод пользователя и сохранить его? В данной ситуации Rust имеет второй строковый тип String. Память для значений этого типа выделяется в куче, так что можно сохранять количество текста неизвестное во время компиляции. Можно создать String из строкового литерала используя функцию from, вот так:


#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Два двоеточия (::) является оператором, который позволяет воспользоваться в текущем пространстве имён функцией from для типа String вместо использования некоторого имени функции string_from. Мы обсудим синтаксис детальнее в разделе "Синтаксис методов" Главы 5 и когда поговорим про пространства имён с модулями "Путь для обращения к элементу в дереве модулей" Главы 7.

Такие строки могут быть изменены:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `hello, world!`
}

В чем здесь разница? Почему String можно менять, а литерал нельзя? Разница в том, как эти два типа работают с памятью.

Память и способы её выделения

В случае строкового литерала мы знаем его содержимое во время компиляции, так что текст жёстко закодирован в итоговом исполняемом файле. Это причина того, что строковые литералы являются быстрыми и эффективными. Но эти свойства приходят только из-за неизменяемости строковых литералов. К сожалению, нельзя поместить неопределённый кусок памяти в выполняемый файл для каждого кусочка текста, размер которого неизвестен при компиляции и который может менять свой размер во время выполнения программы.

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

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

Первая часть сделана нами, когда вызывается String::from: эта реализация запрашивает необходимую память. Это является довольно универсальным подходом в языках программирования.

Тем не менее, вторая часть отличается. В языках со сборщиком мусора, сборщик отслеживает и очищает память, которая больше не используется и нам не нужно заботиться об этом процессе. Без сборщика мусора, мы отвечаем за определение момента, когда память больше не используется и вызываем код явно возвращающий память, также как когда бы запрашивали её. Корректное выполнение этих действий было исторически сложной проблемой программирования. Если забываем освободить, то теряем память. Если освободим слишком рано, то получим недействительную переменную. Если освободим дважды, то это тоже будет ошибкой. Нужно связать ровно одно выделение (allocate) с ровно одним освобождением (free).

Rust выбирает другой путь: память автоматически возвращается как только переменная владеющая памятью выходит из области видимости. Вот версия примера с областью видимости из листинга 4-1 использующего тип String вместо строкового литерала:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Здесь есть естественная точка в которой можно вернуть память занимаемую String обратно в операционную систему: когда переменная s уходит из области видимости. Когда переменная выходит из области видимости, Rust вызывает специальную функцию для нас. Данная функция называется drop и это место где автор String может поместить код для возвращения памяти. Rust вызывает drop автоматически на закрывающей фигурной скобке.

Заметьте: Данный шаблон освобождения ресурсов в конце цикла жизни переменной в C++ иногда называется Resource Acquisition Is Initialization (RAII). Функция drop в Rust будет вам знакома, если вы уже использовали шаблон RAII.

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

Способы взаимодействия переменных и данных: перемещение

Множество переменных могут взаимодействовать разными способами с одинаковыми данными в Rust. Давайте рассмотрим пример использующий целое число в листинге 4-2.

fn main() {
    let x = 5;
    let y = x;
}

Листинг 4-2: Назначение значения целого числа из переменной x в y

Возможно мы догадаемся, что делает данный код: "привязать значение 5 к переменной x; затем сделать копию значения x и привязать его к переменной y". Теперь у нас две переменные, x и y, обе равны 5. Действительно тут происходит именно это потому что целые числа являются простыми значениями с известным, фиксированным размером и эти два значения 5 размещаются в стеке.

Теперь рассмотрим версию с типом String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

Посмотрим на рисунок 4-1 и разберём, что происходит со String под капотом. Тип String состоит из трёх частей показанных слева: указатель на память занятую содержимым строки, длина и ёмкость. Данная группа данных сохраняется в стеке. Справа память в куче, которая хранит содержимое строки.

Строка в памяти

Рисунок 4-1: Представление в памяти строки String содержащей значение "hello" привязанное к s1

Длина - это сколько байт памяти использует содержимое String в данный момент. Ёмкость - это общее количество байт памяти, которые String получила от операционной системы. Разница между длиной и ёмкостью имеет значение, но не в данном контексте, сейчас можно игнорировать ёмкость.

Когда мы назначили s1 переменной s2, то данные типа String были скопированы, что означает мы скопировали указатель, длину и ёмкость, которые находятся в стеке. Мы не копируем данные в куче на которые ссылается указатель. Другими словами данные представленные в памяти выглядят как на картинке 4-2.

s1 и s2 указывают на одинаковое значение

Картинка 4-2: Представление в памяти переменной s2, которая является копией указателя, длины и ёмкости переменной s1

Представление НЕ выглядит как на картинке 4-3, при котором память могла бы выглядеть так, как если бы Rust ещё скопировал и сами данные в куче. Если Rust сделал бы это, то операция s2 = s1 могла бы быть очень дорогостоящей в смысле производительности: представьте если бы копируемые данные в куче были очень большими.

Строка в памяти

Картинка 4-3: Другая возможность того, как можно было бы сделать при s2 = s1, если бы Rust также копировал бы данные в куче

Ранее мы сказали, что когда переменная выходит из области видимости, Rust автоматически вызывает функцию drop и очищает память кучи для данной переменной. Но картинка 4-2 показывает, что теперь оба указателя указывают на одно и тоже место. Это проблема: когда переменная s2 и переменная s1 выходят из области видимости они обе будут пытаться освободить одну и туже память в куче. Это известно как "ошибка двойного освобождения", double free, и является одной из ошибок безопасности памяти, упоминаемых ранее. Освобождение памяти дважды может привести к повреждению памяти, что потенциально может привести к уязвимостям безопасности.

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

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

Вы получите ошибку ниже, потому что Rust не даст использовать недействительную ссылку s1:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership`

To learn more, run the command again with --verbose.

Если вы слышали термины "поверхностное копирование" shallow copy и "глубокое копирование" deep copy в других языках, то концепция копирования указателя, длины и ёмкости без копирования самих данных в куче, возможно выглядит как создание "поверхностной копии". Но так как Rust делает первую переменную недействительной вместо создания поверхностной копии, то такое действие известно как "перемещение" move. В данном примере, мы бы сказали, что s1 была перемещена в переменную s2. То что происходит на самом деле показано на картинке 4-4.

s1 и s2 указывают на одинаковое значение

Картинка 4-4: представление памяти после того как s1 была сделана не действительной

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

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

Способы взаимодействия переменных и данных: клонирование

Если мы хотим сделать глубокое копирование данных в куче для типа String, а не только данных в стеке, то мы можем использовать общий метод называемый clone. Мы обсудим его синтаксис в Главе 5, но так как методы являются общими особенностями во многих языках программирования, то вы возможно уже видели их ранее.

Вот пример метода clone в действии:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

Код работает отлично и явно выполняет поведение, показанное на картинке 4-3, где данные в куче действительно скопированы.

Когда вы видите вызов clone, то вы знаете о выполнении некоторого кода, который может быть дорогим. В то же время использование clone является визуальным индикатором того, что тут происходит что-то нестандартное (глубокое копирование вместо обыденного перемещения).

Стековые данные: Копирование

Это ещё одна особенность о которой мы ещё не говорили. Этот код, часть которого была показа ранее в листинге 4-2, использует целые числа. Этот код работает и не имеет ошибок:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

Но данный код, кажется противоречит тому, что мы только что изучили: тут не нужно вызывать clone, но x является все ещё действительной переменной и не перемещена в y.

Причина такого поведения в том, что типы вроде целых чисел, размер которых известен во время компиляции, сохраняются полностью в стеке, поэтому такое копирование значений является быстрым. Это означает, что нет причин по которым мы бы хотели переменную x оставлять действительной после создания переменной y. Другими словами, здесь нет разницы между глубоким и поверхностным копированием, поэтому вызов clone не будет делать ничего отличного от обычного поверхностного копирования, и мы можем оставить это как есть.

В Rust есть специальная аннотация называемая типаж Copy, который можно применить на типы вроде целых чисел, размещённых в стеке (мы поговорим про типажи в Главе 10). Если тип имеет типаж Copy, то старая переменная этого типа все ещё может быть использована после её перемещения в новую. Rust не позволит аннотировать тип с типажом Copy, если тип или любая его часть имеет реализацию типажа Drop. Если типу нужно делать что-то особенное, когда значение уходит из области видимости и мы добавляем аннотацию Copy к данному типу, мы получим ошибку компиляции. Для изучения как добавлять аннотацию Copy к вашему типу, смотрите раздел "Выводимые типажи" в приложении C.

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

  • все целочисленные типы, такие как u32,
  • логический тип данных bool, возможные значения которого true и false,
  • все числа с плавающей запятой такие как f64,
  • символьный тип char,
  • кортежи, но только если они содержат типы, которые также реализуют Copy. Например, (i32, i32) будет с Copy, но кортеж (i32, String) уже нет.

Владение и функции

Семантика передачи значений в функции является похожей на назначение значения переменной. Передача переменной в функцию в качестве входного параметра будет перемещать или копировать её значение, точно также как это делает операция присвоения. Пример в листинге 4-3 с некоторыми аннотациями, показывает на каких этапах переменные появляются и исчезают из области видимости.

Файл: src/main.rs

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Листинг 4-3: Функции с комментариями про владение и область видимости

Если попытаться использовать s после вызова takes_ownership, Rust выдаст ошибку времени компиляции. Такие статические проверки защищают от ошибок. Попробуйте добавить код в main, который использует переменную s и x, чтобы увидеть где их можно использовать и где правила владения предотвращают их использование.

Возвращение значений и область видимости

Возвращение значений также может перемещать владение. Листинг 4-4 является примером с похожими комментариями, что даны в листинге 4-3.

Файл: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was
  // moved, so nothing happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("hello"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// takes_and_gives_back will take a String and return one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

Листинг 4-4: Перемещение владения и возврат значений

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

Приём во владение и затем возвращение владения из каждой функции немного утомительно. А что если мы позволим функции использовать значение, но не забирать его во владение? Весьма раздражает, если все, что мы передаём, также должно быть возвращено обратно. И это ещё надо будет делать в добавок к обслуживанию любых данных, которые мы также можем захотеть вернуть из тела функции.

Есть возможность возвращать несколько значений используя кортеж, как в листинге 4-5.

Файл: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Листинг 4-5: возврат владения параметров

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