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 идёт третьим путём: памятью управляет система владения — набор правил, которые проверяет компилятор. Если хоть одно правило нарушено, программа не скомпилируется. При этом никакая часть механизма владения не замедляет программу во время работы.

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

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

Стек и куча

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

И стек, и куча — это области памяти, которые доступны вашему коду во время работы, но устроены они по-разному. Стек хранит значения в том порядке, в котором их получает, а убирает — в обратном. Это называется «последним пришёл — первым ушёл» (last in, first out, LIFO). Представьте стопку тарелок: новые тарелки кладут сверху, и снимают тоже сверху. Добавлять или вытаскивать тарелки из середины или снизу было бы куда менее удобно! Добавление данных называется помещением на стек (pushing), а удаление — снятием со стека (popping). Все данные на стеке должны иметь известный фиксированный размер. Данные, размер которых неизвестен при компиляции или может меняться, хранятся в куче.

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

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

Обращение к данным в куче тоже обычно медленнее, чем к данным на стеке, — ведь сначала нужно пройти по указателю. Современные процессоры работают быстрее, когда им реже приходится прыгать по памяти. Продолжим аналогию: официант принимает заказы со многих столов. Эффективнее всего принять все заказы за одним столом и только потом переходить к следующему. Если брать заказ за столом A, потом за столом B, потом снова за A и снова за B, дело пойдёт куда медленнее. Так и процессор обычно лучше справляется с работой, если данные лежат рядом друг с другом (как на стеке), а не разбросаны далеко (как может быть в куче).

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

Следить за тем, какие части кода используют какие данные в куче, сводить к минимуму дублирование данных в куче и вовремя убирать неиспользуемые данные, чтобы не закончилась память, — вот те задачи, которые решает владение. Разобравшись во владении, вы будете нечасто думать о стеке и куче. Но если помнить, что главная задача владения — управлять данными в куче, легче понять, почему оно устроено именно так.

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

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

  • У каждого значения в Rust есть владелец (owner).
  • В каждый момент времени владелец может быть только один.
  • Когда владелец выходит из области видимости, значение уничтожается (dropped).

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

С базовым синтаксисом Rust мы разобрались, поэтому дальше в примерах не будем писать fn main() {. Если вы повторяете примеры, не забывайте сами помещать их внутрь функции main. Так примеры получатся короче, и мы сможем сосредоточиться на сути, а не на шаблонном коде.

Первым примером владения будет область видимости переменных. Область видимости (scope) — это участок программы, в пределах которого элемент действителен. Возьмём такую переменную:

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

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

fn main() {
    {                      // здесь s ещё нет: она пока не объявлена
        let s = "hello";   // с этого места s можно использовать

        // что-то делаем с s
    }                      // область видимости закончилась, s больше нельзя использовать
}
Листинг 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() дописывает литерал в конец String

    println!("{s}"); // выведет `hello, world!`
}

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

Память и её выделение

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

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

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

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

А вот со второй частью всё иначе. В языках со сборщиком мусора (garbage collector, GC) сборщик сам отслеживает и освобождает память, которая больше не используется, и думать об этом не нужно. В большинстве языков без сборщика мусора мы сами должны определить, когда память больше не нужна, и явно вызвать код, который её освободит, — так же, как явно её запрашивали. Делать это правильно исторически было непростой задачей. Забудем освободить — память утечёт. Освободим слишком рано — получим недействительную переменную. Освободим дважды — тоже баг. Каждому выделению (allocate) должно соответствовать ровно одно освобождение (free).

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

fn main() {
    {
        let s = String::from("hello"); // с этого места s можно использовать

        // что-то делаем с s
    }                                  // область видимости закончилась, s больше
                                       // нельзя использовать
}

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

Примечание: в C++ такой подход — освобождать ресурсы в конце времени жизни объекта — иногда называют «получение ресурса есть инициализация» (Resource Acquisition Is Initialization, RAII). Если вы пользовались RAII, функция drop в Rust покажется вам знакомой.

Этот подход очень сильно влияет на то, как пишется код на 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 состоит из трёх частей (они слева): указателя на память, в которой лежит содержимое строки, длины и ёмкости. Эта группа данных хранится на стеке. Справа — память в куче, где лежит само содержимое.

Две таблицы: в первой — представление s1 на стеке: длина (5), ёмкость
(5) и указатель на первое значение во второй таблице. Во второй таблице —
данные строки в куче, побайтово.

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

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

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

Три таблицы: s1 и s2 — представления этих строк на стеке, и обе
указывают на одни и те же данные строки в куче.

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

А вот так, как на рисунке 4-3, память не выглядит. Так было бы, если бы Rust копировал ещё и данные в куче. Тогда при больших данных в куче операция s2 = s1 могла бы обходиться очень дорого.

