Глава 28: Unsafe Rust

Содержание: Введение в Unsafe Rust Когда использовать unsafe Работа с сырой памятью: *const и *mut FFI: взаимодействие с C Выравнивание памяти (alignment) и низкоуровневые детали Проверка безопасности вручную Примеры: вызов C-функции Упражнение: Связать Rust с C-библиотекой Заключение

Добро пожаловать в главу 28 нашего курса по Rust — глубокое погружение в Unsafe Rust. В этой лекции мы подробно разберем, что такое unsafe-код, зачем он нужен, как с ним работать и какие подводные камни могут вас поджидать. Мы охватим все аспекты, указанные в плане курса: от базовых концепций до сложных примеров взаимодействия с C-библиотеками. Лекция будет самодостаточной, с избыточным количеством деталей, примерами кода, практическими советами и разбором упражнения. Поехали!


Введение в Unsafe Rust

Rust — это язык программирования, который славится своей безопасностью, особенно в управлении памятью. Благодаря строгим правилам заимствования (borrowing) и времени жизни (lifetimes), компилятор Rust предотвращает такие ошибки, как гонки данных (data races), использование освобожденной памяти или разыменование нулевых указателей. Однако бывают ситуации, когда стандартные безопасные механизмы Rust либо слишком ограничивают, либо просто не подходят для выполнения определенных задач. Вот тут-то и приходит на помощь unsafe.

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

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


1. Когда использовать unsafe

Unsafe Rust — это инструмент для особых случаев. Его стоит применять только тогда, когда безопасные средства языка не позволяют достичь цели. Вот основные сценарии, в которых unsafe становится необходимым:

Основные случаи применения

  1. Взаимодействие с внешними библиотеками (FFI)
    Если вам нужно вызвать функцию из C-библиотеки или передать данные в код на другом языке, безопасный Rust этого не поддерживает — требуется unsafe.
  2. Работа с сырой памятью
    Например, для реализации собственных структур данных (деревьев, списков) или оптимизации на уровне указателей.
  3. Реализация unsafe trait'ов
    Некоторые trait'ы, такие как Send или Sync, требуют ручного подтверждения безопасности через unsafe, если вы реализуете их для своих типов.
  4. Модификация статических переменных
    Глобальные изменяемые переменные (static mut) доступны только через unsafe, так как Rust не может гарантировать их безопасность в многопоточной среде.
  5. Вызов unsafe-функций
    Если функция помечена как unsafe, её можно вызвать только внутри unsafe-блока.

Пример простого unsafe-блока


fn main() {
    let mut value = 42;
    unsafe {
        // Здесь можно выполнять операции, недоступные в безопасном Rust
        let ptr = &mut value as *mut i32;
        *ptr = 100; // Прямое изменение через сырой указатель
    }
    println!("Value: {}", value); // Выведет: Value: 100
}
    

Важное замечание

Unsafe не отключает безопасность полностью — он лишь снимает некоторые ограничения компилятора. Вы по-прежнему работаете в рамках системы типов Rust, но ответственность за соблюдение инвариантов ложится на вас.

Практический совет

Используйте unsafe только там, где это действительно необходимо. Если задачу можно решить безопасным кодом, даже с дополнительными усилиями, выбирайте безопасный путь — это снизит риск ошибок.


2. Работа с сырой памятью: *const и *mut

Сырые указатели — это фундамент unsafe Rust. Они позволяют напрямую манипулировать памятью, обходя правила заимствования и проверки времени жизни. В Rust есть два типа сырых указателей:

Отличия от ссылок (&T, &mut T)

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


fn main() {
    let mut num = 5;

    // Создаем сырые указатели из ссылок
    let r1 = &num as *const i32;  // Неизменяемый указатель
    let r2 = &mut num as *mut i32; // Изменяемый указатель

    unsafe {
        println!("r1: {}", *r1);  // Читаем через *const
        *r2 = 10;                 // Пишем через *mut
        println!("r2: {}", *r2);
    }

    println!("num: {}", num); // Выведет: num: 10
}
    

