C++ с нуля: большая шпаргалка по синтаксису, ООП, STL и современным стандартам
C++ — компилируемый язык программирования с ручным контролем ресурсов, статической типизацией и поддержкой нескольких парадигм: процедурной, объектной, обобщённой и функциональной.
Простое определение для самых начинающих. C++ — это язык, на котором вы пишете инструкции для компьютера, при этом компьютер проверяет ваши инструкции заранее (до запуска), требует от вас аккуратности с памятью и даёт несколько стилей программирования на выбор. Это как разница между письмом по строгому юридическому шаблону (любая ошибка делает документ недействительным) и дружеской запиской (что-то поймут, что-то нет).
Аналогия из жизни, чтобы запомнить раз и навсегда. C++ — это «инструмент штукатурщика» (мастерок, уровень, правило), а не «робот-пылесос»: он даёт вам полный контроль над тем, что происходит в памяти и процессоре, но взамен требует аккуратности. Робот-пылесос всё делает сам, но вы не контролируете, как именно он убирает; мастерок требует мастерства, но позволяет выровнять стену идеально. Python и Java — это роботы-пылесосы (автоматическая сборка мусора, динамические типы, интерпретация), C++ — это профессиональный инструмент. Почему так: язык создавался в начале 1980-х, когда каждый байт памяти и каждый такт процессора были на счету — поэтому в нём нет «автопилотов», зато есть прямой доступ к «рулю».
Для кого эта шпаргалка и зачем она нужна. Если вам нужна шпаргалка по C++, которая проведёт от первой программы до C++20/23, — вы её открыли. Здесь собран не учебник для первоклассника, а плотный справочник для тех, кто уже написал свой Hello, World! и хочет разобраться глубже. Практический совет: если вы совсем новичок — начните с любого онлайн-курса по основам C++, а эту шпаргалку используйте как справочник «второго уровня», когда нужно быстро освежить в памяти синтаксис или найти подводный камень.
Краткая история языка, чтобы понимать, откуда что растёт. Созданный Бьёрном Страуструпом как «C с классами» в начале 1980-х в Bell Labs, за сорок лет язык получил шаблоны (1980-е–90-е), исключения (1990-е), STL — стандартную библиотеку контейнеров и алгоритмов (1994, Александр Степанов), семантику перемещения (C++11, 2011), constexpr (вычисления на этапе компиляции, C++11/14/20), concepts (ограничения на шаблоны, C++20) и ranges (новый способ работать с контейнерами, C++20). Важная мысль, которую часто упускают: каждый из этих слоёв не заменял предыдущие, а достраивался поверх — поэтому в современном коде можно встретить «старые» идиомы рядом с «новыми». Это не плохо и не хорошо — это особенность живого языка с сорокалетней историей. Типичная ошибка новичков: думать, что «новое = лучшее» и сразу писать в стиле C++23, игнорируя то, что 90% существующего кода написано в стиле C++98/03/11. Учите оба стиля.
Где C++ используется в реальном мире — это важно для мотивации. На C++ написаны браузерные движки (Chromium, Firefox — потому что нужна скорость отрисовки страниц), СУБД (ClickHouse, MongoDB, MySQL — потому что важна скорость обработки миллионов запросов), игровые движки (Unreal Engine, часть Unity — потому что важна скорость рендеринга 60 кадров в секунду), HFT-системы (high-frequency trading — биржевые роботы, где важна каждая микросекунда задержки) и ROS (Robot Operating System — фреймворк для робототехники, где важна работа с железом в реальном времени). Почему именно C++? Скорость + контроль над памятью + богатая стандартная библиотека. Где C++ обычно НЕ используют: веб-бэкенды простой бизнес-логики (там Python/Go удобнее), быстрое прототипирование (там Python/JS), мобильные приложения (там Swift/Kotlin).
Что вы найдёте в этой шпаргалке. Здесь плотно и по делу собраны синтаксис, объектная модель и её память, шаблоны, контейнеры, многопоточность и — главное — подводные камни вроде UB (undefined behavior — неопределённое поведение, когда стандарт не определяет, что произойдёт), slicing (потеря части объекта при копировании по указателю на базу) и инвалидации итераторов (когда указатель внутрь контейнера становится недействительным после изменения контейнера). Зачем подводные камни выделены отдельно: потому что в C++ знать синтаксис — это 30% успеха, остальные 70% — знать, где вас поджидает грабли.
Как пользоваться этой шпаргалкой — практический совет. Держите страницу как справочник: не читайте залпом, а возвращайтесь к нужному разделу. Один и тот же материал, перечитанный трижды с перерывом в неделю, запоминается лучше, чем десять страниц, проглоченных за ночь. Это связано с эффектом «кривой забывания Эббингауза» — мозг забывает 70% новой информации за сутки, если её не повторить. Аналогия: учить C++ за ночь — это как пытаться выучить китайский за ночь перед поездкой в Пекин. Можно выучить «здравствуйте» и «спасибо», но говорить вы не сможете. А вот постепенное повторение — это как регулярные занятия с репетитором: через полгода вы уже сможете поддержать разговор.
Разберём три слова из определения, без которых дальше не обойдёшься. Понимание этих терминов — фундамент, на котором стоит всё остальное.
1. Компилируемый — значит, что текст программы заранее переводится в машинные инструкции отдельной программой-компилятором (g++, clang++, MSVC); в отличие от интерпретируемых языков (Python, JavaScript, Ruby), готовый файл работает быстро и без «переводчика» рядом. Аналогия из жизни: компиляция — это как напечатать рецепт торта в виде готовой книги-инструкции для кондитера (он открыл, прочитал, испёк — всё быстро), а интерпретация — как живой переводчик рядом с иностранным гостем, который переводит каждую фразу на лету (гибко, но медленнее). Первое быстрее в работе, второе гибче в отладке. Именно поэтому C++ обгоняет Python в скорости в десятки и сотни раз, но проигрывает в скорости разработки. Почему это важно понимать: ошибки компиляции (синтаксические) ловятся ДО запуска программы — это плюс. Но цикл «написал → скомпилировал → запустил → увидел результат» длиннее, чем в Python «написал → запустил». Практический совет: чтобы ускорить разработку, используйте режим инкрементальной компиляции (в IDE — это по умолчанию) и кеширование (ccache, ninja). Типичная ошибка: думать, что если код скомпилировался — он работает. Нет! Компилятор ловит только синтаксические ошибки и базовые ошибки типов, но не логические. Скомпилированная программа может падать в рантайме.
2. Ручной контроль ресурсов — память, файлы, блокировки вы освобождаете сами, но в C++ это принято делать не вручную, а через идиому RAII (Resource Acquisition Is Initialization — «захват ресурса есть инициализация», раздел ниже). Простое определение: ресурс — это всё, что программа берёт у системы (память, открытый файл, сетевое соединение, мьютекс) и должна вернуть, когда перестаёт использовать. Аналогия из жизни. Представьте, что ресурс — это арендованный велосипед: на C++ вы сами следите, чтобы вернуть его вовремя, но RAII превращает этот процесс в автоматический — как ключ от замка, который привязан к цепочке: пока вы не положите ключ на место, замок не закроется. RAII в C++ — это магия конструкторов и деструкторов: объект сам себе «владелец» ресурса, и когда объект выходит из области видимости (заканчивается блок {}), деструктор автоматически освобождает ресурс. Почему это важно: в языках с автоматическим сборщиком мусора (Java, C#, Python, Go) программист не думает, когда освободится память. В C++ вы думаете, но с помощью RAII это перестаёт быть страшным — 95% случаев покрываются умными указателями (unique_ptr, shared_ptr). Типичная ошибка новичков: писать new и delete вручную в современном C++. Сейчас это нужно крайне редко — используйте std::vector, std::string, std::unique_ptr. Когда всё-таки нужен ручной контроль: работа с низкоуровневыми системными API, кастомные аллокаторы, embedded-разработка с ограниченной памятью.
3. Статическая типизация — типы проверяются на этапе компиляции, поэтому многие ошибки (опечатки в именах, попытки сложить число со строкой) не доживают до запуска. Простое определение. Тип — это «вид» значения: целое число, дробное число, строка, логическое значение (истина/ложь), пользовательский класс. Статическая типизация значит, что каждая переменная, каждая функция имеют тип, известный ДО запуска программы. Аналогия из жизни: это как проверка чертежа дома до начала стройки: дешевле исправить линию на бумаге, чем сносить готовую стену. Если в чертеже написано «несущая стена из бетона», а вы пытаетесь поставить её на место окна — архитектор (компилятор) скажет «нельзя» ещё до того, как строители (программа) начнут работу. Почему это хорошо: ловит огромный класс ошибок до запуска. Почему это иногда неудобно: нужно заранее продумывать типы, иногда писать длинные декларации. Типичная ловушка: путаница между auto (компилятор сам выводит тип) и слабой типизацией. auto — это всё ещё статическая типизация, просто тип выводит компилятор. Динамическая типизация — это когда тип определяется В МОМЕНТ ВЫПОЛНЕНИЯ (как в Python). Что спрашивают на собеседовании: «В чём разница между статической и динамической типизацией?» и «Какие ошибки ловит статическая типизация, а какие — нет?»
4. Мультипарадигменность — означает, что одну задачу можно решить процедурно (набор функций), объектно (классы и наследование), обобщённо (шаблоны) или функционально (лямбды, алгоритмы) — выбирайте то, что читается проще. Простое определение каждой парадигмы:
- Процедурная — программа как последовательность функций, каждая делает своё (как рецепт: шаг 1, шаг 2, шаг 3).
- Объектная — программа как набор «объектов», у которых есть данные (поля) и действия (методы), они взаимодействуют друг с другом (как оркестр: музыканты-объекты, дирижёр-координатор).
- Обобщённая (generic) — код пишется один раз, но работает с разными типами (шаблоны в C++ — аналог дженериков в Java, но мощнее).
- Функциональная — код строится из маленьких функций, которые не меняют состояние, а возвращают новые значения (лямбды,
std::transform,std::accumulate).
На практике это не «или-или», а «и-и-и»: в реальном проекте вы почти всегда смешиваете подходы в зависимости от подзадачи. Аналогия из жизни: как в кулинарии — можно жарить, варить, запекать; хороший повар использует все методы в зависимости от продукта. Типичная ошибка: фанатично следовать одной парадигме («ООП везде!» или «функциональщина везде!»). Хороший C++-программист выбирает инструмент под задачу, а не наоборот. Практический совет: для маленьких утилит — процедурный стиль, для средних систем — ООП + шаблоны, для обработки данных — STL-алгоритмы + лямбды (функциональный стиль).
Как читать эту шпаргалку. Каждый раздел начинается с короткого объяснения «что это и зачем» (чтобы вы поняли мотивацию до кода), затем идёт код с построчными комментариями (комментарии читайте как основной текст — в них часто спрятаны «почему именно так»), а завершают раздел подсказки:
- «Как запомнить» (мнемоника — короткая фраза или образ, помогающий запомнить правило),
- «Частая ловушка» (на что наступают новички и даже опытные),
- «На собеседовании спросят» (формулировки, которые любят интервьюеры),
- «Когда использовать, а когда нет» (практический гайд по выбору).
Важный практический совет по работе с этой шпаргалкой. Не читайте подряд: сначала просмотрите оглавление, а потом открывайте нужный раздел — так информация ложится в контекст задачи и запоминается в три раза лучше, чем при линейном чтении. Это принцип «обучения через необходимость» — мозг лучше запоминает то, что ему нужно прямо сейчас, а не абстрактные знания «на потом». Аналогия: вы не читаете словарь от A до Z — вы ищете конкретное слово, когда оно нужно. Так и здесь.
Какую версию C++ использовать и как запускать примеры. Все примеры рассчитаны на C++20/23 — собирайте их с флагом -std=c++20 (например, g++ -std=c++20 -Wall -Wextra file.cpp -o file), а при сомнениях запускайте под санитайзерами (см. раздел про UB): -fsanitize=address,undefined ловит 90% типичных ошибок ещё до того, как они превратятся в ночной баг в продакшене. Что значат флаги:
-std=c++20— включить стандарт C++20 (без этого флага новый синтаксис не скомпилируется);-Wall -Wextra— показать все предупреждения (warnings) — их НЕЛЬЗЯ игнорировать, они ловят реальные баги;-fsanitize=address— ловит ошибки памяти (use-after-free, утечки, выход за границы массива);-fsanitize=undefined— ловит UB (undefined behavior): переполнение знаковых чисел, деление на ноль, сдвиги, выход за границыstd::vectorчерез итератор.
Почему санитайзеры — это must-have для обучения. Санитайзер замедляет программу в 2–5 раз и увеличивает расход памяти в 2–3 раза, поэтому в продакшене его не включают. Но для разработки и обучения это лучший друг: вместо того чтобы гадать, почему программа выводит ерунду или падает через час, вы получаете точный отчёт: файл, строка, что случилось. Типичная ошибка новичков: запускать код без санитайзера, получать странное поведение, мучиться часами. Запустите с санитайзером сразу — сэкономите часы.
Техника активного чтения — как извлечь максимум из шпаргалки. Полезный приём: после прочтения раздела закройте шпаргалку и попробуйте воспроизвести ключевой пример по памяти — если получилось, материал усвоен; если нет — перечитайте именно ту часть, где застряли. Это называется «активное воспроизведение» (active recall) и работает в 3–5 раз эффективнее, чем простое перечитывание. Аналогия: перечитать параграф — это как посмотреть, как кто-то другой играет в шахматы. Воспроизвести по памяти — это сыграть самому. Второй путь учит, первый — нет.
Быстрый старт
Что вообще значит «запустить C++-программу». C++ — компилируемый язык: процессор понимает только машинный код (нули и единицы), а не исходники на C++. Поэтому перед запуском специальная программа — компилятор — переводит ваш текст в машинные инструкции и кладёт их в исполняемый файл. Понимание того, как устроен этот процесс, помогает быстро разбираться с ошибками сборки и не бояться CMake. Короткий словарик терминов ниже пригодится сразу: исходник — ваш .cpp, объектный файл — промежуточный .o/obj, библиотека — набор готовых функций (.a/.so/.dll), исполняемый файл — итоговая программа, которую запускает ОС.
Компиляция — что это такое и почему в два этапа. C++ — компилируемый язык: каждый .cpp компилируется независимо в объектный файл (промежуточный бинарник с машинным кодом, но без адресов внешних функций), а линкер соединяет объектные файлы и библиотеки в один исполняемый файл. Представьте себе конвейер сборки мебели: компилятор — это столяр, который из каждой детали (.cpp) делает готовый модуль (.o) со всеми пазами и отверстиями, но без фурнитуры. Линкер — это сборщик, который скрепляет модули болтами, подбирает недостающие детали из каталога (библиотеки) и выдаёт готовый шкаф (исполняемый файл). Такой конвейер позволяет собирать большие проекты по частям и кешировать результат: изменили один .cpp — перекомпилируется только он, остальные модули остаются прежними. Поэтому даже огромные проекты собираются за секунды, а не часы. Ниже — минимальная программа, затем установка компилятора и набор флагов, с которым стоит собирать любой код.
Полезная анатомия ошибки сборки. Если компилятор сообщает «expected ';' before …» или «no matching function for call to …» — проблема в вашем исходнике, линкер тут ни при чём. Если же вы видите «undefined reference to …» или «multiple definition of …» — компиляция прошла успешно, но линкер не может найти нужную функцию (часто забыт .cpp в списке файлов или target_link_libraries для библиотеки), либо нашёл две реализации одного имени в разных единицах трансляции (обычно из-за #include .cpp вместо заголовка). Запомнить просто: первая фаза — синтаксис и типы, вторая фаза — связи между кусками кода. Разница между ними видна по номеру ошибки: error C2065/error: … — компилятор, error LNK…/undefined reference — линкер.
// hello.cpp — первая программа
#include <iostream> // std::cout — поток вывода на консоль
#include <string> // std::string — строка, сама управляет своей памятью
#include <vector> // std::vector — динамический массив
int main() { // точка входа: ОС вызывает именно её
std::vector<std::string> names{"мир", "C++"}; // вектор из двух строк
for (const auto& n : names) { // range-for: перебираем элементы по ссылке
std::cout << "Привет, " << n << "!\n"; // << — оператор вывода; "\n" дешевле std::endl
}
return 0; // 0 — код успешного завершения
}
Что здесь важно понять с первого взгляда.
#include— это не «импорт модуля», а тупой текстовый копи-паст: препроцессор буквально вставляет содержимое<iostream>в ваш файл ещё до компиляции. Отсюда и#(директива препроцессора).std::— пространство имён стандартной библиотеки. Префикс нужен, чтобы ваши имена не конфликтовали со стандартными (например, ваша функцияcountне сломаетstd::count).int main()— единственная функция, которую операционная система вызывает сама при запуске программы. Всё, что внеmain, выполняется только еслиmainэто вызвал.const auto&в цикле — три меры предосторожности сразу:autoугадывает тип,&не копирует строку (избегаем лишних аллокаций),constзапрещает случайно её модифицировать.std::vector<std::string>— параметризованный (шаблонный) тип:vector— это «коробка», а в<>указывают, что именно в ней лежит. Так же работаютstd::array,std::list,std::map— одна коробка для разного содержимого.// комментарий— однострочный, до конца строки;/* ... */— многострочный. Многострочные нельзя вкладывать друг в друга — это частый источник «программа съела полфайла».\nпротивstd::endl: первое — просто символ перевода строки (быстро), второе дополнительно вызываетflush()— сбрасывает буфер на экран (полезно в отладке, но в горячем цикле — лишние затраты).return 0;в концеmain— код возврата: 0 означает «всё хорошо». Скрипты, CI и конвейеры читают именно это число, а не вывод на экран.
Установка компилятора одной строкой. На современных Linux (Ubuntu/Debian) обычно хватает sudo apt install build-essential — это сразу GCC, make и стандартные заголовки. На macOS — xcode-select --install (идёт в комплекте с macOS) или brew install llvm для самого свежего Clang. На Windows есть несколько путей: MSYS2/MinGW-w64 — UNIX-подобная среда с GCC прямо в Windows; WSL — настоящий Linux внутри Windows (лучший вариант для кроссплатформенности и совместимости с туториалами); Visual Studio (MSVC) — компилятор от Microsoft, идущий в комплекте с IDE. Для обучения подойдёт любой; для серьёзной работы выбирайте по целевой платформе проекта.
# Linux: sudo apt install build-essential g++ | macOS: xcode-select --install
# Windows: MSYS2, MinGW-w64 или WSL
g++ hello.cpp -o hello # собрать
./hello # запустить → Привет, мир! / Привет, C++!
# Рабочий набор флагов, который стоит набирать всегда:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -g hello.cpp -o hello
# Проверка на скрытые баги: санитайзеры адреса и неопределённого поведения
g++ -std=c++20 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer hello.cpp -o hello_asan
./hello_asan
Почему именно такие флаги, а не другие. -std=c++20 фиксирует стандарт, -Wall -Wextra -Wpedantic включают максимум предупреждений, -g добавляет отладочную информацию, -O2 оптимизирует. Предупреждения компилятора — это найденные баги; в CI добавляйте -Werror, чтобы они останавливали сборку. clang++ совместим по флагам и часто даёт более понятные диагностики; сборка одного и того же кода двумя компиляторами — дешёвый способ поймать UB. Почему -O1 для санитайзеров, а не -O2? Потому что при агрессивной оптимизации компилятор может переставить инструкции так, что санитайзер перестанет точно указывать на виновника. -O1 — компромисс: достаточно оптимизации для реалистичных условий, но не настолько, чтобы замести следы.
g++ против clang++ — практические нюансы. Оба компилятора реализуют один и тот же стандарт, но отличаются в деталях. GCC — исторический стандарт Linux, широкая поддержка платформ, отличная оптимизация под x86, иногда строже к UB. Clang — заметно более быстрая компиляция, лучшие диагностические сообщения (часто с «подсказкой» — did you mean …?), лучшая интеграция с IDE, дружелюбнее к модулям и constexpr. Совет из практики: возьмите за правило периодически собирать проект обеими компиляторами — то, что один пропустит, другой поймает. Полезная тонкость: -fdiagnostics-color=always раскрашивает ошибки в терминале, что сильно упрощает чтение длинных сообщений.
Как запомнить флаги. -std — стандарт (какой C++), -W — warnings (предупреждения), -O — optimization (уровень 0…3), -g — debug info (символы для отладчика), -fsanitize — включить санитайзеры. Мнемоника: «сначала стандарт и предупреждения, потом оптимизация, потом отладка». Полезный мысленный образ: представьте, что вы настраиваете микроскоп — сначала выбираете режим подсветки (-std), потом включаете все индикаторы (-Wall), потом фокусируете картинку (-O), потом включаете рентген (-g/-fsanitize). Каждый шаг добавляет информации, а не заменяет предыдущий.
Мнемоника для уровней оптимизации. -O0 — без оптимизации (для пошаговой отладки, отладочная информация точнее); -O1 — базовая (быстрая сборка, минимальные улучшения); -O2 — рекомендуемая для релиза (большинство безопасных оптимизаций); -O3 — агрессивная (векторизация, развёртка циклов — может ухудшить размер бинарника и не всегда ускоряет код с маленькими «горячими» функциями); -Os — оптимизация по размеру (для embedded и мобильных платформ); -Ofast — опасная: включает -ffast-math, нарушающую строгий IEEE 754 (может сломать численные расчёты и сортировки на граничных значениях).
Частая ловушка. Не собирайте релиз с -O2 и без -Wall: часть ошибок проявляется только при оптимизации (компилятор опирается на отсутствие UB). И наоборот — не измеряйте производительность Debug-сборки: -O0 может замедлить код в десятки раз. Аналогия: замерять скорость машины на ручной буксировке — бессмысленно. И ещё коварный сценарий: код с -O0 «работает», а с -O2 падает — почти наверняка это UB, которое компилятор при оптимизации решил использовать «в свою пользу» (см. правило «as-if»). Санитайзеры ловят это в Debug, но не все.
Когда использовать какой компилятор. Для повседневной работы — тот, что стоит в системе (g++ на Linux, clang++ на macOS). Перед релизом полезно прогнать оба: разные реализации по-разному оптимизируют и находят разные проблемы. Практическое правило: GCC и Clang договорились о стандарте, но расходятся в расширениях и эвристиках. Если код собирается только одним из них — это повод задуматься, не используете ли вы случайно нестандартную возможность. Также MSVC (Visual Studio) имеет свои особенности в обработке шаблонов и constexpr — кросс-компиляция учит писать переносимый код.
Для проектов из нескольких файлов используют CMake:
# CMakeLists.txt — «рецепт» сборки: описывает цель и её зависимости
cmake_minimum_required(VERSION 3.20) # минимальная версия CMake
project(hello CXX) # имя проекта; CXX — используем C++
set(CMAKE_CXX_STANDARD 20) # стандарт языка
set(CMAKE_CXX_STANDARD_REQUIRED ON) # запретить откат на более старый стандарт
add_executable(hello hello.cpp) # цель «hello» собирается из hello.cpp
target_compile_options(hello PRIVATE -Wall -Wextra -Wpedantic) # флаги именно этой цели
cmake -B build -DCMAKE_BUILD_TYPE=Debug # конфигурация: сгенерировать файлы сборки в build/
cmake --build build # сборка (запускает make/ninja внутри)
./build/hello
Зачем CMake, если g++ и так работает. Для одного-двух файлов CMake избыточен, но в реальном проекте он избавляет от ручной возни: сам находит зависимости, переносим между платформами и IDE и позволяет подключать библиотеки одной строкой (find_package, target_link_libraries). Команды g++ ... из предыдущего блока и CMake — не конкуренты: CMake под капотом вызывает тот же компилятор. Аналогия: g++ напрямую — это ручная готовка по рецепту, а CMake — это кухонный комбайн с тем же рецептом внутри. Для пиццы разница незаметна, для банкета — критична. Полезно знать: CMAKE_BUILD_TYPE влияет на дефолтные флаги — Debug добавит -g, Release добавит -O3 -DNDEBUG. Макрос NDEBUG отключает assert(), поэтому измерения производительности в Release — единственно честные.
Минимум CMake, который нужно понимать с первого дня. Цель (target) — это «что мы делаем»: исполняемый файл (add_executable) или библиотека (add_library). Команды свойств (set, target_*) настраивают, как именно. Переменные в CMake — это строки с подстановкой через ${...}, но современный стиль рекомендует использовать таргетированные команды (target_compile_features, target_link_libraries) — они не «текут» между целями и не зависят от порядка вызовов в файле. Папка build/ — отдельная директория для артефактов: её можно смело удалить, чтобы начать сборку с нуля (rm -rf build/). Если CMake не находит зависимость — добавьте find_package(...) или add_subdirectory(...), чтобы он «увидел» нужный проект.
Что ещё нужно знать про main. Точка входа обязана возвращать int; написание void main() нестандартно и не компилируется частью компиляторов. Код возврата видит оболочка: 0 — успех, ненулевое — ошибка (echo $? в bash). Аргументы командной строки приходят в int main(int argc, char* argv[]): argc — их число, argv[0] — имя программы, argv[argc] — гарантированный nullptr. Дополнение из практики: return 0 в main можно опустить — компилятор подставит его за вас (только в main, ни в какой другой функции). Но писать явно — хорошая привычка: видно, что функция действительно завершается нормально, а не «упала» через исключение или exit(). Коды возврата принято делать осмысленными: 1 — общая ошибка, 2 — неверные аргументы, 127 — «команда не найдена» (по соглашению). Скрипты и CI на это полагаются: if ./myprog; then ... работает именно благодаря коду возврата.
Альтернативные варианты main. Стандарт допускает ровно две формы: int main() и int main(int argc, char* argv[]). Параметры argc и argv — это «как ОС передала вам аргументы»: имена исторические (от argument count и argument vector). Имена параметров менять можно, но argc/argv — настолько устоявшееся соглашение, что отступать от него не стоит (код сразу становится «незнакомым»). Если аргументы не нужны — пишите короткую форму int main(), неиспользованные параметры просто игнорируются.
Частая ловушка. Программа «молча ничего не делает» — проверьте, что вы запускаете именно свежесобранный файл (а не старый из другого каталога), и что компиляция прошла без ошибок: g++ при успехе не печатает ничего, поэтому отсутствие вывода легко принять за успех. Расширенный чек-лист, когда «ничего не работает»:
- Сработала ли компиляция?
echo $?послеg++ ...— должно быть0. - Тот ли файл вы запускаете?
ls -la hello,pwd,file hello(он вообще исполняемый?). - Есть ли вывод в
stderr? Иногда ошибки уходят туда, а не вstdout. Запустите./hello 2>&1 | cat, чтобы увидеть всё. - Не упал ли процесс из-за санитайзера? Если собирали с
-fsanitize, любое UB печатает подробный отчёт вstderr. - Не зависит ли поведение от текущего каталога? Программа могла прочитать файл, которого здесь нет.
- Нет ли «зомби-процессов» или не занят ли порт, если сервер пишет «address already in use»? Помогает
lsof -i :PORT. - Не путаете ли вы
&(фоновый запуск) и&&(логическое «и»)? Одна прописная буква в скрипте меняет всё. - Не съел ли буфер ваш вывод?
std::coutбуферизуется — при крэше (abort, segfault) последние строки не успевают напечататься.
Ещё одна коварная ловушка: забытый буфер вывода. std::cout по умолчанию буферизуется — программа может завершиться штатно, но вы не увидите последних строк (например, при отладке или пайплайне). В обычной программе это не страшно: при нормальном завершении буфер сбрасывается. Но если ваша программа падает (segfault, abort, std::terminate) — буфер не успевает сброситься, и вы не видите, на что упало. Отладочный приём: для подозрительного вывода используйте std::cerr (он небуферизован по умолчанию) либо включите принудительный сброс через std::cout << std::unitbuf (каждый << сразу идёт на экран). То же касается отладочных логов: пишите в std::cerr, чтобы видеть их даже на «падающем» запуске. В продакшене с логированием в файл используют явный flush() либо сторонние библиотеки с очередями — но это уже за пределами «быстрого старта».
Ключевые термины
Простое определение. Это «словарь идей» современного C++. Не синтаксис, а принципы: как код собирается в программу, как живут объекты, как компилятор принимает решения. Если вы понимаете эти идеи, чужой код и умные книжки перестают казаться магией. Термины ниже встречаются буквально в каждой главе дальше — без них не разобрать ни std::vector, ни шаблоны, ни правила линковки.
Дальше в тексте часто встречаются эти слова. Не старайтесь выучить их сразу — вернитесь к таблице, когда термин попадётся в коде. Третий столбец даёт бытовую аналогию: она помогает ухватить смысл, даже если формальное определение забылось. В среднем столбце помимо формального определения есть короткая подсказка «на что обратить внимание» — это та деталь, которая чаще всего всплывает на практике или на собеседовании.
| Термин | Значение | Как понять (аналогия) |
|---|---|---|
| Компиляционная единица (TU) | Один .cpp файл после работы препроцессора — единица компиляции. Препроцессор сначала подставляет содержимое всех #include и раскрывает макросы, и только то, что получилось, видит компилятор. Поэтому два разных .cpp, включающих один и тот же заголовок, компилируются независимо.Почему так: компиляция параллелится именно по TU, поэтому тяжёлые заголовки ( #include <iostream>) замедляют каждую единицу отдельно.Подводный камень: #pragma once и #ifndef спасают от двойного включения внутри одной TU, но не объединяют TU — каждый .cpp всё равно пересобирается отдельно, и в нём заново создаются inline-переменные, constexpr-объекты и т. п. |
Один лист черновика: компилятор видит только его и не знает, что написано на соседнем листе. Объединяет листы уже линковщик. |
| ODR (One Definition Rule) | Каждая сущность (функция, переменная, класс) должна иметь ровно одно определение во всей программе, но объявлений может быть сколько угодно. Нарушение ODR в разных TU — частая причина странных ошибок линковки и «необъяснимого» UB. Почему так: иначе линковщик не знал бы, какое из определений «настоящее», а для классов и функций — какой у объекта размер и какие у него поля. Частая ошибка: определить функцию в заголовке без inline — каждый TU получит свою копию, и компилятор вправе молча взять любую из них, что иногда превращается в UB, а иногда в multiple definition при линковке. |
У человека один паспорт: два разных паспорта — уже подлог. Несколько визиток с одним именем — пожалуйста, это только объявления. |
| Объявление vs определение | Объявление описывает имя и тип сущности (например, прототип функции, extern int x;), определение — создаёт саму сущность: тело функции, инициализированную переменную, тело класса. Без определения сущность нельзя использовать, но ссылаться на имя можно.Частая ошибка: написать int x = 0; в заголовке — каждая TU получит «свою» переменную, и при линковке вы получите либо multiple definition, либо (с static/inline) тихое размножение состояния, которое потом невозможно отладить.На что обратить внимание: ключевое слово extern в объявлении говорит «определение где-то в другом месте», а его отсутствие в инициализированной переменной — «это и есть определение». |
Визитка (объявление) и сам человек (определение). По визитке вы знаете имя и должность, но поговорить ещё не с кем. |
| lvalue / rvalue | lvalue — выражение, у которого есть имя и адрес, по нему можно взять ссылку. rvalue — временное значение, которое «на один шаг»: результат арифметики, литерал, результат вызова функции, возвращающей по значению. С++11 расширил это до glvalue/xvalue/prvalue, но для практики достаточно lvalue vs rvalue. На что обратить внимание: std::move сам ничего не перемещает — он лишь превращает lvalue в rvalue, разрешая вызвать конструктор перемещения. Само перемещение делает принимающая сторона.Подводный камень: неконстантная ссылка ( T&) примет только lvalue, а константная (const T&) — и lvalue, и rvalue. Это правило «обеих рук» объясняет, почему конструктор копирования принимает именно const T&. |
Подписанная коробка на полке (lvalue: стоит, можно вернуться) и то, что вы держите в руках и через секунду выбросите (rvalue). |
| RAII | Resource Acquisition Is Initialization — ресурс (память, файл, мьютекс, сокет) захватывается в конструкторе и освобождается в деструкторе. Объект живёт по стековым правилам, поэтому выход из области видимости гарантирует освобождение, даже при исключении. Почему так: раскрутка стека при исключении вызывает деструкторы всех локальных объектов в обратном порядке — это и есть механизм автоматической уборки. Без RAII пришлось бы вручную расставлять try/catch для каждого ресурса, и это всё равно не спасло бы от return посередине функции.Практический совет: забудьте про голый new/delete — пусть ресурсом владеет объект (std::unique_ptr, std::lock_guard, std::fstream, std::scoped_lock). |
Уборщик, который приходит автоматически, когда гость уходит. Не надо ставить free вручную «на всякий случай» — деструктор всё сделает сам. |
| vtable | Таблица виртуальных функций, скрыто создаваемая компилятором для полиморфного класса (класса хотя бы с одной virtual функцией). Каждый объект хранит скрытый указатель на свою vtable, через который идёт динамическая диспетчеризация. Именно из-за vtable объекты становятся «тяжелее», а класс перестаёт быть trivial.На что обратить внимание: за виртуальность платят все экземпляры класса лишним указателем (обычно +8 байт на 64-битной платформе), а вызовы — лишним прыжком по указателю. В горячих циклах это иногда «лечат» через devirtualization или CRTP. Частая ошибка: забыть виртуальный деструктор в базовом классе — delete по указателю на базу вызовет только базовый деструктор и потечёт память в производных полях. |
Таблица маршрутов: куда идти за нужной функцией. Вы спрашиваете у объекта «как тебя нарисовать?», он смотрит в свою таблицу и идёт по нужному маршруту. |
| UB (undefined behavior) | Неопределённое поведение: стандарт не налагает требований, компилятор вправе делать что угодно. Это не «возможно сломается» — компилятор может оптимизировать код, считая, что UB не произойдёт, и поведение может стать совершенно неожиданным (включая «работает в debug, падает в release»). Типичные примеры UB: signed integer overflow, разыменование nullptr, use-after-free, чтение неинициализированной переменной, гонка данных (data race), доступ за пределы массива, вызов чисто виртуальной функции.Подводный камень: компилятор вправе предполагать, что UB нет, и убирать «защитные» проверки. Поэтому if (p) *p; после *p = 0; может превратиться в безусловное разыменование, и вы получите крэш без видимой причины — компилятор «доказал», что p ненулевой, и выкинул условие. |
Светофор без правил — каждый едет как хочет. Даже если обычно все ездят аккуратно, однажды грузовик выедет на встречку, и никто не удивится. |
| POD / trivial | Класс без виртуальных функций и пользовательских специальных функций (конструкторов/деструкторов/operator=), с тривиально копируемыми членами. Такой объект можно побайтово копировать через memcpy, он совместим по размещению с C-структурами и безопасен для некоторых низкоуровневых трюков.На что обратить внимание: в C++11 и позже формально различают std::is_trivial и std::is_standard_layout, а термин «POD» в C++20 официально deprecated — лучше использовать трейты.Практический совет: свойства класса проверяются трейтами std::is_trivial_v<T> и std::is_standard_layout_v<T> — они пригодятся и для static_assert, и для документирования намерений автора класса. |
Мешок с байтами: можно пересыпать целиком из одного мешка в другой, и содержимое не поменяется. |
| SSO | Small String Optimization: короткая строка хранится внутри самого объекта std::string, без обращения к куче. Обычно порог — около 15–22 символов (зависит от реализации). Это делает типичные короткие строки почти бесплатными по аллокациям.На что обратить внимание: SSO работает автоматически — но как только строка стала длиннее порога, внутри объекта переключается режим хранения и начинается обычная аллокация в куче. Резервировать «короткую» строку через reserve() бесполезно — он не повлияет на SSO.Практический совет: для уменьшения аллокаций следите за длиной строк, а не за их ёмкостью: длинные тексты лучше держать в отдельных std::string, а не дробить и склеивать. |
Маленький чемоданчик умещается в кармане пальто — не надо тащить большой чемодан из багажа. |
| SFINAE | Substitution Failure Is Not An Error — при подстановке типов в шаблон неудачная подстановка не считается ошибкой, а просто отбрасывает перегрузку из списка кандидатов. Это старый механизм «отбора» шаблонов ещё до C++20; работает, но синтаксис бывает пугающим (enable_if, decltype, void_t).Почему так: SFINAE работает только в «непосредственном контексте» подстановки (сигнатура, тип возврата, аргументы по умолчанию), но не в теле функции — иначе ошибка превратилась бы в hard error компиляции всего шаблона, а не тихий отбор кандидатов. Практический совет: если видите typename = void или std::void_t<decltype(...)> в незнакомом коде — это почти наверняка SFINAE-трюк для проверки наличия члена или вложенного типа. |
Отбор по анкете: неподходящую кандидатуру отбрасывают тихо, не вызывая на собеседование. Только в C++20 то же самое делают Concepts начисто. |
| Concepts | Именованные требования к типам-параметрам шаблона (C++20). Пишутся как template<class T> requires Integral<T> или через сокращённый синтаксис. Заменяют большинство случаев SFINAE, дают читаемые сообщения об ошибках и работают как «контракт» между автором шаблона и пользователем.На что обратить внимание: есть два разных requires — requires-clause (ограничение на параметры шаблона) и requires-expression (проверка свойств типа в виде логического выражения). Это разные сущности, хоть и пишутся одинаково.Практический совет: свои концепты лучше собирать в одном заголовке и переиспользовать — это сразу делает API шаблонов самодокументируемым и даёт ошибки вида «T не Integral», а не «не компилируется в строке 347». |
Чёткие требования в вакансии: «нужен C++ и SQL». Кандидат без SQL не пройдёт, и рекрутер сразу скажет почему. |
| Move | Перемещение: передача внутренних ресурсов (буферов, хэндлов) из одного объекта в другой без покопийного копирования. Реализуется через конструктор перемещения и оператор присваивания перемещением, которые принимают rvalue-ссылку (T&&). После перемещения исходный объект остаётся в «valid but unspecified state» — его можно уничтожить или присвоить заново, но нельзя читать без осторожности.Подводный камень: если у класса нет собственного move-конструктора, компилятор попробует сгенерировать его, а если и это невозможно — молча вызовет копирование. Поэтому «перемещение» может оказаться копией, и никакого выигрыша не будет. Все стандартные контейнеры ( std::vector, std::string, std::unique_ptr) умеют перемещаться «по-настоящему».На что обратить внимание: «valid but unspecified» — не «пустой». У перемещённой std::string может быть любой размер, но сам объект гарантированно можно уничтожить или присвоить заново. |
Передать ключи от дома, а не строить копию дома. Старый владелец остался без дома, но формально его не снесли — он «valid, но unspecified». |
| Slicing | Срез: копирование производного объекта в базовый по значению обрезает «производную часть». Тип становится базовым, специфичные поля и поведение исчезают. Лечится передачей по указателю/ссылке или виртуальным клонированием. Частая ошибка: хранить полиморфные объекты по значению в контейнере ( std::vector<Base>) — добавление туда Derived обрежет его до Base ещё на этапе копирования, и никакого полиморфизма внутри контейнера не останется.Практический совет: для коллекций полиморфных объектов используйте std::vector<std::unique_ptr<Base>> плюс виртуальный метод clone() — так каждый элемент сохранит свой настоящий тип при копировании контейнера. |
Обрезали фотографию группы — остался только первый ряд. Люди на втором ряду никуда не делись, просто исчезли с этого снимка. |
| Инвалидация | Ситуация, когда итератор/указатель/ссылка перестают указывать на валидный элемент после изменения контейнера. Типичный пример — std::vector::push_back может перевыделить память, и все итераторы «протухают». Правило: после модифицирующей операции итераторы не использовать.Практический совет: перед циклом с push_back позовите vector::reserve(ожидаемый_размер) — это убирает реаллокации и спасает итераторы. Для частых вставок/удалений в середине возьмите std::list или std::deque — у них правила инвалидации мягче (хотя и не нулевые).Подводный камень: даже std::list инвалидирует итератор того элемента, который удалили, — это «очевидное» исключение, про которое часто забывают. Идиома erase-remove для vector тоже требует пересоздания итератора: vec.erase(std::remove(vec.begin(), vec.end(), x), vec.end());. |
Билет на рейс, который отменили: номер билета есть, а рейса нет. По номеру садиться уже некуда. |
| NRVO | Named Return Value Optimization: компилятор создаёт возвращаемый объект прямо на месте будущего результата вызывающей стороны, без промежуточного копирования. Это оптимизация, а не гарантия языка (в отличие от обязательной copy elision для prvalue в C++17), но все крупные компиляторы её делают. На что обратить внимание: NRVO легко «отключить» самому — достаточно добавить несколько return с разными локальными объектами или обернуть возврат в if/std::optional. Если важна гарантия — пишите фабрику, возвращающую prvalue (return T{...}; без имени), и тогда включается обязательная copy elision из C++17.Подводный камень: NRVO работает для одного возвращаемого объекта, но не для std::optional<T> или std::pair<T, U>, обёрнутых вокруг него — там снова появляется копия/перемещение. |
Строим дом сразу на участке, а не в мастерской с последующей перевозкой. |
| ADL | Argument-Dependent Lookup: поиск функции/оператора ведётся не только в текущем пространстве имён, но и в пространствах имён типов аргументов. Поэтому swap(a, b) находит std::swap, а оператор << для пользовательского типа находит operator<< из того же namespace. Может стать источником сюрпризов, если в чужих namespace спрятаны неожиданные перегрузки.Практический совет: чтобы оператор << для своего типа находился по ADL и не вызывал неявных преобразований, его часто делают «скрытым другом» (friend внутри класса, без объявления во внешнем namespace) — это убирает неявные conversions и сохраняет инкапсуляцию.Подводный камень: ADL применяется после обычного поиска по unqualified name, поэтому собственные перегрузки имеют приоритет, но если их нет, вызов уйдёт в «чужую» функцию из чужого namespace — и ошибка компиляции будет выглядеть совершенно не там, где вы её ждёте. |
Специалиста ищут там, где он работает, а не в общем списке. Если вызвали «Плотника», его найдут в «Столярной мастерской», а не в «Больнице». |
Как запомнить главное. Термины делятся на три группы, и эта группировка — половина успеха:
- Про сборку программы — TU, ODR, ADL. Эти слова встретятся, когда речь пойдёт про заголовки, линковку и поиск функций. Если вы понимаете, как из
.cppполучается исполняемый файл, эта тройка перестаёт быть магией. Конкретнее: TU объясняет, почему каждый.cppкомпилируется независимо; ODR — почему нельзя определить функцию в.hбезinline; ADL — почемуstd::swap(a, b)находит «правильную» перегрузку безstd::-префикса. - Про память и владение ресурсами — RAII, move, SSO, slicing, инвалидация. Здесь живут главные практические вопросы: «кто освобождает память?», «почему итератор сломался?», «почему после
push_backвсё упало?». Эти термины напрямую связаны: RAII — общий принцип, move — способ передачи ресурсов без копий, SSO — оптимизация для частного случая, slicing и инвалидация — типичные ошибки, которые из них вытекают. - Про компилятор и шаблоны — UB, SFINAE, concepts, vtable, NRVO, POD/trivial. Это «внутренняя кухня»: как компилятор принимает решения, почему код быстрый или медленный, откуда берутся неожиданные сообщения об ошибках. UB и vtable/NRVO — про оптимизации и их границы; SFINAE/concepts — про отбор шаблонов, от «страшного» старого к новому чистому синтаксису.
Если запомнить эту группировку, таблица перестаёт быть списком случайных слов: каждый термин сразу попадает в свою «корзинку», и его легче вытащить из памяти. Дополнительный приём: когда встречаете новый термин, попробуйте мысленно положить его в одну из этих трёх корзинок — если не получается, значит, вы не до конца поняли, о чём он, и стоит вернуться к таблице.
На собеседовании спросят. Почти гарантированно попросят объяснить RAII, UB и slicing своими словами — это маркеры того, что человек писал на современном C++, а не только читал учебник. Почти так же часто залезают в соседнюю зону и спрашивают:
- разницу между move и копированием (и что значит «valid but unspecified state»);
- почему инвалидация итераторов — причина падений в продакшене и как с этим жить (
reserve,listвместоvector, пересоздание итератора); - что такое vtable и почему класс с виртуальными методами становится «не POD» и тяжелее по памяти;
- зачем нужны concepts и чем они лучше SFINAE (читаемость ошибок, явные требования).
Как отвечать, чтобы не «провалиться»: не начинайте с формального определения из стандарта — собеседователь это и так знает. Лучше сразу привести короткий сценарий из жизни: «RAII — это когда я открыл файл в конструкторе объекта, и теперь даже если из функции вылетело исключение, файл закроется автоматически в деструкторе, и мне не надо писать try/catch вокруг каждого вызова». На конкретном примере видно, что вы не зубрили, а понимаете. Если спрашивают про UB — обязательно упомяните, что компилятор может оптимизировать код, считая, что UB не случится, и привести конкретный пример (if (p) *p после *p = 0, исчезающая «защитная» проверка). Если спрашивают про slicing — упомяните про std::vector<std::unique_ptr<Base>> как канонический способ хранить полиморфные объекты и про виртуальный clone() для глубокого копирования.
Хороший ответ — это не пересказ определения, а короткая история: «я встретил это, когда у меня в программе случилось вот это, и я решил это так». Умеете рассказать такую историю — собеседование прошло.
Модель компиляции и линковки
Что такое компиляция в C++? Это не один процесс, а конвейер — последовательность из четырёх независимых программ, каждая из которых берёт результат работы предыдущей и превращает его во что-то более «машинно-ориентированное». Понимание этого конвейера — ключ к пониманию большинства ошибок, с которыми сталкивается новичок, потому что на каждом этапе свои правила и свои сообщения об ошибках. Если хотите совсем коротко: компиляция в широком смысле — это всё, что превращает ваш .cpp в исполняемый файл; в узком смысле (когда говорят «фаза компиляции») — это только этапы 1–3 до получения объектного файла.
Препроцессор — самый первый этап, и он работает только с текстом: ещё не понимает ни типов, ни синтаксиса C++. Его задача — подставить содержимое #include (буквально скопировать текст из указанного файла) и раскрыть #define-макросы (заменить каждое вхождение имени на его тело). Представьте себе редактора, который по инструкции «везде, где написано X, подставь Y», правит книгу до того, как её начнут читать программисты. Именно поэтому препроцессор не умеет проверять типы — он оперирует буквами, а не смыслом. Любая синтаксическая ошибка, попавшая внутрь макроса, обнаружится только на следующем этапе, и сообщение об ошибке будет ссылаться уже на раскрытый текст — поэтому иногда трейс указывает в странное место.
Компилятор — следующий этап. Он берёт уже «раскрытый» текст и переводит его в ассемблер — символьный машинный код конкретного процессора (x86, ARM и т.п.). Здесь уже проверяются типы, синтаксис, семантика C++. На выходе — файл, понятный только ассемблеру, но не процессору напрямую. Именно компилятор решает, какие конструкции языка корректны, какие перегрузки функций вызвать, как сопоставить шаблон с аргументами. Кстати, «компилятор» в широком смысле часто называют всю цепочку g++, а в узком — только средний этап; имейте это в виду, читая документацию.
Ассемблер превращает этот ассемблерный текст в объектный код (.o или .obj): это уже настоящие машинные инструкции, но в файле есть «дыры» — места, где должны быть адреса функций и переменных из других файлов, но их пока нет. Поэтому к объектному файлу прилагается таблица символов — список имён, которые он объявляет и которые хочет получить снаружи. Объектный файл — это почти готовый кусок программы, но с пометками «здесь будет адрес функции X, подставите потом».
Линкер — финальный сборщик. Он берёт все объектные файлы и нужные библиотеки, сопоставляет имена из таблиц символов, подставляет реальные адреса и склеивает всё в один исполняемый файл или библиотеку. Если какого-то имени не нашлось — вы получаете ошибку именно на этом этапе. Линкер ничего не знает о C++ как о языке: для него есть только имена символов и их таблицы. Поэтому многие ошибки ODR (множественное определение) звучат «сухо»: линкер просто видит два символа с одним именем.
Понимание этих этапов объясняет две самые частые ошибки новичка: undefined reference приходит от линкера (он не нашёл нужную функцию или переменную), а implicit declaration — от компилятора (он не нашёл объявление функции и решил использовать устаревшее правило языка). Запомните этот «язык ошибок»: видите reference — это линкер; видите declaration / type / expected — это компилятор; видите что-то про #include не найден — это препроцессор.
g++ -E main.cpp -o main.i # 1. препроцессор: раскрыл #include и #define
g++ -S main.i -o main.s # 2. компилятор: C++ → ассемблер
g++ -c main.s -o main.o # 3. ассемблер: ассемблер → объектный код
g++ main.o -o main # 4. линкер: объектный код + библиотеки → бинарник
Как запомнить флаг каждого этапа. -E (expand/preprocess) — только препроцессинг; -S (source → assembly) — до ассемблера; -c (compile only) — до объектного файла; без флага — собрать и слинковать всё сразу. Порядок букв подсказывает «глубину» остановки: E → S → c → готовый файл. Полезный трюк: если программа падает с загадочной ошибкой, прогоните g++ -E и посмотрите, во что раскрылись макросы — иногда это сразу показывает источник проблемы. А g++ -c file.cpp — самый быстрый способ проверить, компилируется ли отдельный файл вообще (без линковки). Ещё одна мелочь: на практике обычно выполняют g++ -c file.cpp -o file.o одной командой, без промежуточных .i и .s; разбиение по этапам нужно либо для отладки, либо для понимания, что именно происходит.
Что такое «объявление» и «определение» и почему их путают. Это разные вещи, и компилятор/линкер относятся к ним по-разному. Объявление (declaration) — это обещание: «где-то есть сущность с таким именем и такой сигнатурой, доверьте мне». Определение (definition) — это воплощение: «вот конкретное тело функции / конкретное значение переменной». Одно и то же имя может быть объявлено сколько угодно раз в разных местах, но определено — строго один раз (это правило ODR, One Definition Rule, о нём ниже). Простая проверка для себя: если в строке есть фигурные скобки { ... } с телом функции — это определение; если только ; после сигнатуры — это объявление. Для переменных: extern int x; — объявление, int x = 5; (или int x; на уровне namespace) — определение.
На собеседовании спросят. «Почему ошибка undefined reference to 'foo' появляется не при компиляции, а при линковке?» Ответ: на этапе компиляции достаточно объявления (прототипа) — компилятор лишь проверяет типы и оставляет адрес незаполненным; тело функции ищет уже линкер, и если не находит — падает. Дополнение для полноты: если вы забыли объявление, а тело функции есть, вы получите implicit declaration от компилятора (в современных версиях GCC/Clang — warning, а не error, но с -Werror это станет ошибкой). Так что две похожие ошибки про одно и то же имя приходят от разных этапов конвейера — и поэтому важно понимать, кто именно жалуется. Следствие отсюда: правильно настроенный проект должен включать -Wall -Wextra -Werror — тогда «молчаливая» ошибка прототипа превратится в явную.
Правило разделения: объявления — в заголовок .hpp, определения — в .cpp. Заголовок подключается в каждый .cpp, который использует объявленные сущности. Зачем так? Компилятор должен знать сигнатуру (имя, типы параметров, тип возврата), чтобы проверить вызов, но тело функции нужно увидеть лишь один раз во всей программе — иначе символ продублируется и линкер выдаст ошибку множественного определения. Заголовок — это «оглавление» модуля, а .cpp — его «текст». Если провести аналогию из жизни: представьте книгу. Заголовок .hpp — это её оглавление с краткими описаниями глав (название функции, какие аргументы принимает, что возвращает). Сами главы лежат отдельно — в .cpp-файлах. Читатель (другой .cpp) сначала смотрит в оглавление, понимает, что за функция перед ним, а потом библиотекарь (линкер) находит для него полный текст нужной главы. Если бы каждая глава дублировалась в каждой книге — был бы хаос; то же самое произойдёт и с кодом.
Практический совет. Заголовочный файл должен быть самодостаточным — если его подключить в пустой .cpp, тот должен скомпилироваться без ошибок. Для этого подключайте в заголовке всё, что в нём используется (другие заголовки, стандартные библиотеки), а не полагайтесь на то, что пользователь «уже подключил что нужно». Это правило называется «include what you use» (IWYU) и экономит часы отладки. Симптом проблемы: проект собирается на вашей машине, но падает у коллеги, который подключает заголовок в другом порядке — классический признак «грязных» зависимостей в заголовке.
// point.hpp — заголовок с объявлениями
#pragma once // нестандартно, но поддерживается всеми компиляторами
#include <cstdlib> // std::abs
struct Point {
int x = 0;
int y = 0;
int sum() const; // объявление метода
};
inline int manhattan(const Point& a, const Point& b) { // inline: определение в заголовке разрешено
return std::abs(a.x - b.x) + std::abs(a.y - b.y);
}
extern int global_counter; // объявление переменной: определение будет в одном .cpp
// point.cpp — определения
#include "point.hpp"
int Point::sum() const { return x + y; } // определение метода
int global_counter = 0; // ровно одно определение на программу
О роли inline и extern. В примере выше функция manhattan помечена inline — это разрешает хранить её определение прямо в заголовке. Почему это работает без нарушения ODR? Слово inline подсказывает линкеру: «таких определений может быть несколько в разных TU, и они обязаны быть одинаковыми — выбери любое или объедини». Это легальный способ держать маленькие вспомогательные функции в заголовке, не заставляя пользователя подключать отдельный .cpp. Заметьте: исторически inline появилось как подсказка компилятору «вставь тело в место вызова для скорости», но в современном C++ его главная роль — именно «разрешение множественного определения в разных TU». Оптимизация теперь побочный эффект, а основная гарантия — линковочная.
А extern int global_counter; — это чистое объявление без значения. Слово extern означает: «переменная определена где-то в другом месте программы». Парное ему объявление без extern, но со значением — int global_counter = 0; в .cpp — становится определением. Если в программе окажется два определения одной и той же глобальной переменной, линкер выдаст ошибку множественного определения (multiple definition). Маленькая ловушка: extern int global_counter = 0; — это и объявление, и определение одновременно (extern + инициализатор), и оно тоже должно быть единственным.
Ключевые правила ODR:
- Функция или глобальная переменная определяется один раз во всей программе (кроме
inline). inline-функции и шаблоны разрешено определять в заголовке — линкер сольёт их.- Функции-члены, определённые внутри тела класса, неявно
inline. - Классы,
enum, шаблоны,constexpr-сущности можно определять в нескольких TU, если определения идентичны. - Нарушение ODR — это UB, а не ошибка компиляции: линкер молча выберет одно определение.
Почему это важно. Если одно и то же имя определено по-разному в двух .cpp, каждый файл компилируется в неведении о другом — ошибки нет. А линкер увидит два символа с одинаковым именем и просто возьмёт один. Получится, что разные части программы работают с разными версиями функции: в a.cpp size() вернёт 10, а в b.cpp — 20. Отлаживать такое крайне тяжело, поэтому ODR и называют «правилом, которое нельзя нарушать молча».
Дополнительная аналогия. ODR — это как паспортный контроль в аэропорту: имя у двух людей может совпадать, но паспорт (определение) должен быть один и тот же — уникальный. Если два человека покажут разные паспорта на одно имя, никто не сорвёт тревогу при посадке (компиляция пройдёт), но в городе назначения кто-то получит чужой багаж (линкер выберет наугад), и последствия будут непредсказуемыми. Именно поэтому хорошие компиляторы и линкеры включают предупреждения о множественных определениях, а в строгих режимах (-flto, -fno-common) делают это ошибкой. Совет: добавляйте -fno-common в CFLAGS для проектов на C и будьте внимательны с C++ — там похожие ситуации ловятся иначе.
// Ловушка: разные определения в разных TU → UB, линкер не заметит
// a.cpp: int size() { return 10; }
// b.cpp: int size() { return 20; } // нарушение ODR, поведение не определено
// Правильно: static или анонимное пространство имён делает связь внутренней
namespace {
int internal_helper() { return 42; } // видна только в этом TU
}
Что здесь происходит и зачем это нужно. В первом блоке показана классическая ловушка: в двух разных единицах трансляции (translation unit, TU — это .cpp после препроцессора) определена функция с одинаковым именем, но разным телом. Каждый .cpp компилируется отдельно и ничего не знает о «собрате» — компилятор доволен. Линкер тоже формально не ругается (если это не extern-функция с внешней связью и нет -fno-common), он берёт одно из определений — какое именно, зависит от порядка линковки и фазы луны. В итоге в одной части программы size() возвращает 10, в другой — 20. Классический «плавающий» баг. Запомните: TU — это не .cpp, а то, что получилось после препроцессора; именно поэтому один и тот же .cpp с разными -D даёт разные TU и потенциально разное поведение.
Второй блок — решение. Анонимное пространство имён (unnamed namespace) — это идиоматический способ сказать: «всё, что внутри, имеет внутреннюю связь (internal linkage), то есть видно только внутри этого TU». Компилятор для каждого TU генерирует уникальное имя (например, _ZN12_GLOBAL__N_115internal_helperEv вместо просто internal_helper), поэтому коллизий между TU не возникает в принципе. Это современная замена ключевому слову static для функций и переменных на уровне файла: static int helper() { ... } работает так же, но namespace { ... } удобнее, когда нужно спрятать сразу несколько сущностей или типы. К тому же анонимный namespace работает и для пользовательских типов (struct, class), а static — нет.
Подсказка на практике. Если вы пишете вспомогательную функцию, которая нужна только в одном .cpp, — оборачивайте её в анонимное пространство имён, а не объявляйте static. Это масштабируется на любое количество локальных имён, не загрязняет глобальное пространство и читается единообразно с другими современными практиками C++. Внутри .cpp без разницы, что выбрать, — но в заголовочном файле static для типов и переменных нарушит ODR (получите копию в каждом TU), а анонимный namespace в заголовке — вообще частая ошибка новичков, потому что каждая TU получит свою «анонимную» сущность.
Когда использовать, а когда нет. Заголовок с объявлениями (.hpp) — всегда; определение в заголовке — только если оно inline, шаблон или constexpr. Вспомогательную функцию, нужную одному модулю, прячьте в static или анонимное пространство имён, чтобы не «засорять» глобальные символы и не ловить конфликты имён с чужими библиотеками. Антипример: определять обычную функцию в .hpp без inline — гарантированная ошибка линковки при наличии двух .cpp, подключивших этот заголовок.
Простая аналогия. Представьте ресторан. Объявление (declaration) — это пункт в меню: «У нас есть борщ». Определение (definition) — это рецепт на кухне: как борщ реально варится. Меню видно всем посетителям (заголовок), а рецепт — внутренняя кухня (
.cpp). Если положить рецепт борща в общее меню, его увидят все рестораны сети — и смогут «готовить по нему», но тогда борщ начнёт готовиться в каждой кухне отдельно (копия кода), что неэффективно и опасно. Поэтому рецепты шаблонов иinlineфункций приходится класть в «общее меню» — иначе кухня просто не сможет их приготовить.
Что кладём в .hpp, а что — в .cpp (шпаргалка):
- В
.hpp(заголовок): объявления функций, объявления классов/структур,inlineфункции, шаблоны,constexpr/constevalфункции, макросы,enum class. - В
.cpp(исходник): определения обычных функций, определения методов класса, определения статических полей класса, реализации шаблонов при явном инстанцировании. - Анонимный namespace или
staticв.cpp: «приватные» функции, которые нужны только этому файлу — они не видны другим TU, поэтому конфликтов имён не будет.
Шаблоны нельзя вынести в .cpp без явного инстанцирования: компилятор должен видеть тело в момент вызова. Поэтому шаблоны живут в заголовках (или в .ipp, подключаемом из заголовка). Почему так. Шаблон — не код, а «инструкция по изготовлению кода»: конкретная функция рождается только тогда, когда известен тип-параметр. Если тело спрятано в другом .cpp, компилятор при вызове не сможет изготовить версию для вашего типа — он просто не видит текста. Из этого же правила следует типичная боль: время компиляции проектов с «жирными» шаблонами в заголовках может взлетать, потому что каждый TU заново парсит и инстанцирует всё заново — отсюда практика forward declaration и pimpl.
Аналогия из жизни. Шаблон — это форма для печенья (звёздочка, ёлочка, сердечко). Сама форма — ещё не печенье. Чтобы получить печенье, нужна форма прямо сейчас, когда вы замешиваете тесто. Если форма лежит в соседней кухне, а у вас только тесто — печенья не получится. Точно так же: при вызове
myFunc<int>(x)компилятор должен «испечь» версию дляint, и для этого тело шаблона должно лежать в той же единице трансляции. В.ipp(include payload) — это как держать формы в общей кладовой рядом с тестом: включаешь заголовок — и форма автоматически оказывается под рукой.
Практическая подсказка. Если шаблон очень большой и засорять заголовок не хочется, используйте паттерн «заголовок +
.ipp»: в.hppостаются только объявления шаблонных функций, а в.ipp— их тела. В конце.hppпишут#include "myclass.ipp", и потребитель ничего не замечает. Либо явно инстанцируйте нужные типы в.cpp(template class MyClass<int>;), но тогда использовать шаблон с другими типами будет нельзя. Третий путь —extern template: объявить инстанцирование «где-то ещё» и инстанцировать только в одной TU, ускоряя сборку остальных.
# Полезные флаги линковки
g++ main.o -o app -L/usr/local/lib -lmylib -lpthread # порядок -l ВАЖЕН: библиотека после объекта
g++ -c file.cpp -Ifinclude -DDEBUG=1 # -I каталог заголовков, -D макрос
Расшифровка флагов. Это флаги для
gcc/g++/clang/clang++(нижележащему линкеруldони передаются через-Wl,...). Библиотека — это заранее скомпилированный файл с готовыми функциями/классами, к которому мы «пристыковываем» свой проект; без библиотек пришлось бы каждый раз писать всё с нуля — даже реализациюprintf.-L путь— добавь каталог, где искать библиотеки (аналог переменнойPATHв ОС, только для.so/.a); полезно, если библиотека лежит не в стандартных местах вроде/usr/lib,/usr/local/lib.-lимя— подключи библиотекуlibимя.soилиlibимя.a(префиксlibи суффикс.so/.aдобавляются автоматически — это соглашение об именовании в Unix-системах: линкер буквально ищет файлы видаlib<имя>.soв путях поиска).-I путь— добавь каталог заголовков для#include "..."и#include <...>(пути из-Iпросматриваются раньше системных, поэтому можно случайно перекрыть стандартный заголовок — соблюдайте осторожность).-DМАКРОС=значение— определи макрос прямо из командной строки, как если бы в начале файла стояло#define МАКРОС значение; удобно для условной компиляции:-DDEBUG(включить отладочный код) или-DNDEBUG(отключитьassert()в release-сборке).
Частая ловушка — порядок
-l. Линкер обрабатывает файлы строго слева направо и на каждом шаге пытается закрыть уже встреченные нерешённые символы (unresolved symbols). Нерешённый символ — это имя функции или переменной, которая объявлена (компилятор поверил, что она где-то есть), но тело/определение которой линкер ещё не нашёл в текущей сборке. Из статической библиотеки.aлинкер берёт только тот объектный файл, в котором есть нужный символ, — он не сканирует всю библиотеку «на всякий случай» (это экономит время при больших.a). Поэтому, если библиотека стоит слева от объектного файла, который её использует, линкер ещё не знает, что она понадобится, и выдастundefined reference. Правило: сначала объектные файлы, потом библиотеки, а при взаимных зависимостях перечислять нужную библиотеку дважды или обернуть группу в-Wl,--start-group ... -Wl,--end-group.
Аналогия. Представьте, что вы собираете рюкзак в поход. Объектные файлы — это список «что мне нужно взять» (фляжка, спальник, тушёнка). Библиотеки — это магазины, где это можно купить. Если вы сначала заходите в магазин, а потом составляете список — продавец не знает, что именно вам нужно, и ничего не продаст. Но если сначала составить список, а потом идти по магазинам — продавец видит: «фляжку? вот, пожалуйста». Линкер работает ровно так же: он «закрывает» только те символы, которые уже увидел в объектных файлах, и не сканирует библиотеки «на всякий случай». Бонус для общего развития: с динамическими
.soдействует та же логика, но «наказание» мягче — код не найдётся уже при запуске программы (сообщениеerror while loading shared libraries), а не на этапе сборки; именно поэтому порядок-lдля.soчуть менее критичен, но всё равно важен.
Практические правила порядка:
- Сначала все
.o(или.cppпри прямой сборке), которые используют внешние библиотеки.- Потом библиотеки, которые предоставляют эти символы.
- Если библиотека A зависит от B, а B — от A (цикл): укажите A дважды:
-lA -lB -lA.- Системные библиотеки (math, pthread, stdc++) — обычно в самом конце.
- С GCC/Clang можно добавить флаг
-Wl,--start-group ... -Wl,--end-groupдля автоматического разрешения циклов, но это замедляет линковку.Дополнительные нюансы, которые часто упускают:
- При компиляции напрямую вида
g++ main.cpp -lfooпорядок обычно менее критичен: современные драйверыgcc/clangсами дописывают нужные системные библиотеки в конец команды (-lstdc++,-lm,-lgcc_s,-lc). Проблема всплывает при ручной линковке черезldили в крупных проектах, где порядок задаётся вручную.- Статические библиотеки (
.a) подчиняются правилу порядка строго. Динамические (.so) — мягче, но всё равно требуют, чтобы хотя бы один объектный файл «попросил» символ.- Каталоги из
-Lпросматриваются до стандартных, поэтому туда удобно класть свою версию библиотеки (-L./vendor -lfoo), не затрагивая системную.- Библиотеки, которые сами зависят от других (например,
libpng→libz), внутри себя уже «знают» свой порядок; вам нужно лишь подключить внешнюю.
Диагностика: кто виноват — компилятор или линкер.
| Сообщение | Этап | Что означает | Как исправить |
|---|---|---|---|
implicit declaration of function |
Компилятор | Компилятор встретил вызов функции, для которой нет прототипа выше по тексту (в этом файле после препроцессора) | Подключить заголовок с объявлением (#include <math.h> для sin или #include <cmath> в C++) |
'foo' was not declared in this scope |
Компилятор | Имя foo не видно в текущей области: не подключён заголовок, опечатка или не то пространство имён |
Добавить #include, проверить using namespace, сверить регистр букв, посмотреть на private/protected у классов |
undefined reference to 'foo' |
Линкер | Компиляция прошла (объявление есть), но в финальной сборке нет тела/определения foo |
Добавить в сборку .cpp, где определена foo, либо подключить библиотеку, которая её предоставляет (-l<имя>) |
multiple definition of 'foo' |
Линкер | Сущность foo определена в нескольких translation unit'ах одновременно (часто из-за определения в .hpp без inline/constexpr) |
Объявление в .hpp, определение — ровно в одном .cpp; для функций — inline; для переменных — inline/extern (C++17); для констант — constexpr/inline |
cannot find -lmylib |
Линкер | Линкер искал файл libmylib.so или libmylib.a во всех путях поиска и не нашёл |
Добавить -L путь (и убедиться, что файл реально там лежит); проверить, что вокруг -l нет лишних пробелов; на Windows/MSYS помнить про -lmy вместо -llibmy |
Как читать таблицу. Колонка «Этап» сразу подсказывает, куда копать:
- Компилятор — проблема в одном файле: забыли
#include, опечатка, нарушение типов, синтаксическая ошибка. Чиним в исходниках (.cpp/.hpp).- Линкер — проблема в сборке целиком: файлы не скомпилированы, не добавлены в команду, библиотека не подключена или не найдена. Чиним в системе сборки (CMakeLists.txt, Makefile, meson, команды сборки).
Простой способ отличить «на глаз»: компилятор оперирует одним файлом (одной translation unit — результатом обработки
.cppсо всеми его#includeпосле препроцессора) и не знает, что есть в других.cpp. Линкер уже видит все объектные файлы разом и жалуется на «стыки» между ними и библиотеками. Поэтому сообщения компилятора ссылаются на строку и колонку конкретного файла, а сообщения линкера — сразу на несколько объектных файлов и сущностей.Если ошибка повторяется после исправления — проверьте, не осталось ли старых бинарных файлов: сделайте
make cleanили удалите кэш сборки (например, папкуbuild/). Часто линкер «не видит» исправлений именно потому, что использует устаревший.o— компилятор решил, что исходник не менялся (по дате и хешу), и не пересобрал его. То же бывает с прекомпилированными заголовками (.pch) и кэшемccache— их тоже стоит сбросить. Ещё одна частая ловушка: правки в.hpp, которые включает много.cpp, — если зависимости не отслеживаются автоматически, пересоберите проект целиком.
Как запомнить. Ошибки компиляции — про текст и типы внутри одной translation unit (одного .cpp со всеми его #include). Ошибки линковки — про символы и связи между разными .cpp и библиотеками. Практическое правило: если в сообщении встречается имя незнакомой вам функции — скорее всего, вы забыли #include; если имя знакомое (вы точно его определяли сами) — забыли добавить файл или библиотеку в сборку, либо возникла циклическая зависимость между библиотеками.
Мнемоника для быстрой диагностики:
- Видите слово
declared/not declared/no type→ компилятор, копаем в.cpp/.hpp(заголовки, области видимости,using, шаблоны).- Видите слово
undefined reference/multiple definition/cannot find→ линкер, копаем в команде сборки и CMakeLists/ Makefile.- Видите
expected ‘;’/expected ‘}’/no matching function→ компилятор, синтаксис или несоответствие типов (аргументы перегруженной функции, неявные приведения).- Видите
relocation/undefined symbolв ассемблерных сообщениях → линкер или проблема с ABI: смешивание C и C++ безextern "C", разная видимость символов (staticvsextern), разные calling conventions.Подсказка: если сообщение очень длинное и в нём упоминается «.o», фраза
undefined referenceилиin function, и за ней часто идёт стрелка вродеmain.cpp:12:— это почти наверняка этап линковки. У компилятора формат короче:file:line:column: описание.
Золотое правило. Компилятор превращает каждый .cpp в отдельный .o независимо от других файлов — он вообще «не знает» про остальные .cpp в проекте. Линкер склеивает все .o и библиотеки в один бинарник. Если ошибка исчезает при компиляции одного файла, но появляется при сборке проекта — это 100% проблема линкера. Обратное тоже верно: если ошибка уже есть при g++ -c file.cpp (компиляция без линковки), то это 100% компилятор. Полезно проверять на двух уровнях — это самый быстрый способ локализовать неисправность без догадок.
Типы, инициализация, auto/decltype
Фундаментальные (встроенные) типы — это базовые типы данных, которые язык предоставляет «из коробки»: целые числа, вещественные числа, символы, логический тип и void. В C++ их набор почти совпадает с C, но, в отличие от Java или Python, размер фундаментального типа не фиксирован стандартом — он зависит от платформы. Это частая ловушка для новичков: код, который работает на 64-битном Linux, может вести себя иначе на Windows или на микроконтроллере.
Простое определение для новичка. Тип данных — это контракт между программистом и компилятором: сколько памяти выделить, какие значения допустимы и какие операции разрешены. Фундаментальный тип — это тип, который компилятор понимает без подключения сторонних библиотек (в отличие от, скажем, std::string или std::vector).
Стандарт гарантирует лишь минимальные диапазоны: int — не меньше 16 бит, long long — не меньше 64. А конкретные размеры задаёт так называемая модель данных ОС и компилятора:
- на 64-битном Linux/macOS это LP64:
longи указатель — 8 байт,int— 4; - на Windows — LLP64:
longостаётся 4 байтами, 8 байт только уlong longи указателя; - на 32-битных платформах всё ещё меньше: указатель 4 байта, а
longможет быть как 4, так и 8 байт.
Аналогия из жизни. Представьте, что вы заказываете «стакан кофе». В Италии это ~30 мл, в России ~250 мл. Слово одно, а размер разный. Так же и с int — «стакан» условный, а реальный объём зависит от «страны» (платформы). Поэтому правило такое: как только размер критичен (бинарные форматы файлов, сетевые протоколы, работа с байтами, сериализация), берите типы фиксированной ширины из заголовка <cstdint> — int32_t, uint64_t и т. п. Их ширина задана в битах буквально в названии.
Ещё одна аналогия. Думайте о фундаментальных типах как о ящиках на складе без этикеток: вы знаете, что там что-то лежит, но точный размер узнаёте, только приложив линейку (sizeof). А int32_t — это ящик с наклейкой «гарантированно 32 бита»: открывать и мерить не нужно.
#include <cstdint>
#include <iostream>
int main() {
std::int8_t a = -100; // ровно 8 бит
std::uint8_t b = 255;
std::int64_t big = 9'000'000'000'000'000'000LL; // разделитель разрядов (C++14)
std::size_t n = 42; // беззнаковый тип для размеров и индексов
std::cout << sizeof(a) << ' ' << sizeof(b) << ' '
<< sizeof(std::size_t) << ' ' << big << '\n';
// char=1, int обычно 4, long=8 на Linux LP64 и 4 на Windows LLP64
return 0;
}
| Тип | Типичный размер | Назначение |
|---|---|---|
bool |
1 байт | Логический тип, значения true / false |
char |
1 байт | Символ; знаковость (signed/unsigned) зависит от реализации |
int |
4 байта | «Целое число по умолчанию» — обычно самая быстрая целочисленная ширина на платформе |
long |
8 байт (Linux) | На Windows — 4 байта; ширина не гарантирована стандартом |
long long |
8 байт | Минимум 64 бита по стандарту; безопасен для больших значений |
float |
4 байта | Вещественный с одинарной точностью, ~7 значащих цифр |
double |
8 байт | Основной вещественный тип, ~15–16 значащих цифр |
std::size_t |
8 байт | Беззнаковый тип для размеров и индексов (совпадает с шириной указателя) |
std::ptrdiff_t |
8 байт | Знаковый тип для разности указателей и результата ptr - ptr |
std::string |
обычно 24–32 байта | Строка с оптимизацией SSO (короткие строки хранятся внутри объекта, без аллокации) |
Что это значит на практике.
- «Типичный размер» в таблице — это про современную 64-битную платформу; не закладывайтесь на эти числа в логике программы. Когда нужна точная ширина (сериализация, бинарные протоколы, заголовки файлов) — берите
intN_t/uintN_t. std::size_tбеззнаковый, поэтому циклfor (int i = 0; i < v.size(); ++i)выдаст предупреждение о сравнении знакового с беззнаковым и потенциальный баг (например, приi >= 0после декремента). Безопаснее:for (std::size_t i = 0; i < v.size(); ++i)либо range-basedfor. Также помните:size_tне может быть отрицательным, поэтомуs.size() - 1при пустом контейнере превращается в огромное положительное число, а не в-1.std::ptrdiff_tнужен там, где разность указателей может быть отрицательной (например,p2 - p1илиstd::distance). Его ширина совпадает с шириной указателя, что гарантирует, что результат поместится.std::stringпользуется SSO (Small String Optimization): короткие строки (обычно до ~15 символов) хранятся прямо внутри объекта, без обращения кnew/delete. Поэтомуsizeof(std::string)всегда один и тот же, независимо от длины хранимой строки.
Типичная ошибка новичка. Написать for (int i = s.size() - 1; i >= 0; --i) для пустой строки: s.size() — это size_t, вычитание единицы даёт 18446744073709551615 на 64-битной платформе, цикл крутится очень долго, программа «зависает». Способ защиты — взять знаковый тип (int, long long) или итерироваться от начала, или проверять empty() заранее.
Как запомнить. size_t — от слова size (размер), ptrdiff_t — от pointer difference (разность указателей). Оба логически связаны с указателями, поэтому их ширина совпадает с шириной адреса на платформе. Если нужно «для индекса» — size_t, если нужно «для смещения, которое может быть минусовым» — ptrdiff_t.
Литералы: суффиксы и основания. Литерал — это фиксированное значение, записанное прямо в коде (например, 42, 3.14, "text"). Тип литерала можно уточнить суффиксом, а систему счисления — префиксом. Это важно, потому что 1 << 31 при int — UB (undefined behavior, неопределённое поведение: сдвиг знакового числа в знаковый бит), а 1u << 31 — корректно, так как операнд беззнаковый.
42 // int
42u // unsigned int
42L // long
42LL // long long
3.14 // double
3.14f // float
3.14L // long double
'a' // char
"text" // const char[5]
0b1010 // двоичный литерал (C++14)
0x1F // шестнадцатеричный
0o17 // восьмеричный (C++14)
1'000'000 // разделитель разрядов (C++14)
Почему это важно.
0xFFFFFFFFне означает автоматическиunsigned: тип литерала выбирается по правилам «первый подходящий». На платформе, гдеint32-битный,0xFFFFFFFF— этоintсо значением-1, а неunsigned4294967295. Отсюда неожиданные переполнения, отрицательные значения после приведения и баги в знаковых сравнениях.- Для битовых операций, масок и значений, которые должны быть беззнаковыми, всегда пишите суффикс
u/ull(0xFFu,0xFFFFFFFFull,1ull << 33). Это делает намерение явным и убирает целый класс ошибок. - Разделитель разрядов
'(апостроф) — чисто визуальная штука: компилятор его игнорирует, поэтому1'000'000и1000000— одно и то же число. Используйте его для читаемости больших литералов (денежные суммы, ID, байтовые смещения). - Суффиксы
f/Lдля вещественных меняют не только размер, но и точность:3.14f— этоfloat(~7 цифр), а3.14L—long double(на x86 обычно 80-битный extended precision). Если вы пишете научные расчёты и важна точность — явно выбирайтеlong double.
Типичная ошибка. Сравнение -1 == 0xFFFFFFFFu и удивление, что результат false: -1 — это int, а 0xFFFFFFFFu — unsigned, и после «расширения» -1 превращается в огромное положительное число 0xFFFFFFFF, но как unsigned оно не равно 0xFFFFFFFFu (да, не равно самому себе по модулю сравнения в unsigned-арифметике здесь нюанс с знаковым представлением; проще — пишите суффикс и не сравнивайте знаковое с беззнаковым).
Что такое auto и decltype (короткое определение).
auto— спецификатор типа, который просит компилятор самому вывести тип переменной по инициализатору справа от=. По сути это та же логика вывода типов, что и в шаблонах функций:template <class T> void f(T x).decltype(expr)— оператор, который возвращает точный тип выраженияexprтак, как оно объявлено, не вычисляя само выражение. Используется в обобщённом коде и шаблонах, когда нужно «спросить тип» без инициализации переменной этим значением.
auto выводит тип из инициализатора, но отбрасывает ссылки и const: auto a = ref; сделает копию, а const-ность потеряется. decltype же сохраняет и ссылку, и const — это принципиальная разница.
Аналогия из жизни. auto — как «сфотографировать человека»: на снимке не видно, что оригинал подписан на журнал; decltype — как «привести человека лично», со всеми его свойствами. Другой пример: auto — это ксерокопия документа (без печати и подписи), а decltype — оригинал со всеми атрибутами.
Почему так работает (немного теории). Правила вывода auto исторически совпадают с правилами вывода параметров шаблона по значению: в f(T) аргумент всегда копируется, поэтому верхний уровень const и ссылочность «снимаются». decltype появился позже и намеренно сохраняет всю «типовую шелуху» — именно для метапрограммирования и шаблонов, где важно знать, был ли аргумент ссылкой или const.
decltype(auto) (C++14). Это гибрид: пишете decltype(auto) x = выражение; — и тип выводится по правилам decltype (сохраняются ссылка и const). Удобно в шаблонах для «идеальной передачи» возвращаемого значения.
Почему это источник багов. auto кажется безопасным, но именно он незаметно копирует тяжёлые объекты. Если нужна ссылка без копии — пишите const auto& или auto&. А если копия как раз нужна (чтобы не зависеть от изменений источника) — наоборот, не забывайте её, как в примере ниже.
#include <map>
#include <string>
#include <utility>
#include <vector>
int main() {
auto i = 42; // int
auto d = 3.14; // double
auto v = std::vector<int>{1, 2, 3};
int x = 5;
int& ref = x;
const int cx = 10;
auto a = ref; // int (копия!) — ссылка потеряна
auto& b = ref; // int& — явно берём ссылку
const auto c = cx; // const int
decltype(ref) e = x; // int& — decltype сохраняет ссылку
decltype(auto) f = ref; // int& — идеальная передача типа
// structured bindings (C++17)
auto [first, second] = std::pair{1, 2.5}; // int, double
for (const auto& [key, value] : std::map<std::string, int>{{"a", 1}}) {
// key, value — ссылки на элементы
}
return 0;
}
Шпаргалка: что писать, чтобы получить нужный тип.
auto и его «родственники» (auto&, const auto&, auto&&) — это не отдельные типы, а просьба компилятору вывести тип из инициализатора. Идея в том, чтобы не писать руками монстров вроде std::map<std::string, std::vector<int>>::const_iterator: пусть машина сделает это за нас. В современном C++ это идиоматично по двум причинам: (а) короче код, (б) устойчивость к рефакторингу — если вы поменяете тип возврата функции, auto в точке вызова подстроится автоматически, а явный тип — нет.
Главное, что нужно понять: auto сам по себе не выбирает ни const, ни ссылочность. По умолчанию он возвращает копию значения (то же поведение, что и T x = expr;). Чтобы получить ссылку или запретить изменения, нужно явно попросить: auto&, const auto, auto&& и т. д. Поэтому таблица ниже — это фактически список модификаторов над auto.
| Цель | Что писать | Пример |
|---|---|---|
| Копия значения (самое частое) | auto |
auto i = 42; |
| Копия, но защищённая от изменений | const auto |
const auto name = "C++"; |
| Ссылка без копирования | auto& |
auto& ref = x; |
| Константная ссылка (параметры, циклы) | const auto& |
for (const auto& item : vec) |
| Rvalue-ссылка (перемещение) | auto&& |
auto&& val = std::move(x); |
| Точный тип выражения, как есть | decltype(expr) |
decltype(ref) e = x; |
| Точный тип инициализатора (шаблоны) | decltype(auto) |
decltype(auto) f = ref; |
Мнемоника. Правило очень простое: «сначала auto, потом — что именно вы хотите получить».
- Нужна копия → просто
auto. - Нужно запретить менять эту копию → добавьте
constслева:const auto. - Нужно работать с оригиналом, без копирования → добавьте
&:auto&. - Нужно и то, и другое →
const auto&. - Нужно переместить во временное хранилище (или написать универсальную шаблонную функцию) →
auto&&.
Почему auto& иногда опасен. В range-for auto& item даёт вам изменяемый доступ к элементам контейнера: любое присваивание внутри цикла молча поменяет контейнер. Это редко желаемое поведение, поэтому по умолчанию ставят const. Для «универсальной» функции-шаблона, где lvalue и rvalue обрабатываются одинаково, используют auto&& — это и есть знаменитая forwarding reference (в шаблоне template<class T> void f(T&& x) она «ловит» всё).
Практический совет. В циклах for почти всегда пишите const auto& — это и безопасно (нельзя случайно изменить элемент), и быстро (нет лишней копии). Копию запрашивайте явно, только когда вам действительно нужна независимая локальная переменная.
Structured bindings (C++17). Конструкция auto [a, b] = pair; распаковывает структуру на отдельные переменные. По умолчанию это копии полей; чтобы получить ссылки — пишите auto& [a, b] или const auto& [a, b]. Удобно для пар, кортежей и даже агрегатов.
Где это работает «из коробки».
std::pair,std::tuple— самый частый случай.- Агрегаты без приватных полей (
struct Point { int x, y; };). - C-массивы фиксированного размера:
int arr[3]{1,2,3}; auto [a,b,c] = arr;. - У членов вашего типа есть публичный
get<N>()или это агрегат — компилятор сам найдёт способ «распаковать».
Старый способ. До C++17 приходилось писать std::tie(a, b) = pair; — он присваивает полям существующих переменных и требует их предварительного объявления. Structured bindings умеют то же самое, но ещё и создают новые переменные с правильным типом. В современном коде используйте auto [...].
Типичная ошибка новичка. const auto& [a, b] = make_pair(); — здесь make_pair() возвращает временный объект, который живёт до конца блока. Это безопасно: ссылка продлевает ему жизнь. А вот auto& [a, b] = make_pair(); — не скомпилируется, потому что нельзя привязать неконстантную ссылку к rvalue. Правило: если справа временное значение — либо const auto&, либо просто auto (копия).
Инициализация фигурными скобками {}: что это и зачем придумана.
Простое определение. {} — это синтаксис «инициализируй объект вот этим списком значений». Появился в C++11, чтобы решить сразу несколько проблем старых способов (T x(value) и T x = value;), которые разрешали тихое «обрезание» типов и неоднозначно работали в некоторых уголках синтаксиса.
Определение. Фигурные скобки {} в C++11+ означают list-initialization — синтаксис «инициализируй объект вот этим списком значений». У него две формы:
- direct:
T x{value}; - copy:
T x = {value};
Главная суперсила {} — они запрещают сужающие (narrowing) преобразования: int x{3.14}; не скомпилируется, потому что при double → int можно потерять данные. Это ловит целый класс глупых ошибок ещё на этапе компиляции.
Зачем вообще это придумали. До C++11 у программистов было два синтаксиса и оба разрешали тихую потерю точности: double d = 3.14; int x = d; компилировалось, и кто-то мог забыть об этом. С {} та же запись становится ошибкой компиляции — баг ловится до запуска. Вторая причина — «самый неприятный разбор» (см. ниже): {} однозначно выглядит как инициализация, и компилятор не путает её с объявлением функции.
Но у std::vector есть знаменитая ловушка:
#include <vector>
int main() {
std::vector<int> a(10, 0); // 10 нулей: конструктор (count, value)
std::vector<int> b{10, 0}; // два элемента: 10 и 0 — initializer_list выигрывает!
std::vector<int> c{10}; // один элемент: 10
int x = 3.14; // ошибка компилятора: сужение double → int в { }
int y(3.14); // 3 — «классическое» сужение разрешено
double z{7}; // OK: расширение int → double
// Обнуление через value-initialization
int* p = new int[100]{}; // все элементы = 0
return 0;
}
Правило: для большинства объектов используйте {}, но помните, что при наличии initializer_list-конструктора {} выбирает именно его. Для vector(n, value) нужны круглые скобки.
Почему {} «сильнее» круглых скобок. В правилах overload resolution конструктор со std::initializer_list<T> имеет приоритет перед любыми другими подходящими конструкторами, как только компилятор видит {...}. Поэтому vector{10, 0} распознаётся как «список из двух int» и вызывает именно initializer_list-конструктор, а не (count, value). Это поведение иногда называют initializer-list overload priority.
Как это работает «под капотом». Когда компилятор встречает vector<int> b{10, 0};, он сначала смотрит: «есть ли конструктор, принимающий std::initializer_list<int>?» — да, есть. Тогда любой другой конструктор (например, принимающий (size_t, int)) становится запасным вариантом и применяется, только если первый не подходит. У vector оба конструктора могли бы сработать — но initializer_list-версия побеждает по правилам приоритета.
Аналогия из жизни. Представьте, что вы звоните в кафе и делаете заказ. Универсальное меню «любой набор блюд» ({}) — это как комбо-обед: официант предпочтёт его, если вы перечислили блюда списком. Конкретное меню «10 порций одного салата» ((10, 0)) — это уже поштучный заказ: «принесите 10 одинаковых». Если вы скажете «комбо из 10 и 0», официант честно соберёт комбо из двух блюд — а не 10 порций одного.
Как запомнить. Круглые скобки — «вызов конструктора с аргументами» (vector(10, 0) — 10 нулей). Фигурные — «список значений» (vector{10, 0} — два элемента: 10 и 0). Фигурные скобки «сильнее»: если компилятор может прочитать содержимое как список инициализации, он так и сделает.
Простой тест для себя. Перед тем как писать инициализацию, задайте вопрос: «Я говорю что именно передать конструктору (число, размер, символ) — или перечисляю значения, которые должны лечь в объект?» Если перечисляете — {}. Если задаёте параметры — ().
Ещё одна аналогия. Круглые скобки — как заказ в ресторане по меню: «принесите 10 порций одного и того же салата» (vector(10, 0) — 10 нулей). Фигурные скобки — как список покупок: «возьми 10 яблок и 0 груш» (vector{10, 0} — два элемента: 10 и 0). Если в ресторане есть «комбо из списка блюд», официант предпочтёт его — так удобнее.
Частая ловушка. std::vector<int> v{10}; — это вектор из одного элемента со значением 10, а не массив из десяти нулей. Если нужен размер — только круглые скобки: std::vector<int> v(10);. Ошибка коварна тем, что компилируется без предупреждений.
Почему эта ошибка так часто встречается. Программисты по привычке из C/Java/Python ожидают, что {N} означает «массив размера N». В C++ это верно для встроенных массивов (int arr[10];), но не для STL-контейнеров. Хуже того, компилятор не выдаёт предупреждения — с точки зрения языка код корректен, просто семантика не совпадает с интуицией.
Как устроена ошибка в голове. Человек читает {10} и мысленно произносит «вектор из десяти (нулей)». Компилятор же буквально читает {10} как «список из одного элемента — числа 10». Это два разных прочтения одного синтаксиса. Аналогия: если вы скажете другу «купи десять (мороженых)», он поймёт, что вы не уточнили сорт, и купит на свой вкус; компилятор же купит ровно одно мороженое — сорт «десять».
Лайфхак-подсказка. Если вам нужен вектор из N нулей, безопаснее писать с явным , 0 — vector<int> v(N, 0);. Запись vector<int> v{N, 0}; тоже корректна и читается как «два элемента: N и 0», но если N — это переменная, легко спутать с v(N).
Дополнительная защита от самой себя. Если в проекте есть code review, договоритесь о правиле: «для создания пустого контейнера заданного размера — всегда круглые скобки, всегда с явным вторым аргументом». Тогда одна только форма v(N, value) будет сигналом «размер + значение», и эта ловушка исчезнет из кодовой базы.
Когда использовать круглые скобки, а когда фигурные.
Фигурные ({}) — по умолчанию: они запрещают сужающие преобразования (int x{3.14} не скомпилируется) и защищают от «самого неприятного разбора». Круглые (()), когда конструктор явно принимает размер и значение (vector(n, v), string(n, c)) или когда нужен конкретный конструктор без initializer_list.
Общий принцип выбора. Смотрите не на синтаксис, а на намерение:
- «Вот список значений, которые должны лечь в объект» →
{}. - «Вот параметры для конкретного конструктора» →
().
Если оба прочтения возможны, а тип имеет initializer_list-конструктор, по умолчанию побеждает список. Именно поэтому «10 нулей» через {} не получится — компилятор решит, что вы перечислили значения.
Подробная таблица выбора.
| Ситуация | Лучше писать | Почему именно так |
|---|---|---|
Обычная переменная (int, double, std::string) |
T x{value}; |
Защита от narrowing |
| Контейнер с явным размером | std::vector<T> v(N); |
Иначе {} вызовет initializer_list-ctor |
| Контейнер со списком значений | std::vector<T> v{1,2,3}; |
Это и есть «список» |
| «N копий значения» | std::vector<T> v(N, value); |
Иначе {} создаст 2 элемента |
std::string из N одинаковых символов |
std::string s(N, 'a'); |
{} вызовет initializer_list (не то) |
| C-массив | int arr[10]; |
Единственный вариант синтаксиса |
| Обнуление / value-initialization | int x{}; new int[N]{}; |
Все байты инициализируются нулём |
Что такое «самый неприятный разбор» (most vexing parse). Старая ловушка из C++03: std::vector<int> v(int()); объявлял не переменную, а функцию, возвращающую vector<int> и принимающую функцию без аргументов. Фигурные скобки (T x{...};) эту проблему снимают — компилятор однозначно видит инициализацию, а не объявление функции.
Почему компилятор так считал. Правило C++ гласит: «если синтаксис может быть и объявлением, и выражением, считай, что это объявление». Конструкция T x(int()) формально подходит под шаблон T x(аргумент), где аргумент — функция int(). Получается объявление функции с именем x. У {} такой двусмысленности нет: T x{...} всегда читается как инициализация.
Пример из реального кода. До C++11 был популярный «заклинательный» способ создания временного объекта: Timer t(Timer::start()); — программист думал, что вызывает конструктор, а компилятор считал, что объявляет функцию t, возвращающую Timer и принимающую результат Timer::start(). После перехода на {} (Timer t{Timer::start()};) двусмысленность исчезает полностью.
Про сужающие преобразования.
{}— запрещены:int x{3.14};не компилируется.()— разрешены (как и=):int x(3.14);молча отрежет дробную часть.int x = 3.14;— компилируется, компилятор может выдать предупреждение-Wnarrowing, но не обязан.
Какие преобразования считаются сужающими. Это любые неявные преобразования, при которых целевой тип гарантированно не вмещает все значения исходного. Классические примеры:
double → floatиdouble → int(теряется дробная часть).long long → int(теряются старшие биты).unsigned → signedтого же размера (значения «из отрицательной зоны» превратятся в положительные). А расширяющие преобразования (int → double,char → int) — не сужающие, они безопасны и в{}разрешены.
Почему это важно для отладки. Баги типа «int price = 19.99; оказалось 19, потому что забыли умножить на 100» — классика. С {} такой код вообще не скомпилируется, и ошибка найдётся за секунды, а не после жалоб пользователей.
Итоговое правило. Спросите себя: «Я передаю список значений или конкретные аргументы конструктору?»
- Список значений →
{}. - Конкретные аргументы (особенно «размер + значение») →
(). - Сомневаетесь — пишите
{}, это безопаснее почти всегда, кроме случаев сinitializer_list-ловушкой.
Шпаргалка-резюме на одной строке. По умолчанию {} для всего, что похоже на список или значение, и () — для пар вида «размер + значение» или когда вы точно знаете, что хотите конкретный конструктор без initializer_list. Если сомневаетесь — откройте документацию на конструкторы вашего типа и посмотрите, есть ли среди них initializer_list-версия.
const / constexpr / consteval / constinit / volatile
Простые определения на старте (прочитайте перед подробностями):
const— контракт «значение не будет изменено после инициализации». Проверяется компилятором: попытка изменить даёт ошибку сборки. Это контракт на этапе компиляции, а не рантайм-защита: значенияconstвсё так же лежат в памяти, и указатель на них в обход типизации (const_castили низкоуровневый доступ) может изменить данные.constexpr— пометка «значение может быть вычислено при компиляции». Это возможность, а не обязанность. Если аргументы известны в compile-time — компилятор посчитает; если нет — функция спокойно отработает в рантайме как обычная.consteval(C++20) — пометка «вычисление обязано произойти при компиляции, иначе — ошибка компиляции». Функция не может быть вызвана в рантайме ни при каких условиях.constinit(C++20) — гарантия статической (zero/static) инициализации переменной доmain, без «dynamic initialization order fiasco». Сама переменная при этом остаётся изменяемой — это не проconst, а про порядок инициализации.volatile— запрет оптимизаций доступа к памяти; не про потоки и не про атомарность. Каждое чтение/запись происходит как написано — без кеширования в регистрах и без перестановки.
const — обещание не изменять объект во время выполнения. constexpr — вычисление на этапе компиляции. Это разные вещи: constexpr-переменная всегда const, но не наоборот. Аналогия: const — «табличка Не трогать» на готовой вещи, а constexpr — «эту вещь изготовили заранее, ещё на заводе», поэтому её значение подставляется прямо в код и не занимает времени в рантайме. Всё, что можно посчитать заранее, лучше посчитать заранее — так программа работает быстрее и безопаснее. Почему это работает быстрее: вычисления на этапе компиляции не тратят CPU при работе программы, а результат часто вшивается в инструкции (constant folding и constant propagation), позволяя оптимизатору убрать лишние обращения к памяти и арифметику из рантайма. Почему это безопаснее: ошибка в формуле константы ловится компилятором, а не в продакшне — например, static_assert сразу подтвердит, что square(5) == 25, иначе сборка откажет.
#include <array>
constexpr int square(int x) { return x * x; } // может вычисляться в compile-time
consteval int cube(int x) { return x * x * x; } // ОБЯЗАН вычисляться в compile-time (C++20)
constinit int counter = 42; // инициализация на этапе загрузки, но изменяем (C++20)
int main() {
const int runtime = 10; // известно в runtime
constexpr int compile = square(5); // 25, вычислено компилятором
static_assert(compile == 25); // проверка на этапе компиляции
std::array<int, square(4)> arr{}; // размер массива — compile-time значение
// int bad = cube(runtime); // ошибка: аргумент constexpr не известен
int good = cube(3); // OK: 27
counter++; // constinit НЕ запрещает изменения
return 0;
}
| Квалификатор | Гарантия | Когда использовать |
|---|---|---|
const |
Объект не меняется после инициализации (контракт проверяется компилятором; касается только способа доступа через этот тип — указатель/ссылка в обход типизации всё ещё могут изменить данные) | Параметры функций (часто const T&), методы без побочных эффектов, поля только для чтения, локальные переменные, обещание интерфейса, контейнеры и итераторы, которые не должны менять элементы |
constexpr |
Может вычисляться в compile-time; переменная всегда const. Функция имеет два режима: compile-time при известных аргументах и runtime — в остальных случаях |
Константы, размеры массивов, template-аргументы, функции без побочных эффектов, таблицы lookup, вычисления заранее, всё, что хочется «зашить» в бинарник |
consteval |
Обязан вычисляться в compile-time (C++20), иначе ошибка компиляции. Любой вызов в рантайме запрещён | Метафункции времени компиляции, генерация таблиц и lookup-массивов на этапе компиляции, запрет любых рантайм-вызовов, гарантия «формула вычисляется заранее» |
constinit |
Статическая (zero/static) инициализация без «fiasco» (C++20); переменная остаётся изменяемой (не const!) |
Глобальные объекты с нетривиальным конструктором, когда важен предсказуемый порядок инициализации между единицами трансляции, безопасная замена «глобальных констант с побочным эффектом в конструкторе» |
volatile |
Компилятор не кеширует и не переупорядочивает доступ; каждое чтение/запись выполняется как написано. Не даёт атомарности и happens-before | Memory-mapped регистры устройств (MMIO), signal-обработчики (volatile sig_atomic_t), прямой доступ к железу. Не для многопоточности |
Что это значит на практике. Выбирайте квалификатор по вопросу «когда известно значение?». Если в рантайме — const. Если при компиляции — constexpr (и заодно получите const). Если обязано вычисляться при компиляции и иначе быть не должно — consteval. Если у глобального объекта есть конструктор, но он не должен ломать порядок инициализации — constinit. volatile — отдельная история: он про «память, которую меняет кто-то извне», а не про потоки. Практический совет: добавляйте constexpr ко всему, что может быть вычислено заранее, — это часто открывает оптимизации и даёт компилятору больше свободы. Почему это работает: constexpr-функция имеет два режима — при compile-time-аргументах она превращается в константу (значение «вшивается» в места использования), при runtime-аргументах работает как обычная функция. Ловушка: constexpr не отменяет правила языка — если тело функции содержит то, что нельзя вычислить при компиляции (например, new или непрослеживаемый вызов), она просто не сработает в compile-time-режиме и станет обычной runtime-функцией. Практический совет: начинайте с constexpr, а consteval используйте только когда нужно заставить компилятор считать заранее (например, чтобы случайный runtime-вызов не замедлил код или не сломал предположения о времени инициализации).
Как запомнить. const — constant (постоянный). constexpr — constant expression (постоянное выражение). consteval — constant evaluation (обязательное вычисление при компиляции). constinit — constant initialization (инициализация на этапе загрузки). Все четыре начинаются с const, но говорят о разных «когда». Мнемоника с конвейером: const фиксирует деталь на месте, constexpr изготавливает её заранее на складе, consteval требует, чтобы деталь обязательно пришла со склада (иначе сборка откажет), constinit гарантирует, что деталь установлена в правильном порядке до запуска конвейера.
На собеседовании спросят.
- «Чем
constexprотличается отconst?» —constговорит «не меняй»,constexprговорит «можно посчитать заранее».constexpr-переменная автоматическиconst, но обратное неверно:const int n = 5;— значение известно в рантайме точки объявления, но не обязано вычисляться компилятором. Подвох:const int n = rand();— это валидный код:constгарантирует лишь, что после инициализацииnне изменят, но самrand()исполняется в рантайме и даёт каждый раз новое значение. - «Почему
constexpr-функцию можно вызвать и в рантайме?» —constexpr-функция способна вычислиться при компиляции, но при вызове с рантайм-аргументом отработает как обычная;constevalже обязан вычисляться всегда. Следствие: если хотите запретить вычисление в рантайме — пишитеconsteval(C++20); если просто хотите дать компилятору шанс посчитать заранее — пишитеconstexpr. - «Зачем
constinit, если естьconstexpr?» —constinitнужен для переменных, которые меняются (неconst), но должны инициализироваться статически — это страховка от проблемы порядка инициализации глобальных переменных между единицами трансляции (порядок междуconstexpr-значениями фиксирован, а вот конструкторы с побочными эффектами — нет). Пример пользы: счётчик запросов, инициализированный нулём черезconstinit, гарантированно стартует с0до входа вmain, даже если в другой единице трансляции есть глобальный объект, читающий этот счётчик в своём конструкторе. - «Чем
volatileотличается отstd::atomic?» —volatileзапрещает оптимизации доступа, но не атомарен и не создаёт happens-before. Для потоков используйтеstd::atomicили мьютексы;volatileостался только для hardware-регистров иsignal-обработчиков. Типичная ошибка: студент защищает флагvolatile bool ready = false;для потока-производителя/потребителя — компилятор и CPU переставят обращения, и второй поток может никогда не увидетьtrue. Нуженstd::atomic<bool>или мьютекс.
const-корректность — часть интерфейса. Метод, не меняющий состояние, помечают const, иначе его нельзя вызвать на const-объекте (или на объекте, пришедшем по const-ссылке — а именно так передают почти всё, чтобы избежать копий). Компилятор проверяет это за вас: чем больше методов помечено const, тем шире класс можно использовать. Члены-данные, которые меняет const-метод (кэш, счётчик обращений), объявляют mutable — это явное «разрешение» менять поле в const-методе. Аналогия из жизни: библиотека — const-метод это библиотекарь, который выдаёт книги и не делает пометок в самих книгах. А mutable — это его карманный блокнот, в котором он записывает, сколько раз выдавал книгу: блокнот принадлежит лично библиотекарю, его изменение не портит состояние книги и не нарушает контракт «объект неизменяем». Типичная ошибка новичка: пометить поле mutable просто потому, что «лень делать метод не-const» — это нарушает контракт: внешний код видит «объект не меняется», а на самом деле меняется его важная часть. Используйте mutable только для того, что логически не является состоянием объекта с точки зрения наблюдателя (кэш, статистика, std::mutex для ленивой синхронизации внутри const-метода).
Ловушка с указателями. const ведёт себя по-разному в зависимости от места в типе — это один из самых частых источников ошибок у новичков:
const int* p(то же, чтоint const* p) — указатель на константу: менять*pнельзя, самpможно переназначить. Аналогия: телевизор в музее — смотреть можно, перенаправить камеру можно, а вот переключать каналы через объектив нельзя.int* const p— константный указатель: менять*pможно, самpпереназначить нельзя. Аналогия: камера на штативе — направление менять можно (содержимое по указателю), а вот переставить штатив в другое место нельзя.const int* const p— ни то, ни другое. Камера на штативе, направленная в музейный телевизор, которую нельзя ни сдвинуть, ни повернуть.constпосле*защищает указатель, до*— то, на что он указывает. Правило чтения справа налево: сначала имя, потом*(указатель на), потом тип с квалификаторами.
Почему это не формальность. Функция, принимающая const T&, обещает вызывающему «я не изменю твой объект». Если у класса нет const-методов, вызвать их на таком объекте нельзя — и вам придётся либо копировать объект, либо отказываться от const, теряя гарантии. mutable нужен редко: обычно это кэш, счётчик статистики или мьютекс, которые не являются «логическим состоянием» объекта. Практический совет: помечайте const всё, что не изменяет логическое состояние, — компилятор проверит это за вас, а вы получите больше гарантий и сможете использовать объект в большем числе контекстов (например, передавать в const-методы других классов и хранить в const-контейнерах). Ловушка: не используйте mutable как оправдание для изменения «настоящего» состояния — это лишает const смысла и затрудняет рассуждение о коде. Правило большого пальца: если убирание mutable ломает многопоточную корректность или инициализацию кэша — это легитимный mutable; если без mutable приходится отказываться от const на методе просто для удобства — это, скорее всего, злоупотребление.
class Cache {
mutable int hits_ = 0; // можно менять даже в const-методе
public:
int lookup(int key) const {
++hits_; // допустимо благодаря mutable
return key * 2;
}
int hits() const { return hits_; }
};
Разбор кода Cache. Поле hits_ помечено mutable, поэтому даже в const-методе lookup его можно инкрементировать. Сам метод lookup при этом не меняет наблюдаемое состояние клиента — он только возвращает key * 2, — но кэширует статистику обращений. Метод hits() тоже const, потому что только читает значение. Без mutable пришлось бы делать lookup не-const, и тогда нельзя было бы вызвать его на const Cache& (а значит, на любом объекте, переданном по const-ссылке). Типичная ошибка: добавить в такой класс поле mutable std::string last_key_; и менять его в lookup, потому что «ну а что такого» — но это уже меняет логическое состояние, и const перестаёт быть гарантией для клиента: внешний код, рассчитывающий на чистую функцию, неожиданно получит побочный эффект.
volatile в современном C++ означает «доступ к памяти нельзя оптимизировать», но не даёт атомарности и не синхронизирует потоки. Для многопоточности используйте std::atomic и мьютексы; volatile оставьте для memory-mapped I/O. Типичная ловушка: многие новички пишут volatile bool flag = false; для флага между потоками — это не работает. volatile не создаёт happens-before связи, не блокирует и не атомарен: компилятор может закэшировать значение в регистре, а CPU — переупорядочить обращения. Между двумя потоками нужен std::atomic<bool> или мьютекс. Когда volatile действительно нужен: регистры периферии (*(volatile uint32_t*)0x40021000), переменные, изменяемые в signal-обработчике (sig_atomic_t исторически используется вместе с volatile), общая память с внешним устройством. Почему компилятор «трогает» память: оптимизатор видит, что в коде переменная явно не меняется, и считает её значение «известным» — может закэшировать его в регистре и не перечитывать из памяти, а также переставить чтение/запись ради скорости. volatile говорит: «не делай этого — значение может стать другим без моего ведома, читай и пиши строго там, где написано». Аналогия: без volatile оптимизатор — это аккуратный клерк, который ведёт блокнот «вот эту переменную я уже смотрел, она равна 5, зачем спрашивать ещё раз?»; с volatile — это строгий наблюдатель, который каждый раз подходит к окну и смотрит наружу заново, даже если вчера уже видел то же самое.
Ссылки, указатели и категории значений
Что такое ссылка и указатель простым языком. Указатель (T*) — это переменная, которая хранит адрес другой переменной в памяти. Сам указатель — обычная переменная (занимает место, может лежать в стеке или куче), а его «значением» является адрес. Ссылка (T&) — это псевдоним (альтернативное имя) уже существующего объекта: она не хранит ничего «отдельно», а просто говорит компилятору «под этим именем работай с тем же объектом». Именно поэтому указатель может быть nullptr (бумажка с адресом, на которой ничего не записано) и переприсваиваться (стёрли старый адрес — написали новый), а ссылка всегда привязана к конкретному объекту и не может быть «нулевой». Технически компилятор чаще всего реализует ссылку тем же указателем «под капотом», но в семантике языка это разные сущности — и в коде, и в правилах языка.
Аналогия, которая снимает половину вопросов. Указатель — это адрес дома, записанный на бумажке: бумажку можно потерять (станет nullptr), стереть и написать другой адрес (переприсваивание), а по адресу дом может уже снести (висячий указатель, dangling pointer). Ссылка — это второе имя человека: у него не бывает «пустого» состояния, переименовать нельзя (привязана раз и навсегда), но зато обращаться по имени всегда безопасно, пока человек жив. Поэтому для параметров функций по умолчанию берут ссылку (безопаснее и понятнее), а указатель — когда значение действительно может отсутствовать или нужна арифметика адресов (как в C-API или при обходе массивов).
#include <iostream>
void by_value(int x) { x = 42; } // копия — вызывающий не видит изменения
void by_pointer(int* x) { if (x) *x = 42; } // может быть nullptr
void by_reference(int& x) { x = 42; } // всегда валидна, не может быть nullptr
void by_const_ref(const std::string& s); // без копии, не изменяет
int main() {
int a = 1, b = 2;
int& r = a; // r — псевдоним a
r = 10; // a == 10
int* p = &a; // указатель
*p = 20; // a == 20
int* np = nullptr; // современная замена NULL и 0
// int& bad = nullptr; // ошибка: ссылка не может быть пустой
return 0;
}
| Критерий | Указатель T* |
Ссылка T& |
|---|---|---|
| Может быть пустой | Да (nullptr) |
Нет |
| Переприсваивание | Да | Нет (связывается один раз) |
| Арифметика | Да | Нет |
| Владеет ресурсом | Обычно нет | Нет |
| Когда брать | Опциональность, массивы, C-API | Параметры, псевдонимы |
Что это значит на практике. Строка «Может быть пустой» — главное отличие: если функция принимает int*, она обязана проверять if (x) перед разыменованием (иначе — UB, undefined behaviour), а если int& — проверка не нужна, ссылка всегда валидна по контракту. Поэтому параметр-указатель «сигнализирует» вызывающему, что аргумент необязателен, а параметр-ссылка — что обязателен. Это негласный договор между автором и пользователем функции, и его полезно соблюдать: не заставляйте вызывающего думать «а вдруг там nullptr?», если по логике объект всегда есть. Исключение — const T&, которая служит заменой «передать по значению, но без дорогого копирования».
Когда использовать, а когда нет. Практическое правило:
const T&— для чтения больших объектов (std::string,std::vector, структуры): дешёвая передача без права изменения.T&— когда функция должна изменить аргумент и это часть её публичного контракта (например,swap, операторы>>/<<).T*— когда значение опционально, нужно указать на массив, либо вы работаете с C-API, который диктует указатели.T(по значению) — для маленьких типов (int,double, указатели), когда нужна именно локальная копия или когда тип настолько дешёв, что ссылка только усложнит код.
Типичная ошибка новичка — брать T& для маленьких типов «на всякий случай». Это не даёт выигрыша (ссылка внутри — тот же указатель) и ломает удобство вызова с временными значениями.
Категории значений — это классификация выражений (не переменных!), которая говорит компилятору: можно ли с этого выражения «украсть» внутренние ресурсы объекта (буфер строки, элементы вектора) или нет. Зачем это нужно — чтобы при возврате из функций, в конструкторах и операторах присваивания выбирать между дорогим копированием и дешёвым перемещением. Существует три основных категории: lvalue (именованный объект), prvalue (чистое временное значение, pure rvalue), xvalue (объект, помеченный std::move, из которого можно перемещать, expiring value).
выражение
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
- lvalue —
x,arr[0],*p, результат++i: имеет имя и адрес, живёт дальше. - prvalue —
42,x + y,f()при возврате по значению: чистый временный объект, который вот-вот умрёт. - xvalue —
std::move(x),static_cast<T&&>(x): lvalue, помеченный как «перемещаемый», его ресурсы можно забирать.
Как запомнить. l — left (стоит слева от =, у него есть имя и адрес), r — right (стоит справа, временное). x — expiring (истекающее, скоро можно забрать ресурсы). На практике из всей теории важны только два вопроса:
- «Можно ли из этого безопасно украсть ресурсы?» — да, если это xvalue или prvalue (объект временный или явно помечен для перемещения); нет, если lvalue (объект ещё нужен).
- «Переживёт ли объект выражение?» — prvalue не переживёт, поэтому любой указатель/
string_view/ссылка на него — опасна; lvalue переживёт — и с ним работать безопасно.
#include <utility>
#include <string>
void take_copy(std::string s); // копирует переданное
void take_ref(std::string& s); // lvalue-ссылка: только именованные
void take_move(std::string&& s); // rvalue-ссылка: только временные
template <typename T>
void forwarder(T&& arg) { // forwarding reference (не rvalue-ссылка!)
take_copy(std::forward<T>(arg)); // сохраняет категорию значения
}
int main() {
std::string s = "hello";
take_copy(s); // копия
take_copy(std::move(s)); // перемещение (s в «валидном, но неопределённом» состоянии)
std::string a = "x";
std::string b = std::move(a); // b забрал буфер; a пуст (обычно), но НЕ трогайте его
return 0;
}
Reference collapsing — правило схлопывания ссылок: & & → &, & && → &, && & → &, && && → &&. Зачем оно нужно? Ссылок на ссылку в C++ формально нет — но при подстановке шаблонных параметров такие комбинации возникают постоянно, и без правила компилятор не знал бы, во что превращать int& & или int& &&. Именно поэтому T&& в шаблоне может стать lvalue-ссылкой, если передан lvalue: при выводе T = int& и схлопывании int& && → int&. Этот механизм — фундамент «универсальных ссылок».
template <typename T> void f(T&&); // forwarding reference
// f(x) с int x → T = int&, параметр int& (lvalue передаётся по ссылке)
// f(5) → T = int, параметр int&& (rvalue передаётся как rvalue)
Почему T&& в шаблоне — особенное. Снаружи это выглядит как обычная rvalue-ссылка, но в шаблоне T&& называется forwarding reference (раньше говорили universal reference): компилятор выводит T так, чтобы сохранить категорию аргумента. Передали именованный объект — T = int&, и с учётом схлопывания (& && → &) параметр становится int&. Передали временный — T = int, параметр остаётся int&&. Именно это позволяет одной функции принимать «что угодно» и через std::forward<T> передавать дальше без потери категории значения — иначе вложенный вызов всегда видел бы lvalue или всегда rvalue, и мы бы теряли эффективность перемещений.
Частая ловушка. T&& — универсальная ссылка только если T выводится компилятором из аргумента. Если тип известен заранее (void g(std::string&&)) или есть дополнительные квалификаторы (void g(const std::vector<T>&&), void g(MyClass<T>&&) где T указан явно) — это обычная rvalue-ссылка. Не путайте эти случаи — от них зависит, скомпилируется ли передача lvalue: в rvalue-ссылку std::string&& нельзя передать именованную переменную, а в forwarding reference — можно.
std::move — это просто static_cast<T&&>, то есть приведение к rvalue-категории. Сам он ничего не перемещает: ни память не копирует, ни поля не обнуляет. Он лишь разрешает компилятору выбрать перемещающий конструктор/оператор присваивания вместо копирующего — если такой вообще есть.
std::forward — похожий каст, но работает только при определённых условиях (тип выводится) и сохраняет исходную категорию значения в шаблонном коде. Использовать std::forward вне шаблонов — почти всегда ошибка.
Частая ловушка — «std::move ничего не делает». Новичок пишет std::move(x) и ждёт, что объект «сам собой опустеет». На самом деле std::move лишь разрешает компилятору выбрать перемещающий конструктор/присваивание. Если у типа нет таких операций (например, у const-объекта или у типа без поддержки move, как старые структуры с полями только для чтения), произойдёт обычное копирование — молча, без ошибки и без предупреждения. Поэтому правило: std::move осмыслен только там, где адресат умеет перемещаться, а вы после перемещения не трогаете источник.
Почему std::move опасен на const. Если объект помечен const, то после std::move вы получаете const std::string&&. А перемещающий конструктор принимает не-константную ссылку (std::string&&) и не подходит — снова вызывается копирующий. Никакой ошибки компиляции, просто молча теряете эффективность. Практический вывод: не помечайте объекты const, если собираетесь их перемещать, и не пишите std::move(const_var) в надежде на выигрыш.
// Продление жизни временного объекта (lifetime extension):
const std::string& bad() { return std::string("temp"); } // ОШИБКА: висячая ссылка
std::string good() { return std::string("temp"); } // OK: копия/перемещение
int main() {
const int& r = 42; // OK: константная ссылка продлевает жизнь prvalue
const std::string& s = "abc"; // OK: временный объект живёт столько же, сколько s
// но это НЕ работает через параметр функции:
// void sink(const std::string&); sink("abc"); // временное умирает в конце выражения
return 0;
}
Как запомнить правило продления жизни. Константная ссылка, привязанная к временному объекту при инициализации переменной, продлевает его жизнь до конца области видимости этой переменной. Но если временный объект передан в параметр функции — он живёт только до конца выражения с вызовом: параметр функции — это не «инициализация переменной» в смысле lifetime extension, он лишь принимает значение. Отсюда знаменитая ошибка: сохранить const std::string& на результат s1 + s2 безопасно (временный продлевается), а вернуть такую ссылку из функции — нет (временный создаётся в кадре вызывающей функции, ссылка возвращается в никуда, после выхода — висячая). Если хотите вернуть строку — возвращайте по значению: компилятор применит NRVO и перемещение, и эффект будет даже лучше, чем ссылка.
На собеседовании спросят. «Почему ссылка не может быть nullptr?», «Чем const T& лучше передачи по значению?», «Что такое dangling reference?» — базовый набор по теме; держите в голове аналогию «адрес дома / второе имя».
Классы и ООП
Что такое класс и зачем он нужен. Класс — это пользовательский тип данных, который объединяет данные (поля, data members) и поведение (методы, member functions) в одной сущности. По умолчанию члены class — private, члены struct — public. Это, по сути, единственное синтаксическое различие между class и struct в C++: и там, и там можно писать поля, методы, наследование, виртуальные функции — всё что угодно. Класс отвечает на три вопроса: «какие данные хранит объект?», «что он умеет делать?» и «что из этого видно снаружи?».
Аналогия из жизни. Класс объединяет данные и поведение. По умолчанию члены class — private, члены struct — public. Аналогия: класс — это чертёж дома, а объект (экземпляр) — сам дом, построенный по чертежу; можно построить сколько угодно домов, и у каждого будут свои значения полей. Метод класса — это инструкция из чертежа: «как открыть дверь», «как посчитать площадь». Статическое поле — «общий двор» на все дома, а не у каждого дома свой.
Зачем нужна инкапсуляция. Сокрытие данных (private) — это «закрытая проводка»: снаружи видно только выключатели (публичные методы), а внутренности нельзя испортить напрямую. Если бы все провода торчали наружу, любой мог бы случайно замкнуть сеть. Через private пользователь работает только через безопасный интерфейс, а разработчик класса может менять реализацию (например, перейти с std::vector на std::deque внутри), не ломая внешний код. В этом и состоит главная практическая выгода ООП: можно менять внутренности, сохраняя контракт.
#include <string>
#include <utility>
class Account {
public:
Account(std::string owner, double balance) // список инициализации членов
: owner_(std::move(owner)), balance_(balance) {} // порядок = порядок объявления!
void deposit(double amount) { // неявный this
if (amount > 0) balance_ += amount; // this->balance_
}
[[nodiscard]] double balance() const { return balance_; } // const-метод
static int accounts_created() { return count_; } // статический метод
private:
std::string owner_; // порядок объявления задаёт порядок инициализации
double balance_ = 0.0; // инициализатор по умолчанию
mutable int reads_ = 0; // можно менять из const-методов
static int count_; // одно поле на весь класс
};
int Account::count_ = 0; // определение статического члена
class Tagged {
friend void inspect(const Tagged&); // friend получает доступ к private
int secret_ = 7;
};
void inspect(const Tagged& t) { /* может читать t.secret_ */ }
| Спецификатор | Значение |
|---|---|
public |
Доступно всем — и из самого класса, и из внешнего кода |
protected |
Доступно самому классу и всем его наследникам (производным классам) |
private |
Доступно только самому классу и его friend-объявлениям |
this |
Скрытый указатель на текущий объект; в const-методе имеет тип const T* |
static |
Поле или метод принадлежат классу в целом, а не конкретному объекту |
explicit |
Запрещает неявное преобразование через конструктор с одним аргументом |
mutable |
Поле разрешено изменять даже из const-методов (обычно для кэша и счётчиков) |
friend |
Дружественная функция или класс получают доступ к private/protected членам |
[[nodiscard]] |
Атрибут: компилятор выдаст предупреждение, если возвращаемое значение проигнорировано |
Что это значит на практике. struct в C++ — это тот же класс, просто с публичными членами по умолчанию; выбирайте struct для «простых данных без инвариантов» (Point, Config, пары координат) и class, когда есть внутреннее состояние, которое нужно защищать. Хорошее правило: если в типе есть хоть один метод, проверяющий корректность полей, — это class, потому что поля должны быть private. explicit — ваш друг: он превращает случайную неявную конвертацию в ошибку компиляции. Без explicit компилятор молча создаст временный объект там, где вы этого не хотели (например, передадите double вместо Meters). [[nodiscard]] спасает от классического бага, когда функция возвращает статус ошибки или новый объект, а вызывающий код его забывает использовать — компилятор сам напомнит.
Когда использовать, а когда нет. static-метод — для операций, не зависящих от конкретного объекта (фабрика, утилита, подсчёт созданных экземпляров). Если метод логически работает с данными объекта — делайте его обычным, чтобы он мог обращаться к полям через this. friend — только когда без него интерфейс получается кривым: классический пример — перегрузка operator<< для потокового вывода, где левый аргумент std::ostream не наш, и без friend к приватным полям не добраться. Злоупотребление friend разрушает инкапсуляцию: если у вас десяток friend-классов, скорее всего, дизайн стоит пересмотреть и вынести общую логику в отдельный компонент. mutable — для служебных полей (кэш, счётчик обращений, ленивая инициализация), которые не меняют наблюдаемое состояние объекта, но нужны для оптимизации.
Про this и static-члены. this — неявный указатель на объект, для которого вызван метод; внутри const-метода он имеет тип const T*, поэтому из const-метода нельзя менять обычные поля (кроме mutable). Это гарантирует, что константный объект действительно останется неизменным после вызова метода. Именно поэтому константность «заразна»: const-метод может вызывать только другие const-методы и обращаться только к const-ссылкам и const-указателям. static-поле — одно на весь класс, а не на объект: оно существует даже без созданных экземпляров и требует определения вне класса (int Account::count_ = 0;), иначе линкер выдаст undefined reference. Это частое первое сообщение об ошибке у новичков: «я объявил static int count_ в классе, а он не линкуется» — компилятор про декларацию знает, а линковщик не видит самого определения.
Как запомнить. Обычный член — «у каждого дома своя дверь», static — «один общий подъезд на все дома». this — «этот конкретный дом». private — «за закрытой дверью, ключ только у хозяина и его friend-друзей». mutable — «чёрная метка: это поле можно трогать даже у константного дома».
Ловушка: порядок инициализации членов задаётся порядком их объявления в классе, а не порядком в списке инициализации. Компилятор предупредит (-Wreorder), но молчаливое нарушение — источник трудноуловимых багов, если один член инициализируется значением другого: вы читаете ещё не сконструированное поле.
class Bad {
int b_;
int a_;
public:
Bad(int v) : a_(v), b_(a_) {} // ОШИБКА: b_ инициализируется ДО a_!
// реально: b_ = мусор, a_ = v
};
// explicit спасает от неявных преобразований
class Meters {
double value_;
public:
explicit Meters(double v) : value_(v) {}
};
// void f(Meters m);
// f(3.0); // ошибка благодаря explicit
// f(Meters{3.0}); // OK — явно
Почему порядок инициализации так устроен. Компилятор инициализирует члены ровно в том порядке, в котором они объявлены в классе, — потому что физическое расположение полей в памяти тоже задаётся порядком объявления, а конструктор обязан построить их по одному, как кирпичи в стене снизу вверх. Список инициализации лишь задаёт значения, он не меняет порядок: компилятор сначала смотрит на тело класса, расставляет члены по их фактическому порядку и только потом применяет рецепт из списка. Если один член инициализируется значением другого, а объявлен раньше — вы читаете ещё не построенное поле, то есть мусор из памяти. Именно поэтому b_(a_) при int b_; int a_; даёт b_ мусор: b_ инициализируется первым, когда a_ ещё не существует. Компилятор GCC/Clang выдаст -Wreorder, но не ошибку — код соберётся и упадёт в рантайме, иногда только под нагрузкой или на другой платформе:
class Bad {
int b_; // объявлен ПЕРВЫМ — значит инициализируется первым
int a_;
public:
Bad(int v) : a_(v), b_(a_) {} // a_ ещё не инициализирован → b_ = мусор
};
Как запомнить. «Список инициализации — это не порядок, а рецепт». Читайте его глазами, но верьте только порядку объявления полей. Правило гигиены: держите список в том же порядке, что и объявления, тогда предупреждений -Wreorder не будет вовсе и баг с зависимыми полями не возникнет физически. Если два поля зависят друг от друга — это сигнал, что архитектуру класса стоит пересмотреть: либо вынесите зависимость в метод после конструктора, либо пересмотрите порядок объявления.
Специальные функции-члены
Определение. Специальные функции-члены (special member functions) — это функции, которые компилятор может сгенерировать за вас для управления жизненным циклом объекта: его созданием, копированием, перемещением и уничтожением. Вы их явно не пишете, но они всё равно существуют — как невидимые помощники, которые знают, что делать с каждым объектом.
Компилятор генерирует шесть специальных функций: конструктор по умолчанию, деструктор, копирующий/перемещающий конструктор, копирующее/перемещающее присваивание. Как только вы объявляете одну из них, генерация остальных может измениться. Эти функции — «служебные»: они решают, что происходит при создании, копировании, перемещении и уничтожении объекта, и именно в них сосредоточено управление памятью.
Зачем их вообще выделяют. Любой объект в C++ живёт по расписанию: его нужно сконструировать, иногда скопировать (например, при передаче в функцию по значению), иногда переместить (когда промежуточная копия больше не нужна) и в конце — уничтожить. Если программист забывает правильно описать эти шаги для класса, владеющего ресурсом (памятью, файлом, сокетом), возникают классические ошибки: утечки памяти, двойное освобождение, use-after-free. Спец. функции — это точки, в которых вы «перехватываете» эти шаги, чтобы всё сделать корректно. Аналогия из жизни. Спец. функции — это договор аренды квартиры: въезд (конструктор), подселение соседа (копирование), переезд в другую квартиру (перемещение) и выезд с уборкой (деструктор). Если хотя бы один пункт договора забыт — жди проблем.
Правило нуля: не пишите спец. функции вручную — пусть члены (std::string, std::vector, std::unique_ptr) управляют ресурсами. Правило трёх: если нужен деструктор, нужны и копирующий конструктор, и копирующее присваивание. Правило пяти (C++11): добавьте перемещающие конструктор и присваивание.
Как применять на практике. Начинайте с правила нуля: возьмите std::string вместо char*, std::vector вместо int*, std::unique_ptr вместо сырого указателя — и компилятор сам напишет за вас все пять функций. Если по каким-то причинам это невозможно (вы пишете собственный контейнер или низкоуровневую RAII-обёртку над ресурсом) — переходите к правилу пяти: пишите все пять явно. Типичная ошибка новичка — выучить только правило трёх и в современном C++11-коде забыть о перемещающих операциях: в итоге std::vector начинает копировать вместо того, чтобы перемещать, и теряется вся выгода от C++11.
Почему эти правила существуют. Правило трёх возникло в C++98: если объект владеет ресурсом (например, указателем на буфер), деструктор обязан его освободить, но тогда и копирование должно создать свою копию ресурса, иначе два объекта начнут удалять его дважды (double free). В C++11 добавились перемещающие операции — так правило стало «пятью». А правило нуля — это его эволюция: пусть ресурсами владеют готовые члены, и тогда ни одну из пяти функций писать не придётся. Аналогия: не храните деньги в кармане сами — положите их в банк (RAII-обёртку), и банк сам позаботится о сохранности.
Историческая справка. В C++98 правило формулировалось как «правило трёх» — без перемещений. Тогда, если вы писали класс с деструктором, было принято сразу писать и копирующий конструктор, и копирующее присваивание. Когда в C++11 появились rvalue-ссылки и std::move, к правилу добавились ещё две функции, и оно стало «правилом пяти». Одновременно сообщество осознало, что чаще всего эти функции вообще не нужно писать вручную — достаточно использовать готовые RAII-обёртки (std::string, std::vector, std::unique_ptr), и компилятор всё сделает сам. Так появилось правило нуля — самое современное и рекомендуемое к применению по умолчанию.
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
~Buffer() { delete[] data_; } // 1. деструктор
Buffer(const Buffer& other) // 2. копирование
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) { // 3. копирующее присваивание
if (this != &other) { // защита от self-assignment
Buffer tmp(other); // copy-and-swap
swap(tmp);
}
return *this;
}
Buffer(Buffer&& other) noexcept // 4. перемещение
: size_(other.size_), data_(other.data_) { // не копируем — забираем
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept { // 5. перемещающее присваивание
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
private:
std::size_t size_;
int* data_;
};
Разбор ключевых приёмов. if (this != &other) в присваивании защищает от a = a: без проверки мы сначала удалили бы собственный буфер, а потом скопировали данные из уже удалённого — use-after-free. Аналогия: представьте, что вы переставляете содержимое двух коробок и по ошибке передаёте функции одну и ту же коробку дважды — без проверки вы выбросите всё в мусор ещё до того, как начнёте перекладывать. copy-and-swap (создать копию, затем обменять её с собой) даёт strong guarantee: если копирование бросит исключение, исходный объект останется нетронутым, ведь мы ещё ничего не меняли. Почему именно так, а не new + delete напрямую? Путь «выделить новый буфер → скопировать → удалить старый» плох тем, что при исключении в new или copy мы уже потеряли старые данные. copy-and-swap инвертирует порядок: сначала строим новые данные во временном объекте, и только когда они гарантированно готовы — обмениваем. В перемещающем конструкторе важно забрать указатель и обнулить чужой (other.data_ = nullptr), иначе деструктор other дважды освободит тот же буфер. Практический совет. После перемещения «источник» должен находиться в валидном, но неопределённом состоянии — обычно это означает «пусто», чтобы его можно было безопасно уничтожить или присвоить заново.
// =default и =delete управляют генерацией
struct MoveOnly {
MoveOnly() = default;
MoveOnly(const MoveOnly&) = delete; // копировать нельзя
MoveOnly& operator=(const MoveOnly&) = delete;
MoveOnly(MoveOnly&&) = default; // перемещать можно
MoveOnly& operator=(MoveOnly&&) = default;
};
Когда это нужно. Типичный пример MoveOnly — классы, владеющие уникальным ресурсом: std::unique_ptr, std::thread, std::mutex, файловые дескрипторы. Копировать их бессмысленно (кому достанется право владения?), а вот переместить — естественно: «передать» владение другому объекту. Если случайно попытаетесь скопировать такой класс, компилятор выдаст ошибку прямо в месте вызова — это лучше, чем двойное освобождение в рантайме.
Подводный камень №1 — noexcept у move. Если перемещающий конструктор не помечен noexcept, std::vector при росте копирует, а не перемещает элементы (strong exception guarantee). Это может превратить O(n) роста в O(n²) и заметно замедлить программу.
struct HeavyNoexcept {
HeavyNoexcept(HeavyNoexcept&&) noexcept = default; // vector будет перемещать — быстро
};
struct HeavyThrowing {
HeavyThrowing(HeavyThrowing&&) {} // без noexcept → vector копирует
};
Почему это так. При росте vector выделяет новый буфер и переносит туда элементы. Если перемещение может бросить исключение и бросит на середине, восстановить исходное состояние невозможно — часть элементов уже перемещена. Чтобы дать строгую гарантию, vector заранее проверяет noexcept перемещающего конструктора и, если её нет, предпочитает копирование: копию можно отбросить, оригинал цел. Аналогия: переезд с хрупкой мебелью — грузчики сначала делают копию мебели, а не рискуют перевезти оригинал и разбить его. Практический совет. Почти все пользовательские move-конструкторы и move-присваивания должны быть noexcept — они только «отбирают» указатели у источника, а это не бросает. noexcept — это контракт: вы обещаете компилятору и стандартной библиотеке, что операция не бросит, и они вам за это дают более агрессивные оптимизации.
На собеседовании спросят. «Почему std::move_if_noexcept существует и где применяется?» Ответ: именно vector (и другие контейнеры) через него решают, перемещать элемент или копировать, чтобы сохранить strong exception guarantee. Разбор по шагам. move_if_noexcept<T>(x) возвращает static_cast<T&&>(x) если у T есть noexcept-move-конструктор, иначе — const T&. То есть функция «отдаёт» перемещаемость только тогда, когда она безопасна; иначе «отдаёт» только константную ссылку, по которой можно только скопировать. Внутри vector::push_back это выглядит как: «новый элемент создаётся через move_if_noexcept, старый буфер копируется или перемещается в новый — и если что-то пойдёт не так, старый буфер всё ещё цел».
Подводный камень №2 — правило пяти «ломает» генерацию. Объявив деструктор, вы подавляете генерацию перемещающих операций, а объявив перемещение — копирующих. Итоговая матрица:
| Объявлено | Копирование | Перемещение |
|---|---|---|
| Ничего | генерируется | генерируется |
| Деструктор | генерируется (deprecated) | не генерируется |
| Копирование | генерируется | не генерируется |
| Перемещение | удаляется | генерируется |
=delete копирования |
удалено | генерируется (если не подавлено) |
Что это значит на практике. Таблица читается так: объявив что-то одно, вы неявно «выключаете» генерацию другого. Самый частый сюрприз — строка «Деструктор»: автор добавил ~Foo() для логирования, и перемещение исчезло — все std::vector<Foo> внезапно стали копироваться, а std::sort по элементам — тормозить. Лечится одной строкой: Foo(Foo&&) = default; Foo& operator=(Foo&&) = default;. Типичная ошибка. Программист добавляет пустой деструктор ~Foo() {} «на всякий случай» или для трассировки — и сразу получает катастрофу с производительностью в std::vector<Foo>. Компилятор не предупредит, профилировщик не сразу покажет причину, а виновник — безобидная пустая функция. Практический совет. Если вам нужен только лог в деструкторе — пишите его отдельной функцией-членом и вызывайте её явно, а сам деструктор оставляйте сгенерированным компилятором (= default).
Как запомнить. «Объявил деструктор — потерял перемещение». «Объявил перемещение — потерял копирование». Хотите и то и другое — объявите явно или, лучше, вообще не пишите спец. функции (правило нуля). Мнемоника. Запомните три ключевых слова-триггера: деструктор, копирование, перемещение. Любое из них «выбивает» из генерации то, что стоит правее в цепочке «копирование → перемещение». Исключение — =delete: явный запрет не подавляет генерацию того, что не упомянуто, поэтому =delete копирования оставляет перемещение живым (последняя строка таблицы). Практический совет на каждый день. Перед тем как добавить в класс любую из пяти спец. функций, спросите себя: «точно ли мне нужно писать её вручную?» В большинстве случаев ответ — нет, и тогда применяется правило нуля.
Наследование и полиморфизм
Простое определение. Наследование — это способ описать, что один класс (например, Circle) является частным случаем другого (Shape), автоматически получив все его поля и методы. Полиморфизм — это способ одним и тем же вызовом (s->area()) выполнить разный код в зависимости от того, какой именно объект стоит за указателем: круг, квадрат или треугольник.
Наследование моделирует «является» (public). Виртуальные функции дают динамический полиморфизм: вызов выбирается во время выполнения по реальному типу объекта через таблицу vtable. Это ключ ко всему ООП в C++: вы пишете код, работающий с базовым типом (Shape), а он в рантайме вызывает правильную реализацию (Circle::area) — не зная заранее, какие фигуры появятся.
Аналогия из жизни. Представьте пульт от кондиционера с кнопкой «охладить». Сам пульт один (базовый интерфейс), но в зависимости от того, к какой модели сплит-системы он подключён, реакция будет разной: одни модели начнут дуть на 16°, другие — на 18°. Для пользователя важно только нажать кнопку, а «как именно охлаждать» — решает конкретное устройство в рантайме. Это и есть полиморфизм: один интерфейс — много реализаций.
Что такое vtable и зачем она нужна. Когда объект создаётся, компилятор кладёт первым скрытым полем указатель на таблицу виртуальных функций — массив адресов конкретных реализаций. Аналогия: это таблица маршрутов в аэропорту; самолёт (объект) один, но по какому маршруту (функции) лететь в конкретном случае — определяется записью в таблице. Вызов s->area() превращается в «загляни в таблицу и перейди по адресу» — отсюда небольшая цена динамической диспетчеризации. Без virtual компилятор подставил бы адрес во время компиляции (раннее связывание), и вызов всегда попадал бы в базовую версию.
Почему vtable — это плата за гибкость. Компилятор не знает заранее, какой именно объект будет лежать в vector<unique_ptr<Shape>>, поэтому не может «зашить» адрес вызова прямо в код. Ему приходится в рантайме: (1) прочитать указатель vptr из объекта, (2) заглянуть в нужную ячейку vtable, (3) перейти по найденному адресу. Это три косвенных обращения к памяти вместо одного прямого вызова. На практике процессоры хорошо предсказывают такие переходы, и накладные расходы обычно невелики — но в горячих циклах миллионы виртуальных вызовов могут стать узким местом.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Shape {
public:
virtual ~Shape() = default; // ВИРТУАЛЬНЫЙ деструктор обязателен!
virtual double area() const = 0; // чистая виртуальная → абстрактный класс
virtual std::string name() const { return "shape"; } // можно переопределить
};
class Circle : public Shape {
public:
explicit Circle(double r) : r_(r) {}
double area() const override { return 3.14159 * r_ * r_; } // override проверяет сигнатуру
std::string name() const override { return "circle"; }
private:
double r_;
};
class UnitCircle final : public Circle { // final: от этого класса нельзя наследовать
public:
UnitCircle() : Circle(1.0) {}
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Circle>(2.0));
for (const auto& s : shapes) {
std::cout << s->name() << ": " << s->area() << '\n'; // динамическая диспетчеризация
}
return 0;
}
| Ключевое слово | Смысл |
|---|---|
virtual |
Вызов разрешается по динамическому типу |
override |
Компилятор проверит, что метод действительно переопределяет базовый |
final |
Запрещает дальнейшее переопределение или наследование |
= 0 |
Чистая виртуальная: тело не задано, класс абстрактный |
dynamic_cast |
Безопасное приведение с проверкой в runtime (нужен полиморфный тип) |
typeid |
Информация о типе в runtime (RTTI) |
Что это значит на практике. virtual включает динамическую диспетчеризацию и стоит лишнего обращения к памяти — поэтому не помечайте виртуальным всё подряд. override не влияет на работу программы, но ловит опечатку в сигнатуре: без него вы бы получили новую функцию, а не переопределение, и полиморфизм молча перестал бы работать. dynamic_cast дорог (проверка в рантайме) и требует RTTI; в горячем коде его избегают, предпочитая виртуальные методы или std::variant.
Практические подсказки по таблице.
virtual— плата идёт за возможность вызова через базовый указатель. Если вы всегда работаете с конкретным типом —virtualне нужен и даже вреден.override— пишите его ВСЕГДА при переопределении. Это «бесплатная страховка»: компилятор проверит, что сигнатура совпадает с базовой функцией (имя, тип возврата, параметры, константность). Если что-то не так — получите ошибку компиляции, а не молчаливый баг.final— ставится на метод, чтобы запретить его переопределение в наследниках, или на класс, чтобы запретить дальнейшее наследование. Помогает оптимизатору (он знает, что больше переопределений не будет) и проектировщику (явно фиксирует намерения).= 0— делает класс абстрактным: нельзя создать объект такого типа. Используется для определения интерфейса без реализации.dynamic_cast— работает только для полиморфных типов (тех, у которых есть хотя бы одна виртуальная функция). Возвращаетnullptrдля указателей и бросаетstd::bad_castдля ссылок, если приведение не удалось.typeid— возвращаетstd::type_info. Требует RTTI, может быть отключён флагом-fno-rtti.
Когда использовать, а когда нет. Наследование с virtual — когда объекты разных типов должны вести себя по-разному через общий интерфейс и выбор происходит в рантайме. Если тип известен при компиляции, лучше шаблоны (статический полиморфизм, без vtable) — они быстрее. Наследуйте публично только по принципу «является» (круг — это фигура); «имеет» моделируется композицией (членом класса).
Принцип «является» vs «имеет». Публичное наследование выражает отношение «is-a»: круг ЯВЛЯЕТСЯ фигурой, поэтому везде, где ожидается Shape, можно передать Circle. Отношение «has-a» (машина ИМЕЕТ двигатель) выражается композицией — полем класса. Наследование для «has-a» — частая ошибка новичков, которая ведёт к хрупкому дизайну и проблемам с модификацией иерархии.
Подводный камень №1 — нет виртуального деструктора. Удаление производного объекта через указатель на базу с невиртуальным деструктором — UB: производная часть не будет разрушена, ресурсы утекут.
class Base { public: ~Base() {} }; // НЕ виртуальный
class Derived : public Base { std::string big_; };
Base* p = new Derived();
delete p; // UB: ~Derived() не вызван, big_ утечёт
Почему это происходит. delete p вызывает деструктор по статическому типу указателя (Base), если деструктор не виртуальный. Производная часть объекта (big_) остаётся неразрушенной, её память и ресурсы утекают. С виртуальным деструктором вызов пойдёт через vtable и попадёт в ~Derived(), а тот автоматически вызовет ~Base().
Аналогия. Представьте, что вы сдаёте в аренду дом через агентство. Агентство знает только адрес дома (базовый указатель), но не знает, что внутри — мебель, техника, животные. Если агентство при «выселении» просто закроет дверь и уйдёт (невиртуальный деструктор), то животные останутся запертыми (утечка). Если агентство сначала войдёт, выпустит животных, вывезет мебель, а потом закроет дом (виртуальный деструктор с правильной цепочкой) — всё будет в порядке.
Типичная ошибка новичков. Пишут ~Base() = default; без virtual и думают, что «деструктор есть — и хорошо». Но именно virtual заставляет компилятор строить цепочку вызовов деструкторов от производного к базовому. Без этого слова — цепочка не строится, и компилятор «срезает» объект до базового типа.
На собеседовании спросят. «Когда нужен виртуальный деструктор?» Ответ: всегда, когда через указатель/ссылку на базу можно удалить объект производного типа. Правило-мнемоника: «есть хотя бы одна виртуальная функция — делай виртуальный деструктор».
Подводный камень №2 — object slicing. При копировании производного объекта в базовый по значению теряется производная часть.
void print(Shape s); // по значению — ПЛОХО
void print(const Shape& s); // по ссылке — правильно
std::vector<Shape> shapes; // ПЛОХО: все элементы «срежутся» до Shape
std::vector<std::unique_ptr<Shape>> good; // правильно: указатели сохраняют тип
Почему это происходит. Когда вы передаёте объект по значению, компилятор копирует только ту часть, которая «видна» через статический тип. Если статический тип — Shape, то копируется только «кусок» Shape, а специфичные поля Circle (например, r_) просто отбрасываются. Получается новый объект типа Shape без какой-либо информации о радиусе. Аналогия: если вы сфотографируете только лицо человека, а потом по фото попытаетесь узнать его рост — информации нет, она «срезалась».
Как распознать на собеседовании. Если видите функцию void foo(Base b) без ссылок и указателей — это красный флаг. Любой полиморфизм через такую функцию невозможен: всё «срежется».
Подводный камень №3 — виртуальный вызов в конструкторе/деструкторе. Во время работы базового конструктора объект ещё имеет базовый тип, поэтому вызов virtual попадёт в базовую версию (или в чисто виртуальную — с падением). Никогда не вызывайте виртуальные методы из конструкторов и деструкторов.
class Base {
public:
Base() { init(); } // вызовется Base::init, а не производный!
virtual void init() { std::cout << "base\n"; }
};
class Derived : public Base {
public:
void init() override { std::cout << "derived\n"; } // НЕ будет вызван из ctor
};
// Derived d; → печатает "base"
Почему так устроено. Когда выполняется базовый конструктор, производная часть объекта ещё не инициализирована — её просто не существует. Если бы компилятор вызвал Derived::init, тот мог бы прочитать неинициализированные поля и упасть, или хуже — получить UB. Поэтому стандарт C++ явно требует, чтобы виртуальные вызовы в конструкторах и деструкторах «деградировали» до статического типа на текущем уровне иерархии. Аналогия: пока фундамент дома строится, нельзя красить крышу — крыши ещё нет.
Практический совет. Если вам нужна «инициализация» с использованием данных производного класса — вынесите её в отдельный метод, который пользователь должен вызвать сам после конструктора (например, шаблон «двухфазная инициализация» или «initializer»). Либо используйте статический полиморфизм (шаблоны) — там такой проблемы нет.
Множественное наследование даёт несколько базовых подобъектов; если два пути ведут к одному базовому классу, используют виртуальное наследование (virtual Base), чтобы был один общий подобъект — так решается «ромб». dynamic_cast при этом корректно переходит между ветвями.
Аналогия с ромбом. Представьте генеалогическое древо, где у человека два родителя, и оба происходят от одного общего предка (например, общего прадеда). Без виртуального наследования у объекта было бы две копии «прадеда» — и непонятно, к какой из них обращаться. Виртуальное наследование говорит: «у этого предка должна быть только одна копия, общая для всей иерархии».
Почему ромб — это сложно. Виртуальное наследование решает дублирование базового подобъекта, но: (1) конструктор самого виртуального базового класса должен вызываться напрямую из самого производного класса (обычно это MostDerived), а не из промежуточных; (2) доступ к полям виртуальной базы чуть медленнее из-за дополнительного уровня косвенности; (3) приведение типов dynamic_cast требует обхода структуры vtable.
Как запомнить три главных ловушки. «Нет virtual ~ — утечёт память». «Копируешь по значению — срежешь производную часть». «Вызываешь virtual в конструкторе/деструкторе — попадёшь в базовую версию». Эти три вопроса — любимая тема интервью и источник реальных багов.
Когда использовать, а когда нет. Виртуальное наследование решает ромб, но усложняет модель объектов и замедляет доступ — применяйте только при необходимости. Множественное наследование чаще всего оправдано для интерфейсов (абстрактных классов без данных), а не для «смешивания реализации».
Практический совет по дизайну. Если хочется множественное наследование для «скрещивания» реализаций — лучше подумать о композиции, шаблоне «стратегия» или миксинах на шаблонах (CRTP). Множественное наследование интерфейсов (например, class Circle : public Shape, public Drawable, public Serializable) — нормальная практика, потому что интерфейсы не содержат данных и не дают ромба. А вот множественное наследование классов с данными — почти всегда признак плохого дизайна.
Полиморфизм: динамический или статический?
| Критерий | Виртуальные функции (virtual) |
Шаблоны (статический полиморфизм) |
|---|---|---|
| Когда выбирается реализация | В рантайме, по реальному типу | При компиляции, по типу-параметру |
| Цена вызова | Обращение к vtable (косвенно) | Нулевая, вызов инлайнится |
| Размер кода | Одна реализация | Копии для каждого типа |
| Хранение разнородных объектов | Да (vector<unique_ptr<Base>>) |
Нет (типы должны быть известны) |
| Сообщения об ошибках | Понятные | Длинные |
| Когда брать | Объекты разных типов в одной коллекции | Горячий код, типы известны заранее |
Дополнительные критерии выбора.
- Бинарная совместимость плагинов. Динамический полиморфизм позволяет загружать модули (
.so,.dll), написанные независимо: они реализуют известный интерфейс и регистрируются в фабрике. Шаблоны так не умеют — они требуют код конкретного типа в момент компиляции. - Размер бинарника. Шаблоны раздувают код: 10 разных типов в
vector<T>— это 10 разных версий каждого шаблонного метода. Виртуальные функции — одна реализация, но каждая единица трансляции тащит vtable. - Отладка. Виртуальные вызовы удобнее отлаживать: стек вызовов показывает реальный тип объекта. В шаблонах после инлайнинга стек может быть «развёрнут» в одну функцию.
- Анализ кода. Статические анализаторы лучше работают с шаблонами: всё известно на этапе компиляции. Виртуальные вызовы требуют анализа всех возможных наследников, что часто невозможно в больших проектах.
Гибридный подход — std::variant и std::visit. Если нужна коллекция разнородных объектов, но без vtable и RTTI, рассмотрите std::variant (из C++17): это типобезопасное объединение фиксированного набора типов. std::visit позволяет вызвать нужную реализацию без динамической диспетчеризации — компилятор строит таблицу переходов один раз. Это компромисс между скоростью шаблонов и гибкостью виртуальных функций.
Итоговая мнемоника. «Нужны разные типы в одной коллекции и выбор в рантайме — virtual. Тип известен заранее и нужна скорость — шаблон. Набор типов фиксирован, но лень писать иерархию — variant.»
Как запомнить. Это два разных инструмента для двух разных задач — выбирай по ситуации, а не по принципу «что круче».
Быстрая аналогия. Представь, что ты организуешь обед:
virtual— это шведский стол: одна большая «тарелка-массив», на которую разные повара (классы) кладут блюда любых видов. Ты берёшь тарелку и накладываешь в неё всё подряд —Circle,Rectangle,Triangle. Тарелка одна, блюда разные.- Шаблоны — это конвейер на фабрике: ты заранее знаешь, какие формы печёшь (круги или прямоугольники), и для каждой формы отдельная, идеально подогнанная линия. Скорость максимальная, но переоборудовать линию на ходу нельзя.
Правило выбора
| Ситуация | Что выбрать | Почему |
|---|---|---|
| Нужна коллекция «разных фигур в одном массиве» (гетерогенный контейнер) | virtual |
Полиморфизм позволяет хранить указатели на любых потомков в одном std::vector<Shape*> и вызывать единый интерфейс draw(). |
| Нужна максимальная скорость для фиксированного набора типов | Шаблоны | Компилятор генерирует отдельный код под каждый тип — никакого vtable, вызовы разрешаются статически, инлайнятся компилятором. |
| Типы становятся известны только в рантайме (читаются из файла, приходят по сети) | virtual (или std::variant как альтернатива) |
Шаблон требует знать типы на этапе компиляции, а virtual работает с любым потомком. |
Хочется и скорости шаблонов, и гибкости virtual |
std::variant, CRTP, или концепты C++20 |
Это компромиссные техники, но они сложнее — для учёбы пока достаточно знать, что они существуют. |
Почему так работает
virtualдобавляет косвенность: при вызове метода компилятор сначала смотрит в таблицу виртуальных функций (vtable), чтобы найти нужную реализацию. Это один лишний прыжок по указателю и невозможность заинлайнить вызов — зато работает для любого наследника.- Шаблоны убирают эту косвенность: код «размножается» на этапе компиляции под каждый конкретный тип, поэтому компилятор видит всё и может жёстко оптимизировать. Плата — раздутый бинарник и время компиляции.
Типичные ошибки
- «Использую шаблон, чтобы хранить разные фигуры в одном векторе». Не получится:
std::vector<Shape<T1, T2, ...>>— это разные типы, а не один. Для коллекции разных фигур нуженvirtualилиstd::variant. - «Делаю
virtualради пары известных типов, потому что "так универсальнее"». Потеряешь в скорости зря — фиксированный набор типов эффективнее обработать шаблоном илиif constexpr. - «Пишу чисто виртуальный класс, но забываю
virtualв деструкторе базы». Удаление объекта через указатель на базу приведёт к утечке памяти в наследниках. Деструктор в полиморфном классе обязан бытьvirtual. - «Считаю, что
virtual— это медленно, а шаблоны — всегда быстро». В реальностиvirtual— это один лишний прыжок по указателю, на современных CPU он часто предсказуем и почти бесплатен. Преждевременная оптимизация через шаблоны без профилирования обычно вредит читаемости кода.
Перегрузка операторов
Определение. Перегрузка операторов — это механизм C++, который позволяет задать собственное поведение встроенных операторов (+, <<, ==, (), [] и т. д.) для пользовательских типов. Технически оператор — это обычная функция с «магическим» именем вида operator@ (например, operator+, operator==); компилятор вызывает её вместо встроенного поведения, когда хотя бы один из операндов — объект вашего класса.
Аналогия из жизни. Универсальный калькулятор умеет складывать числа. Вы хотите, чтобы он также «понимал» координаты точек, матрицы или денежные суммы. Перегрузка — это инструкция калькулятору: «вот как работать с моим типом». Сам калькулятор остаётся тем же, но набор операций расширяется под ваши данные. Главное — не заставляйте его «вычитать», когда пользователь нажал «+»: такое поведение ломает интуицию и приводит к багам, которые невозможно отловить на этапе компиляции.
Операторы — это функции с особыми именами. Перегружайте только там, где синтаксис естественен: + для чисел, == для сравнения, << для вывода. Не давайте operator+ семантику удаления.
Зачем это нужно. Перегрузка позволяет пользовательскому типу вести себя как встроенный: Vec2 a, b; a + b читается понятнее, чем a.add(b). Принцип «наименьшего удивления» — главный: программист, увидев +, ожидает сложение, а не удаление. Если оператор не имеет очевидного смысла для типа (например, * для «соединить строки»), лучше назвать метод словами.
Как это устроено «под капотом». Когда компилятор встречает a + b, он ищет подходящую функцию operator+ сначала среди методов класса a (если a — ваш тип), затем среди обычных функций в текущей области видимости и в пространствах имён, связанных с типами аргументов. Эта последняя «ветка» называется ADL (Argument-Dependent Lookup, поиск по аргументам) — именно благодаря ей std::cout << vec находит дружественную функцию operator<<, объявленную внутри класса Vec2. Если ничего не найдено, компилятор пробует встроенные операторы (например, для int + int) либо сообщает об ошибке.
#include <compare>
#include <iostream>
#include <ostream>
class Vec2 {
public:
Vec2(double x, double y) : x_(x), y_(y) {}
Vec2 operator+(const Vec2& other) const { // член класса
return Vec2{x_ + other.x_, y_ + other.y_};
}
Vec2& operator+=(const Vec2& other) {
x_ += other.x_;
y_ += other.y_;
return *this; // возвращаем ссылку на себя
}
bool operator==(const Vec2& other) const = default; // C++20: сравнение членов
auto operator<=>(const Vec2& other) const = default; // C++20: лексикографический порядок
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) { // друг
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
private:
double x_, y_;
};
int main() {
Vec2 a{1, 2}, b{3, 4};
std::cout << a + b << '\n'; // (4, 6)
std::cout << (a == b) << '\n'; // 0
std::cout << (a < b) << '\n'; // 1 (по operator<=>)
return 0;
}
На что смотреть в этом коде.
operator+возвращает новый объект по значению: арифметика не должна менять операнды.operator+=возвращаетVec2&на*this— это разрешает цепочкуa += b += c.operator==иoperator<=>объявлены= default(C++20): компилятор сравнивает все поля автоматически и безопасно.operator<<сделанfriend-ом, потому что его левый операнд — поток, а не наш класс.
| Форма | Где объявлять | Пример |
|---|---|---|
operator+, operator== |
Член или друг | a + b, a == b |
operator<< (вывод) |
Друг или свободная | os << a |
operator=, [], (), ->, += |
Только член | a[0] |
Унарные +, -, !, ~ |
Член или друг | -a |
operator<=> |
Член | a <= b (C++20) |
Что это значит на практике. operator<< нельзя сделать членом, потому что левый операнд — поток std::ostream, а не ваш тип; поэтому его объявляют свободной функцией или friend. Операторы =, [], (), -> синтаксис языка требует делать членами. operator<=> (трёхстороннее сравнение, «космический корабль») в C++20 автоматически порождает <, <=, >, >= — писать шесть операторов сравнения вручную больше не нужно.
Как выбирать: член класса, свободная функция или friend.
- Метод класса — когда оператор логически «принадлежит» типу и должен видеть приватные поля. Удобно для унарных (
-a), составного присваивания (+=), а в C++20 — для<=>и==. - Свободная функция — когда один из операндов «чужой» (
std::ostream) или когда критична симметрия (a + bиb + aбез неявных преобразований правого операнда). friend-функция — компромисс: свободная функция, но с доступом к приватным полям. Используется дляoperator<<,operator>>и симметричной арифметики.
Почему для operator== безопаснее свободная функция. Если operator== — метод, в выражении a == b компилятор сначала пробует неявно привести b к типу a: асимметрия, неожиданные конверсии и сюрпризы при наследовании. Свободная функция bool operator==(const Vec2&, const Vec2&) обрабатывает оба операнда одинаково, что особенно важно в иерархиях классов.
Когда использовать, а когда нет. Перегружайте, если операция очевидна и часто используется. Не перегружайте &&, || (теряется короткое замыкание), , (ломает привычный смысл) и не давайте operator== несимметричную семантику.
Почему &&, || и , перегружать нельзя. Встроенные операторы гарантируют порядок вычисления: a && b пропускает b, если a ложно (короткое замыкание), а a, b всегда вычисляет a первым. Любая функция в C++ обязана вычислить все аргументы до вызова — компилятор не знает, что внутри «логика», поэтому короткое замыкание теряется. По этой же причине стандарт C++ запрещает перегрузку &&, || и , синтаксически: компилятор выдаст ошибку «cannot overload operator ...», если вы попробуете.
Инкремент и декремент различают по фиктивному параметру int:
class Counter {
int n_ = 0;
public:
Counter& operator++() { ++n_; return *this; } // префиксный: ++c
Counter operator++(int) { Counter old = *this; ++n_; return old; } // постфиксный: c++
int value() const { return n_; }
};
Почему префиксный без аргументов, а постфиксный — с int. C++ должен отличать ++a (префикс: сначала увеличить, вернуть новое значение) от a++ (постфикс: запомнить старое, увеличить, вернуть старое). Чтобы не вводить два разных имени, ввели фиктивный параметр int у постфиксной формы. Само значение int внутри не используется — это лишь «метка» для компилятора; по соглашению туда передаётся 0, но читать его не нужно.
Практический совет. В циклах всегда предпочитайте префиксный ++ постфиксному: for (Counter c; ...; ++c). Префиксный работает «на месте» и возвращает ссылку — без копирования. Постфиксный обязан сохранять старую копию, что для сложных типов обходится лишними тактами и аллокациями. То же касается итераторов стандартной библиотеки: для итераторов произвольного доступа префиксная форма почти всегда дешевле.
Ловушка — операторы преобразования. Неявный operator bool или operator int может привести к неоднозначностям; в C++11 и позже помечайте их explicit — тогда они сработают только в контексте, где нужен именно этот тип.
Почему это ловушка. Без explicit компилятор выполнит неявное преобразование везде, где оно синтаксически допустимо: в арифметике (h + 1), при передаче в функцию с другим типом параметра, в тернарных выражениях. Это одна из самых коварных причин загадочных ошибок: «почему мой Handle молча складывается с целым числом?». С explicit неявного пути нет, и каждая конвертация требует явного static_cast или контекста, где тип действительно требуется.
class Handle {
int fd_;
public:
explicit operator bool() const { return fd_ >= 0; } // работает в if, но не в арифметике
};
// if (h) { ... } // OK
// int x = h + 1; // ошибка — и это хорошо
Почему explicit здесь обязателен. Без него operator bool сработал бы в любом арифметическом контексте: h + 1, h * 2, передача в функцию, ожидающую int. Это порождает неоднозначности перегрузок и логические ошибки, которые компилятор не заметит. С explicit преобразование происходит только там, где язык действительно требует булево значение (if, while, !, &&, ||) — что и нужно.
Где explicit разрешает преобразование.
- В условии
if (h),while (h),for (; h; ). - В логических операциях
!h,h && cond,h || cond. - В тернарном операторе
cond ? h : default_value, где один из операндов явноbool. - В
static_assert(h),noexcept(h),delete[]— позиции, где синтаксически требуетсяbool. И не работает: в арифметике, при инициализации другим типом (bool b = h;), вreturn x;из функции, возвращающейint.
Аналогия. Handle — это «детектор ошибок». Вы хотите, чтобы в if он сообщал «есть ошибка?», но не хотите, чтобы его случайно использовали как число (handle + 5). explicit — это замок: открывается только там, где «дверь» ведёт именно в булеву проверку.
На собеседовании спросят. «Почему operator bool делают explicit?» и «Чем explicit-конструктор отличается от explicit operator T?» Оба вопроса — про контроль над неявными преобразованиями; ответ один: explicit запрещает «случайные» конвертации, оставляя только явные.
Как развернуть ответ.
explicit-конструктор: запрещает неявное создание объекта из другого типа.Handle h = 5;не скомпилируется, аHandle h(5);илиHandle h{5};— да.explicit operator T: запрещает неявное превращение нашего типа вT.bool b = h;не скомпилируется, аif (h) { ... }— да, потому что язык явно требуетbool. Общий принцип: неявные преобразования — это скрытые баги; лучше писатьstatic_castявно, чтобы код читался однозначно.
Что ещё могут спросить.
- «Зачем
operator<=>называют «космическим кораблём»?» — символ<=>похож на кораблик, а сам оператор «отправляет» два значения в трёх направлениях: меньше / равно / больше. Возвращаемый тип — обычноstd::strong_ordering,std::weak_orderingилиstd::partial_ordering. - «Можно ли сделать
operator+виртуальным?» — нет. Виртуальность работает только для методов с диспетчеризацией по*this; свободные операторы не виртуальны. Полиморфное копирование делается через виртуальныйclone()илиvirtualконструктор копирования. - «Чем
operator+отличается отoperator+=?» — первый создаёт новый объект, второй изменяет левый операнд и возвращает ссылку на него. Стандартная идиома:operator+черезoperator+=(T a = lhs; a += rhs; return a;). - «Нужно ли перегружать
&&и||?» — нет, и это запрещено синтаксически ради сохранения короткого замыкания.
Частые ошибки при перегрузке.
- Несимметричный
operator==. Сравнениеa == bиb == aдолжно давать одно и то же. Лучше делать свободной функцией с двумяconst&. Еслиoperator==— метод, неявные преобразования работают только для правого операнда — это асимметрия, особенно неприятная при наследовании и при типах сexplicit-конструкторами. - Забытый
return *thisвoperator+=. Теряется цепочкаa += b += c, а иногда и корректность. Компиляция пройдёт, но(a += b) += cлибо не скомпилируется (потому что возвращаетсяvoid), либо молча выбросит изменённую копию — баг проявится только в больших выражениях, искать его неприятно. operator+изменяет операнд. Арифметические операторы должны возвращать новый объект, а не менять исходный. Это нарушает интуицию (1 + 2не меняет1) и ломает работу с временными объектами иconst-корректностью. Идиома:T operator+(T a, const T& b) { a += b; return a; }.- Неявные преобразования без
explicit. Приводят к неоднозначностям перегрузок. Компилятор может выбрать «не ту» перегрузку из цепочки неявных приведений, и ошибка всплывёт далеко от места создания. - Перегрузка
&&,||,,. Теряется короткое замыкание и привычный порядок вычисления. В C++ это официально запрещено синтаксисом — компилятор выдаст ошибку «cannot overload operator &&», но знать об этом полезно, чтобы понимать, почему. operator<<как член класса. Не компилируется, потому что слева стоит поток, а не ваш объект. Синтаксисos << aтребует, чтобыoperator<<был найден в области видимостиstd::ostreamили найден через ADL; метод классаaтут недоступен — компилятор выбирает функцию до того, как «заходит» внутрьa.
Дополнительные ошибки, которые часто всплывают.
- Лишний
constна операторе, который должен мутировать.operator++()безconst— нормально; если пометить егоconst,++n_внутри не скомпилируется, потому что меняется состояние объекта. - Игнорирование
noexcept. Арифметика над «лёгкими» типами (Vec2,int) почти всегдаnoexcept— это помогает компилятору оптимизировать и безопасно для исключений. Для типов с аллокациями (std::string + std::string)noexceptснимается, и это нормально. - Передача по значению там, где достаточно
const&. Лишние копии; для больших типов это ощутимо. Но: для типов размером с дваdouble(Vec2) компилятор часто оптимизирует передачу по значению в регистрах — здесь это безопасно. - Возврат по значению ссылки на локальный объект.
return Vec2{...};— нормально, объект живёт в возвращаемом значении.return *new Vec2{...};— утечка памяти. - Сравнение через
operator==для типов с плавающей точкой без эпсилон-точности.Vec2(0.1+0.2) == Vec2(0.3)почти всегдаfalseиз-за ошибок округления. Для вещественных чисел используйтеstd::almost_equalили сравнение с эпсилон-окрестностью.
Как запомнить. «Постфиксный ++ берёт фиктивный int», «+= возвращает ссылку на себя», «<< — друг, а не член». Три эти фразы закрывают большинство вопросов по теме.
Расширенная мнемоника.
+,-,*,/,%,==,!=,<,>,<=,>=— бинарные, могут быть членами или свободными. Выбирай свободную функцию, если нужна симметрия.=,[],(),->— только члены: так требует синтаксис (первый операнд —*this).+=,-=,*=,/=— только члены: они изменяют левый операнд и должны иметь к нему доступ.<<,>>для потоков — только свободные: левый операнд «чужой».<=>,==,!=в C++20 — члены с= default, остальные операторы сравнения порождаются автоматически.- Префиксный
++— без аргументов; постфиксный++— с фиктивнымint. - Любой оператор, который не меняет данные, помечай
const. - Любой оператор, изменяющий левый операнд, возвращает
T&на*this.
Экспресс-чеклист перед коммитом.
- Все операторы сравнения либо написаны вручную, либо получены из
= default(<=>). - Арифметические операторы (
+,-,*) не меняют операнды и возвращают новое значение. - Составное присваивание (
+=,-=и т. п.) возвращаетT&на*this. operator<<для вывода объявлен какfriendили свободная функция.- Операторы преобразования помечены
explicit(если только намеренно не нужны неявные конверсии). - Бинарные операторы, не меняющие операнды, имеют
const-квалификацию (если они — члены). - Симметричные операторы (
a + bиb + a) выражены свободной функцией. - Для типов с плавающей точкой сравнение идёт через эпсилон, а не через
==.
RAII и умные указатели
Определение. RAII (Resource Acquisition Is Initialization, «захват ресурса есть инициализация») — это центральная идиома C++, в которой ресурс (память, файл, мьютекс, сетевое соединение, дескриптор ОС) захватывается в конструкторе объекта и автоматически освобождается в его деструкторе. Идиома опирается на детерминированное время жизни локальных объектов: как только переменная выходит из области видимости, её деструктор вызывается гарантированно. Именно это делает C++ безопаснее, чем C, и удобнее, чем языки с try/finally.
RAII — центральная идиома C++: ресурс захватывается в конструкторе и освобождается в деструкторе. Всё, что имеет пару acquire/release (new/delete, open/close, lock/unlock), оборачивают в класс. std::unique_ptr, std::shared_ptr и std::lock_guard — готовые RAII-обёртки.
Аналогия и смысл. RAII — это «уборщик, который приходит автоматически»: вы не пишете free, close, unlock руками, потому что при выходе объекта из области видимости компилятор сам вызывает деструктор. Представьте библиотеку: вы берёте книгу и получаете «талон» (объект RAII); как только вы выходите из читального зала (область видимости), талон автоматически списывает книгу обратно — никакого ручного возврата, штрафа за забытую книгу нет. Работает это благодаря гарантии языка: при выходе из блока, при return, при исключении (раскрутка стека) деструкторы локальных объектов вызываются всегда. Поэтому RAII — единственный способ писать код, безопасный к исключениям, без ручных try/finally (которых в C++ и нет — в отличие от Java, Python и C#, где приходится явно закрывать файлы в finally). До RAII в C использовали ручные пары open/close и надеялись, что программист ничего не забудет; в современном C++ такой код считается ошибкой проектирования.
#include <memory>
#include <iostream>
struct Resource {
Resource() { std::cout << "acquire\n"; }
~Resource() { std::cout << "release\n"; }
void use() { std::cout << "use\n"; }
};
int main() {
// unique_ptr: единоличное владение, нулевой оверхед, нельзя копировать
auto up = std::make_unique<Resource>();
up->use();
// auto copy = up; // ошибка: копирование запрещено
auto moved = std::move(up); // передача владения
// shared_ptr: разделяемое владение через счётчик ссылок
auto sp = std::make_shared<Resource>();
auto sp2 = sp; // счётчик = 2
std::cout << "use_count=" << sp.use_count() << '\n'; // 2
// weak_ptr: наблюдает, не влияет на время жизни — разрывает циклы
std::weak_ptr<Resource> wp = sp;
if (auto locked = wp.lock()) { // временно получить shared_ptr
locked->use();
}
return 0; // здесь вызовутся деструкторы
}
| Умный указатель | Владение | Копирование | Оверхед | Когда |
|---|---|---|---|---|
unique_ptr<T> |
Единоличное | Запрещено | Нулевой | По умолчанию |
shared_ptr<T> |
Разделяемое | Разрешено | Счётчик + control block | Общий ресурс |
weak_ptr<T> |
Не владеет | Разрешено | Указатель на control block | Разрыв циклов, кэш |
Что это значит на практике. «Владение» (ownership) — это ответ на вопрос «кто отвечает за удаление объекта и когда он будет уничтожен». Это самая важная концепция в управлении памятью: каждый объект в программе должен иметь ровно одного «хозяина», который знает, когда пора удалять (либо нескольких хозяев, договорившихся через счётчик ссылок). unique_ptr — один хозяин, поэтому его нельзя копировать (иначе появилось бы два хозяина на один объект и непонятно, кто удаляет), зато можно перемещать (передать владение). shared_ptr — несколько хозяев, объект удалится, когда счётчик ссылок упадёт до нуля; цена — атомарные (потокобезопасные, но не бесплатные) инкременты/декременты счётчика и control block — служебная структура с самим счётчиком, удалителем и аллокатором. weak_ptr ничего не удаляет, только «подсматривает» в живой shared_ptr, поэтому им разрывают циклические ссылки (например, в графах и деревьях). Правило выбора простое: начинайте с unique_ptr, а shared_ptr берите только если владение действительно разделяется между независимыми частями программы, которые не знают друг о друге заранее. Перейти с unique_ptr на shared_ptr легко (через std::move), обратно — почти невозможно без перепроектирования интерфейсов.
Как запомнить. unique — один (англ. «уникальный»), shared — общий (англ. «разделяемый»), weak — слабый (англ. «слабый», не влияет на время жизни). use_count() показывает, сколько shared_ptr смотрят на объект — это отладочный инструмент; в продакшен-коде полагаться на его конкретное значение нельзя, потому что в многопоточной программе оно может измениться сразу после чтения в соседнем потоке.
Готовые RAII-обёртки стандартной библиотеки.
| Обёртка | Ресурс | Освобождает |
|---|---|---|
std::unique_ptr<T> |
Объект в куче | delete в деструкторе |
std::shared_ptr<T> |
Объект с разделяемым владением | delete, когда счётчик = 0 |
std::lock_guard / unique_lock / scoped_lock |
Мьютекс | unlock |
std::fstream / ifstream / ofstream |
Файл | close |
std::vector / string |
Буфер памяти | Освобождение буфера |
std::jthread |
Поток | join + request_stop |
Вывод. Почти любой «ручной» захват ресурса имеет готовую RAII-обёртку в <memory>, <mutex>, <fstream>, <thread> и контейнерах STL. Если пишете lock()/unlock() или open()/close() руками — скорее всего, вы изобретаете то, что уже есть, и рискуете забыть освобождение на одной из веток (особенно при исключениях — компилятор не напомнит). Золотое правило современного C++: никакого голого new/delete, malloc/free, fopen/fclose, lock/unlock в прикладном коде — всё через RAII-обёртки, иначе вы платите за безопасность, которую компилятор мог бы обеспечить бесплатно.
Подводный камень №1 — циклы shared_ptr. Два объекта, ссылающихся друг на друга через shared_ptr (например, родитель → ребёнок → обратно к родителю в дереве), никогда не освободятся: счётчик каждого объекта в цикле никогда не станет нулём, потому что они держат друг друга живыми. Одно из рёбер делают weak_ptr — обычно ссылку «от ребёнка к родителю», потому что родитель и так владеет ребёнком и не нуждается в обратной сильной ссылке. Это классический источник утечек памяти в графовых структурах, который не видно глазом без профилировщика.
Подводный камень №2 — make_shared vs new. std::make_shared<T>() выделяет объект и control block одним блоком памяти: быстрее (одна аллокация вместо двух) и исключительно-безопасно (нет временного окна, когда объект уже создан, но shared_ptr ещё не сформирован — если между этими шагами бросится исключение, объект потеряется). Но weak_ptr держит весь блок живым (там лежит control block), поэтому долгоживущие weak_ptr к большим объектам лучше не смешить с make_shared: память под сам объект не освободится, пока жив последний weak_ptr, даже если все shared_ptr уже умерли. В таком редком случае лучше использовать std::shared_ptr<T>(new T(...)) — две отдельные аллокации, зато тело объекта освобождается, как только умирает последний shared_ptr.
// enable_shared_from_this: получить shared_ptr на себя внутри метода
class Node : public std::enable_shared_from_this<Node> {
public:
std::shared_ptr<Node> self() { return shared_from_this(); } // только если объект уже в shared_ptr
// std::shared_ptr<Node> bad() { return std::shared_ptr<Node>(this); } // ПЛОХО: двойное удаление
};
Почему нельзя shared_ptr<T>(this). Такой указатель создаёт второй control block со своим независимым счётчиком, ничего не зная о первом. Когда оба счётчика дойдут до нуля, объект будет удалён дважды — double free, классическое неопределённое поведение: чаще всего это краш, реже — тихое повреждение кучи, которое проявится через полчаса в не связанном с этим месте программы. enable_shared_from_this решает проблему: он даёт доступ к существующему control block через shared_from_this(), поэтому счётчик один на всех владельцев. Вызывать shared_from_this() можно только после того, как объект уже принадлежит shared_ptr (в конструкторе — нельзя: control block ещё не создан; в деструкторе — поздно, объект уже умирает). Правило: если у класса есть метод, возвращающий shared_ptr на себя (this), класс обязан наследоваться от enable_shared_from_this, иначе — UB.
На собеседовании спросят. «В чём разница между unique_ptr и shared_ptr?», «Почему цикл из shared_ptr течёт?», «Что будет, если сделать два shared_ptr из одного сырого указателя?» — все три вопроса про модель владения, и ответы стоит держать в голове дословно. Готовые формулировки: (1) «unique_ptr — единоличный владелец, нулевой оверхед, нельзя копировать, можно перемещать; shared_ptr — разделяемое владение через атомарный счётчик, дороже, можно копировать»; (2) «Потому что счётчик каждого объекта в цикле никогда не станет нулём — одно из рёбер должно быть weak_ptr»; (3) «Два независимых control block, двойной delete — UB, нужен make_shared или enable_shared_from_this для безопасного создания shared_ptr на this».
Сырые new/delete нужны в редких случаях. placement new конструирует объект в заранее выделенной памяти:
#include <new>
struct Node { int value = 0; };
alignas(Node) unsigned char buffer[sizeof(Node)]; // выровненная сырая память
Node* n = new (buffer) Node{42}; // сконструировать на месте
n->~Node(); // деструктор вызвать вручную
// delete n — НЕЛЬЗЯ: память не из new
Зачем это нужно. placement new конструирует объект в уже выделенном буфере, не запрашивая память у системного аллокатора. Это применяют в пулах памяти (memory pool), аренах, контейнерах фиксированной ёмкости (когда заранее известно максимальное число элементов и выделен массив unsigned char), при работе с shared memory между процессами и в ядре ОС, где динамическая аллокация запрещена или недопустимо дорога. Плата — ручное управление временем жизни: деструктор вызывается явно (n->~Node()), а delete применять нельзя, потому что память выделена не через new. Если вызвать деструктор дважды (double destroy) или не вызвать вовсе (забыть) — неопределённое поведение: use-after-free или утечка. Ещё одна тонкость: буфер должен быть правильно выровнен (alignas(Node)), иначе на платформах со строгим выравниванием (ARM, AVX-инструкции на x86) обращение к полям класса даст crash или очень медленную эмуляцию выравнивания. Помните: new (buffer) T{...} только конструирует, он не выделяет память — за память отвечает ваш код.
Когда использовать, а когда нет. В обычном коде placement new не нужен — берите std::vector, std::optional или std::aligned_storage. Если вы добавляете его «для скорости» без измерений (профилирования), вы просто усложняете код и получаете источник трудноуловимого UB. Практическое правило: используйте placement new только там, где требуется (а) zero-allocation в горячем коде реального времени, (б) работа с разделяемой памятью ОС (mmap, shm), (в) реализация собственного контейнера с фиксированным capacity, (г) embedded-системы без кучи. Во всех остальных случаях стандартные аллокаторы работают быстрее, чем кажется (особенно с tcmalloc/jemalloc), и с ними невозможно ошибиться в подсчёте деструкторов. Сигнал, что вы зря применяете placement new: вы пишете комментарий-напоминание «не забудь вызвать деструктор» — значит, пора остановиться и взять std::vector.
Исключения
Исключение — это специальный объект, который «вылетает» из функции в момент ошибки и летит по стеку вызовов вверх, пока его кто-нибудь не поймает через catch. Такой механизм нужен, чтобы передавать информацию об ошибке через границы функций без обязательной проверки возвращаемого кода.
Механизм работы: при броске (throw) среда выполнения начинает раскручивать стек вызовов в обратном порядке. На каждом уровне для всех полностью построенных локальных объектов вызываются деструкторы (это и есть работа принципа RAII — Resource Acquisition Is Initialization: «захват ресурса есть инициализация»), затем управление передаётся ближайшему подходящему catch-блоку.
Зачем нужны исключения. Вернуть код ошибки можно и в C-стиле, но тогда вызывающий обязан проверять его на каждом вызове — одну проверку забыли, и ошибка поехала дальше, превратившись в загадочный сбой далеко от места. Исключение же нельзя проигнорировать: если никто не поймал, программа завершится.
Аналогия из жизни: код ошибки — это записка, которую можно потерять по дороге; исключение — пожарная сирена, которую слышат все, пока кто-то не нажмёт «отбой» (catch).
Мини-словарь терминов:
- Бросить исключение (
throw) — создать объект-исключение и запустить его вверх по стеку. - Поймать исключение (
catch) — перехватить его в подходящем обработчике. - Раскрутка стека (stack unwinding) — процесс вызова деструкторов на пути от
throwкcatch. - Тип исключения — определяет, какой именно
catchего поймает (по совпадению типа вcatch-объявлении).
#include <iostream>
#include <stdexcept>
#include <string>
double divide(double a, double b) {
if (b == 0.0) {
throw std::invalid_argument("деление на ноль"); // бросаем объект-исключение
}
return a / b;
}
int main() {
try {
std::cout << divide(10, 0) << '\n';
} catch (const std::invalid_argument& e) { // ловим по const-ссылке!
std::cerr << "Ошибка: " << e.what() << '\n';
} catch (const std::exception& e) { // общий случай — последним
std::cerr << "Стандартная ошибка: " << e.what() << '\n';
} catch (...) { // перехват всего
std::cerr << "Неизвестная ошибка\n";
}
return 0;
}
Разбор по строкам:
tryоткрывает «зону наблюдения»: всё, что вылетит внутри, будет поймано нижестоящимиcatch.- Порядок
catchважен: проверка идёт сверху вниз, от более конкретного типа к более общему. Поэтомуcatch (...)всегда стоит последним. catch (const std::exception& e)ловит всё семейство стандартных исключений, потому что они наследуются отstd::exception.- Метод
e.what()возвращает C-строку с описанием ошибки — это общий интерфейс для всех стандартных исключений.
Как исключение работает вместе с RAII. Раскрутка стека — это «обратный проход»: выходя из каждой функции на пути к catch, среда вызывает деструкторы всех построенных локальных объектов. Посмотрите:
#include <fstream>
#include <stdexcept>
void process() {
std::ifstream in("data.txt"); // RAII: файл откроется
if (!in) throw std::runtime_error("нет файла");
std::vector<int> buffer(1000); // RAII: память выделится
// ... если здесь бросить исключение — файл закроется
// и память освободится АВТОМАТИЧЕСКИ, без try/catch внутри process()
}
Никаких try внутри process нет, но при любом исключении (или return) buffer и in будут корректно уничтожены. Это и есть смысл RAII: ресурсы освобождаются не там, где «удобно программисту», а гарантированно при любом выходе из функции — нормальном (дошли до конца) или аварийном (исключение).
Аналогия: представьте, что на входе в лабораторию вы автоматически надеваете защитные перчатки (конструктор), а на выходе — автоматически их снимаете и утилизируете (деструктор). Если внутри случится «аварийная эвакуация» (исключение), перчатки всё равно окажутся снятыми — санитару не придётся обходить каждую комнату вручную.
| Класс исключения | Когда |
|---|---|
std::logic_error |
Ошибка программиста: нарушен инвариант, который должен был выполняться по логике кода |
std::runtime_error |
Ошибка во время выполнения, не зависящая напрямую от кода (нет файла, сеть пропала) |
std::invalid_argument |
Некорректный аргумент функции (например, отрицательное число там, где ожидается положительное) |
std::out_of_range |
Выход за границы допустимого диапазона (at, substr, array::operator[]) |
std::bad_alloc |
Не удалось выделить память через new или при росте контейнера |
std::bad_cast |
Неудачный dynamic_cast по ссылке, когда тип не совпадает |
Что это значит на практике. Иерархия std::exception даёт единый интерфейс: e.what() возвращает текст, и вы можете ловить как конкретный тип, так и всю ветку через базовый. Это удобно для организации обработчиков по принципу «от частного к общему».
logic_error — это «ошибка в коде, её нужно исправить» (например, индекс вне диапазона), её не имеет смысла «обрабатывать» в рантайме — корректное решение состоит в исправлении бага. runtime_error — «внешние обстоятельства» (нет файла, отвалилась сеть), её как раз ловят и повторяют попытку либо сообщают пользователю.
Почему иерархия устроена именно так? Это позволяет писать универсальный код: библиотека может бросить любой наследник std::exception, а пользовательский код — поймать его одним catch (const std::exception& e) и достать сообщение через what(). Без такой иерархии каждая библиотека изобретала бы свой тип ошибки и свой способ узнать её описание, и обработка превратилась бы в ад.
Как запомнить. logic — вина программиста (баг в коде), runtime — вина окружения (внешний мир подвёл). Наследуйте свои исключения от std::runtime_error/std::logic_error, чтобы их можно было поймать общим catch (const std::exception&). Это правило хорошего тона: ваши исключения станут «гражданами» стандартной иерархии.
Совет: для своих типов исключений делайте то же, что делает стандартная библиотека — наследуйтесь от подходящего базового класса и передавайте сообщение в конструктор базового класса через std::string.
Гарантии безопасности исключений — это контракт между классом и его пользователями: класс обещает, в каком состоянии останется объект, если во время операции вылетит исключение.
- Nothrow (no-throw guarantee) — операция не бросает никогда. Помечается ключевым словом
noexcept. Самая сильная гарантия. - Basic (basic guarantee) — при исключении объект остаётся в валидном, но, возможно, изменённом состоянии. Никаких утечек, объект можно использовать дальше, но данные могут быть «не те».
- Strong (strong guarantee) — операция либо выполняется целиком, либо полностью откатывается (как транзакция в базе данных). Чаще всего реализуется через идиому copy-and-swap.
Как запомнить уровни. Думайте про откат транзакции: nothrow — «операция не умеет падать»; basic — «при падении объект цел, но состояние могло измениться»; strong — «либо всё, либо ничего, как с банковским переводом». Контейнеры STL обычно дают strong guarantee для вставки в vector (через noexcept move или копирование), а вот push_back у vector без move-noexcept сползает к basic.
Почему это важно? Если вы пишете функцию, которая меняет сразу несколько полей объекта, вы обязаны ответить себе на вопрос: «А что увидит пользователь, если на пятом шаге вылетит исключение?» Ответ и есть уровень гарантии, который вы обещаете в документации.
Подводный камень №1 — деструкторы noexcept по умолчанию. Деструктор неявно noexcept, и если он бросит во время раскрутки стека, программа завершится через std::terminate. Никогда не позволяйте деструкторам бросать: логируйте и глотайте, либо явно помечайте noexcept(false) (и не используйте такой объект в раскрутке).
Почему так строго? Раскрутка стека — это уже обработка одного «активного» исключения. Если в этот момент прилетает второе исключение из деструктора, то непонятно, какое из двух считать текущим, как их связать и куда передавать управление. Чтобы не разбираться с этим хаосом, стандарт просто вызывает std::terminate и завершает программу.
Подводный камень №2 — бросать по значению, ловить по ссылке. Если ловить по значению catch (std::exception e), произойдёт срезка (object slicing) — потеряется динамический тип и e.what() вернёт сообщение базового класса, а не реально брошенного производного.
throw std::runtime_error("boom");
// catch (std::exception e) // ПЛОХО: slicing
// catch (const std::exception& e) // ПРАВИЛЬНО
Аналогия: представьте, что вы ловите мяч. Если вы берёте его копию (по значению), вы теряете информацию о том, что это был, например, футбольный мяч с автографом, а не обычный теннисный. По ссылке же вы смотрите на оригинал и видите его реальный тип со всеми деталями.
noexcept — не только документация: компилятор может оптимизировать вызов (например, отказаться от генерации таблиц раскрутки в noexcept-функциях), а std::vector использует это свойство, чтобы решить, перемещать или копировать элементы (std::move_if_noexcept).
void safe() noexcept; // обещание не бросать
void risky() noexcept(false); // явно разрешаем бросать
static_assert(noexcept(safe()));
// Внутри noexcept-функции любой бросок → std::terminate
На собеседовании спросят. «Почему деструкторы неявно noexcept?» — потому что они вызываются при раскрутке стека; если деструктор бросит в этот момент, новое исключение возникнет до обработки старого, и программа аварийно завершится (std::terminate). «Что делает noexcept(false)?» — разрешает бросать, но тогда такой объект опасен в контексте раскрутки. Дополнительно могут спросить: «Зачем noexcept swap-функциям?» — чтобы std::vector мог безопасно перемещать элементы при реаллокации; без noexcept он выберет копирование, что медленнее. Или: «Что такое std::move_if_noexcept?» — утилита, которая возвращает std::move(x), если перемещение у x помечено noexcept, иначе возвращает копию — чтобы сохранить strong guarantee.
Когда использовать noexcept. Помечайте перемещающие операции (иначе контейнеры будут копировать), деструкторы, swap и простые геттеры. Не помечайте функции, которые могут законно бросить, — ложное обещание приведёт к std::terminate вместо нормальной обработки ошибки.
Практическое правило: если вы пишете функцию, которая делает что-то «очень простое» (возвращает поле, меняет одно значение, освобождает ресурс), и вы уверены, что ни один из вызываемых методов не может бросить — помечайте noexcept. Сомневаетесь — не помечайте, безопаснее. Ложный noexcept страшнее, чем его отсутствие: он превращает любую ошибку в крэш программы.
Частые ошибки с исключениями.
- Ловить по значению.
catch (std::exception e)срезает динамический тип — толькоconst&. Теряетсяwhat()производного класса и любая специфичная информация. - Бросать из деструктора. Деструктор неявно
noexcept; бросок при раскрутке →std::terminate. Если нужно сообщить об ошибке в деструкторе — пишите в лог, но не бросайте. - Глотать исключение молча. Пустой
catch (...) {}прячет ошибку; минимум — залогировать. Иначе вы неделями ищете «куда делись данные». - Ловить всё подряд в
catch (...). Теряется информация о типе и сообщение. Используйте только как «сетку безопасности» в самом конце. - Исключение как управляющий поток. Бросать на каждый найденный элемент — медленно; исключения — для исключительных ситуаций. Раскрутка стека дорогая, не используйте её вместо
if. - Не помечать move
noexcept.vectorначнёт копировать вместо перемещать. Это не ошибка с точки зрения корректности, но серьёзный performance regression.
Как выбирать между исключениями и кодом ошибки. Исключения — когда ошибка «не должна» случаться в нормальном потоке и её нельзя обработать на месте (нет файла, нет памяти). Код ошибки / expected — когда ошибка ожидаема и является частью логики (пользователь ввёл не число, элемент не найден).
Простое правило: «Это баг или нормальный случай?» Если баг — бросайте исключение, починка в коде. Если нормальный случай — возвращайте код ошибки, обрабатывайте на месте. Исключения стоят дорого (раскрутка стека, поиск catch), поэтому злоупотреблять ими для обычной логики — плохая идея. Дополнительно: в критичных к latency участках (hot loops, real-time) предпочитайте коды ошибок, потому что предсказать время обработки исключения нельзя.
Шаблоны и метапрограммирование
Шаблон (template) — это «чертёж» для генерации кода: сам по себе он не исполняется, а описывает, как из типов и значений-аргументов «выпечь» конкретную функцию, класс или переменную. Шаблоны позволяют писать код, параметризованный типами и значениями. Компилятор инстанцирует конкретную версию для каждого набора аргументов — это называется мономорфизацией (от греч. «одна форма»): из одного шаблона получается множество конкретных функций/классов, каждая под свой тип. Простыми словами, шаблон — это не готовый код, а форма для отливки: max_of<int> и max_of<double> — две разные функции, «отлитые» по одной форме в момент компиляции. Отсюда и скорость (нет накладных расходов рантайма — всё решено на этапе компиляции, как машинный код «зашит» в бинарник), и цена (код размножается в бинарнике, время компиляции растёт, отлаживать тяжелее — вы видите не свой шаблон, а его развёрнутую копию). Именно поэтому шаблоны иногда называют «бесплатным полиморфизмом с оплатой при компиляции».
#include <iostream>
#include <type_traits>
template <typename T>
T max_of(T a, T b) { // параметр-тип
return (a < b) ? b : a;
}
template <typename T, int N>
struct Array { // параметр-значение (non-type)
T data[N]{};
constexpr int size() const { return N; }
};
template <typename T>
constexpr T pi = T{3.14159265358979323846}; // шаблонная переменная (C++14)
template <typename... Args>
auto sum(Args... args) { // variadic template
return (args + ... + 0); // fold expression (C++17)
}
int main() {
std::cout << max_of(3, 7) << '\n'; // T = int
std::cout << max_of(2.5, 1.5) << '\n'; // T = double
Array<int, 5> arr;
std::cout << arr.size() << ' ' << sum(1, 2, 3, 4) << '\n';
return 0;
}
| Инструмент | Назначение |
|---|---|
template <typename T> |
Параметр-тип — подставляется любой тип данных |
template <int N> |
Параметр-значение (non-type) — целое число, указатель или т.п. |
template <typename... Ts> |
Пакет параметров (variadic) — произвольное число типов |
(args + ...) |
Fold expression (C++17) — свёртка пакета оператором |
if constexpr |
Ветвление на этапе компиляции (C++17) — отбрасывает ветки до инстанцирования |
std::enable_if_t |
SFINAE-ограничение перегрузки — классический способ отсева |
requires / concepts |
Ограничение параметров (C++20) — современная замена SFINAE |
type_traits |
Запросы о типах: is_integral, is_same, remove_reference |
Что это значит на практике. Параметр-тип (typename T) — самый частый: подставляется любой тип — хоть int, хоть ваш собственный класс. Именно так работает std::vector<T>. Параметр-значение (int N) позволяет задать размер ещё на этапе компиляции, как в std::array<T, N>: размер становится частью типа, поэтому компилятор может проверить выход за границы и аллоцировать массив на стеке без new. Пакет параметров (typename... Ts) принимает переменное число типов — так устроены std::tuple, std::variant, и «вариадические» обёртки вокруг printf-подобных функций. if constexpr компилирует только выбранную ветку, поэтому код в отброшенной может быть даже некорректным для данного типа (например, обращаться к полю, которого нет) — компилятор его не увидит и ошибки не будет.
Типичная ошибка новичка. Путать «параметр шаблона» и «аргумент шаблона». В template <typename T> — T это параметр (формальное имя). В max_of<int> — int это аргумент (конкретное значение, подставляемое вместо T). Точно как в функциях: void f(int x) — параметр, f(5) — аргумент.
Специализация позволяет задать особую реализацию для конкретного типа — как исключение из общего правила: «по умолчанию делай так, а вот для int — иначе»:
template <typename T>
struct TypeName { static constexpr const char* value = "unknown"; };
template <>
struct TypeName<int> { static constexpr const char* value = "int"; };
template <typename T>
struct TypeName<T*> { static constexpr const char* value = "pointer"; }; // частичная
Полная vs частичная специализация. Полная (template <> struct TypeName<int>) переопределяет шаблон для одного конкретного типа. Частичная (template <typename T> struct TypeName<T*>) — для семейства типов (всех указателей). Для функций полная специализация запрещена — используйте перегрузку.
if constexpr отбрасывает невыбранную ветку до инстанцирования — так пишут код, который не должен компилироваться для части типов. Это чистая альтернатива рантайм-веткам: платите нулевые издержки в рантайме, зато компилятор проверяет только нужную ветку:
template <typename T>
void describe(const T& value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "целое: " << value << '\n';
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "вещественное: " << value << '\n';
} else {
std::cout << "другое\n";
}
}
Почему тут if, а не обычный if? Обычный if проверяется в рантайме, но обе ветки должны быть корректным кодом для типа T — иначе ошибка компиляции. if constexpr проверяет условие в момент компиляции и выкидывает ложную ветку до того, как компилятор начнёт разбирать её синтаксис и семантику. Поэтому в «мёртвой» ветке можно писать value.size(), даже если у int нет метода size() — компилятор эту ветку просто не увидит.
SFINAE и void_t — старая (но всё ещё встречающаяся) техника ограничения перегрузок. Название — аббревиатура от Substitution Failure Is Not An Error: «если подстановка не удалась — это не ошибка, а повод отбросить перегрузку». std::void_t<T> превращается в void, если все типы в нём валидны, иначе подстановка проваливается, и перегрузка отбрасывается. Используется как «детектор» наличия у типа нужного свойства:
#include <type_traits>
#include <iterator>
template <typename T, typename = std::void_t<>> // базовый случай
struct has_iterator : std::false_type {};
template <typename T>
struct has_iterator<T, std::void_t<typename T::iterator>> : std::true_type {};
Разбор has_iterator. Базовый шаблон отвечает «нет» (false_type). Частичная специализация с void_t<typename T::iterator> подходит только для типов, у которых есть T::iterator; для остальных подстановка проваливается по правилу SFINAE, и специализация просто отбрасывается — ошибки нет. Итог: мы спрашиваем у компилятора, есть ли у типа итератор. Это основа старых библиотек (Boost, C++11/14) до появления concepts. Аналогия из жизни: вы проверяете, умеет ли человек плавать, спрашивая у страховой компании — если тип «плавучий», она выдаст полис; иначе — откажет без скандала.
Частая ловушка. SFINAE работает только в «непосредственном контексте» подстановки: если ошибка возникает в теле инстанцированной функции, это уже жёсткая ошибка компиляции, а не «мягкое» отбрасывание перегрузки. Именно поэтому SFINAE-код такой громоздкий и почему в C++20 от него отказались в пользу concepts. Практический совет: если видите enable_if в современном коде — это либо легаси, либо случай, где concepts неудобны; в новом коде предпочитайте requires.
C++20: concepts и requires заменяют громоздкий SFINAE читаемыми ограничениями. Концепт — именованное требование к типу; перегрузки отбираются компилятором до тела функции. Если шаблон — это «форма для отливки», то концепт — это «чертёж допусков»: какими свойствами должна обладать заготовка, чтобы её вообще взяли в обработку.
#include <concepts>
#include <iostream>
template <typename T>
concept Addable = requires(T a, T b) { // требует валидности выражений
{ a + b } -> std::same_as<T>;
};
template <Addable T>
T add(T a, T b) { return a + b; } // участвует в перегрузке только для Addable
// Эквивалентно: template <typename T> requires Addable<T> T add(T a, T b);
template <std::integral T> // стандартный концепт из <concepts>
T twice(T x) { return x * 2; }
int main() {
std::cout << add(1, 2) << ' ' << twice(21) << '\n';
// add(std::string{"a"}, std::string{"b"}); // тоже OK: std::string + std::string
// add(1, 2.5); // ошибка: разные типы
return 0;
}
Анатомия concept Addable. Ключевое слово requires(...) описывает набор требований-выражений: внутри скобок стоят валидные C++-выражения от параметров концепта. Запись { a + b } -> std::same_as<T>; означает: «выражение a + b должно быть корректным И его тип должен совпадать с T». Если тип не удовлетворяет хотя бы одному требованию — он «не Addable», и шаблон с template <Addable T> для него не инстанцируется, компилятор ищет другую перегрузку.
Практическая подсказка. В C++20 чаще используют не собственные концепты, а готовые из <concepts>: std::integral, std::floating_point, std::same_as, std::convertible_to, std::ranges::range. Это сильно сокращает шаблонный код и делает сообщения об ошибках понятными — компилятор укажет, какое именно требование нарушено.
Когда использовать шаблоны, а когда нет. Шаблоны в C++ — это механизм, позволяющий написать алгоритм или структуру данных один раз и использовать её с разными типами (int, double, std::string, пользовательскими классами), сохраняя при этом максимальную производительность.
Простая аналогия: представьте кондитерскую форму для печенья. Сама форма — это шаблон, а тесто (шоколадное, ореховое, миндальное) — это конкретный тип. Из одной формы получается печенье разных «вкусов», но логика вырезания одна и та же. Так и шаблон: один код → множество конкретных реализаций под каждый тип.
Шаблоны нужны, когда один и тот же алгоритм должен работать с разными типами, а потери на виртуальных вызовах недопустимы: контейнеры, алгоритмы, математические типы, обёртки.
Почему виртуальные вызовы — это «потери»? Виртуальная функция требует обращения к таблице виртуальных методов (vtable) во время выполнения программы — это как спрашивать дорогу у прохожих на каждом перекрёстке. Шаблон же генерирует отдельную «копию» кода под каждый тип ещё на этапе компиляции, и компилятор может спокойно заинлайнить вызовы, применить агрессивные оптимизации. Поэтому std::vector<int>::push_back в горячем цикле работает заметно быстрее, чем эквивалентный вызов через виртуальный интерфейс.
Не пишите шаблон «на будущее» для одного типа — это усложнит компиляцию ошибок и увеличит время сборки.
Почему это плохая практика? Даже если сейчас шаблон инстанцируется под единственный тип, компилятор всё равно будет разбирать шаблонный код в «двухпроходном» режиме, проверять зависимые имена (dependent names), и при любой ошибке вы получите развёрнутые, тяжёлые для чтения сообщения. Другие разработчики будут вынуждены разбираться в шаблонной механике без реальной выгоды. Шаблон должен быть следствием повторяющегося паттерна, а не преждевременным обобщением.
Помните о цене: код инстанцируется для каждого типа (растёт бинарник), а сообщения об ошибках становятся длинными.
Типичная ошибка новичков: использование шаблонов ради «общего вида» в простых функциях, которые используются один-два раза.
- Плохо:
template <typename T> T addOne(T x)ради гипотетической универсальности. - Хорошо:
int addOne(int x), пока нет реальной необходимости в обобщении.
Практическая подсказка: правило «трёх вызовов» — если вы используете одинаковую логику для трёх и более разных типов, шаблон оправдан. Если меньше — лучше явный код или перегрузка функций. Ещё один маркер: если в API библиотеки тип упоминается в каждом методе (MyClass<T>::method(), MyClass<T>::field), значит, перед вами кандидат на шаблон.
Подводный камень — ошибки шаблонов огромны. Это одна из самых известных «болей» C++-разработчиков, и причина — в самом механизме работы шаблонов.
Почему ошибки такие длинные? Когда компилятор встречает вызов шаблонной функции с конкретным типом, он должен мысленно «подставить» этот тип везде, где в шаблоне стоит параметр T. Если где-то в процессе возникает ошибка (например, T не поддерживает оператор +), компилятор печатает всю цепочку инстанцирования — то есть весь путь от точки вызова до проблемного места в шаблоне. Это как если бы вы читали не одно сообщение об ошибке, а целый детективный сюжет, где каждый «персонаж» — это один уровень вложенности шаблона в шаблон.
Простая аналогия: представьте, что вы просите друга передать вам книгу через цепочку из 10 человек. Если последний человек не может найти нужную книгу, вам придёт сообщение от каждого из 10: «Я попросил следующего...», «Я передал дальше...», «Я получил запрос...». Чтобы найти суть проблемы, нужно пролистать всё до самого последнего сообщения.
Компилятор печатает всю цепочку инстанцирования. Средства: concepts вместо SFINAE, static_assert с понятным сообщением, template <typename T> using ... для сокращения, clang с -fno-show-template-tree не нужен — читайте снизу вверх.
Разбор каждого средства:
- Concepts (C++20) — это способ явно сказать компилятору «
Tдолжен поддерживать оператор+» или «Tдолжен быть целочисленным». Если тип не подходит, ошибка возникает сразу на этапе проверки концепта, а не глубоко внутри шаблона после раскрытия всех типов. static_assertс понятным сообщением — это ручной «контроль качества»: «стоп, если условие неверно, и вот почему». Например:static_assert(std::is_integral_v<T>, "T должен быть целочисленным типом");. Так вы получаете ошибку ровно там, где хотите, с тем текстом, который сами написали.template <typename T> using ...— это alias-шаблоны, которые сокращают длинныеtypename-выражения и убирают часть «шума» из сообщений об ошибках. Вместоtypename std::vector<T>::iteratorможно написатьusing Iter = typename std::vector<T>::iterator;.
Практическая подсказка: флаг Clang -fno-show-template-tree действительно существует и «расплющивает» дерево инстанцирования, но часто это делает сообщение ещё менее читаемым — теряется визуальная структура «кто от кого зависит». Лучше освоить технику чтения «снизу вверх», о которой пойдёт речь ниже.
Как читать ошибку шаблона. Это ключевой навык для любого C++-разработчика, работающего с обобщённым кодом.
Аналогия из жизни: представьте, что вы заблудились в большом торговом центре и спрашиваете дорогу. Вам отвечают длинно: «Сначала пройдите мимо магазина А, потом через переход к Б, потом поднимитесь на второй этаж к В, потом поверните направо...» — это всё «путь». А реальная проблема может быть простой: «нужный магазин сегодня закрыт на переучёт». Так вот, верх сообщения об ошибке — это путь инстанцирования, а низ — суть проблемы.
Читайте снизу вверх: последняя строка обычно и есть настоящая причина, а всё, что выше, — «путь» инстанцирования (кто кого вызвал).
Пошаговый алгоритм:
- Прокрутите сообщение об ошибке до самого конца — иногда это сотни строк.
- Найдите строку, начинающуюся с
error:— это и есть настоящая причина. - Прочитайте её внимательно: часто там написано что-то вроде «no match for
operator+» или «invalid initialization of reference». - Посмотрите на строку выше — там обычно указан файл и строка внутри шаблона, где произошла ошибка.
- Если не понимаете, кто вызвал этот шаблон с неподходящим типом, — читайте вверх по цепочке, пока не встретите свой код.
Первое, что стоит сделать, — найти в сообщении ваш файл и строку с вызовом.
Почему именно так? Ошибки внутри системных заголовков (например, <vector>, <algorithm>, <type_traits>) — это почти всегда следствие неправильного использования шаблона в вашем коде. Если вы нашли свою строку с вызовом, значит, проблема в типе, который вы передали, а не в самом стандартном шаблоне. Сstd::vector не сломан — сломан ваш способ его использовать.
Практическая подсказка: в IDE (CLion, Visual Studio, VS Code с C++-плагинами) обычно есть специальное окно «свёрнутых» ошибок, где каждая инстанциация представлена одной строкой с возможностью раскрыть её по клику. Используйте его — это сильно ускоряет навигацию по длинным сообщениям.
Типичная ошибка: пытаться читать ошибку сверху вниз и паниковать от объёма. Реальная информация почти всегда в конце. Ещё одна частая ошибка — путать «путь» (instantiation stack) с самой ошибкой и пытаться «починить» системный заголовок, который ни в чём не виноват.
На собеседовании спросят. Это одни из самых частых вопросов, которые проверяют глубину понимания шаблонов в современном C++.
«Зачем concepts, если есть SFINAE?» — читаемость: template <Addable T> понятнее, чем enable_if_t<...>; к тому же concepts дают осмысленные сообщения об ошибках и участвуют в разрешении перегрузок понятным образом.
Расширенный ответ для собеседования — четыре ключевых преимущества concepts:
1. Читаемость кода и самодокументирование.
Concepts — это, по сути, документирование намерений прямо в сигнатуре. Когда вы пишете template <Addable T> T sum(T a, T b), любой, кто читает код, мгновенно понимает: «T — это тип, который можно складывать». SFINAE же без комментариев выглядит как магия: template <typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>> — нужно вчитаться, чтобы понять ограничение.
2. Качество сообщений об ошибках. С SFINAE, если условие не выполнилось, вы часто получаете загадочное «no matching function for call to ...» — без указания, почему именно не подошёл тип. С concepts компилятор пишет прямо: «the associated constraints are not satisfied» или даже указывает, какой именно концепт нарушен и какое выражение в нём не компилируется.
3. Полноценное участие в разрешении перегрузок.
Concepts — это полноправные «предикаты», которые участвуют в overload resolution на равных с другими факторами. SFINAE через enable_if — это, по сути, хак, и его поведение в перегрузках иногда неочевидно: при частичной специализации и приоритетах шаблонов можно получить сюрпризы.
4. Композиция и повторное использование.
Concepts можно комбинировать через && и ||: template <Integral && Addable T>. Можно делать «концепт-концепта»: template <typename T> concept Numeric = Integral<T> || FloatingPoint<T>;. С SFINAE то же самое превращается в арифметику с вложенными enable_if_t, которую сложно читать и поддерживать.
Другие частые вопросы на собеседовании по шаблонам:
- «Чем
typenameотличается отclassв параметре шаблона?» — исторически ничем, оба слова эквивалентны, ноtypenameпредпочтительнее, потому что явно говорит: параметр может быть любым типом (включая вложенныеtypedefв шаблоне). - «Что такое специализация шаблона?» — это способ написать отдельную реализацию для конкретного типа (например, оптимизированную версию
Stack<bool>с битовой упаковкой). - «Что такое variadic templates?» — шаблоны с переменным числом аргументов:
template <typename... Args>. Используются, например, вstd::make_tupleилиstd::function. - «Что такое template metaprogramming?» — это выполнение вычислений на этапе компиляции через рекурсивную специализацию шаблонов (раньше — единственный способ, сейчас часто заменяется
constexpr-функциями).
Практическая подсказка для ответа: не просто перечисляйте преимущества concepts, а покажите конкретный пример кода: «вот так SFINAE выглядит запутанно, а вот так с concepts — прозрачно и понятно даже junior-разработчику». Это демонстрирует практический опыт, а не заученную теорию.
STL: контейнеры, итераторы, алгоритмы
STL состоит из трёх «китов»: контейнеров (хранят данные), итераторов (дают доступ к элементам контейнера единообразно) и алгоритмов (обрабатывают данные через итераторы). Главная идея — алгоритмы ничего не знают о контейнерах: они работают только с итераторами. Такое разделение даёт комбинаторику: один std::sort работает и с vector, и с deque, и с обычным C-массивом — потому что все они предоставляют итераторы. Это и есть полиморфизм времени компиляции: вместо виртуальных функций — шаблоны и абстракция «указатель-в-контейнер».
Что такое итератор. Это обобщённый «указатель на элемент» — объект, который умеет *it (разыменовать), ++it (перейти к следующему) и it != other (сравнить с другим). У любого контейнера есть begin() (указывает на первый элемент) и end() (указывает на условный элемент за последним — полуинтервал [begin, end)). Аналогия из жизни: итератор — закладка в книге; алгоритм листает страницы, не зная, бумажная это книга или электронная. Ещё точнее — это как палец читающего человека: он «сейчас на этом слове», может сдвинуться вправо и в какой-то момент «соскочить» за конец текста — там читать уже нечего. Именно из-за полуинтервала циклы пишут it != end(), а не it <= end(): end() — это «место сразу после последнего», туда ходить нельзя, но сравнивать — можно.
Категории итераторов (от слабого к сильному): input (только чтение, одно прохождение — как чтение файла), output (только запись, одно прохождение), forward (можно ходить вперёд много раз — forward_list), bidirectional (можно и вперёд, и назад — list, map, set), random access (можно прыгнуть на it + n за O(1) — vector, deque, обычный указатель). Алгоритм предъявляет к итератору минимально нужную категорию: std::find хватит input, а std::sort требует random access — поэтому sort для list не работает (и не нужен, ведь у list есть свой list::sort).
Контейнеры и сложность операций
| Контейнер | Доступ | Поиск | Вставка/удаление | Особенность |
|---|---|---|---|---|
vector |
O(1) по индексу | O(n) | O(1) в конец (амортизировано) | Непрерывная память, лучший по умолчанию |
deque |
O(1) | O(n) | O(1) с обоих концов | Блоки, нет непрерывности |
list |
O(n) | O(n) | O(1) по итератору | Двусвязный список |
forward_list |
O(n) | O(n) | O(1) по итератору | Односвязный, минимальный оверхед |
array |
O(1) | O(n) | — | Фиксированный размер на стеке |
map / set |
O(log n) | O(log n) | O(log n) | Красно-чёрное дерево, упорядочен |
unordered_map |
O(1)* | O(1)* | O(1)* | Хеш-таблица, порядок не определён |
stack / queue |
— | — | — | Адаптеры поверх deque/vector |
priority_queue |
O(1) top | — | O(log n) | Бинарная куча |
* Амортизированно; при плохой хеш-функции деградирует до O(n).
Как читать таблицу. Колонка Доступ отвечает на вопрос «сколько стоит добраться до i-го элемента?». Поиск — «сколько стоит найти элемент по значению/ключу?». Вставка/удаление — «сколько стоит добавить/убрать элемент туда, куда можно у данного контейнера». Прочерки означают, что операция либо невозможна (нет [] у list), либо бессмысленна для адаптера (stack — обёртка, к ней нельзя обратиться «сбоку»).
Что значит O(...) на практике. O(1) — «не зависит от размера»: и для 10 элементов, и для 10 миллионов время одно и то же (с точностью до константы). O(log n) — «очень мало шагов»: для n = 1 000 000 это всего ~20 шагов (потому что log₂(10⁶) ≈ 20). O(n) — «просматриваем всё»: время растёт линейно с размером. O(n log n) — «сортировка»: чуть больше, чем линейно. Для vector поиск — O(n), потому что данные лежат подряд в памяти и их приходится перебирать один за другим; для map — O(log n), потому что это сбалансированное дерево и на каждом шаге мы отбрасываем половину; для unordered_map — O(1) в среднем (идеальная хеш-таблица — массив, где по хешу сразу прыгаем в нужную ячейку), но в худшем случае (все ключи попали в один бакет — «хеш-коллизия») — снова O(n). «Амортизированно» для push_back в vector означает: обычно O(1), но раз в N вставок происходит перевыделение буфера за O(n) (вектор удваивает ёмкость, копирует элементы, освобождает старый буфер), и в среднем на одну вставку выходит константа. Аналогия — лотерея: иногда больно, но «в среднем по больнице» — дёшево.
Какую сложность выбирать. Если важен быстрый доступ по индексу — vector; если частый поиск по ключу — unordered_map (быстрее в среднем, но платите за хеширование и память) или map (если нужен порядок обхода и предсказуемое время). list берут редко: выигрыш на вставке в середину (O(1) против O(n) у vector) часто перекрывается плохой локальностью памяти — элементы list разбросаны по куче, процессор постоянно промахивается мимо кеша, и реальное время оказывается хуже, чем у «более медленного» алгоритма на vector.
Типичные ошибки при чтении сложности.
- Путать среднее и худшее: у
unordered_mapнаписано O(1), но безreserve()и с плохим хешем в продакшене будет O(n) — и вы это увидите только под нагрузкой. - Путать вставку по итератору (O(1)) и вставку по значению (O(n) — сначала найти, куда).
list::push_back— O(1), аlist::insert(find(...))— уже O(n). - Забывать про «константу в O»: O(1) у
unordered_mapна деле дороже, чем O(1) уvector— там хеш, там аллокация узла, там указатель. Поэтому для маленьких данныхvectorпобеждает дажеunordered_map.
Как выбрать контейнер за 30 секунд.
- Нужен индекс
[i]и быстрый обход —std::vector(по умолчанию берите его). - Нужны вставки/удаления с обоих концов —
std::deque. - Нужен поиск по ключу без порядка —
std::unordered_map/std::unordered_set. - Нужен поиск по ключу и упорядоченный обход —
std::map/std::set. - Нужен стек/очередь/приоритет —
std::stack/std::queue/std::priority_queue. - Нужен фиксированный размер на стеке —
std::array. - Нужны вставки в середину при огромных размерах и стабильные итераторы —
std::list(редко).
Когда использовать, а когда нет. Начинайте с vector, даже если кажется, что «нужен список»: на современных процессорах непрерывная память настолько важнее алгоритмической сложности, что vector часто обгоняет list даже на вставках. Измеряйте, а не угадывайте — правило «premature optimization is the root of all evil» здесь работает в обе стороны: не оптимизируйте вслепую, но и не отказывайтесь от измерений, когда разница в 10× уже маячит на горизонте.
Подводный камень хеш-таблиц. unordered_map хорош, пока ваши ключи — простые типы с готовым std::hash (int, std::string). Если ключ — структура, придётся писать operator== и специализацию std::hash, иначе код не скомпилируется. Ещё тонкость: при rehash все итераторы unordered_map инвалидируются (в отличие от ссылок, которые остаются живыми) — учитывайте это, если храните итераторы надолго.
#include <algorithm>
#include <iostream>
#include <map>
#include <unordered_map>
#include <vector>
int main() {
std::vector<int> v{5, 2, 8, 1, 9};
std::sort(v.begin(), v.end()); // 1 2 5 8 9
auto it = std::lower_bound(v.begin(), v.end(), 5); // первый >= 5 (требует сортировки)
v.erase(std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }), v.end());
std::map<std::string, int> m{{"b", 2}, {"a", 1}}; // упорядочен по ключу
m["c"] = 3; // вставка через operator[]
m.insert_or_assign("a", 10); // не создаёт значение по умолчанию
if (auto found = m.find("b"); found != m.end()) { // C++17: init-statement в if
std::cout << found->first << '=' << found->second << '\n';
}
std::unordered_map<int, std::string> cache;
cache.reserve(1000); // заранее резервируем бакеты — меньше рехешей
cache.emplace(1, "one");
return 0;
}
Инвалидация итераторов — главная ловушка STL
Операции, меняющие размер контейнера, могут сделать итераторы и ссылки невалидными. Использование невалидного итератора — UB (undefined behavior, неопределённое поведение): программа имеет право сделать что угодно — упасть, выдать неправильный ответ, испортить память. Компилятор не обязан вас предупреждать.
Почему итераторы «ломаются». vector хранит элементы в непрерывном буфере (как обычный массив в куче). Когда при push_back места не хватает, контейнер выделяет новый буфер большего размера (обычно ×2), переносит туда элементы и освобождает старый. Старые итераторы/ссылки продолжают «помнить» адрес в уже освобождённой памяти — это висячие указатели (dangling pointers). erase же сдвигает элементы влево, заполняя «дыру», поэтому итератор на удалённый элемент и все последующие итераторы указывают уже не туда (как места в строю, когда одного человека выдернули — остальные шагнули влево). У узловых контейнеров (list, map) элементы живут в отдельных узлах, разбросанных по куче, и при вставке/удалении других элементов не переезжают — поэтому там инвалидируется только итератор/указатель на удалённый узел. Аналогия: vector — это шкаф с плотно стоящими книгами: чтобы вставить одну, нужно подвинуть соседей (или переставить весь шкаф). list — это сумка с карточками: каждая карточка лежит, где лежала, и вставка новой ничего не сдвигает.
Практическое правило безопасности. Прежде чем что-то делать с контейнером в цикле — сверьтесь с таблицей ниже или с документацией. Если операция инвалидирует итераторы — заведите новый итератор сразу после операции. Для vector самый надёжный путь — идиома erase-remove или std::erase_if (C++20), а вручную — брать it = container.erase(it).
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4};
// ПЛОХО: erase сдвигает элементы, текущий итератор invalided
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0) v.erase(it); // UB: it указывает на удалённый элемент
}
// ПРАВИЛЬНО (C++11+): erase возвращает следующий валидный итератор
for (auto it = v.begin(); it != v.end();) {
if (*it % 2 == 0) it = v.erase(it);
else ++it;
}
// ПРАВИЛЬНО (C++20): std::erase_if
// std::erase_if(v, [](int x) { return x % 2 == 0; });
// Ловушка push_back: итераторы инвалидируются при перевыделении буфера
auto first = v.begin();
for (int i = 0; i < 100; ++i) v.push_back(i);
// first — невалиден, если буфер перевыделялся
return 0;
}
| Контейнер | Вставка/удаление | Инвалидируется |
|---|---|---|
vector |
в конец (без роста) | только end() |
vector |
в конец (с ростом) | все итераторы и ссылки |
vector |
в середину | все итераторы и ссылки от точки вставки |
deque |
в конец/начало | все итераторы, ссылки на элементы сохраняются |
deque |
в середину | все итераторы и ссылки |
list |
любая | только итератор на удалённый элемент |
map/set |
любая | только итератор на удалённый элемент |
unordered_map |
рехеш | все итераторы (ссылки сохраняются) |
Частые сценарии инвалидации, о которых забывают.
reserveуvector— не инвалидирует ничего (только перевыделяет буфер, если новый размер больше), а вотshrink_to_fit— может инвалидировать всё.clear— дляvectorинвалидирует всё (буфер остаётся, но элементов нет); дляlist/map— инвалидирует всё (узлы освобождаются).- Передача контейнера в функцию, которая внутри делает
push_back, — классическая ошибка «безопасно в нашем файле, падает в соседнем». Сохраняйте итератор снаружи — и помните: если функция может расти, итератор может умереть. - Цикл с
push_backи заранее сохранённымbegin()— почти гарантированный UB: после первой же вставки, если буфер перевыделился, вашbegin()указывает в освобождённую память.
Лайфхак. Если нужен «вектор, в который можно безопасно добавлять, не думая об итераторах» — берите std::deque (вставки с конца инвалидируют итераторы, но не ссылки). Если нужны стабильные итераторы при любых вставках — std::list. Но помните про кеш: за стабильность итераторов вы платите скоростью на обходе.
Что это значит на практике. «Все итераторы и ссылки» для vector при росте — самая частая причина висячих ссылок. Висячая ссылка (dangling reference/pointer) — это ссылка или указатель, которые указывают туда, где объекта уже нет: память либо переиспользована, либо освобождена. Чтение или запись через такую ссылку — неопределённое поведение (UB): сегодня программа «работает», завтра падает, послезавтра выдаёт неверный ответ.
Аналогия из жизни. Представьте, что вы записали домашний адрес друга в блокнот, а друг переехал. Блокнот по-прежнему содержит старый адрес — заглянув туда, вы попадёте либо к чужим людям, либо в пустую квартиру. Именно так работают ссылки в C++: они не следят за жизнью объекта — за этим следите вы сами.
Правила гигиены: (1) не храните итераторы и ссылки на элементы vector, если он может вырасти; (2) в цикле удаления используйте возвращаемый erase, а не ++it; (3) если размер известен — вызовите reserve() заранее, тогда перевыделений не будет вовсе.
Пояснение к каждому пункту:
- (1) Не храните итераторы/ссылки на время роста. Если вам нужно обратиться к элементу после потенциальной модификации контейнера — пересчитайте индекс или получите ссылку заново:
int& r = v[i];. Это дёшево и безопасно. - (2)
eraseсам двигает итератор. Послеit = vec.erase(it)итератор указывает на следующий элемент (или наend()). Если делать++itвручную, вы пропустите элемент, идущий сразу после удалённого, — классическая ошибка. - (3)
reserve(n)говорит: «выдели место под n элементов сразу». Это устраняет перевыделения в будущем, пока вы не превыситеn. Указание точного размера превращаетpush_backиз потенциально дорогой операции в гарантированно дешёвую (амортизированная сложность остаётся O(1), но без скачков).
Ссылки на элементы vector инвалидируются при росте буфера — распространённый источник висячих ссылок:
std::vector<int> v;
v.reserve(1);
const int& r = v[0]; // ссылка на элемент
v.push_back(42); // перевыделение → r висит!
Почему так. Внутри vector — обычный динамический массив. Когда места не хватает, он выделяет новый буфер (обычно в 1.5–2 раза больше), копирует/перемещает элементы и освобождает старый. Все старые итераторы и ссылки указывают в освобождённую память — отсюда и «висячесть». Обойти это нельзя: перевыделение обязано перенести данные, и старые адреса теряют смысл.
Лямбда-выражения и захваты
Лямбда-выражение (lambda) — это способ определить маленькую функцию прямо в том месте, где она нужна: без отдельного объявления, без имени, с возможностью захватить переменные из окружения. Удобно для алгоритмов, колбэков и коротких предикатов. Компилятор «под капотом» превращает каждую лямбду в безымянный класс с оператором operator(), а список захвата (в квадратных скобках [ ]) задаёт поля этого класса — копии или ссылки на внешние переменные.
Аналогия из жизни. Лямбда — это курьер, которому вы в момент отправки передаёте «заметки» (захваты) о том, что ему понадобится во время работы. Захват по значению — выдать ему ксерокопию документа, по ссылке — разрешить подсматривать в оригинал. Если оригинал потом уничтожить — курьер окажется с пустыми руками.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
int threshold = 3;
std::vector<int> v{1, 5, 2, 8};
// [threshold] — захват по значению: копия переменной внутрь лямбды
auto by_value = [threshold](int x) { return x > threshold; };
// [&threshold] — захват по ссылке: лямбда видит изменения снаружи
auto by_ref = [&threshold](int x) { return x > threshold; };
// [=] и [&] — захватить всё по значению / по ссылке (осторожно!)
// [this] — захватить указатель на текущий объект (в методах класса)
// [x = threshold * 2] — инициализирующий захват (C++14): вычисляем при создании
int count = 0;
std::for_each(v.begin(), v.end(), [&count](int) { ++count; }); // счётчик снаружи
std::cout << count << ' ' << by_value(4) << by_ref(4) << '\n'; // 4 1 1
return 0;
}
Памятка по типам захватов:
[x]— копияxна момент создания лямбды. Измененияxснаружи на лямбду не влияют.[&x]— ссылка наx. Лямбда видит свежие значения и может менятьx(еслиxнеconst).[=]— захватить все использованные переменные по значению.[&]— захватить все использованные переменные по ссылке. Самый опасный режим.[this]— захватить указатель на текущий объект (только в методах класса). Помните:thisживёт, пока жив объект.[x = expr](C++14) — инициализирующий захват: внутри лямбды доступна переменнаяx, инициализированная выражениемexpr. Удобно для переименования и для перемещения «тяжёлых» объектов ([v = std::move(vec)]).[*this](C++17) — захват копии текущего объекта целиком. Полезно, когда лямбда переживёт исходный объект.
Практические советы — когда какой выбрать:
- Для коротких алгоритмов (
for_each,find_if,sort) на одну строку — обычно безопасно и удобно[&]или[=]. - Если лямбда уходит из текущей функции (возврат,
std::thread,std::async, сохранение вstd::function, отложенный колбэк) — только[=]или захват по значению конкретных переменных. - Не используйте
[&]вstd::thread, если передаёте туда же и саму лямбду, и внешние переменные — поток может начать работу, когда переменные уже умерли. - Захват по ссылке большого объекта (
std::vectorиз миллиона элементов) не копирует данные и не стоит ничего — но создаёт скрытую зависимость по времени жизни.
Частая ловушка — [&] и время жизни. Лямбда, захватившая ссылку, становится висячей, если исходная переменная умерла. Особенно опасно при возврате лямбды из функции или передаче в поток:
// auto bad() { int x = 5; return [&x](int y) { return x + y; }; } // ОШИБКА: x уже нет
auto good() { int x = 5; return [x](int y) { return x + y; }; } // OK: копия внутри
Почему bad — ошибка. Локальная переменная x живёт до закрывающей } функции. Возвращаемая лямбда попадает наружу — в место, где x уже нет. Ссылка внутри лямбды указывает в «освобождённую рамку стека». Обращение к ней — UB. В good лямбда владеет собственной копией x, поэтому спокойно живёт сколько угодно.
Как запомнить. [x] — «сфотографировать значение», [&x] — «смотреть вживую», [=] — «сфотографировать всё», [&] — «смотреть на всё». Для лямбд, которые переживут текущую функцию (асинхронные задачи, потоки, колбэки), берите только захват по значению.
Итераторы, алгоритмы, ranges
Итератор (iterator) — это объект, который «указывает на элемент контейнера» и умеет переходить от одного элемента к другому. По сути это обобщение указателя: для разных контейнеров итераторы поддерживают разный набор операций. Категория итератора — это именно тот набор операций, который гарантируется: чтение, запись, переход вперёд, переход назад, переход на произвольное смещение, проверка расстояния за O(1).
Аналогия из жизни. Представьте навигацию по городу. Пешеход (Input) идёт только вперёд и один раз. Велосипедист (Forward) — туда и обратно по тому же маршруту, много раз. Автомобилист (Bidirectional) — вдоль любой улицы туда и обратно. Вертолёт (Random access) — в любую точку карты напрямую. Чем «мощнее» транспорт, тем больше возможностей — и тем больше алгоритмов STL ему можно доверить.
| Категория | Возможности | Примеры |
|---|---|---|
| Input | Чтение, один проход | istream_iterator |
| Forward | Много проходов | forward_list |
| Bidirectional | Назад и вперёд | list, map |
| Random access | Арифметика, [], < |
vector, deque, array |
| Contiguous (C++20) | Непрерывная память | vector, array, string |
Что это значит на практике. Категория определяет, какие алгоритмы применимы. std::sort требует произвольного доступа (vector, deque), поэтому его нельзя вызвать для list — для списка есть отдельный list::sort(). std::advance/std::distance работают для любой категории, но для vector это O(1), а для list — O(n). Чем «богаче» категория, тем больше алгоритмов и тем быстрее отдельные операции.
#include <algorithm>
#include <numeric>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
std::cout << std::accumulate(v.begin(), v.end(), 0) << '\n'; // сумма: 21
std::cout << std::count_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }) << '\n'; // 3
std::vector<int> out(v.size());
std::transform(v.begin(), v.end(), out.begin(),
[](int x) { return x * x; }); // out = 1 4 9 16 25 36
std::vector<int> dst;
std::copy_if(v.begin(), v.end(), std::back_inserter(dst),
[](int x) { return x > 3; }); // передать размер заранее
// C++20 ranges: ленивые представления (views), конвейер через |
auto even_squares = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
for (int x : even_squares) std::cout << x << ' '; // 4 16 36
return 0;
}
Шпаргалка по частым алгоритмам <algorithm> и <numeric>.
| Алгоритм | Что делает | Замечание |
|---|---|---|
sort(b, e) |
Сортировка | Требует random access; O(n log n) |
find(b, e, v) |
Первое вхождение значения | Возвращает итератор или end() |
count / count_if |
Подсчёт элементов / по предикату | — |
any_of / all_of / none_of |
Проверка условия для диапазона | Читается лучше ручного цикла |
transform |
Преобразование элементов | Результат — в выходной диапазон |
copy / copy_if |
Копирование (с фильтром) | Для вставки — back_inserter |
accumulate |
Свёртка (сумма и т. п.) | <numeric>; задайте начальное значение |
min_element / max_element |
Наименьший/наибольший | Возвращают итератор |
lower_bound / upper_bound |
Границы в отсортированном | O(log n), диапазон должен быть отсортирован |
remove_if + erase |
Удаление по условию | Идиома erase-remove; C++20: std::erase_if |
Как читать имена. _if — версия с предикатом (find_if), _n — на n элементов (copy_n), _back — вставка в конец (back_inserter), stable_ — сохраняет порядок равных (stable_sort). Зная эти суффиксы, вы угадаете назначение незнакомого алгоритма.
Типичные ошибки новичков при работе с алгоритмами:
- Вызвать
sort(list.begin(), list.end())— уlistнет произвольного доступа, компилятор выдаст длинную диагностику про категории итераторов. Для списка есть собственный методlist.sort(). - Забыть, что
find/countвозвращают «ничего не нашли» какend(), и сравнивать итератор с числом илиnullptr— так делать нельзя, нужно сравнивать сend(). - Передать в
lower_bound/upper_boundнеотсортированный диапазон — алгоритм не сортирует, он только ищет. Результат — произвольный мусор без ошибок компиляции. - Писать
min_element(...) - 1— арифметика итераторов работает только для random access. Дляlistтак нельзя, используйтеstd::prev/std::next. - Принять, что
transformсам увеличивает контейнер-приёмник — нет, он пишет ровно столько, сколько прочитал. Нужно либо заранее знать размер (dst.resize(n)), либо использоватьback_inserter.
Про ranges (C++20) подробнее. Конвейер | из views работает «лениво»: элементы не копируются и не вычисляются, пока вы не начнёте их читать (например, в for или в материализующем алгоритме). Это дёшево по памяти и удобно для составления сложных фильтров, но требует аккуратности со временем жизни исходного контейнера.
Подводный камень — висячие views. View не владеет данными: если исходный контейнер уничтожен или изменён, диапазон становится висячим.
std::ranges::to (C++23) — способ материализовать ленивую цепочку views в обычный контейнер (например, std::vector). Без него views::filter возвращает лишь лёгкий «взгляд» на исходные данные, который живёт ровно столько, сколько данные, на которые он смотрит.
Аналогия из жизни. views::filter — это как прозрачная плёнка-трафарет, которую вы положили поверх фотографий: вы видите только подходящие кадры, но если фотографии выбросить, трафарет станет бесполезным. А ranges::to — это проявка: вы закрепляете результат на новой плёнке, и теперь она живёт независимо.
auto bad() {
std::vector<int> v{1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; }); // ОШИБКА: v умирает
}
auto good() {
std::vector<int> v{1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; }) | std::ranges::to<std::vector>();
}
Почему bad() — ошибка. Функция возвращает объект-view, который внутри хранит итератор/указатель на элементы v. Когда v выходит из области видимости в конце bad(), вектор уничтожает своё хранилище. Возвращённый view теперь указывает «в никуда» — это классический dangling (висячая ссылка). Любое обращение к результату — UB (undefined behavior, неопределённое поведение).
Почему good() — корректно. std::ranges::to<std::vector>() пробегает по всем элементам ленивой цепочки и копирует их в новый std::vector, который уже владеет своими данными. Теперь возвращённый вектор полностью независим от v.
Практическая подсказка. Возвращать
viewиз функции безопасно только в двух случаях: (1) источник — это параметр по ссылке/контейнер, переданный извне и живущий дольше результата; (2) источник — статическая/глобальная память. Во всех остальных ситуациях — материализуйте черезranges::to(C++23) или вручную через цикл.
Типичная ошибка.
auto result = v | views::filter(...)внутри функции, гдеvживёт до конца, — это нормально. Но как только вы возвращаете такойviewнаружу — данные почти наверняка «умирают» по дороге. Компилятор, к сожалению, это не ловит: проверяйте время жизни вручную.
std::span (C++20) — невладеющее представление непрерывной последовательности; заменяет пару «указатель + размер»:
Простое определение. std::span<T> — это маленькая структура из двух полей: T* data (указатель на первый элемент) и size_t size (число элементов). Сам span ничего не выделяет в куче и не освобождает — он лишь «смотрит» на уже существующий массив, как подзорная труба на корабли.
#include <span>
#include <vector>
void process(std::span<const int> data) { // работает и с vector, и с массивом, и с частью
for (int x : data) { /* ... */ }
}
int main() {
std::vector<int> v{1, 2, 3};
process(v);
int arr[4]{};
process(arr);
process(std::span{v}.subspan(1, 2)); // поддиапазон
return 0;
}
Почему process принимает span, а не vector. Это убирает ненужное копирование: vector пришлось бы копировать целиком (либо передавать по ссылке, теряя универсальность). span оборачивает указатель и размер, его передача стоит ~16 байт (на 64-бит) — дёшево и работает с любым непрерывным хранилищем: std::vector, std::array, обычный C-массив, std::string и даже часть любого из них.
Почему именно std::span<const int>. Слово const сообщает компилятору и читателю кода: «я не собираюсь менять элементы». Это дополнительный контракт и защита от случайной модификации. Если функция должна писать — берите std::span<int>.
Что делает subspan(1, 2). Метод subspan(offset, count) создаёт новый span, который «смотрит» на поддиапазон исходного, начиная со смещения offset длиной count элементов. Никакого копирования данных — только пересчёт указателя и размера. Удобно для парсинга: «дай мне элементы со 2-го по 3-й».
Когда использовать span. Если функция должна принять «непрерывную последовательность» и не копировать её — std::span заменяет пару (T*, size_t) и делает интерфейс самодокументируемым. Но span не владеет данными: если исходный контейнер уничтожен, span станет висячим. Не возвращайте span на локальный контейнер и не храните его дольше, чем живёт источник.
Типичные ошибки с
span.
- Возврат
spanна локальный объект — то же dangling, что и в примере сbad()выше: контейнер умирает, span остаётся без данных.- Хранение
spanв структуре как «удобную замену вектору» — опасно: структура переживёт источник. Лучше хранить по value контейнер, который владеет данными.- Передача
spanвstd::threadбез синхронизации — span не защищает данные от гонок, это по-прежнему сырая память с указателем.- Забытый
const—std::span<int>позволит вызывающему менять ваши «чужие» данные, даже если он передалconst vector.
Практическая подсказка. В API библиотек
span— отличный тип параметра. В API с долгим временем жизни (поля классов, возвращаемые значения, данные потоков) — почти всегда лучше взять владеющий контейнер (std::vector,std::arrayфиксированного размера).
Как запомнить. string_view — невладеющий «взгляд» на строку; span — невладеющий «взгляд» на массив. Оба дешёвы в передаче (указатель + размер) и оба опасны по времени жизни. Правило: view можно передавать вниз (как параметр), но нельзя возвращать вверх или хранить долго.
Расширение правила. Это правило применимо ко всем non-owning типам в C++:
std::string_view— взгляд на символы строки;std::span— взгляд на элементы массива;std::ranges::filter_view,std::ranges::transform_viewи др. — взгляды на элементы контейнера;- итераторы и ссылки в общем случае.
Все они — «лёгкие обёртки над чужими данными». Живут, пока жив источник. Передавайте их по значению (это дёшево), но никогда не отдавайте «наружу» функции, если источник может исчезнуть.
Строки, IO и файловая система
Что вообще такое «строка в C++». Это последовательность символов, с которой программа работает как с единым целым: может узнать длину, склеить с другой, найти подстроку. В C исторически использовали массивы char, оканчивающиеся нулём ('\0'), — это неудобно и опасно (легко забыть про ноль, легко выйти за границы). В C++ есть безопасные обёртки: std::string владеет своими символами и сам следит за длиной и памятью.
std::string владеет буфером и умеет SSO (Small String Optimization — оптимизация хранения коротких строк) — короткие строки (обычно до 15–22 символов, точное число зависит от реализации стандартной библиотеки: libstdc++, libc++ и MSVC STL имеют разные пороги) хранятся внутри объекта, без выделения кучи. std::string_view — невладеющий «взгляд» на строку: указатель + длина, без копирования.
Зачем SSO и string_view. Выделение памяти в куче — дорогая операция (нужно вызвать аллокатор, синхронизироваться, записать метаданные), поэтому короткие строки (имена, теги, ключи) хранят прямо в объекте std::string: SSO экономит и время, и фрагментацию (когда память разбита на множество мелких свободных кусков). string_view решает другую задачу: когда функция только читает строку, копировать её или даже выделять std::string незачем — достаточно указателя и длины. Аналогия: std::string — собственный дом, std::string_view — адрес чужого дома, который вы просто знаете и не строите. Пока чужой дом стоит, адрес полезен; снесли — адрес ведёт в пустоту.
#include <iostream>
#include <string>
#include <string_view>
std::string_view first_word(std::string_view text) { // не копирует строку
auto pos = text.find(' ');
return text.substr(0, pos == std::string_view::npos ? text.size() : pos);
}
int main() {
std::string s = "Hello, C++ world";
std::cout << s.size() << ' ' << s.substr(7, 3) << '\n'; // 16 C++
s += "!";
s.replace(0, 5, "Привет");
if (s.starts_with("Привет")) { /* C++20 */ }
std::cout << first_word(s) << '\n';
return 0;
}
Как выбрать тип для текста.
| Тип | Владеет памятью | Изменяемый | Когда брать |
|---|---|---|---|
const char* |
Нет | Нет | C-API, литералы, старый код |
std::string |
Да | Да | Хранение и изменение текста |
std::string_view |
Нет | Только снаружи | Параметры «только чтение», парсинг без копий |
char[N] (массив) |
Да (на стеке) | Да | Фиксированные буферы, C-API |
Как запомнить. const char* — «адрес в чужом тексте», string — «свой текст», string_view — «окно в чужой текст». Окно дешёвое, но закрывается, когда владелец уходит.
Подводный камень — время жизни string_view. View не владеет памятью: он валиден ровно пока жив исходный буфер. Возврат view на локальную строку или на временный std::string — висячий указатель (dangling pointer — указатель, ссылающийся на уже освобождённый или уничтоженный объект; любое чтение через него — неопределённое поведение, то есть UB, undefined behavior: стандарт C++ не определяет, что произойдёт, и программа может делать что угодно, включая вывод неверных данных или аварийное завершение).
std::string_view bad1() {
std::string local = "temp";
return local; // ОШИБКА: буфер уничтожен
}
std::string_view bad2() {
std::string s = "abc";
return std::string_view{s}; // ОШИБКА
}
// ОПАСНО: временная строка живёт до конца выражения
// void take(std::string_view sv);
// take(std::string("abc") + "def"); // view висит после выхода из выражения (до C++23)
Почему string_view так опасен. Компилятор не предупреждает: view — это просто пара «указатель + длина», она копируется мгновенно и не знает, жив ли буфер. bad1 возвращает view на локальную строку, которая разрушается при выходе из функции; bad2 — на временный объект, живущий до конца выражения. В обоих случаях получается висячий указатель, и чтение по нему — UB. В C++23 добавили правило «продления жизни» (так называемое P2434 — устранение некоторых висячих string_view за счёт временного продления жизни владеющей строки) для временных строк в некоторых контекстах, но полагаться на это нельзя.
Когда использовать, а когда нет. string_view идеален для параметров «только для чтения» (он принимает и std::string, и литерал, и C-строку без копий). Не используйте его для возврата, для полей класса и для всего, что переживёт исходную строку. Если сомневаетесь — берите const std::string& (безопаснее, но не принимает C-строку без создания временного объекта).
Что такое «потоки ввода-вывода». Это абстракция над источниками и приёмниками байтов: консоль, файл, сетевое соединение, строка в памяти. std::cout — поток вывода на консоль, std::cin — поток ввода с консоли, std::ofstream — запись в файл, std::ifstream — чтение из файла, std::stringstream — работа со строкой как с потоком. Оператор << означает «отправь данные в поток», >> — «прочитай данные из потока с учётом типа». Главная идея — один и тот же синтаксис работает для экрана, файла и строки.
Потоки — базовый ввод-вывод. Форматирование состояния (std::format, C++20) безопаснее printf и быстрее stringstream:
#include <format>
#include <iostream>
#include <string>
int main() {
int n = 42;
double pi = 3.14159;
std::cout << "n=" << n << " pi=" << pi << '\n'; // классические потоки
std::string s = std::format("n={:>5} pi={:.3f}", n, pi); // C++20: type-safe
std::cout << s << '\n'; // n= 42 pi=3.142
// std::print (C++23) — форматированный вывод одним вызовом
// std::print("n={}\n", n);
return 0;
}
Почему std::format лучше. printf не проверяет типы: передали %s вместо %d — получили UB. std::format проверяет соответствие типа и спецификатора при компиляции (на этапе сборки, а не во время выполнения), читается компактнее и работает с пользовательскими типами через formatter (это специальная структура, описывающая, как превратить ваш тип в текст). Производительность у него обычно не хуже, а иногда лучше stringstream.
Полезные спецификаторы std::format. {} — значение по умолчанию, {:>5} — выровнять вправо по ширине 5, {:<5} — влево, {:^5} — по центру, {:.3f} — три знака после точки, {:05d} — дополнить нулями до 5 цифр, {:x} — шестнадцатеричная запись, {:#x} — то же с префиксом 0x, {}, — с разделителем тысяч (зависит от локали — региональных настроек форматирования чисел, дат и валют).
Частая ловушка — std::endl против '\n'. std::endl не просто переводит строку, а ещё и сбрасывает буфер (flush — принудительно отправляет все накопленные в памяти данные в реальное устройство вывода: терминал, файл, сеть). В цикле из миллиона итераций это превращает быстрый вывод в медленный, потому что каждая строка тут же уходит в ОС. Пишите '\n', а std::flush/std::endl оставьте для мест, где вывод действительно должен появиться немедленно (логи перед падением, интерактивные подсказки).
Почему вообще нужен буфер. Обращение к ОС и к диску — дорогое (миллионы тактов процессора против единиц для операций с памятью). Буфер собирает мелкие записи в одну крупную и отдаёт её одним системным вызовом. Поэтому отключать буферизацию почти всегда — плохая идея.
Типичные ошибки при работе с потоками.
- Забыли открыть файл и сразу читаете —
ifstreamв этом случае просто молча переходит в состояние «fail», и все операции чтения возвращают «ничего», без исключения. Проверяйтеif (file)илиif (file.is_open()). - Смешали
>>иgetline: оператор>>оставляет в буфере символ\n, и следующийgetlineсразу читает пустую строку. Решение —std::cin.ignore()после>>. - Использовали
>>для чтения строки с пробелами — оператор>>читает только до первого пробела. Для строк с пробелами нуженstd::getline.
std::filesystem (C++17) даёт переносимую работу с путями и файлами:
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
fs::path p = fs::current_path() / "data" / "file.txt"; // склейка путей через /
if (fs::exists(p)) {
std::cout << "размер: " << fs::file_size(p) << " байт\n";
std::cout << "имя: " << p.filename() << ", расширение: " << p.extension() << '\n';
}
fs::create_directories("out/logs"); // создать каталоги рекурсивно
for (const auto& entry : fs::directory_iterator(".")) { // обход каталога
std::cout << entry.path() << '\n';
}
std::error_code ec; // версия без исключений
fs::remove(p, ec);
if (ec) std::cerr << "ошибка: " << ec.message() << '\n';
return 0;
}
Зачем нужен std::filesystem. До C++17 для работы с файлами приходилось использовать API конкретной ОС: stat, opendir, CreateFile, _wfindfirst — каждое со своими типами, кодировками и соглашениями о путях. std::filesystem прячет все эти различия за единым интерфейсом: один и тот же код собирается и работает на Linux, Windows и macOS.
Полезные приёмы std::filesystem.
fs::pathсам подставляет правильный разделитель (/на Linux/macOS,\на Windows) — используйте оператор/для склейки, а не конкатенацию строк.fs::absolute(p)— превратить относительный путь (относительно текущего каталога программы) в абсолютный (полный, от корня файловой системы).fs::weakly_canonical(p)— «нормализовать» путь: убрать.,.., лишние разделители. Полезно перед сравнением путей.fs::recursive_directory_iterator(root)— обход дерева каталогов в глубину. Осторожно с символическими ссылками: можно попасть в цикл.- Две перегрузки почти у всех функций: бросающая
filesystem_errorи принимающаяstd::error_code. Если не хотите ловить исключения — используйте вторую.
Типичные ошибки при работе с путями.
- Склейка строк вместо оператора
/:"data" + "/" + "file.txt"ломается на Windows, где разделитель\\, и не учитывает случаи вроде уже имеющегося слеша. - Игнорирование кодировки имени файла: на Windows имена хранятся в UTF-16, на Linux — в байтах UTF-8. Если вы передаёте в
fs::pathобычныйstd::string, на Windows возможны сюрпризы с не-ASCII именами. Решение —std::u8string(C++20) или широковещательныеwchar_t-варианты. - Чтение
directory_entryпосле удаления файла: итератор хранит кэшированные метаданные (размер, тип). Если файл удалили междуdirectory_iteratorи обращением к полям —fs::file_sizeбросит исключение. Проверяйтеexists()перед доступом. Когда использоватьfilesystem.
std::filesystem — это часть стандартной библиотеки C++17, предоставляющая единый переносимый интерфейс для работы с файлами, каталогами и путями. По сути, это «швейцарский нож» файловой системы: вместо того чтобы вручную вызывать разные системные API, вы работаете с одним набором функций, который сам адаптируется под Windows, Linux и macOS.
Аналогия из жизни. Представьте универсальный пульт дистанционного управления: вместо того чтобы держать на столе отдельный пульт от телевизора, приставки и кондиционера, у вас один пульт, который понимает все команды. Так и std::filesystem — один API вместо набора разных системных вызовов.
Забудьте про fopen/stat в новом коде: std::filesystem переносим между ОС, умеет склеивать пути через / (на Windows сам подставит \), обходить каталоги, узнавать размер и тип файла, создавать директории рекурсивно. У каждой операции есть две формы: бросающая исключение и принимающая std::error_code — вторую берут в тех местах, где ошибка ожидаема и не должна прерывать программу.
Почему именно filesystem, а не старые функции?
fopen/stat— это унаследованные функции из C, которые по-разному ведут себя на разных платформах (например,statна Windows и Linux имеют разный набор полей).std::filesystemиспользуетstd::filesystem::path, который хранит путь в едином внутреннем представлении и сам разбирается с разделителями (/или\).- Библиотека корректно обрабатывает Unicode-пути и длинные имена файлов, что на «голых» C-функциях часто вызывает проблемы.
Две формы функций — практические подсказки:
- Бросающая форма (например,
std::filesystem::copy(...)) удобна в «счастливом пути» программы, когда ошибка — это исключительная ситуация и вы хотите, чтобы она всплыла наверх черезtry/catch. - Форма с
std::error_code(например,std::filesystem::copy(..., ec)) идеально подходит для утилит командной строки, скриптовых сценариев и пакетной обработки файлов, где одна ошибка не должна останавливать обработку остальных файлов.
Типичные ошибки новичков:
- Забывают подключить
#include <filesystem>(а для старых версий GCC — линковать с-lstdc++fs). - Используют
std::stringдля путей вместоstd::filesystem::path— теряют переносимость и удобные методы вродеparent_path()илиextension(). - Склеивают пути через конкатенацию строк:
path = dir + "/" + filename— лучше использовать оператор/:path = dir / filename, тогда библиотека сама вставит нужный разделитель. - Игнорируют переданный
std::error_code, не проверяя его значение после вызова — это приводит к «тихим» сбоям, когда файл не скопировался, а программа думает, что всё в порядке. - Вызывают
file_size()илиlast_write_time()на несуществующем файле, забыв предварительно проверить существование черезexists()— получают исключениеstd::filesystem::filesystem_errorв неожиданном месте.
Краткая шпаргалка по частым операциям:
| Задача | Функция std::filesystem |
|---|---|
| Проверить существование | exists(path) |
| Узнать размер файла | file_size(path) |
| Создать одну директорию | create_directory(path) |
| Создать директорию с родителями | create_directories(path) |
| Удалить файл или пустую папку | remove(path) |
| Рекурсивно удалить папку | remove_all(path) |
| Переименовать / переместить | rename(from, to) |
| Обойти каталог | directory_iterator(dir) / recursive_directory_iterator(dir) |
Утилиты: tuple, optional, variant, chrono, random
Простыми словами. Это готовые «коробочки» из стандартной библиотеки, которые заменяют опасные приёмы из C: голые указатели, магические
-1иnullptr, «сырые»union,rand()иtime(NULL). Каждая утилита кодирует намерение прямо в типе — компилятор помогает вам не ошибаться.
Зачем это нужно. Тип — это документация, которую проверяет компилятор. Представьте, что вы передаёте коллеге коробку: если на ней написано «внутри может быть пусто» (std::optional<T>), он не удивится отсутствию содержимого. А если коробка молчит, а внутри nullptr — это сюрприз, который ловится только в рантайме, и обычно в продакшене у пользователя. То же самое с std::variant<A, B>: «ровно один из типов», и switch-аналог (std::visit) заставляет обработать все варианты — забыть ветку нельзя, компилятор напомнит. Такие типы убирают целый класс багов: невозможно «забыть проверить на null», перепутать активное поле union, или вернуть код ошибки, который вызывающий проигнорирует.
#include <optional>
#include <string>
#include <tuple>
#include <variant>
int main() {
std::pair<int, std::string> p{1, "one"};
std::tuple<int, double, std::string> t{1, 2.5, "three"};
std::cout << std::get<0>(t) << std::get<double>(t) << '\n'; // доступ по позиции/типу
std::optional<int> maybe; // отсутствие значения без «-1» и nullptr
if (!maybe) maybe = 42;
std::cout << maybe.value_or(0) << '\n';
std::variant<int, std::string> v = "text"; // типобезопасный union
if (std::holds_alternative<std::string>(v)) {
std::cout << std::get<std::string>(v) << '\n';
}
v = 7; // меняем активный тип
return 0;
}
Подсказка по примеру.
std::pairиstd::tuple— «кортежи фиксированного размера».pairдля двух полей,tupleдля любого количества. Доступ: по индексу (std::get<0>(t)) или по типу (std::get<double>(t)) — последнее удобно, когда индекс неочевиден или типы уникальны.std::optional<int> maybe;создаёт пустой optional — никакого UB, никакого «неинициализированного значения».std::holds_alternative<T>(v)— безопасный способ спросить «а что сейчас лежит?». Альтернатива —std::get<T>(v), который бросает исключениеstd::bad_variant_access, если активный тип не совпадает.- Присваивание
v = 7;переключает активный тип — это и есть магияvariantповерх «сырого»union.
| Тип | Смысл | Аналог из мира C |
|---|---|---|
std::optional<T> |
Значение может отсутствовать | T* + nullptr, «sentinel» |
std::variant<...> |
Ровно один из типов | union + тег |
std::any |
Любой тип с проверкой в runtime | void* |
std::expected<T, E> (C++23) |
Значение или ошибка | (T, errno) |
std::span<T> |
Невладеющий диапазон | T* + size_t |
std::tuple |
Набор разнотипных значений | struct без имён |
Что это значит на практике. «Аналог из мира C» показывает, чем заменить опасный приём из старого C-стиля:
- Вместо
T*сnullptr(«нет значения») —optional<T>: компилятор видит, что значение опционально, и просит обработать случай отсутствия (черезhas_value()/value_or()/*opt). - Вместо
unionс ручным тегом —variant: типобезопасный «один-из»; нельзя прочитать не тот тип, нельзя забыть выставить тег, невозможна неинициализированная «дыра» в памяти. - Вместо
void*—any(ноanyотказывается от статической типизации, поэтому применяйте редко: обычно лучше сразу выбратьvariantили шаблон). - Вместо
(T result, int error_code)—expected<T, E>(C++23): ошибка — полноправная часть возвращаемого типа, её нельзя проигнорировать так легко, какerrno. - Вместо
T* arr, size_t n—span<T>: пара «указатель + длина» в одном объекте, который не владеет памятью (не освобождает), но знает границы — и компилятор может ловить выход за них в debug-сборке. - Вместо безымянного
struct { int a; double b; }—tuple: удобно для локальных группировок, но для «публичных» данных лучше дать полям имена черезstruct(см. «Поле с именем лучше, чем поле с номером» ниже).
std::expected<T, E> (C++23) — современная альтернатива кодам ошибок и исключениям одновременно: функция возвращает либо результат, либо причину, и компилятор требует обработать оба случая. Это особенно ценно в коде, где исключения запрещены (embedded, low-latency trading, части game-dev).
Поле с именем лучше, чем поле с номером.
std::get<2>(t)читается хуже, чемresult.status. Поэтомуtupleхорош для внутренних, короткоживущих группировок, а «публичный» API лучше строить на именованныхstruct. То же касаетсяstd::pair— для долгоживущих данных используйтеstruct { T first; U second; }с понятными именами полей.
Как запомнить. Придумайте «человеческие» ассоциации — они работают лучше любых определений:
optional— «может быть, есть» → пустая коробка из-под пиццы: либо пустая (нет пиццы), либо с пиццей.variant— «ровно одно из» → переключатель на три положения, и одно из них всегда активно.any— «что угодно, проверю в рантайме» → чёрный ящик: засунул что-то, потом достал и проверил тип.expected— «успех или ошибка» → конверт с деньгами: либо купюры, либо записка «причина отказа».span— «чужой массив» → закладка в чужой книге: знаем, где читать, но книга чужая, и перелистывать её мы не будем.tuple— «struct на скорую руку» → пакет с продуктами без подписей: знаем, что внутри молоко и хлеб, но без этикеток.
Пример std::expected (C++23) — современный способ вернуть ошибку без исключений:
#include <expected>
#include <string>
std::expected<double, std::string> parse_double(const std::string& s) {
try {
return std::stod(s); // успех: возвращаем значение
} catch (...) {
return std::unexpected("не число"); // ошибка: возвращаем причину
}
}
int main() {
auto r = parse_double("3.14");
if (r) return static_cast<int>(*r * 100); // проверка на успех
// иначе r.error() содержит текст ошибки
}
Разбор по шагам.
- Сигнатура
std::expected<double, std::string>сразу читается как «либоdouble, либо строка с ошибкой». Тип ошибки может быть любым:enum class, свойstruct,std::error_code.- Внутри функции два пути возврата: либо готовое значение (
return std::stod(s)— неявно конвертируется вexpected), либоstd::unexpected("не число")— это специальная обёртка для «ошибочной» альтернативы.- В вызывающем коде
if (r)проверяет, есть ли значение (как уoptional). Если есть — разыменуем через*rилиr.value(). Если нет — забираем причину черезr.error().- В отличие от исключения, вызов
parse_double("abc")не пробрасывает ошибку через стек — мы её аккуратно получаем в той же функции, где вызвали.
Почему это удобно. expected делает ошибку частью типа функции, но не заставляет писать try/catch — вызывающий либо проверяет результат, либо игнорирует, и компилятор не даёт молча забыть про ветку ошибки (в отличие от кодов возврата). Сравните три подхода:
| Подход | Плюсы | Минусы |
|---|---|---|
Код возврата (int, errno) |
Дешёво, предсказуемо по скорости | Легко забыть проверить; нет привязки к смыслу ошибки; «магические» числа |
Исключения (throw/try) |
Нельзя забыть «пробросить»; читаемый happy-path | Дорого в hot-path; плохо предсказуемо по скорости; не работает в noexcept-функциях и в ряде встраиваемых окружений |
expected<T, E> |
Ошибка — в типе; дешевле исключений; явная обработка | Многословнее, чем throw; вызывающий может проигнорировать (но это видно при чтении) |
Типичная ошибка. Писать
return r.value();без проверкиif (r)— тогда при ошибке летит исключениеstd::bad_expected_access<E>. Это не «ошибка проигнорирована молча», но и не идиоматическийexpected-код. Привыкайте проверять черезif (r), аr.value()используйте только когда вы уже убедились, чтоr.has_value()истинно.Ещё одна ошибка. Класть в
E«магические»int-коды в стилеerrno— вы теряете весь смысл строгой типизации. ДелайтеEenum classилиstructс понятным именем поля причины.
std::chrono — типизированное время, std::random — качественный генератор (никогда не используйте rand() для симуляций, статистики и игр):
#include <chrono>
#include <iostream>
#include <random>
int main() {
using namespace std::chrono;
auto start = steady_clock::now(); // монотонные часы для измерений
// ... измеряемая работа ...
auto elapsed = duration_cast<milliseconds>(steady_clock::now() - start);
std::cout << "прошло: " << elapsed.count() << " мин\n";
std::mt19937 gen{std::random_device{}()}; // качественный ГПСЧ
std::uniform_int_distribution<int> dice(1, 6);
std::cout << "бросок: " << dice(gen) << '\n';
return 0;
}
Подсказка по примеру.
using namespace std::chrono;— стандартный приём, чтобы не писать длинные префиксы (ms,seconds,steady_clock). В заголовочных файлах этотusingлучше не писать, чтобы не засорять глобальное пространство имён.duration_cast<milliseconds>(...)переводит «наносекундные» тики в удобные для печати миллисекунды. Без приведения вы получите длинную цифру в тех единицах, которые выберет реализация (обычно наносекунды).std::mt19937принимает 32-битное зерно при создании.std::random_device{}()— это «честный» источник энтропии из ОС (на Linux —/dev/urandom). Не пишитеstd::mt19937 gen{42};для продакшена — детерминированное зерно даёт детерминированную последовательность, что для игр иногда полезно (воспроизводимость багов), а для безопасности — катастрофа.dice(gen)— это распределение, а не функция-генератор. Каждый вызов возвращает следующее число согласно равномерному распределению от 1 до 6. Распределение «запоминает» параметры, генератор — состояние; это правильное разделение ответственности.
Почему не rand(). rand() — наследие 1980-х и для серьёзных задач не годится по нескольким причинам сразу:
- Крошечный диапазон. На многих платформах
RAND_MAXравен всего 32767 (2¹⁵−1). Если вы делаете симуляцию, где нужны миллионы уникальных значений — у вас их физически нет: цикл повторится очень быстро. - Плохое распределение. График
rand()на простом тесте (взять младшие биты, разложить по гистограмме) покажет видимую неравномерность — это знаменитый «провал» в середине цикла у линейного конгруэнтного генератора, который стоит в libc на части платформ. - Глобальное состояние.
rand()хранит одно число на всю программу. Если две части кода вызываютrand()параллельно или вложенно — они мешают друг другу и портят воспроизводимость. В многопоточном коде это ещё и data race. - Смещение от
%. Записьrand() % nне даёт равномерного распределения по[0, n), еслиRAND_MAX + 1не делится нацело наn. Например, приRAND_MAX = 32767иn = 6числа 0–5 будут выпадать чуть чаще, чем 2730+5. Это известная ошибка «modulo bias».
std::mt19937 (вихрь Мерсенна) — полноценный генератор с периодом 2¹⁹⁹³⁷ (число с 6001 цифрой в десятичной записи — больше, чем атомов в наблюдаемой Вселенной в виде оценок); random_device даёт начальное зерно из энтропии системы. Распределения (uniform_int_distribution, normal_distribution) гарантируют правильную математику, чего rand() % n не делает (оно смещает значения к меньшим). Если нужна скорость и хорошее качество — берут std::mt19937_64 или сторонние библиотеки (Xoroshiro, PCG); для криптографии — отдельный класс библиотек (OpenSSL, libsodium, NaCl), обычные PRNG для этого не годятся: их выход предсказуем по нескольким наблюдениям.
Почему steady_clock. Часы бывают трёх видов, и путать их — частая ошибка новичков:
system_clock— «настенное время» (текущие дата/время). Может прыгнуть назад при переводе часов, синхронизации NTP, ручной правке системного времени пользователем, в виртуалке при миграции на другой хост. Для измерения интервалов он непригоден — вы получите отрицательное «прошло» или огромный скачок.steady_clock— монотонные часы, гарантированно идут только вперёд. Идеальны для бенчмарков, таймаутов и замера длительности операций.high_resolution_clock— самые точные часы, которые найдёт реализация; на некоторых платформах это алиас дляsteady_clock, на других — дляsystem_clock, поэтому полагаться на его «монотонность» не стоит.
Практическое правило.
- Измеряете интервал (
auto t0 = ...; auto t1 = ...;и считаете разницу) →steady_clock::now().- Нужно текущее время в формате «календарь» (
YYYY-MM-DD HH:MM:SS) →system_clock::to_time_t(...).- Нужен таймер с минимальным шагом (профилирование горячих циклов) →
high_resolution_clock(но с оговорками про монотонность).
Есть и подводный камень: duration_cast округляет вниз (к меньшему целому). Если вы измеряете что-то короче, чем единица измерения, результат будет равен нулю. Для наносекундных измерений берите nanoseconds, для миллисекунд — milliseconds. И ещё: count() отдаёт «сырое» число, без указания единиц — поэтому в выводе подписывайте единицы явно ("мс", "нс").
На собеседовании спросят. «Почему rand() плох?» и «Чем system_clock отличается от steady_clock?» — оба вопроса про то, что стандартные утилиты C++ дают корректность и предсказуемость там, где C-функции дают сюрпризы. Готовые тезисы для ответа:
- Про
rand(): короткий период (часто 2¹⁵ или 2³¹), плохое распределение младших бит, глобальное состояние (ломает воспроизводимость в многопоточном коде). Правильный путь —std::mt19937(илиmt19937_64) +std::uniform_int_distribution<T>(a, b), зерно брать изstd::random_device. - Про часы:
system_clockотражает «настенное» время и может идти назад (NTP, перевод часов, ручная правка),steady_clockгарантированно монотонен — для бенчмарков и таймаутов берите его.high_resolution_clock— это «самые точные часы», что найдёт реализация; на практике чаще всего совпадает сsteady_clock, но стандарт этого не гарантирует.
Бонус-вопрос, который любят задавать. «А почему не
rand() % n?» — потому что приRAND_MAX + 1не кратномnвозникает смещение (modulo bias): меньшие значения выпадают чаще. Решение —std::uniform_int_distribution<int>(0, n-1)(gen), который внутри делает отбрасывание «лишних» значений и перегенерацию, обеспечивая математически равномерное распределение.
Многопоточность
Что такое поток простым языком. Поток (thread) — независимая линия исполнения внутри одного процесса. У каждого потока свой стек вызовов, набор регистров CPU и указатель инструкций, но все потоки одного процесса делят общую память: кучу, глобальные переменные, статические объекты, открытые файлы. Аналогия: процесс — это офис, потоки — сотрудники; у каждого свой рабочий стол (стек), но шкаф с документами (общая память) один на всех. Именно общая память — главный источник проблем в многопоточном коде.
Что такое модель памяти C++11. Это формальный набор правил стандарта, который описывает, какие гарантии видимости изменений есть у потоков и что именно считается data race (гонкой данных). До C++11 стандарт вообще не описывал многопоточность, и компиляторы могли переставлять обращения к памяти ради оптимизаций — поэтому «работает у меня» могло ломаться у соседа. C++11 зафиксировал правила: что обязано быть видно другим потокам, что может быть переставлено, и какие операции дают синхронизацию.
Поток — независимая линия исполнения внутри процесса. Модель памяти C++11 определяет, что считается data race: одновременный доступ к одной ячейке, где хотя бы одна операция — запись, без синхронизации, — это UB.
Почему это отдельная тема. Все потоки процесса видят одну и ту же память, и планировщик ОС переключает их в непредсказуемые моменты. Без правил две линии исполнения могли бы одновременно писать в одну переменную — получился бы «мусор» из перемешанных байтов. Поэтому язык вводит формальную модель памяти и понятие гонки данных (data race): если она есть, поведение программы не определено, даже если «на моей машине работает». Аналогия: два человека одновременно правят один и тот же документ — результат зависит от того, кто успел последним. Чтобы этого не было, нужны «правила доступа» — мьютексы и атомики.
Главное правило. Одновременное чтение одной ячейки безопасно; одновременное чтение и запись или две записи без синхронизации — UB. Синхронизацией служат мьютексы, атомарные операции и condition_variable.
Практический минимум. Чтобы не было гонки данных, у вас есть ровно три инструмента:
- Мьютексы (
std::mutex,std::shared_mutex) — критическая секция, внутри только один поток. - Атомарные операции (
std::atomic<T>) — операции, неделимые на уровне CPU (например, инкремент без блокировки). - Condition variable — ожидание события с привязкой к мьютексу. Атомарные операции дают синхронизацию сами по себе (по сути, это «мьютекс длиной в одну инструкцию»), но подходят только для простых типов.
#include <iostream>
#include <thread>
#include <vector>
void work(int id, int& result) {
result = id * 2; // ОПАСНО: гонка, если result общий
}
int main() {
std::vector<std::thread> pool;
for (int i = 0; i < 4; ++i) {
pool.emplace_back([](int id) { std::cout << "поток " << id << '\n'; }, i);
}
for (auto& t : pool) t.join(); // дождаться завершения каждого
return 0;
}
Разбор примера.
std::threadпринимает любой вызываемый объект и произвольные аргументы, которые копируются во внутреннее хранилище потока.- Лямбда в
emplace_backзахватывает ничего (пустые[]) —idприходит как аргумент, у каждого потока своя копия. - Вектор потоков — классический паттерн «пул задач»: создали, запустили, потом все
join-или. - Функция
workнамеренно опасна: если её вызвать из нескольких потоков с одним и тем жеresult— это гонка данных (UB). Еслиresult— локальная переменная каждого потока, всё безопасно.
Почему join обязателен. Поток — самостоятельная линия исполнения; если объект std::thread разрушается, пока поток ещё работает, программа аварийно завершается (std::terminate), потому что неясно, что делать с «осиротевшим» потоком. join() дожидается конца, detach() отпускает поток в свободное плавание (и тогда нельзя гарантировать, что он закончится до выхода из main). jthread из C++20 решает эту боль автоматически.
Почему std::terminate так строг. Стандарт намеренно не разрешает тихо уничтожать ещё работающий поток: вы не знаете, что он делает прямо сейчас — может, держит мьютекс, пишет в файл, меняет общий объект. Деструктор потока обязан либо дождаться завершения (join), либо отпустить поток (detach), иначе авария. Отсюда правило: каждый std::thread должен быть либо join-нут, либо detach-нут до уничтожения — иначе UB.
Частая ловушка. Захват локальных переменных по ссылке в лямбду потока — если переменная выйдет из области видимости раньше, чем поток закончит, получится висячая ссылка. Захватывайте по значению или владейте данными через shared_ptr.
Подробнее о ловушке с захватом. Лямбда, переданная в std::thread, хранит свои захваченные переменные в специальном внутреннем хранилище, которое живёт всё время работы потока. Если вы захватили локальную переменную по ссылке [&x], а x живёт в стеке функции, которая уже вернулась, поток обратится к мёртвой памяти — классический use-after-free, только в многопоточном контексте. Безопасные варианты:
- Захват по значению
[x]— поток получает копию, оригинал может спокойно умереть. - Захват
std::shared_ptr— поток разделяет владение, объект живёт, пока хоть один владелец существует. - Передача владения через
std::moveв лямбду — поток становится единственным владельцем. - Глобальные переменные — живут до конца программы, но с ними легко получить гонку.
std::jthread (C++20) — «joinable thread»: автоматически присоединяется в деструкторе и поддерживает кооперативную остановку через std::stop_token.
Чем jthread лучше обычного thread.
- Авто-
joinв деструкторе — больше не нужно помнить, что каждыйstd::threadобязан бытьjoin-нут илиdetach-нут, иначеstd::terminate. - Кооперативная остановка — через
std::stop_tokenиrequest_stop()можно вежливо попросить поток завершиться; поток сам решает, когда остановиться, проверяяstop_requested(). - Безопаснее по умолчанию — даже если вы забыли написать
join()в концеmain(), деструкторjthreadсделает это сам и аккуратно попросит поток остановиться.
#include <thread>
int main() {
std::jthread worker([](std::stop_token st) {
while (!st.stop_requested()) { /* полезная работа */ }
});
// при выходе из main: request_stop(), затем join() автоматически
return 0;
}
Разбор примера.
std::jthread worker(...)сразу создаёт и запускает поток.- Лямбда принимает
std::stop_token st— это «ручка управления», через которую внешний код может попросить остановиться. - Цикл
while (!st.stop_requested())периодически проверяет, не поступил ли запрос на остановку. - При выходе из
mainдеструкторworkerсначала вызываетrequest_stop()(будит поток), затемjoin()(ждёт завершения). Всё чисто, безstd::terminate.
Мьютексы и RAII-блокировки
Что такое мьютекс. Мьютекс (mutex, mutual exclusion — «взаимное исключение») — примитив синхронизации, который разрешает находиться в критической секции только одному потоку одновременно. Остальные потоки, пытающиеся захватить мьютекс, блокируются и ждут, пока текущий владелец не отпустит его. Аналогия: единственный ключ от комнаты — кто взял ключ, тот внутри; остальные ждут у двери.
Что такое RAII-блокировка. Идиома RAII (Resource Acquisition Is Initialization) говорит: захват ресурса — в конструкторе, освобождение — в деструкторе. Для мьютексов это значит: создал объект-обёртку — мьютекс захвачен; объект вышел из области видимости — мьютекс автоматически отпущен. Это критично при исключениях: если между mtx.lock() и mtx.unlock() вылетит исключение, unlock() не выполнится — мьютекс «застрянет», и все остальные потоки зависнут навсегда. RAII-обёртка гарантирует разблокировку.
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> shared;
void safe_push(int x) {
std::lock_guard<std::mutex> lock(mtx); // захват в ctor, освобождение в dtor
shared.push_back(x);
}
// unique_lock: можно отпирать/запирать вручную, нужен для condition_variable
void flexible() {
std::unique_lock<std::mutex> lock(mtx);
// ...
lock.unlock();
// ...
lock.lock();
}
// scoped_lock (C++17): захватывает несколько мьютексов без дедлока
std::mutex a, b;
void transfer() {
std::scoped_lock lock(a, b); // алгоритм избегает взаимной блокировки
}
| Инструмент | Назначение |
|---|---|
std::mutex |
Базовый мьютекс — самый дешёвый вариант блокировки |
std::recursive_mutex |
Реентерабельный (можно брать повторно тем же потоком) — считает захваты, нужен при рекурсивных вызовах |
std::shared_mutex |
Много читателей ИЛИ один писатель (C++17) — для редко меняемых данных |
std::lock_guard |
Простейший RAII-захват, почти нулевой overhead |
std::unique_lock |
Гибкий: ручное отпирание, condition_variable, передача владения |
std::scoped_lock |
Несколько мьютексов без дедлока (C++17) — замена ручных lock(m1); lock(m2); |
Что это значит на практике. Мьютекс — это «турникет»: только один поток проходит в критическую секцию, остальные ждут. Захват/освобождение делают RAII-обёртками, чтобы не забыть разблокировать при исключении. lock_guard — самый дешёвый вариант, когда блокировка нужна на всю область видимости. unique_lock дороже, зато позволяет отпирать/запирать вручную и обязателен для condition_variable. scoped_lock умеет захватить несколько мьютексов сразу без взаимной блокировки (deadlock) — используйте его вместо ручных пар lock/lock.
Практические советы по выбору блокировки.
- Блокировка на всю функцию — берите
std::lock_guard(минимум overhead, невозможно забыть). - Нужно отпустить мьютекс посереди или использовать
condition_variableили передать владение — беритеstd::unique_lock. - Захватываете несколько мьютексов — берите
std::scoped_lock, иначе рискуете получить дедлок. - Много потоков читают, один пишет —
std::shared_mutex+std::shared_lock(для чтения) иstd::unique_lock(для записи) сильно ускорят систему. - Рекурсивный вызов под мьютексом (например, рекурсивная функция берёт тот же мьютекс) —
std::recursive_mutex.
Частая ловушка — deadlock. Взаимная блокировка возникает, когда поток A держит мьютекс 1 и ждёт 2, а поток B — наоборот. std::scoped_lock(a, b) и std::lock применяют алгоритм, который избегает этого. Второе правило: не вызывайте чужой код (колбэки, сеть, файлы) под мьютексом.
Подробнее про дедлок. Классический сценарий: поток A делает lock(m1); lock(m2);, поток B делает lock(m2); lock(m1); — каждый ждёт мьютекс, который держит другой. Программа зависает намертво, и обычно единственный выход — принудительное завершение. Способы борьбы:
- Всегда брать мьютексы в одном порядке (например, по адресу объекта или по логическому номеру) — простое правило, но требует дисциплины во всём коде.
std::scoped_lock(a, b)— стандарт применяет алгоритмstd::lock(deadlock-avoidance), который захватывает мьютексы в безопасном порядке и при необходимости отпускает уже взятые.std::try_lock— попытаться взять без ожидания; если не получилось — отпустить всё и попробовать позже.- Не держать мьютекс при вызове чужого кода — колбэк пользователя может попытаться захватить тот же мьютекс и получить дедлок. Лучше скопировать данные, отпустить мьютекс, затем вызвать чужой код.
Condition variable
Что такое condition variable. std::condition_variable — примитив синхронизации, позволяющий потоку «заснуть» до наступления некоторого события и быть разбуженным другим потоком. Аналогия: вы спите дома (не тратите CPU), вам звонят в дверь — вы просыпаетесь и проверяете, что за гость. Всегда проверяйте условие в цикле: возможны ложные пробуждения (spurious wakeups) — стандарт разрешает ОС будить поток без видимой причины, поэтому без проверки предиката можно «проснуться» с пустой очередью и попасть в UB.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> tasks;
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(42);
}
cv.notify_one(); // уведомляем ПОСЛЕ изменения под мьютексом
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !tasks.empty(); }); // ждёт с проверкой предиката
int value = tasks.front();
tasks.pop();
}
Разбор примера.
- Producer добавляет задачу под мьютексом, потом отпускает мьютекс и только после этого вызывает
notify_one. Если уведомить, не отпуская мьютекс, consumer может проснуться, попытаться захватить мьютекс и сразу заблокироваться — лишнее переключение контекста и лишняя задержка. - Consumer использует
unique_lock— он нужен, потому чтоwaitотпускает мьютекс на время ожидания и снова захватывает его при пробуждении.lock_guardэтого не умеет (он всегда владеет мьютексом). cv.wait(lock, predicate)внутри делает эквивалентwhile (!predicate()) cv.wait(lock)— это защита и от ложных пробуждений, и от ситуации «producer добавил задачу раньше, чем consumer успел заснуть» (потеря notify).- Без предиката
cv.wait(lock)просыпался бы по любомуnotify— даже чужому, даже ложному — и consumer попытался бы достать значение из пустой очереди (UB). - Есть ещё
cv.notify_all()— будит всех ожидающих; используйте, когда несколько потоков могут ждать одно и то же условие и каждому нужен «свой» ресурс. Почему проверка — в цикле. Поток, который вызвалcv.wait(lock, predicate), может проснуться «сам по себе» — это так называемый spurious wakeup (ложное пробуждение). Стандарт C++ прямо разрешает такие пробуждения даже безnotify_all/notify_one: они могут случаться из-за деталей реализации ОС, ядра потоков или аппаратуры. Кроме того, даже если пробуждение настоящее, между моментом, когда другой поток вызвалnotify, и нашим фактическим пробуждением мог вмешаться третий поток и забрать задачу обратно (состояние гонки). Поэтому условие всегда проверяют повторно — формаcv.wait(lock, predicate)разворачивается компилятором в эквивалентwhile (!predicate()) cv.wait(lock);, и это единственно безопасный способ.waitавтоматически отпускает мьютекс на время сна и захватывает обратно при пробуждении — поэтому он требует именноunique_lock(толькоunique_lockумеет временно отпускать и снова захватывать мьютекс;lock_guardэтого не умеет, потому что у него нет публичных методовlock/unlock).
Аналогия. Представьте, что вы ждёте курьера с посылкой. Дверной звонок зазвонил — но это могло быть ложное срабатывание, курьер уже ушёл, или кто-то другой уже забрал вашу посылку. Поэтому каждый раз, когда звонок «срабатывает», вы ещё раз выглядываете в глазок — действительно ли курьер стоит у двери. Цикл while (!predicate()) — это и есть повторная проверка.
Как запомнить. Изменяете условие — делайте это под мьютексом, а notify вызывайте после изменения (можно уже без мьютекса). Обратный порядок приводит к потерянному уведомлению: поток-получатель мог уже проверить условие, увидеть «ложь» и уснуть раньше, чем вы измените переменную, — тогда notify улетит в пустоту и пробуждения не будет никогда.
async, future, promise
std::async — это «запусти задачу в фоне и верни мне билет, по которому я позже заберу результат». Этот «билет» называется std::future — объект-обёртка, из которого позже получится значение (через get()). Исключения из задачи автоматически сохраняются и перебрасываются в get() — то есть вам не нужно вручную ловить их внутри лямбды и пробрасывать через отдельный канал.
#include <future>
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> data(1'000'000, 1);
auto sum_future = std::async(std::launch::async, [&data] {
return std::accumulate(data.begin(), data.end(), 0LL);
});
long long total = sum_future.get(); // блокирует, пока не готово; перебросит исключение
std::cout << total << '\n';
std::promise<int> promise; // ручной канал передачи значения
std::future<int> fut = promise.get_future();
std::thread t([&promise] { promise.set_value(7); });
std::cout << fut.get() << '\n';
t.join();
return 0;
}
promise vs async. std::async — это «готовая конфета»: оборачивает запуск задачи, передачу результата, пересылку исключений и join-деструктор в один вызов. std::promise + std::future — это «ручной канал связи», который нужен, когда у вас уже есть собственный поток, или когда сигнал о результате приходит не из вычисления, а из чужого события (например, из коллбэка внешней библиотеки). В большинстве случаев предпочитайте async; к promise прибегайте, когда async не подходит по сценарию.
Атомарные операции и порядок памяти
std::atomic<T> — это обёртка над переменной типа T, которая гарантирует, что операции над ней (чтение, запись, ++, +=, compare_exchange) выполняются неделимо (атомарно) и видимы всем потокам в согласованном порядке — без мьютекса. Аргумент std::memory_order задаёт, насколько строго упорядочиваются эти обращения относительно других операций с памятью: seq_cst (по умолчанию, самый строгий) — для понимания и для большинства задач; relaxed — для счётчиков и метрик, где важен только сам факт операции; acquire/release — для публикации данных одним потоком и чтения их другим.
#include <atomic>
#include <thread>
std::atomic<int> counter{0}; // атомарный счётчик, без мьютекса
void increment() {
for (int i = 0; i < 1000; ++i) counter.fetch_add(1, std::memory_order_relaxed);
}
std::atomic<bool> ready{false};
int payload = 0;
void writer() {
payload = 42; // обычная запись
ready.store(true, std::memory_order_release); // публикуем ДО этого
}
void reader() {
while (!ready.load(std::memory_order_acquire)) {} // ждём и видим payload
// payload гарантированно == 42
}
Что такое порядок памяти. Компилятор и процессор вправе переставлять независимые обращения к памяти ради скорости — и в одном потоке это безопасно (он сам прочтёт свои же значения), но другой поток видит «другой» порядок и может прочитать устаревшее или перемешанное значение. memory_order говорит, какие перестановки запрещены. Простыми словами:
relaxed— порядок не важен вообще; годится для счётчиков, статистики, метрик, где нам важен сам факт операции, а не её отношение к другим данным.releaseпри записи +acquireпри чтении образуют пару «публикации»: всё, что записано доrelease, гарантированно будет видно тому потоку, который прочитал соответствующееacquire-значение (как если бы они были связаны в порядке «запись → чтение»).seq_cst— самый строгий и самый понятный: можно рассуждать, будто все атомарные операции во всей программе идут в едином глобальном порядке, согласованном у всех потоков.
Практическая рекомендация. Начинайте с seq_cst — он почти всегда «достаточно быстр» и гораздо проще для рассуждений; оптимизируйте до relaxed/acquire-release только после измерений профилировщиком, и только если вы точно понимаете, какие гарантии теряете. Сэкономленные наносекунды не стоят часов отладки.
Как запомнить. release — «отпустить данные в мир» (как закрыть крышку чемодана и сказать: «всё уложено, можно везти»); acquire — «принять их» (открыть чемодан и гарантированно увидеть, что внутри). Пара release/acquire — это «передача посылки»: содержимое, упакованное до отправки, гарантированно доступно получателю после приёма, и переставить его относительно самого факта передачи нельзя.
На собеседовании спросят. «Чем atomic отличается от mutex?» — atomic защищает одну переменную, не блокирует ядро ОС и не требует ожидания (операция завершается за константное время на уровне CPU-инструкции, например LOCK XADD), но не умеет защищать несколько переменных или инвариант между ними (например, «размер вектора и сам буфер согласованы»). mutex защищает целую критическую секцию, в которую можно положить сколько угодно операций и любые нетривиальные инварианты, но платит за это возможным переводом потока в режим ожидания на уровне ядра ОС.
Что НЕ лечит volatile. Ключевое слово volatile в C++ — это не средство межпоточной синхронизации. Оно отключает оптимизации компилятора для конкретной переменной (полезно для memory-mapped hardware registers), но не предотвращает гонку данных и не делает операции атомарными. Для многопоточности используйте std::atomic, а не volatile.
Как выбрать примитив синхронизации.
| Задача | Инструмент | Почему |
|---|---|---|
| Счётчик, флаг остановки | std::atomic |
Одна ячейка, без блокировки |
| Публикация данных другим потокам | atomic + release/acquire |
Явный порядок видимости |
| Защита нескольких полей/инварианта | std::mutex |
Атомик не умеет «всё вместе» |
| Ожидание события | condition_variable |
Не жжёт CPU, как spin-цикл |
| Запуск задачи с результатом | std::async / future |
Возвращает значение и исключение |
| Долгая фоновая работа с остановкой | std::jthread + stop_token |
Авто-join и кооперативная остановка |
Когда использовать многопоточность, а когда нет. Потоки нужны, если у вас есть реальная параллельная работа: численные вычисления, обработка нескольких независимых запросов (сетевой сервер, веб-бэкенд), разгрузка интерфейса (UI остаётся отзывчивым, а тяжёлая работа идёт в фоне), конвейерная обработка данных, насыщение CPU при I/O-bound задачах. Если задача последовательная по природе (разбор логов одной строки за другой, простой пайплайн без разветвлений) или данных так мало, что время выполнения всё равно в пределах миллисекунд, — потоки добавят сложности, багов и оверхеда без выигрыша. Начинайте с самого простого: std::async для запуска, mutex для защиты общих данных, и только при доказанной необходимости и под контролем профилировщика — атомики и ручной memory_order.
Типичные ошибки многопоточного кода.
- Гонка данных. Два потока обращаются к общей переменной без синхронизации, минимум один пишет — это undefined behavior (поведение не определено в стандарте, компилятор и CPU могут сделать что угодно: дублировать операцию, выкинуть её, прочитать «половину» значения). Лечится
mutex(для нескольких переменных) илиatomic(для одной). - Deadlock (взаимная блокировка). Два мьютекса, захваченные в разном порядке в разных потоках, — классический сценарий «A ждёт B, B ждёт A», программа зависает. Лечится единообразным порядком захвата или
std::scoped_lock, который захватывает несколько мьютексов атомарно специальным алгоритмом «избегания deadlock». - Забытый
join/detach. Еслиstd::threadразрушается безjoinи безdetach, программа вызываетstd::terminate(аварийное завершение). Беритеstd::jthread— он вызываетjoinв деструкторе автоматически. - Захват локальной переменной по ссылке. Лямбда, переданная в поток, переживает локальную переменную из внешней функции — получается висячая ссылка (использование после уничтожения, classic use-after-free). Лечится захватом по значению или явной передачей владения (
std::shared_ptr,std::move). - Потерянное уведомление. Условие изменено вне мьютекса, или
notifyвызван до изменения условия — поток-получатель может уже «просмотреть» условие и уснуть до того, как оно изменится; тогда он не проснётся никогда (или проснётся только при следующем чужомnotify). - Проверка условия один раз.
condition_variableможет проснуться ложно — нужна проверка в цикле. Используйте формуwaitс предикатом, она разворачивается в безопасныйwhile (!predicate()). - Ложная экономия на блокировке. «Быстрый флаг» в виде обычного
boolвместоstd::atomic<bool>— компилятор имеет полное право закешировать его в регистре CPU, и тогда другой поток никогда не увидит новое значение. Это UB, даже если «у вас работает». - Логгирование под мьютексом. Медленный ввод-вывод (
std::cout, запись в файл, сетевые вызовы) внутри критической секции сериализует все потоки на скорости самого медленного из них и убивает производительность в десятки раз; лучше собрать сообщение в локальную строку и записать лог уже после отпускания мьютекса.
Как отлаживать. Санитайзер -fsanitize=thread (он же TSan — Thread Sanitizer) находит гонки данных автоматически, если прогнать под ним тесты и дать нагрузку. Полезно комбинировать с -fsanitize=address для ловли use-after-free в шардах данных. Отлаживать многопоточность «на глаз» в printf-debugging стиле практически бесполезно: баг воспроизводится раз в тысячу запусков, и само добавление логов меняет тайминги, пряча проблему. Дополнительные приёмы: пишите отдельные маленькие воспроизводимые тесты, фиксируйте количество потоков и чёткий сценарий; в продакшне помогают атомарные счётчики, метрики и корреляционные id в логах, чтобы потом собрать картину из разных потоков.
Подводный камень — неатомарные «флаги». Переменная bool, объявленная без atomic, при гонке может «залипнуть» навсегда из-за кеширования в регистре CPU и агрессивных оптимизаций компилятора (он считает, что раз «никто в этом файле эту переменную не меняет» — значит, можно не перечитывать её из памяти). Ключевое слово volatile это не лечит: оно отключает лишь оптимизации компилятора внутри одного потока, но не даёт гарантий памяти между потоками и не делает операции атомарными. Для флага остановки потока — std::atomic<bool> или, лучше, std::stop_token (высокоуровневый механизм в std::jthread). Это одна из самых частых ошибок новичков: код, который «всегда работает», остаётся формально UB, и однажды «взорвётся» на другом компиляторе, процессоре или с другой нагрузкой.
Современные стандарты C++11 → C++23
Что такое «стандарт C++»? Это официальный документ, который описывает, какие конструкции языка обязан поддерживать компилятор. Каждые примерно три года комитет ISO WG21 публикует новую версию — ей присваивается «год-номер»: C++11, C++14, C++17, C++20, C++23. Это «правила дорожного движения» для компилятора: новый стандарт добавляет новые «знаки» и «светофоры», а старые продолжают работать, пока вы их явно не отключите.
Аналогия из жизни. Представьте эволюцию смартфона: первые модели умели звонить и принимать SMS, потом появилась камера, GPS, бесконтактная оплата. C++11 — это «момент мультитача»: камеры ещё нет, но интерфейс уже современный. C++17/20 — момент, когда телефон стал действительно удобным. C++23 — топовые функции и тонкие улучшения удобства. Все эти «прошивки» обратно совместимы: старые приложения продолжают работать.
Начиная с C++11 язык обновляется раз в три года. Новичку это кажется хаосом, но на практике важен один ориентир: пишите на C++17/20, а «старые» приёмы (new/delete, NULL, ручные циклы) постепенно заменяются безопасными эквивалентами. Таблица показывает, что и когда появилось, — по ней удобно проверять, можно ли использовать ту или иную конструкцию в вашем проекте.
| Фича | Стандарт | Зачем |
|---|---|---|
Семантика перемещения, auto, range-for, nullptr, лямбды, enum class |
C++11 | Основа «современного C++»: убрали ручную работу с памятью и дали удобный синтаксис для обобщённого кода |
make_unique, generic lambdas, std::chrono-литералы, 0b-литералы |
C++14 | Удобство и обобщённые лямбды — точечные доработки поверх C++11, ничего революционного |
Structured bindings, if constexpr, std::optional/variant/any, filesystem, fold expressions |
C++17 | Безопасные типы-обёртки (optional вместо «магических -1»), меньше SFINAE, удобная работа с файлами |
Concepts, ranges, concepts-constrained auto, <=>, std::format, span, jthread |
C++20 | Проверяемые ограничения на шаблоны, читаемый вывод, удобные диапазоны — качественный скачок в выразительности |
std::expected, std::print, flat_map, mdspan, import std |
C++23 | Практичные утилиты: ошибки как значения, плоские контейнеры, ускорение сборки через модули |
Что это значит на практике. C++11 был «революцией» (move, лямбды, умные указатели, auto, range-for) — без него современный C++ не существует. C++14 и C++17 — «удобство» (обобщённые лямбды, structured bindings, optional/variant, filesystem, if constexpr). C++20 — «читаемость и проверяемость» (concepts, ranges, <=>, format, span, jthread). C++23 — «практичность» (expected, print, flat_map). Если вы знаете C++17 и основы C++20, вы уже продуктивно пишете на «современном C++».
Правило для собеседований и продакшна. Знание C++11/14/17 — обязательный минимум, который спросят почти везде. C++20 уже «must know» для новых проектов (особенно concepts и ranges). C++23 поддерживается ещё не всеми компиляторами полностью — перед использованием проверяйте наличие фичи через макрос __cpp_lib_expected и т.п. или в таблицах поддержки на cppreference.
Карта модернизации типичного кода:
NULL/0→nullptr.new/delete→unique_ptr/make_unique.- Цикл по индексам → range-for или
std::ranges. typedef→using.enum→enum class(сильная типизация).#define MAX→constexprилиstd::clamp.- Ручное управление ресурсами → RAII-обёртки.
printf/stringstream→std::format.void*+ размер →std::span.- SFINAE → concepts.
Совет по миграции. Не надо переписывать всю кодовую базу за один раз — действуйте по принципу «правило бойлера» (boy scout rule): каждый раз, когда прикасаетесь к функции, сразу приводите её к современному виду (замените NULL на nullptr, new/delete на unique_ptr, добавьте auto). За 2–3 релиза большая часть проекта обновится органически, без отдельной «миграционной» недели.
// enum class: без неявного преобразования в int, без «протечки» имён
enum class Color { Red, Green, Blue };
// int x = Color::Red; // ошибка — хорошо
int x = static_cast<int>(Color::Red);
// using вместо typedef читается лучше и дружит с шаблонами
using IntPair = std::pair<int, int>;
template <typename T> using Vec = std::vector<T>;
Разбор кода по строкам.
enum class Color { Red, Green, Blue };— объявляем типизированное перечисление: именаRed/Green/Blueживут только внутриColor, вintнапрямую не превращаются. Это как отдельный «почтовый ящик», чтобы никто случайно не положил туда яблоки вместо апельсинов.// int x = Color::Red;— эта строка не скомпилируется: компилятор защищает вас от ошибки «апельсин сравниваем с яблоком». Раскомментируйте, чтобы своими глазами увидеть сообщение об ошибке.int x = static_cast<int>(Color::Red);— явное приведение типа. Используйте его осознанно, когда вам действительно нужно число (отладка, сериализация, индекс в массиве).using IntPair = std::pair<int, int>;— современный псевдоним типа. Читается слева направо: «IntPair — это пара интов».template <typename T> using Vec = std::vector<T>;— шаблонный псевдоним, которыйtypedefне умеет делать в принципе. Объявили один раз — иVec<int>,Vec<std::string>готовы к использованию.
Типичные ошибки новичков.
- Писать
class Color { static const int Red = 0; ... };вместоenum class— получается длинно и менее типобезопасно (нет «автоматического» перечисления значений). - Пытаться использовать
Vec<int>послеusing Vec = std::vector<int>;безtemplate <typename T>— для параметризации псевдонима нужен именно шаблон. - Забывать, что
enum classпо умолчанию имеет базовый типintи занимаетsizeof(int)байт. Если нужен компактный тип — пишитеenum class : std::uint8_t { ... };. - Путать «просто
enum» иenum class: первый остаётся в стандарте легально, но для нового кода выбирайтеenum class, если нет веской причины (например, обратная совместимость с C-API, где нужны неявные преобразования вint).
Почему enum class, а не старый enum. Обычный enum «протекает» в окружающую область видимости (имена Red, Green становятся глобальными — как переменные, случайно объявленные в общем коридоре, куда заглядывают все) и неявно преобразуется в int, что позволяет случайно сравнить или сложить несвязанные значения. enum class держит имена внутри себя (Color::Red) и не конвертируется неявно — компилятор требует static_cast<int>. Это пример общего направления современных стандартов: больше проверок на этапе компиляции, чтобы баг не дошёл до пользователя.
Практический совет по базовому типу. Если вам нужен набор именованных констант времени компиляции разного размера — пишите enum class : std::uint8_t { Red, Green, Blue };. Это и типобезопасно, и не раздувает размер переменной. То же касается enum class : std::uint32_t для флагов, которые точно не поместятся в байт.
Почему using лучше typedef. Для простых псевдонимов они равнозначны, но using читается слева направо (alias = type, а не спрятанное в середине имя typedef), и только using умеет делать шаблонные псевдонимы (template <typename T> using Vec = ...). Кроме того, using лучше работает в SFINAE-контекстах и понятнее при отладке шаблонов — компилятор выдаёт более читаемые сообщения об ошибках, потому что видит псевдоним как «нормальное» имя типа.
Идиомы и подводные камни
Идиома — это устоявшийся приём, который решает типовую задачу «правильным» для C++ способом. Если проводить аналогию с жизнью, идиома — это как кулинарный рецепт: не закон природы, а проверенный способ получить хороший результат, который повара передают друг другу. Их полезно знать не для того, чтобы применять везде, а чтобы узнавать в чужом коде и не изобретать своё колесо. Ниже три классические идиомы и несколько ловушек, о которые спотыкаются все — от студентов до senior-разработчиков.
// CRTP: статический полиморфизм без vtable
template <typename Derived>
class Shape {
public:
double area() const { return static_cast<const Derived*>(this)->area_impl(); }
};
class Square : public Shape<Square> {
public:
double area_impl() const { return side_ * side_; }
private:
double side_ = 1.0;
};
// NVI (Non-Virtual Interface): публичный невиртуальный + приватный виртуальный
class Base {
public:
void run() { pre(); do_run(); post(); } // контролируем инвариант
private:
virtual void do_run() = 0;
void pre() {}
void post() {}
};
// PIMPL: скрываем детали реализации, стабилизируем ABI
class Widget {
public:
Widget();
~Widget(); // обязательно объявить в .cpp для unique_ptr<Pimpl>
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
Что делают эти идиомы. CRTP (Curiously Recurring Template Pattern — «причудливо повторяющийся шаблон») — базовый класс знает производный как шаблонный параметр и вызывает его метод без virtual, то есть без vtable: быстрее, но связывание статическое (разрешается на этапе компиляции). Аналогия: представьте, что начальник (база) даёт задание подчинённому (наследнику), причём начальник заранее знает имя конкретного подчинённого — никакой «канцелярии» (vtable) для передачи задачи не нужно. NVI (Non-Virtual Interface — «невиртуальный интерфейс») — публичный невиртуальный метод задаёт «скелет» операции, а приватные виртуальные реализуют шаги; наследник не может сломать порядок вызовов, потому что у него нет доступа к изменению скелета. Это как ресторан: официант (публичный метод) принимает заказ и подаёт блюдо, а повар (виртуальный метод) готовит — клиент не может ворваться на кухню и навести там свои порядки. PIMPL (Pointer to Implementation — «указатель на реализацию») — детали реализации спрятаны за указателем, заголовок не тянет лишние #include, а двоичный интерфейс (ABI) остаётся стабильным при изменениях внутри. Аналогия: фасад магазина (заголовок) не меняется, даже если внутри полностью перестроили торговый зал.
Почему ~Widget() объявляют в .cpp. unique_ptr<Impl> требует полного типа Impl в момент своего разрушения, а в заголовке он неполный (только struct Impl; — это предварительное объявление, forward declaration). Поэтому деструктор (и конструктор) выносят в .cpp, где Impl определён. Если этого не сделать, компилятор сгенерирует деструктор в месте использования unique_ptr (то есть в заголовке) и выдаст ошибку о неполном типе. Практический совет: в заголовочном файле PIMPL-класса всегда явно пишите ~Widget(); и Widget();, а в .cpp определяйте их как Widget::~Widget() = default;. Никогда не определяйте деструктор прямо в заголовке — это лишает идиому смысла.
Как запомнить. CRTP — «наследник передаёт себя базе». NVI — «публичное не виртуально, виртуальное приватно». PIMPL — «не показывай внутренности в заголовке».
Static initialization order fiasco. Порядок инициализации глобальных объектов из разных translation unit (TU) не определён стандартом; если один глобальный объект использует другой в своём конструкторе — это UB (undefined behavior). Аналогия: представьте двух подрядчиков, которые приходят на пустую стройку в разные дни и при этом каждый рассчитывает, что другой уже что-то построил. Результат непредсказуем. Решение: функция Meyers singleton (локальная статика инициализируется при первом вызове) или constinit (C++20).
// ПЛОХО: порядок между TU не определён
// a.cpp: extern std::string g_name; std::string g_greeting = "Hi " + g_name;
// b.cpp: std::string g_name = "world";
// ПРАВИЛЬНО: локальная статика инициализируется при первом обращении
const std::string& greeting() {
static const std::string value = "Hi " + name();
return value;
}
const std::string& name() {
static const std::string value = "world";
return value;
}
Почему локальная статика спасает. Инициализация локальной статической переменной происходит при первом обращении к функции, а не при загрузке программы (начиная с C++11 это ещё и потокобезопасно — компилятор вставляет скрытый мьютекс). Поэтому неважно, в каком порядке инициализируются глобальные объекты из разных TU: функция greeting() вызовет name() ровно в тот момент, когда name понадобится. Это и есть «Meyers singleton» (по имени Скотта Мейерса, популяризировавшего приём). constinit (C++20) решает похожую задачу иначе: он заставляет компилятор инициализировать переменную на этапе загрузки и выдаёт ошибку, если это невозможно (например, если инициализатор требует вызова функции). Практический совет: если вам нужен именно глобальный объект (а не функция), но без fiasco — оберните доступ в функцию и возвращайте ссылку на локальную статику. Это самый дешёвый и надёжный способ.
Most vexing parse — объявление переменной компилятор читает как прототип функции:
struct Widget { Widget(int); };
// Widget w(10); // OK: переменная
// Widget w(); // ОБЪЯВЛЕНИЕ функции, не переменная!
class Timer { public: Timer(int, double); };
// Timer t(Timer()); // парсится как объявление функции, принимающей Timer(*)()
// Решения:
Timer t1{10, 1.5}; // единообразная инициализация (C++11)
auto t2 = Timer(10, 1.5); // через auto
Почему это «самый досадный разбор». В C++ есть правило: если конструкция может быть прочитана как объявление функции, компилятор читает её именно так. Widget w(); он видит не как «создать объект без аргументов», а как «объявить функцию w, возвращающую Widget». Отсюда и название most vexing parse («самый досадный разбор») — потому что программист имеет в виду одно, а компилятор упорно видит другое, и ошибка компиляции не возникает (получается валидный код, который делает не то). Ещё коварнее случай Timer t(Timer()); — здесь внешние скобки парсятся как объявление функции t, принимающей параметр типа «указатель на функцию, возвращающую Timer». Лечится фигурными скобками (Widget w{};), auto, лишними скобками вокруг значения (Timer t((Timer(10, 1.5)));) или просто прямым вызовом конструктора.
Как запомнить. Пустые круглые скобки в объявлении переменной — почти всегда ошибка. Видите Type name(); рядом с определением переменной — присмотритесь. Золотое правило современного C++: для инициализации объектов используйте фигурные скобки {} — они почти никогда не попадают в ловушку most vexing parse.
ADL (Argument-Dependent Lookup, или «Koenig lookup») позволяет swap(a, b) без квалификации найти правильную перегрузку в пространстве имён типа. Это основа идиомы «two-step»:
using std::swap;
swap(a, b); // ADL найдёт swap для пользовательского типа, если он определён
Зачем ADL нужен в жизни. Представьте, что вы говорите другу: «Поменяй нам билеты». Друг сам понимает, в каком смысле «поменять» — в зависимости от того, билеты на что (в кино, на самолёт, в театр). Компилятор делает то же самое: видит тип аргумента a (например, std::string или ваш MyType) и ищет функцию swap сначала в пространстве имён этого типа, и только потом — в текущем. Почему две строки. Конструкция using std::swap; поднимает стандартный swap в текущую область видимости (как «запасной вариант»), а неквалифицированный вызов swap(a, b) даёт шанс ADL найти специализированную версию для пользовательского типа. Без using std::swap; при отсутствии пользовательского swap код просто не скомпилируется. Практический совет: в обобщённом коде (шаблонах) всегда пишите swap именно так — через ADL и using std::swap;. Это идиоматический способ, рекомендованный самим Мейерсом.
Вызов виртуальной функции через базовый указатель в деструкторе-наследнике и бесконечная рекурсия в operator= без проверки this != &other — соседние классические ошибки. Первая возникает потому, что в теле деструктора наследника объект уже «стал» базой (таблица виртуальных функций переключена на базовый класс), и вызов virtual уходит в базовую реализацию, а не в производную. Вторая — классическая самоприсваивающая рекурсия: T& operator=(const T& other) { ... this = ...; ... } без проверки if (this != &other). Ещё одна: std::vector<bool> — не настоящий контейнер: его operator[] возвращает прокси-объект, поэтому auto b = v[0] даёт прокси, а не bool.
std::vector<bool> flags{true, false};
// auto x = flags[0]; // x — прокси-ссылка, не bool!
bool x = flags[0]; // правильно: явное преобразование
Почему vector<bool> особенный. Чтобы хранить биты упакованно (8 флагов в одном байте вместо 1 байта на флаг), стандарт разрешил реализации сделать operator[] через прокси-объект — «умную ссылку», которая ведёт себя как bool (её можно присвоить, прочитать, конвертировать в bool), но не является bool. Аналогия: это как пульт от телевизора — нажимаете кнопку «канал+», и внутри пульта происходит сложная работа с ИК-сигналами, но для вас это выглядит как «увеличить канал». Из-за этого auto x = flags[0] выводит тип прокси, и такие вещи, как &flags[0] (взять адрес элемента) или flags[0] = flags[1] (прямое копирование) работают не так, как у обычных контейнеров. Стандарт даже не гарантирует, что это «настоящий контейнер» — это специальная специализация шаблона, единственная в STL, нарушающая концепцию ContiguousContainer. Практический совет: если нужен обычный контейнер из bool — берите std::deque<bool> (не имеет такой специализации) или std::vector<char>/std::vector<uint8_t>.
Как запомнить. Видите спец-специализацию std::vector<bool> — ждите сюрпризов: нет data(), нет указателей на элементы, auto даёт прокси, контейнер не соответствует ожиданиям от std::vector. Это самый известный исторический компромисс STL (иногда шутят, что это худшее решение комитета по стандартизации). В C++20 появились std::span<bool>-обёртки и обсуждения замены, но специализация пока остаётся.
На собеседовании спросят. «Что такое most vexing parse?», «Зачем ADL?», «Почему vector<bool> не vector?» — эти три вопроса встречаются чаще всего, когда речь заходит о «странностях» C++. Держите под рукой мини-пример к каждому: одной фразы «это Widget w(); — объявление функции» обычно достаточно. Бонус-вопросы, которые тоже любят задавать: «Что такое static initialization order fiasco и как с ним бороться?», «Зачем в PIMPL деструктор выносят в .cpp?», «Что такое Meyers singleton?». Ответы на все пять есть выше — главное помнить не дословно, а понимать причину каждого явления: компилятор C++ следует определённым правилам парсинга и инициализации, и эти правила иногда расходятся с интуицией программиста.
Каталог undefined behavior
UB (Undefined Behavior, неопределённое поведение) — это ситуация в коде, при которой стандарт C++ снимает с себя любые обязательства по поводу результата. Компилятор не обязан сообщать об ошибке, не обязан делать что-то предсказуемое и не обязан даже «безопасно падать». Это не баг компилятора, а сознательное решение комитета: дать реализации свободу для агрессивных оптимизаций.
UB — ситуации, где стандарт «не налагает требований». Компилятор вправе считать, что их не бывает, и оптимизировать соответственно: программа может «работать», падать, портиться или выдавать разный результат при -O0 и -O2.
Аналогия из жизни. UB — это как аварийный выход в здании: он есть, но если вы воспользуетесь им в обычной ситуации, никто не знает, что произойдёт. Может, сработает сигнализация. Может, вы упадёте. Стандарт не описывает, что должно случиться, — именно это делает UB коварным.
Почему UB — это не «как повезёт». Компилятор не проверяет UB в рантайме, он использует отсутствие UB как допущение при оптимизации. Например, если знаковое переполнение — UB, компилятор вправе считать, что x + 1 > x всегда истинно, и выбросить проверку. Нарушили допущение — получили «странный» код, который на -O0 работал. Аналогия: правила дорожного движения не «запрещают» ехать на красный — они предполагают, что никто так не делает, и рассчитывают тайминги светофоров исходя из этого.
Как запомнить. Есть UB, которая «молчит» (переполнение знакового, чтение неинициализированного, нарушение strict aliasing), и есть UB, которая «громко падает» (разыменование nullptr). Опаснее первая: её не видно ни в отладчике, ни в коде. Именно поэтому санитайзеры обязательны в тестах.
Что значит «молчит» на практике.
- Компилятор удаляет «никогда не срабатывающие» проверки.
- Цикл
while (x + 1 > x)становится бесконечным, если компилятор «знает», чтоx + 1 > x— тавтология (без UB). - Две переменные «вдруг» оказываются одной и той же ячейкой памяти (после оптимизации strict aliasing).
- Программа выдаёт разные ответы на разных компиляторах/версиях/флагах — это верный признак UB.
#include <climits>
#include <vector>
int overflow() { int x = INT_MAX; return x + 1; } // UB: переполнение знакового
int uninit() { int x; return x + 1; } // UB: чтение неинициализированного
int oob() { int a[3]{}; return a[5]; } // UB: выход за границы
int shift_bad(unsigned x) { return 1 << 32; } // UB: сдвиг >= ширины типа
double div_zero() { int a = 1, b = 0; return a / b; } // UB: целочисленное деление на 0
int min_neg() { return INT_MIN / -1; } // UB: переполнение
void use_after_free() {
int* p = new int(5);
delete p;
*p = 10; // UB: use-after-free
delete p; // UB: double free
}
void dangling_ref() {
std::vector<int> v;
v.reserve(1);
const int& r = v[0];
v.push_back(7); // r может стать висячей ссылкой
}
int* stack_return() {
int local = 42;
return &local; // UB при разыменовании
}
Как читать примеры. Комментарий // UB: ... указывает на конкретное место, где «нарушается контракт». Например, в uninit UB — это чтение x до записи; в overflow — сам момент сложения. Это помогает при ревью: ищите точку первого нарушения допущения.
Связь UB с качеством кода. Каждый UB — это потенциальная мина замедленного действия. Сегодня код работает, потому что компилятор «случайно» сгенерировал безопасный машинный код. Завтра вы обновите GCC, включите -O3 или переедете на другую архитектуру — и мина взорвётся. Лечить последствия UB всегда дороже, чем писать корректный код с самого начала.
Главные источники UB в C++:
- Переполнение знаковых целых (беззнаковые определены и заворачиваются по модулю 2ⁿ). Почему так: для знаковых типов стандарт оставил реализациям свободу — некоторые CPU ловят переполнение аппаратно, некоторые нет; чтобы оптимизации работали одинаково на всех платформах, ввели UB. Подсказка: если нужна «заворотная» арифметика — используйте
unsignedили__builtin_add_overflow. - Разыменование
nullptr, висячего или невалидного указателя. Типичная ошибка: «у меня указатель ненулевой, проверять не буду» — но если указатель ведёт на освобождённую память, он формально не равенnullptr, и компилятор это не отловит. Подсказка: используйте умные указатели (std::unique_ptr,std::shared_ptr). - Чтение неинициализированной переменной. Подсказка: всегда инициализируйте переменные при объявлении (
int x = 0;вместоint x;). Современные компиляторы предупредят об этом с-Wall -Wextra. - Выход за границы массива и использование невалидного итератора. Подсказка: используйте
std::vector::at()илиstd::array::at()— в отладочной сборке они бросаютstd::out_of_rangeи сразу указывают на проблему. use-after-free,double free, утечка не UB, но дефект. Почему разница: утечка — это «плохо», но предсказуемо (память просто не освободится); use-after-free — UB, потому что память могла быть уже переиспользована под другой объект, и запись туда испортит чужие данные.- Гонка данных (data race) при многопоточности. Почему: компилятор и CPU вправе переупорядочивать операции и кэшировать значения в регистрах; без синхронизации нельзя гарантировать видимость изменений между потоками. Подсказка: используйте
std::mutex,std::atomicили каналы передачи сообщений. - Изменение строкового литерала:
char* s = "abc"; s[0] = 'A';. Почему: строковые литералы в C++ имеют типconst char[N], и запись вconst-память — UB. Подсказка: пишитеconst char* s = "abc";— компилятор сразу отвергнет присваивание. - Деление на ноль,
INT_MIN / -1. Подсказка: для целых типов деление на ноль — UB, а дляdoubleиfloat— этоinf/nan(определённое поведение IEEE 754, не UB). - Нарушение strict aliasing: чтение
intчерезfloat*. Что это: правило, по которому объекты разных типов не должны «пересекаться» в одной области памяти. Подсказка: для безопасного «побитового» преобразования используйтеstd::memcpyилиstd::bit_cast(C++20). - Отсутствие
returnв функции с ненулевым типом возврата. Типичная ошибка: забылиreturnв одной из ветокif/else— компилятор может не предупредить без-Wreturn-type. Подсказка: включите-Wall -Wextra -Werror=return-typeв CI. - Использование объекта после начала его деструктора. Подсказка: не вызывайте виртуальные функции и не обращайтесь к полям в деструкторе — для виртуальных функций уже выбрана «текущая» реализация, и поведение может быть неожиданным.
deleteчерез указатель на базу без виртуального деструктора. Типичная ошибка: наследник с нетривиальным деструктором, а в базе деструктор не виртуальный —delete base_ptrуничтожит только базовую часть, а поля наследника «утекут» вместе с памятью, и это UB.- Невалидная специализация/нарушение ODR — «не UB прямо сейчас», но непредсказуемо. Что это: два разных определения одной функции в разных единицах трансляции — компоновщик вправе выбрать любое, и вы получите «не ту» функцию.
Категории UB по «громкости» последствий.
| Категория | Пример | Почему опасно | Санитайзер |
|---|---|---|---|
| Память | use-after-free, OOB | Может испортить любые данные | ASan |
| Инициализация | Чтение до записи | Может дать любой битовый паттерн | MSan |
| Переполнение | INT_MAX + 1 |
Компилятор выбросит «лишние» проверки | UBSan |
| Алиасинг | Чтение int через float* |
Оптимизации могут перепутать переменные | UBSan |
| Многопоточность | Data race | Случайные «потерянные» обновления | TSan |
Как этим пользоваться. Не пытайтесь запомнить весь список — выучите три самых частых категории: память (висячие указатели, выход за границы, use-after-free), инициализация (чтение до записи) и переполнение знаковых. Остальное придёт с опытом и санитайзерами.
Типичные ошибки новичков.
- «У меня работает на моём компиляторе» — это не гарантия отсутствия UB. Завтра выйдет новая версия GCC, и код «вдруг» сломается.
- «Я добавил
if (p) *p = 0;— теперь безопасно» — нет:pможет указывать на освобождённую память, а не бытьnullptr. - «Вектор же сам управляет памятью» — но
&v[0]послеpush_backстановится висячим указателем из-за реаллокации. - «Беззнаковое и знаковое — почти одно и то же» — нет: переполнение беззнаковых определено и заворачивается, знаковых — UB.
- «Цикл не бесконечный, в нём же есть
break» — а компилятор «видит», что условие цикла всегда истинно (из-за UB), и выбрасываетbreak.
Практика: собирайте с -fsanitize=address,undefined в тестах — они превращают молчаливый UB в громкую ошибку с трассировкой. И никогда не полагайтесь на «у меня работает».
Какие санитайзеры для чего.
| Санитайзер | Что ловит | Флаг компиляции |
|---|---|---|
| AddressSanitizer (ASan) | Выход за границы, use-after-free, double free | -fsanitize=address |
| UndefinedBehaviorSanitizer (UBSan) | Переполнение знаковых, невыровненный доступ, деление на ноль | -fsanitize=undefined |
| MemorySanitizer (MSan) | Чтение неинициализированной памяти | -fsanitize=memory (только Clang) |
| ThreadSanitizer (TSan) | Гонки данных | -fsanitize=thread |
Совет на каждый день. Включайте санитайзеры в CI, а не только на своей машине. Один прогон с санитайзерами в неделю экономит недели отладки «странных» багов. И помните: санитайзеры — это «сеть», а не «страховка». Они ловят многое, но не всё (например, ASan и MSan конфликтуют на уровне рантайма и не могут работать одновременно).
Частые ошибки
Эта таблица — «контрольный список» перед сдачей кода. Просмотрите её и мысленно проверьте свой код по каждой строке: большая часть багов на C++ — это повторяющиеся, давно известные ошибки, а не что-то экзотическое.
Почему в C++ так много «классических» ошибок? Язык даёт прямой доступ к памяти, ручное управление ресурсами и мощную, но опасную семантику копирования/перемещения. Компилятор не защищает вас от работы с уже освобождённой памятью, не проверяет выход за границы массива и не контролирует гонки данных. Зато он имеет право оптимизировать код, опираясь на отсутствие UB, — и эти оптимизации иногда «ломают» программу, которая «всегда работала». Поэтому почти все пункты ниже — это про владение ресурсами, время жизни объектов и согласованность состояния.
Как читать таблицу. Колонка Ошибка — краткое имя проблемы (часто это устоявшийся англоязычный термин). Колонка Причина — что именно идёт не так в коде. Колонка Решение — каким приёмом проблему предотвращают. Если в колонке Решение вы видите идиому (unique_ptr, noexcept, erase_if и т. п.) — найдите её в шпаргалке и изучите подробно: в C++ профилактика всегда дешевле отладки.
| Ошибка | Причина | Решение |
|---|---|---|
| Use-after-free | Указатель хранит адрес уже удалённого объекта; разыменование читает/пишет в освобождённую память или в чужие данные | unique_ptr/shared_ptr, AddressSanitizer; после delete обнулять указатель (p = nullptr) |
| Memory leak | new без соответствующего delete, потерянный shared_ptr-цикл, RAII-объект не вышел из области видимости |
RAII, make_unique/make_shared, weak_ptr для обратных ссылок; Valgrind/ASan в тестах |
| Double free | Два владельца освобождают один и тот же ресурс или повторный delete по сырому указателю |
Только один владелец; умные указатели; помечать копирование как =delete |
| Dangling reference | Ссылка/string_view/span указывает на уже разрушенный объект; ссылка — это просто псевдоним, она не «удлиняет» жизнь |
Не возвращать ссылки/вью на локальные переменные и временные объекты; владеть данными |
| Object slicing | Производный объект копируется в базовый по значению — теряется «специфичная» часть (виртуальная таблица, поля наследника) | Хранить объекты через unique_ptr<Base> или передавать по ссылке/указателю на базу |
| Нет виртуального деструктора | delete через указатель на базу вызывает только ~Base(), деструктор наследника не запускается → утечка/UB |
virtual ~Base() = default; в любом классе, предназначенном для полиморфного удаления |
Забыт override |
Сигнатура метода слегка отличается от базовой (const, тип аргумента, ссылки) — вместо переопределения создаётся новая функция | Всегда писать override (или final); компилятор проверит совпадение сигнатуры |
Move без noexcept |
std::vector и другие контейнеры при росте выбирают копирование вместо перемещения ради exception safety |
Помечать перемещающие конструктор и оператор noexcept, если они действительно не бросают |
| Инвалидация итератора | erase/push_back/insert внутри цикла делает текущий (и часто последующие) итераторы невалидными |
Использовать итератор, возвращённый из erase; std::erase_if; писать цикл в стиле «pop-and-process» |
| Инвалидация ссылки | vector/string при push_back/resize перевыделяет буфер и переносит элементы — ссылки/указатели указывают в освобождённую память |
reserve() заранее; не хранить ссылки/указатели на элементы контейнера дольше одной операции |
| Most vexing parse | Widget w(); грамматически выглядит как объявление функции w, возвращающей Widget — не определение переменной |
Widget w{}; (braced-init) либо Widget w(аргументы); |
| Порядок инициализации членов | В списке инициализации указан один порядок, в классе объявлен другой — компилятор всё равно инициализирует в порядке объявления | Соблюдать порядок объявления в списке; включать -Wreorder в GCC/Clang |
| Self-assignment | В operator= сначала освобождаются старые ресурсы, потом читаются other — на a = a они уже освобождены |
Проверка if (this != &other) или идиома copy-and-swap |
| Static init order fiasco | Глобальный объект одной единицы трансляции читает глобал другой — порядок инициализации между TU не определён | Meyers singleton (функция со статической локальной переменной), constinit, отказ от глобальных объектов со сложной логикой |
| Неявное преобразование | Конструктор с одним аргументом без explicit разрешает неявные преобразования — компилятор молча превращает типы в неожиданных местах |
Помечать такие конструкторы explicit (начиная с C++11 — почти всегда) |
std::move не перемещает |
std::move — это только static_cast<T&&>, он ничего не перемещает, лишь делает объект rvalue |
Реальное перемещение происходит в move-конструкторе/операторе; убедиться, что они определены |
Возврат std::move(local) |
std::move(local) обязывает компилятор рассматривать local как rvalue и запрещает NRVO — лишнее копирование/перемещение |
Возвращать просто local (или return std::move(*p) для членов кучи) |
vector<bool> ≠ vector |
Ради плотной упаковки битов operator[] возвращает прокси-объект vector<bool>::reference, а не bool& — ломает шаблоны и ссылки |
std::deque<bool>, std::vector<char>, std::bitset; либо явно static_cast<bool>(v[i]) |
| Конкатенация в цикле | s += a много раз вызывает реаллокацию строки — сложность вырождается в O(n²) |
s.reserve(...) до цикла; собирать через std::ostringstream или std::string result; + append |
rand() для статистики |
rand() имеет малый период, плохое распределение, глобальное состояние; в новом коде считается устаревшим |
std::mt19937 (или mt19937_64) + std::random_device для сида; распределения из <random> |
| UB «работает локально» | Компилятор вправе предполагать отсутствие UB и делать оптимизации, которые ломают программу, если UB всё-таки есть | Санитайзеры (ASan/UBSan/MSan/TSan), -Wall -Wextra -Wpedantic, сборка минимум двумя компиляторами |
| Сравнение указателей разных массивов | Результат </<=/>/>= для указателей из разных массивов не определён (==/!= формально разрешены, но почти всегда бессмысленны) |
Сравнивать индексы или итераторы в пределах одного диапазона (std::less для итераторов) |
delete[] для new |
Скалярный new и массивный new[] используют разные служебные структуры в куче — delete[]/delete должны совпадать |
Парность new[]/delete[]; в современном коде — std::vector<T> / std::unique_ptr<T[]> |
| Возврат ссылки/указателя на локальную | Локальная переменная разрушается при выходе из функции — возвращённая ссылка указывает в освобождённый кадр стека | Возвращать по значению (RVO/NRVO) либо владельца (unique_ptr), либо объект, живущий в куче/у вызывающего |
Забыт join() или detach() |
Деструктор std::thread для ещё работающего потока вызывает std::terminate — программа падает |
Каждый std::thread обязан закончиться join() или detach(); лучше — std::jthread (C++20) |
| Data race | Два потока без синхронизации читают/пишут одну ячейку памяти — результат не определён, баг может не воспроизводиться | std::mutex + std::scoped_lock, std::atomic<T>, lock-free структуры; прогон под TSan |
| Захват локальной по ссылке в поток | Лямбда, переданная в новый поток, переживает функцию — захваченная по ссылке локальная переменная уже разрушена | Захватывать по значению; либо std::shared_ptr (копия shared_ptr живёт, пока нужен потоку) |
Ложное пробуждение condition_variable |
cv.wait() может вернуться без видимой причины (spurious wake-up) и без выполнения предиката |
Всегда ждать в цикле: cv.wait(lock, [&]{ return ready; }) — это и есть вариант с предикатом |
sort для list |
std::sort требует RandomAccessIterator, а у std::list итераторы двусвязные — не компилируется |
Использовать собственный list::sort(); либо перенести данные в vector, отсортировать, вернуть |
string_view/span на временный объект |
View — это не владелец, только пара «указатель + длина»; если источник умер, view указывает в труху | Хранить владеющие данные (std::string, контейнер) рядом с view; не привязывать view к временным объектам |
| Неявное преобразование в конструкторе | Конструктор с одним аргументом без explicit — компилятор делает неявные преобразования в самых неожиданных местах |
Помечать explicit; для C++17 — конструкторы от std::string_view тоже стоит делать explicit |
Долгоживущий weak_ptr к make_shared |
make_shared размещает объект и control block в одном блоке памяти — weak_ptr удерживает весь блок, пока сам жив |
std::shared_ptr<T>(new T(...)) (отдельный control block), либо отказаться от make_shared ради долгих weak_ptr |
| Бросающий деструктор | Деструкторы неявно noexcept — выход исключения из деструктора вызывает std::terminate |
Не бросать из деструктора; ловить и проглатывать все исключения внутри; при необходимости — явно noexcept(false) (редко) |
vector<bool> в auto |
auto b = v[i] выводит тип прокси vector<bool>::reference, а не bool — присваивание и сравнения ведут себя не так, как ожидается |
bool b = v[i]; (неявное преобразование) либо deque<bool> |
Как этим пользоваться. Заведите привычку: перед каждым коммитом — -Wall -Wextra -Wpedantic и прогон тестов под ASan/UBSan (а для многопоточного кода — ещё и под TSan). Санитайзеры находят именно эти ошибки автоматически, и это дешевле, чем ловить их в продакшене.
Практические подсказки, которые ловят сразу несколько пунктов сверху:
- Предпочитайте умные указатели сырым — они закрывают use-after-free, double free, утечки и висячие ссылки в одном флаконе.
- Включайте
-Wreorder,-Wnon-virtual-dtor,-Wstringop-overflow— компилятор укажет на проблемы с порядком инициализации, отсутствием виртуального деструктора и работой со ссылками до компиляции продакшена. - Используйте range-based for с
std::erase_ifвместо ручногоforсerase— исчезает целый класс багов с инвалидацией итераторов. - Если функция возвращает контейнер/строку/объект — не оборачивайте результат в
std::move; доверьтесь NRVO и move-семантике компилятора. - Думайте о времени жизни, а не о «синтаксисе». Вопрос «когда этот объект будет разрушен и кто им владеет?» решает 80 % перечисленных выше проблем.
Ресурсы
Что это. Подборка источников, к которым стоит обращаться, когда встроенных знаний и шпаргалки недостаточно. Здесь собраны инструменты трёх типов: справочники (быстро найти точное описание функции/класса), книги/курсы (понять концепции и идеи языка) и инструменты (увидеть, как код реально работает «под капотом»).
Аналогия из жизни. Представьте, что C++ — это большой город:
cppreference— это карта с указанием каждого дома и его жильцов (точные адреса, правила, жители стандартной библиотеки).- Книги Страуструпа и Мейерса — это путеводители по городу от местных жителей, которые объясняют, как здесь принято жить и почему.
Compiler Explorer— это камера наблюдения, через которую вы смотрите, что делает ваш код прямо сейчас на уровне процессора.- Core Guidelines — это свод правил дорожного движения, который помогает не попасть в аварию.
Почему важно иметь несколько источников. Ни один ресурс не покрывает всё: в cppreference нет «почему так», в книгах — нет актуальных сигнатур за вчерашним стандартом, а компилятор не расскажет о хорошем стиле. Хороший разработчик комбинирует источники.
Практический совет. Заведите привычку: прежде чем спрашивать у коллег или ChatGPT — откройте cppreference. Это быстрее и точнее, а заодно вы тренируетесь читать официальную документацию — навык, который экономит часы в будущем.
cppreference (C++) — самая точная справка по стандарту с примерами. Начните с неё, если не знаете, как работает конкретная функция или контейнер. Это первый источник, к которому обращаются профессионалы. Подсказка: ссылки внизу страницы (
See also) часто ведут на связанные алгоритмы и контейнеры — используйте их для быстрой навигации.Бьёрн Страуструп. «Программирование: принципы и практика использования C++» — учебник от создателя языка. Лучший выбор для старта, если вы только входите в C++. Книга учит не только синтаксису, но и стилю мышления, принятому в C++. Подходит тем, кто уже немного умеет программировать на чём-то ещё.
Бьёрн Страуструп. «Язык программирования C++» (4-е изд.) — полное описание C++11/14. Это настольная книга-справочник для тех, кто хочет знать язык «из первых рук». Тяжёлая, местами академичная, но если что-то непонятно до конца — здесь объяснение и обоснование.
Скотт Мейерс. «Эффективный современный C++» — 42 правила C++11/14, must-read. Книга учит писать идиоматичный код и объясняет тонкости владения ресурсами, move-семантики, лямбд. Очень практичная: каждое правило — реальная проблема из продакшна. Подсказка: даже простое пролистывание оглавления даёт чек-лист того, что стоит проверить в своём коде.
C++ Core Guidelines — правила Страуструпа и Сутера по безопасному C++. Набор рекомендаций с обоснованиями: почему делать так, а не иначе. Удобно использовать как чек-лист при код-ревью. Подсказка: правила помечены профилями (
Pro,Gsl,CppCoreCheck) — для повседневной работы достаточно следовать базовым (Pro.*).AnthonyCalandra/modern-cpp-features — шпаргалка по фичам C++11…23. Сжатый обзор что появилось в каждом стандарте с минимальными примерами. Идеально, чтобы быстро вспомнить: «А как зовут ту новую штуку для проверки наличия метода?». Подсказка: используйте как карту, а не как учебник — для глубокого понимания идите в
cppreference.hackingcpp.com — визуальные шпаргалки по STL и алгоритмам. Если вы любите учиться через схемы и картинки — это ваш ресурс. Удобно распечатать и повесить над столом, чтобы быстро вспомнить иерархию контейнеров или набор алгоритмов.
Compiler Explorer (godbolt) — смотреть ассемблер и поведение оптимизаций. Когда вы сомневаетесь: «А мой
inlineдействительно что-то даёт?», «А компилятор видит мой UB?» — откройте godbolt и проверьте. Подсказка: переключайте уровни оптимизации (-O0,-O2,-O3) — поведение кода может радикально меняться.learncpp.com — подробный бесплатный курс с упражнениями. Отличная альтернатива платным курсам для самостоятельного изучения с нуля до среднего уровня. Материал структурирован по главам, есть контрольные вопросы и задачи.
ISO C++ (WG21) — новости стандарта, черновики, комитет. Сюда стоит заглядывать, чтобы быть в курсе нововведений и понимать, что появится в следующей версии языка. Для повседневной разработки чаще всего не нужен, но очень полезен для общего кругозора.
cppreference: undefined behavior — полный список случаев UB. Держите закладкой. UB (undefined behavior) — главный источник трудноуловимых багов: код компилируется, иногда работает, иногда нет. Этот список поможет понять, какие конструкции опасны и почему компилятор не обязан вас предупреждать.
Godbolt + санитайзеры — ASan, UBSan, TSan, MSan. Санитайзеры — это инструменты динамического анализа, которые ловят ошибки во время выполнения программы:
- ASan (AddressSanitizer) — выходы за границы массива, use-after-free, утечки памяти.
- UBSan (UndefinedBehaviorSanitizer) — целочисленные переполнения, невалидные сдвиги, нарушения выравнивания.
- TSan (ThreadSanitizer) — гонки потоков (data races).
- MSan (MemorySanitizer) — использование неинициализированной памяти.
Подсказка: подключайте санитайзеры в
CMakeLists.txtчерез опции вроде-fsanitize=address,undefinedво время разработки и тестирования — это ловит огромный класс багов почти бесплатно.
Типичные ошибки новичков при работе с ресурсами:
- Сразу лезть в Stack Overflow, не заглянув в
cppreference— в результате получаете устаревшие советы из C++98 вместо современных идиом. - Читать только книги и не подкреплять практикой — без компилятора и реальных задач знания быстро выветриваются. Комбинируйте книгу +
Compiler Explorer+learncpp. - Игнорировать санитайзеры — а ведь именно они ловят 80% типичных UB-багов. Добавьте их в проект с первого дня.
- Пытаться прочесть «Язык программирования C++» Страуструпа от корки до корки за выходные — это справочник, а не роман. Используйте его выборочно, по темам.
Что дальше
Этот раздел — навигатор: здесь нет нового материала по самому C++, только подсказки, куда двигаться дальше. Цели у студентов и практиков обычно три: укрепить фундамент, подготовиться к экзамену или собеседованию, либо посмотреть, как похожие задачи решаются в современных языках. Ниже — три маршрута под эти цели.
Если нужно укрепить базу — начните с Си. C++ вырос из Си, и его «нижний этаж» (модель памяти, указатели, массивы, работа со стеком и кучей) унаследован почти без изменений. Разобравшись в Си, вы автоматически лучше поймёте, что именно делает компилятор C++ и почему многие вещи ведут себя так, а не иначе. Аналогия: C++ — это большой современный дом, а Си — его фундамент и несущие стены; если не понимаете фундамент, сложно понять, почему стены стоят именно так.
Если готовитесь к экзамену — откройте раздел для студентов. Там материал отобран иначе: меньше «почему», больше «что спросят и как правильно написать». Это удобный тренажёр перед контрольной, но не замена нормальному пониманию — преподаватель может повернуть вопрос чуть иначе, и заученная формулировка «не сойдётся».
Если хотите сравнить подходы — посмотрите Rust и Go. Это не замена C++, а два современных ответа на его слабые места: Rust показывает, как сделать безопасную работу с памятью без сборщика мусора (проверки на этапе компиляции), а Go — как проще писать конкурентный код (горутины и каналы вместо ручных потоков). Полезно смотреть на них именно как на «вторую инженерную оптику», а не как на «C++ плохой, они хорошие».
- C++ быстрый справочник конструкций — все конструкции языка на одной странице: синтаксис и примеры без пояснений
- Си (C): большая шпаргалка по синтаксису, указателям и памяти — база, из которой вырос C++
- C++ для студентов: шпаргалка к экзамену — концентрированно о том, что спрашивают
- Си для студентов: шпаргалка к экзамену — синтаксис и классические задачи
- Rust: шпаргалка — владение, заимствование, типы, трейты — альтернатива с проверкой памяти компилятором
- Go: шпаргалка — горутины, каналы, интерфейсы, модули — простой конкурентный язык
Частые ошибки при выборе следующего шага:
- Сразу прыгать в Rust или Go, не закрыв базу по C++. Без твёрдого понимания C++ сравнение языков превращается в спор о вкусах, а не в инженерный разбор — вы просто не видите, какую именно проблему каждый из них решает.
- Зубрить «экзаменационную» шпаргалку вместо понимания темы. Заученный ответ рассыпается, как только вопрос задан чуть иначе; преподаватели это хорошо чувствуют.
- Бояться Си из-за указателей и откладывать их «на потом». Указатель — это просто адрес в памяти плюс тип; разобравшись один раз, вы получаете навык, который работает и в C, и в C++, и в любом низкоуровневом коде. Откладывание только накапливает страх.
- Выбирать следующий язык по принципу «модно», а не по задаче. Rust и Go действительно хороши, но решают разные проблемы — если вам нужен быстрый HTTP-сервис, Go зайдёт легче, а если системная библиотека без утечек памяти — Rust.
Практический совет: не пытайтесь идти по всем маршрутам одновременно. Выберите один — и доведите его до конца. Глубокое понимание хотя бы одного соседнего языка даёт больше, чем поверхностное знакомство с пятью.
Связанные шпаргалки: C++ быстрый справочник, Си, C++ для студентов, Rust, Go