Системное программирование на C C++ после C: RAII, классы, шаблоны, умные указатели — что меняется
0%

C++ после C: RAII, классы, шаблоны, умные указатели — что меняется

C++ после C: RAII, классы, шаблоны, умные указатели — что меняется

Вы прошли одиннадцать статей о C: адреса и указатели, раскладка структур, ручное управление кучей, компоновка, отладка, системные вызовы, потоки, ассемблер и неопределённое поведение. Теперь можно честно ответить на вопрос, который у C-программиста возникает рано или поздно: что именно даёт C++ и чем за это платят.

Плохой способ изучать C++ — читать его как «C с классами». Так рождается код, где new вместо malloc, а всё остальное по-старому: ручной delete, коды возврата, char* вместо строк, макросы вместо шаблонов. Такой код хуже и C, и C++ — он потерял простоту первого и не получил гарантий второго.

Хороший способ — понять один центральный механизм, из которого выводится почти всё остальное: детерминированное разрушение объектов. Компилятор гарантирует, что при выходе из области видимости — любым путём, включая исключение — будет вызван деструктор. На этом стоят RAII, умные указатели, контейнеры, локи, файловые дескрипторы, транзакции. Всё остальное в C++ — либо следствие, либо ортогональная фича (шаблоны), либо историческое наследие.

Эта статья — не курс C++ (для языка целиком нужен Стандарт и пара тысяч страниц книг), а карта различий для человека, который уже знает, что происходит под капотом, и хочет понять, во что превращается C++-код после компилятора.

Карта: что меняется, а что остаётся

Из этой карты вытекает главная мысль статьи: C++ переносит рутину владения из головы программиста в систему типов, но не переносит туда доказательство корректности. Компилятор больше не забудет вызвать free, но по-прежнему не заметит, что вы вернули ссылку на локальную переменную.

C++ — не надмножество C

Первое заблуждение, которое стоит убить: «C-код скомпилируется как C++». Не скомпилируется. Возьмём совершенно обычный C:

struct node { int v; };
int main(void) {
    struct node *p = malloc(sizeof *p);   /* void* -> T* неявно */
    char *msg = "hello";                  /* строковый литерал */
    int class = 1;                        /* class — не ключевое слово в C */
    free(p);
    return class + (msg[0] != 0);
}
/* gcc -std=c17 — ни одного замечания. g++ -std=c++20:
   error:   invalid conversion from 'void*' to 'node*' [-fpermissive]
   warning: ISO C++ forbids converting a string constant to 'char*'
   error:   expected primary-expression before 'int'      <- int class = 1; */

Различия систематические, а не случайные:

Конструкция C17 C++20
void*T* неявно да нет, нужен static_cast
"литерал"char* да (но запись — UB) нет, только const char*
class, new, this, template, try как имена можно ключевые слова
sizeof('a') 4 (int) 1 (char)
Tentative definitions (int x; дважды) одна переменная ошибка — дублирование символа
Designated initializers .field = v C99, любой порядок C++20, только по порядку
VLA int a[n], restrict, составные литералы стандарт C99/C11 нет в стандарте (есть как расширения GNU)
Приведение результата malloc лишнее и вредное обязательно

Практический вывод: не «портируйте C на C++». Компилируйте C как C, C++ как C++ и связывайте их через границу extern "C".

Граница: extern “C” и манглинг имён

C++ кодирует в имени символа типы параметров, пространство имён и класс — иначе перегрузка функций невозможна на уровне компоновщика. Схема кодирования описана в Itanium C++ ABI, которому следуют GCC и Clang на всех Unix-платформах.

namespace net { class Socket { public: int send(const char *, unsigned long); }; }
int net::Socket::send(const char *, unsigned long) { return 0; }
int cxx_style(int a, double b) { return a + (int)b; }
int cxx_style(int a) { return a; }
g++ -c mang.cpp && nm mang.o | grep ' T ' && nm -C mang.o | grep ' T '
# _Z9cxx_stylei              ->  cxx_style(int)
# _Z9cxx_styleid             ->  cxx_style(int, double)
# _ZN3net6Socket4sendEPKcm   ->  net::Socket::send(char const*, unsigned long)

