Avatar ·

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

Привет, коллеги! Я начал изучать Rust и наткнулся на термин "fat pointer" (жирный указатель). В книжках пишут, что это "указатель с дополнительной метаинформацией", но я никак не могу понять, зачем это нужно и где это используется. Я знаю, что в Rust есть обычные ссылки `&T`, которые просто указывают на данные. Но вот, например, срезы. Я написал такой код: ```rust fn main() { let arr = [1, 2, 3, 4, 5]; let slice: &[i32] = &arr[1..4]; println!("Длина среза: {}", slice.len()); // Выведет 3 } ``` Мне непонятно: как Rust узнает, что длина среза равна 3, если я передаю только ссылку на первый элемент? Обычный указатель в C/C++ хранит только адрес, и длину нужно передавать отдельно. Получается, что `&[i32]` — это не просто адрес? Еще я слышал про трейт-объекты, типа `Box`. Говорят, что они тоже fat pointers. Но в чем их "жирность"? Ведь обычный `Box` — это просто указатель на кучу. А что такого особенного в `Box`? Объясните, пожалуйста, на пальцах: что такое fat pointer, какие они бывают и как Rust хранит эту "лишнюю" информацию внутри? Буду благодарен за примеры с разбором по памяти. <<>>

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

Вы всё верно подозреваете: &[i32] — это не просто адрес. В Rust fat pointer (или "широкий указатель") — это структура, которая состоит из двух обычных машинных слов (usize), а не из одного. Первое слово — это указатель на данные, а второе — метаданные (обычно длина или указатель на таблицу виртуальных методов). Именно из-за того, что он занимает в два раза больше места в памяти (например, 16 байт на 64-битной системе вместо 8), его и называют "жирным".

Тип 1: Срезы (Slices) — указатель + длина

Рассмотрим ваш пример со срезом. Когда вы пишете let slice: &[i32] = &arr[1..4];, компилятор создает fat pointer. Внутри него хранится:

  • Указатель: на первый элемент среза (в вашем случае на arr[1]).
  • Длина (len): количество элементов (в вашем случае 3).

Без длины Rust не смог бы проверить выход за границы массива во время выполнения (runtime bounds check). Вот как это выглядит в памяти:

// Стек:
// arr: [1, 2, 3, 4, 5] (5 * 4 байт = 20 байт)
// slice: fat pointer (16 байт на x86_64)
//   [ptr: 0x7fff...] -> указывает на arr[1]
//   [len: 3]         -> длина среза

Если бы срез был просто обычным указателем, вы бы никогда не смогли вызвать slice.len() — эта информация была бы утеряна.

Тип 2: Трейт-объекты (dyn Trait) — указатель + vtable

Второй случай — это трейт-объекты, такие как &dyn MyTrait или Box<dyn MyTrait>. Здесь метаданные — это не длина, а указатель на vtable (виртуальную таблицу методов).

Представьте, что у вас есть трейт Animal и две структуры: Dog и Cat. Обе реализуют метод speak(). Когда вы создаете Box<dyn Animal>, fat pointer хранит:

  • Указатель на данные: на экземпляр Dog или Cat в куче.
  • Указатель на vtable: на таблицу, где записано, где в памяти лежит функция Dog::speak() или Cat::speak().
// Пример:
trait Animal { fn speak(&self); }
struct Dog;
impl Animal for Dog { fn speak(&self) { println!("Гав!"); } }

fn main() {
    let animal: Box<dyn Animal> = Box::new(Dog);
    // fat pointer (16 байт):
    // [data_ptr: 0x...] -> Dog в куче
    // [vtable_ptr: 0x...] -> таблица методов для Dog
    animal.speak(); // Работает благодаря vtable
}

Обычный Box<Dog> — это просто указатель (8 байт), так как тип конкретный и компилятор знает все методы на этапе компиляции. Но как только вы стираете тип до dyn Animal, компилятор теряет конкретную информацию, поэтому ему приходится хранить vtable рядом с указателем на данные — отсюда и "жирность".

Итог: как отличить

  • Обычный указатель/ссылка: &T, Box<T>, *const T — 1 машинное слово (8 байт).
  • Fat pointer: &[T], &str, Box<dyn Trait>, &dyn Trait — 2 машинных слова (16 байт).

Проверить это можно с помощью std::mem::size_of:

println!("{}", std::mem::size_of::<&[i32]>());   // 16
println!("{}", std::mem::size_of::<&i32>());      // 8
<<>>

Дополнение: как передать fat pointer в C-код и почему это опасно

В вашем вопросе затронута важная тема совместимости. Если вы пишете FFI (Foreign Function Interface) и передаете срез в C-функцию, вы не можете просто передать &[i32] как один аргумент. С-функция ожидает обычный указатель (8 байт), а не fat pointer (16 байт).

Правильный способ — разобрать fat pointer на две части вручную с помощью методов .as_ptr() и .len():

let slice: &[i32] = &[1, 2, 3];
let ptr = slice.as_ptr(); // *const i32
let len = slice.len();    // usize

// Теперь можно передать в C как два отдельных параметра:
// unsafe { c_function(ptr, len); }

То же самое касается трейт-объектов. Вы не можете безопасно привести Box<dyn Trait> к *mut c_void, потому что за ним последуют лишние 8 байт метаданных. Для передачи в C нужно "раздеть" трейт-объект до конкретного типа или использовать сырые указатели и vtable вручную (что крайне сложно).

Таким образом, fat pointer — это мощный инструмент Rust для безопасности типов, но он несовместим с ABI (Application Binary Interface) языка C, где указатели всегда однозначны.