Опасные ловушки C++: память, UB и код, который предаёт

6564
Опасные ловушки C++: память, UB и код, который предаёт
image

Около 70% уязвимостей, которым Microsoft ежегодно назначала номера CVE, были связаны с ошибками безопасности памяти. Проект Chromium публиковал почти ту же картину для серьёзных ошибок Chrome: примерно 70% проблем возникали из-за небезопасной работы с памятью, а половина таких дефектов приходилась на использование памяти после освобождения. Спустя десятилетия разработки на C и C++ индустрия продолжает наступать на одну и ту же граблю, просто теперь грабля встроена в браузер, сервер и обновление прошивки.

C++ не плохой язык по случайности. Язык сознательно разрешает разработчику работать близко к памяти, избегать обязательных проверок и получать производительность там, где лишняя проверка действительно стоит денег. Но сделка требует дисциплины: объект должен жить дольше ссылки на него, индекс не должен покидать диапазон, размер буфера нельзя считать арифметикой «авось пронесёт», а два потока не должны молча писать в одну переменную.

Когда код нарушает эти правила, программа часто не падает сразу. Она может пройти тесты, отработать неделю на сервере и сломаться после новой оптимизации, другого входного файла или удачно подобранного сетевого пакета. Разработчик говорит: «У нас всё работало». Специалист по эксплуатации открывает дамп процесса. Злоумышленник, если повезло ему, а не команде, забирает результат раньше всех.

Неопределённое поведение: программа больше ничего не обещает

Undefined behavior, или UB, по-русски означает «неопределённое поведение». Согласно тексту стандарта, при UB язык не предъявляет требований к результату выполнения программы. Компилятор не обязан выдавать предупреждение. Процесс не обязан падать. Код вправе вывести ожидаемое значение, мусор, секрет из памяти или выполнить совсем другую ветку после оптимизации.

Причина проста: оптимизатор рассуждает о корректной программе. Если стандарт запрещает знаковое переполнение, компилятор вправе считать, что переполнение никогда не происходит. Проверка, построенная на попытке заметить уже случившееся переполнение, может исчезнуть из исполняемого файла.

#include <limits>

bool grows_after_increment(int value) {
    return value + 1 > value;
    // При value == INT_MAX выражение value + 1 вызывает UB.
    // Поэтому оптимизатор вправе свести функцию к return true;
}

bool can_increment(int value) {
    return value < std::numeric_limits<int>::max();
}

Компилятор здесь не ведёт себя враждебно и не «ломает нормальную арифметику». Автор кода сначала пообещал соблюдать правила языка, а затем попытался построить логику на нарушении такого обещания. В C++ подобные трюки редко выглядят драматично в исходнике, зато отлично выглядят в отчёте об инциденте.

Использование после освобождения: адрес есть, объекта нет

Память и объект в C++ не равны друг другу. Программа может вернуть участок памяти распределителю, сохранив указатель на прежний адрес. Число внутри указателя осталось тем же, но объект уже уничтожен. Следующее выделение вправе занять тот же участок новыми данными.

#include <iostream>

void read_dead_object() {
    int* value = new int(42);

    delete value;

    std::cout << *value; // UB: память больше не принадлежит объекту int
}

Так возникает использование после освобождения, известное в документации уязвимостей как CWE-416. Пока освобождённый блок не переиспользовали, программа иногда выводит прежние данные и создаёт иллюзию исправности. Если новый объект занял тот же блок, старый указатель начинает читать или менять уже чужое состояние. При удачной для атакующего раскладке дефект превращается в подмену объекта, повреждение управляющих структур или выполнение кода.

Похожая ошибка возникает при повторном освобождении одного блока. Распределитель памяти получает два сообщения о возврате одного участка и может повредить собственные служебные структуры либо остановить программу.

void release_twice() {
    int* value = new int(42);

    delete value;
    delete value; // UB: повторное освобождение одного блока
}

Умный указатель std::unique_ptr резко сокращает риск, потому что выражает единственного владельца и освобождает объект автоматически. Но формула «unique_ptr физически исключает использование после освобождения» неверна. Невладеющий указатель можно вынуть через get(), сохранить, а затем использовать после уничтожения владельца.

#include <iostream>
#include <memory>