Читается механически: _Z — префикс, 9cxx_style — длина и имя, iint, idint, double, N3net6Socket4sendE — вложенность, PKcconst char*, munsigned long. Всё, что вы знаете о таблицах символов из сборки и компоновки, работает и здесь — просто имена длиннее.

Отсюда — канонический заголовок, пригодный для обоих языков:

/* net.h — один заголовок для C и C++ */
#ifndef NET_H
#define NET_H
#ifdef __cplusplus
extern "C" {
#endif
int  net_open(const char *host, int port);
long net_send(int fd, const void *buf, size_t len);
#ifdef __cplusplus
}      /* extern "C" */
#endif
#endif /* NET_H */

extern "C" выключает манглинг и переводит функцию на C-соглашение о вызовах. Что важно понимать про границу:

  • extern "C" не делает функцию безопасной: исключение, вылетевшее через границу в C-код, — неопределённое поведение. Ловите всё на границе: try { ... } catch (...) { return -1; }.
  • Классы и шаблоны через границу не проходят вообще. Экспортируйте непрозрачные указатели (typedef struct engine engine;) и функции над ними — ровно как поступают SQLite, libcurl и OpenSSL.
  • Линковать программу с C++-объектниками нужно через g++, а не gcc: иначе не подтянутся libstdc++, глобальные конструкторы и __gxx_personality_v0.
  • C++ ABI не стабилен между версиями стандартной библиотеки; C ABI стабилен десятилетиями. Именно поэтому системные библиотеки экспортируют C-интерфейс, даже когда внутри написаны на C++.

RAII: главное, ради чего это всё

В C освобождение ресурса — отдельный оператор, который вы обязаны написать на каждом пути выхода. Отсюда классический паттерн с goto cleanup, который вы уже видели в динамической памяти:

int load_config(const char *path, struct cfg *out) {
    int rc = -1, fd = -1;
    FILE *f = NULL; char *buf = NULL;
    if (!(f = fopen(path, "r")))                  goto cleanup;
    if (!(buf = malloc(64 * 1024)))               goto cleanup;
    if ((fd = open("/var/lock/cfg", O_RDWR)) < 0) goto cleanup;
    if (parse(f, buf, out) != 0)                  goto cleanup;
    rc = 0;
cleanup:                     /* порядок освобождения — обратный захвату */
    if (fd >= 0) close(fd);
    free(buf);
    if (f) fclose(f);
    return rc;
}

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

C++ делает освобождение свойством типа, а не свойством кода:

Cfg load_config(const std::string &path) {
    std::ifstream f(path);                     // деструктор закроет
    if (!f) throw std::runtime_error("open: " + path);
    std::vector<char> buf(64 * 1024);          // деструктор освободит
    FdGuard lock("/var/lock/cfg", O_RDWR);     // деструктор закроет fd

    Cfg out;
    parse(f, buf, out);                        // бросит — всё разрушится само
    return out;                                // и на успешном пути тоже
}

Ни одного goto, ни одной ветки освобождения, ни одной возможности забыть. Компилятор расставляет вызовы деструкторов сам — в обратном порядке конструирования, на всех путях выхода: return, исключение, break из блока.

Ключевой нюанс, который часто упускают: если конструктор бросил исключение, деструктор не вызовется, потому что объекта не было. Уже сконструированные подобъекты и члены при этом разрушатся. Отсюда правило: конструктор либо полностью устанавливает инвариант, либо бросает; «полусконструированных» объектов в C++ не бывает — если вы их сделали (двухфазная инициализация init()), вы отказались от RAII вручную.

Обёртка над C API: канонический пример

Системный код на C++ на 80% состоит из таких обёрток. Вот полная, с правильным набором операций:

#include <cstdio>           // g++ -std=c++20 -O2 -Wall -Wextra -c file.cpp
#include <stdexcept>
#include <utility>

class File {
    std::FILE *f_ = nullptr;
public:
    File(const char *path, const char *mode) : f_(std::fopen(path, mode)) {
        if (!f_) throw std::runtime_error(std::string("fopen: ") + path);
    }
    ~File() { if (f_) std::fclose(f_); }         // единственная точка освобождения

    File(const File &) = delete;                 // копировать владение нельзя
    File &operator=(const File &) = delete;

    File(File &&o) noexcept                      // передавать владение можно
        : f_(std::exchange(o.f_, nullptr)) {}
    File &operator=(File &&o) noexcept {
        if (this != &o) { if (f_) std::fclose(f_); f_ = std::exchange(o.f_, nullptr); }
        return *this;
    }
    std::FILE *get() const noexcept { return f_; }
};

Три обязательных решения в каждой такой обёртке:

  1. Копирование запрещено (= delete) — иначе два объекта закроют один FILE*, получится double-free.
  2. Перемещение разрешено и noexceptstd::exchange забирает ресурс и обнуляет источник. noexcept критичен: std::vector при реаллокации использует move только если он noexcept, иначе копирует ради строгой гарантии безопасности.
  3. Деструктор ничего не бросает. Деструктор в C++11+ неявно noexcept; исключение из него во время раскрутки стека вызывает std::terminate. Ошибку fclose в деструкторе можно только залогировать — или вынести явный close(), который бросает, оставив деструктору роль страховки.

Правило нуля, трёх и пяти

Пять специальных операций: деструктор, копирующие конструктор и присваивание, перемещающие конструктор и присваивание.

  • Правило нуля — цель. Если класс не владеет сырым ресурсом (его члены — std::string, std::vector, unique_ptr), не пишите ни одной из пяти. Компилятор сгенерирует корректные, а вы не ошибётесь.
  • Правило пяти — если написали хотя бы одну, продумайте все пять. Классическая ошибка: написали деструктор с delete, забыли про копирование — получили double-free при первом же push_back в вектор.

Ссылка для проверки себя: cppreference: rule of three/five/zero.

Исключения: как это работает и сколько стоит

В C ошибка — это возвращаемое значение (-1 + errno, код, NULL) и дисциплина её проверки. В C++ добавляется второй канал: исключение раскручивает стек до подходящего catch, вызывая деструкторы всех локальных объектов по дороге.

Модель называется zero-cost / table-driven: на успешном пути исключения не стоят ничего — нет ни одной лишней инструкции, нет установки обработчиков в прологе. Вся информация вынесена в секции .eh_frame и .gcc_except_table, которые читаются только в момент броска.

Ценой за это платят двумя вещами.

Размер бинаря. Секции с таблицами занимают место, а -fno-exceptions их частично убирает:

g++ -std=c++20 -O2                           -o sz_full  sizes.cpp
g++ -std=c++20 -O2 -fno-exceptions -fno-rtti -o sz_noexc sizes.cpp
size -A sz_full | grep -E 'text|eh_frame|except'
# .text 2657   .eh_frame_hdr 76   .eh_frame 368   .gcc_except_table 52
size -A sz_noexc | grep -E 'text|eh_frame|except'
# .text 2297   .eh_frame_hdr 60   .eh_frame 252   (таблицы обработчиков исчезли)

На игрушечном примере разница — сотни байт; на реальном коде с шаблонами счёт идёт на десятки-сотни килобайт, что критично во встраиваемых системах.

Непредсказуемая задержка броска. Раскрутка — это разбор DWARF-таблиц в рантайме, микросекунды и глобальный мьютекс в libgcc (в старых версиях). Поэтому в hard real-time и в ядре исключения не используют. Полный разбор проблемы — в статье Херба Саттера P0709 «Zero-overhead deterministic exceptions».

noexcept и границы

noexcept — не оптимизация «на всякий случай», а контракт. Если из noexcept-функции всё-таки вылетит исключение, вызывается std::terminate немедленно, без раскрутки. Ставьте его там, где это правда: move-операции, swap, деструкторы, C-callback’и.

// callback передаётся в C-библиотеку: исключение наружу = UB, глушим на границе
extern "C" int on_event(void *ctx, const char *data, size_t len) noexcept {
    try { static_cast<Engine *>(ctx)->handle({data, len}); return 0; }
    catch (const std::exception &e) { log_error(e.what()); return -1; }
    catch (...) { return -1; }                  // обратно в C — код возврата
}

Альтернатива без исключений

Если проект собирается с -fno-exceptions (embedded, ядро, некоторые игровые движки, часть кода Google — см. Google C++ Style Guide), ошибки возвращают значением. С C++23 это стандартизовано:

#include <expected>       // g++ -std=c++23 -O2 exp.cpp

std::expected<int, std::string> parse(const std::string &s) {
    try { return std::stoi(s); }
    catch (...) { return std::unexpected("не число: " + s); }
}

