Жирные указатели в Rust: разбираемся с fat pointer

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

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

Начать курс

Что скрывается за «жирным» указателем? Разбор fat pointer в Rust

Представьте, что вы пришли в библиотеку и попросили книгу. Библиотекарь даёт вам маленькую бумажку с номером стеллажа и полки — это обычный указатель. Но что, если вы не знаете, какого размера книга? Или вдруг это трёхтомник? Вам понадобится вторая бумажка: «Том 1 из 3» или «Длина: 450 страниц».

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

Обычные указатели: тонкие и быстрые

Начнём с базы. В Rust есть простые указатели — &T (ссылка) или *const T (сырой указатель). Они занимают ровно 8 байт (на 64-битной системе) и хранят только адрес в памяти.

fn main() {
    let x: i32 = 42;
    let ptr: &i32 = &x; // тонкий указатель — 8 байт
    println!("Размер ссылки: {} байт", std::mem::size_of_val(&ptr));
}

Вывод: Размер ссылки: 8 байт. Всё предсказуемо: компилятор знает, что i32 — это 4 байта, поэтому дополнительная информация не нужна. Но что, если размер данных заранее неизвестен?

Первый жирный указатель: срезы (&[T])

Срез — это «окошко» в массив или вектор. Но чтобы им пользоваться, нужно знать не только начало, но и количество элементов. Rust хранит это как пару (ptr, len) — это и есть fat pointer.

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let slice: &[i32] = &arr[1..4]; // срез из 3 элементов

    println!("Размер среза: {} байт", std::mem::size_of_val(&slice));
    println!("Элементы: {:?}", slice);
}

Вывод: Размер среза: 16 байт (8 байт указатель + 8 байт длина). Если бы мы передавали только адрес, мы бы не знали, где остановиться. Вот почему срез — классический пример fat pointer.

Кстати, строка &str работает точно так же: это fat pointer (*const u8, usize). Именно поэтому &str занимает 16 байт, а &String — только 8 (потому что String сам хранит длину внутри).

Второй жирный указатель: трейт-объекты (&dyn Trait)

В Rust есть динамическая диспетчеризация через трейт-объекты. Когда вы пишете &dyn MyTrait, компилятор не знает, какой конкретный тип будет передан. Ему нужна виртуальная таблица методов (vtable), чтобы вызывать правильные функции.

trait Animal {
    fn speak(&self);
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) { println!("Гав!"); }
}

impl Animal for Cat {
    fn speak(&self) { println!("Мяу!"); }
}

fn main() {
    let dog = Dog;
    let cat = Cat;

    let animals: [&dyn Animal; 2] = [&dog, &cat];

    println!("Размер &dyn Animal: {} байт", std::mem::size_of::<&dyn Animal>());

    for animal in &animals {
        animal.speak();
    }
}

Вывод: Размер &dyn Animal: 16 байт. Эти 16 байт — снова fat pointer: первые 8 байт — адрес данных (Dog или Cat), вторые 8 байт — адрес vtable (таблицы методов).

Как fat pointer влияет на производительность?

Жирные указатели — это компромисс. С одной стороны, они несут больше информации, что позволяет работать с динамическими типами. С другой стороны:

  • Память: fat pointer занимает 16 байт вместо 8. Это не критично, но в больших коллекциях может сказаться.
  • Скорость: доступ через vtable (для трейт-объектов) немного медленнее, чем прямой вызов метода (мономорфизация). Разница обычно микросекундная.
  • Гибкость: вы получаете возможность писать обобщённый код без дженериков на этапе компиляции. Например, можно хранить в векторе разные типы, реализующие один трейт.

Когда Rust использует fat pointer неявно?

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

  • Строковые срезы: &str — это fat pointer.
  • Срезы массивов: &[u8] — fat pointer.
  • Трейт-объекты: Box<dyn Error>, &dyn Fn() — все они fat pointer.
  • Аргументы командной строки: std::env::args() возвращает итератор, но если вы соберёте его в Vec<&str>, каждый &str будет fat pointer.

Практический пример: своя реализация fat pointer

Чтобы закрепить, давайте напишем простой аналог fat pointer вручную. Предположим, мы хотим хранить ссылку на массив чисел, но не знаем его длину на этапе компиляции.

struct FatPtr {
    ptr: *const i32,
    len: usize,
}

impl FatPtr {
    fn new(slice: &[i32]) -> Self {
        FatPtr {
            ptr: slice.as_ptr(),
            len: slice.len(),
        }
    }

    fn get(&self, index: usize) -> Option<&i32> {
        if index < self.len {
            unsafe { Some(&*self.ptr.add(index)) }
        } else {
            None
        }
    }
}

fn main() {
    let data = [10, 20, 30, 40];
    let fat = FatPtr::new(&data);

    println!("Размер FatPtr: {} байт", std::mem::size_of::<FatPtr>());
    println!("Элемент 2: {:?}", fat.get(2));
    println!("Элемент 10: {:?}", fat.get(10));
}

Вывод: Размер FatPtr: 16 байт. Мы вручную сконструировали fat pointer, хотя в реальном Rust за нас это делает компилятор.

Почему это важно для начинающих?

Понимание fat pointer — это ключ к нескольким важным темам в Rust:

Блоги

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