void smart_pointer_is_not_a_force_field() {
    auto owner = std::make_unique<int>(42);
    int* borrowed = owner.get();

    owner.reset();

    std::cout << *borrowed; // UB: borrowed пережил владельца
}

Практическое правило из C++ Core Guidelines звучит жёстко и полезно: обычный указатель T* в современном коде следует считать невладеющим. Владение нужно выражать контейнером, значением, std::unique_ptr или, когда совместное владение действительно нужно, std::shared_ptr.

Heartbleed: как отдельная длина заставила сервер отдавать память

В апреле 2014 года уязвимость Heartbleed в OpenSSL показала, насколько дорого стоит доверие к размеру, который пришёл отдельно от данных. Ошибка получила номер CVE-2014-0160. Уязвимый код обрабатывал heartbeat-сообщение протокола TLS: клиент отправлял полезную нагрузку и заявлял её длину, а сервер должен был вернуть те же данные.

Проблема состояла в том, что OpenSSL не сверял заявленную длину с реально полученным фрагментом. Атакующий мог отправить один байт, объявить длину в 64 КБ и получить ответ, содержащий присланный байт вместе с соседними участками памяти процесса. Официальное уведомление OpenSSL описывало возможность раскрытия содержимого памяти клиента или сервера.

#include <cstdint>
#include <cstring>
#include <memory>

std::unique_ptr<std::byte[]> broken_heartbeat(
    const std::byte* payload,
    std::uint16_t actual_size,
    std::uint16_t claimed_size)
{
    auto reply = std::make_unique<std::byte[]>(claimed_size);

    // Ошибка: claimed_size пришёл от клиента,
    // но код не проверил claimed_size <= actual_size.
    std::memcpy(reply.get(), payload, claimed_size);

    return reply;
}

Heartbleed был чтением за границами, а не записью и не прямым выполнением чужого кода. Сервер мог не упасть вообще. Он просто отдавал соседние байты памяти, где могли лежать секреты сеансов, пароли и закрытые ключи. Пример полезен именно поэтому: выражение «программа не рухнула» не имеет отношения к выражению «программа безопасна».

Выход за границы: один неверный знак, чужая память

Самая бытовая ошибка диапазона выглядит почти невинно. Разработчик использует <= вместо <, и цикл выполняет один лишний проход. При чтении программа может раскрыть соседние данные. При записи она меняет байты за границей массива: служебные поля, соседний объект, указатель или защитное значение стека.

#include <cstddef>

void clear_buffer(unsigned char* data, std::size_t size) {
    for (std::size_t i = 0; i <= size; ++i) {
        data[i] = 0; // последний проход пишет за концом буфера
    }
}

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

Начиная с C++20, std::span позволяет передавать непрерывный диапазон вместе с длиной, не разрывая их на независимые параметры. Но span не бронежилет. В C++20 и C++23 у представления нет метода at(), а обычный operator[] не обязан проверять индекс. Метод span::at() вошёл в технически завершённый C++26 через предложение P2821R5.

#include <span>

bool set_flag(std::span<unsigned char> bytes, std::size_t index) {
    if (index >= bytes.size()) {
        return false;
    }

    bytes[index] = 1;
    return true;
}

Смысл span не в том, что ошибочный индекс внезапно стал безопасным. Тип делает длину частью интерфейса и упрощает корректную проверку. Если индекс пришёл из файла, пакета или запроса пользователя, сравнение с размером всё равно обязан написать разработчик.

Переполнение целых чисел: буфер уменьшился, копирование осталось большим

Ошибка памяти часто начинается до первого обращения к памяти. Код получает длину входных данных, добавляет размер заголовка, выделяет буфер и копирует сообщение. Если арифметика размера переполнилась, программа выделяет небольшой блок, а затем записывает в него большой объём данных.

#include <cstdint>
#include <cstring>
#include <memory>

void copy_packet(const std::byte* input, std::uint32_t user_length) {
    constexpr std::uint32_t header_size = 16;

    std::uint32_t total = user_length + header_size;
    // Для unsigned переполнение определено как арифметика по модулю.
    // Огромный user_length может превратить total в малое число.

    auto output = std::make_unique<std::byte[]>(total);

    std::memcpy(output.get() + header_size, input, user_length);
    // Если total обернулся, запись далеко выйдет за выделенный буфер.
}

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

