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 — длина и имя, i — int, id — int, double, N3net6Socket4sendE — вложенность, PKc — const char*, m — unsigned 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 из блока.
деструктор НЕ вызывается Владеет --> Передан: move — источник обнулён Передан --> [*]: деструктор пустой объект пропускает Владеет --> Разрушение: return / исключение / конец блока Разрушение --> [*]: ресурс освобождён ровно один раз note right of Владеет Инвариант RAII: если объект сконструирован — ресурс захвачен. Промежуточных состояний нет. end note
Ключевой нюанс, который часто упускают: если конструктор бросил исключение, деструктор не вызовется, потому что объекта не было. Уже сконструированные подобъекты и члены при этом разрушатся. Отсюда правило: конструктор либо полностью устанавливает инвариант, либо бросает; «полусконструированных» объектов в 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_; }
};
Три обязательных решения в каждой такой обёртке:
- Копирование запрещено (
= delete) — иначе два объекта закроют одинFILE*, получится double-free. - Перемещение разрешено и
noexcept—std::exchangeзабирает ресурс и обнуляет источник.noexceptкритичен:std::vectorпри реаллокации использует move только если онnoexcept, иначе копирует ради строгой гарантии безопасности. - Деструктор ничего не бросает. Деструктор в 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, вызывая деструкторы всех локальных объектов по дороге.
объект исключения в отдельной памяти"] --> D{"Фаза 1 — поиск.
Идём вверх по кадрам, читаем
.eh_frame и .gcc_except_table"} D -->|"обработчик найден"| G["Фаза 2 — раскрутка. Для каждого кадра
вызвать деструкторы живых объектов"] D -->|"обработчик не найден"| F["std::terminate → abort"] G --> H{"Это кадр с catch?"} H -->|нет| G H -->|да| I["Восстановить регистры, войти в catch,
__cxa_end_catch освобождает объект"] style F fill:#c0392b22,stroke:#c0392b style I fill:#3f8f5c22,stroke:#3f8f5c
Модель называется 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 из объекта, прыгнуть по слоту. Предсказатель ветвлений на горячем пути с одним фактическим типом попадает почти всегда, так что сам прыжок стоит около нуля. Реальная цена другая — инлайнинг невозможен, а значит недоступны все зависящие от него оптимизации; подробнее про механику в оптимизациях компилятора. Поэтому в горячем коде виртуальность заменяют шаблонами или 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<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-проекта. Работающая стратегия:
сборка gcc"] --> B["Шаг 1: собрать как есть,
исправить несовместимости
void*, ключевые слова"] B --> C["Шаг 2: переключить линковку на g++,
публичные заголовки под extern C"] C --> D["Шаг 3: RAII-обёртки
над своими ресурсами
fd, FILE*, мьютексы, хендлы"] D --> E["Шаг 4: контейнеры и строки
вместо ручных буферов"] E --> F["Шаг 5: unique_ptr вместо
сырого владения"] F --> G["Шаг 6: шаблоны вместо
макросов и void*"] G --> H["Опционально: исключения
или std::expected —
решение уровня проекта"] style D fill:#3f8f5c22,stroke:#3f8f5c style H fill:#c07a2822,stroke:#c07a28
Шаг 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++-проекты.
Источники
- C++ Core Guidelines — Bjarne Stroustrup, Herb Sutter. Свод правил, а не стиль.
- cppreference.com — фактический справочник по языку и библиотеке.
- Itanium C++ ABI — раскладка объектов, vtable, манглинг, механика исключений.
- Черновик стандарта C++ (eel.is) — источник истины по вопросам UB.
- Scott Meyers. Effective Modern C++ — move-семантика, умные указатели,
auto. - Bjarne Stroustrup. The C++ Programming Language, 4th ed. и A Tour of C++ — второе короче и современнее.
- isocpp.org FAQ: Mixing C and C++ — практика
extern "C"и совместной сборки. - Herb Sutter. P0709: Zero-overhead deterministic exceptions — разбор реальной стоимости исключений.
- Herb Sutter. C++ safety, in context — где C++ проигрывает по безопасности памяти и что с этим делают.
- GCC: C++ Dialect Options —
-fno-exceptions,-fno-rtti,-fdump-lang-class. - Google C++ Style Guide — пример проекта, живущего без исключений.
- AddressSanitizer wiki и libstdc++ Debug Mode.
Что дальше
RAII и умные указатели убирают целый класс ошибок, но не проверяют времена жизни: висячая ссылка компилируется молча и в C, и в C++. Языки, которые пытаются закрыть именно эту дыру — проверкой заимствований, явными временами жизни, отказом от неопределённого поведения по умолчанию, — разбираем в следующей статье: Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться.