У блока unsafe в Rust репутация двойственная. Одни разработчики запрещают его атрибутом #![forbid(unsafe_code)] на уровне крейта и считают плохим тоном. Другие при первом конфликте заимствований оборачивают проблемные строки в unsafe и едут дальше с зелёной сборкой. Оба подхода обходятся дорого: первый закрывает легитимные задачи, второй превращает ошибку компиляции в неопределённое поведение, которое всплывёт через полгода у одного клиента из тысячи. Стабильный компилятор дорос до 1.99.1, а споры вокруг unsafe идут с первых релизов. Разберём, что открывает этот блок, в каких инженерных сценариях он оправдан и где начинается заглушка на сообщениях компилятора.

Пять операций которые открывает блок unsafe и границы полномочий компилятора

Первый факт, меняющий отношение к теме: unsafe не выключает проверку заимствований, не отменяет типы и не переводит компилятор в режим "всё можно". Проверки типов, времени жизни и владения работают как работали. Блок снимает запрет ровно с пяти операций, перечисленных в Rustonomicon:

  1. разыменование сырых указателей const T и mut T;
  2. вызов unsafe функций, включая внешние C функции через FFI;
  3. реализация unsafe типажей вроде Send и Sync;
  4. чтение и запись изменяемых статик static mut;
  5. доступ к полям юнионов.

Каждая строка списка переносит ответственность с компилятора на человека, как кредитка без лимита: каждый платёж вы подтверждаете лично. Инструмент тот же, цена ошибки другая.

fn main() {
    let value: i32 = 42;
    let ptr = &value as *const i32;
    let deref = unsafe { *ptr };
    println!("{deref}");
}

За пределами блока указатель можно передавать, копировать, сравнивать, но читать нельзя. Подставьте адрес чужой области памяти: сборка пройдёт успешно, сбой случится в работе и не обязательно сразу.

FFI внешние библиотеки и регистры железа как легальная зона unsafe

Самая массовая причина unsafe в реальных проектах: связь с кодом на C. SQLite, libpq, zlib, OpenSSL переписывать на Rust никто не станет, а использовать из Rust нужно. Прямое объявление выглядит так:

use std::os::raw::{c_char, c_int};

#[link(name = "sqlite3")]
extern "C" {
    fn sqlite3_open(filename: *const c_char, db: *mut *mut sqlite3) -> c_int;
    fn sqlite3_close(db: *mut sqlite3) -> c_int;
}

Вызов sqlite3_open без unsafe невозможен, и это правильное решение: компилятор не знает контракта чужой функции. Сколько раз вызвать sqlite3_close, кто владеет буфером, что означает код возврата, язык проверить не в состоянии. bindgen генерирует объявления из заголовков автоматически, но код остаётся unsafe по той же причине. Дальше ручная работа: Drop, закрывающий соединение ровно один раз, CString с нулевым байтом в конце, проверка результата на SQLITE_OK. Пользователь обёртки не пишет ни одного unsafe блока.

Родственный сценарий: микроконтроллеры. Периферия STM32F407 отображена в память, запись по адресу 0x40020000 меняет режим пина GPIOA. Формулировка на Rust:

const GPIOA_MODER: *mut u32 = 0x4002_0000 as *mut u32;

pub fn set_pin_mode(pin: u8, mode: u32) {
    unsafe {
        let moder = core::ptr::read_volatile(GPIOA_MODER);
        let mask = 0b11 << (pin * 2);
        core::ptr::write_volatile(GPIOA_MODER, (moder & !mask) | (mode << (pin * 2)));
    }
}

Volatile нужен по причине: компилятор имеет право закешировать значение и выбросить повторное чтение, а чтение регистра меняет состояние железа. read_volatile и write_volatile запрещают такие оптимизации. Платформа thumbv7em-none-eabihf собирается без стандартной библиотеки, в no_std мире сырые указатели это единственный способ поговорить с устройством. Крейты cortex-m прячут адреса за макросами и регистровыми типами, внутри те же указатели. Сюда же относятся упакованные структуры repr(packed), где поле лежит на невыровненном адресе и создание ссылки некорректно, поэтому с Rust 1.82 есть синтаксис &raw const и &raw mut, а чтение идёт через read_unaligned.