Нюансы сырых указателей

  1. Алиасинг: Вы можете создать несколько *mut T на одну область памяти, что в безопасном Rust запрещено. Это опасно, если не контролировать доступ.
  2. Нулевые указатели: Разыменование null — неопределенное поведение (UB).
  3. Методы указателей: Rust предоставляет безопасные методы, такие как .add(offset) для смещения или .is_null() для проверки на null.

Подводные камни

Пример с UB


fn main() {
    let ptr: *mut i32 = std::ptr::null_mut();
    unsafe {
        *ptr = 42; // UB! Разыменование нулевого указателя No newline at end of file
    }
}
    

Практический совет

Используйте сырые указатели только внутри хорошо изолированных функций с четкими предусловиями (например, "указатель не null и указывает на действительные данные").


3. FFI: взаимодействие с C

Foreign Function Interface (FFI) — это механизм, позволяющий Rust взаимодействовать с кодом на других языках, чаще всего с C, благодаря его широко поддерживаемому ABI (Application Binary Interface).

Основы FFI

  1. Объявление внешней функции: Используйте extern "C" для указания C ABI.
  2. Связывание с библиотекой: Атрибут #[link] указывает имя библиотеки.
  3. Вызов: Вызов внешних функций — всегда unsafe.

Пример: вызов простой C-функции

C-код (mylib.c):


int add(int a, int b) {
    return a + b;
}
    

Скомпилируйте в libmylib.so:


gcc -shared -o libmylib.so -fPIC mylib.c
    

Rust-код:


#[link(name = "mylib")]
extern "C" {
    fn add(a: i32, b: i32) -> i32;
}

fn main() {
    let result = unsafe { add(3, 4) };
    println!("Result: {}", result); // Выведет: Result: 7
}
    

Нюансы FFI

  1. Соответствие типов:
  2. Управление памятью: Rust не освобождает память, выделенную в C, и наоборот.
  3. Ошибки: C-функции могут возвращать коды ошибок или null-указатели — это нужно обрабатывать.

Подводные камни

Практический совет

Создавайте безопасные обертки вокруг FFI-вызовов, чтобы пользователи вашего кода не сталкивались с unsafe напрямую.


4. Выравнивание памяти (alignment) и низкоуровневые детали

Что такое выравнивание?

Выравнивание (alignment) — это требование к адресам памяти, чтобы они были кратны определенному числу (обычно степени двойки). Например, i32 часто требует выравнивания на 4 байта, а f64 — на 8.

Проверка выравнивания

В Rust выравнивание типа можно узнать с помощью std::mem::align_of::<T>().

Пример:


use std::mem;

fn main() {
    println!("Alignment of i32: {}", mem::align_of::<i32>()); // Обычно 4
    println!("Alignment of f64: {}", mem::align_of::<f64>()); // Обычно 8
}
    

Работа с памятью вручную

Для выделения памяти с учетом выравнивания используется модуль std::alloc.

Пример выделения памяти:


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

fn main() {
    let layout = Layout::new::<i32>(); // Layout для i32 (размер 4, выравнивание 4)
    let ptr = unsafe { alloc(layout) } as *mut i32;

    if ptr.is_null() {
        panic!("Allocation failed");
    }

    unsafe {
        *ptr = 42;
        println!("Value: {}", *ptr);
        dealloc(ptr as *mut u8, layout);
    }
}
    

Нюансы

Подводные камни

Практический совет

Используйте Layout для всех операций с памятью, чтобы гарантировать правильное выравнивание.


5. Проверка безопасности вручную

В unsafe-коде компилятор не проверяет инварианты безопасности. Это ваша задача. Вот ключевые инварианты, которые нужно поддерживать:

  1. Отсутствие data races: Нельзя допускать одновременный доступ к mutable данным из разных потоков без синхронизации.
  2. Корректность ссылок: Указатели должны указывать на действительные данные.
  3. Инициализация: Нельзя разыменовывать неинициализированную память.
  4. Правила заимствования: Даже в unsafe код должен логически соответствовать правилам Rust.

Пример: безопасная обертка