Проверка должна происходить до опасной операции:

#include <cstdint>
#include <cstring>
#include <limits>
#include <memory>

bool copy_packet_safe(
    const std::byte* input,
    std::uint32_t user_length,
    std::unique_ptr<std::byte[]>& output)
{
    constexpr std::uint32_t header_size = 16;

    if (user_length >
        std::numeric_limits<std::uint32_t>::max() - header_size) {
        return false;
    }

    const std::uint32_t total = user_length + header_size;
    output = std::make_unique<std::byte[]>(total);

    std::memcpy(output.get() + header_size, input, user_length);
    return true;
}

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

Висячие представления: string_view не владеет строкой

std::string_view хранит адрес и длину последовательности символов, но не хранит сами символы. Представление удобно как параметр функции: строку не приходится копировать, когда функция только читает уже существующий текст. Опасность начинается, когда представление переживает строку-владельца.

#include <string>
#include <string_view>

class Response {
public:
    explicit Response(std::string body)
        : body_(std::move(body)) {}

    std::string_view body() const {
        return body_;
    }

private:
    std::string body_;
};

std::string_view broken_response_body() {
    Response response("token=secret");
    return response.body();
    // response уничтожается, возвращённое представление становится висячим
}

Прямой возврат адреса локальной переменной современные компиляторы часто замечают. Clang поддерживает предупреждение -Wreturn-stack-address, GCC использует -Wreturn-local-addr. Пример с объектом и его методом ближе к реальной проблеме: в крупной кодовой базе строка и представление расходятся через несколько уровней интерфейсов, после чего предупреждение уже не выглядит гарантией.

Если функция создаёт строку, безопаснее вернуть владеющее значение. Современный C++ оптимизирует возврат объектов, а попытка сэкономить гипотетическую копию иногда покупает вполне настоящий висячий указатель.

std::string response_body() {
    return "token=secret";
}

Лямбды и отложенные задачи: ссылка не знает, что очередь задержалась

Похожая ловушка появляется в многопоточном коде и обработчиках событий. Лямбда захватывает локальную переменную по ссылке, функция ставит задачу в очередь и завершается, а обработчик запускается позже. Ссылка внутри задания продолжает существовать, но объект, к которому ссылка относилась, уже уничтожен.

#include <functional>
#include <string>

void schedule(std::function<void()> task);
void send_request(const std::string& token);

void start_job() {
    std::string token = "secret";

    schedule([&token] {
        send_request(token); // UB, если задача выполнится после выхода из start_job()
    });
}

Если заданию нужна собственная строка, значение нужно захватить явно. Когда объект большой и общий, владение требуется спроектировать отдельно, а не надеяться, что очередь всегда успеет отработать до завершения функции.

void start_job_safe() {
    std::string token = "secret";

    schedule([token = std::move(token)] {
        send_request(token); // задача владеет собственной строкой
    });
}

Код с асинхронными вызовами особенно неприятен тем, что тестовая машина часто исполняет задачу быстро и скрывает ошибку. Рабочая система под нагрузкой растягивает временные интервалы, и «никогда не происходивший» дефект появляется ровно тогда, когда журнал уже переполнен вторичными симптомами.

Контейнеры: vector переезжает, unordered_map перестраивается

Стандартные контейнеры освобождают разработчика от ручного выделения памяти, но не отменяют время жизни ссылок и итераторов. std::vector хранит элементы подряд. Когда ёмкости не хватает, добавление элемента выделяет новый блок и переносит содержимое. Ссылки, указатели и итераторы на прежние элементы после переноса недействительны.

#include <iostream>
#include <string>
#include <vector>

void print_first() {
    std::vector<std::string> names{"Alice"};
    const std::string& first = names[0];

    names.push_back("Bob"); // может перенести элементы в новый блок памяти

    std::cout << first; // UB, если перенос состоялся
}

Ошибка зависит от размера данных. Пока зарезервированного места хватает, программа работает. Первый рост контейнера превращает ссылку в ловушку. reserve() полезен, если верхняя граница размера действительно известна, но не является универсальным исправлением: следующий рефакторинг способен добавить элемент сверх обещанной ёмкости.