Внутренности стандартной библиотеки на примере split_at_mut и MaybeUninit

Второй законный сценарий: структуры данных и производительность. Стандартная библиотека сама полна unsafe блоков, и это конструкция, а не дыра. Хрестоматийный пример: split_at_mut. Безопасной реализации не существует, потому что две изменяемые ссылки на одну память запрещены, а факт непересечения система типов выразить не умеет. Ручная версия короче, чем объяснение её необходимости:

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();
    assert!(mid <= len);
    // SAFETY: mid проверен assert, длины не превышают исходную,
    // указатели двух половин не пересекаются
    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Инварианты видны руками: mid не больше длины, сумма длин равна исходной, срезы не пересекаются, всё закрывает одна строка с assert. Та же техника живёт в std: Vec::reserve выделяет память через MaybeUninit, тип из Rust 1.36.0, который разрешает владеть неинициализированной областью и читать её после явной записи. Без него рост вектора строился бы на трансмутах и честном слове.

Для горячих циклов есть обход проверки границ:

let value = unsafe { *bytes.get_unchecked(idx) };

Обход экономит несколько машинных команд, на больших буферах заметно. Цена: ошиблись с индексом, и вместо паники с точным сообщением программа молча прочитает соседнюю память. Рабочий порядок: сначала безопасный код и профилировщик, потом unsafe там, где цифры подтвердили узкое место.

Антипаттерн заглушки проверок компилятора небрежным unsafe блоком

Теперь обратная сторона. История, знакомая почти каждому ревьюеру: разработчик получает ошибку E0502, обижается на компилятор, оборачивает проблемные строки в unsafe. Ошибка исчезает из консоли, тесты зелёные, код уезжает в основную ветку. Через месяц сбой ловит один клиент из десяти тысяч.

let mut data = vec![1, 2, 3];
let first = unsafe { &mut *data.as_mut_ptr() };
data.push(4);
*first += 10;

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

Признак антипаттерна один: unsafe нужен не для задачи, а для борьбы с сообщением об ошибке. Простая проверка на честность: попробуйте вслух объяснить коллеге, какие условия обязаны выполняться, чтобы блок оставался корректным. Нет объяснения, нет и права на unsafe. Почти всегда за ошибкой E0502 или E0499 стоит структурная проблема: лишнее состояние, неверная композиция модулей, два изменяемых взгляда на одну сущность. Компилятор не мешает писать программу, он описывает её настоящее устройство. Заглушить описание значит оставить проблему в коде навсегда.

Инварианты которые компилятор доверяет автору unsafe блока

Слово инвариант звучит в каждом обсуждении, зафиксируем смысл. Инвариант это утверждение о состоянии памяти, истинное в каждой точке программы. В безопасном Rust компилятор выводит и проверяет такие утверждения сам. В unsafe коде они превращаются в устную гарантию, которую машина принимает на веру.

Базовый набор обязанностей выглядит так: алиасинг, две изменяемые ссылки на одну область не существуют одновременно; инициализация, чтение только там, где значение записано; строки, содержимое &str всегда корректный UTF-8; потоки, доступ к общей памяти из разных потоков требует синхронизации; выравнивание, ссылка на u64 лежит по адресу, кратному восьми.

Нарушение каждого пункта не "падение с ошибкой", а неопределённое поведение: компилятор считает такие ситуации невозможными и строит оптимизации с опорой на это. Пример ошибки на ровном месте:

let bytes = [0xFF, 0xFE];
let s: &str = unsafe { std::str::from_utf8_unchecked(&bytes) };
println!("длина {}", s.len());