Четыре таблицы: две — данные s1 и s2 на стеке, и каждая указывает на
собственную копию данных строки в куче.

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

Выше мы говорили, что, когда переменная выходит из области видимости, Rust автоматически вызывает drop и освобождает память в куче, принадлежащую этой переменной. Но на рисунке 4-2 оба указателя ведут в одно и то же место. В этом и проблема: когда s2 и s1 выйдут из области видимости, обе попытаются освободить одну и ту же память. Это называется ошибкой двойного освобождения (double free) — одна из тех ошибок безопасности памяти, о которых мы говорили раньше. Двойное освобождение может повредить память, а это, в свою очередь, может привести к уязвимостям.

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

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

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

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

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
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!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

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

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

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

Рисунок 4-4: Представление в памяти после того, как s1 стала недействительной

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

Из этого вытекает ещё одно проектное решение: Rust никогда автоматически не создаёт «глубоких» копий ваших данных. Поэтому любое автоматическое копирование можно считать дешёвым с точки зрения производительности.

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

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

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

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

Сначала мы объявляем переменную s и привязываем к ней String со значением "hello". Затем сразу создаём новую String со значением "ahoy" и присваиваем её s. В этот момент на исходное значение в куче уже ничто не ссылается. Рисунок 4-5 показывает, что теперь на стеке и в куче:

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

Рисунок 4-5: Представление в памяти после того, как исходное значение полностью заменено

Таким образом, исходная строка сразу выходит из области видимости. Rust вызывает для неё drop, и её память тут же освобождается. Когда в конце мы выводим значение, получаем "ahoy, world!".

Переменные и данные: клонирование

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

Вот как работает метод clone:

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

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

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

Увидев вызов clone, вы понимаете, что выполняется какой-то произвольный код и он может быть дорогим. Это наглядный сигнал: здесь происходит что-то особенное.

Данные только на стеке: копирование

Есть ещё одна тонкость, о которой мы пока не говорили. Вот код с целыми числами (часть его была в листинге 4-2) — он работает и корректен:

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

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

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

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

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

Rust не позволит пометить тип как Copy, если сам тип или любая его часть реализует трейт Drop. Если при выходе значения из области видимости для типа должно происходить что-то особенное, а вы добавите ему пометку Copy, будет ошибка компиляции. О том, как добавить пометку Copy к своему типу и тем самым реализовать этот трейт, читайте в разделе «Выводимые трейты» приложения C.

Так какие же типы реализуют 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 входит в область видимости

    takes_ownership(s);             // значение s перемещается в функцию...
                                    // ...поэтому дальше s использовать нельзя

    let x = 5;                      // x входит в область видимости

    makes_copy(x);                  // i32 реализует трейт Copy,
                                    // поэтому x НЕ перемещается в функцию,
                                    // и x можно использовать и дальше.

} // Здесь из области видимости выходит x, затем s. Но значение s уже
  // перемещено, поэтому ничего особенного не происходит.

fn takes_ownership(some_string: String) { // some_string входит в область видимости
    println!("{some_string}");
} // Здесь some_string выходит из области видимости, вызывается `drop`,
  // и память, где лежала строка, освобождается.

fn makes_copy(some_integer: i32) { // some_integer входит в область видимости
    println!("{some_integer}");
} // Здесь some_integer выходит из области видимости. Ничего особенного не происходит.
Листинг 4-3: Функции с пометками о владении и области видимости

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

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

Возврат значения тоже может передавать владение. В листинге 4-4 пример функции, которая возвращает значение, — с такими же пометками, как в листинге 4-3.

Файл: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership перемещает своё
                                       // возвращаемое значение в s1

    let s2 = String::from("hello");    // s2 входит в область видимости

    let s3 = takes_and_gives_back(s2); // s2 перемещается в
                                       // takes_and_gives_back, а та
                                       // перемещает своё возвращаемое значение в s3
} // Здесь s3 выходит из области видимости и уничтожается. s2 уже перемещена,
  // с ней ничего не происходит. s1 выходит из области видимости и уничтожается.

fn gives_ownership() -> String {       // gives_ownership переместит своё
                                       // возвращаемое значение в ту функцию,
                                       // которая её вызвала

    let some_string = String::from("yours"); // some_string входит в область видимости

    some_string                        // some_string возвращается и
                                       // перемещается в вызывающую
                                       // функцию
}

// Эта функция принимает String и возвращает String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string входит в
    // область видимости

    a_string  // a_string возвращается и перемещается в вызывающую функцию
}
Листинг 4-4: Передача владения через возвращаемые значения

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

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

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

Файл: src/main.rs
fn main() {
    let s1 = String::from("hello");

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

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

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() возвращает длину String

    (s, length)
}
Листинг 4-5: Возвращаем владение параметрами обратно

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