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

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

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

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

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

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

И стек, и куча — это части памяти, доступные вашему коду для использования во время выполнения, но они структурированы по-разному. Стек хранит значения в порядке их получения и удаляет значения в обратном порядке. Что называется «последний пришёл, первый вышел». Подумайте о стопке тарелок: когда вы добавляете тарелки, вы кладёте их сверху стопки, а когда вам нужна тарелка, вы берёте одну сверху. Добавление или удаление тарелок посередине или снизу не сработает этим же образом! Добавление данных называется помещением в стек, а удаление данных называется извлечением из стека. Все данные, хранящиеся в стеке, должны иметь известный фиксированный размер. Вместо этого данные с неизвестным размером во время компиляции или размером, который может измениться, должны храниться в куче.

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

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

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

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

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

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

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

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

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

Теперь, когда мы прошли базовый синтаксис Rust, мы не будем включать весь код 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, его реализация запрашивает необходимую память. Это довольно универсально в языках программирования.

Однако вторая часть отличается. В языках с сборщиком мусора (GC) он отслеживает и очищает память, которая больше не используется, и нам не нужно об этом думать. В большинстве языков без сборщика мусора мы обязаны определить, когда память больше не используется, и вызвать код для явного её освобождения, точно так же, как мы делали это для её запроса. Выполнение этого процесса правильно исторически было сложной проблемой программирования. Если мы забудем это сделать, мы потеряем память. Если мы сделаем это слишком рано, у нас будет недопустимая переменная. Если мы сделаем это дважды, это тоже ошибка. Нам нужно соединить ровно один 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, и является одной из ошибок безопасности памяти, упоминаемых ранее. Освобождение памяти дважды может привести к повреждению памяти, что потенциально может привести к уязвимостям безопасности.

Чтобы обеспечить безопасность памяти, после строки let s2 = s1 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
  |
  = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

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

Если вы слышали термины поверхностное копирование и глубокое копирование при работе с другими языками, концепция копирования указателя, длины и ёмкости без копирования данных, вероятно, звучит как создание поверхностной копии. Но поскольку Rust также аннулирует первую переменную, вместо того, чтобы называть её поверхностной копией, это называется перемещением. В этом примере мы бы сказали, что 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 к вашему типу для реализации типажа, смотрите Раздел «Производные типажи». в Приложении С.

Так какие типы имеют типаж 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 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("yours"); // some_string comes into scope

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

// This function takes a String and returns 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 , если только данные не были перемещены во владение другой переменной.

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

Rust позволяет нам возвращать несколько значений с помощью кортежа, как показано в Листинге 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 есть возможность использовать значение без передачи права владения, называемая ссылками.