Программирование RustUnsafe и памятьРазработчик Rust системного или FFI-кода

Как Rust должен обращаться с указателем на непрозрачный тип C, если его layout не опубликован?

Как Rust должен обращаться с указателем на непрозрачный тип C, если его layout не опубликован?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Rust должен рассматривать такой объект как непрозрачный дескриптор: хранить и передавать только указатель, не создавая значение типа по значению и не разыменовывая его. Layout, выравнивание полей, владение и допустимые операции определяются контрактом C-API, а не объявлением маркерного типа в Rust.

Маркерная структура нужна лишь для типобезопасности указателей и запрета случайной подмены разных C-объектов. Перед использованием указателя нужно отдельно проверить ненулевое значение, срок жизни, владение и требования конкретной функции C.

Исторический контекст

В C библиотека часто скрывает определение структуры, публикуя только её имя. Это позволяет менять внутренние поля без перекомпиляции клиентского кода и сохранять стабильный ABI, пока остаются неизменными функции работы с объектом.

Rust не может безопасно восстановить неизвестный layout автоматически. Поэтому FFI-обёртка должна сохранить границу абстракции C, а не пытаться описать скрытую структуру приблизительно.

Постановка проблемы

Если объявить непрозрачный C-объект обычной Rust-структурой с предполагаемыми полями, компилятор может разрешить операции, которые не соответствуют реальному объекту. Чтение поля, создание значения на стеке или передача объекта по значению способны привести к неверному размеру, выравниванию, ABI или неопределённому поведению.

Даже корректный указатель не означает права на разыменование. Библиотека может требовать, чтобы объект создавался только специальной функцией, уничтожался только парной функцией, использовался только из определённого потока или не применялся после передачи владения.

Подробное решение

Обычно объявляют непрозрачный маркерный тип и используют его только через указатели:

use std::ptr::NonNull; #[repr(C)] pub struct CWidget { _private: [u8; 0] } unsafe extern "C" { fn widget_create() -> *mut CWidget; fn widget_destroy(p: *mut CWidget); } pub struct Widget(NonNull<CWidget>); impl Widget { pub fn new() -> Option<Self> { NonNull::new(unsafe { widget_create() }).map(Self) } } impl Drop for Widget { fn drop(&mut self) { unsafe { widget_destroy(self.0.as_ptr()) } } }

Поле нулевого размера не описывает настоящую структуру C и не даёт права обращаться к её памяти. Такой тип нельзя безопасно создавать по значению или разыменовывать; его назначение — сделать сигнатуры различимыми и не смешивать указатели на разные непрозрачные ресурсы.

NonNull<CWidget> удобно применять после проверки результата C-функции: он выражает инвариант ненулевого указателя, но сам по себе не доказывает валидность объекта. Если C допускает NULL как штатное состояние, следует оставить тип Option<*mut CWidget> либо явно обработать это состояние.

Владение нельзя выводить из типа указателя. Нужно установить по документации API, кто вызывает функцию уничтожения, можно ли копировать указатель, допускается ли передача его между потоками и сохраняет ли библиотека указатель после возврата. Без доказанного владения нельзя реализовывать Drop, а без доказанной потокобезопасности — Send или Sync.

Если C предоставляет функции доступа к полям, Rust должен вызывать их вместо попытки узнать layout. Если библиотека гарантирует совместимость с конкретной открытой структурой, её можно описать отдельно с соответствующим repr(C), но это уже другой контракт, не непрозрачный тип.

Ситуация из практики

Графическая библиотека возвращает указатель на внутренний объект контекста. Его поля меняются между версиями, а уничтожение разрешено только через context_destroy.

Рассматривались два варианта:

  • Описать структуру по текущему заголовочному файлу. Это позволило бы обращаться к полям напрямую, но сломалось бы при изменении layout и создало бы риск неверного ABI.
  • Хранить *mut CContext без обёртки. Это сохраняет layout-агностичность, но не фиксирует проверку NULL, усложняет управление временем жизни и допускает смешение указателей разных ресурсов.
  • Ввести непрозрачный маркерный тип и безопасную Rust-обёртку с NonNull, конструктором и Drop. Этот вариант требует точно описать контракт уничтожения, зато локализует unsafe и запрещает доступ к скрытым полям.

Выбирается третий вариант. В результате обновление внутренней структуры библиотеки не требует изменения Rust-кода, а нарушение контракта сосредоточено в небольшом FFI-слое.

Что кандидаты часто упускают

1. Можно ли передать непрозрачный тип C из Rust по значению, если размер структуры сейчас известен из заголовка?

Нет, если API не обещает такой способ передачи. Для непрозрачного типа Rust не должен создавать, копировать или перемещать значение: фактический layout может отличаться, а C-функция может ожидать указатель на объект, размещённый самой библиотекой. Передача по значению допустима только для явно открытого типа с документированным ABI и корректным repr(C).

2. Достаточно ли repr(C) у маркерной структуры, чтобы доказать корректность разыменования указателя?

Нет. repr(C) задаёт представление самой Rust-структуры, но не раскрывает неизвестную структуру C и не подтверждает, что указатель указывает на живой объект нужного типа. Для разыменования потребовались бы отдельные доказательства существования объекта, корректного выравнивания, инициализации, срока жизни и разрешённого доступа — именно поэтому непрозрачный указатель обычно не разыменовывают в Rust.

3. Как определить, должна ли Rust-обёртка реализовывать Drop для непрозрачного указателя?

Только по контракту API. Если объект освобождается функцией C и Rust действительно владеет результатом создания, Drop может вызвать эту функцию ровно один раз. Если библиотека возвращает заимствованный указатель, его нельзя освобождать; если уничтожение выполняет вызывающая сторона C, Rust-обёртка не должна притворяться владельцем. Неверный выбор приводит либо к утечке, либо к двойному освобождению.