Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Почему разработчикам C/C++ нужен Rust

Что вы узнаете:

  • Полный список проблем, которые устраняет Rust: безопасность памяти, неопределённое поведение, гонки данных и многое другое
  • Почему shared_ptr, unique_ptr и другие средства C++ — это пластыри, а не решения
  • Конкретные примеры уязвимостей C и C++, которые в безопасном Rust структурно невозможны

Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код

Что Rust устраняет — полный список

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

Устранённая проблемаCC++Как Rust её предотвращает
Переполнения и недополнения буфера✅✅У всех массивов, срезов и строк есть границы; индексация проверяется во время выполнения
Утечки памяти (без сборщика мусора)✅✅Трейт Drop = RAII, сделанный правильно; автоматическая очистка, правило пяти не нужно
Висячие указатели✅✅Система времён жизни доказывает, что ссылки переживают объект, на который ссылаются, на этапе компиляции
Использование после освобождения✅✅Система владения делает это ошибкой компиляции
Использование после перемещения—✅Перемещения разрушающие — исходная привязка перестаёт существовать
Неинициализированные переменные✅✅Все переменные должны быть инициализированы до использования; это обеспечивает компилятор
Неопределённое поведение при переполнении целых✅✅В отладочных сборках переполнение вызывает panic; в релизных — переполняется (в обоих случаях поведение определено)
Разыменование NULL-указателей / SEGV✅✅Нулевых указателей нет; Option<T> вынуждает явно обрабатывать отсутствие значения
Гонки данных✅✅Трейты Send/Sync и проверка заимствований делают гонки данных ошибкой компиляции
Неконтролируемые побочные эффекты✅✅Неизменяемость по умолчанию; для изменения нужен явный mut
Отсутствие наследования (лучшая сопровождаемость)—✅Трейты и композиция заменяют иерархии классов; способствуют повторному использованию без жёсткой связанности
Отсутствие исключений; предсказуемый поток управления—✅Ошибки — это значения (Result<T, E>); их невозможно проигнорировать, нет скрытых путей throw
Инвалидация итераторов—✅Проверка заимствований запрещает изменять коллекцию во время итерации по ней
Циклы ссылок / утечки финализаторов—✅Владение имеет древовидную структуру; циклы Rc — явный выбор, который можно обнаружить и разорвать с помощью Weak
Забытые разблокировки мьютексов✅✅Mutex<T> оборачивает данные; защитник блокировки — единственный способ доступа к ним
Неопределённое поведение (вообще)✅✅В безопасном Rust нет неопределённого поведения; блоки unsafe явные и поддаются аудиту

Главное: это не желаемые цели, которые навязываются правилами кодирования. Это гарантии на этапе компиляции. Если ваш код компилируется, эти ошибки не могут существовать.


Проблемы, общие для C и C++

Хотите пропустить примеры? Перейдите к разделу Как Rust решает всё это или сразу к Покажите мне код

Оба языка разделяют базовый набор проблем безопасности памяти, которые являются первопричиной более 70% CVE (Common Vulnerabilities and Exposures — общеизвестных уязвимостей и подверженностей):

Переполнения буфера

Массивы, указатели и строки в C не имеют встроенных границ. Выйти за них очень легко:

#include <stdlib.h>
#include <string.h>

void buffer_dangers() {
    char buffer[10];
    strcpy(buffer, "This string is way too long!");  // Переполнение буфера

    int arr[5] = {1, 2, 3, 4, 5};
    int *ptr = arr;           // Теряется информация о размере
    ptr[10] = 42;             // Нет проверки границ — неопределённое поведение
}

В C++ std::vector::operator[] по-прежнему не проверяет границы. Проверяет только .at() — но кто поймает исключение?

Висячие указатели и использование после освобождения

int *bar() {
    int i = 42;
    return &i;    // Возвращается адрес переменной на стеке — висячий указатель!
}

void use_after_free() {
    char *p = (char *)malloc(20);
    free(p);
    *p = '\0';   // Использование после освобождения — неопределённое поведение
}

Неинициализированные переменные и неопределённое поведение

И C, и C++ допускают неинициализированные переменные. Их значения не определены, и чтение таких значений — неопределённое поведение:

int x;               // Не инициализирована
if (x > 0) { ... }  // Неопределённое поведение — x может быть чем угодно

Переполнение целых в C определено для беззнаковых типов, но не определено для знаковых. В C++ переполнение знакового типа тоже является неопределённым поведением. Оба компилятора могут и действительно используют это для «оптимизаций», которые ломают программы самым неожиданным образом.

