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

Что именно гарантирует компилятор Rust, когда разыменование raw pointer помещено в unsafe блок?

Что именно гарантирует компилятор Rust, когда разыменование raw pointer помещено в unsafe-блок?

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

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

unsafe-блок лишь разрешает компилятору принять операцию, которую он не может доказать безопасной. Он не проверяет, что raw pointer не равен null, указывает на живой объект правильного типа и выровнен; доказательство этих условий остаётся ответственностью разработчика. Если они нарушены, программа может иметь неопределённое поведение, несмотря на наличие unsafe-блока.

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

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

Поэтому язык отделяет проверяемый безопасный код от явно обозначенной зоны Unsafe Rust. Это не отключение всех проверок и не подтверждение корректности, а маркировка места, где разработчик принимает на себя доказательство дополнительных инвариантов.

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

Raw pointer может быть получен из FFI, адреса устройства или внутреннего низкоуровневого кода. Сам тип указателя не сообщает компилятору, указывает ли он на живой объект, доступный для чтения, и сохраняется ли необходимое выравнивание.

Даже проверка на null не устраняет остальные риски: указатель может быть висячим, указывать на неинициализированную память или нарушать правила доступа Rust. Ошибка может проявиться не в месте разыменования, а значительно позже, поскольку неопределённое поведение не обязано давать стабильный сбой.

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

Компилятор проверяет, что потенциально опасная операция находится в разрешённом unsafe-контексте, а обычные правила Rust продолжают действовать. Он не выполняет проверку адреса во время компиляции и не превращает raw pointer в безопасную ссылку автоматически.

Минимальный пример показывает разницу между разрешением операции и доказательством её корректности:

unsafe fn load(p: *const u32) -> u32 { *p } fn caller(p: *const u32) -> u32 { unsafe { load(p) } }

Вызов корректен только при выполнении контракта load: p должен быть ненулевым, указывать на живой и инициализированный u32, быть корректно выровнен и обеспечивать допустимый доступ на чтение. Код не проверяет эти условия; их должен гарантировать вызывающий код или безопасная обёртка.

unsafe не отменяет требования к алиасингу. Например, запись через raw pointer не должна конфликтовать с существующими активными доступами, если это нарушает модель памяти Rust. При работе с внешней памятью дополнительно нужно учитывать её владение, синхронизацию, время жизни и контракт внешнего API.

Практический компромисс таков: небольшой unsafe-фрагмент помещают внутрь безопасной абстракции, а её публичные условия делают проверяемыми. Если условия невозможно надёжно проверить, интерфейс обычно объявляют unsafe и явно документируют обязанности вызывающего кода.

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

Библиотека C возвращает указатель на внутренний объект и отдельную функцию его уничтожения. Разработчик помещает чтение полей этого объекта в unsafe-блок и считает проблему решённой. Однако C-код может уничтожить объект между двумя вызовами Rust, поэтому сам unsafe-блок не защищает от обращения к освобождённой памяти.

Рассматриваются варианты:

  • доверять документации C и оставить raw pointer в нескольких местах — просто, но легко нарушить время жизни;
  • передавать raw pointer через весь Rust-код — гибко, но инвариант становится трудно контролировать;
  • создать обёртку, которая владеет C-ресурсом, запрещает его уничтожение до завершения операций и выполняет все опасные обращения в одном месте — требует проектирования, зато локализует доказательство корректности;
  • немедленно копировать данные в Rust — безопаснее для последующего использования, но может быть дороже и не подходит для больших или изменяемых объектов.

Выбирается обёртка с контролируемым временем жизни, а для данных, которые должны пережить C-объект, выполняется копирование. В результате raw pointer не покидает внутреннего слоя, а безопасный интерфейс не позволяет пользоваться уже уничтоженным ресурсом.

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

1. Достаточно ли проверить raw pointer на null перед разыменованием?

Нет. Проверка на null устраняет только один класс ошибок. Указатель всё ещё может быть висячим, невыравненным, направленным на неинициализированную память или не иметь права на требуемый тип доступа. Безопасность определяется полным контрактом указателя, а не одним условием.

2. Делает ли объявление функции unsafe доказательство корректности её тела?

Нет. unsafe fn сообщает, что вызывающий код обязан выполнить документированные условия, но не проверяет реализацию. Внутри функции всё равно нужно корректно использовать unsafe-операции и поддерживать инварианты; полезно ограничивать их отдельными unsafe-блоками, чтобы место ручного доказательства было явным.

3. Можно ли после разыменования raw pointer передать полученную ссылку в безопасный код?

Можно только после доказательства более сильных условий: объект жив, корректно выровнен и инициализирован, ссылка действительно указывает на нужный тип, а её время жизни и правила совместного доступа соблюдены. Создание ссылки не исправляет плохой указатель — оно переносит его предполагаемую корректность в гарантии, на которые затем будет полагаться безопасный код. Если доказательство неверно, неопределённое поведение может возникнуть уже из-за дальнейшего использования ссылки.