int main() {
    std::printf("%d / %s\n", parse("42").value_or(-1), parse("xx").error().c_str());
    // 42 / не число: xx
}

Это ровно та же идея, что «результат или ошибка» из обработки ошибок и устойчивости, только теперь тип не даёт молча проигнорировать ветку ошибки — value() на ошибочном expected бросит или упадёт.

Классы: что реально генерирует компилятор

Нефизической магии в классах нет. Обычный метод — это функция с неявным первым параметром this, то есть буквально то, что вы писали в C руками:

/* C:  int buffer_push(struct buffer *b, char c); */
/* C++: class Buffer { int push(char c); };
        на деле — та же функция, this приходит первым аргументом в rdi
        по System V AMD64 ABI, ровно как явный параметр в C */

Разница появляется, когда добавляется virtual. Тогда объект перестаёт быть чистыми данными: компилятор кладёт в него скрытое поле vptr — указатель на таблицу виртуальных функций.

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual void draw() const { std::printf("shape\n"); }
};
struct Circle : Shape {
    double r; int id;
    Circle(double r, int id) : r(r), id(id) {}
    double area() const override { return 3.14159 * r * r; }
    void draw() const override { std::printf("circle\n"); }
};
// Square : Shape — с полем double a и area(), но БЕЗ своего draw()

Это не догадки — раскладку можно распечатать:

g++ -std=c++20 -fdump-lang-class=/dev/stdout -c shapes.cpp -o /dev/null
# Vtable for Circle
# Circle::_ZTV6Circle: 6 entries
# 0     (int (*)(...))0                   <- offset_to_top
# 8     (int (*)(...))(& _ZTI6Circle)     <- typeinfo (RTTI)
# 16    (int (*)(...))Circle::~Circle     <- полный деструктор
# 24    (int (*)(...))Circle::~Circle     <- удаляющий деструктор
# 32    (int (*)(...))Circle::area
# 40    (int (*)(...))Circle::draw
# Class Circle
#    size=24 align=8
#    vptr=((& Circle::_ZTV6Circle) + 16)

sizeof(Circle) == 24, хотя полей всего на 12 байт: 8 на vptr, 8 на double, 4 на int, 4 на добивку (правила выравнивания те же, что в раскладке структур). Два слота под деструктор — особенность Itanium ABI: полный (просто разрушить) и удаляющий (разрушить и вызвать operator delete).

Раскладка объекта с виртуальными функциями: vptr, vtable и косвенный вызов

Цена виртуального вызова — две косвенности: прочитать vptr из объекта, прыгнуть по слоту. Предсказатель ветвлений на горячем пути с одним фактическим типом попадает почти всегда, так что сам прыжок стоит около нуля. Реальная цена другая — инлайнинг невозможен, а значит недоступны все зависящие от него оптимизации; подробнее про механику в оптимизациях компилятора. Поэтому в горячем коде виртуальность заменяют шаблонами или std::variant + std::visit.

Наследование и полиморфизм — тема ООП-парадигмы; здесь важно лишь одно системное правило: если у базового класса есть виртуальные функции, деструктор тоже должен быть виртуальным. Иначе delete base_ptr вызовет не тот деструктор — это UB и утечка одновременно. Компилятор предупредит: -Wnon-virtual-dtor.

Умные указатели: владение как тип

unique_ptr — это ровно тот File из примера выше, только шаблонный и для памяти. Ключевое утверждение: он бесплатен. Проверим на ассемблере, а не на слово.

#include <memory>                          // g++ -std=c++20 -O2 -S -o - up2.cpp
struct W { int a, b; };
extern void use(W *);

int with_unique() { auto p = std::make_unique<W>(); use(p.get()); return p->a; }
int with_raw()    { W *p = new W; use(p); int r = p->a; delete p; return r; }
_Z11with_uniquev:                _Z8with_rawv:
    movl    $8, %edi                 movl    $8, %edi
    call    _Znwm@PLT                call    _Znwm@PLT
    movq    $0, (%rax)          <—   (этой строки нет)
    movq    %rax, %rbx               movq    %rax, %rbx
    call    _Z3useP1W@PLT            call    _Z3useP1W@PLT
    movl    (%rbx), %ebp             movl    (%rbx), %ebp
    movl    $8, %esi                 movl    $8, %esi
    call    _ZdlPvm@PLT              call    _ZdlPvm@PLT
    ret                              ret

