Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Краткое повторение: владение

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

Владение против сборки мусора

Чтобы понять контекст владения, стоит поговорить о сборке мусора (garbage collection). Большинство языков программирования используют сборщик мусора для управления памятью, например, Python, JavaScript, Java и Go. Сборщик мусора работает во время выполнения программы (по крайней мере, трассировочный сборщик). Он сканирует память, чтобы найти данные, которые больше не используются — то есть, запущенная программа больше не может получить доступ к этим данным из локальной переменной функции. Затем сборщик освобождает неиспользуемую память для последующего использования.

Ключевое преимущество сборщика мусора в том, что он предотвращает неопределённое поведение (например, использование освобождённой памяти), которое может происходить в C или C++. Сборка мусора также устраняет необходимость сложной системы типов для проверки неопределённого поведения, как в Rust. Однако у сборки мусора есть несколько недостатков. Один из очевидных недостатков — производительность, поскольку сборка мусора влечёт либо частые небольшие накладные расходы (для подсчёта ссылок, как в Python и Swift), либо редкие крупные накладные расходы (для трассировки, как во всех остальных языках с сборкой мусора).

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

class Document:     
    def __init__(self, words: List[str]):
        """Create a new document"""
        self.words = words

    def add_word(self, word: str):
        """Add a word to the document"""
        self.words.append(word)
        
    def get_words(self) -> List[str]:  
        """Get a list of all the words in the document"""
        return self.words

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

words = ["Hello"]
d = Document(words)

d2 = Document(d.get_words())
d2.add_word("world")

Рассмотрим два ключевых вопроса об этом примере:

  1. Когда массив words будет освобождён? Эта программа создала три указателя на один и тот же массив. Переменные words, d и d2 все содержат указатель на массив words, выделенный в куче. Поэтому Python освободит массив words только тогда, когда все три переменные выйдут из области видимости. В более общем случае, часто бывает трудно предсказать, где данные будут собраны сборщиком мусора, просто прочитав исходный код.

  2. Каково содержимое документа d? Поскольку d2 содержит указатель на тот же массив words, что и d, то d2.add_word("world") также изменяет документ d. Поэтому в этом примере слова в d — это ["Hello", "world"]. Это происходит потому, что d.get_words() возвращает изменяемую ссылку на массив words в d. Повсеместные неявные изменяемые ссылки могут легко привести к непредсказуемым ошибкам, когда структуры данных могут раскрывать свою внутреннюю реализацию1. Здесь, вероятно, не предполагалось, что изменение d2 может изменить d.

Эта проблема не уникальна для Python — вы можете столкнуться с подобным поведением в C#, Java, JavaScript и так далее. Фактически, у большинства языков программирования есть понятие указателей. Вопрос лишь в том, как язык предоставляет указатели программисту. Сборка мусора затрудняет определение, какая переменная указывает на какие данные. Например, было неочевидно, что d.get_words() производит указатель на данные внутри d.

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

#![allow(unused)]
fn main() {
type Document = Vec<String>;

fn new_document(words: Vec<String>) -> Document {
    words
}

fn add_word(this: &mut Document, word: String) {
    this.push(word);
}

fn get_words(this: &Document) -> &[String] {
    this.as_slice()
}
}

Этот API Rust отличается от API Python в нескольких ключевых моментах:

  • Функция new_document потребляет владение входным вектором words. Это означает, что Document владеет вектором слов. Вектор слов будет предсказуемо освобождён, когда его владеющий Document выйдет из области видимости.

  • Функция add_word требует изменяемую ссылку &mut Document для возможности изменения документа. Она также потребляет владение входным параметром word, что означает, что никто больше не может изменять отдельные слова документа.

  • Функция get_words возвращает явную неизменяемую ссылку на строки внутри документа. Единственный способ создать новый документ из этого вектора слов — глубоко скопировать его содержимое, вот так:

fn main() {
    let words = vec!["hello".to_string()];
    let d = new_document(words);

    // .to_vec() преобразует &[String] в Vec<String> путём клонирования каждой строки
    let words_copy = get_words(&d).to_vec();
    let mut d2 = new_document(words_copy);
    add_word(&mut d2, "world".to_string());

    // Изменение `d2` не влияет на `d`
    assert!(!get_words(&d).contains(&"world".into()));
}