from_utf8_unchecked означает: байты проверены, отвечаю. Байты не проверены, и любая функция над строкой работает с состоянием, которого не бывает. Именно поэтому Rust 1.85 с редакцией 2024 сделал линт unsafe_op_in_unsafe_fn предупреждением по умолчанию: небезопасная операция внутри unsafe функции обязана получить собственный блок и стать событием на ревью. Там же ужесточили static mut: линт static_mut_refs запрещает ссылки на изменяемую статику, остаётся работа через &raw mut, потому что глобальная изменяемая память и потоки сочетаются хуже всего.

Инструменты проверки Miri Loom санитайзеры и строгие линты Clippy

Держать инварианты на честном слове наивно, экосистема предлагает детекторы, ловящие нарушения до продакшена. Главный инструмент: Miri, интерпретатор поверх MIR. Подключение:

rustup +nightly component add miri
cargo +nightly miri test
cargo +nightly miri run

Miri перехватывает обращения к памяти и сообщает о выходе за границы буфера, использовании после освобождения, чтении неинициализированных данных, невыровненных доступах, о нарушении инвариантов базовых типов, например bool со значением, отличным от 0 и 1, о гонках данных. Поверх работают экспериментальные модели алиасинга Stacked Borrows и Tree Borrows, проверяющие пересечения ссылок строже компилятора. Плата за точность: скорость ниже на порядок, часть системных вызовов эмулируется, поэтому Miri ставят в CI на тесты.

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

RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu

AddressSanitizer ловит порчу кучи, TSan гонки. Clippy добавляет дисциплину: линт undocumented_unsafe_blocks требует комментарий SAFETY перед каждым блоком, атрибут #![forbid(unsafe_code)] закрывает модуль или крейт от появления unsafe совсем. В зрелых командах так и строят структуру: один крейт с низкоуровневым ядром, остальные с явным запретом.

Сокращение unsafe поверхности и безопасная обёртка над сырой памятью

Практика зрелых библиотек сводится к трём правилам: блоки минимальные, внутри только сама операция; каждый блок с комментарием SAFETY, ревьюер проверяет список обещаний; публичный API без unsafe, все проверки до входа в блок. Обёртка над сырой аллокацией показывает принцип в сборе:

use std::alloc::{alloc, dealloc, Layout};
use std::ptr::NonNull;

pub struct OwnedBuffer {
    ptr: NonNull<u8>,
    len: usize,
}

impl OwnedBuffer {
    pub fn new(len: usize) -> Self {
        let layout = Layout::array::<u8>(len).unwrap();
        unsafe {
            let ptr = alloc(layout);
            assert!(!ptr.is_null());
            // SAFETY: ненулевой адрес проверен assert выше
            Self { ptr: NonNull::new_unchecked(ptr), len }
        }
    }

    pub fn as_slice(&self) -> &[u8] {
        // SAFETY: ptr и len приходят из живой аллокации
        unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
    }
}

impl Drop for OwnedBuffer {
    fn drop(&mut self) {
        // SAFETY: layout совпадает с использованным при alloc
        unsafe {
            dealloc(self.ptr.as_ptr(), Layout::array::<u8>(self.len).unwrap());
        }
    }
}

Снаружи ни одного unsafe вызова: new проверяет аллокацию, as_slice отдаёт срез корректной длины, Drop освобождает память ровно один раз и тем же layout. Блоки крошечные, каждый держит один инвариант. Если завтра понадобится изменяемый доступ, точка изменения одна. Так unsafe превращается из источника риска в изолированное ядро с документированными границами.

Unsafe в Rust не преступление и не привилегия, а инструмент с дорогой эксплуатацией. Легальные сценарии конечны: FFI и регистры, ядра структур данных, узкие места после профилировщика. В остальных случаях сначала ищут безопасную конструкцию, цена ошибки измеряется не часами отладки, а тихой порчей памяти в продакшене. По аудитам экосистемы unsafe встречается примерно в трети крейтов, и почти весь он в нижних слоях, огороженных безопасными API. Стратегия: концентрировать unsafe в нескольких модулях, гонять их под Miri и Loom в CI, требовать комментарии SAFETY, держать ядро маленьким. Тогда низкоуровневые проценты кода приносят надёжность, ради которой проект и выбрал Rust, а не сжирают её.