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

При поэлементном заполнении массива через MaybeUninit какой инвариант позволяет безопасно превратить его в ...

При поэлементном заполнении массива через MaybeUninit<T> какой инвариант позволяет безопасно превратить его в массив T?

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

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

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

MaybeUninit<T> разрешает временно хранить память без значения T, но не отменяет требований к корректности самого T. Преобразование в массив T безопасно только после доказательства полноты инициализации всех элементов.

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

Обычный объект Rust должен всегда содержать корректное значение своего типа. Это важно не только для чтения: компилятор и стандартная библиотека могут полагаться на инварианты типа, например на допустимость всех значений bool, корректность ссылок или необходимость вызвать Drop.

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

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

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

Особенно опасен частично заполненный массив. Например, если построение прервалось после записи трёх элементов из пяти, преобразование всего буфера в [T; 5] некорректно. Если T владеет ресурсом, при обработке ошибки нужно уничтожить только уже записанные элементы, а не весь буфер.

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

Главный инвариант состоит из двух частей:

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

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

Минимальный пример для массива чисел:

use std::mem::MaybeUninit; fn build<const N: usize>() -> [u32; N] { let mut buffer = MaybeUninit::<[u32; N]>::uninit(); let ptr = buffer.as_mut_ptr() as *mut u32; for i in 0..N { unsafe { ptr.add(i).write(i as u32); } } unsafe { buffer.assume_init() } }

В цикле каждая позиция записывается ровно один раз, поэтому после завершения цикла массив полностью инициализирован. write не читает старое содержимое и не пытается уничтожить значение, которого ещё нет.

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

Повторная запись в уже инициализированную позицию допустима только как осознанная замена значения с корректным управлением старым объектом. Простая запись через MaybeUninit::write безопасна для неинициализированного слота, но не должна использоваться как способ незаметно заменить живое значение, если это приводит к утечке ресурса.

MaybeUninit также не делает операции с raw pointers безопасными автоматически. Остаются требования к выравниванию, границам выделенного объекта, уникальности изменяемого доступа и корректности типа, которые должен обеспечить unsafe-код.

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

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

Первый вариант — сначала создать массив значениями по умолчанию, а затем заменять элементы. Он проще с точки зрения времени жизни, но требует существования корректного Default, может выполнять лишнюю работу и временно создавать ненужные ресурсы.

Второй вариант — использовать MaybeUninit и заполнять слоты по одному. Он эффективнее и не требует фиктивных значений, но обязан учитывать частичное заполнение: guard должен уничтожить уже созданные элементы при ошибке или панике.

Выбирается второй вариант, если создание T дорого, Default отсутствует или требуется прямое заполнение буфера. После успешного заполнения всех позиций guard отключается, и только тогда вызывается assume_init; при ошибке уничтожается ровно инициализированная часть. Это сохраняет отсутствие утечек и не допускает Drop для несуществующих значений.

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

  1. Можно ли вызвать assume_init, если часть элементов массива никогда не будет использоваться?

    Нет. assume_init относится ко всему объекту T, а не к фактическим будущим обращениям к его отдельным элементам. Для [T; N] все N позиций должны содержать корректные значения, даже если программа планирует читать только несколько из них.

  2. Что произойдёт, если один элемент записать дважды через MaybeUninit::write?

    Для слота, который всё ещё считается неинициализированным, запись корректна. Но если первая запись уже создала значение типа T, повторная запись поверх него без уничтожения старого значения может привести к утечке ресурса: прежний T не получит Drop. Для типов без значимого уничтожения это может не иметь практического эффекта, но общий контракт для произвольного T всё равно нарушен.

  3. Достаточно ли считать элементы инициализированными после успешного вызова конструктора?

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