Почему разработчикам C/C++ нужен Rust
Что вы узнаете:
- Полный список проблем, которые устраняет Rust: безопасность памяти, неопределённое поведение, гонки данных и многое другое
- Почему
shared_ptr,unique_ptrи другие средства C++ — это пластыри, а не решения- Конкретные примеры уязвимостей C и C++, которые в безопасном Rust структурно невозможны
Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код
Что Rust устраняет — полный список
Прежде чем переходить к примерам, вот краткое резюме. Безопасный Rust структурно предотвращает каждую проблему из этого списка — не дисциплиной, инструментами или код-ревью, а системой типов и компилятором:
| Устранённая проблема | C | C++ | Как 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<T>)<br/>Нет исключений (Result<T,E>)<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
| Концепция | C | C++ | Rust | Ключевое отличие |
|---|---|---|---|---|
| Управление памятью | malloc()/free() | unique_ptr, shared_ptr | Box<T>, Rc<T>, Arc<T> | Автоматическое, без циклов и зомби |
| Массивы | int arr[10] | std::vector<T>, std::array<T> | Vec<T>, [T; N] | Проверка границ по умолчанию |
| Строки | char* с \0 | std::string, string_view | String, &str | Гарантированный UTF-8, проверка времён жизни |
| Ссылки | int* (сырые) | T&, T&& (перемещение) | &T, &mut T | Проверка времён жизни и заимствований |
| Полиморфизм | Указатели на функции | Виртуальные функции, наследование | Трейты, трейт-объекты | Композиция вместо наследования |
| Обобщения | Макросы / void* | Шаблоны | Обобщения + ограничения трейтами | Понятные сообщения об ошибках |
| Обработка ошибок | Коды возврата, errno | Исключения, std::optional | Result<T, E>, Option<T> | Никакого скрытого потока управления |
| Безопасность при null | ptr == NULL | nullptr, std::optional<T> | Option<T> | Принудительная проверка на null |
| Потокобезопасность | Вручную (pthreads) | Вручную (std::mutex и др.) | Send/Sync на этапе компиляции | Гонки данных невозможны |
| Система сборки | Make, CMake | CMake, Make и др. | Cargo | Интегрированный инструментарий |
| Неопределённое поведение | Повсеместно | Тонкое (переполнение знаковых, алиасинг) | Нет в безопасном коде | Безопасность гарантирована |