Владение в Rust

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

Большинство языков программирования имеют возможности управления памятью:

Языки, такие как C/C++, управляют памятью в основном вручную; разработчикам необходимо вручную выделять и освобождать ресурсы памяти. Однако для повышения эффективности разработки, если это не влияет на функциональность программы, многие разработчики не имеют привычки своевременно освобождать память. Поэтому ручное управление памятью часто приводит к растрате ресурсов.

Программы, написанные на Java, выполняются в виртуальной машине (JVM), которая способна автоматически освобождать ресурсы памяти. Однако такой подход часто снижает эффективность выполнения, поэтому JVM освобождает ресурсы как можно реже, что также приводит к тому, что программы занимают больший объём ресурсов памяти.

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

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

Владение имеет следующие три правила:

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

Эти три правила — основа концепции владения.

Далее мы познакомимся с концепциями, связанными с владением.

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

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

{
    // 在声明以前,变量 s 无效
    let s = "example";
    // 这里是变量 s 的可用范围
}
// 变量范围已经结束,变量 s 无效

Область видимости переменной — это атрибут переменной. Она представляет допустимую область переменной; по умолчанию она действительна от объявления переменной до конца области, в которой эта переменная находится.


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

Если мы определяем переменную и присваиваем ей значение, значение переменной находится в памяти. Это очень распространено. Но если длина данных, которые нам нужно сохранить, неизвестна (например, строка, введённая пользователем), мы не можем определить длину данных при определении и не можем заставить программу выделить на этапе компиляции область памяти фиксированной длины для хранения данных. (Некоторые говорят, что выделение как можно большего объёма может решить проблему, но этот способ не элегантен.) Для этого нужен механизм, с помощью которого программа сама запрашивает память во время выполнения — куча. Все «ресурсы памяти», обсуждаемые в этой главе, относятся к пространству памяти, занимаемому кучей.

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

Давайте напишем эквивалентную версию примера программы со строкой на C:

{
    char *s = strdup("example");
    free(s); // Освобождение ресурса s
}

Очевидно, Rust не вызывает функцию free для освобождения ресурсов строки s (я знаю, что это неправильный способ записи на C, потому что "example" не находится в куче; здесь мы предполагаем, что это так). Причина, по которой Rust явно не показывает шаг освобождения, состоит в том, что когда область видимости переменной заканчивается, компилятор Rust автоматически добавляет шаг вызова функции освобождения ресурса.

Этот механизм может показаться простым: он всего лишь помогает программисту добавить вызов функции освобождения ресурса в подходящем месте. Но этот простой механизм способен эффективно решить одну из самых хлопотных проблем программирования в истории для программистов.


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

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

Move

Несколько переменных могут взаимодействовать с одними и теми же данными разными способами в Rust:

let x = 5;
let y = x;

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

  • Все целочисленные типы, такие как i32, u32, i64 и т. д.
  • Логический тип bool со значениями true или false.
  • Все типы с плавающей запятой, f32 и f64.
  • Символьный тип char.
  • Кортежи, содержащие только данные вышеуказанных типов.

Но если взаимодействующие данные находятся в куче, ситуация иная:

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

На первом шаге создаётся объект String со значением "hello". Здесь "hello" можно рассматривать как данные неопределённой длины, которые нужно хранить в куче.

Второй шаг немного отличается (Это не совсем верно; это только для сравнения и справки):

Как показано на рисунке: два объекта String находятся в стеке, каждый объект String имеет указатель на строку "hello" в куче. При присваивании s2 копируются только данные в стеке; строка в куче остаётся исходной строкой.

Ранее мы упоминали, что когда переменная выходит из области видимости, Rust автоматически вызывает функцию освобождения ресурсов и очищает память переменной в куче. Но если и s1, и s2 будут освобождены, "hello" в области кучи будет освобождён дважды, что не допускается системой. Чтобы обеспечить безопасность, s1 становится недействительным при присваивании s2. Верно: после присваивания значения s1 переменной s2 s1 больше нельзя использовать. Следующая программа неверна:

let s1 = String::from("hello");
let s2 = s1; 
println!("{}, world!", s1); // 错误!s1 已经失效

Итак, фактическая ситуация такова:

s1 существует лишь номинально.


Clone

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

Пример

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

Вывод:

s1 = hello, s2 = hello

Здесь "hello" в куче действительно копируется, поэтому s1 и s2 каждая привязана к значению, и при освобождении они рассматриваются как два ресурса.

Конечно, клонирование следует использовать только тогда, когда необходимо копирование; в конце концов, копирование данных занимает больше времени.


Механизм владения в функциях

Это наиболее сложная ситуация для переменных.

Если переменная передаётся как аргумент функции в другую функцию, как безопасно обращаться с владением?

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