У других контейнеров действуют другие правила. Вставка в std::map не делает недействительными итераторы и ссылки на существующие элементы. У std::unordered_map вставка может запустить перестройку хеш-таблицы, после которой итераторы становятся недействительными. Поэтому совет «никогда не храните ссылку на элемент контейнера» слишком груб, а привычка не читать контракт конкретного контейнера слишком дорога.

RAII и правило нуля: владение должно читаться из типа

RAII связывает ресурс со временем жизни объекта: конструктор получает ресурс, деструктор освобождает. Подход закрывает множество утечек и повторных освобождений, потому что освобождение происходит автоматически при любом выходе из области видимости, включая исключение и ранний return.

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

#include <cstddef>

class BrokenBuffer {
public:
    explicit BrokenBuffer(std::size_t size)
        : data_(new char[size]) {}

    ~BrokenBuffer() {
        delete[] data_;
    }

private:
    char* data_;
};

void crash_later() {
    BrokenBuffer first(1024);
    BrokenBuffer second = first; // копируется адрес одного буфера
} // оба деструктора вызывают delete[] для одного адреса: UB

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

#include <cstddef>
#include <vector>

class Buffer {
public:
    explicit Buffer(std::size_t size)
        : data_(size) {}

private:
    std::vector<char> data_;
};

std::shared_ptr не стоит раздавать по проекту как универсальную таблетку. Совместное владение усложняет понимание времени жизни и допускает циклы, при которых память никогда не освобождается. Когда объект имеет одного владельца, std::unique_ptr или обычное значение передают намерение точнее.

После std::move: объект не умер, прежнее значение не обещано

Ловушку вокруг std::move часто описывают неверно. Сам вызов std::move ничего не уничтожает. Для объектов стандартной библиотеки перемещённый объект обычно остаётся корректным, но его конкретное содержимое после перемещения не определено контрактом.

#include <string>
#include <utility>

void store_token(std::string token) {
    std::string stored = std::move(token);

    if (!token.empty()) {
        audit(token);
        // Для std::string чтение допустимо,
        // но логика ошибочна: token не обязан быть ни пустым,
        // ни равным прежней строке.
    }

    token = "new-token"; // присваивание корректно
}

Здесь чтение token после перемещения не обязано быть UB. Ошибка логическая: программа строит решение на состоянии, которое интерфейс больше не обещает. Настоящее UB появляется, когда пользовательский тип неправильно реализует перемещение, например оставляет два объекта владельцами одного ресурса и затем дважды освобождает память.

Надёжное правило проще тонкостей: после перемещения объект можно уничтожить либо присвоить ему новое значение. Любая иная работа должна опираться на явные гарантии конкретного типа, а не на наблюдение, что «в моей сборке строка стала пустой».

Неинициализированные значения и изменение в C++26

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

int choose_limit(bool use_default) {
    int limit;

    if (use_default) {
        limit = 100;
    }

    return limit; // ошибочный путь при use_default == false
}

В прежних версиях C++ чтение подобных неопределённых значений во многих обычных случаях приводило к UB. В C++26 приняли предложение P2795R5, которое вводит категорию erroneous behavior, или «ошибочного поведения», для части чтений неинициализированных автоматических переменных. Техническую работу над C++26 комитет WG21 завершил в марте 2026 года; далее стандарт проходит финальные административные этапы публикации ISO.

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

#include <optional>

std::optional<int> choose_limit(bool use_default) {
    if (use_default) {
        return 100;
    }

    return std::nullopt;
}

Гонки данных: два потока, одно значение, никаких гарантий

Ошибки памяти не заканчиваются на указателях. Если два потока одновременно обращаются к одному неатомарному объекту, а хотя бы один поток пишет без правильной синхронизации, возникает гонка данных. В C++ такая гонка приводит к UB, а не к честной лотерее «кто последний записал».

int counter = 0;

void worker() {
    ++counter; // UB при одновременном вызове из нескольких потоков
}

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

#include <atomic>

std::atomic<int> counter{0};

void worker_safe() {
    ++counter;
}

Не всякую многопоточную структуру можно исправить заменой int на атомарный тип. Когда несколько полей должны меняться согласованно, требуется защита всего инварианта. Иначе программа получает быстрые и атомарные, но логически несовместимые куски состояния.

