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

Сценарий: при сдвиге элементов буфера исходный диапазон частично перекрывается с целевым. Какую семантику к...

Сценарий: при сдвиге элементов буфера исходный диапазон частично перекрывается с целевым. Какую семантику копирования должен выбрать Rust-код через raw pointers?

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

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

Нужно выбрать перекрывающееся копирование — std::ptr::copy, семантически аналогичное memmove. std::ptr::copy_nonoverlapping здесь применять нельзя: при пересечении исходного и целевого диапазонов это нарушает его предусловие и приводит к неопределённому поведению.

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

Низкоуровневые операции копирования традиционно разделяются на две семантики: memcpy предполагает отсутствие пересечения диапазонов, а memmove корректно обрабатывает перекрытие. Rust сохраняет это различие, чтобы дать системному коду возможность явно выбрать нужные гарантии и не платить за поддержку перекрытия там, где она не нужна.

Такие операции нужны внутри реализаций контейнеров, аллокаторов, сериализаторов и обработчиков буферов. Безопасные методы срезов обычно предпочтительнее, но raw pointers позволяют управлять памятью там, где стандартных абстракций недостаточно.

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

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

Для copy_nonoverlapping это не просто логическая ошибка, а нарушение контракта unsafe-операции. Возможное последствие — неопределённое поведение, поэтому нельзя рассчитывать на конкретный результат, даже если на тестовой сборке сдвиг иногда выглядит правильным.

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

ptr::copy допускает пересечение диапазонов и выбирает корректную стратегию копирования, аналогичную memmove. При этом он не проверяет безопасность автоматически: вызывающий код обязан доказать, что исходный диапазон доступен для чтения, целевой — для записи, указатели корректно выровнены для T, а вычисленный размер не выходит за границы соответствующего объекта.

Минимальный пример сдвига байтов влево:

use std::ptr; fn shift_left(buf: &mut [u8], by: usize) { assert!(by <= buf.len()); let count = buf.len() - by; unsafe { ptr::copy(buf.as_ptr().add(by), buf.as_mut_ptr(), count); } }

Здесь исходный диапазон начинается внутри buf, а целевой — с его начала, поэтому диапазоны пересекаются. Для независимых диапазонов лучше применять copy_nonoverlapping: он точнее выражает инвариант и может быть эффективнее, но требует доказательства отсутствия пересечения.

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

Если задачу можно выразить безопасной операцией над срезом, например корректным rotate или перемещением элементов средствами контейнера, такой вариант предпочтительнее. Он переносит проверку диапазонов, инициализации и алиасинга из unsafe-кода в проверенную библиотечную абстракцию.

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

В собственной реализации компактного буфера парсер удаляет уже обработанный префикс и сдвигает оставшиеся байты к началу массива. Рассматривались три варианта: copy_nonoverlapping, временный буфер и ptr::copy.

copy_nonoverlapping был отвергнут из-за пересечения диапазонов. Временный буфер был проще концептуально, но добавлял выделение памяти и дополнительное копирование. Выбран ptr::copy, поскольку диапазоны находятся в одной корректно выделенной области, их размеры вычисляются до unsafe-блока, а тип элементов — u8, не имеющий проблем владения и уничтожения.

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

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

  1. Почему нельзя просто скопировать перекрывающиеся диапазоны в обратном направлении?

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

  2. Чем опасен ptr::copy для типа с Drop?

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

  3. Достаточно ли того, что исходный и целевой диапазоны находятся внутри одного среза?

    Нет. Нужно также доказать корректность арифметики указателей, отсутствие выхода за объект, доступность исходных элементов для чтения, целевого диапазона для записи и соответствие выравнивания типу. Кроме того, нельзя использовать ptr::copy для обхода правил алиасинга: наличие raw pointers само по себе не разрешает одновременно несовместимые активные доступы к значению.