pub struct MyVec<T> {
    ptr: *mut T,
    len: usize,
    capacity: usize,
}

impl<T> MyVec<T> {
    pub fn new() -> Self {
        MyVec {
            ptr: std::ptr::null_mut(),
            len: 0,
            capacity: 0,
        }
    }

    pub fn push(&mut self, value: T) {
        if self.len == self.capacity {
            // Логика перевыделения памяти (упрощено)
            self.capacity = if self.capacity == 0 { 1 } else { self.capacity * 2 };
            let layout = Layout::array::<T>(self.capacity).unwrap();
            let new_ptr = unsafe { alloc(layout) } as *mut T;

            if new_ptr.is_null() {
                panic!("Allocation failed");
            }

            if !self.ptr.is_null() {
                unsafe {
                    std::ptr::copy_nonoverlapping(self.ptr, new_ptr, self.len);
                    dealloc(self.ptr as *mut u8, Layout::array::<T>(self.capacity / 2).unwrap());
                }
            }
            self.ptr = new_ptr;
        }

        unsafe {
            self.ptr.add(self.len).write(value);
            self.len += 1;
        }
    }

    pub fn get(&self, index: usize) -> Option<&T> {
        if index < self.len {
            unsafe { Some(&*self.ptr.add(index)) }
        } else {
            None
        }
    }
}

impl<T> Drop for MyVec<T> {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            unsafe {
                dealloc(self.ptr as *mut u8, Layout::array::<T>(self.capacity).unwrap());
            }
        }
    }
}

fn main() {
    let mut vec = MyVec::new();
    vec.push(1);
    vec.push(2);
    println!("vec[0] = {:?}", vec.get(0)); // Some(1)
}
    

Практические советы


6. Примеры: вызов C-функции

Рассмотрим более сложный пример с выделением памяти в C.

C-код (mylib.c):


#include <stdlib.h>

int* create_int_array(size_t size) {
    int* arr = malloc(size * sizeof(int));
    if (arr == NULL) return NULL;
    for (size_t i = 0; i < size; i++) {
        arr[i] = i;
    }
    return arr;
}

void free_int_array(int* arr) {
    free(arr);
}
    

Компиляция:


gcc -shared -o libmylib.so -fPIC mylib.c
    

Rust-код:


#[link(name = "mylib")]
extern "C" {
    fn create_int_array(size: usize) -> *mut i32;
    fn free_int_array(arr: *mut i32);
}

fn main() {
    let size = 5;
    let arr_ptr = unsafe { create_int_array(size) };

    if arr_ptr.is_null() {
        panic!("Failed to allocate memory");
    }

    unsafe {
        for i in 0..size {
            println!("arr[{}] = {}", i, *arr_ptr.add(i));
        }
        free_int_array(arr_ptr);
    }
}
    

Нюансы


7. Упражнение: Связать Rust с C-библиотекой

Задание

Напишите программу, которая вызывает C-функцию для вычисления факториала.

Шаг 1: C-код (factorial.c)


unsigned long long factorial(unsigned int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}
    

Компиляция:


gcc -shared -o libfactorial.so -fPIC factorial.c
    

Шаг 2: Rust-код


#[link(name = "factorial")]
extern "C" {
    fn factorial(n: u32) -> u64;
}

// Безопасная обертка
fn safe_factorial(n: u32) -> Option<u64> {
    if n > 20 { // Предотвращаем переполнение
        return None;
    }
    Some(unsafe { factorial(n) })
}

fn main() {
    let n = 5;
    match safe_factorial(n) {
        Some(result) => println!("Factorial of {} is {}", n, result),
        None => println!("Input too large"),
    }
}
    

Разбор


Заключение

Unsafe Rust — это мощный инструмент для низкоуровневой работы, но он требует дисциплины и внимания к деталям. Мы рассмотрели, как использовать сырые указатели, взаимодействовать с C, управлять памятью и проверять безопасность вручную. Главное — минимизировать unsafe-код, изолировать его и документировать.

Теперь вы готовы применять unsafe там, где это нужно, и избегать его там, где можно обойтись безопасными средствами. Удачи в освоении Rust!