Ключевое заблуждение новичков — думать, что unsafe "отключает borrow checker" или проверки типов. Это не так: borrow checker, проверка времён жизни и вся система типов работают внутри unsafe-блока точно так же, как и снаружи. Ключевое слово unsafe открывает доступ ровно к пяти операциям, которые компилятор не может статически доказать безопасными:
*const T / *mut T)unsafe fn, включая FFI-функцииstatic mut)unsafe traitunionfn main() {
let x = 5;
let raw: *const i32 = &x as *const i32; // создание raw pointer — safe
unsafe {
println!("{}", *raw); // разыменование — требует unsafe
}
}
Всё остальное — вызов обычных функций, создание (но не разыменование) сырых указателей, работа с generics и трейтами — остаётся safe-кодом внутри unsafe-блока, и компилятор продолжает проверять заимствования и типы.
Undefined Behavior (UB) — это ситуация, для которой спецификация языка не определяет поведение вообще. Компилятор вправе предполагать, что UB никогда не происходит, и агрессивно оптимизировать код на основе этого допущения — итоговое поведение может быть непредсказуемым: от "случайно сработало" до segfault, повреждения памяти или удаления кода, который выглядел рабочим.
fn dangling_example() -> *const i32 {
let x = 42;
&x as *const i32 // x умирает в конце функции
} // возвращаем dangling pointer — сам факт возврата ещё не UB
fn main() {
let p = dangling_example();
unsafe {
println!("{}", *p); // UB: разыменование dangling pointer
}
}
Классические источники UB в Rust:
&mut T на один объект одновременно (нарушение aliasing-модели)bool со значением байта, отличным от 0/1)slice[i], который паникует безопасно)#[repr] или инвариантов, которые компилятор использует для оптимизаций (например, NonNull, у которого 0 — невалидное значение)Сырые указатели в отличие от ссылок (&T/&mut T) не проверяются borrow checker'ом: их можно копировать, приводить друг к другу, они могут быть null, dangling, невыровненными — и всё это компилируется без единого предупреждения, пока вы их не разыменуете.
fn main() {
let mut value = 10;
let r1 = &mut value as *mut i32;
let r2 = r1; // копирование указателя — safe, это просто число
unsafe {
*r1 += 1;
*r2 += 1; // два "алиасящих" mut-указателя — компилятор это разрешает,
// но программист обязан гарантировать отсутствие data race
println!("{value}"); // 12
}
let null_ptr: *const i32 = std::ptr::null();
println!("{}", null_ptr.is_null()); // true, проверка — safe
}
| Свойство | &T / &mut T | *const T / *mut T |
|---|---|---|
| Проверка borrow checker'ом | да, статически | нет |
| Гарантия ненулевого значения | да | нет, может быть null |
| Гарантия валидности (не dangling) | да, через lifetime | нет, ответственность программиста |
| Разыменование | без unsafe | только внутри unsafe |
| Aliasing (несколько mut одновременно) | запрещено компилятором | разрешено, но UB при одновременном использовании |
Каждая unsafe fn — это неявный контракт: вызывающий код обязан обеспечить набор условий (preconditions), которые компилятор проверить не может, но которые функция считает истинными. Хорошая практика — документировать их в секции /// # Safety.
/// Читает значение по указателю `ptr`.
///
/// # Safety
///
/// Вызывающий обязан гарантировать:
/// - `ptr` указывает на инициализированное значение типа `T`;
/// - `ptr` корректно выровнен (aligned) под `T`;
/// - память по `ptr` валидна как минимум на время вызова;
/// - никакой другой поток не пишет по этому адресу одновременно.
unsafe fn read_unchecked<T>(ptr: *const T) -> T {
// SAFETY: инварианты гарантирует вызывающий, см. doc выше
unsafe { ptr.read() }
}
Комментарий // SAFETY: ... непосредственно перед unsafe-операцией — общепринятая конвенция в экосистеме (её требует clippy-lint undocumented_unsafe_blocks и стандартная библиотека сама следует этому правилу): он объясняет, почему именно в этом месте вызова инварианты выполнены.
Идиоматичный Rust-паттерн — спрятать unsafe-код за safe API, который по построению не может нарушить инварианты. Классический пример из стандартной библиотеки — split_at_mut: borrow checker не может доказать, что две непересекающиеся части одного слайса можно одновременно заимствовать как &mut, поэтому внутри используется unsafe, а наружу торчит безопасная сигнатура.
fn split_at_mut_demo<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
assert!(mid <= len);
// SAFETY: [0, mid) и [mid, len) не пересекаются,
// ptr валиден на len элементов по инварианту &mut [T]
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
Снаружи функция полностью safe: её нельзя вызвать так, чтобы получить UB, потому что все опасные допущения проверены внутри (границы, отсутствие пересечения). Это и есть суть "zero-cost", безопасной абстракции — работа компилятора и рантайм-проверок сделана один раз, в одном месте.
Любой вызов внешней функции — потенциальный источник UB, поскольку компилятор не может проверить инварианты чужого кода. Блок extern "C" объявляет сигнатуры, а сам вызов всегда требует unsafe.
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
#[unsafe(no_mangle)]
pub extern "C" fn rust_double(x: i32) -> i32 {
x * 2 // экспорт функции с C-совместимым ABI, вызывается safe-кодом на стороне C
}
fn main() {
let result = unsafe { abs(-5) };
assert_eq!(result, 5);
}
Ключевые нюансы FFI: типы должны быть #[repr(C)] для предсказуемой раскладки, паника не должна пересекать границу FFI (в C нет механизма unwinding — используйте catch_unwind или extern "C-unwind"), а владение памятью между Rust и C должно быть чётко определено (кто аллоцирует — тот и освобождает).
Изменяемые статические переменные — ещё один источник потенциальных data race, поэтому любой доступ к ним требует unsafe. С Rust 2024 edition сам факт объявления static mut уже помечается как небезопасный паттерн, а получение ссылки на него — отдельная unsafe-операция.
static mut COUNTER: u32 = 0;
fn increment() {
// SAFETY: вызывается только из одного потока в этом примере;
// в многопоточном коде здесь нужен AtomicU32, а не static mut
unsafe {
COUNTER += 1;
}
}
На практике static mut почти всегда стоит заменить на атомики (AtomicU32, AtomicUsize) или Mutex/OnceLock — они дают ту же функциональность без ручного unsafe и без риска data race.
unsafe trait используется, когда сам факт реализации трейта несёт инвариант, который компилятор проверить не может — классический пример: Send и Sync для типа с сырыми указателями внутри.
struct SharedBuffer {
ptr: *mut u8,
len: usize,
}
// SAFETY: доступ к ptr синхронизируется извне мьютексом,
// поэтому одновременная передача между потоками безопасна
unsafe impl Send for SharedBuffer {}
unsafe impl Sync for SharedBuffer {}
Miri — это MIR-интерпретатор, входящий в nightly-инструментарий Rust, который выполняет программу не как нативный код, а пошагово интерпретируя MIR, отслеживая при этом состояние памяти, инициализацию, выравнивание и aliasing-модель (Stacked/Tree Borrows). Многие виды UB, которые незаметны при обычном запуске (потому что "просто повезло"), Miri обнаруживает детерминированно.
$ rustup +nightly component add miri
$ cargo +nightly miri test
$ cargo +nightly miri run
fn main() {
let mut data = [1, 2, 3];
let ptr = data.as_mut_ptr();
unsafe {
let out_of_bounds = ptr.add(10);
*out_of_bounds = 42; // UB: запись за границами массива
// нативный запуск может "случайно сработать",
// Miri гарантированно сообщит об ошибке
}
}
Miri находит: чтение неинициализированной памяти, use-after-free, выход за границы через сырые указатели, нарушение aliasing-правил для &mut, утечки памяти, некорректное выравнивание. Он значительно медленнее нативного запуска (интерпретация MIR), поэтому применяется в тестах, а не в продакшене.
cargo miri test в CI — это дешёвый способ поймать UB до того, как он проявится непредсказуемым багом у пользователя на другой платформе или версии компилятора.