Краткое повторение: владение
В этой главе были представлены многие новые концепции, такие как владение, заимствование и срезы. Если вы не знакомы с системным программированием, эта глава также познакомила вас с такими понятиями, как распределение памяти, стек против кучи, указатели и неопределённое поведение. Прежде чем переходить к остальной части 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")
Рассмотрим два ключевых вопроса об этом примере:
-
Когда массив
wordsбудет освобождён? Эта программа создала три указателя на один и тот же массив. Переменныеwords,dиd2все содержат указатель на массивwords, выделенный в куче. Поэтому Python освободит массивwordsтолько тогда, когда все три переменные выйдут из области видимости. В более общем случае, часто бывает трудно предсказать, где данные будут собраны сборщиком мусора, просто прочитав исходный код. -
Каково содержимое документа
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 в будущих главах.
И не забывайте пройти викторины, если хотите проверить своё понимание!
-
Фактически, исходное изобретение типов владения вообще не было связано с безопасностью памяти. Оно было направлено на предотвращение утечек изменяемых ссылок на внутренности структур данных в языках, похожих на Java. Если вам интересно узнать больше об истории типов владения, ознакомьтесь со статьёй “Ownership Types for Flexible Alias Protection” (Clarke et al. 1998). ↩