Пример

fn main() {
    let s = String::from("hello");
    // s объявлена действительной

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

    let x = 5;
    // x объявлена действительной

    makes_copy(x);
    // значение x передаётся в функцию как аргумент
    // Но x — базовый тип, поэтому он остаётся действительным
    // Здесь мы всё ещё можем использовать x, но не s

} // Функция завершается, x становится недействительным, затем s. Но s была перемещена, поэтому её не нужно освобождать


fn takes_ownership(some_string: String) {
    // Передаётся аргумент some_string типа String, он действителен
    println!("{}", some_string);
} // Функция завершается, аргумент some_string освобождается здесь

fn makes_copy(some_integer: i32) {
    // Передаётся аргумент some_integer типа i32, он действителен
    println!("{}", some_integer);
} // Функция завершается, аргумент some_integer — базовый тип, нет необходимости освобождать

Если переменная передаётся в функцию в качестве аргумента, это действует так же, как перемещение.

Механизм владения возвращаемыми значениями функций

Пример

fn main() {
    let s1 = gives_ownership();
    // gives_ownership перемещает своё возвращаемое значение в s1

    let s2 = String::from("hello");
    // s2 объявлена действительной

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

fn gives_ownership() -> String {
    let some_string = String::from("hello");
    // some_string объявлена действительной

    return some_string;
    // some_string перемещается из функции в качестве возвращаемого значения
}

fn takes_and_gives_back(a_string: String) -> String { 
    // a_string объявлена действительной

    a_string  // a_string перемещается из функции в качестве возвращаемого значения
}

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


Ссылки и заимствование

Ссылка — это понятие, знакомое разработчикам на C++.

Если вы знакомы с указателями, можете рассматривать её как разновидность указателя.

По сути, "ссылка" — это косвенный способ доступа к переменной.

Пример

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    println!("s1 — {}, s2 — {}", s1, s2);
}

Вывод:

s1 is hello, s2 is hello

&Оператор может взять "ссылку" на переменную.

Когда на значение переменной ссылаются, сама переменная не считается недействительной. Это потому, что "ссылка" не копирует значение переменной в стеке:

Тот же принцип применяется к передаче параметров функции:

Пример

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

    let len = calculate_length(&s1);

    println!("Длина '{}' равна {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Вывод:

The length of 'hello' is 5.

Ссылки не получают владение значением.

Ссылки могут только заимствовать владение значением.

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

Пример

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
}

Эта программа некорректна: поскольку s1, заимствованный s2, уже передал своё владение s3, s2 больше не сможет продолжать заимствовать владение s1. Если s2 нужно использовать это значение, оно должно заимствовать его повторно:

Пример

fn main() {
    let s1 = String::from("hello");
    let mut s2 = &s1;
    let s3 = s1;
    s2 = &s3; // Повторно заимствовать владение у s3
    println!("{}", s2);
}

Эта программа корректна.

Поскольку ссылки не имеют владения, даже если они заимствуют владение, они обладают только правами использования (это то же самое, что аренда дома).

Если вы попытаетесь воспользоваться заимствованным правом на изменение данных, это будет запрещено:

Пример

fn main() {
    let s1 = String::from("run");
    let s2 = &s1;
    println!("{}", s2);
    s2.push_str("oob"); // Ошибка: изменение заимствованного значения запрещено
    println!("{}", s2);
}

В этой программе попытка s2 изменить значение s1 предотвращена; заимствованное владение не может изменять значение владельца.

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

Пример

fn main() {
    let mut s1 = String::from("run");
    // s1 изменяемая

    let s2 = &mut s1;
    // s2 — изменяемая ссылка

    s2.push_str("oob");
    println!("{}", s2);
}

Эта программа корректна. Мы используем `&mut` для обозначения типов изменяемых ссылок.

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

Пример

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

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

Эта программа некорректна, потому что у s есть несколько изменяемых ссылок.

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

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

Висячие ссылки

Это понятие с другим названием. В языках программирования, где есть понятие указателей, оно обозначает указатель, который на самом деле не указывает на действительно доступные данные (примечание: это не обязательно нулевой указатель; это также может быть ресурс, который уже был освобождён). Они подобны верёвкам, потерявшим объект, на котором висели, отсюда и название «висячая ссылка».

«Висячие ссылки» не допускаются в Rust; если они существуют, компилятор их обнаружит.

Ниже приведён типичный пример висячей ссылки:

Пример

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Очевидно, что при завершении функции `dangle` значение её локальной переменной не используется в качестве возвращаемого значения и освобождается. Однако вместо этого возвращается ссылка на него. Больше нельзя подтвердить, что значение, на которое указывает эта ссылка, существует, поэтому это не разрешено.

Другие расширения