Ownership (владение, права собственности) это самая уникальная особенность Rust, и она вводит глубокие последствия для остального функционала Rust. Это позволяет Rust обеспечить безопасность памяти без необходимости привлечения сборщика мусора, поэтому важно понимать принцип работы ownership. В этой главе (перевод главы 4 [1]) мы поговорим про ownership, а также связанные с ним фичи: заимствование (borrowing), слайсы (slices), и как Rust располагает данные в памяти.
[Что такое Ownership]
Ownership это набор правил о том, как Rust управляет памятью. Все программы должны как-то управлять тем, как они используют память во время своей работы. В некоторых языках есть система сбора мусора, которая регулярно проверяет во время работы программы, какая память больше не используется. В других языках программист должен явно выделять и освобождать память. Rust использует третий способ: память управляется через так называемую систему владения (ownership) с набором правил, которые проверяет компилятор. Если нарушено любое из правил, то программа не скомпилируется. Ни одна из особенностей системы владения не замедлит работу вашей программы.
Поскольку ownership это новая концепция для новых программистов, требуется некоторое время, чтобы к ней привыкнуть. Хорошей новостью является то, что чем более опытным вы станете с Rust и правилами системы владения, тем легче будет применять Rust для естественной разработки кода, который будет безопасным и эффективным.
При хорошем понимании ownership вы получите прочную основу для применения функционала Rust, который делает его уникальным языком. В этой главе мы обучимся правилам ownership путем проработки некоторых примеров, фокусирующихся на самой общей структуре данных: строки (тип String).
Многие высокоуровневые языки программирования не требуют от вас очень часто вспоминать от том, что такое стек (stack), и что такое куча (heap). Однако в системных языках программирования наподобие Rust поведение программы во многом зависит от того, где хранится объект в памяти, и это требует от программиста принимать соответствующие решения при выделении памяти для объектов. Части ownership будут описаны далее по отношению к стеку и куче, поэтому необходимы некоторые подготовительные объяснения.
И стек, и куча это области памяти, доступные для вашего кода во время его работы (runtime), однако они структурированы и работают по-разному. Стек сохраняет значения в в памяти таким образом, чтобы извлекать эти значения в обратном порядке, т. е. по принципу FILO (First Input Last Output, первым вошел последним вышел). Это можно представить себе в виде стопки пластинок: когда вы накладываете пластинки на стопку, для извлечения нижней пластинки нужно сначала извлечь те, которые находятся над ней. Добавление или удаление пластин напрямую посередине стопки запрещено! Добавление данных называют "проталкиванием" в стек (push), а извлечение называют "выталкиванием" из стека (pop; на большинстве языков ассемблера существуют для этого специальные команды PUSH и POP). Все данные, которые сохранены в стеке, должны иметь известный, фиксированный размерe. Данные, размер которых неизвестен во время компиляции, или когда размер может меняться во время выполнения программы, должны сохраняться в куче вместо стека.
Куча (heap) менее упорядочена: когда вы помещаете данные в кучу, вы делаете запрос на выделение указанного объема памяти в куче. Система выделения памяти находит в куче свободную область, которая имеет достаточный размер, помечает эту область как используемую и возвращает на неё указатель, значение которого равно адресу начала этой выделенной области памяти. Этот процесс называется выделением памяти из кучи, allocating (проталкивание значений в стек не считается выделением). Поскольку указатель на кучу имеет известный, фиксированный размер, вы можете сохранить значение указателя в стеке, но когда вы хотите получить реальные данные из выделенной памяти в куче, необходимо следовать по адресу в указателе.
Проталкивание в стек работает быстрее, чем выделение памяти в куче, потому что при сохранении в стек не нужно искать для этого специальное место, как нужно делать при выделении памяти в куче. При проталкивании в стек место хранения известно сразу - это вершина стека. Для сравнения, выделение памяти в куче требует больше работы, потому что алокатору сначала надо найти достаточную область в куче, и необходимо вести журнал учета выделения и освобождения памяти, чтобы можно было корректно обслуживать последующие выделения.
Обращение к данным в куче иногда также происходит медленнее, потому что необходимо загружать указатель на эти данные. Процессоры работают быстрее, если они меньше делают прыжков по памяти, и работают с данными, которые расположены близко друг к другу (одна из причин - уменьшение в этом случае количества промахов кэша).
Когда ваш код вызывает функцию, передаваемые в функцию значения (которые потенциально могут включать указатель на данные в куче) и локальные переменные функции проталкиваются в стек. Когда функция завершает работу, эти значения выталкиваются из стека.
Отслеживание, какие части кода используют какие данные в куче, минимизация количества дублированных данных в куче и очистка неиспользуемых данных в куче, чтобы у вас не закончилось место - все это проблемы, которые решает ownership. Как только вы поймете суть этой технологии, вам не понадобится часто думать о стеке и куче, но знание того, что основная цель ownership - управление данными кучи, может помочь объяснить, почему оно работает так, как работает.
Ownership Rules. Сначала давайте рассмотрим правила владения (ownership rules). Имейте их в виду при работе с приведенными здесь примерами, илюстрирущие правила владения:
• Каждое значение в Rust имеет владельца. • В любой момент времени может быть только один владелец значения. • Когда владелец выходит из области видимости значения, это значение выбрасывается.
Область видимости переменной (Variable Scope). В качестве первого примера владения мы рассмотрим область видимости некоторых переменных.
Примечание: теперь, когда мы изучили базовый синтаксис Rust в главах 1 и 3 [2, 3], мы не будем включать весь код fn main() { в примерах. Поэтому когда вы будете рассматривать примеры, последующие далее, помещайте необходимый код в функцию main. В результате наши примеры будут более краткими, что позволит нам сосредоточиться на интересующих деталях.
Область видимости в программе для какой-то переменной это место в коде, в котором эта переменная достоверна и доступна. Возьмем следующую переменную:
let s ="hello";
Эта переменная относится к строковому литералу, где значение строки жестко закодировано в тексте нашей программы. Переменная достоверна, начиная от точки её объявления и до конца текущей области. Листинг 4-1 показывает программу с комментариями, показывающими, где переменная является достоверной.
{ // s здесь недостоверна, потому что еще не декларированаlet s ="hello"; // s начиная с этого места становится достоверной
// Здесь можно выполнять с переменной s какие-то действия.
} // эта область закончилась, и за ней s больше// не будет достоверной
Листинг 4-1. Переменная и область её действия (в которой она остается достоверной).
Другими словами, здесь есть 2 важных момента:
• Когда s находится в области видимости, она достоверна. • Она остается достоверной, пока не выйдет из области видимости.
На этом этапе взаимосвязь между областью видимости переменной и её достоверностью остается аналогичной другим языкам программирования. Теперь мы попробуем расширить наше понимание происходящего в контексте типа String.
[Тип String]
Для иллюстрирования правил владения нам нужен тип данных, который более сложный, чем типы, что мы рассматривали в секции "Типы данных" главы 3 [3]. У ранее рассмотренных типов размер был известен заранее, и он был фиксированный, так что эти данные можно легко сохранять в стеке и отбрасывать, когда их область действия заканчивается. Такие данные могут быть быстро и тривиально скопированы в новый, независимый экземпляр, если другая часть кода нуждается в таком же значении для другой части кода с другой областью видимости. Но мы хотим рассмотреть данные, которые сохраняются в куче, чтобы разобраться, как Rust определяет, когда нужно очистить эти данные, и для этого тип String послужит хорошим примером.
Мы сконцентрируемся на тех частях String, которые относятся к ownership. Эти аспекты также применимы к другим сложным типам данных, независимо от того, были ли эти данные созданы вами, либо они предоставлены стандартной библиотекой. Более подробно тип String рассматривается в главе 8.
Мы уже видели литералы строк, когда значение строки жестко закодировано в программе. Строковые литералы удобны, однако они не подходят для каждой ситуации, когда нам надо использовать текст. Одна из проблем строковых литералов - они не мутируемые (т. е. их значение нельзя поменять runtime). Другая проблема состоит в том, что не всякая строка заранее известна во время написания кода: например, как быть, если мы хотим принять ввод пользователя и сохранить его? Для таких ситуаций в Rust есть еще один строковый тип: String. Этот тип данных выделяется в куче, так что можно сохранять текст, когда его размер неизвестен в момент компиляции кода. Вы можете создать экземпляр переменной типа String из строкового литерала, примерно так:
let s = String::from("hello");
Здесь оператор двойного двоеточия :: позволяет нам вызвать функцию from из пространства имен String вместо того, чтобы использовать какое-то специальное имя, наподобие string_from. Этот синтаксис более подробно будет обсуждаться в секции "Синтаксис метода" главы 5, и когда мы поговорим про пространство имен с модулями в "Paths for Referring to an Item in the Module Tree" главы 7.
Этот вид строки может мутировать (изменяться runtime):
letmut s = String::from("hello");
s.push_str(", world!"); // push_str() прикрепляет литерал к String
println!("{s}"); // Напечатает 'hello, world!'
В чем же здесь отличие? Почему String может менять значение, а литералы не могут? Различие состоит в том, как эти два типа взаимодействуют с памятью.
[Память и её выделение]
В случае строкового литерала мы знаем его содержимое в момент компиляции, так что его текст жестко закодирован в конечном исполняемом коде. Поэтому строковые литералы обрабатываются быстро и эффективно. Однако эти свойства получаются только благодаря тому, что литералы не мутируемые. К сожалению, мы не можем поместить большой кусок памяти (blob) в бинарный код для каждого куска текста, когда его размер неизвестен в момент компиляции, и когда размер текста может поменяться во время работы программы (runtime).
С типом String для поддержки мутируемости и возможности разрастания размера сохраняемого текста нам нужно выделять память из кучи, когда выделяемый размер неизвестен во время компиляции. Это означает:
• Память должна запрашиваться у распределителя памяти runtime. • Нам нужен способ возвратить эту память обратно распределителю, когда мы закончили работу с нашим объектом String.
Первая часть этих требований выполняется, когда мы вызываем функцию String::from, её реализация запрашивает столько памяти, сколько нужно. Это универсальный способ в языках программирования.
Однако вторая часть имеет отличия. В языках, где реализован сборщик мусора (garbage collector, GC), GC отслеживает и очищает части памяти, которые больше не используются, и нам не нужно заботиться об этом. В большинстве языков, где нет GC, эта забота ложится на плечи программиста - ему необходимо самому явно выделять и освобождать память для объектов. Корректная обработка выделения и освобождения памяти кучи всегда составляла проблему в программировании. Если мы забудем освободить память, то произойдет утечка памяти. Если же мы освободим память слишком рано, то получим недостоверную переменную. Если мы дважды освободим одну и ту же выделенную область, то это тоже серьезный баг. Нам необходимо строго соблюдать пары выделение/освобождение памяти кучи.
У 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. Сейчас это может показаться простым, однако поведение кода может быть неожиданным в более сложных ситуациях, когда нам нужно иметь несколько переменных для данных, выделенных в куче. Давайте сейчас рассмотрим некоторые подобные случаи.
Переменные и данные, взаимодействующие с перемещением. Несколько переменных в 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;
Внешне выглядит все так же, как в листинге 4-2, так что можно подумать, что происходит то же самое: вторая строка делает копию значения в s1 и привязывает его к s2. Но это не совсем то, что происходит на самом деле.
Посмотрите на рис. 4-1, чтобы увидеть, что происходит со String внутри. String состоит из 3 частей, показанных слева: указатель на память, где хранится строка (ptr), длина этой строки (length), и занимаемый объем (capacity). Эта группа данных сохраняется в стеке. Справа на рисунке показана память кучи, которая хранит содержимое текста строки.
Рис. 4-1. Представление в памяти объекта String, хранящего значение "hello", привязанное к s1.
Значение length это сколько памяти в байтах использует содержимое String в настоящий момент. Значение capacity это общий объем памяти в байтах, которое объект String получил от распределителя памяти. Разница между length и capacity имеет значение, но не в этом контексте, поэтому сейчас можно игнорировать capacity.
Когда мы присваиваем s2 = s1, данные String копируются, т. е. копируется указатель (ptr), длина (length) и объем (capacity), которые находятся в стеке. Мы при этом не копируем данные в куче, на который указывают значения ptr. Другими словами, представление данных в памяти будет, как показано на рис. 4-2.
Рис. 4-2. Представление данных в памяти, когда переменная s1 копируется в переменную s2: копируются только указатель, длина и емкость s1.
Представление рис. 4-2 не похоже на представление рис. 4-3, а именно как выглядела бы память, если бы Rust копировал также и память кучи. Если бы Rust сделал это, то операция s2 = s1 была бы очень затратна в контексте производительности, особенно когда размер данных кучи был бы большим.
Рис. 4-3. Другой возможный вариант операции s2 = s1, если Rust копирует также данные и кучи.
Мы уже говорили ранее, что когда переменная выходит за свою область видимости, Rust автоматически вызывает функцию drop и очищает память кучи для этой переменной. Однако рис. 4-2 показывает, что оба указателя на данные ссылаются на одну и ту же область. Это создает проблему: когда s2 и s1 выходят за свою область действия, они обе попытаются освободить одну и ту же память. Это известная проблема двойного освобождения одной и той же область памяти (double free error), и это один из багов безопасности памяти, которые мы обсуждали выше. Попытка двойного освобождения памяти приведет к её повреждению, и потенциально может привести к уязвимости системы безопасности.
Для обеспечения безопасности памяти после строки let s2 = s1; Rust рассматривает s1 как более недостоверную. Таким образом, теперь для Rust не надо освобождать s1, когда у неё заканчивается область видимости. Давайте проверим, что получится, когда вы попытаетесь использовать s1 после создания s2; это не сработает:
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: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` which comes from the expansion
of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
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 (глубокая копия) при работе с другими языками, то концепция копирования указателя, длины и емкости без копирования данных возможно будет выглядеть как shallow copy. Однако из-за того, что Rust также делает недостоверной первую переменную вместо выполнения неглубокой копии, эта операция известна как перемещение (move). В этом примере вы говорим, что s1 была перемещена в s2. Так что фактически произойдет то, что показано на рис. 4-4.
Рис. 4-4. Представление памяти после того, как s1 была сделана недостоверной.
Это решает проблему! Если достоверна только строка s2, то когда она выходит из области видимости, то она остается одна при освобождении памяти, и проблема двойного освобождения решена.
Кроме того, существует выбор дизайна, подразумеваемый поведением компилятора: Rust никогда не будет автоматически делать deep-копии ваших данных. Поэтому любое автоматическое копирование можно считать недорогим с точки зрения runtime производительности.
Переменные и данные, взаимодействующие с клонированием. Если мы сделать deep-копию данных кучи объекта String, не просто данных стека, то можно использовать общий метод clone. Мы рассмотрим синтаксис этого метода в главе 5, однако поскольку методы это общая особенность многих языков программирования, то возможно вы уже сталкивались с этим раньше.
Пример работы метода clone:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
Это сработает нормально и явно сгенерирует поведение, показанное на рис. 4-3, где копировались данные кучи.
Когда вы видите вызов clone, то должны знать, что выполняется также некий дополнительный код, и это может быть затратным в контексте производительности. Это визуальный индикатор того, что происходит нечто особенное.
Stack-Only Data: Copy. Есть еще кое-что, о чем мы не говорили. Код с использованием целых чисел из листинга 4-2 работает, и он допустим:
let x =5;
let y = x;
println!("x = {x}, y = {y}");
Этот код выглядит противоречием тому, что мы только что изучили: clone не вызывается, однако переменная x остается достоверной и не перемещается в y.
Причина в том, что типы наподобие целых чисел имеют известный размер в момент компиляции, и полностью сохраняются в стеке, так что копии реальных значений осуществляются быстро. Это значит, что нет причин делать переменную x недостоверной, когда мы создаем переменную y. Другими словами, здесь нет различий между deep и shallow копированием, так что вызов clone не сделает ничего, чтобы отличалось от обычного shallow-копирования, так что здесь это можно опустить.
В Rust есть специальная аннотация Copy трейт, которую мы можем разместить в типах, хранящихся в стеке, как и целые числа (более подробно про трейты мы поговорим в главе 10). Если в типе реализован трейт Copy, то переменные, его использующие, не перемещаются, и вместо этого тривиально копируются, тем самым они остаются достоверными после того, как были присвоены другой переменной.
Rust не позволит нам аннотировать тип с Copy, если сам тип или любая его часть реализует трейт Drop. Если типу нужно что-то особенное, что должно произойти, когда его значение выходит за область видимости, и мы добавим аннотацию Copy для этого типа, то получим ошибку компиляции. Сведения о добавлении аннотации Copy к вашему типу для реализации трейта см. в раздел "Derivable Traits" в Appendix C.
Итак, какие типы реализуют трейт Copy? Вы можете свериться с документацией по конкретному типу, однако как общее правило, любая группа скалярных значений может реализовать Copy, и ничто, требующее выделение памяти или какой-то формы ресурса не может реализовать Copy. Вот некоторые типы, которые могут реализовать Copy:
• Все целочисленные типы, такие как u32. • Двоичный тип bool, у которого значения могут быть только true и false. • Все типы плавающей точки, такие как f64. • Символьный тип char. • Кортежи (tuple), если они содержат только типы, которые также реализуют Copy. Например (i32, i32) реализуют Copy, но (i32, String) не реализуют.
[Ownership и функции]
Механизм передачи значения в функцию подобен присваиванию значения переменной. Передача переменной в функцию произведет перемещение (move) или копирование (copy) переменной, точно так же, как это делает присваивание let. Листинг 4-3 дает пример с некоторыми пояснениями, где переменная входит в область видимости, и где выходит из области видимости.
fn main() {
let s = String::from("hello"); // s входит в область действия
takes_ownership(s); // значение s перемещается в функцию...// ... и в этом месте s больше недостоверна
let x =5; // x входит в область действия makes_copy(x); // x переместится в функцию,// однако у i32 есть Copy, так что все еще// можно использовать x после вызова функции } // Здесь x выходит из области видимости. Поскольку значение s было перемещено,// ничего специального не происходит.
fn takes_ownership(some_string: String) { // some_string входит в область действия
println!("{some_string}");
} // Здесь some_string выходит из области видимости, и будет вызван drop.// Память, выделенная под текст some_string, освобождается.
fn makes_copy(some_integer:i32) { // some_integer входит в область действия
println!("{some_integer}");
} // Здесь some_integer выходит из области видимости. Поскольку она сохранялась// в стеке, то ничего специального не происходит.
Листинг 4-3. Функции с ownership, где помечены области действия переменных.
Если попытаться использовать s после вызова takes_ownership, то Rust выдаст ошибку компиляции. Эти статические проверки защищают нас от неправильного использования сложных переменных. Попробуйте добавить код, который использует s и x, чтобы увидеть, где вы можете использовать их, где правила ownership не дадут вам это сделать.
[Возвращаемые значения и область видимости]
Возврат значений также может привести к передаче владения (transfer ownership). Листинг 4-4 показывает пример функции, которая возвращает некоторое значение, с пояснениями, подобными тем, что были в листинге 4-3.
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 2 входит в область действия
some_string // some_string возвращается, и перемещается// в вызвавшую функцию
}
// Эта функция принимает String и её же возвращает fn takes_and_gives_back(a_string: String) -> String { // a_string входит в область// действия
a_string // a_string возвращается, и перемещается в вызвавшую функцию
}
Листинг 4-4. Демонстрация передачи владения (transfer ownership) возвращаемых значений.
Владение переменной каждый раз следует одному и тому же шаблону: присваивание значения другой переменной осуществляет его перемещение. Когда переменная, которая включает в себе данные кучи, выходит из своей области видимости, её значение будет очищено вызовом drop, за исключением ситуации, когда владение её данными перемещается в другую переменную.
Пока это работает, принятие во владение и затем возврат владения с каждой функцией становится несколько утомительным. Что если мы хотим позволить функции использовать значение, но не принимать не себя владением им? Несколько раздражает, что все, что мы передаем нужно передать обратно, если мы хотим использовать это снова, в дополнение к любым данным, полученным из тела функции, которые мы возможно захотим также вернуть.
Rust позволяет нам возвратить несколько значений помощью кортежа (tuple), как показано в листинге 4-5.
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("Длина '{s2}' равна {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() возвратит длину String
(s, length)
}
Листинг 4-5. Возврат ownership параметров.
Однако это приводит к слишком большим церемониям, и приходится делать много работы для концепции, которая должна быть общей. К счастью для нас, у Rust есть функция для использования значения без передачи ownership, называемая ссылками (references).
[Ссылки и заимствования]
Проблема кода с кортежем (tuple) листинга 4-5 в том, что мы должны возвратить String в вызывающую функцию таким образом, чтобы все еще можно было использовать String после вызова calculate_length, потому что String была перенесена в calculate_length. Вместо этого мы можем предоставить ссылку (reference) на значение String. Ссылка похожа на указатель (pointer) в том, что это адрес, по которому мы можем обратиться к данным, которые сохранены по этому адресу; эти данные принадлежать какой-то другой переменной. В отличие от указателя, ссылка гарантированно указывает на достоверное значение определенного типа в течение всего времени жизни этой ссылки.
Ниже показано, как можно определить и использовать функцию calculate_length, которая имеет ссылку на объект в качестве параметра, вместо того, чтобы получать значение и ownership на это значение:
Во-первых, обратите внимание, что убран весь код кортежа в декларации переменной, и функция calculate_length больше ничего не возвращает. Во-вторых, мы передаем &s1 в calculate_length и, в её определении мы берем &String вместо String. Амперсанды & обозначают здесь ссылки, и они позволяют вам ссылаться на какое-то значение без взятия над ним ownership. Рис. 4-5 иллюстрирует эту концепцию.
Рис. 4-5. Диаграмма &String s, указывающей на String s1.
Примечание: противоположностью ссылки (referencing) с помощью & является разыменование (dereferencing), которое выполняется оператором разыменования *. Мы увидим некоторые варианты использования оператора разыменования в главе 8, и обсудим детали разыменования в главе 15.
Давайте подробнее рассмотрим вызов функции в этом коде:
let s1 = String::from("hello");
let len = calculate_length(&s1);
Синтаксис &s1 позволит нам создать ссылку (reference), которая ссылается на значение s1, но не вступает во владение s1. Из-за того, что нет вступления во владение, значение s1 не выбрасывается, когда прекращается использование ссылки (отбрасывается только сама ссылка).
Подобным образом сигнатура функции использует &, чтобы показать, что тип параметра s является ссылкой. Давайте добавим некоторые поясняющие аннотации:
fn calculate_length(s:&String) -> usize { // s это ссылка на String
s.len()
} // Здесь s заканчивает свою область видимости и отбрасывается. Однако поскольку// не было взято владение (ownership) на значение того, на что указывала ссылка s, // само значение не выбрасывается.
Область действия, в котором переменная s достоверна, будет такой же, как область действия любого параметра функции, однако значение, на которое указывает переменная s, не отбрасывается, когда прекращается использование s, потому что к s не прикрепляется владение переменной, на которую она ссылается. Когда у функции есть ссылки в качестве параметров вместо реальных значений, нам нет необходимости возвращать это значение, чтобы взять обратно владение над ним, потому что при использовании ссылки мы не получали владение.
Действие по созданию ссылки мы называем заимствованием (borrowing). Как и в реальной жизни, если какая-то персона чем-то владеет, то это у неё можно одолжить. Когда вы завершили заимствование, то возвращаете все обратно. Вы не вступаете во владение.
Так что же произойдет, если мы попытаемся изменить то, что заимствуем? Попробуйте код в листинге 4-6. Вы увидите, что ничего не получится!
Листинг 4-6. Демонстрация попытки изменения borrowed-значения.
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot
| be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn change(some_string: &mut String) {
| +++ For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
То, что переменные по умолчанию неизменяемы, относится и к ссылкам. Нам не разрешается изменять то, на что у нас есть ссылка.
Мутируемые ссылки. Мы можем исправить код листинга 4-6, чтобы можно было изменять заимствованное значение, путем применения мутируемой ссылки (mutable reference):
Сначала мы поменяли s модификатором mut. Затем мы создали мутируемую ссылку передачей &mut s в параметре функции change, и обновили сигнатуру функции, чтобы она принимала мутируемую ссылку через some_string: &mut String. Это очень ясно показывает, что функция будет изменять значение, которое она заимствует.
Мутируемые ссылки имеют одно большое ограничение: если у вас применена мутируемая ссылка на значение, то вы не можете иметь никакие другие ссылки на это значение. Следующий код, который попытается создать две мутируемые ссылки на s, приведет к ошибке:
letmut s = String::from("hello");
let r1 =&mut s;
let r2 =&mut s;
println!("{}, {}", r1, r2);
Компилятор покажет ошибку:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Эта ошибка сообщает о том, что код недопустимый, потому что нельзя заимствовать s как мутируемой более одного раза одновременно. Первое мутируемое заимствование находится в r1, и оно должно продолжаться до тех пор, пока не будет использовано в println!, однако между созданием мутируемого заимствования r1 и его использованием мы также пытаемся создать другое мутируемое заимствование r2, которое должно заимствовать те же данные, что и r1.
Ограничение на исключительно одинарное мутируемое заимствование гарантирует нам очень хорошо управляемое мутирование. Это как раз то, с чем приходится бороться новичкам в Rust, потому что другие языки часть позволяют мутировать что ни попадя и как попало. Выгода этого ограничения в том, что Rust может защитить от конкурентного доступа к данным на модификацию (data races, рейсинг, или "гонка данных") уже во время компиляции. Гонка данных это ситуации, в которых могут произойти три варианта поведения кода:
• Два или большее количество указателей обращаются одновременно к одним и тем же данным. • Как минимум один из этих указателей используется для записи данных. • Нет механизма, обеспечивающего синхронизацию доступа к данным (атомарность доступа к данным).
Гонка данных может привести к неопределенному поведению кода, и это очень сложный для отладки и исправления баг, когда вы пытаетесь выявить причину ошибки runtime; Rust в корне предотвращает возможность возникновения такой проблемы, не позволяя компилировать код, который потенциально болен рейсингом!
Как и всегда, мы можем использовать фигурные скобки для создания новой области видимости, что позволяет создавать несколько мутируемых ссылок, но с соблюдением ограничения на одновременные мутируемые ссылки:
letmut s = String::from("hello");
{
let r1 =&mut s;
} // В этом месте r1 выходит за область действия, поэтому мы можем// без проблем создавать новые мутируемые ссылки.
let r2 =&mut s;
Rust применяет аналогичное правило для комбинирования мутируемых и не мутируемых ссылок. Следующий код приведет к ошибке:
letmut s = String::from("hello");
let r1 =&s; // нет проблемlet r2 =&s; // нет проблемlet r3 =&mut s; // БОЛЬШАЯ ПРОБЛЕМА
println!("{}, {}, and {}", r1, r2, r3);
Компилятор покажет ошибку:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // no problem
| -- immutable borrow occurs here
5 | let r2 = &s; // no problem
6 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Ну и ну! Мы также не можем одновременно создать мутируемую ссылку на объект, для которого уже есть немутируемые ссылки.
Причина такого запрета понятна: в многопоточной среде пользователи немутируемых ссылок могут не ожидать, что их значение вдруг неожиданно поменялось! Однако несколько немутируемых ссылок применять разрешено, потому что ни одна из них не приведет к случайному изменению целевого значения.
Следует отметить, что область действия ссылки начинается с места, где она была введена, и продолжается до последнего места, где она использовалась. Например, следующий код будет нормально скомпилирован, потому что последнее использование немутируемой ссылки было в println!, что произошло перед введением мутируемой ссылки на тот же самый объект:
letmut s = String::from("hello");
let r1 =&s; // нет проблемlet r2 =&s; // нет проблем
println!("{r1} and {r2}");
// в этой точке переменные r1 и r2 больше не используются
let r3 =&mut s; // нет проблем
println!("{r3}");
Область действия немутируемых ссылок r1 и r2 заканчивается после println!, где они последний раз использовались, что было перед созданием мутируемой ссылки r3. Области действия r1, r2 не пересекаются с областью действия r3, так что этот код допустим.
Несмотря на то, что ошибки заимствования могут иногда вводить в недоумение, следует радоваться тому, что компилятор Rust на ранней стадии (уже во время компиляции, а не runtime) укажет на потенциальный баг, и точно подскажет, где проблема. Тогда вам не нужно будет ломать голову над тем, почему ваши данные не те, которые ожидались.
Висящие ссылки. В языках, где применяются указатели, очень легко создать "висящий" указатель (dangling pointer) - когда указатель возможно был передан кому-то другому, и он освободил эту память, а значение указателя сохранилось. Т. е. есть значение указателя, а то, на что он указывает, уже не существует. Напротив, компилятор Rust гарантирует, что ссылки никогда не будут зависшими (dangling references): если у вас есть ссылка на какие-то данные, то можно быть уверенным, что область действия этих данных все еще активна.
Давайте попробуем создать висящую ссылку, чтобы проверить, как Rust разберется с этой проблемой:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() ->&String {
let s = String::from("hello");
&s
}
В результате получится ошибка компиляции:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is
| no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're
| returning a borrowed value from a `const` or a `static`
|
5 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
|
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Здесь упоминается фича, которую мы пока не рассматривали: времена жизни. Более подробно этот термин рассматривается в главе 10. Однако если вы не обращаете внимание на время жизни, то сообщение содержит ключ к тому, что этот код является проблемой:
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from
Давайте внимательней посмотрим на то, что происходит на каждом шаге нашего кода, где обнаружена проблема висящей ссылки:
fn dangle() ->&String { // dangle возвратит ссылку на String
let s = String::from("hello"); // s это новый объект String
&s // мы возвратили ссылку на s
} // В этом месте для s заканчивается область действия, и она отбрасывается.// Память, на которую она ссылалась, больше не существует.// Опасность!
Поскольку s создается внутри функции dangle, то когда код dangle заканчивается, выделение памяти, на которую ссылалась s, освобождается. Но мы попытались возвратить ссылку, указывающую на эту выброшенную память. Это значит, что s будет указывать на несуществующий объект String. В этом ничего хорошего нет! И Rust не позволит нам действовать подобным образом.
Решение состоит в том, чтобы напрямую возвратить объект s, а не ссылку на него. Тогда на выходе из функции память объекта s не будет освобождена, и он сохранится:
fn no_dangle() -> String {
let s = String::from("hello");
s
}
Этот код сработает без проблем. Владение объектом передается вместе с ним из функции, и память для него не освобождается.
Правила ссылок. Давайте резюмируем, что мы обсуждали о ссылках:
• В любой момент времени у вас может быть либо одна мутируемая ссылка, либо любое количество не мутируемых ссылок на один и тот же объект (переменную). • Ссылки всегда должны быть достоверными.
Далее мы рассмотрим другой тип ссылки: слайс (slice).
[Тип Slice]
Slice (фрагмент, срез, слайс) позволяет вам ссылаться на смежную последовательность элементов коллекции вместо этой коллекции целиком. Слайс является своего рода ссылкой, поэтому у него нет ownership.
Вот небольшая проблема, которую нужно решить программисту: надо написать функцию, которая принимает строку слов, разделенных пробелами, и она должна возвращать первое найденное в этой строке слово. Если функция не найдет пробел во входной строке, то это значит что вся строка соответствует искомому слову, так что должна быть возвращена строка целиком.
Давайте подумаем, как бы мы создали сигнатуру для этой функции без использования слайсов, чтобы понять проблему, которую решают слайсы:
fn first_word(s:&String) ->?
Функция first_word принимает &String в качестве параметра. Мы не хотим принимать во владение параметр, так что это нормально. Но что мы должны возвратить? У нас нет способа говорить о возврате части строки. Однако мы могли бы вернуть индекс конца слова, обозначенного пробелом. Давайте попробуем поступить таким образом, как показано в листинге 4-7.
fn first_word(s:&String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
Листинг 4-7. Функция first_word, возвращающая значение индекса байта в параметре String.
Поскольку нам нужно просмотреть String по каждому её элементу, чтобы найти в ней пробел, мы преобразовали нашу строку String в массив байт с помощью метода as_bytes.
let bytes = s.as_bytes();
Далее мы создали итератор по массиву байт с помощью метода iter:
for (i, &item) in bytes.iter().enumerate() {
Итераторы будут более подробно обсуждаться в главе 13. Пока все, что надо знать - iter это метод, который возвращает каждый элемент в коллекции, а enumerate оборачивает результат iter и возвращает вместо этого каждый элемент как часть кортежа. Первый элемент кортежа, возвращаемый из enumerate, это индекс, и второй элемент это ссылка на элемент. Это несколько удобнее, чем вычислять индекс самому.
Поскольку метод enumerate возвращает кортеж, мы можем использовать шаблоны для деструктурирования этого кортежа. Более подробно шаблоны будут обсуждаться в главе 6. В цикле for мы указываем шаблон, который имеет i для индекса в кортеже и &item для первого байта в кортеже. Поскольку мы получаем ссылку на элемент из .iter().enumerate(), то используем & в шаблоне.
Внутри цикла for мы ищем байт, который равен коду пробела, используя синтаксис литерала байта. Если пробел был найден, то мы возвращаем его позицию. Иначе мы возвратим длину строки, используя s.len().
if item == b' ' {
return i;
}
}
s.len()
Теперь у нас есть способ найти индекс окончания первого слова в строке, однако здесь есть проблема. Мы сами возвращаем тип usize, но его значение имеет смысл только в контексте &String. Другими словами, поскольку это значение, отдельное от String, нет никакой гарантии, что оно останется достоверным в будущем. Рассмотрим программу в листинге 4-8, которая использует функцию first_word из листинга 4-7.
fn main() {
letmut s = String::from("hello world");
let word = first_word(&s); // переменная word получит значение 5
s.clear(); // это действие опустошит String, после чего она равна ""
// в этом месте word все еще равна 5, однако теперь не существует// больше строка, длина которой соответствует значению 5.// Значение word получается вообще неправильным!
}
Листинг 4-8. Сохранение результата вызова функции first_word и после этого изменение содержимого String.
Эта программа скомпилируется без какой-либо ошибки, но произойдет несоответствие реальности, если мы будем использовать переменную word после вызова s.clear(). Поскольку переменная word никак не связана с состоянием s, word все еще содержит значение 5. Мы могли бы попытаться использовать значение 5 вместе с переменной s, чтобы попытаться извлечь первое слово, но это приведет к ошибке, потому что содержимое s поменялось после того, как 5 было сохранено в word.
Необходимо как-то побеспокоиться о том, чтобы индекс в word синхронизировался с данными в s, что весьма утомительно и потенциально может приводить к многим ошибкам. Управление этими индексами станет еще более проблемным, если мы напишем функцию second_word для поиска второго слова. Сигнатура этой функции выглядела бы следующим образом:
fn second_word(s:&String) -> (usize, usize) {
Теперь мы отслеживаем начальный и конечный индексы, возвращая их в виде кортежа, и у нас уже больше значений, вычисленных по данным определенного состояния, но эти данные никак не привязаны к вычисленным индексам. Получаются 3 не связанные друг с другом переменные, которые необходимо синхронизировать.
К счастью, в Rust есть решение для такой проблемы: срезы строк (string slices).
String Slices. Срез строки это ссылка на часть строки, и он выглядит примерно так:
let s = String::from("hello world");
let hello =&s[0..5];
let world =&s[6..11];
Вместо ссылки на строку String целиком здесь hello это ссылка на часть String, указанное дополнением [0..5]. Мы создаем срез используя диапазон (range), который указывается с помощью синтаксиса [начальный_индекс..конечный_индекс], где начальный_индекс это первая позиция в срезе, а конечный_индекс это на единицу больше последней позиции в срезе. Внутри среза его структура данных сохраняет начальную позицию и длину среза, которая соответствует значению (конечный_индекс - начальный_индекс). Т. е. для случая let world = &s[6..11]; срез world будет содержать указатель на байт по индексу 6 строки s, со значением длины 5.
Рис. 4-6 показывает это состояние на диаграмме.
Рис. 4-6. Срез String, ссылающийся на часть строки String.
С помощью синтаксиса диапазона Rust (..), если вы захотите начать с индекса 0, то вы можете отбросить значение перед двумя точками. Другими словами, следующие операторы let дают одинаковый результат:
let s = String::from("hello");
let slice =&s[0..2]; let slice =&s[..2];
Аналогично, если ваш срез включает последний байт String, то вы можете опустить завершающее значение диапазона. Другими словами, следующие операторы let дают одинаковый результат:
let s = String::from("hello");
let len = s.len();
let slice =&s[3..len]; let slice =&s[3..];
И также вы можете отбросить оба граничных значения диапазона, если срез содержит строку целиком. Следующие последние два оператора let эквивалентны:
let s = String::from("hello");
let len = s.len();
let slice =&s[0..len]; let slice =&s[..];
Замечание: индексы диапазона String должны включать корректные границы символов UTF-8. Если вы попытаетесь создать срез строки посередине многобайтного символа, то произойдет выход из программы с сообщением об ошибке. В этой секции с целью знакомства со срезами строк мы подразумеваем, что используются только символы ASCII (в этих примерах нельзя использовать для строк русские буквы). Более подробное описание работы с кодировкой UTF-8 приведено в секции "Storing UTF-8 Encoded Text with Strings" главы 8.
Давайте перепишем first_word с учетом этого нового функционала. Тип, обозначающий "string slice", записан как &str:
fn first_word(s:&String) ->&str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return&s[0..i];
}
}
&s[..]
}
Мы получаем индекс конца слова таким же способом, как делали это в листинге 4-7, путем поиска первого пробела в строке. Когда нашли пробел, мы возвращаем срез строки (string slice), используя начало строки и индекс пробела в качестве начального и конечного индексов диапазона.
Теперь при вызове first_word, мы получим обратно одно значение, которое привязано к нижележащим данным. Это значение составлено из ссылки на начальную точку среза и количество элементов в срезе.
Возврат среза также работает и для функции second_word:
fn second_word(s:&String) ->&str {
Теперь у нас есть простые программные функции, которые сложнее испортить, потому что компилятор гарантирует, что ссылки на String остаются достоверными. Помните про баг в программе листинга 4-8, когда мы получили индекс конца первого слова, но затем очистили оригинал строки, в результате чего этот индекс стал недостоверным? Этот код был логически некорректен, но компилятор не мог тогда обнаружить ошибку, и проблемы получатся позже, при работе программы, когда мы попытаемся изменить строку. Со слайсами компилятор будет вооружен, и не даст создать такую логическую ошибку, сразу выявив проблему на стадии компиляции:
fn main() {
letmut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // ошибка!
println!("the first word is: {word}");
}
Компилятор выдаст ошибку на такой код, когда используется срез строки:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {}", word);
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Вспомните правила заимствования (borrowing rules): если у нас есть немутируемая ссылка на что-нибудь, то мы также не можем создать на это что-то немутируемую ссылку. Поскольку метод clear должен обрезать String, ему нужно получить мутируемую ссылку. Вызов println! после вызова clear использует ссылку в word, так что в этом месте все еще должна быть активна немутируемая ссылка. Rust не позволяет мутируемую ссылку в clear и мутируемую ссылку в word, чтобы они существовали одновременно, в результате чего компиляцию завершается неудачей. Так что с помощью слайсов Rast наш код стал не только проще, но и еще более безопасным, потому что возможная логическая ошибка была отловлена уже во время компиляции!
Литералы String в качестве Slice. Вспомним, что мы говорили про строковые литералы, которые сохраняются внутри двоичного исполняемого кода. Теперь, когда мы знаем про слайсы, можно правильно понимать строковые литералы:
let s ="Hello, world!";
Здесь тип s это &str: т. е. это слайс, указывающий на определенную точку в бинарнике. Это также причина, почему строковые литералы немутируемые; &str это немутируемая ссылка.
Слайсы String в качестве параметров. Знание того, что вы можете взять слайсы литералов и значений String, приводит нас к еще одному улучшению функции first_word, и вот её сигнатура:
fn first_word(s:&String) ->&str {
Более продвинутый программист написал бы эту сигнатуру так, как показано в листинге 4-9, потому что это позволило использовать одну и ту же функцию как для значений &String, так и для значений &str.
fn first_word(s:&str) ->&str {
Листинг 4-9. Улучшение функции first_word на основе слайса строки для типа её параметра s.
Если у нас есть слайс строки, то мы можем напрямую передать его в функцию. Если у нас есть String, то мы можем передать слайс String или ссылку на String. Эта гибкость позволяет применить достоинства deref coercions, этот функционал мы рассмотрим в секции "Implicit Deref Coercions with Functions and Methods" главы 15.
Определение функции для взятия строкового среза вместо ссылки на String делает наш API более общим и полезным без потери функциональности:
fn main() {
let my_string = String::from("hello world");
// `first_word` работает на слайсах строк String, либо от их части,// либо от от строки целиком:let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` также работает и со ссылками на строки String,// которые эквивалентны целым срезам строк String:let word = first_word(&my_string);
let my_string_literal ="hello world";
// `first_word` работает и на срезах строковых литералов,// как от их части, так и от литерала целиком:let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Поскольку строковые литералы *уже* слайсы, то следующий// код также работает, без синтаксиса слайса!let word = first_word(my_string_literal);
}
Другие слайсы. Срезы String, как вы можете себе представить, специфичны для строк. Но есть также и более общий тип слайса. Рассмотрим следующий массив:
let a = [1, 2, 3, 4, 5];
Так же, как мы ссылались на часть стоки, можно ссылаться на часть массива:
let a = [1, 2, 3, 4, 5];
let slice =&a[1..3];
assert_eq!(slice, &[2, 3]);
У этого слайса тип &[i32]. Это работает так же, как работают слайсы строки, путем сохранения ссылки на первый элемент и длины. Вы будете использовать этот тип слайса для всех видов других коллекций. Мы обсудим эти коллекции более подробно, когда поговорим о векторах в главе 8.
[Общие выводы]
Концепции владения (ownership), заимствования (borrowing) и срезов (slices) обеспечивают безопасность памяти в программах Rust, выявляя баги на стадии компиляции. Язык Rust дает вам контроль над использованием памяти таким же способом, как и другие системные языки программирования, но тот факт, что владелец данных автоматически очищает эти данные, когда они выходят из области видимости, означает отсутствие необходимости писать и отлаживать дополнительный код для достижения такого уровня контроля.
Ownership влияет на то, как работают много другого функционала Rust, так что мы более глубоко поговорим про эти концепции в остальной части книги [1]. Давайте перейдем к главе 5 (см. [4]), и рассмотрим группировку фрагментов данных в структуре (struct).