Программирование RustUnsafe и памятьИнженер по системному программированию на Rust

Представьте вызов C функции, которая записывает результат по переданному указателю. Как Rust должен предста...

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

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

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

Для выходного параметра следует использовать MaybeUninit<T>, передав C указатель, полученный через as_mut_ptr(). После вызова значение можно прочитать только при доказанном условии, что C-функция полностью и корректно записала объект типа T; тогда применяется assume_init().

Нельзя заранее создавать обычную переменную T «для записи поверх неё»: это утверждает, что значение уже инициализировано, хотя C ещё ничего не записал.

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

При взаимодействии с C распространён шаблон out-параметра: вызывающая сторона выделяет место, а вызываемая функция заполняет его. В Rust обычная переменная имеет строгий инвариант инициализации, поэтому нельзя безопасно представить такое место как уже существующее значение T.

MaybeUninit<T> отделяет хранилище нужного размера и выравнивания от самого инициализированного значения. Это позволяет явно перенести ответственность за момент и корректность инициализации на код, который взаимодействует с внешней функцией.

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

Если передать C указатель на неинициализированную память, используя обычный &mut T, Rust-код ложно сообщает компилятору, что корректный T уже существует. Даже если ссылка немедленно преобразуется в raw pointer, исходное создание ссылки могло нарушить инвариант типа.

Обратная ошибка также опасна: вызов assume_init() до полной записи превращает неинициализированные байты в значение T. Это неопределённое поведение; для типов с нетривиальным уничтожением последующий drop может дополнительно обратиться с некорректным значением.

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

MaybeUninit<T> предоставляет неинициализированное хранилище, но не считается значением T. Его as_mut_ptr() можно передать внешней функции как *mut T, не создавая ссылку на ещё несуществующий объект.

use std::mem::MaybeUninit; unsafe extern "C" { fn c_make(out: *mut u32) -> bool; } fn make() -> Option<u32> { let mut slot = MaybeUninit::<u32>::uninit(); let success = unsafe { c_make(slot.as_mut_ptr()) }; if success { Some(unsafe { slot.assume_init() }) } else { None } }

В этом примере безопасность assume_init() основана не на самом типе bool, а на контракте C-функции: при true она должна записать полностью корректный u32 в out, не выходя за границы и не сохраняя указатель после завершения вызова. Этот контракт должен быть проверен разработчиком или документирован внешним API.

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

MaybeUninit не решает вопросы владения, времени жизни, потоковой безопасности или корректности ABI. Он решает только представление хранилища до инициализации; остальные гарантии остаются частью FFI-контракта.

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

Библиотека C возвращает статус операции и принимает указатель на структуру результата. При ошибке она ничего не записывает, а при успехе полностью заполняет структуру.

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

Выбранное решение — MaybeUninit<ResultStruct> с repr(C) у структуры. Указатель передаётся только на время вызова, а assume_init() выполняется исключительно после успешного статуса. Такой подход сохраняет инвариант инициализации Rust, явно фиксирует границу доверия к C и не требует лишней предварительной инициализации.

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

  1. Достаточно ли проверить только код успешного возврата C-функции?

    Нет. Нужно знать точный контракт: означает ли успешный статус полную инициализацию всех байтов и полей, допускает ли функция нулевой указатель, может ли она записать только часть результата. Если контракт не гарантирует полной инициализации, assume_init() применять нельзя.

  2. Можно ли использовать MaybeUninit<T> для типа с деструктором, если C заполняет его побайтно?

    Не автоматически. Произвольная побайтовая запись не гарантирует создание корректного значения Rust-типа: у него могут быть ограничения на допустимые значения, владение и внутренние ссылки. Такой способ допустим только при тщательно определённом представлении и контракте; для обычных Rust-типов безопаснее использовать FFI-совместимые структуры из простых полей.

  3. Что произойдёт, если C сохранит переданный указатель после возврата?

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