Инструкция в инструкцию, за одним исключением: make_unique<W>() value-инициализирует объект (обнуляет поля), сырой new W — нет. Если обнуление не нужно и вы это доказали профилировщиком, в C++20 есть std::make_unique_for_overwrite<W>(). Абстракция стоит ноль байт и ноль тактов — это и называют принципом «не платишь за то, чем не пользуешься».

shared_ptr — совсем другая история. Он несёт указатель на контрольный блок с атомарными счётчиками.

Раскладка unique_ptr, shared_ptr, weak_ptr и make_shared в памяти

unique_ptr<T> shared_ptr<T> weak_ptr<T> сырой T*
sizeof (x86-64) 8 Б 16 Б 16 Б 8 Б
Аллокаций 1 2 (или 1 с make_shared) 1
Копирование запрещено атомарный инкремент атомарный инкремент тривиально
Разрушение delete атомарный декремент + ветвление декремент weak ничего
Потокобезопасность самого объекта нет счётчик — да, объект — нет счётчик — да нет
Семантика единственный владелец разделённое владение наблюдатель «я не владею»

Копия shared_ptr — это не «просто присваивание». Вот что генерирует GCC 13 на -O2:

    cmpb    $0, __libc_single_threaded(%rip)   # быстрый путь: программа однопоточная?
    je      .L32                               # да — обычный inc, нет — lock xadd
    addl    $1, 8(%rbx)

libstdc++ проверяет глобальный флаг __libc_single_threaded и в однопоточной программе обходится обычным инкрементом. Как только вы создали хоть один поток — каждая копия shared_ptr становится атомарной операцией с блокировкой кэш-линии, а горячая кэш-линия счётчика, которую дёргают все ядра, превращается в классический false sharing (см. кэш и локальность).

Практические правила владения, выведенные из этой таблицы:

  • По умолчанию — unique_ptr. Разделённое владение — редкость, а не норма. Если вы не можете назвать второго владельца по имени, его нет.
  • Передавайте не-владеющие ссылки как T&, T* или std::span<T>. Функция, которая только читает объект, не должна принимать const shared_ptr<T>& — это ложное указание на владение и лишняя косвенность.
  • shared_ptr не делает объект потокобезопасным. Атомарен счётчик, а не данные. Гонки за самим объектом лечатся мьютексами — см. потоки и синхронизацию.
  • Циклы shared_ptr — это утечка. Родитель держит детей shared_ptr, ребёнок родителя — weak_ptr. Подсчёт ссылок циклы не собирает: фундаментальное отличие от трассирующего GC, разобранное в статье про сборку мусора.
  • make_shared экономит аллокацию, но держит память до последнего weak_ptr. Для крупных объектов с долгоживущими наблюдателями лучше shared_ptr<T>(new T(...)).
  • Кастомный делитер превращает unique_ptr в RAII-обёртку над любым C-ресурсом без единой строчки собственного класса:
using FilePtr = std::unique_ptr<std::FILE, decltype(&std::fclose)>;   // sizeof == 16
FilePtr f(std::fopen("data.bin", "rb"), &std::fclose);                // делитер хранится

struct FdDeleter { void operator()(int *fd) const { ::close(*fd); delete fd; } };
// делитер-класс без состояния занимает 0 байт — empty base optimization

Шаблоны: генерация кода вместо макросов

В C обобщённый код пишут либо через void* и size_t (как qsort), либо через макросы:

/* Классический C: тип стирается, компилятор ничего не проверит */
void qsort(void *base, size_t n, size_t size, int (*cmp)(const void *, const void *));

/* Или макрос: тип есть, но диагностика ужасна, отладчик бессилен */
#define MAX(a, b) ((a) > (b) ? (a) : (b))    /* и двойное вычисление аргументов */

Шаблон C++ — это инструкция компилятору сгенерировать отдельную функцию для каждого набора типов (мономорфизация):

template <typename T>
T max_of(T a, T b) { return a > b ? a : b; }