Смысл этого примера в том: если Rust — не ваш первый язык, то у вас уже есть опыт работы с памятью и указателями! Rust просто делает эти концепции явными. Это даёт двойную выгоду: (1) повышение производительности во время выполнения за счёт отсутствия сборки мусора и (2) повышение предсказуемости за счёт предотвращения случайных “утечек” данных.

Концепции владения

Далее давайте повторим концепции владения. Это повторение будет кратким — цель состоит в том, чтобы напомнить вам соответствующие концепции. Если вы поймёте, что забыли или не поняли какую-то концепцию, мы дадим вам ссылки на соответствующие главы, которые вы сможете перечитать.

Владение во время выполнения

Начнём с повторения того, как Rust использует память во время выполнения:

  • Rust размещает локальные переменные в кадрах стека, которые выделяются при вызове функции и освобождаются при завершении вызова.
  • Локальные переменные могут содержать либо данные (например, числа, булевы значения, кортежи и т.д.), либо указатели.
  • Указатели могут быть созданы либо через “ящики” (Box — указатели, владеющие данными в куче), либо через ссылки (невладеющие указатели).

Эта диаграмма иллюстрирует, как каждая концепция выглядит во время выполнения:

Изучите эту диаграмму и убедитесь, что понимаете каждую часть. Например, вы должны быть able ответить на вопросы:

  • Почему a_box_stack_ref указывает на стек, в то время как a_box_heap_ref указывает на кучу?
  • Почему значение 2 больше не находится в куче на L2?
  • Почему a_num имеет значение 5 на L2?

Если вы хотите повторить “ящики” (Box), перечитайте Главу 4.1. Если вы хотите повторить ссылки, перечитайте Главу 4.2. Если вы хотите увидеть исследования случаев с “ящиками” и ссылками, перечитайте Главу 4.3.

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

Если вы хотите повторить срезы, перечитайте Главу 4.4.

Владение на этапе компиляции

Rust отслеживает разрешения R (чтение), W (запись) и O (владение) для каждой переменной. Rust требует, чтобы у переменной были соответствующие разрешения для выполнения заданной операции. В качестве базового примера, если переменная не объявлена как let mut, то у неё отсутствует разрешение W, и её нельзя изменять:

Разрешения переменной могут измениться, если она перемещена (moved) или заимствована (borrowed). Перемещение переменной с типом, не поддерживающим копирование (например, Box<T> или String), требует разрешений RO, и перемещение лишает переменную всех разрешений. Это правило предотвращает использование перемещённых переменных:

Если вы хотите повторить, как работают перемещения, перечитайте Главу 4.1.

Заимствование переменной (создание ссылки на неё) временно удаляет некоторые разрешения переменной. Неизменяемое заимствование создаёт неизменяемую ссылку и также запрещает изменение или перемещение заимствованных данных. Например, печать неизменяемой ссылки допустима:

Но изменение неизменяемой ссылки недопустимо:

И изменение неявно заимствованных данных недопустимо:

И перемещение данных из ссылки недопустимо:

Изменяемое заимствование создаёт изменяемую ссылку, которая запрещает чтение, запись или перемещение заимствованных данных. Например, изменение изменяемой ссылки допустимо:

Но доступ к неявно заимствованным данным недопустим:

Если вы хотите повторить разрешения и ссылки, перечитайте Главу 4.2.

Связь владения между этапом компиляции и выполнением

Разрешения Rust предназначены для предотвращения неопределённого поведения. Например, один вид неопределённого поведения — это использование после освобождения (use-after-free), когда освобождённая память читается или записывается. Неизменяемые заимствования удаляют разрешение W для предотвращения использования после освобождения, как в этом случае:

Другой вид неопределённого поведения — это двойное освобождение (double-free), когда память освобождается дважды. Разыменования ссылок на данные, не поддерживающие копирование, не имеют разрешения O для предотвращения двойных освобождений, как в этом случае:

Если вы хотите повторить неопределённое поведение, перечитайте Главу 4.1 и Главу 4.3.

Остальное о владении

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

И не забывайте пройти викторины, если хотите проверить своё понимание!


  1. Фактически, исходное изобретение типов владения вообще не было связано с безопасностью памяти. Оно было направлено на предотвращение утечек изменяемых ссылок на внутренности структур данных в языках, похожих на Java. Если вам интересно узнать больше об истории типов владения, ознакомьтесь со статьёй “Ownership Types for Flexible Alias Protection” (Clarke et al. 1998).