Разыменование NULL-указателей

int *ptr = NULL;
*ptr = 42;           // SEGV — но компилятор вас не остановит

В C++ std::optional<T> помогает, но он громоздкий, и его часто обходят вызовом .value(), который выбрасывает исключение.

Визуализация: общие проблемы

graph TD
    ROOT["Проблемы безопасности памяти в C/C++"] --> BUF["Переполнения буфера"]
    ROOT --> DANGLE["Висячие указатели"]
    ROOT --> UAF["Использование после освобождения"]
    ROOT --> UNINIT["Неинициализированные переменные"]
    ROOT --> NULL["Разыменование NULL"]
    ROOT --> UB["Неопределённое поведение"]
    ROOT --> RACE["Гонки данных"]

    BUF --> BUF1["Нет границ у массивов/указателей"]
    DANGLE --> DANGLE1["Возврат адресов со стека"]
    UAF --> UAF1["Повторное использование освобождённой памяти"]
    UNINIT --> UNINIT1["Неопределённые значения"]
    NULL --> NULL1["Нет принудительной проверки на null"]
    UB --> UB1["Переполнение знаковых, алиасинг"]
    RACE --> RACE1["Нет безопасности на этапе компиляции"]

    style ROOT fill:#ff6b6b,color:#000
    style BUF fill:#ffa07a,color:#000
    style DANGLE fill:#ffa07a,color:#000
    style UAF fill:#ffa07a,color:#000
    style UNINIT fill:#ffa07a,color:#000
    style NULL fill:#ffa07a,color:#000
    style UB fill:#ffa07a,color:#000
    style RACE fill:#ffa07a,color:#000

C++ добавляет новые проблемы

Читатели, знакомые только с C: можете сразу перейти к разделу Как Rust решает эти проблемы, если не используете C++.

Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код

C++ ввёл умные указатели, RAII, семантику перемещения и исключения, чтобы решить проблемы C. Это пластыри, а не лекарства — они переносят сбой из режима «падение во время выполнения» в режим «более тонкая ошибка во время выполнения»:

unique_ptr и shared_ptr — пластыри, а не решения

Умные указатели C++ — заметное улучшение по сравнению с сырыми malloc/free, но они не решают основных проблем:

Средство C++Что исправляетЧто не исправляет
std::unique_ptrПредотвращает утечки через RAIIИспользование после перемещения по-прежнему компилируется; остаётся «зомби»-nullptr
std::shared_ptrСовместное владениеЦиклы ссылок незаметно приводят к утечкам; дисциплина weak_ptr — ручная
std::optionalЗаменяет часть использований null.value() выбрасывает исключение, если значения нет — скрытый поток управления
std::string_viewИзбегает копированияВисячий, если исходная строка освобождена — нет проверки времён жизни
Семантика перемещенияЭффективная передачаОбъекты после перемещения находятся в «допустимом, но не определённом состоянии» — неопределённое поведение, которое только ждёт своего часа
RAIIАвтоматическая очисткаЧтобы всё сделать правильно, нужно правило пяти; одна ошибка ломает всё
// unique_ptr: использование после перемещения компилируется без проблем
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr);
std::cout << *ptr;  // Компилируется! Неопределённое поведение во время выполнения.
                     // В Rust это ошибка компиляции: "value used after move"
// shared_ptr: циклы ссылок незаметно приводят к утечкам
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> parent;  // Цикл! Деструктор никогда не вызывается.
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->parent = a;  // Утечка памяти — счётчик ссылок никогда не станет 0
                 // В Rust Rc<T> + Weak<T> делают циклы явными и разрываемыми

Использование после перемещения — тихий убийца

std::move в C++ — это не перемещение, а приведение типа. Исходный объект остаётся в «допустимом, но не определённом состоянии». Компилятор позволяет продолжать его использовать:

auto vec = std::make_unique<std::vector<int>>({1, 2, 3});
auto vec2 = std::move(vec);
vec->size();  // Компилируется! Но разыменование nullptr — падение во время выполнения

В Rust перемещения разрушающие. Исходной привязки больше нет:

#![allow(unused)]
fn main() {
let vec = vec![1, 2, 3];
let vec2 = vec;           // Перемещение — vec поглощён
// vec.len();             // Ошибка компиляции: значение использовано после перемещения
}