int main() { std::printf("%d %f %ld\n", max_of(1, 2), max_of(1.5, 2.5), max_of(3L, 4L)); }
g++ -std=c++20 -O0 -c tmpl.cpp && nm -C tmpl.o | grep max_of
# 0000000000000000 W double max_of<double>(double, double)
# 0000000000000000 W int    max_of<int>(int, int)
# 0000000000000000 W long   max_of<long>(long, long)

Три инстанцирования, три реальные функции в объектнике. Символы помечены W (weak) — это COMDAT: один и тот же шаблон, инстанцированный в десяти единицах трансляции, даст десять копий, из которых компоновщик оставит одну. Механизм подробно разобран в сборке и компоновке.

void*-обобщённость (C) Макрос (C) Шаблон (C++)
Проверка типов нет нет полная
Инлайнинг / отладочная информация нет / есть да / нет да / есть
Размер кода одна копия по копии на вызов по копии на тип
Скорость (сортировка int) базовая обычно в 1.5–3× быстрее qsort
Сообщения об ошибках нормальные ужасные очень длинные (лечится концептами)
Время компиляции низкое низкое высокое

Разница в скорости std::sort против qsort — не идеология, а следствие: компаратор шаблона инлайнится в тело сортировки, у qsort это косвенный вызов на каждое сравнение.

Обратная сторона — code bloat: std::vector<int>, std::vector<long>, std::vector<MyType> — три разных набора кода. Стандартный приём борьбы: вынести не зависящую от типа логику в нешаблонную базу, а шаблон оставить тонкой типизированной обёрткой. Именно так std::vector<T*> внутри libstdc++ разделяет реализацию для всех указательных типов.

С C++20 диагностика шаблонов лечится концептами — ограничениями на параметры:

template <std::integral T>                    // T обязан быть целочисленным
T mid(T a, T b) { return a + (b - a) / 2; }   // без переполнения, в отличие от (a+b)/2

mid(1.5, 2.5);   // ошибка в ОДНУ строку: constraints not satisfied — вместо
                 // 200 строк про несуществующий operator где-то внутри шаблона

Глубже про обобщённое программирование как парадигму — в дженериках и метапрограммировании.

Контейнеры и строки вместо ручных буферов

Половина кода на C — это ручное управление растущими массивами и строками, с полным набором граблей из массивов и строк:

/* C: рост массива вручную — три способа ошибиться в восьми строках */
if (len == cap) {
    size_t new_cap = cap ? cap * 2 : 8;
    if (new_cap < cap) return -1;                    /* переполнение size_t */
    int *tmp = realloc(a, new_cap * sizeof *a);      /* и здесь тоже */
    if (!tmp) return -1;                             /* нельзя писать в a напрямую */
    a = tmp; cap = new_cap;
}
a[len++] = value;

// C++: v.push_back(value);  — та же амортизированная O(1), негде ошибиться

Сложность операций от языка не зависит — это те же структуры данных: vector — динамический массив с амортизированным O(1) на вставку в конец и O(n) в середину; unordered_map — хеш-таблица; map — красно-чёрное дерево с O(log n). Меняется не алгоритмика, а количество мест, где можно ошибиться.

Ещё четыре замены, которые стоит сделать сразу:

std::string s = "hello";                  // вместо char*: длина хранится, нет ручного \0
std::string_view sv = s;                  // вместо (const char*, size_t) в параметрах
std::span<int> window(arr + 10, 20);      // вместо (int*, size_t) — с методом size()
auto cb = [ctx](int ev) { ctx->on(ev); }; // вместо (void (*fn)(void*), void *ctx)

Лямбда — не магия: компилятор генерирует безымянный класс с полем ctx и operator(), то есть ровно ту пару «функция + контекст», что в C передаётся двумя параметрами, — только согласованность проверяет он, а не вы, и вызов можно заинлайнить.

Что НЕ чинится: UB и безопасность памяти

Самое важное место статьи. C++ не является безопасным по памяти языком. Всё, что вы читали в неопределённом поведении, остаётся в силе, а список UB становится длиннее: к переполнению знакового, разыменованию NULL и нарушению строгого алиасинга добавляются вызов виртуальной функции из конструктора базового класса, использование объекта после std::move в неопределённом состоянии, гонки данных, reinterpret_cast мимо правил времени жизни.

Три специфически C++-ных способа получить use-after-free, которых в C нет:

1. Инвалидация итераторов и указателей на элементы.

