Время жизни в Rust (Часть 1)

• Александр Яшкин • обучение • поддержите на Patreon

Введение

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

Постановка задачи

Мы будем разрабатывать структуру с именем Logger, которая будет хранить в себе текстовые данные и предоставляющая доступ к своим данным только для чтения.

Пишем код

Давайте напишем код для выполнения задачи:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct Logger {
    log: String
}

// Реализация методов для структуры Logger
impl Logger {
    pub fn new() -> Logger {
        Logger{ log: String::new() }
    }

    // Метод добавления текста в лог
    pub fn add_text(&mut self, data: &str) {
        self.log.push_str(data);
    }
}

fn main() {
    let mut logger = Logger::new();

    logger.add_text("foo");
    logger.add_text("bar");
}

Этот код правильный и компилируется. Сделали всё, что требовалось задачей, кроме доступа к текстовым данным. Что собственно сейчас и начнём делать.

Предоставляем чтение данных

Рассмотрим, как предоставить пользователю нашей структуры доступ к данным, хранящимся в ней. Есть 3 варианта.

Вариант 1: Клонирование данных

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

1
2
3
4
5
6
7
impl Logger {
    // ... методы структуры Logger ...

    pub fn get_log_clone(&self) -> String {
        self.log.clone()
    }
}

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

Этот вариант ужасен в плане производительности! Особенно это будет заметно при работе с большими текстовыми данными. Нам этот вариант не подходит, т. к. мы хотим большей производительности. И Rust может дать нам её.

Вариант 2: Копирование

А что если мы попробуем не клонировать данные, а лишь вернуть их копию? При копировании данных не выделяется новый участок данных, а передаётся указатель на старые данные. Это могло бы сэкономить на выделении памяти и переносе данных. Давайте попробуем сделать это:

1
2
3
4
5
6
7
8
9
10
11
12
impl Logger {
    // ... методы структуры Logger ...

    pub fn get_log_copy(&self) -> String {
        self.log
    }
}

fn main() {
    let mut logger = Logger::new();
    let my_log = logger.get_log_copy();
}

Увы, но наш код не компилируется. Всё из-за того что мы пытаемся передать право владения внутренним полем структуры Logger пользовательской переменной my_log. Передав владение в my_log внутреннее поле становится не доступным, т. е. методы нашей структуры не смогут пользоваться им. Ужасная ситуация! Поэтому в нашем случае Rust запрещает передавать владение внутренних полей структур. Так что этот второй вариант также нам не подходит.

Право владения в Rust помогает избавиться от проблемы повторного освобождения ресурса («Double free problem»). Как мы уже знаем, когда переменная выходит из области видимости она освобождается. Если бы не было права владения, то текстовые данные были бы освобождены дважды: первый раз когда переменная my_log выйдет из области видимости и второй раз при выходе области переменной logger с нашей структурой.

Вариант 3: передача ссылки на данные

В этом варианте мы будет передавать пользователю ссылку на наши данные. Это не потребует ресурсоёмких затрат и будет работать очень-очень быстро. Этот вариант отлично нам подойдёт.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl Logger {
    // ... прочие методы Logger ...

    pub fn get_log(&self) -> &String {
        &self.log
    }
}

fn main() {
    let mut logger = Logger::new();

    logger.add_text("foo");
    logger.add_text("baz");

    // Получаем указатель на текст с данными
    let my_log = logger.get_log();  // Тип: &String

    println!("{}", my_log);
}

Вот тут бы я хотел остановиться и обсудить время жизни. В этом коде мы используем время жизни даже не зная об этом.

Рассмотрим проблему. Наша структура дала нам ссылку на текст и после этого она вышла из области видимости, что приведёт к её уничтожению. Куда будет теперь указывать полученная ссылка? Ссылка стала указывать на удалённую область памяти куда уже записали другие данные.

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

Давайте воспользуемся полученной ссылкой после уничтожения нашей структуры:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
    let my_log;

    {
        let mut logger = Logger::new();

        logger.add_text("foo");
        logger.add_text("baz");

        my_log = logger.get_log(); // error: `logger` does not live long enough
    } // Конец времени жизни для logger

    println!("{}", my_log); // ссылаeтся на уничтоженный участок памяти! ОШИБКА!
}

В этом случае нам компилятор запретит так делать, т. к. my_log будет ссылаться на уничтоженный участок памяти.

Параметры времени жизни

Часто компилятор будет сам обозначать время жизни без нашего участия. Он легко определяет, что указатель на строку &String передаётся из &self. Это означает, что время жизни у &self должна быть больше чем у возвращаемого указателя на строку, хранящейся в ней. Такие простые случаи компилятор не требует от нас явного указания времени жизни, т. к. он сам способен определить время жизни. Но нам ничто не мешает вручную указать параметры времени жизни:

1
2
3
4
5
6
7
impl Logger {
    // ... прочие методы Logger ...

    pub fn get_log<'a>(&'a self) -> &'a String {
        &self.log
    }
}

Вот так мы указываем параметры времени жизни. В нашем примере мы использовали a как параметр времени жизни. В реальных проектах вы будете часто встречать параметры времени жизни, обозначенные латинскими буквами: a, b, c, d и т. д. Но ничто нам не запрещает выбрать для параметра времени жизни любое другое имя, например my_super_lifetime.

В get_log<'a> мы объявляем имена времён жизни. Мы будем использовать одно время жизни a.

В (&'a self) мы определяем, что у &self время жизни должно быть равным a.

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

Проверка на заимствование

Даже если мы правильно укажем параметры времени жизни, то можно столкнуться с указателем на уже освобождённую строку, т. к. наша структура может освободить внутреннюю переменную log. Тогда наша переменная my_log будет ссылаться на удалённые данные в памяти. Добавим ещё один метод в нашу структуру:

1
2
3
4
5
6
7
impl Logger {
    // ... прочие методы ...

    pub fn reset(&mut self) {
        self.log = String::new();
    }
}

А теперь попробуем освободить строку в нашей структуре, заменив её новой строкой:

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
    let mut logger = Logger::new();

    logger.add_text("foo");
    logger.add_text("bar");

    let my_log = logger.get_log();

    logger.reset();

    println!("{}", my_log); // А старой строки уже в памяти нет! ОШИБКА!
}

Переменная my_log, содержащая указатель на строку, после вызова метода reset() станет указывать на освобождённый участок памяти, что может привести к большим проблемам в работе программы. К счастью, нас в такой ситуации спасает проверка на заимствование (borrow check).

Проверка на заимствование работает следующим образом: переменная my_log заимствует неизменяемую ссылку на переменную log в нашей структуре, а также заимствуем ссылку на родителя переменной, т. е. переменную logger. После этого мы вызываем метод reset(), который пытается позаимствовать изменяемую ссылку для logger. Здесь произойдёт ошибка заимствования, т. к. нарушено правило заимствования.

Правила заимствования:

Другие языки программирования

Если вам приходилось сталкиваться с языками программирования типа Java или Objective-C, то вас удивит использование времени жизни в Rust. В Java для освобождения памяти от неиспользуемых объектов используется сборщик мусора (Garbage Collector). В нашем примере Java не освобождал бы переменную log пока кто-то ссылается на неё. Objective-C ведёт подсчёт количества ссылок на участок памяти. В нашем примере есть 2 ссылки на участок памяти, где хранится строка: первая ссылка — это переменная log внутри нашей структуры, а вторая ссылка — это переменная my_log. В Objective-C участок памяти с текстовыми данными может быть освобождён лишь когда эти ссылки перестанут ссылаться на данные и тогда счётчик ссылок станет равен 0.

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