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

RustRustBeginner
Практиковаться сейчас

This tutorial is from open-source community. Access the source code

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

Добро пожаловать в Что такое владение?. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

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

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

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

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

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

Стек и кучу

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

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

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

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

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

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

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

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

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

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

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

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

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

let s = "hello";

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

{                      // s не действительна здесь, так как она еще не объявлена
    let s = "hello";   // s действительна начиная с этой точки

    // работаем с s
}                      // эта область видимости теперь завершена, и s больше не действительна

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

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

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

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

Тип String

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

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

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

let s = String::from("hello");

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

Такой тип строк может изменяться:

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

s.push_str(", world!"); // push_str() добавляет литерал к строке

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

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

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

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

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

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

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

Однако вторая часть отличается. В языках с сборщиком мусора (GC) сборщик следит за памятью и очищает память, которая больше не используется, и мы не должны об этом думать. Во многих языках без сборщика мусора наша задача — определить, когда память больше не используется, и вызвать код для явного освобождения ее, так же, как мы делали при запросе памяти. Правильное выполнение этой задачи всегда представляло собой сложную проблему программирования. Если мы забываем, мы будем тратить память. Если мы освобождаем память слишком рано, у нас будет недопустимая переменная. Если мы освобождаем память дважды, это также является ошибкой. Мы должны точно сопоставить один вызов allocate с одним вызовом free.

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

{
    let s = String::from("hello"); // s действительна начиная с этой точки

    // работаем с s
}                                  // эта область видимости теперь завершена, и s больше не
                                   // действительна

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

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

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

Переменные и данные, взаимодействующие с Move

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

let x = 5;
let y = x;

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

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

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

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.

Рисунок 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; это не сработает:

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

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

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

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!("{s1}, world!");
  |                ^^ value borrowed here after move

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

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

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

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

Переменные и данные, взаимодействующие с Clone

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

Вот пример использования метода clone:

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

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

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

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

Только стековые данные: Copy

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

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 к своему типу для реализации трейта, см. раздел "Производимые трейты".

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

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

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

    makes_copy(x);                  // x было бы передано в функцию,
                                    // но i32 реализует Copy, поэтому можно
                                    // по-прежнему использовать 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: Передача владения возвращаемым значениям

Владение переменной всегда遵循相同的模式:将值赋给另一个变量会移动它。当包含堆上数据的变量超出作用域时,除非数据的所有权已转移到另一个变量,否则该值将由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 есть функция для использования значения без передачи владения, называемая ссылками.

Резюме

Поздравляем! Вы завершили лабораторную работу "Что такое владение?". Вы можете выполнить больше лабораторных работ в LabEx, чтобы улучшить свои навыки.