std::vector<int> v{1, 2, 3, 4};
int *p = &v[0];             // указатель в буфер вектора
v.push_back(5);             // реаллокация: старый буфер освобождён
std::printf("%d\n", *p);    // heap-use-after-free

// g++ -std=c++20 -fsanitize=address -g -O0 -o iter iter.cpp && ./iter
// ==46848==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
// READ of size 4 at 0x502000000010 thread T0   #0 in main iter.cpp:7

Правила инвалидации у каждого контейнера свои, их надо знать: vector инвалидирует всё при росте, deque — итераторы, но не ссылки, list/map — только на удалённый элемент, unordered_map — итераторы при рехешировании.

2. Висячие string_view и span. Невладеющие типы — острые:

std::string_view v;
{
    std::string s = "строка длиннее SSO-буфера, поэтому она в куче";
    v = s;                      // view смотрит в буфер s
}                               // s разрушена, буфер освобождён
std::printf("%.*s\n", (int)v.size(), v.data());  // ASan МОЛЧИТ: чтение внутри libc
char first = v[0];                               // ASan ловит: чтение в вашем коде

Особенно коварен std::string_view sv = make_string(); — временная строка умирает в конце того же выражения, и sv мёртв сразу после точки с запятой. И обратите внимание на две последние строки: ASan инструментирует только код, скомпилированный с санитайзером, поэтому чтение висячей памяти внутри неинструментированной libc проходит незамеченным. Это не дефект инструмента, а свойство подхода (см. отладку и диагностику): «санитайзер молчит» — не доказательство корректности.

3. Ссылка или указатель на локальную переменную, вернувшиеся из функции, — старая ошибка C, но в C++ она прячется за возвратом «объекта».

Рабочий набор для C++, ровно тот же, что для C, плюс специфика:

# 1. Диагностика компилятора — всегда
g++ -std=c++20 -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
    -Wnon-virtual-dtor -Wold-style-cast -c src.cpp
# 2. Санитайзеры: отладочная сборка и CI
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 ...
g++ -fsanitize=thread -g -O1 ...      # отдельная сборка, с ASan несовместим
# 3. Проверки самой библиотеки: границы, инвалидация итераторов
g++ -D_GLIBCXX_ASSERTIONS ...         # дёшево, годится и для релиза
g++ -D_GLIBCXX_DEBUG ...              # дорого, только отладка; меняет ABI!
# 4. Закалка релиза
g++ -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
    -fcf-protection=full -Wl,-z,relro,-z,now
# 5. Статический анализ
clang-tidy src.cpp -checks='bugprone-*,cppcoreguidelines-*' -- -std=c++20

-D_GLIBCXX_DEBUG меняет ABI контейнеров — собирать так нужно весь проект и все библиотеки, иначе получите тихую порчу памяти вместо диагностики. Это, кстати, отличная иллюстрация к тому, почему ABI-совместимость в C++ — постоянная головная боль.

Ориентир по дисциплине: C++ Core Guidelines Страуструпа и Саттера — не стиль, а свод правил, часть которых проверяется clang-tidy автоматически. Про то, почему безопасность памяти в C++ до сих пор не решена и что предлагается, — эссе Херба Саттера «C++ safety, in context». Общая инженерная рамка — безопасное кодирование.

Цена: размер, время компиляции, ограниченные среды

Измеримые накладные расходы (GCC 13.3, Ubuntu 24.04, x86-64):

Метрика C C++
Строк после препроцессора (3–4 типичных заголовка) 2 250 51 833 (<vector> <string> <algorithm> <map>)
То же, с <ranges> 64 339
Компиляция пустого TU с этими заголовками, -O2 ~0.03 с ~0.42 с
Динамический бинарь простой программы 15 960 Б 17 016 Б
Статический бинарь той же программы 785 КБ 984 КБ

Более чем десятикратная разница во времени компиляции пустого файла — это то, из-за чего C++-проекты требуют ccache, precompiled headers, -ftime-trace для поиска тяжёлых заголовков и, с C++20, модулей. Проверить, что именно тормозит, можно так:

clang++ -std=c++20 -ftime-trace -c src.cpp   # даст src.json для chrome://tracing
g++ -ftime-report -c src.cpp                 # сводка по фазам компилятора