Что находят компилятор и инструменты динамической проверки

Ручная проверка кода нужна, но недостаточна. Висячая ссылка может появиться после изменения контейнера в другом месте, а переполнение длины проявиться только на повреждённом пакете. Современная сборка C++-проекта должна включать предупреждения, статический анализ и отдельные запуски под инструментами динамической проверки.

Инструмент Что обнаруживает Цена и ограничения
AddressSanitizer, ASan Выходы за границы, использование после освобождения, повторное и неверное освобождение, часть ошибок стека Документация Clang указывает типичное замедление примерно в 2 раза; расход памяти заметно возрастает
UndefinedBehaviorSanitizer, UBSan Часть UB: знаковое переполнение, неверные сдвиги, проблемы выравнивания, некоторые ошибочные обращения Проверяет только выполненные пути
MemorySanitizer, MSan Чтение неинициализированных данных Нужна отдельная сборка и инструментированные зависимости
ThreadSanitizer, TSan Гонки данных между потоками Clang указывает типичное замедление в 5-15 раз и рост расхода памяти в 5-10 раз
GCC -fanalyzer, clang-tidy Часть путей с повторным освобождением, использованием после освобождения, ошибками интерфейсов и подозрительными конструкциями Возможны пропуски и ложные предупреждения

ASan и UBSan удобно запускать совместно. MSan и TSan обычно держат в отдельных конфигурациях: инструменты требуют иной тяжёлой проверки выполнения и соответствующего набора тестов. Официальная документация Clang показывает базовую схему сборки ASan и перечень обнаруживаемых ошибок.

# Проверка памяти и части неопределённого поведения
clang++ -std=c++20 -O1 -g \
  -fsanitize=address,undefined \
  -fno-omit-frame-pointer \
  parser.cpp -o parser_asan

# Отдельная сборка для неинициализированных данных
clang++ -std=c++20 -O1 -g \
  -fsanitize=memory \
  -fno-omit-frame-pointer \
  parser.cpp -o parser_msan

# Отдельная сборка для гонок данных
clang++ -std=c++20 -O1 -g \
  -fsanitize=thread \
  -fno-omit-frame-pointer \
  server.cpp -o server_tsan

Инструментированная сборка не доказывает безопасность программы. ASan не найдёт выход за границы в обработчике пакета, если тесты не передали нужный пакет. Поэтому для парсеров, декодеров, архиваторов и сетевых протоколов нужны тесты с повреждёнными входами и фаззинг, то есть автоматическая подача множества изменённых входных данных.

Предупреждения компилятора: ошибка должна шуметь до запуска

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

Для GCC и Clang разумной отправной точкой служит строгий набор предупреждений. Он не покрывает все ловушки времени жизни и не заменяет динамические проверки, но отсекает часть ошибок ещё на этапе сборки.

# Clang
clang++ -std=c++20 -Wall -Wextra -Wpedantic \
  -Wconversion -Wshadow -Wreturn-stack-address \
  source.cpp

# GCC
g++ -std=c++20 -Wall -Wextra -Wpedantic \
  -Wconversion -Wshadow -Wreturn-local-addr \
  -fanalyzer \
  source.cpp

В новом проекте предупреждения разумно превращать в ошибки сборки через -Werror. В старой кодовой базе включение всех правил разом может утопить команду в историческом шуме. Рабочий компромисс состоит в том, чтобы зафиксировать существующий долг, запрещать новые предупреждения и постепенно очищать модули, которые обрабатывают недоверенные данные.

Как чинить старый C++ без театральных обещаний

