Avatar ·

Что такое "fat pointer" (жирный указатель) в Rust и зачем он нужен?

Привет, коллеги! Я начал изучать Rust недавно, и столкнулся с термином "fat pointer" (жирный указатель). В официальной документации и на форумах это слово встречается, но определения размытые. Я понимаю, что в Rust есть обычные указатели (например, `*const T` или ссылки `&T`), которые просто хранят адрес в памяти. Но что такое "жирный" указатель? Чем он отличается от обычного? Я слышал, что это связано с типажами (`dyn Trait`) и срезами (`&[u8]`). Например, когда я пишу: ```rust let slice: &[i32] = &[1, 2, 3]; ``` Я знаю, что `slice` — это ссылка на массив. Но говорят, что это fat pointer. Почему? Ведь это просто ссылка, разве нет? Или когда я использую трейт-объект: ```rust fn print_debug(item: &dyn Debug) { println!("{:?}", item); } ``` Здесь `item` — это тоже fat pointer? Сколько памяти он занимает? И как Rust узнает, какой метод вызывать, если тип известен только во время выполнения? Объясните, пожалуйста, на пальцах, с примерами. Мне кажется, я пропустил что-то важное про управление памятью в Rust. Заранее спасибо! <<>>

Что такое fat pointer и почему он "жирный"?

Отличный вопрос! Вы почти угадали. Fat pointer (жирный указатель) — это указатель, который хранит не только адрес памяти, но и дополнительную мета-информацию. В Rust есть два основных случая:

  • Срезы (slices): &[T] или &str
  • Трейт-объекты (trait objects): &dyn Trait или Box<dyn Trait>

Обычный указатель (например, &i32) занимает 8 байт на 64-битной системе — это просто адрес. Fat pointer занимает 16 байт (два машинных слова). Отсюда и название — он "жирнее" обычного.

Срезы: почему &[i32] — это fat pointer?

Когда вы пишете let slice: &[i32] = &[1, 2, 3];, переменная slice хранит:

// Структура fat pointer для среза:
struct SliceFatPtr {
    ptr: *const i32,  // указатель на первый элемент (8 байт)
    len: usize,       // длина среза (8 байт)
}
// Итого: 16 байт

Зачем нужна длина? Rust — безопасный язык. Если бы хранился только адрес, компилятор не мог бы проверить границы при обращении к slice[5]. Длина позволяет:

  • Проверять выход за границы (panic при slice[100])
  • Итерироваться по срезу без внешнего счетчика
  • Передавать срез в функции, не теряя информацию о размере

Пример, который показывает разницу:

fn main() {
    let arr = [10, 20, 30, 40];
    let slice: &[i32] = &arr[1..3]; // срез из 2 элементов
    
    // Размеры в байтах:
    println!("Размер &i32: {}", std::mem::size_of::<&i32>());     // 8
    println!("Размер &[i32]: {}", std::mem::size_of::<&[i32]>()); // 16
}

Трейт-объекты: &dyn Debug — второй вид fat pointer

Когда вы используете &dyn Debug, Rust не знает конкретный тип во время компиляции. Но он должен знать, как вызвать метод fmt(). Для этого fat pointer хранит:

struct TraitObjectFatPtr {
    ptr: *const (),       // указатель на данные (8 байт)
    vtable: *const (),    // указатель на таблицу виртуальных методов (8 байт)
}
// Итого: 16 байт

vtable — это таблица, созданная компилятором, которая содержит адреса всех методов трейта для конкретного типа. Когда вы вызываете item.fmt(), Rust:

  1. Берёт vtable из fat pointer
  2. Находит в ней адрес метода fmt для вашего типа
  3. Вызывает его, передавая данные по ptr

Демонстрация:

use std::fmt::Debug;

fn print_debug(item: &dyn Debug) {
    // item — fat pointer 16 байт
    println!("{:?}", item);
}

fn main() {
    let x = 42;
    let s = "hello";
    print_debug(&x); // &dyn Debug для i32
    print_debug(&s); // &dyn Debug для &str
}

Обратите внимание: &dyn Debug занимает 16 байт, хотя &i32 — всего 8. "Лишние" 8 байт — это vtable, которая позволяет динамически диспетчеризовать вызовы методов.

Почему это важно?

  • Производительность: fat pointer увеличивает размер передаваемых данных (16 байт вместо 8), но избавляет от необходимости хранить длину/методы отдельно.
  • Безопасность: Rust не может позволить себе C-style "сырые" указатели без метаданных — это привело бы к ошибкам.
  • Гибкость: трейт-объекты позволяют писать полиморфный код без дженериков.

Надеюсь, теперь стало понятнее! Fat pointer — это не магия, а просто пара (указатель + метаданные), упакованная в 16 байт.

<<>>

Дополнение: как создать свой fat pointer?

Вы можете сами реализовать "жирный указатель", используя кортеж из двух полей. Но Rust делает это за вас для срезов и трейт-объектов. Интересный факт: fat pointer может быть только для ссылок (&) или умных указателей (Box, Rc). Сырые указатели (*const T) — всегда тонкие (8 байт).

Практический совет

Если вы работаете с FFI (интерфейс с C), помните: C не понимает fat pointer. Передавая срез в C-функцию, вы должны передать два отдельных параметра: указатель и длину:

// Rust
fn call_c(slice: &[u8]) {
    unsafe {
        c_function(slice.as_ptr(), slice.len()); // ptr + len отдельно
    }
}
// C
void c_function(const uint8_t* data, size_t len);

Если вы попытаетесь передать &[u8] как один аргумент, C получит мусор, так как ожидает 8 байт, а получит 16.

Проверка размера в коде

Для любопытных — вот как проверить, является ли ваш тип fat pointer:

fn is_fat_pointer<T>() -> bool {
    std::mem::size_of::<T>() == 2 * std::mem::size_of::<usize>()
}

fn main() {
    println!("&[i32]: {}", is_fat_pointer::<&[i32]>());   // true
    println!("&i32: {}", is_fat_pointer::<&i32>());       // false
    println!("&dyn Debug: {}", is_fat_pointer::<&dyn std::fmt::Debug>()); // true
}

Этот трюк полезен для отладки, когда вы не уверены, что именно хранится в переменной.