Программирование RustUnsafe и памятьИнженер по разработке на Rust

От чего зависит безопасность уничтожения значения через raw pointer, если его тип реализует Drop?

От чего зависит безопасность уничтожения значения через raw pointer, если его тип реализует Drop?

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

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

Безопасность зависит от того, что указатель адресует корректно выровненное, полностью инициализированное значение нужного типа, которое ещё не было уничтожено и находится под исключительным контролем вызывающего кода. Уничтожение должно произойти ровно один раз; после drop_in_place значение нельзя использовать как живое, а сама операция не освобождает память под объект.

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

Rust автоматически вызывает Drop при завершении владения значением, поэтому обычному безопасному коду не требуется вручную управлять временем уничтожения. Однако FFI, контейнеры с ручным размещением и низкоуровневые структуры данных иногда хранят только адрес объекта.

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

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

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

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

Важно различать уничтожение и освобождение. drop_in_place запускает деструктор, но не возвращает память аллокатору; освобождение выполняется отдельным владельцем или операцией, соответствующей способу выделения памяти.

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

Перед вызовом drop_in_place нужно подтвердить несколько связанных условий:

  • указатель ненулевой и корректно выровнен для T;
  • по нему находится полностью инициализированный экземпляр T;
  • экземпляр ещё жив и его деструктор не запускался;
  • указатель имеет корректный тип и, для динамически разм sized типов, необходимые метаданные;
  • текущий код действительно отвечает за уничтожение объекта и исключает другое одновременное или последующее уничтожение;
  • после операции память используется только в соответствии с её новым состоянием.

Ключевой инвариант владения — деструктор вызывается ровно один раз. Если объект был создан через Box::into_raw, drop_in_place уничтожит значение, но не саму аллокацию. После этого нельзя восстанавливать обычный Box<T> и позволять ему автоматически вызвать Drop ещё раз. Если нужно освободить аллокацию, это делают отдельно и согласованным с исходным способом выделения образом.

Минимальный пример ручного разделения этих операций:

use std::mem; use std::ptr; struct Resource; impl Drop for Resource { fn drop(&mut self) {} } let raw = Box::into_raw(Box::new(Resource)); unsafe { ptr::drop_in_place(raw); let owner = Box::from_raw(raw); mem::forget(owner); }

Здесь drop_in_place запускает Drop, а восстановленный Box временно возвращает владение аллокацией. mem::forget не даёт ему повторно вызвать деструктор; при уничтожении самого Box его аллокация освобождается. Такой шаблон требует, чтобы указатель действительно был получен из совместимого Box и не использовался другими владельцами.

Для памяти, выделенной C-библиотекой, нельзя безоговорочно применять Box::from_raw: Rust-аллокатор и C-аллокатор могут быть несовместимы. В этом случае Rust может вызвать деструктор значения, если контракт API это допускает, а освобождение сырой памяти должен выполнить предусмотренный C-механизм.

Если тип не имеет значимого деструктора, явный вызов drop_in_place обычно не нужен, но требования к валидности указателя для самой операции всё равно нельзя игнорировать. Для типов с вложенными полями компилятор вызывает соответствующее drop glue, поэтому ручной вызов деструктора родительского значения также уничтожает его управляемые поля.

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

C-контейнер хранит указатель на объект Rust и при удалении элемента вызывает зарегистрированный Rust-callback. Возможный ошибочный вариант — восстановить Box в callback, затем отдельно вызвать деструктор или позволить C освободить ту же память. Это создаёт риск двойного уничтожения или несовместимого освобождения.

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

Практическое решение — явно зафиксировать протокол владения: callback один раз вызывает drop_in_place, а сторона, выделившая память, отдельно и единожды освобождает аллокацию своим API. Если объект изначально создан Rust через Box, C не получает право освобождать его напрямую; callback или управляющий Rust-код отвечает и за уничтожение, и за корректное освобождение Rust-аллокации.

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

  1. Освобождает ли drop_in_place память?

Нет. Операция вызывает деструктор значения по адресу, но не деаллоцирует память. Поэтому для объекта в куче нужно отдельно определить, кто и каким аллокатором освобождает занимаемый блок.

  1. Можно ли после drop_in_place снова использовать тот же raw pointer?

Сам адрес можно хранить как адрес памяти, но он больше не указывает на живое значение прежнего типа. Разыменование, чтение или повторное уничтожение требуют нового корректного объекта, созданного в этой памяти с соблюдением правил инициализации и времени жизни.

  1. Достаточно ли уникальности указателя, если объект полностью инициализирован?

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