Совет «включите ASan и перепишите указатели на умные» хорошо выглядит в докладе и плохо работает в крупной системе. Сетевой сервер с собственным протоколом, плагинами и десятками зависимостей не получает безопасность за вечер. Сначала команде придётся собрать тестовое окружение, добиться воспроизводимых входов, выяснить, какие библиотеки можно проверять инструментами, и отделить настоящие ошибки от шума стороннего кода.

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

  1. Найдите участки с new/delete, malloc/free, memcpy, сырыми буферами и отдельными размерами, возвращаемыми ссылками, string_view, захватом локальных объектов по ссылке и ручными перемещающими операциями.

  2. Добавьте сборку с ASan и UBSan для тестов, которые уже существуют, затем расширяйте покрытие опасных путей. Инструмент без входных сценариев не расследователь, а выключенный датчик.

  3. Переводите владение на RAII и правило нуля: контейнеры, значения и unique_ptr вместо ручного освобождения там, где архитектура позволяет.

  4. Меняйте интерфейсы диапазонов: std::span вместо пары «указатель и длина», явные проверки индексов, проверки переполнения до сложения и умножения.

  5. Для многопоточных компонентов запускайте TSan отдельно, а для парсеров подключайте фаззинг повреждённых входов. Обычный удачный тест не проверяет гонку и не моделирует злоумышленника.

Миграция занимает недели и месяцы. Такая оценка звучит менее героически, чем обещание «безопасный C++ за выходные», зато не вводит руководителя и разработчиков в заблуждение. Ошибки памяти слишком дороги, чтобы лечить их маркетинговыми сроками.

Когда новый компонент лучше писать не на C++

C++ остаётся рациональным выбором для существующих систем, двоичной совместимости, драйверов, высокопроизводительных библиотек и проектов, где огромная часть работающего кода уже написана на этом языке. Но новый сетевой сервис, парсер недоверенных файлов или компонент изоляции не обязан наследовать риск только потому, что команда привыкла к фигурным скобкам C++.

В ноябре 2022 года АНБ США выпустило документ Software Memory Safety, где рекомендовало рассматривать языки с автоматической защитой памяти. Среди примеров ведомство назвало Rust, Go, Java, C#, Swift, Ruby, Python и Ada. Для системного компонента, которому нужна предсказуемая производительность без сборщика мусора, разговор обычно быстро приходит к Rust.

Rust не устраняет неверную авторизацию, ошибочную криптографию, логические гонки и плохой протокол. Но язык убирает целый класс дефектов времени жизни и владения ещё до запуска программы. Если новый сервис принимает данные из сети и не связан обязательной совместимостью с C++-миром, отказ даже рассмотреть Rust выглядит не инженерным решением, а привычкой, выставленной счётом будущему дежурному.

Вопросы и ответы

Опасности C++ легко превратить либо в панику, либо в успокаивающее «у нас же умные указатели». Ни одна крайность не помогает. Ниже собраны короткие ответы на вопросы, которые возникают при проверке реального проекта.

Любой сырой указатель в C++ опасен?

Нет. Сырой указатель подходит для невладеющего доступа, когда объект гарантированно живёт дольше указателя. Проблема начинается, когда T* скрывает владение, выходит за время жизни объекта или используется как массив без достоверной длины. Для нового кода полезно закрепить правило: сырой указатель не владеет ресурсом.

std::unique_ptr полностью исключает использование после освобождения?

Нет. std::unique_ptr исключает многие ошибки ручного освобождения и двойного владения, но невладеющий указатель, полученный через get(), может пережить владельца. Умный указатель управляет ресурсом, а не исправляет любые ссылки на ресурс.

std::span автоматически защищает от неправильного индекса?

В C++20 и C++23 нет. std::span переносит длину вместе с указателем, но обычный доступ через operator[] не обязан проверять границы. Метод at() добавили в C++26. Индекс, пришедший извне, всё равно нужно проверять явно.

Использование объекта после std::move всегда вызывает UB?

Нет. Для стандартных типов перемещённый объект обычно остаётся корректным, но его значение заранее не определено. Объект можно уничтожить или присвоить ему новое значение. Строить логику на предположении, что строка после перемещения обязательно пуста либо сохранила прежний текст, нельзя.

Достаточно ли прогнать тесты под ASan один раз?

Нет. ASan обнаруживает дефект только на выполненном пути. Если тесты не довели код до перераспределения vector, не передали чрезмерную длину или не воспроизвели проблемный пакет, ошибка останется скрытой. Нужны регулярные инструментированные сборки, краевые тесты и проверка повреждёнными входными данными.

Нужно ли переписывать старую систему на Rust?

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

антипов жжёт

В санатории вас лечат.
Или просто доят?

// пиявки, озон и клизмы в вашем счёте →