Для ограниченных сред существует общепринятое подмножество: -fno-exceptions -fno-rtti -fno-threadsafe-statics, свободная функция вместо динамической аллокации, никаких std::string/std::vector в горячих путях, etl/fixed_vector вместо контейнеров с кучей. Это то, что реально применяют в автомобильном и авиационном софте (MISRA C++, AUTOSAR C++14) и в прошивках — детали в C для встраиваемых систем. Ядро Linux остаётся на C по совокупности причин: стабильный ABI, предсказуемость, отсутствие рантайма — см. архитектуру ядра.

Когда переходить, а когда оставаться

Читается так: чем больше в задаче владения ресурсами и нетривиальных времён жизни, тем сильнее окупается RAII. Чем жёстче среда (нет кучи, нет исключений, нужен стабильный ABI, важен каждый килобайт), тем ближе разумный выбор к C или к урезанному C++.

Отдельный случай — постепенная миграция существующего C-проекта. Работающая стратегия:

Шаг 3 даёт 80% пользы за 20% усилий — с него и начинайте. Шаг 7 («перепишем всё на классы с наследованием») в этот список не входит намеренно: иерархии ради иерархий — самый частый способ сделать код на C++ хуже, чем он был на C.

Типичные ошибки C-программиста в C++

Ошибка Почему плохо Как правильно
malloc для объекта с конструктором конструктор не вызван, объект не существует — UB new, лучше make_unique
memcpy структуры с std::string внутри два объекта владеют одним буфером — double-free копирующий конструктор / =
Ручной delete в конце функции не сработает при исключении и раннем return RAII: unique_ptr, vector
new[] + delete (без скобок) UB, разные функции освобождения std::vector
Передача std::string по значению в API лишняя аллокация и копия на каждый вызов const std::string& или std::string_view
catch (std::exception e) — по значению срезка объекта, теряется динамический тип catch (const std::exception &e)
Возврат ссылки на локальную переменную висячая ссылка возвращайте по значению — RVO бесплатен
Виртуальные методы при невиртуальном деструкторе delete через базовый указатель — UB virtual ~Base() = default;
shared_ptr везде «на всякий случай» атомарные счётчики, циклы, потеря контроля unique_ptr + сырые невладеющие ссылки
Двухфазная инициализация init() появляется «полусконструированный» объект вся работа в конструкторе или фабрике
#define MAX(a,b) в C++-коде двойное вычисление, нет типов, ломает std::max constexpr-функция или шаблон
Приведение (Derived*)base_ptr в стиле C нет проверки, ломается при множественном наследовании static_cast / dynamic_cast

Мини-итог

  • C++ не надмножество C. Совместимость обеспечивается на уровне ABI через extern "C", а не на уровне исходников. Манглинг имён видно через nm -C.
  • RAII — единственная действительно необходимая фича. Детерминированный деструктор превращает «не забудь освободить» в свойство типа. Всё остальное можно не использовать и всё равно выиграть.
  • Правило нуля — цель проектирования; правило пяти — контрольный список для классов, владеющих сырым ресурсом.
  • Исключения бесплатны на успешном пути и дороги при броске; в ограниченных средах их выключают, а ошибки возвращают через std::expected.
  • Классы — это структуры плюс функции с this; virtual добавляет 8 байт vptr в объект и запрещает инлайнинг. Раскладку видно в -fdump-lang-class.
  • unique_ptr — zero-overhead (доказано ассемблером), shared_ptr — 16 байт, две аллокации и атомарные счётчики. По умолчанию берите первый.
  • Шаблоны — мономорфизация: типобезопасность и инлайнинг ценой размера кода и времени компиляции.
  • Безопасности памяти C++ не даёт. Инвалидация итераторов, висячие string_view, гонки — ASan, UBSan, TSan, -D_GLIBCXX_ASSERTIONS и clang-tidy остаются обязательными.
  • Платите вы размером бинаря и временем сборки: разница на порядок по времени компиляции пустого файла с типичными заголовками — реальность, с которой живут все крупные C++-проекты.

Источники

Что дальше

RAII и умные указатели убирают целый класс ошибок, но не проверяют времена жизни: висячая ссылка компилируется молча и в C, и в C++. Языки, которые пытаются закрыть именно эту дыру — проверкой заимствований, явными временами жизни, отказом от неопределённого поведения по умолчанию, — разбираем в следующей статье: Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов