Селф-референсные структуры в Rust: пошаговый туториал

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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

Представьте ситуацию: вы пишете на Rust программу, и вам нужно создать структуру, которая хранит какие-то данные, а также ссылку на эти же данные. Логично? Ещё бы! Но компилятор Rust вдруг выдаёт ошибку, и вы в недоумении. Знакомо? Этот вопрос — один из самых популярных на Stack Overflow (тысячи просмотров), и сегодня мы разберём его от и до.

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

Проблема: наивная попытка

Допустим, мы хотим создать структуру, которая хранит строку и её первую букву (ссылку на первый символ). Вот как это выглядит в коде:

struct MyStruct {
    data: String,
    first_char: &str,  // ссылка на data
}

fn main() {
    let s = String::from("Hello");
    let first = &s[0..1];  // ссылка на первый символ
    
    let my_struct = MyStruct {
        data: s,
        first_char: first,  // пытаемся сохранить ссылку
    };
}

Компилятор выдаёт ошибку, которая сбивает с толку многих новичков. Давайте разберёмся, что именно пошло не так.

Почему Rust запрещает селф-референсные структуры?

Корень проблемы — в системе владения (ownership) и заимствования (borrowing) Rust. Когда вы перемещаете (move) значение s в поле data, оно физически копируется в новое место в памяти. Ссылка first при этом всё ещё указывает на старый адрес, который теперь недействителен.

В Rust каждая ссылка должна быть действительной всё время своего существования. Селф-референсные структуры Rust нарушают это правило, потому что ссылка и значение живут в одном объекте, и при перемещении структуры ссылка становится висячей (dangling reference).

Что происходит под капотом?

Когда вы пишете такой код:

let s = String::from("Hello");
let first = &s[0..1];
let my_struct = MyStruct { data: s, first_char: first };

Происходит следующее:

  1. s владеет строкой "Hello" в куче.
  2. first — ссылка на часть этой строки.
  3. При создании структуры data: s — право владения строкой передаётся структуре. Строка может переместиться в памяти.
  4. Ссылка first всё ещё указывает на старый адрес — это висячая ссылка. Rust не может этого допустить.

Borrow checker Rust видит эту потенциальную небезопасность и блокирует код на этапе компиляции. Это не баг, а фича, которая спасает от множества проблем в рантайме.

Реальные сценарии, где нужны селф-референсные структуры

Несмотря на запрет, такие структуры бывают полезны. Вот типичные случаи:

  • Парсеры и AST: когда узлы дерева ссылаются друг на друга.
  • Игровые движки: сущности, которые содержат ссылки на свои компоненты.
  • Сетевые протоколы: буферы с внутренними указателями.
  • Системы плагинов: когда объект содержит ссылку на свой конфиг.

Хорошая новость: есть безопасные способы обойти это ограничение!

Решение 1: Использование индексов вместо ссылок (самый простой способ)

Самый простой и безопасный способ — хранить не ссылку, а индекс или ключ. Вместо &str храните usize (позицию первого символа).

struct MyStruct {
    data: String,
    first_char_index: usize,  // храним позицию, а не ссылку
}

impl MyStruct {
    fn first_char(&self) -> &str {
        &self.data[self.first_char_index..self.first_char_index + 1]
    }
}

fn main() {
    let s = String::from("Hello");
    let my_struct = MyStruct {
        data: s,
        first_char_index: 0,  // индекс первого символа
    };
    
    println!("Первый символ: {}", my_struct.first_char());
}

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

Плюсы и минусы:

  • ✅ Полностью безопасно, без unsafe
  • ✅ Простота и понятность
  • ❌ Нужно следить за синхронизацией индексов при изменении данных
  • ❌ Не подходит для сложных структур данных

Решение 2: Использование Rc и RefCell (для сложных случаев)

Если вам нужно разделяемое владение и внутренняя изменяемость, на помощь приходят умные указатели Rc (счётчик ссылок) и RefCell (проверка заимствования в рантайме).

use std::cell::RefCell;
use std::rc::Rc;

struct Node {
    value: i32,
    children: Vec<Rc<RefCell<Node>>>,
    parent: Option<Rc<RefCell<Node>>>,  // ссылка на родителя
}

fn main() {
    let root = Rc::new(RefCell::new(Node {
        value: 1,
        children: vec![],
        parent: None,
    }));
    
    let child = Rc::new(RefCell::new(Node {
        value: 2,
        children: vec![],
        parent: Some(Rc::clone(&root)),  // ссылка на родителя
    }));
    
    root.borrow_mut().children.push(Rc::clone(&child));
    
    println!("Дочерний узел {} имеет родителя с значением {}", 
             child.borrow().value, 
             child.borrow().parent.as_ref().unwrap().borrow().value);
}

Rc позволяет иметь несколько владельцев одного значения, а RefCell даёт возможность изменять данные даже при наличии нескольких ссылок. Это идеальное решение для графов, деревьев и других структур с циклическими ссылками.

Важно знать:

  • ✅ Безопасно (проверки в рантайме, а не в compile-time)
  • ✅ Подходит для сложных графов и деревьев
  • ❌ Небольшое снижение производительности (runtime checks)
  • ❌ Возможны паники при нарушении правил заимств

Блоги

Рекомендаций по книгам