Инвалидация итераторов — настоящие ошибки из боевого C++

Это не надуманные примеры — они представляют собой реальные шаблоны ошибок, встречающиеся в больших кодовых базах на C++:

// ОШИБКА 1: удаление без переприсваивания итератора (неопределённое поведение)
while (it != pending_faults.end()) {
    if (*it != nullptr && (*it)->GetId() == fault->GetId()) {
        pending_faults.erase(it);   // ← итератор инвалидирован!
        removed_count++;            //   следующая итерация использует висячий итератор
    } else {
        ++it;
    }
}
// Исправление: it = pending_faults.erase(it);
// ОШИБКА 2: удаление по индексу пропускает элементы
for (auto i = 0; i < entries.size(); i++) {
    if (config_status == ConfigDisable::Status::Disabled) {
        entries.erase(entries.begin() + i);  // ← сдвигает элементы
    }                                         //   i++ пропускает сдвинутый элемент
}
// ОШИБКА 3: один путь удаления правильный, другой — нет
while (it != incomplete_ids.end()) {
    if (current_action == nullptr) {
        incomplete_ids.erase(it);  // ← ОШИБКА: итератор не переприсвоен
        continue;
    }
    it = incomplete_ids.erase(it); // ← Правильный путь
}

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

Безопасность исключений и шаблон dynamic_cast/new

Современные кодовые базы на C++ по-прежнему опираются на шаблоны, у которых нет безопасности на этапе компиляции:

// Типичный фабричный шаблон C++ — каждая ветка является потенциальной ошибкой
DriverBase* driver = nullptr;
if (dynamic_cast<ModelA*>(device)) {
    driver = new DriverForModelA(framework);
} else if (dynamic_cast<ModelB*>(device)) {
    driver = new DriverForModelB(framework);
}
// Что, если driver всё ещё nullptr? Что, если new выбросит исключение? Кто владеет driver?

В типичной кодовой базе на C++ на 100 тысяч строк можно найти сотни вызовов dynamic_cast (каждый — потенциальный сбой во время выполнения), сотни сырых вызовов new (каждый — потенциальная утечка) и сотни методов virtual/override (накладные расходы на таблицы виртуальных функций повсюду).

Висячие ссылки и захваты в лямбдах

int& get_reference() {
    int x = 42;
    return x;  // Висячая ссылка — компилируется, неопределённое поведение во время выполнения
}

auto make_closure() {
    int local = 42;
    return [&local]() { return local; };  // Висячий захват!
}

Визуализация: дополнительные проблемы C++

graph TD
    ROOT["Дополнительные проблемы C++<br/>(помимо проблем C)"] --> UAM["Использование после перемещения"]
    ROOT --> CYCLE["Циклы ссылок"]
    ROOT --> ITER["Инвалидация итераторов"]
    ROOT --> EXC["Безопасность исключений"]
    ROOT --> TMPL["Сообщения об ошибках шаблонов"]

    UAM --> UAM1["std::move оставляет зомби<br/>Компилируется без предупреждений"]
    CYCLE --> CYCLE1["Циклы shared_ptr приводят к утечкам<br/>Деструктор никогда не вызывается"]
    ITER --> ITER1["erase() инвалидирует итераторы<br/>Реальные ошибки в продакшене"]
    EXC --> EXC1["Частичное конструирование<br/>new без try/catch"]
    TMPL --> TMPL1["30+ строк вложенных ошибок<br/>инстанцирования шаблонов"]

    style ROOT fill:#ff6b6b,color:#000
    style UAM fill:#ffa07a,color:#000
    style CYCLE fill:#ffa07a,color:#000
    style ITER fill:#ffa07a,color:#000
    style EXC fill:#ffa07a,color:#000
    style TMPL fill:#ffa07a,color:#000

Как Rust решает всё это

Каждая проблема, перечисленная выше — и из C, и из C++, — предотвращается гарантиями Rust на этапе компиляции:

ПроблемаРешение в Rust
Переполнения буфераСрезы хранят длину; индексация проверяется на границы
Висячие указатели / использование после освобожденияСистема времён жизни доказывает, что ссылки валидны, на этапе компиляции
Использование после перемещенияПеремещения разрушающие — компилятор не позволит трогать исходное значение
Утечки памятиТрейт Drop = RAII без правила пяти; автоматическая и корректная очистка
Циклы ссылокВладение имеет древовидную структуру; Rc + Weak делают циклы явными
Инвалидация итераторовПроверка заимствований запрещает изменять коллекцию, пока она заимствована
NULL-указателиНет null. Option<T> вынуждает явно обрабатывать значение через сопоставление с образцом
Гонки данныхТрейты Send/Sync делают гонки данных ошибкой компиляции
Неинициализированные переменныеВсе переменные должны быть инициализированы; это проверяет компилятор
Неопределённое поведение при переполненииВ отладке panic; в релизе переполнение (оба варианта определены)
ИсключенияИсключений нет; Result<T, E> виден в сигнатурах типов и распространяется с помощью ?
Сложность наследованияТрейты + композиция; нет проблемы ромба, нет хрупкости таблиц виртуальных функций
Забытые разблокировки мьютексовMutex<T> оборачивает данные; защитник блокировки — единственный путь доступа
#![allow(unused)]
fn main() {
fn rust_prevents_everything() {
    // ✅ Нет переполнения буфера — границы проверяются
    let arr = [1, 2, 3, 4, 5];
    // arr[10];  // panic во время выполнения, никогда не UB

    // ✅ Нет использования после перемещения — ошибка компиляции
    let data = vec![1, 2, 3];
    let moved = data;
    // data.len();  // error: value used after move

    // ✅ Нет висячих указателей — ошибка времени жизни
    // let r;
    // { let x = 5; r = &x; }  // error: x does not live long enough

    // ✅ Нет null — Option вынуждает обработку
    let maybe: Option<i32> = None;
    // maybe.unwrap();  // panic, но вместо этого вы бы использовали match или if let

    // ✅ Нет гонки данных — ошибка компиляции
    // let mut shared = vec![1, 2, 3];
    // std::thread::spawn(|| shared.push(4));  // error: closure may outlive
    // shared.push(5);                         //   borrowed value
}
}

Модель безопасности Rust — полная картина

graph TD
    RUST["Гарантии безопасности Rust"] --> OWN["Система владения"]
    RUST --> BORROW["Проверка заимствований"]
    RUST --> TYPES["Система типов"]
    RUST --> TRAITS["Трейты Send/Sync"]

    OWN --> OWN1["Нет использования после освобождения<br/>Нет использования после перемещения<br/>Нет двойного освобождения"]
    BORROW --> BORROW1["Нет висячих ссылок<br/>Нет инвалидации итераторов<br/>Нет гонок данных через ссылки"]
    TYPES --> TYPES1["Нет NULL (Option&lt;T&gt;)<br/>Нет исключений (Result&lt;T,E&gt;)<br/>Нет неинициализированных значений"]
    TRAITS --> TRAITS1["Нет гонок данных<br/>Send = безопасно передавать<br/>Sync = безопасно разделять"]

    style RUST fill:#51cf66,color:#000
    style OWN fill:#91e5a3,color:#000
    style BORROW fill:#91e5a3,color:#000
    style TYPES fill:#91e5a3,color:#000
    style TRAITS fill:#91e5a3,color:#000

Краткий справочник: C vs C++ vs Rust

КонцепцияCC++RustКлючевое отличие
Управление памятьюmalloc()/free()unique_ptr, shared_ptrBox<T>, Rc<T>, Arc<T>Автоматическое, без циклов и зомби
Массивыint arr[10]std::vector<T>, std::array<T>Vec<T>, [T; N]Проверка границ по умолчанию
Строкиchar* с \0std::string, string_viewString, &strГарантированный UTF-8, проверка времён жизни
Ссылкиint* (сырые)T&, T&& (перемещение)&T, &mut TПроверка времён жизни и заимствований
ПолиморфизмУказатели на функцииВиртуальные функции, наследованиеТрейты, трейт-объектыКомпозиция вместо наследования
ОбобщенияМакросы / void*ШаблоныОбобщения + ограничения трейтамиПонятные сообщения об ошибках
Обработка ошибокКоды возврата, errnoИсключения, std::optionalResult<T, E>, Option<T>Никакого скрытого потока управления
Безопасность при nullptr == NULLnullptr, std::optional<T>Option<T>Принудительная проверка на null
ПотокобезопасностьВручную (pthreads)Вручную (std::mutex и др.)Send/Sync на этапе компиляцииГонки данных невозможны
Система сборкиMake, CMakeCMake, Make и др.CargoИнтегрированный инструментарий
Неопределённое поведениеПовсеместноТонкое (переполнение знаковых, алиасинг)Нет в безопасном кодеБезопасность гарантирована