Кладовочка

О разном, чтобы было под рукой онлайн

C++ Core Guidelines на русском: ключевые правила (перевод)

C++ Core Guidelines — это свод правил для современного C++, который поддерживают Бьёрн Страуструп (Bjarne Stroustrup), автор языка, и Херб Саттер (Herb Sutter), председатель комитета по стандартизации C++. Простыми словами, это «гигиенический минимум» C++‑разработчика: набор проверенных сообществом рекомендаций о том, как писать безопасный, читаемый и поддерживаемый код. Если представить C++ как большой и мощный инструмент (вроде бензопилы), то Core Guidelines — это инструкция по технике безопасности и набор best practices, без которых легко «отпилить себе ногу» — получить UB, утечку памяти или код, который невозможно поддерживать.

Документ отвечает на практический вопрос: как писать код, который компилируется, работает предсказуемо, не течёт по памяти и понятен коллегам. За каждым правилом стоит чья‑то реальная ошибка или часы мучительной отладки: язык даёт огромную свободу, и без общих конвенций команда быстро утонет в противоречивых стилях и неопределённом поведении (UB — undefined behavior, ситуации, когда стандарт не определяет, что делает программа, и любой компилятор вправе поступить по‑своему: зависнуть, стереть данные или выдать «мусор»).

Правила делятся на тематические разделы с буквенными префиксами:

  • P (Philosophy, философия) — общие принципы: почему важны простота, явные интерфейсы и владение ресурсами;
  • I (Interfaces, интерфейсы) — как объявлять функции и классы, чтобы ими было удобно пользоваться;
  • F (Functions, функции) — проектирование функций: аргументы, побочные эффекты, разумный размер;
  • C (Classes, классы) — правила работы с классами: инварианты, наследование, виртуальные функции;
  • R (Resources, ресурсы) — управление памятью, файлами, дескрипторами (RAII, умные указатели);
  • ES (Expressions and Statements, выражения и операторы) — как писать выражения, чтобы компилятор мог проверить их статически;
  • E (Errors, ошибки) — обработка ошибок, исключения, коды возврата;
  • CP (Concurrency, конкурентность) — многопоточность, гонки данных, синхронизация;
  • и другие (например, SF, SL, T, Per, N, GSL — в зависимости от версии документа).

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

Практические советы по использованию guidelines:

  • Не пытайтесь выучить все правила сразу — откройте раздел, относящийся к вашей текущей задаче: работаете с памятью — смотрите R, проектируете интерфейс — I, разбираетесь с многопоточностью — CP.
  • Используйте Core Guidelines как чек‑лист при code review: если правило нарушено — спросите себя, есть ли на то осознанная причина, и задокументируйте её.
  • Подключите статические анализаторы (например, clang-tidy с набором проверок cppcoreguidelines-*) — они автоматически подсветят нарушения прямо в редакторе и сэкономят время на разборе.
  • Помните, что это рекомендации, а не закон: в редких случаях осознанное отступление от правила — нормально, но решение должно быть явно прокомментировано в коде.

Типичные ошибки новичков:

  • Воспринимать guidelines как «священное писание» и слепо следовать каждой букве, даже когда правило не относится к контексту: правила — это советы, а не синтаксис языка.
  • Игнорировать раздел P (Philosophy) и сразу прыгать к конкретным приёмам: без понимания «почему» правила превращаются в формальные галочки и быстро забываются.
  • Путать Core Guidelines с официальным стандартом C++: стандарт определяет, что должен делать компилятор, а guidelines — что лучше делать программисту.
  • Забывать, что правила эволюционируют вместе с языком: то, что было актуально для C++11, может быть пересмотрено или уточнено в C++20/23 — заглядывайте в обновления.
  • Читать только английскую версию и не заглядывать в обсуждения (Issues и PR на GitHub): там часто объясняется мотивация правил и границы применимости.

Оригинал: Оригинал (isocpp.github.io) и Исходник на GitHub.

Как пользоваться переводом

Шпаргалка — компактный справочник по ключевым правилам C++ Core Guidelines, документа, который поддерживается сообществом Bjarne Stroustrup и Herb Sutter и считается «каноном» хорошего современного C++. Если оригинал — это большая медицинская энциклопедия, то шпаргалка играет роль памятки первой помощи: здесь собрано только то, что чаще всего спасает код от типичных «болезней» вроде утечек памяти, неопределённого поведения и запутанной логики. Шпаргалка не заменяет оригинал, а помогает быстро вспомнить ключевые принципы в повседневной работе и принять верное решение ещё до того, как баг попадёт в продакшен.

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

Как читать пометки:

  • ID — официальный номер правила: P.1, I.1, R.1, ES.20 и так далее. По ним удобно искать правило в оригинале и в сообщениях линтеров. Буквы в ID — это тематический префикс (P. — Philosophy/философия, I. — Interfaces/интерфейсы, R. — Resource management/ресурсы, ES. — Expressions and Statements/выражения и инструкции), а число — порядковый номер внутри темы. Когда статический анализатор вроде clang-tidy выдаёт предупреждение вида cppcoreguidelines-avoid-goto, первая часть имени чаще всего совпадает с ID правила — так проще всего найти, о чём именно идёт речь.
  • Английское название — название правила в оригинале, чтобы вы могли сверить перевод. Это же название используется в настройках clang-tidy, SonarQube и других инструментах статического анализа, поэтому при настройке IDE или CI ориентируйтесь именно на английский вариант.
  • Русское название — перевод названия правила. Иногда перевод получается «неуклюжим» из-за специфики английской формулировки — в таких местах всегда заглядывайте в оригинал, чтобы понять точный смысл. Если формулировка кажется странной, это сигнал, что в оригинале кроется нюанс, без которого правило легко понять неправильно.
  • Почему — суть обоснования: какую проблему решает правило. Это самая важная колонка для понимания: если вы знаете, почему правило существует, вы сможете применять его осознанно даже в нестандартных ситуациях, где буквальное следование невозможно. Без понимания «почему» правила превращаются в механическое заучивание и начинают конфликтовать друг с другом на практике.
  • Как — практическое указание, что делать в коде. Здесь приводятся короткие «рецепты», которые можно сразу применить. Для сложных случаев обращайтесь к разделу «Пример» в оригинале — там показаны варианты «хорошо/плохо» с пояснениями, почему один код лучше другого.

Правила бывают «жёсткими» (почти всегда обязательны) и «рекомендательными» (зависят от контекста). Жёсткие правила — это в основном те, что защищают от неопределённого поведения, гонок данных и утечек ресурсов: их нарушение почти всегда приводит к багам или крашам. Рекомендательные правила говорят о читаемости и поддерживаемости кода: их можно нарушить осознанно, если этого требует конкретный случай. Отдельные правила помечены в оригинале как исключения; здесь такие тонкости опущены. Типичная ошибка новичков — воспринимать все правила как догмы и пытаться применить их даже там, где они неуместны: в учебных проектах на 50 строк, в одноразовых прототипах или в спортивном программировании. Используйте правила как компас, а не как смирительную рубашку. Ссылки ведут на якоря в опубликованном оригинале — например, правило R.1 находится по адресу isocpp.github.io/CppCoreGuidelines#rr-raii. Заголовки разделов ниже даны на русском с английским оригиналом в скобках.

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

P. Философия (Philosophy)

Что такое «философия» в контексте языка программирования? Это набор базовых принципов, которые объясняют почему в языке принято писать именно так, а не иначе. Этот раздел содержит самые общие правила. Они почти не проверяются автоматически, но именно на них опираются все конкретные и проверяемые правила ниже. Философия документа проста: выражайте намерение прямо в коде, опирайтесь на статическую типизацию, ловите ошибки как можно раньше, не теките ресурсами и не тратьте время и память впустую. Без философской основы частные правила выглядят произвольными запретами, поэтому начинать стоит именно здесь. Эти правила — про мышление, а не про синтаксис, и они задают тон всему документу.

Аналогия из жизни. Представьте правила дорожного движения: «держитесь правой стороны», «не превышайте скорость». Это не технические инструкции к конкретной модели автомобиля — это принципы, которые работают везде. Точно так же философские правила C++ говорят не о синтаксисе for или while, а о том, как мыслить о программе в целом.

P.1. Выражайте идеи непосредственно в коде

Оригинал: P.1 — Express ideas directly in code

Что это значит? Любую идею, которую вы хотите донести до читателя, лучше воплотить в самом коде (через типы, имена, структуру), а не оставлять её только в комментариях или документации.

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

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

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

Как применять на практике:

  • если в комментарии описан закон изменения (единицы измерения, диапазон значений, формат данных) — оформите его как тип, функцию или проверку;
  • если в комментарии описано намерение операции — выразите его через имя функции;
  • если в комментарии описаны инварианты (что всегда должно быть истинно) — зашить их в тип (через constexpr, концепты, классы-обёртки).
// Плохо: смысл зашит в комментарии
int elapsed = 100;   // время ожидания в миллисекундах

// Хорошо: смысл выражен типом
std::chrono::milliseconds timeout{100};

Типичная ошибка: программист пишет комментарий «в секундах», но в коде передаёт миллисекунды, потому что менял логику и забыл обновить комментарий. Тип std::chrono::seconds vs std::chrono::milliseconds такую ошибку делает невозможной — компилятор откажется собирать несовместимые единицы.

P.2. Пишите на стандартном ISO C++

Оригинал: P.2 — Write in ISO Standard C++

Что это значит? Ваш код должен компилироваться любым современным компилятором C++ (GCC, Clang, MSVC и др.) без привязки к расширениям конкретной реализации.

Код должен собираться на стандартном C++, а не на диалекте конкретного компилятора. Расширения компиляторов и нестандартные конструкции затрудняют перенос, ломают инструменты и мешают обновлению.

Почему это важно (развёрнуто): переносимость и предсказуемость важнее локальных удобств. Сегодня вы пишете под GCC, завтра проект переносят на Clang или MSVC, послезавтра появляется новый стандарт C++ — и нестандартные расширения превращаются в технический долг. Кроме того, инструменты анализа (линтеры, форматтеры, статические анализаторы) ориентируются именно на стандарт: нестандартный код они могут либо игнорировать, либо интерпретировать неправильно.

Аналогия из жизни. Если вы пишете деловое письмо на общепринятом литературном языке, его поймут в любой компании. Если же вы используете только местный жаргон, понять вас смогут лишь «свои». Стандарт C++ — это литературный язык программирования.

Как применять на практике:

  • держите флаги стандарта при сборке (-std=c++20, /std:c++20);
  • включайте предупреждения (-Wall -Wextra -Wpedantic или /W4) и рассматривайте предупреждения как ошибки;
  • изолируйте платформенный код за узкими обёртками (Platform Abstraction Layer), чтобы основная масса кода оставалась переносимой;
  • если очень нужно системное расширение — пишите его в отдельном модуле с минимальной площадью соприкосновения.
// Плохо: нестандартная конструкция привязывает код к компилятору
// __attribute__((packed)) struct Packet { /* ... */ };

// Хорошо: переносимый вариант через стандартные поля и сериализацию
struct Packet {
    std::uint32_t id;
    std::uint16_t flags;
};

Типичная ошибка: разработчик использует __attribute__((packed)) или #pragma pack, чтобы «сэкономить» пару байт памяти, и получает невыровненный доступ на некоторых архитектурах (например, ARM), что приводит к падению программы или UB (неопределённому поведению). Стандарт C++ даёт другие, более безопасные способы упаковки данных.

P.3. Выражайте намерение

Оригинал: P.3 — Express intent

Что это значит? Код должен отвечать на вопрос «что мы хотим получить?», а не «как именно компьютер это посчитает?». Намерение должно быть видно на поверхности кода, а не закопано в деталях реализации.

Выражайте, что вы хотите получить, а не как компилятор должен это сделать. Код должен читаться как описание задачи, а не как последовательность трюков.

Почему это важно (развёрнуто): понятное намерение снижает число ошибок и облегчает сопровождение. Когда человек читает чужой (или свой, но написанный год назад) код, он тратит основное время не на понимание алгоритма, а на расшифровку намерения: «зачем здесь этот цикл? что мы тут ищем?». Если намерение видно сразу, время на понимание резко сокращается, а риск неправильно «улучшить» код падает.

Аналогия из жизни. Сравните два рецепта:

  • «Возьмите чашку, насыпьте туда что-то белое и сыпучее, долейте жидкость, помешайте 30 секунд, нагрейте в микроволновке 2 минуты» — это инструкция как.
  • «Возьмите чашку муки, влейте стакан молока, замесите тесто, выпекайте 2 минуты» — это описание намерения (рецепт блинов).

Второй рецепт короче, понятнее и его труднее исполнить неправильно.

Как применять на практике:

  • давайте имена операциям: не process(), а apply_discount() или find_duplicates();
  • используйте стандартные алгоритмы (std::find, std::transform, std::accumulate) вместо самодельных циклов — они уже содержат в имени своё намерение;
  • используйте стандартные типы (std::optional, std::variant, std::span) вместо «магических» комбинаций флагов и указателей.
std::vector<int> v = {3, 1, 2};

// Плохо: намерение теряется в индексах
for (std::size_t i = 0; i < v.size(); ++i) { /* ... */ }

// Хорошо: видно, что мы перебираем элементы
for (int x : v) { /* ... */ }

Типичная ошибка: автор пишет «универсальный» цикл с индексом и через пару месяцев не может вспомнить, зачем i смещался на единицу или почему перебирался не от нуля. Range-based for устраняет эти вопросы: видно, что мы просто идём по элементам.

P.4. Программа должна быть статически типобезопасной

Оригинал: P.4 — Ideally, a program should be statically type safe

Что такое «статическая типобезопасность»? Это свойство программы, при котором компилятор до запуска отвергает все операции, способные привести к ошибкам типов (например, складывание строки с числом, разыменование нулевого указателя, выход за границы массива).

В идеале компилятор должен ловить все несоответствия типов. На практике остаются небезопасные области — сужение при преобразованиях, выход за границы массива, разыменование nullptr, целочисленное переполнение. Их нужно минимизировать, а не игнорировать.

Почему это важно (развёрнуто): ошибки типов, найденные компилятором, не доходят до продакшена. Они не зависят от входных данных, времени суток или фазы луны — компилятор отказывается собирать программу. Это самый дешёвый и самый надёжный класс ошибок. Чем больше «проверок» мы можем перенести из рантайма в компайл-тайм, тем стабильнее программа.

Аналогия из жизни. Типобезопасность — это как маркировка «яд» / «не яд» на бутылочках с реактивами в лаборатории. Если маркировка есть, лаборант не перепутает кислоту с водой. Если маркировки нет — катастрофа вопрос времени.

Как применять на практике:

  • применяйте типы-обёртки (Meter, Second, Dollar), чтобы запретить бессмысленные операции;
  • используйте enum class вместо «голых» целых констант;
  • заменяйте сырые указатели T* на std::span<T>, gsl::not_null<T*>, std::optional<T>;
  • включайте предупреждения о неявных сужающих преобразованиях (-Wnarrowing, -Wconversion).
// Плохо: легко перепутать параметры и получить бессмыслицу
void draw(int x, int y, int w, int h);

// Хорошо: тип делает намерение проверяемым
struct Point { int x; int y; };
struct Size  { int width; int height; };
void draw(Point p, Size s);

Типичная ошибка: вызов draw(10, 20, 30, 40) — компилятор не отловит перепутывание параметров w и h (это просто четыре int). С типами Point и Size перепутать «точку» и «размер» невозможно: они разные типы.

P.5. Предпочитайте проверки времени компиляции проверкам времени выполнения

Оригинал: P.5 — Prefer compile-time checking to run-time checking

Что такое проверки времени компиляции? Это проверки, которые компилятор выполняет на этапе сборки программы: проверка типов, constexpr-вычисления, static_assert, концепты, SFINAE. Они не добавляют накладных расходов в рантайме.

Ошибку лучше находить при сборке, чем при запуске. Статическая типизация, constexpr, концепции и шаблоны позволяют перенести часть проверок в компилятор.

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

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

Как применять на практике:

  • заменяйте магические числа именованными константами constexpr;
  • кодируйте инварианты в типах (через шаблоны, концепты, std::integral_constant);
  • используйте static_assert для проверки допущений прямо в коде;
  • выносите вычисления в constexpr-функции — пусть компилятор их посчитает за вас.
constexpr int max_size = 1024;

static_assert(max_size > 0, "Размер буфера должен быть положительным");

// Компилятор сам поймает выход за диапазон известного массива
constexpr int table[3] = {1, 2, 3};
static_assert(table[2] == 3);

Типичная ошибка: разработчик проверяет границы массива или корректность константы через assert() или if в рантайме. Но если данные известны на этапе компиляции, лучше static_assert — проверка отработает один раз при сборке и больше не будет тратить процессорное время.

P.6. То, что нельзя проверить при компиляции, должно проверяться при выполнении

Оригинал: P.6 — What cannot be checked at compile time should be checkable at run time

Что это значит? Если какое-то условие принципиально нельзя проверить при сборке (потому что оно зависит от внешних данных), то в коде должна быть явная проверка в рантайме. «Не проверено нигде» — самый плохой вариант.

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

Почему это важно (развёрнуто): внешние данные нельзя считать доверенными. Даже если ваш фронтенд «всегда» отправляет валидный JSON, в реальности там окажется мусор: пользователь подправил запрос, диск повреждён, сеть обрезала пакет. Программа, которая не проверила вход и продолжила работать как ни в чём не бывало, превращает мелкий сбой в катастрофу. Лучше вовремя отказать и вернуть понятную ошибку, чем молча породить бессмысленный результат.

Аналогия из жизни. Контролёр в метро не может проверить билет заранее — он делает это при посадке. Если бы контролёра не было, безбилетников стало бы больше, но это полбеды: без проверок система не знала бы даже, насколько велика проблема, и не могла бы принять решения.

Как применять на практике:

  • проверяйте инварианты в начале функции (assert, if (!ok) throw …);
  • валидируйте входные данные и возвращайте понятную ошибку (std::optional, std::expected, исключения с информативным сообщением);
  • используйте безопасные парсеры (std::from_chars) вместо небезопасных (atoi, scanf).
std::optional<int> parse_port(std::string_view s)
{
    int value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || value < 1 || value > 65535) {
        return std::nullopt;   // некорректный порт
    }
    return value;
}

Типичная ошибка: использовать atoi или stoi, которые не сообщают об ошибке (просто возвращают 0 при неудаче) и не проверяют диапазон. После такого вызова программа «успешно» откроет порт 0 или вовсе передаст -1 сетевому слою, а разработчик не получит никакого предупреждения.

P.7. Ловите ошибки времени выполнения как можно раньше

Оригинал: P.7 — Catch run-time errors early

Что значит «рано»? Это значит: как можно ближе к тому месту, где ошибка возникла. Не через 100 строк, не через минуту работы, не после записи в базу — а сразу, в той же функции, желательно в той же строке.

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

Почему это важно (развёрнуто): чем позже обнаружена ошибка, тем больше «побочного ущерба» она успела нанести и тем сложнее установить причину. Если ошибка найдена в момент её возникновения, у вас на руках полный контекст: стек вызовов, значения переменных, состояние системы. Если та же ошибка всплывает через час в логах продакшена, у вас есть только «что-то сломалось» и пользовательский отчёт.

Аналогия из жизни. Датчик дыма в кухне срабатывает сразу, как только начинает подгорать еда — и вы просто выключаете плиту. Если бы датчик срабатывал только тогда, когда уже загорелась мебель, тушить пришлось бы пожарным.

Как применять на практике:

  • проверяйте результат операции сразу после её выполнения, не откладывайте «на потом»;
  • используйте RAII и деструкторы, чтобы освобождение ресурса срабатывало независимо от того, где возникла ошибка;
  • в критичных местах добавляйте логирование с контекстом: «что мы делали», «с какими данными», «что пошло не так»;
  • предпочитайте «fail-fast» стратегию: если состояние неконсистентно — падайте сразу, а не продолжайте «как-то работать». Что такое предусловие и «быстрый отказ» (fail-fast)? Предусловие — это требование, которое должно выполняться на входе в функцию или операцию: указатель не nullptr, индекс не выходит за границы массива, файл существует и доступен для чтения. Принцип fail-fast говорит: если условие нарушено — сообщай об этом сразу, не пытайся «доработать» молча и не откладывай реакцию. Это как повар, который пробует блюдо на каждом шаге, а не узнаёт о пересоле уже от гостя за столом: чем дальше ошибка от причины, тем дороже её искать.

Почему: чем раньше найден дефект, тем дешевле исправление. Как: проверяйте предусловия и результаты вызовов сразу, не откладывайте проверку «на потом».

// Плохо: ошибка всплывёт далеко от источника
auto file = open_file(path);
read_all(file);

// Хорошо: проверяем результат немедленно
auto file = open_file(path);
if (!file) {
    throw std::runtime_error("Не удалось открыть файл");
}

Совет. Проверяйте входные данные на границе модуля — там, где ваш код встречается с внешним миром (пользовательский ввод, сеть, файлы). Внутри модуля доверяйте собственным инвариантам: лишние проверки только зашумляют логику и замедляют код.

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

P.8. Не допускайте утечек ресурсов

Оригинал: P.8 — Don't leak any resources

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

Аналогия. Арендуете дрель в мастерской: взяли — поработали — вернули. Забыли вернуть — в следующий раз дрели не будет, и вам выставят счёт. RAII в C++ работает так же: Resource Acquisition Is Initialization — «получение ресурса есть инициализация». Ресурс привязывается к объекту, и когда объект выходит из области видимости (даже из-за исключения), его деструктор автоматически возвращает ресурс.

Ресурсы — это не только память, но и файловые дескрипторы, сокеты, блокировки, соединения. Любой ресурс нужно освобождать; в C++ это делается автоматически через деструкторы и RAII.

Почему: утечка блокировки или дескриптора может остановить всю программу. Как: оборачивайте ресурсы в классы с деструктором, не пишите ручной free/close.

{
    std::lock_guard<std::mutex> lock{mtx};   // заперли
    do_work();
}   // деструктор автоматически отпустит блокировку, даже при исключении

Совет. Для самых частых ресурсов в стандартной библиотеке уже есть готовые RAII-обёртки:

  • память — std::unique_ptr, std::shared_ptr;
  • мьютексы — std::lock_guard, std::unique_lock, std::scoped_lock;
  • файлы — std::fstream (закрывается в деструкторе);
  • сокеты и внешние ресурсы — std::unique_ptr с пользовательским Deleter.

Типичные ошибки.

  1. Ручной delete/free без обработки исключений между «получили» и «освободили».
  2. Двойное освобождение: один путь кода освободил ресурс, другой — тоже пытается.
  3. Забытый close()/unlock() в одной из веток if/else — достаточно одной такой ветки, чтобы при определённых входных данных случилась утечка.

P.9. Не тратьте время и память впустую

Оригинал: P.9 — Don't waste time or space

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

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

Программа должна решать задачу за разумное время и в разумном объёме памяти. Преждевременная оптимизация вредна, но сознательное расточительство — тоже ошибка.

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

void consume(std::vector<int> data);        // копия — лишние затраты
void consume(const std::vector<int>& data); // без копии
void consume(std::vector<int>&& data);      // принимаем владение без копии

Совет. Перед оптимизацией — измерьте. Используйте профилировщики (perf, VTune, Tracy, gprof) или хотя бы <chrono>. Часто «горячая» функция оказывается не там, где кажется интуитивно. Правило «преждевременная оптимизация — корень всех бед» (Дональд Кнут) не отменяет выбора подходящих структур данных и алгоритмов с самого начала.

Типичные ошибки.

  1. Передача больших контейнеров по значению вместо const& — каждое обращение копирует весь вектор.
  2. Линейный поиск в больших коллекциях там, где уместна std::unordered_map или std::unordered_set.
  3. std::endl вместо "\n": endl ещё и сбрасывает буфер (flush), что в цикле на миллионы итераций ощутимо замедляет вывод.

P.10. Предпочитайте неизменяемые данные изменяемым

Оригинал: P.10 — Prefer immutable data to mutable data

Определение. Изменяемые (mutable) данные — те, содержимое которых можно поменять после создания. Неизменяемые (immutable) — те, что после создания «заморожены»: их нельзя случайно (или намеренно) модифицировать. В C++ неизменяемость выражается через const, constexpr, а в более строгом виде — через типы вроде Immutable<T> или шаблонные решения с const.

Аналогия. Фотография в рамке — это неизменяемые данные: что на ней напечатано, уже не сотрёшь. А надпись мелом на доске — изменяемые данные: любой проходящий может её изменить или стереть. Чем больше в доме распечатанных документов (а не записей мелом), тем меньше шансов, что важная информация «пропадёт» из-за случайного движения руки.

Неизменяемые данные проще анализировать: их не нужно защищать блокировками и сложно испортить по неосторожности. Чем меньше в программе изменяемого состояния, тем меньше возможных ошибок.

Почему: изменяемое состояние — источник гонок и логических ошибок. Как: объявляйте const, используйте constexpr, избегайте глобальных изменяемых переменных.

// Плохо: кто угодно может изменить
std::vector<int> config;

// Хорошо: доступна только для чтения
const std::vector<int> config = load_config();

Совет. Привычка объявлять всё, что можно, как const — это так называемая const-корректность. Она помогает компилятору: если вы случайно попытаетесь изменить const-переменную, компилятор сразу сообщит об ошибке. Многие баги, связанные с неожиданной модификацией параметра или поля, исчезают сами собой после расстановки const.

Типичные ошибки.

  1. «Спасение» от const через const_cast — почти всегда признак ошибки в архитектуре, а не в типе.
  2. Возврат ссылки или указателя на внутреннее состояние объекта: вы отдали «ручку» к изменяемым данным наружу и потеряли контроль.
  3. Глобальные переменные без const — один поток записывает, другой читает, и появляются гонки данных.

P.11. Инкапсулируйте «грязные» конструкции, а не размазывайте их по коду

Оригинал: P.11 — Encapsulate messy constructs, rather than spreading through the code

Определение. В этом контексте инкапсуляция — «обёртывание» сложной или опасной логики в аккуратный интерфейс, за которым детали спрятаны. Бизнес-код работает с понятными функциями и типами и не знает, что внутри творится «кухня» с указателями, ручными буферами и системными вызовами.

Аналогия. В ресторане посетитель не видит, как повар ругается, жарит и режет — он получает чистую тарелку с красиво оформленным блюдом. Если бы вся «кухня» была выставлена в зал, посетители путались бы и спотыкались о кастрюли. То же и в коде: «кухню» собирают в одном файле, классе или модуле, а наружу торчит только аккуратное меню — публичный интерфейс.

Низкоуровневый или опасный код (ручная работа с памятью, системные вызовы, вычисления указателей) неизбежен, но он должен быть скрыт за чистым интерфейсом. Пусть «грязь» живёт в одном месте, а остальной код пользуется понятными функциями.

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

// Грязно и размазано: разыменование сырого буфера в бизнес-логике
char* raw = get_buffer();
process(raw[0], raw[1]);

// Чисто: обёртка прячет детали
struct Buffer {
    std::span<char> span;
};
process(buffer_view.first(2));

Совет. Идиома pImpl (Pointer to Implementation) — классический приём инкапсуляции: в заголовочном файле остаётся только «ручка» (умный указатель на внутренний класс), а все детали реализации прячутся в .cpp. Это ускоряет инкрементальную компиляцию и защищает ABI.

Типичные ошибки.

  1. Отдавать в интерфейс сырые указатели и std::vector<char> вместо абстракции (std::span, std::string_view, собственный класс Buffer).
  2. Реализовывать бизнес-логику прямо в обёртках над системными вызовами — границы «кухни» размываются.
  3. Пробрасывать «сырые» исключения из низкоуровневого слоя наверх вместо того, чтобы преобразовать ошибку в понятный тип на границе модуля.

P.12. Используйте поддерживающие инструменты

Оригинал: P.12 — Use supporting tools as appropriate

Определение. Инструменты проверки кода условно делят на три уровня:

  1. Статический анализ — проверка кода без запуска: компилятор с предупреждениями, clang-tidy, cppcheck, SonarQube. Находят синтаксические ошибки, подозрительные конструкции, нарушения стиля.
  2. Динамический анализ — проверка во время выполнения: санитайзеры (ASan, UBSan, TSan, MSan) ловят выходы за границы, гонки, утечки уже в рантайме.
  3. Профильные «проверяльщики» правил — например, набор чеков для C++ Core Guidelines в clang-tidy.

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

Часть правил не может быть проверена человеком надёжно: утечки памяти, гонки, выход за границы. Для этого есть компиляторные предупреждения, статические анализаторы, санитайзеры и специальные «проверяльщики» Core Guidelines.

Почему: инструменты находят то, что пропускает глаз. Как: включите максимум предупреждений, clang-tidy, ASan/UBSan/TSan в тестовых сборках, статический анализ в CI.

# Пример набора инструментов для проверки кода
clang++ -std=c++20 -Wall -Wextra -Wpedantic -fsanitize=address,undefined app.cpp
clang-tidy app.cpp --checks='cppcoreguidelines-*'

Совет. Не оставляйте предупреждения «на потом» — они накапливаются, как непрочитанные письма. Включите в проекте флаг -Werror хотя бы для CI-сборки: одно предупреждение — и сборка падает. Санитайзеры включайте в тестовых сборках и регулярно прогоняйте тесты — в релизной сборке они заметно замедляют программу и увеличивают расход памяти.

Типичные ошибки.

  1. Игнорировать предупреждения компилятора («это же не ошибка»). Со временем их сотни, и среди них легко пропустить настоящую проблему.
  2. Включать санитайзеры только разово «ради эксперимента», а не как часть регулярного прогона тестов.
  3. Использовать один и тот же набор чеков на все случаи жизни: для встраиваемой системы и для серверного приложения требования и ограничения разные.

P.13. Используйте библиотеки поддержки

Оригинал: P.13 — Use support libraries as appropriate

Определение. Библиотека поддержки — это готовый, протестированный сообществом код для типовых задач: форматирование строк, работа с диапазонами, безопасные обёртки над указателями, многопоточность, сериализация, работа с датой и временем. Использовать такую библиотеку — значит получить чужой опыт и тесты «в коробке».

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

Стандартная библиотека — не единственный источник готовых решений. Хорошо поддерживаемые библиотеки (GSL, range-v3, fmt, Boost) экономят время и содержат проверенный код.

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

#include <gsl/gsl>

// GSL даёт типы для выражения контрактов: not_null, span, narrow_cast
void process(gsl::not_null<Widget*> widget, gsl::span<char> buffer);

Совет. При выборе сторонней библиотеки обращайте внимание на:

  • Активность разработки: давно ли был последний релиз, отвечают ли мейнтейнеры на issues.
  • Лицензию: совместима ли она с лицензией вашего проекта (MIT/BSD/Apache подходят для большинства; GPL иногда не подходит для проприетарного кода).
  • Зависимости: не тянет ли библиотека за собой ещё десяток других.
  • Документацию и тесты: хорошие библиотеки покрывают код тестами и имеют понятные примеры.

Типичные ошибки.

  1. NIH-синдром (Not Invented Here): «своё всегда лучше». На практике приводит к месяцам отладки уже решённых сообществом задач.
  2. Подключить библиотеку и забыть обновить — через пару лет она превращается в «технический долг» с известными уязвимостями.
  3. Игнорировать разницу лицензий: иногда это юридическая проблема, а не просто строчка в шапке файла.

I. Интерфейсы (Interfaces)

Интерфейс — это контракт между двумя частями программы. Точно сказать, что поставщик услуги ожидает от пользователя и что пользователь может ожидать от услуги, — обязательно. Хорошие интерфейсы (понятные, поощряющие эффективное использование, не провоцирующие ошибки, удобные для тестирования) — вероятно, самый важный аспект организации кода. Ключевые идеи: делайте интерфейс явным, точно типизируйте данные, фиксируйте предусловия и постусловия, не передавайте владение через сырые указатели. В этом разделе также о том, как избегать глобальных изменяемых переменных и синглтонов и почему интерфейсы-шаблоны стоит документировать концепциями.

I.1. Делайте интерфейсы явными

Оригинал: I.1 — Make interfaces explicit

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

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

// Плохо: зависимость спрятана в глобальной переменной
int global_limit = 10;
int compute() { return global_limit * 2; }

// Хорошо: зависимость видна в сигнатуре
int compute(int limit) { return limit * 2; }

I.2. Избегайте не-const глобальных переменных

Оригинал: I.2 — Avoid non-const global variables

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

Почему: глобальное изменяемое состояние — источник трудноуловимых ошибок. Как: делайте глобальные данные const или constexpr, а изменяемое состояние передавайте явно.

// Плохо
int counter;   // изменяемая глобальная переменная

// Хорошо
constexpr int max_retries = 3;   // константа времени компиляции

I.3. Избегайте синглтонов

Оригинал: I.3 — Avoid singletons

Синглтон маскирует глобальную переменную под функцию доступа. Он создаёт те же проблемы: скрытые зависимости, трудности с тестами, неопределённый порядок инициализации и разрушения.

Почему: синглтон — это глобальное состояние с интерфейсом объекта. Как: передавайте объект явно или используйте внедрение зависимостей.

// Плохо: скрытая глобальная зависимость
auto& db() { static Database instance; return instance; }

// Хорошо: зависимость приходит снаружи
class Service {
public:
    explicit Service(Database& db) : db_{db} {}
private:
    Database& db_;
};

I.4. Делайте интерфейсы точно и строго типизированными

Оригинал: I.4 — Make interfaces precisely and strongly typed

Типы — это документация, которую проверяет компилятор. double для температуры, int для размера в байтах и bool для флага — не одно и то же; смешение приводит к ошибкам.

Почему: строгие типы ловят ошибки ещё при сборке. Как: создавайте типы-обёртки для физических величин и идентификаторов.

// Плохо: легко перепутать метры и секунды
double speed(double distance, double time);

// Хорошо: типы не дадут перепутать величины
struct Meters { double value; };
struct Seconds { double value; };
double speed(Meters d, Seconds t);

I.5. Указывайте предусловия

Оригинал: I.5 — State preconditions (if any)

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

Почему: без явных предусловий ответственность размывается. Как: документируйте их и по возможности проверяйте через Expects() или assert.

// Предусловие: p указывает на объект, и n >= 0
void fill(int* p, int n)
{
    Expects(p != nullptr);
    Expects(n >= 0);
    // ...
}

I.7. Указывайте постусловия

Оригинал: I.7 — State postconditions

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

Почему: постусловия формируют контракт результата. Как: документируйте гарантии и проверяйте их через Ensures().

// Постусловие: результат >= 0
int abs_value(int x)
{
    int result = x < 0 ? -x : x;
    Ensures(result >= 0);
    return result;
}

I.9. Если интерфейс — шаблон, документируйте его параметры с помощью концепций

Оригинал: I.9 — If an interface is a template, document its parameters using concepts

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

template <std::integral T>
T sum(const std::vector<T>& values)
{
    T result{};
    for (T v : values) result += v;
    return result;
}

sum(std::vector<double>{1.0});   // ошибка: double не integral

Зачем это нужно: концепция (concept) — это именованный набор требований к типу, который компилятор умеет проверять. До C++20 о том, что шаблон ждёт «что-то, умеющее складываться», писали в комментарии, и компилятор не мог это проконтролировать. С концепцией требование становится частью сигнатуры шаблона и проверяется так же строго, как типы обычных параметров.

Аналогия из жизни: представьте табличку «вход только в тапочках». Без концепции это вежливая просьба в комментарии — посетитель может зайти в ботинках, и охранник заметит это только в момент, когда уже испортил паркет. С концепцией это правило, вшитое в турникет: в ботинках физически не пройдёшь.

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

Как применять:

  • Для каждого шаблона формулируйте требования как можно жёстче: не «что-то итерируемое», а «контейнер с value_type и size()».
  • Не дублируйте в комментариях то, что уже сказано концепцией.
  • Избегайте «концепций-помоек» вроде requires true — они не дают никакого контракта.

Типичная ошибка: использовать слишком слабую концепцию (например, std::integral подходит, а std::signed_integral подошёл бы точнее) — это формально верно, но оставляет «дыру» для неожиданных типов вроде bool, у которого операции сложения ведут себя не так, как у чисел.

I.10. Используйте исключения, чтобы сообщить о невозможности выполнить задачу

Оригинал: I.10 — Use exceptions to signal a failure to perform a required task

Что это значит: исключение в C++ — это механизм, при котором функция «кричит» вызывающему коду: «я обещала сделать X, но не смогла, разбирайся». Если функция приняла на себя ответственность за задачу (открыть файл, установить соединение, посчитать значение) и не выполнила её, это ненормальная ситуация, и её нужно сигнализировать исключением, а не возвратом «магического» значения.

Это позволяет вызывающему коду отделить обработку ошибок от основной логики и не забыть проверить код возврата.

Аналогия из жизни: вы заказали в кафе пиццу. Если кухня не может её приготовить (нет ингредиентов, сломалась печь), официант не принесёт вам пустую тарелку и не скажет «у нас код ошибки -1, посмотрите сами». Он вернётся и скажет: «Извините, пиццы не будет». Вы решаете, заказать что-то другое или уйти. Исключение — это именно такой «возврат» с объяснением, что что-то пошло не так.

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

Как применять:

  • Бросайте исключение при невозможности выполнить задачу.
  • Не используйте исключения для обычного управления потоком (например, «конец файла» — это не ошибка, для этого есть std::end итераторы или штатный возврат).
  • Выбирайте тип исключения по смыслу: std::invalid_argument для плохих параметров, std::out_of_range для выхода за границы, std::runtime_error для ошибок ввода-вывода и внешних систем.
std::string read_config(const std::string& path)
{
    std::ifstream f{path};
    if (!f) {
        throw std::runtime_error("Не удалось открыть " + path);
    }
    return std::string(std::istreambuf_iterator<char>{f}, {});
}

Типичные ошибки:

  • Глотать исключения пустым catch (...) {} — это хуже, чем коды возврата, потому что скрывает проблему.
  • Бросать исключения в деструкторах (C++ по умолчанию завершит программу, если деструктор выбрасывает во время раскрутки стека).
  • Использовать исключения как замену if-проверкам: «попробую открыть, а если не откроется — поймаю» вместо проверки условий перед вызовом, когда условия можно проверить заранее.

I.11. Никогда не передавайте владение через сырой указатель или ссылку

Оригинал: I.11 — Never transfer ownership by a raw pointer (T*) or reference (T&)

Что такое владение: в C++ под владением понимается ответственность за освобождение ресурса (памяти, файлового дескриптора, соединения). Кто «владеет» объектом, тот обязан его уничтожить. Сырой указатель T* не несёт этой информации — это просто адрес.

Сырой указатель не говорит, кто обязан освободить объект. Владение должно выражаться типом: unique_ptr для единственного владельца, shared_ptr для общего владения.

Аналогия из жизни: передать ключи от квартиры — это одно, а просто продиктовать адрес — совсем другое. По адресу непонятно, дали ли вам право заходить, должны ли вы поливать цветы или просто знаете, где живёт друг. А unique_ptr<Ключи> — это прямо вложенные в руку ключи с пояснением «вы теперь хозяин».

Почему так: неясное владение приводит к двум самым неприятным классам ошибок в C++:

  1. Утечка памяти — никто не вызвал delete, объект жил вечно.
  2. Двойное освобождение — два места решили, что каждый из них владелец, и вызвали delete на одном и том же указателе; это неопределённое поведение и частый источник падений и дыр в безопасности.

Как применять:

  • Возвращайте и принимайте умные указатели, когда передаётся право владения.
  • Используйте std::unique_ptr<T> по умолчанию — он бесплатен по памяти и почти бесплатен по скорости.
  • Переходите к std::shared_ptr<T> только тогда, когда владение действительно разделяемое (общий кэш, граф объектов со взаимными ссылками).
  • Сырой указатель и ссылка отлично подходят для наблюдения (не владея), но не для передачи права собственности.
// Плохо: кто должен удалить возвращённый объект?
Widget* create();

// Хорошо: владение выражено типом
std::unique_ptr<Widget> create();

Типичные ошибки:

  • Возвращать shared_ptr, когда unique_ptr достаточно: shared_ptr платный (атомарный счётчик ссылок) и часто не нужен.
  • Делать shared_ptr из this внутри класса, не наследующего std::enable_shared_from_this — это UB.
  • Смешивать владение и наблюдение: если функция принимает Widget* и где-то внутри делает delete, это нарушение контракта.

I.12. Помечайте указатель, который не может быть нулевым, как not_null

Оригинал: I.12 — Declare a pointer that must not be null as not_null

Что это значит: not_null<T*> (из Guidelines Support Library, GSL) — это тонкая обёртка над указателем, которая несёт в типе обещание «я не nullptr». Это документирование намерений на уровне системы типов.

Если функция требует ненулевой указатель, это должно быть видно в её сигнатуре. not_null<T> из GSL делает требование частью типа и защищает от случайной передачи nullptr.

Аналогия из жизни: на входе в метро стоит турникет. Без него формально можно пройти и без билета, но охрана может не заметить. Турникет не пропускает без билета физически. not_null — это такой же «турникет в типе»: попытка передать nullptr будет поймана на этапе компиляции или сразу при выполнении, а не в самом неожиданном месте через час работы.

Почему так: проверки if (p == nullptr) return; в начале каждой функции раздражают, легко забываются и приводят к раздуванию кода. Хуже того, такие проверки часто прячут логические ошибки: если nullptr «невозможен» по задумке автора, но всё равно приходит, это сигнал о баге в архитектуре, а не повод для тихой обработки. Явный контракт через not_null заставляет относиться к такой ситуации как к настоящей ошибке.

Как применять:

  • Используйте not_null для обязательных указателей.
  • Применяйте not_null к параметрам функций и к полям классов, которые по смыслу не могут быть пустыми.
  • Не используйте not_null там, где nullptr — допустимое значение (например, в полях, которые представляют «необязательную зависимость»).
void process(not_null<Widget*> w);   // w гарантированно не nullptr

// Вызов с nullptr не скомпилируется / упадёт сразу
process(nullptr);   // ошибка

Типичные ошибки:

  • Применять not_null ко всему подряд «для безопасности» — это зашумляет API и противоречит правилу «nullptr — допустимое значение, если так задумано».
  • Полагаться только на проверки в рантайме: not_null хорош тем, что защищает обе стороны — и компилятор, и исполнение.
  • Не оборачивать результат new в not_null — new в C++ по умолчанию бросает std::bad_alloc при нехватке памяти и никогда не возвращает nullptr, так что там not_null избыточен.

I.13. Не передавайте массив как одиночный указатель

Оригинал: I.13 — Do not pass an array as a single pointer

В чём проблема: указатель в C++ не хранит размер того, на что указывает. Когда вы передаёте (T*, size_t) как два параметра, между ними нет связи на уровне типов — компилятор не может проверить, что размер соответствует реальному буферу.

Пара (указатель, количество) легко рассинхронизировать: комментарий «n — число элементов» компилятор не читает. Для последовательностей используйте span или контейнеры.

Аналогия из жизни: передать коллеге лист бумаги с надписью «здесь 10 страниц» — но реально вложить 7. Без пересчёта это выяснится только в момент, когда на 8-й странице что-то важное пропадёт. std::span — это такой «пронумерованный контейнер»: размер хранится вместе с данными и не может разъехаться с реальностью.

Почему так: потеря размера ведёт к выходу за границы буфера (buffer overflow/underflow) — классической уязвимости, через которую ломают программы. Это одна из главных причин переполнений в C и C++ коде.

Как применять:

  • Передавайте std::span<T>, std::vector<T>& или другой контейнер.
  • Для read-only данных — std::span<const T>.
  • std::span хорош тем, что не владеет памятью (как и сырой указатель), но знает размер и поддерживает диапазонный for.
  • Для строк фиксированного размера рассмотрите std::string_view.
// Плохо: размер легко указать неверно
void sum(const int* p, int n);

// Хорошо: размер хранится вместе с данными
void sum(std::span<const int> values);

Типичные ошибки:

  • Использовать T* + n и потом считать n «по привычке из C», хотя пишете на современном C++.
  • Забывать, что std::array<T, N> — это контейнер фиксированного размера, и его размер доступен через .size(), в отличие от C-массива, который «деградирует» до указателя при передаче в функцию.
  • Передавать std::vector<T>& без const, когда данные только читаются — это разрешает вызывающему коду случайно изменить содержимое.

I.22. Избегайте сложной инициализации глобальных объектов

Оригинал: I.22 — Avoid complex initialization of global objects

Что это значит: «глобальный объект» — это переменная со статическим временем жизни (глобальная, в пространстве имён, статическая в классе/файле). В C++ такие объекты создаются до вызова main, и порядок их инициализации между единицами трансляции не определён стандартом.

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

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

Почему так: это называется «static initialization order fiasco» — реальный источник трудноуловимых багов. Программа может работать в отладочной сборке, падать в релизной, зависеть от того, в каком порядке линкер расположит объектные файлы. Воспроизводится редко, выглядит как мистика.

Как применять:

  • Заменяйте глобальные объекты функциями, создающими объект при первом обращении.
  • Передавайте зависимости явно, а не подтягивайте их из глобального состояния.
  • Используйте шаблон «Meyers singleton» — функция, возвращающая static локальную переменную; компилятор гарантирует безопасную (начиная с C++11) инициализацию при первом вызове.
  • Если без глобального состояния не обойтись, делайте его инициализацию максимально простой — «дешёвые» типы вроде int, пустые структуры, константы.
// Плохо: порядок инициализации не определён
Config global_config = load_config();

// Хорошо: инициализация при первом использовании в нужном порядке
Config& config()
{
    static Config instance = load_config();
    return instance;
}

Типичные ошибки:

  • Делать Meyers singleton не потокобезопасным вручную через static std::mutex и lock_guard — в C++11 и позже локальная static уже потокобезопасна, двойная защита вредна и снижает производительность.
  • Держать в глобальных объектах «тяжёлые» ресурсы (открытые файлы, соединения) — их время жизни неочевидно, и при завершении программы порядок уничтожения тоже может быть сюрпризом.
  • Считать, что «constexpr-объекты безопасны» — да, их инициализация проста и предсказуема, но как только появляется нетривиальный конструктор с побочными эффектами, безопасность теряется.

I.23. Держите число аргументов функции небольшим

Оригинал: I.23 — Keep the number of function arguments low

Что считается «небольшим» числом: практический ориентир — 3–4 аргумента. Больше — сигнал, что функция делает слишком много или параметры логически связаны и просятся в отдельную сущность.

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

Аналогия из жизни: форма заказа в интернет-магазине. Когда вы заполняете 5–6 полей (имя, адрес, телефон, способ доставки) — это удобно. Когда форма требует 20 полей вперемешку с датой, цветом упаковки и пожеланиями курьеру — хочется закрыть вкладку. Параметры функции — это такая же форма для вызывающего кода: чем их меньше и чем они логичнее сгруппированы, тем проще пользоваться.

Почему так: длинные списки параметров провоцируют ошибки при вызове:

  • Аргументы одного типа (int x, int y, int width, int height) легко переставить местами — компилятор не заметит.
  • При рефакторинге (добавили параметр, изменили порядок) ломаются все вызовы, и не все из них находятся сразу.
  • Сигнатура становится шумной и плохо читается в подсказках IDE.

Как применять:

  • Группируйте связанные данные в структуры, передавайте их по ссылке.
  • Если у параметров одинаковый тип и они могут перепутаться — это почти всегда причина завести структуру.
  • Передавайте крупные объекты по const&, чтобы не платить за копирование.
  • Если функция имеет 7+ параметров, задумайтесь: возможно, это две разные функции, или параметры нужно сгруппировать в «конфиг» и «входные данные».
// Плохо: легко перепутать порядок
void draw(int x, int y, int width, int height, int border, int color);

// Хорошо: связанные параметры сгруппированы
struct Rect { int x, y, width, height; };
struct Style { int border; int color; };
void draw(Rect rect, Style style);

Типичные ошибки:

  • Группировать в структуру всё подряд «для порядка» — если параметры логически не связаны, лишняя абстракция только мешает.
  • Создавать структуру ради одного параметра — это уже антипаттерн «паттерн для галочки».
  • Передавать параметры через std::tuple или пары — теряются имена, и ситуация становится хуже, чем была.
  • Оставлять длинный список параметров «по историческим причинам» — рефакторинг таких API обычно не такой страшный, как кажется, и почти всегда окупается.

I.25. Предпочитайте пустые абстрактные классы как интерфейсы иерархиям классам

Оригинал: I.25 — Prefer empty abstract classes as interfaces to class hierarchies

Простое определение. Абстрактный класс — это класс, у которого есть хотя бы одна чисто виртуальная функция (объявленная с = 0 и без тела). Такой класс нельзя создать напрямую: он существует только для того, чтобы от него наследовались. Интерфейс в C++ — это договор: «любой наследник обязуется уметь делать то-то и то-то», но как именно — не уточняется.

Аналогия из жизни. Представьте розетку в стене: у неё есть договор (220 В, два штырька), но нет батареек, проводов и схемы внутри. Вилка от чайника, зарядки или лампы подходит к одной и той же розетке — и каждая реализует свой «контракт» по-своему. Пустой абстрактный класс — это именно такая розетка: только контакты, никакой начинки.

Почему именно так. Если интерфейс содержит данные или не-виртуальные методы, наследники вынуждены наследовать и реализацию тоже. Это резко увеличивает связность (coupling): меняете внутренности интерфейса — ломаются все наследники. Пустой класс с чисто виртуальными методами разделяет «что» и «как», и менять реализацию можно независимо.

Как делать правильно.

  • Объявляйте в интерфейсе только чисто виртуальные методы и виртуальный деструктор (virtual ~I() = default;). Деструктор обязателен — иначе удаление объекта по указателю на базу приведёт к неопределённому поведению.
  • Никаких полей данных, никаких реализаций по умолчанию.
  • Если нужна «общая логика для всех реализаций» — используйте паттерн NVI (Non-Virtual Interface): публичный не-виртуальный метод вызывает закрытый чисто виртуальный.

Типичные ошибки.

  1. Забыли виртуальный деструктор. Самый частый и самый опасный баг: delete ptr; где ptr имеет тип базового интерфейса не вызовет деструктор наследника.
  2. Добавили поле в интерфейс. После этого любое изменение размера/расположения поля ломает ABI и всех наследников.
  3. Сделали метод «почти чисто виртуальным» — дали ему реализацию по умолчанию, но не virtual. Наследник может не знать, что метод вообще нужно переопределять.
  4. Использовали интерфейс как «удобный базовый класс» с состоянием. Это уже не интерфейс, а иерархия классов — для неё есть другие правила.
class Logger {                      // интерфейс
public:
    virtual ~Logger() = default;
    virtual void log(std::string_view message) = 0;
};

class ConsoleLogger : public Logger {
public:
    void log(std::string_view message) override;   // реализация
};

I.30. Инкапсулируйте нарушения правил

Оригинал: I.30 — Encapsulate rule violations

Простое определение. Нарушение правила — это место в коде, где вы сознательно делаете то, что обычно считается плохой практикой: используете reinterpret_cast, читаете сырые байты, обращаетесь к глобальному состоянию, применяете goto и т. п. Само по себе такое нарушение не грех — грех, когда оно разбросано по всему проекту.

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

Почему именно так.

  • Любое нарушение — это технический долг. Если долг размазан по сотням мест, его невозможно ни найти, ни отрефакторить, ни проверить при аудите.
  • Скрытое за интерфейсом нарушение можно позже заменить безопасной реализацией, не трогая остальной код.
  • Комментарий «почему» рядом с нарушением спасает следующего программиста от повторения ваших же изысканий.

Как делать правильно.

  • Соберите весь «грязный» код в одну функцию или один маленький класс с говорящим именем (например, read_u32_le, OsAdapter, UnsafeLowLevel).
  • Обязательно оставьте комментарий с причиной: почему здесь нельзя сделать «правильно» (требование ABI, производительность, ограничение сторонней библиотеки).
  • Если нарушение связано с типом — оборачивайте его в новый строгий тип, чтобы остальной код работал с безопасным интерфейсом.
  • По возможности пишите юнит-тесты вокруг обёртки: если что-то сломается, вы узнаете первым.

Типичные ошибки.

  1. «Это только здесь один раз, зачем оборачивать?» — классика. «Один раз» имеет свойство превращаться в десять, а потом никто не помнит, где они все.
  2. Нет комментария с причиной. Через полгода вы (или коллега) увидите обёртку и решите её «упростить» — и сломаете то, ради чего она создавалась.
  3. Нарушение торчит наружу. Если возвращаемое из обёртки значение несёт в себе «опасный» смысл (например, void*), правило не выполнено: нарушение протекает в клиентский код.
  4. Слишком толстая обёртка. Если в «техническом шкафу» живёт уже почти вся логика, значит, пора честно пересмотреть дизайн, а не маскировать архитектурные проблемы.
// Единственное место, где нужен байтовый доступ: обёртка прячет его
std::uint32_t read_u32(const char* data)
{
    std::uint32_t value = 0;
    std::memcpy(&value, data, sizeof value);   // осознанно внутри обёртки
    return value;
}

F. Функции (Functions)

Функция — это именованный фрагмент кода, который описывает действие или вычисление и переводит систему из одного согласованного состояния в другое. Это базовый строительный блок любой программы, и именно через функции программа общается с внешним миром. Хорошо спроектированную функцию должно быть возможно осмысленно назвать, чётко указать требования к её аргументам (что они должны означать) и ясно описать связь аргументов с результатом. Реализация — это не спецификация: код тела функции говорит о том, как она работает, а имя, параметры и возвращаемое значение — о том, что она делает. Думайте о назначении функции в первую очередь, а о деталях реализации — во вторую. Функции — самая важная часть большинства интерфейсов (а интерфейсы — самое важное в долгоживущих программах), поэтому смотрите и правила раздела I. Отдельное внимание в разделе F уделено способам передачи параметров: когда по значению, когда по ссылке, а когда через перемещение.

F.1. «Упаковывайте» осмысленные операции в аккуратно названные функции

Оригинал: F.1 — "Package" meaningful operations as carefully named functions

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

Аналогия. В рецепте написано «посолите по вкусу», а не «возьмите поваренную соль NaCl крупного помола и добавьте 2 грамма». Точно так же в коде должно быть написано midpoint(...), а не (p + q) / 2.0. Один шаг — одно осмысленное действие с понятным именем.

Почему: дубликаты расходятся и порождают ошибки. Если одна и та же формула написана в десяти местах, при изменении логики легко забыть обновить пару мест — и появится скрытая несогласованность. Помимо прочего, имя функции делает намерение явным: (x + y) / 2 — это математика, а midpoint(...) — это семантика.

Как: выносите повторяющиеся операции в функции и давайте им понятные имена. В хорошем имени функции обычно есть глагол: compute, validate, normalize, lookup. Односложные имена вроде foo или doStuff — антипаттерн: они говорят не больше, чем комментарий «какая-то функция».

Типичная ошибка. Чрезмерное увлечение — делать функцию ради одной строки, особенно если строка очевидна сама по себе (например, square для x * x обычно оправдано, а вот increment(x) для x + 1 — уже спорно). Правило простое: если у выражения есть смысл и оно встречается хотя бы дважды или его смысл не очевиден с первого взгляда — выносите.

// Плохо: выражение повторяется и его легко испортить
double a = (x1 + x2) / 2.0;
double b = (y1 + y2) / 2.0;

// Хорошо: смысл назван
double midpoint(double p, double q) { return (p + q) / 2.0; }

F.2. Функция должна выполнять одну логическую операцию

Оригинал: F.2 — A function should perform a single logical operation

Что это значит. Под «логической операцией» понимается одно законченное действие с одним явным результатом — то, что можно описать одной фразой без союзов «и», «а также», «плюс». Это близко к принципу единой ответственности (Single Responsibility Principle), но сформулировано в терминах именно функций, а не классов.

Аналогия. Хороший ресторан разделяет роли: официант принимает заказ, повар готовит, кассир проводит оплату. Если один и тот же человек и варит суп, и считает налоги — это плохо масштабируется, плохо тестируется и плохо переиспользуется. То же самое с функциями.

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

Как: если функцию можно описать союзом «и», скорее всего её нужно разбить. Полезный приём — смотреть на имя функции: если оно получается длинным и содержит несколько слов-действий (load_and_process_and_save), это явный сигнал к рефакторингу.

Типичная ошибка. «Скрытая связка»: функции кажется, что они делают разные вещи, но на самом деле они неявно используют общие временные файлы, кеши или последовательность вызовов. Разбить такую функцию сложнее, но и пользы от разделения больше — часто это выявляет скрытое состояние, которое тоже стоит вынести.

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

// Плохо: чтение, парсинг и запись в одной функции
void load_and_process_and_save(const std::string& path);

// Хорошо: три шага — три функции
Config load(const std::string& path);
Report process(const Config& config);
void save(const Report& report, const std::string& path);

F.3. Держите функции короткими и простыми

Оригинал: F.3 — Keep functions short and simple

Что это значит. Короткая функция — это функция, которая помещается на одном экране без прокрутки, имеет мало ветвлений и небольшую глубину вложенности. Точное число строк менее важно, чем «укладывается ли функция в оперативную память читателя». Близкое понятие — цикломатическая сложность (число независимых путей через код): чем она меньше, тем легче охватить функцию взглядом.

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

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

Как: выносите вложенные блоки в отдельные функции, уменьшайте глубину условий. Один из самых мощных приёмов — «ранний выход» (early return/guard clauses): вместо лестницы if всё глубже и глубже проверяйте негативные условия в начале и сразу возвращайтесь. Это убирает так называемую «пирамидку гибели» (else if стремятся вправо всё дальше).

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

Практический совет. Хорошая эвристика: функция умещается на экране, обрабатывает одну логическую операцию, имеет не больше 1–2 уровней вложенности и имя отражает то, что она делает.

// Плохо: глубокая вложенность
if (a) { if (b) { if (c) { do_work(); } } }

// Хорошо: ранний выход
if (!a) return;
if (!b) return;
if (!c) return;
do_work();

F.4. Если функция может вычисляться на этапе компиляции, объявите её constexpr

Оригинал: F.4 — If a function might have to be evaluated at compile time, declare it constexpr

Что это значит. Ключевое слово constexpr говорит компилятору: «эту функцию, если получится, можно посчитать заранее, ещё до запуска программы». Важно понимать, что constexpr — это разрешение, а не обязанность компилятора: одну и ту же constexpr-функцию можно вызвать и при компиляции (например, в массиве фиксированного размера), и при обычном выполнении программы.

Аналогия. Это как заранее приготовленное блюдо в холодильнике: посетитель пришёл — блюдо уже готово, ждать не надо. Обычные функции готовят блюдо при каждом заказе. constexpr позволяет компилятору приготовить заранее там, где входные данные тоже известны заранее (например, литералы).

Почему: вычисления при компиляции не тратят время выполнения и не увеличивают бинарник «бесплатной» логикой — они буквально превращаются в число. Кроме того, constexpr расширяет круг контекстов, где значение можно использовать: размеры массивов, нетиповые параметры шаблонов, static_assert и т. п.

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

Типичная ошибка. Считать, что constexpr означает «будет вычислено при компиляции». Нет: функция просто имеет право быть вычисленной при компиляции. Если аргументы известны только в рантайме, она спокойно отработает в рантайме. Симметричная ошибка — пытаться помечать constexpr функции с побочными эффектами или с невозможностью вычисления в принципе (например, с аллокацией без ограничений C++20).

constexpr int square(int x) { return x * x; }

constexpr int table_size = square(4);   // вычислено при компиляции: 16
int runtime = square(n);                // тоже работает

F.6. Если функция не должна бросать исключения, объявите её noexcept

Оригинал: F.6 — If your function must not throw, declare it noexcept

Что это значит. Спецификатор noexcept — это часть контракта функции. Он сообщает вызывающему коду и компилятору: «из этой функции не выйдет исключение». Если исключение всё-таки выйдет из noexcept-функции, программа завершится вызовом std::terminate() — это гарантия «все или никогда». До C++11 тот же смысл был менее формализован (через throw()), но с приличными штрафами за ошибку; современный noexcept — это компактный и проверяемый контракт.

Аналогия. Представьте заявление «вход без очереди»: если при входе всё-таки кто-то застрянет, охранник немедленно закрывает двери (аварийная остановка). noexcept — именно такое заявление: либо вход прошёл гладко, либо система экстренно тормозит.

Почему: noexcept позволяет стандартной библиотеке выбирать более эффективные пути. Например, std::vector::push_back при перемещении элементов использует move_if_noexcept: если T::operator noexcept — элементы перемещаются (быстро и без лишних копий), иначе — копируются (безопасно, но медленнее). То есть одно объявление noexcept незаметно ускоряет код по всему проекту. Кроме того, noexcept разрешает компилятору чуть оптимизировать обработку исключений (не строить тяжёлые таблицы очистки).

Как: помечайте noexcept деструкторы (они и так noexcept по умолчанию начиная с C++11, но писать явно — хорошая привычка), операции перемещения (если они действительно не бросают), функции обмена (swap), и вообще любые функции, про которые вы можете дать строгую гарантию.

Типичная ошибка. Главная — поставить noexcept ради «оптимизации», не подумав о невозможности гарантии. Если функция вызывает другие функции, которые могут бросать, без try/catch ставить noexcept опасно: при первом же исключении — std::terminate, и причина будет скрыта от пользователя. Вторая ошибка — наоборот, забыть noexcept там, где он действительно уместен (особенно в конструкторах перемещения и swap).

Практический совет. Если не уверены в гарантии — не ставьте noexcept. Лучше безопасная функция, которая может бросить, чем «оптимизированная», которая молча убивает программу.

class Buffer {
public:
    Buffer(Buffer&& other) noexcept;   // перемещение не бросает
    ~Buffer() noexcept;                // деструктор не бросает
};

F.7. Для общего использования принимайте T* или T&, а не умные указатели

Оригинал: F.7 — For general use, take T* or T& arguments rather than smart pointers

Что это значит. Умный указатель (std::unique_ptr, std::shared_ptr) — это способ выразить владение объектом: кто-то из вызывающих и вызываемых отвечает за его время жизни. Если функция просто использует объект на время вызова, эта информация ей не нужна — достаточно ссылки или обычного указателя.

Аналогия. Это разница между «одолжить книгу почитать» и «приютить бездомного котёнка». Если вы просто хотите прочитать пару страниц, вам не нужно брать на себя ответственность за кормёжку и прививки. Точно так же функции обычно нужно просто «поработать с объектом», а не «присматривать за ним».

Почему: интерфейс не должен требовать больше, чем ему нужно. Если функция принимает std::shared_ptr<T>, она навязывает два требования: во-первых, объект должен жить в динамической памяти (а не быть стековой переменной или полем); во-вторых, у него должно быть конкретное средство владения (shared_ptr). Это лишние ограничения, которые делают функцию менее удобной в вызове. Кроме того, такой интерфейс теряет информацию: вызывающий не знает, передаёт ли он владение или просто ссылку.

Как: берите T& для обязательного объекта (отсутствие недопустимо), T* — если допустимо отсутствие (nullptr означает «нет объекта»). Беря std::shared_ptr<T>, вы тем самым говорите «я могу продлить время жизни объекта, если решу скопировать указатель» — это редко нужно функции, и почти всегда это сигнал о плохо продуманном владении.

Типичная ошибка. Брать std::shared_ptr по константной ссылке в надежде «передать владение только при необходимости» — это ложная экономия: либо функция действительно влияет на время жизни (и тогда интерфейс должен явно принимать владение), либо нет (и тогда достаточно T&). Среднего состояния здесь нет. Другая ошибка — брать std::unique_ptr в функциях, которые на самом деле «просто пользуются» объектом, что вынуждает вызывающего создавать unique_ptr только ради передачи.

Практический совет. Где уместны умные указатели в интерфейсе? В функциях, которые меняют владение: фабрики, которые возвращают unique_ptr (передают владение наружу), и функции-приёмники владения, которые его поглощают. Всё остальное — обычные ссылки.

// Плохо: функция навязывает владение, которого ей не нужно
void render(const std::shared_ptr<Sprite>& sprite);

// Хорошо: достаточно ссылки
void render(const Sprite& sprite);

F.8. Предпочитайте чистые функции

Оригинал: F.8 — Prefer pure functions

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

Аналогия. Математическая функция f(x) = x² всегда даёт 9 для x = 3 — сегодня, завтра, на Луне. А вот торговый автомат тоже что-то возвращает, но его поведение зависит от того, сколько в нём осталось товара, какие монеты вы засунули и включён ли он в розетку. Чистая функция — это математика, нечистая — автомат, у которого есть внутреннее состояние.

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

Как: не изменяйте глобальное состояние, не полагайтесь на изменяемые внешние данные. Если функция должна сделать «внешнее» действие (записать в файл, отправить запрос), выделяйте чистую часть (вычисление того, что именно нужно сделать) от «грязной» обёртки (которая и выполняет действие). Этот приём называется «functional core, imperative shell» — функциональное ядро, императивная оболочка.

Типичная ошибка. Считать функцию чистой только потому, что «она вроде ничего не меняет». Проверьте: зависит ли результат от чего-то, кроме аргументов? Если функция читает время, сетевой сокет или глобальную переменную — она нечистая, просто с ленивыми побочными эффектами. Симметричная ошибка — наоборот, считать функцию с побочным эффектом чистой из-за имени, например lookup, которое «должно бы быть чистым».

Практический совет. Стремитесь сделать функции чистыми везде, где это возможно, особенно в библиотечном коде. «Грязный» слой (ввод-вывод, мутации, эффекты) концентрируйте в тонкой оболочке на границе системы — так основная логика останется проверяемой и надёжной.

// Чистая функция: только читает вход
int area(int width, int height) { return width * height; }

// Нечистая: меняет внешнее состояние
int counter = 0;
int next() { return ++counter; }

F.15. Предпочитайте простые и общепринятые способы передачи информации

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

Оригинал: F.15 — Prefer simple and conventional ways of passing information

Не изобретайте экзотические схемы передачи параметров. Для «вход» — по значению или по const-ссылке, для «вход-выход» — по неконстантной ссылке, для владения — умные указатели. Привычные способы понимают и люди, и инструменты.

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

Аналогия из жизни. Представьте курьера, который доставляет посылку: передача по значению — курьер вручает вам точную копию посылки, оригинал остаётся у отправителя; передача по const-ссылке — вам показывают посылку для осмотра, но вскрыть её нельзя; передача по неконстантной ссылке — посылку передают вам, и вы можете её перепаковать; умный указатель — вам передают ключ от склада и говорят: «теперь это твоё, следи за ним».

Практические подсказки:

  • Прежде чем выбрать способ передачи, ответьте себе на три вопроса: функция только читает аргумент? Изменяет его на месте? Забирает себе? Ответ сразу подскажет правильный вариант.
  • Если сомневаетесь между «по значению» и «по const-ссылке» — см. правило F.16.
  • Не используйте сырые указатели (T*) для передачи владения: std::unique_ptr<T> говорит о владении явно и не оставляет места для утечек.

Типичные ошибки:

  • Передача тяжёлого объекта по значению там, где достаточно const T& — лишние копии и потеря производительности.
  • Передача по неконстантной ссылке там, где объект только читается, — это вводит читателя в заблуждение («функция что-то меняет?»).
  • Смешивание T* и std::unique_ptr<T> для одной и той же семантики в рамках проекта: коллеги не будут понимать, кто владеет ресурсом.
void f(const std::string& in);       // "вход": не копируем
void g(std::string& inout);          // "вход-выход": меняем аргумент
std::string h();                     // возврат значения предпочтителен

F.16. Для «входных» параметров дешёвые копии передавайте по значению, остальные — по const-ссылке

Определение. Дешёвая копия — это копирование объекта, которое компилятор выполняет за константное и очень небольшое время: как правило, тип занимает пару машинных слов (на практике — до 2–3 указателей). К таким типам относятся int, double, указатели, итераторы, std::chrono-значения и небольшие POD-структуры.

Оригинал: F.16 — For "in" parameters, pass cheaply-copied types by value and others by reference to const

Маленькие типы (int, double, указатели) копируются дёшево, поэтому их удобно передавать по значению. Большие объекты лучше передавать по const-ссылке, чтобы избежать лишнего копирования.

Почему: это оптимальный компромисс между стоимостью копии и накладными расходами ссылки. Как: int/double/указатель — по значению; string/vector/классы — по const&.

Аналогия из жизни. Сравните, как вы делитесь документом с коллегой: если это короткая записка в одно предложение — проще сказать её вслух (передача по значению); если это толстая папка с отчётом — логичнее показать оригинал и разрешить смотреть, но не забирать (передача по const-ссылке).

Практические подсказки:

  • Граница «дёшево» — грубо до 2–3 машинных слов (≈ 16–24 байт на 64-битной платформе). Для std::string, std::vector, контейнеров и пользовательских классов со сложным состоянием выбирайте const&.
  • Передача «по значению + std::move внутрь» — это отдельный случай для «sink»-параметров (см. F.18).
  • Не используйте передачу по значению ради «возможной модификации локальной копии» — это запутывает читателя кода.

Типичные ошибки:

  • Передача std::string по значению в «read-only» функцию — каждый вызов платит за аллокацию и копирование буфера, хотя функция только читает.
  • Передача int по const int& — это не ошибка компиляции, но избыточно: компилятор всё равно передаёт значение через регистр, а сигнатура становится длиннее.
  • Слепое следование правилу «всё по ссылке» даже для арифметических типов.
void by_value(int n, double x, Widget* p);                      // дешёвые копии
void by_ref(const std::string& s, const std::vector<int>& v);   // избегаем копий

F.17. Для параметров «вход-выход» используйте ссылку на неконстантный объект

Определение. Параметр «вход-выход» (in-out) — это аргумент, который функция и читает, и изменяет на месте. В C++ такие параметры принято обозначать неконстантной ссылкой T&: само наличие & без const уже является сигналом «объект будет изменён» — и компилятор, и читатель это понимают мгновенно.

Оригинал: F.17 — For "in-out" parameters, pass by reference to non-const

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

Почему: намерение изменить аргумент должно быть видно в сигнатуре. Как: принимайте T&, когда функция модифицирует объект на месте.

Аналогия из жизни. Это как передать коллеге заполненную анкету и попросить: «Проверь мои ответы и исправь ошибки прямо в документе». Коллега имеет прямой доступ к вашему экземпляру и оставляет на нём свои пометки — это и есть «in-out».

Практические подсказки:

  • Если функция только возвращает новое значение — предпочтите возврат значения (см. F.20), а не «in-out» параметр.
  • Если параметр опционален или может не использоваться — подумайте, не подходит ли здесь std::optional<T>& или возврат нового объекта.
  • Для контейнеров, которые функция должна полностью заменить, иногда лучше принять параметр «will-move-from» (X&&) — см. F.18.

Типичные ошибки:

  • Случайное использование const T& в функции, которая на самом деле модифицирует объект, — это приведёт к ошибке компиляции, а если её обойти через const_cast, поведение станет неопределённым.
  • Маскировка «выхода» под «возврат через параметр» для функции, которая логически производит значение, — нарушает F.20 и затрудняет чтение.
  • Применение T& для типов, которые дешевле вернуть (например, int или bool) — здесь лучше возврат по значению.
void normalize(std::vector<double>& data)   // данные меняются на месте
{
    for (double& x : data) {
        x = std::clamp(x, 0.0, 1.0);
    }
}

F.18. Для параметров «из которых будет выполнено перемещение» принимайте X&& и делайте std::move

Определение. Move-семантика — это механизм C++11, при котором содержимое одного объекта «переносится» в другой без копирования: указатели на внутренние буферы просто перекидываются, а источник остаётся в валидном, но пустом состоянии. Параметр «will-move-from» — это аргумент, который функция забирает себе, не оставляя вызывающему коду прежнего содержимого.

Оригинал: F.18 — For "will-move-from" parameters, pass by X&& and std::move the parameter

Когда функция забирает содержимое аргумента, rvalue-ссылка позволяет выполнить перемещение вместо дорогой копии.

Почему: перемещение дешевле копирования для тяжёлых объектов. Как: принимайте X&& и внутри делайте std::move, забирая ресурсы.

Аналогия из жизни. Это как передать другу свой рюкзак с вещами, сказав: «Я уезжаю, забирай всё себе». Вы не копируете каждую вещь в новый рюкзак — друг просто берёт ваш. То же делает перемещение: указатель на внутренний буфер «перепрыгивает» к новому владельцу.

Практические подсказки:

  • Используйте X&& только если функция действительно забирает содержимое (sink-параметр). Для «in-out» применяйте обычную T&.
  • После std::move параметр остаётся в «valid but unspecified state» — не читайте его без повторной инициализации.
  • Для типов, у которых перемещение не быстрее копирования (например, int или std::array<int, N>), X&& выигрыша не даёт.
  • Если вызывающий код может передать и lvalue, и rvalue, рассмотрите перегрузку с T&& + const T&, либо принимайте по значению и перемещайте внутрь (см. правило F.26 в Core Guidelines).

Типичные ошибки:

  • Принять const X&& — std::move от const сведётся к копированию, и выигрыша не будет.
  • Принять X&& и не вызвать std::move внутри — компилятор не сможет переместить, и параметр будет скопирован.
  • Попытаться использовать X&& после перемещения — поведение не определено до переинициализации.
void store(std::string&& text)
{
    data_ = std::move(text);   // забираем буфер без копии
}

store(std::string("большой текст"));   // временный объект перемещается

F.20. Для выходных значений предпочитайте возврат значения выходным параметрам

Определение. Выходной параметр (out-parameter) — это аргумент, через который функция возвращает результат, а не принимает входные данные. В C++ исторически такие параметры оформляли как T& или T* (часто первым аргументом), но современный стиль предпочитает возврат по значению: это безопаснее, читаемее и хорошо оптимизируется компилятором.

Оригинал: F.20 — For "out" output values, prefer return values to output parameters

Возврат значения проще читать и безопаснее: вызывающий не может забыть подставить выходной параметр. Компиляторы умеют оптимизировать возврат больших объектов.

Почему: возврат значения выражает «функция производит объект». Как: возвращайте результат, а не заполняйте переданную ссылку.

Аналогия из жизни. Сравните два способа заказать пиццу: «Привезите мне пиццу» (возврат значения — вы получаете готовый объект) и «Вот пустая коробка, положите туда пиццу и верните мне её» (выходной параметр — вы заранее готовите контейнер и передаёте его). Второй способ формально работает, но требует больше телодвижений и легко допускает ошибку («забыл передать коробку — пиццы нет»).

Практические подсказки:

  • Компиляторы применяют копирующую элизию (copy elision) и NRVO — возврат std::vector из функции может обойтись вообще без копий и перемещений.
  • Для функций, которые производят несколько значений, рассмотрите возврат структуры/кортежа или используйте «in-out» параметры для тех, что реально нужно изменять на месте.
  • Если по соображениям совместимости с C API нужен выходной параметр — это исключение, а не правило; внутри C++-кода отдавайте предпочтение возврату значения.
  • Для функций, которые могут «не произвести» значение, возвращайте std::optional<T> — это явная и типобезопасная альтернатива «выходу через параметр + код ошибки».

Типичные ошибки:

  • Слепое использование void f(T& out) для каждой функции, производящей значение, — ломает читаемость и затрудняет композицию.
  • Убеждение, что возврат по значению всегда дорог, — для современных компиляторов это миф: агрессивная элизия и move-семантика делают возврат дешёвым даже для тяжёлых контейнеров.
  • Пропуск проверки возвращаемого значения (например, для std::optional или кода ошибки) — выходной параметр «прятал» бы ту же проблему, но более явно.
// Плохо: выходной параметр
void read_config(Config& out, const std::string& path);

// Хорошо: возврат значения
Config read_config(const std::string& path);

F.43. Никогда (прямо или косвенно) не возвращайте указатель или ссылку на локальный объект

Определение. Висячая ссылка/указатель (dangling reference) — это ссылка или указатель, которые после возврата из функции указывают на уже уничтоженный объект. Обращение по ним — это неопределённое поведение (UB): программа может упасть, вернуть мусор или «повредить» чужую память, перезаписанную новыми данными.

Оригинал: F.43 — Never return a pointer or reference to a local object

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

Почему: обращение к уничтоженному объекту — классический источник падений. Как: возвращайте значение по копии, unique_ptr или объект из кучи под владением.

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

Практические подсказки:

  • Если локальный объект нужно «отдать наружу» — верните его по значению: компилятор применит NRVO и не будет делать копию.
  • Для дорогих в создании объектов, чьё время жизни должно пережить функцию, выделяйте ресурс в куче и возвращайте std::unique_ptr<T> — это явно передаёт владение.
  • Не возвращайте ссылки/указатели на параметры, если они сами по себе не живут дольше вызова (например, не возвращайте const T& на поле временного объекта).
  • Включите санитайзеры (ASan, MSan) — они ловят обращения к висячим ссылкам ещё на этапе тестирования.

Типичные ошибки:

  • Возврат ссылки на локальную переменную «потому что хочу избежать копии» — это UB, а не оптимизация; правильный путь — возврат по значению с NRVO.
  • Возврат T* или T& на элемент локального контейнера, думая, что контейнер «переживёт» функцию, — он уничтожается вместе с локальной переменной.
  • Попытка обойти проблему через static локальную переменную — это уже другой паттерн (со своими подводными камнями в многопоточности и при повторных вызовах).
// Плохо: возвращаем ссылку на локальную переменную
const std::string& bad() {
    std::string local = "текст";
    return local;   // висячая ссылка
}

// Хорошо: возвращаем значение
std::string good() {
    return "текст";
}

F.46. int — тип возврата main()

Определение. Функция main — это точка входа программы: с неё операционная система запускает ваш код. В отличие от обычных, у main фиксированный набор допустимых сигнатур, и стандарт C++ явно требует возвращать именно int. Любой другой тип возврата (включая void) делает программу нестандартной.

Оригинал: F.46 — int is the return type for main()

main — точка входа, и стандарт требует возвращать int. void main() не является стандартным C++ и не переносится.

Почему: код возврата видит оболочка и другие программы. Как: всегда пишите int main(), возвращайте 0 при успехе и ненулевое значение при ошибке.

Аналогия из жизни. Представьте, что вы сдаёте работу руководителю. Код возврата — это ваш отчёт: «0» значит «всё сделано, никаких проблем», ненулевое значение — «что-то пошло не так, посмотрите». ОС и скрипты (например, make, CI-системы) читают этот код, чтобы решить, продолжать ли сценарий и сигнализировать ли об ошибке пользователю.

Практические подсказки:

  • Если вы не пишете return явно в main, компилятор неявно вставит return 0; — это единственная функция, где такое поведение разрешено стандартом.
  • Договоритесь в команде о смысле кодов возврата: например, 1 — ошибка инициализации, 2 — ошибка ввода, 3 — ошибка обработки. Это поможет в скриптах и CI.
  • Для переносимости используйте EXIT_SUCCESS (обычно 0) и EXIT_FAILURE (обычно 1) из <cstdlib> — это делает намерение явным и не зависит от платформы.
  • Допустимы две сигнатуры: int main() и int main(int argc, char* argv[]) — всё остальное стандарт не гарантирует.

Типичные ошибки:

  • Объявить void main() — это расширение некоторых компиляторов, но не стандарт C++; на других платформах код может не собраться или повести себя непредсказуемо.
  • Всегда возвращать 0 даже при ошибке — теряется возможность отладки и автоматизации (CI не отличит успешный прогон от упавшего).
  • Возвращать «магические числа» без документирования их смысла — другие разработчики и скрипты не смогут интерпретировать код корректно.
int main()
{
    if (!initialize()) {
        return 1;   // ненулевой код — признак ошибки
    }
    run();
    return 0;
}

F.48. Не пишите return std::move(local)

Оригинал: F.48 — Don't return std::move(local)

Что это значит: при возврате локальной переменной из функции не оборачивайте её в std::move. Возвращайте переменную по её имени — этого достаточно, чтобы компилятор сгенерировал максимально эффективный код.

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

Почему: компилятор C++ умеет применять к возврату локальных переменных две оптимизации:

  1. NRVO (Named Return Value Optimization) — компилятор конструирует локальную переменную прямо в той области памяти, которую вызывающий код выделил под результат. Все копирования и перемещения устраняются полностью.
  2. Неявное перемещение (implicit move) — если NRVO по каким-то причинам не сработала (например, в функции несколько путей return), компилятор начиная с C++11 автоматически применяет перемещение вместо копирования.

std::move превращает переменную в rvalue, и компилятор больше не считает её кандидатом на NRVO — лучшая из возможных оптимизаций отключается. В лучшем случае вы получите одно явное перемещение (потеряв шанс на NRVO), в худшем — компилятор не сможет применить оптимизации, доступные при прямом возврате по имени.

Как правильно: возвращайте локальную переменную по имени. Никаких дополнительных действий не требуется.

Почему: компилятор сам выбирает лучший способ возврата. Как: возвращайте локальную переменную напрямую.

std::vector<int> build()
{
    std::vector<int> result;
    result.push_back(1);
    return result;            // хорошо: работает NRVO
    // return std::move(result); — плохо: мешает оптимизации
}

Практические советы:

  • Если в функции несколько путей return с разными локальными переменными — NRVO применяется к каждой из них по отдельности, лишь бы переменная возвращалась по своему имени.
  • std::move в return оправдан только тогда, когда вы возвращаете параметр функции (не локальную переменную): параметры NRVO не подлежат, и явное перемещение действительно ускоряет код.
  • Компиляторы GCC, Clang и MSVC умеют предупреждать о бесполезном std::move в return (флаги -Wmove, -Wreturn-std-move, /we4180) — обращайте внимание на такие подсказки.

Типичные ошибки:

  • «Добавлю std::move на всякий случай, чтобы компилятор точно переместил, а не скопировал». — Не нужно: для локальной переменной компилятор и так применит перемещение (а часто и NRVO). Вы лишь отключаете лучшую из доступных оптимизаций.
  • «std::move — это функция, она выполняет перемещение в рантайме». — Нет, std::move — это всего лишь static_cast к rvalue-ссылке. Само перемещение выполняет конструктор перемещения вызываемого типа.
  • «Добавлю std::move для единообразия с другими функциями, где я возвращаю параметры». — Не стоит: правила для локальных переменных и для параметров различаются. Параметры перемещать через std::move полезно, локальные переменные — вредно.

C. Классы и иерархии классов (Classes and class hierarchies)

Класс — это пользовательский тип, для которого программист сам задаёт внутреннее представление (поля), допустимые действия (методы) и внешний интерфейс (набор публичных операций). Иерархии классов организуют связанные классы в древовидные структуры наследования, где потомки уточняют и расширяют поведение предков. Представьте класс как «чертёж» детали: чертёж задаёт форму (поля) и допустимые операции (методы), а конкретная деталь — это объект, созданный по чертежу. Раздел охватывает создание классов, конструкторы, деструкторы, копирование и перемещение, контейнеры, лямбды, иерархии, перегрузку операторов и объединения. Ключевые темы: правило нуля, virtual-деструктор у базового класса, explicit для одноаргументных конструкторов, override и final вместо неявного переопределения, noexcept для перемещения. Отдельно — запрет на вызов виртуальных функций из конструкторов и деструкторов и запрет на «псевдоним типов» через union.

C.1. Организуйте связанные данные в структуры (struct или class)

Оригинал: C.1 — Organize related data into structures (structs or classes)

Когда несколько значений всегда передаются вместе, это сильный сигнал, что они принадлежат одному понятию. Объединив их в тип, вы даёте этой группе имя и предотвращаете случайный «расщеплённый» доступ, когда поля можно перепутать местами или передать в неправильном порядке. Аналогия из жизни: в паспорте фамилия, имя и дата рождения всегда идут вместе — никто не передаёт их тремя отдельными бумажками. В программе та же логика: имя и фамилия — это Person, а не две независимые строки.

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

Практическая подсказка: если вы заметили, что пишете функцию с несколькими параметрами одного «семейства» (координаты, размеры, диапазоны), — это почти наверняка кандидат на новый тип.

Типичная ошибка: оставлять координаты «разделёнными» (x, y, z отдельными аргументами). Это приводит к вызовам вроде draw(y, x, color) — компилятор не заметит подмены, а отлаживать придётся долго.

// Плохо: координаты всегда ходят парой
void move(int x, int y);

// Хорошо: пара оформлена типом
struct Point { int x; int y; };
void move(Point p);

C.2. Используйте class, если у типа есть инвариант; struct — если поля могут меняться независимо

Оригинал: C.2 — Use class if the class has an invariant; use struct if the data members can vary independently

Инвариант — это свойство, которое обязано выполняться для любого объекта типа в любой «публичной» точке программы. Например, дата всегда должна быть корректной (1 ≤ день ≤ 31 для выбранного месяца), возраст — неотрицательным, скорость — неотрицательной и не превышающей скорость света. struct уместна для простого набора независимых полей, у которых нет взаимных ограничений. Если же тип обязан поддерживать такой закон — используйте class с закрытыми полями и конструктором, который проверяет инвариант.

Почему: наличие инварианта требует контроля доступа: иначе любой посторонний код сможет записать в поле любое значение и сломать закон типа. Как: struct — открытые независимые данные (агрегат); class — скрытые поля и проверки в конструкторе и методах.

Аналогия: struct Point — это две независимые координаты на листе бумаги, их можно менять как угодно. class Date — это календарь с правилами: нельзя записать «31 февраля», и эти правила охраняют приватные поля.

Практическая подсказка: если при добавлении нового метода вы постоянно думаете «а вдруг поле сейчас в неправильном состоянии?» — значит, у типа есть инвариант, и это class, а не struct.

Типичная ошибка: использовать struct для сущностей с правилами (даты, диапазоны, цвета в HSL). В результате day = 99 компилируется без ошибок, и баги проявляются только в рантайме.

struct Point { int x; int y; };   // x и y независимы

class Date {                       // инвариант: корректная дата
public:
    Date(int d, int m, int y);
private:
    int day_, month_, year_;
};

C.9. Минимизируйте доступность членов

Оригинал: C.9 — Minimize exposure of members

Делайте члены закрытыми (private или protected) по умолчанию и открывайте (public) только то, что действительно нужно снаружи. Каждый публичный член — это «обещание», которое вы даёте всем пользователям класса: менять сигнатуру, семантику или удалять его уже нельзя без риска сломать чужой код. Чем меньше открытого состояния, тем меньше способов привести объект в некорректное состояние извне.

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

Аналогия из жизни: у пульта от телевизора снаружи только кнопки, а внутренние микросхемы спрятаны в корпусе. Вам не нужно знать, какая там плата, чтобы переключить канал — и инженер может заменить плату, не заставляя вас учить новые кнопки. Так же и в классе: внешний код работает с публичным интерфейсом, а внутренности можно переписывать без последствий.

Практическая подсказка: полезный приём — начинать разработку класса с приватными полями и только потом открывать те, что действительно понадобились. Это автоматически даёт минимальный интерфейс.

Типичная ошибка: открывать все поля «на всякий случай» (особенно часто встречается у struct бездумно превращённых в «почти класс»). В итоге любая правка внутренностей ломает всех клиентов.

class Counter {
public:
    void increment() { ++value_; }
    int value() const { return value_; }
private:
    int value_ = 0;   // прямой доступ закрыт
};

C.20. Если можно не определять операции по умолчанию — не определяйте

Оригинал: C.20 — If you can avoid defining default operations, do

Если все члены класса сами корректно управляют своими ресурсами, компилятор сгенерирует конструктор копирования, оператор присваивания, конструктор перемещения, оператор перемещения и деструктор правильно за вас. Это и есть «правило нуля» (rule of zero): не пишите деструктор и операции копирования/перемещения, если в этом нет реальной нужды. Каждая ручная реализация — это потенциальный источник багов (забыли освободить, забыли скопировать поле, забыли noexcept).

Почему: сгенерированные операции корректны, проверены десятилетиями практики и не требуют ручной поддержки при добавлении полей. Как: используйте члены с RAII (std::string, std::vector, std::unique_ptr, std::map) и не объявляйте деструктор, копирующие или перемещающие операции.

Аналогия: если в вашем доме вся техника (холодильник, стиральная машина, плита) сама отключается, когда вы уходите, вам не нужно писать инструкцию «не забудьте выключить каждый прибор». Доверьтесь автоматике — и не создавайте лишних сущностей.

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

Типичная ошибка: вручную писать деструктор с delete указателя, когда можно было просто заменить «голый» указатель на std::unique_ptr — и тогда компилятор всё сделает сам.

struct NamedMap {
    explicit NamedMap(std::string n) : name(std::move(n)) {}
    // ни деструктора, ни операций копирования/перемещения —
    // компилятор сгенерирует их правильно
private:
    std::string name;
    std::map<int, int> rep;
};

C.21. Если определяете или удаляете любую операцию копирования/перемещения или деструктор — определяйте или удаляйте их все

Оригинал: C.21 — If you define or =delete any copy, move, or destructor function, define or =delete them all

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

Почему: частично определённый набор операций порождает сюрпризы: объект может оказаться некопируемым там, где ожидалось копирование, или утечь ресурс. Как: если объявили деструктор — явно решите судьбу копирования и перемещения (default, delete или собственная реализация) и наоборот.

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

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

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

class Handle {
public:
    ~Handle();
    Handle(const Handle&) = delete;             // копирование запрещено
    Handle& operator=(const Handle&) = delete;
    Handle(Handle&&) noexcept = default;        // перемещение разрешено
    Handle& operator=(Handle&&) noexcept = default;
};

C.31. Все ресурсы, захваченные классом, должны освобождаться его деструктором

Оригинал: C.31 — All resources acquired by a class must be released by the class's destructor

Если конструктор класса захватил ресурс (открыл файл, выделил память, установил соединение, заблокировал мьютекс), именно деструктор этого класса обязан его освободить. Иначе ресурс утечёт при любом способе уничтожения объекта — нормальном выходе из области видимости, исключении, раннем return. Это и есть фундаментальный принцип RAII (Resource Acquisition Is Initialization): захват ресурса привязан к времени жизни объекта.

Почему: симметрия «захват в конструкторе — освобождение в деструкторе» и есть RAII. Только так можно гарантировать корректное освобождение при исключениях и любых путях выхода из функции. Как: освобождайте в деструкторе ровно те ресурсы и в том порядке, в котором они были захвачены в конструкторе.

Аналогия из жизни: когда вы арендуете автомобиль, ключ — это объект-«обёртка». Пока ключ у вас (объект жив), машина ваша (ресурс захвачен). Сдали ключ в прокат (деструктор) — машина свободна для других. Если забыть сдать ключ — машина «утекла» (никто не может её арендовать, пока вы её формально держите).

Практическая подсказка: предпочитайте стандартные RAII-обёртки (std::unique_ptr, std::lock_guard, std::ofstream) — они уже реализуют это правило за вас. Писать «сырые» классы с ручным освобождением стоит только для нестандартных ресурсов.

Типичная ошибка: освобождать ресурс где-то «по пути» (в середине метода), а в деструкторе проверять валидность. Это путь к двойному освобождению и утечкам.

class Socket {
public:
    Socket() : fd_{::socket(AF_INET, SOCK_STREAM, 0)} {}
    ~Socket() { if (fd_ >= 0) ::close(fd_); }   // освобождаем захваченное
private:
    int fd_;
};

C.35. Деструктор базового класса должен быть либо публичным и виртуальным, либо защищённым и невиртуальным

Оригинал: C.35 — A base class destructor should be either public and virtual, or protected and non-virtual Если удалять объект наследника через указатель на базу, а деструктор базы не виртуален — поведение неопределено (Undefined Behavior, UB). Это худшая категория ошибок в C++: компилятор не даёт никаких гарантий. Программа может упасть, может «тихо» повредить память, а может отработать «как будто правильно» — но при смене компилятора, флагов оптимизации или платформы всё сломается в самый неподходящий момент.

Аналогия из жизни. Представьте ресторан с обычным залом (базовый класс) и VIP-кухней (наследник) с дорогим оборудованием. Если закрыть ресторан через интерфейс «обычного зала», то свет в зале погаснет, но кухонные плиты и холодильники продолжат работать и нагревать пустое помещение. В программе это выглядит как утечка ресурсов, а в худшем случае — как повреждение кучи (heap corruption), которое проявится через полчаса работы совсем в другом месте.

Публичный виртуальный деструктор решает проблему: при delete через указатель на базу сначала вызывается деструктор наследника, потом — базы. Защищённый невиртуальный деструктор (protected ~Base();) — наоборот, запрещает удалять объект через указатель на базу, что иногда полезно для гарантии, что наследник живёт дольше базы (например, в полиморфных типах с подсчётом ссылок или когда удаление — обязанность фабрики).

Почему: иначе при delete через базу не вызовется деструктор наследника. Как: любой класс с виртуальными функциями получает виртуальный (обычно = default) деструктор.

Практическая подсказка. Простое правило: если у класса есть хотя бы одна виртуальная функция, делайте деструктор виртуальным. Если же вы хотите явно запретить удаление через базу (например, чтобы объекты жили только в std::shared_ptr со своим удалителем), используйте protected ~Base(); — компилятор тогда просто не даст написать delete base_ptr; снаружи класса.

Типичная ошибка. Думать, что «раз я использую std::unique_ptr<Base>, утечек не будет». Это не так: std::unique_ptr<Base> при уничтожении вызывает delete через указатель на Base. Без виртуального деструктора UB произойдёт точно так же — unique_ptr не знает про наследника, он знает только про тип, которым его параметризовали.

class Base {
public:
    virtual ~Base() = default;   // обязательно виртуальный
    virtual void f();
};

class Derived : public Base {
    std::string resource_;   // требует очистки
public:
    ~Derived() override;      // будет вызван
};

std::unique_ptr<Base> p = std::make_unique<Derived>();
// без virtual ~Base() деструктор Derived не вызвался бы — утечка

C.41. Конструктор должен создавать полностью инициализированный объект

Оригинал: C.41 — A constructor should create a fully initialized object

После завершения конструктора объект обязан быть в корректном состоянии. Нельзя оставлять поля неинициализированными или требовать отдельного вызова init(). Слово «корректное» здесь означает «удовлетворяющее инвариантам класса» — то есть набору правил, которые должны выполняться для любого живого объекта этого типа (например, «размер вектора неотрицателен», «указатель либо валиден, либо равен nullptr», «баланс счёта согласован с суммой транзакций»).

Аналогия из жизни. Автомобиль, сходящий с конвейера, должен иметь все четыре колеса, руль и двигатель. Нельзя продавать машину с тремя колёсами и запиской «вызовите installWheels() позже». Покупатель ожидает, что машина сразу готова к движению — иначе он поедет на трёх колёсах и попадёт в аварию. Точно так же пользователь вашего класса ожидает, что сразу после конструктора объект готов к работе: можно вызывать любые методы, передавать его в функции, сохранять в контейнер.

Почему: частично инициализированный объект — источник ошибок: его можно случайно использовать до полной инициализации, забыть вызвать init() и долго искать баг в чужом коде. Как: инициализируйте все члены в конструкторе или через инициализаторы по умолчанию.

Практическая подсказка. В современном C++ есть три удобных способа инициализации:

  • Инициализация в объявлении поля (in-class member initializer): int port_ = 8080; — задаёт значение по умолчанию для всех конструкторов. Работает с C++11.
  • Список инициализации конструктора: предпочтительнее присваивания в теле, потому что срабатывает один раз, а не два (конструктор члена + присваивание). Полезно для типов без конструктора по умолчанию.
  • Контейнеры STL (std::vector, std::string и т.д.) — всегда создаются пустыми по умолчанию, не нужно думать о «неинициализированном состоянии».

Типичная ошибка. Написать конструктор, принимающий параметры, и забыть проинициализировать какой-то числовой член. Компилятор не предупредит (для встроенных типов вроде int, double, указателей), и этот член будет содержать «мусор» из памяти. Всегда проверяйте, что каждое поле либо инициализировано в объявлении, либо в списке инициализации, либо присваиванием в самом начале тела конструктора.

class Connection {
public:
    Connection(std::string host, int port)
        : host_{std::move(host)}, port_{port}, socket_{connect(host_, port_)} {}
private:
    std::string host_;
    int port_;
    int socket_;
};

C.42. Если конструктор не может построить корректный объект, бросайте исключение

Оригинал: C.42 — If a constructor cannot construct a valid object, throw an exception

Конструктор в C++ не возвращает значение, поэтому сообщить об ошибке «кодом возврата», как это делают обычные функции, невозможно. Единственный способ — выбросить исключение. В этом случае объект либо создан полностью и валиден, либо не создан вовсе (его как будто и не было). Третьего не дано.

Аналогия из жизни. Пекарь, обнаруживший, что мука отсырела и тесто не поднимется, не станет продавать «частично испечённый» хлеб с запиской «допеките сами дома». Он выбросит бракованную партию — и покупатель не получит подделку. Точно так же должен поступать и конструктор: либо объект полностью готов, либо его не существует в природе.

Почему: исключение гарантирует, что невалидный объект не будет использован. C++ в этом случае автоматически вызовет деструкторы для уже сконструированных подобъектов-членов (это называется раскрутка стека, stack unwinding), и программа продолжит работу в ближайшем подходящем обработчике. Никаких «полуживых» объектов с утечками внутри. Как: при невозможности выполнить инвариант класса бросайте исключение из тела конструктора.

Практическая подсказка. Стандартная библиотека предоставляет подходящие типы исключений — выбирайте самый конкретный из них:

  • std::invalid_argument — для неверных значений параметров (отрицательный размер, нулевой указатель там, где его быть не должно).
  • std::out_of_range — если значение вне допустимого диапазона (индекс за пределами массива).
  • std::runtime_error — если ошибка обнаруживается только во время выполнения (например, не удалось открыть файл или подключиться к серверу).
  • std::bad_alloc — если не хватило памяти (обычно бросается неявно оператором new).
  • std::length_error, std::domain_error, std::overflow_error — более узкие случаи.

Типичная ошибка. Сделать в конструкторе «best effort»: попытаться что-то сделать, при неудаче оставить поле в «пустом» или «дефолтном» состоянии и надеяться, что пользователь сам проверит. Это нарушает правило C.41 — объект уже не полностью инициализирован, а пользователь может об этом даже не узнать (метод проверки легко забыть вызвать). Правильный подход — всегда либо успех, либо исключение.

class Matrix {
public:
    Matrix(int rows, int cols) : rows_{rows}, cols_{cols} {
        if (rows <= 0 || cols <= 0) {
            throw std::invalid_argument("Размеры должны быть положительными");
        }
        data_.resize(static_cast<std::size_t>(rows) * cols);
    }
private:
    int rows_, cols_;
    std::vector<double> data_;
};

C.46. По умолчанию объявляйте одноаргументные конструкторы explicit

Оригинал: C.46 — By default, declare single-argument constructors explicit

Одноаргументный конструктор без explicit разрешает неявное преобразование типа — это значит, что компилятор вправе автоматически создать временный объект вашего класса везде, где ожидается ваш тип, но передан «похожий» (например, int). На практике это часто приводит к неожиданным вызовам в самых неожиданных местах: в if, в возврате из функции, в списке аргументов. Ключевое слово explicit запрещает такие неявные превращения и требует явного синтаксиса создания объекта.

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

Почему: неявные преобразования делают ошибки незаметными. Код компилируется, проходит ревью, тесты могут быть зелёными — а потом в продакшене кто-то передал 0 или -1 вместо объекта, компилятор молча создал «пустышку», и через месяц выясняется, что данные молча теряются или обрабатываются неправильно. Как: помечайте explicit все одноаргументные конструкторы, кроме осознанных исключений (например, обёртки-маркеры вроде StrongType<int> или умных указателей, где неявное преобразование семантически безопасно).

Практическая подсказка. В C++11 и новее explicit можно ставить и на конструкторы с несколькими аргументами (через {}), и на операторы преобразования (explicit operator bool()). Последнее особенно полезно: ваш тип становится «безопасно проверяемым» — if (ptr) работает как проверка на пустоту, а вот int n = ptr; или ptr + 42 — нет, потому что bool можно получить только явно. Это классический «safe bool» идиом.

Типичная ошибка. Считать, что use(Widget{42}) и use(42) — «это одно и то же, просто разный синтаксис». Нет, это совершенно разные вещи: первый вариант — явное создание объекта через синтаксис списка инициализации, второй — попытка неявного преобразования int → Widget. Если конструктор explicit, второй вариант просто не скомпилируется — и это хорошо, ошибка видна сразу.

// Плохо: int неявно превращается в Widget
struct Widget { Widget(int); };

// Хорошо: преобразование только явное
struct Widget { explicit Widget(int); };

void use(Widget w);
use(42);          // ошибка компиляции — так задумано
use(Widget{42});  // явное создание

C.66. Делайте операции перемещения noexcept

Оригинал: C.66 — Make move operations noexcept

Перемещение (move) — это передача внутренних ресурсов от одного объекта к другому без копирования (например, перехват указателя на динамическую память, переброска маленького объекта вместо копирования большого буфера). Если ваш класс владеет только «дешёвыми» в перемещении ресурсами (указатели, целые числа, контейнеры стандартной библиотеки), то перемещение не должно бросать исключений: на этом строится выбор контейнеров между копированием и перемещением при росте.

Аналогия из жизни. Переезд из одной квартиры в другую: вы просто перевозите коробки, не пересобирая их содержимое. Это быстро и почти не может «упасть». А вот если бы переезд заключался в том, чтобы скопировать каждую вещь заново (копирование), это медленнее, и теоретически можно «уронить» вазу. Если ваш класс перемещается как коробка с вещами — пишите noexcept. Если же ваш класс вынужден именно копировать (например, внутри хранится массив, указатель на который нельзя просто «передать» — скажем, потому что поле const), — тогда обещать noexcept нельзя.

Почему: std::vector (и другие стандартные контейнеры с строгой гарантией исключений) перемещают элементы только если перемещение noexcept. Иначе они предпочтут копирование — иначе при исключении во время перемещения контейнер оказался бы в рассогласованном состоянии (часть элементов переехала, часть нет). Без noexcept вы лишаете свои объекты всех оптимизаций, ради которых перемещение и придумано: при добавлении элементов в вектор будет вызываться медленное копирование вместо дешёвого перемещения. Как: помечайте перемещающий конструктор и перемещающее присваивание noexcept, если они действительно не бросают.

Практическая подсказка. Полезное правило — «Правило нуля» (Rule of Zero): если ваш класс состоит из членов, которые сами умеют корректно перемещаться (все контейнеры STL, умные указатели, std::string, std::function), компилятор сгенерирует перемещение автоматически — и оно уже будет noexcept. Перемещение «ломается» только если вы вручную написали нетривиальный деструктор, копирующий конструктор или оператор копирующего присваивания — тогда компилятор вообще не генерирует перемещающие операции.

Типичная ошибка. Забыть написать noexcept в перемещающих операциях и оставить перемещение «по умолчанию» — тогда компилятор сгенерирует его, но без noexcept. Векторы и std::sort будут копировать ваши объекты вместо перемещения, и вы потеряете производительность на ровном месте, даже не подозревая об этом.

class Buffer {
public:
    Buffer(Buffer&& other) noexcept
        : data_{other.data_}, size_{other.size_} {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    Buffer& operator=(Buffer&& other) noexcept;
private:
    char* data_ = nullptr;
    std::size_t size_ = 0;
};

C.82. Не вызывайте виртуальные функции из конструкторов и деструкторах

Оригинал: C.82 — Don't call virtual functions in constructors and destructors

Во время работы конструктора базового класса объект ещё не стал наследником — конструирование идёт снизу вверх: сначала база, потом члены, потом наследник. В этот момент «динамический тип» объекта — это тот тип, конструктор которого сейчас выполняется, а не самый производный. Виртуальный вызов не может «заглянуть в будущее» и потому вызовет версию для текущего (ещё неполного) типа. В деструкторе — симметрично наоборот: разрушение идёт сверху вниз, и когда выполняется деструктор базы, наследник уже «мёртв».

Аналогия из жизни. Представьте стройку дома: бригада заливает фундамент (базовый класс). Вы не можете попросить рабочих «включить свет на десятом этаже» — этого этажа ещё физически нет, можно осветить только то, что уже стоит. Точно так же и виртуальная функция во время конструктора базы увидит только «фундамент» и вызовет реализацию для базы, даже если потом «надстроят» верхние этажи. И наоборот: при сносе дома, когда разбирают фундамент, верхних этажей уже нет — вызов «лифта на десятый этаж» упадёт в пустоту.

Почему: во время конструирования и разрушения динамический тип не тот, что кажется — он меняется по мере того, как конструкторы и деструкторы «пробираются» по иерархии. Если виртуальная функция внутри конструктора базы случайно вызовет реализацию из наследника (через хитрую цепочку вызовов), та получит доступ к ещё не сконструированным полям наследника — чтение «мусора», повреждение данных. Как: не вызывайте виртуальные (и чисто виртуальные) функции из конструкторов и деструкторов.

Практическая подсказка. Если нужно передать информацию из базы в наследника или заставить наследника «отреагировать» на создание — есть несколько альтернатив:

  • Параметры конструктора: передавайте нужные значения явно через аргументы конструктора базы.
  • Двухэтапная инициализация: в конструкторе объект только создаётся, а отдельный метод (например, Init(), Begin(), Open()) вызывается пользователем уже после полного конструирования.
  • Шаблон проектирования «Шаблонный метод»: база вызывает не виртуальную функцию, а переопределяемый protected-метод, который наследник вызовет сам в нужный момент своей реализации.
  • CRTP и статический полиморфизм: если иерархия известна на этапе компиляции — вообще избавьтесь от виртуальности.

Типичная ошибка. Думать, что компилятор «предупредит» про вызов виртуальной функции в конструкторе. Он не предупредит — вызов корректен с точки зрения языка (это вызов версии для текущего типа), просто семантика обычно не та, которую ожидал программист. Это типичный баг «из тех, что долго не находятся»: код компилируется, запускается, в простых сценариях работает, а ломается только в сложных иерархиях с переопределениями — или неожиданно после рефакторинга, когда кто-то добавляет наследника.

class Base {
public:
    Base() {
        // init(); — опасно: вызовется версия Base, а не наследника
    }
    virtual void init();
};

C.128. Виртуальные функции должны указывать ровно одно из virtual, override или final

Оригинал: C.128 — Virtual functions should specify exactly one of virtual, override, or final

Определения:

  • Виртуальная функция (virtual) — функция-член базового класса, поведение которой может быть изменено в наследниках; при вызове через указатель или ссылку на базу выбирается реализация, соответствующая реальному (динамическому) типу объекта.
  • override — аннотация в классе-наследнике, обещание компилятору: «я переопределяю функцию базового класса». Если сигнатура (имя, типы параметров, const-квалификация) не совпадёт с виртуальной функцией базы, компиляция завершится ошибкой.
  • final — запрет дальнейшего переопределения (для виртуальной функции) или дальнейшего наследования (для класса). Иерархия «закрывается» в этом узле.

Аналогия из жизни: представьте должностную инструкцию в большой компании. virtual — это пункт «обязанности могут уточняться подразделением». override — конкретное уточнение от отдела: «мы делаем именно так, и любое несоответствие с шаблоном сразу выявят при проверке». final — печать «утверждено окончательно, изменения запрещены».

override гарантирует, что функция действительно переопределяет виртуальную функцию базы; если подпись не совпадёт, компилятор сообщит об ошибке. final запрещает дальнейшее переопределение.

Почему: без override опечатка создаёт новую функцию вместо переопределения. Как: в наследниках пишите override, в базе — virtual, для запрета — final.

Дополнительно о «почему»: без override компилятор не проверяет совпадение сигнатур, и любая опечатка (в имени, типах параметров или const) тихо создаёт новую функцию вместо переопределения. Программа компилируется без ошибок, но динамическая диспетчеризация вызывает «не ту» реализацию — чаще всего базовую. Это один из самых коварных источников трудноуловимых ошибок в иерархиях классов: код «работает», но делает не то, что задумано.

Дополнительно о «как»: override уже подразумевает виртуальность (повторять virtual в наследнике не нужно и считается ошибкой стиля), а final исключает любые дальнейшие переопределения; использовать все три аннотации одновременно бессмысленно — поэтому правило и говорит «ровно одно из».

Типичные ошибки:

  • Писать и virtual, и override в наследнике — избыточно, многие компиляторы выдают предупреждение -Woverloaded-virtual (или ошибку при -Werror).
  • Забыть const в override: void draw() override; при базе void draw() const — это новая функция, а не переопределение; полиморфизм тихо ломается.
  • Пометить функцию final слишком рано в публичной библиотеке — это мешает пользователям и тестам (мокам) расширять поведение.
class Base {
public:
    virtual void draw() const;
};

class Derived : public Base {
public:
    void draw() const override;   // ошибка компиляции, если подпись не совпала
};

class Leaf final : public Derived { };   // наследовать дальше нельзя

C.145. Обращайтесь к полиморфным объектам через указатели и ссылки

Оригинал: C.145 — Access polymorphic objects through pointers and references

Определения:

  • Полиморфизм (динамический) — возможность единообразно работать с объектами разных конкретных типов через общий интерфейс (базовый класс). В C++ достигается с помощью виртуальных функций и реально работает только при доступе через указатель или ссылку.
  • Срезка объекта (object slicing) — копирование производного класса в переменную базового типа по значению; «лишняя» часть (дополнительные поля и, главное, динамический тип) отбрасывается, и виртуальные вызовы адресуют только базовую реализацию.
  • std::unique_ptr<T> — «умный указатель», единолично владеющий полиморфным объектом; при разрушении автоматически удаляет объект через delete (требует виртуального деструктора в базе).

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

Полиморфизм работает только через указатель или ссылку: при копировании по значению происходит «срез» — теряется часть наследника.

Почему: копирование базового объекта обрезает производные данные. Как: передавайте полиморфные объекты по ссылке или через умный указатель.

Дополнительно о «почему»: после среза информация о реальном типе исчезает на уровне объекта — typeid вернёт имя базового класса, а виртуальный вызов уйдёт в базовую реализацию, даже если в исходном объекте она была переопределена. Особенно опасна неявная срезка при возврате из функции или при push_back в контейнер значений.

Практические подсказки:

  • Параметры функций: void process(const Shape& s) — почти всегда лучше, чем void process(Shape s); добавляйте const, если объект не модифицируется.
  • В стеке владейте полиморфным наследником через std::unique_ptr<Base> (auto p = std::make_unique<Derived>();).
  • В контейнерах храните std::vector<std::unique_ptr<Base>>, а не std::vector<Base> — последний режет элементы при копировании.
  • Фабрики возвращают std::unique_ptr<Base> (или std::shared_ptr<Base> при разделяемом владении), а не сырые Derived*.

Типичные ошибки:

  • std::vector<Shape> v; v.push_back(circle); — круг превращается в «плоский» Shape, виртуальные методы вызывают базовые версии.
  • Случайно принять параметр по значению: опечатка в void f(Shape s) вместо void f(const Shape& s) компилируется без ошибок и тихо срезает объект.
  • Возвращать Derived из фабрики с типом возврата Base — срезка произойдёт ещё на этапе возврата.
void draw(const Shape& shape);          // полиморфизм сохранён
void draw(Shape shape);                 // срез: вызовется Shape-версия

std::vector<std::unique_ptr<Shape>> shapes;   // корректное хранение

C.183. Не используйте union для «псевдонима типов»

Оригинал: C.183 — Don't use a union for type punning

Определения:

  • «Псевдоним типа» (type punning) — приём, при котором одна и та же область памяти интерпретируется как значение разных типов (например, посмотреть на биты float как на uint32_t). Используется для низкоуровневых трюков, сериализации, работы с графическими и сетевыми протоколами.
  • Неопределённое поведение (UB) — ситуация, когда стандарт C++ не предписывает, как должен вести себя компилятор: программа может работать случайным образом, давать разные результаты при смене уровня оптимизации или вообще «исчезнуть» после перекомпиляции.
  • std::bit_cast<T>(value) — стандартный (C++20) способ побитового преобразования значения одного типа в другой без UB. Требует одинакового размера исходного и целевого типов.
  • std::memcpy — побайтовое копирование из одной области памяти в другую; для тривиально копируемых типов в C++ это определённый способ реализовать type punning.

Аналогия из жизни: представьте, что в коробке лежит предмет, и вы хотите понять, что это. Способ «через union» — открыть коробку и описать то, что видите, словарём от другой игры: описание может «почти» подойти, а может и нет, и стандарт этого не гарантирует. А std::bit_cast — это инструкция «открыть коробку, пересчитать содержимое по утверждённому регламенту и составить новое описание по тому же регламенту»: результат всегда корректен и переносим между платформами.

Чтение из члена union, в который не записывали, — неопределённое поведение (за исключением некоторых случаев для байтов). Для преобразования байтов используйте std::bit_cast или memcpy.

Почему: «псевдоним типов» через union не гарантирован стандартом. Как: применяйте std::bit_cast (C++20) или memcpy.

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

Практические подсказки:

  • std::bit_cast проверяет, что размеры исходного и целевого типов совпадают, — это ловится на этапе компиляции.
  • Для тривиально копируемых типов std::memcpy(&dst, &src, sizeof(T)) — корректный и переносимый способ; современные компиляторы распознают его и генерируют оптимальный код (никакого «лишнего копирования» не будет).
  • В C++17 и старше используйте комбинацию std::memcpy и reinterpret_cast над буфером std::byte/unsigned char — это определённое поведение.

Типичные ошибки:

  • Использовать *(uint32_t*)&f или reinterpret_cast<uint32_t*>(&f) — формально нарушает правило strict aliasing и является UB.
  • Думать, что «у меня работает на GCC/Clang/MSVC, значит правильно»: стандарт этого не гарантирует, в другой версии компилятора или при -O3 поведение может сломаться.
  • Применять std::bit_cast к типам с нетривиальными конструкторами или к типам с указателями — стандарт запрещает это, поведение не определено.
  • Считать union допустимым для type punning только потому, что «так делают в Си»: в Си правила aliasing другие, в C++ они строже.
// Плохо: неопределённое поведение
union U { float f; std::uint32_t u; };
U v; v.f = 1.0f;
std::uint32_t bits = v.u;   // UB

// Хорошо: определённое побитовое преобразование
std::uint32_t bits = std::bit_cast<std::uint32_t>(1.0f);

R. Управление ресурсами (Resource management)

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

Основная цель этого раздела — не течь ресурсами (то есть не забывать их освобождать) и не держать их дольше необходимого. Сущность, отвечающая за освобождение, называется владельцем (owner). В C++ владение выражается типами, а освобождение — деструкторами; вместе это и есть идиома RAII (Resource Acquisition Is Initialization — «захват ресурса есть инициализация»).

Аналогия из жизни. Когда вы берёте книгу в библиотеке, вы получаете «квитанцию» — документ, в котором записано, что книга у вас. Вернуть книгу нужно строго по правилам библиотеки. Если вы потеряете квитанцию и книгу, библиотека оштрафует. RAII в C++ работает так же: при «взятии» ресурса создаётся объект-квитанция (например, std::unique_ptr или std::lock_guard), а когда объект-квитанция выходит из области видимости, ресурс автоматически «возвращается». Никаких штрафов и утечек.

Раздел охватывает выделение памяти, умные указатели и правила их передачи в параметрах, а также разрыв циклов shared_ptr через weak_ptr.

R.1. Управляйте ресурсами автоматически с помощью RAII

Оригинал: R.1 — Manage resources automatically using resource handles and RAII (Resource Acquisition Is Initialization)

RAII — это центральная идиома C++ для работы с ресурсами. Суть проста: захват ресурса происходит в конструкторе объекта-обёртки, а освобождение — в его деструкторе. Симметрия конструктора и деструктора, обеспеченная самим языком (они вызываются автоматически в строго определённые моменты), повторяет симметрию известных пар: fopen/fclose, lock/unlock, new/delete, connect/disconnect.

Аналогия из жизни. Представьте рыболовный крючок с леской: леска привязана к удочке в момент покупки (конструктор), а когда удочка ломается — леска автоматически срезается (деструктор). Никто не забывает, потому что всё связано физически. RAII делает то же самое с памятью и другими ресурсами в C++ — связь «ресурс ↔ деструктор» зашита в тип.

Почему: при исключении или раннем return деструктор всё равно освободит ресурс. Без RAII каждый путь выхода из функции пришлось бы сопровождать ручными вызовами free/close/unlock, и хотя бы на одном из них легко ошибиться. С RAII код остаётся корректным независимо от того, какой путь выхода был выбран — нормальный, ранний return или throw.

Как: оборачивайте каждый парный ресурс в класс с конструктором (захватывает) и деструктором (освобождает). В стандартной библиотеке уже есть готовые RAII-обёртки для большинства случаев: std::unique_ptr/std::shared_ptr для памяти, std::lock_guard/std::unique_lock для мьютексов, std::fstream для файлов, std::ofstream/std::ifstream, std::thread (для потоков) и т. д. — прежде чем писать свою обёртку, поищите готовую.

Практическая подсказка. Правило трёх (и пяти) в C++ гласит: если вы определяете деструктор, конструктор копирования или оператор копирующего присваивания — почти наверняка нужно определить и все три. Это страхует от случайного «двойного освобождения» или «двойного владения».

Типичная ошибка. Считать, что try/catch достаточно: «я напишу try, в catch освобожу ресурс — и готово». Такой код быстро распухает, множится и всё равно пропускает утечки. RAII снимает эту головную боль полностью.

// Плохо: освобождение вручную на всех путях
void send(X* x, std::string_view dest)
{
    auto port = open_port(dest);
    mtx.lock();
    // ... если здесь исключение, блокировка и x утекут
    mtx.unlock();
    close_port(port);
    delete x;
}

// Хорошо: освобождение автоматическое
void send(std::unique_ptr<X> x, std::string_view dest)
{
    Port port{dest};                          // закроется сам
    std::lock_guard<std::mutex> guard{mtx};   // отпустит сам
    // ...
}   // всё освобождено, даже при исключении

R.2. В интерфейсах обозначайте сырыми указателями только отдельные объекты

Оригинал: R.2 — In interfaces, use raw pointers to denote individual objects (only)

Сырой указатель (T*) в C++ исторически использовался для всего: и для одиночных объектов, и для массивов, и для владения, и для наблюдения. Core Guidelines сужают его роль до одной простой функции: «указатель на один объект, не владею им». Если же нужна последовательность (массив), используйте либо владеющий контейнер вроде std::vector, либо невладеющее представление вроде std::span<T> (или std::string_view для строк). Комментарий вроде «n — число элементов» компилятор не проверяет: ни тип, ни компилятор не помогут поймать ошибку «передали указатель и забыли размер», а вот span и контейнеры несут эту информацию внутри типа.

Аналогия из жизни. Если вы скажете другу: «Дай мне указатель на третий стул слева», — это одно. А если скажете: «Дай мне адрес первого и длину 10», — это уже требует доверия к числу. Контейнер же — это всегда и адрес, и длина «в одном флаконе».

Почему: контейнеры и представления несут информацию о размере — это сразу устраняет целый класс ошибок (выход за границы, разыменование nullptr, забытый размер). А ещё span/vector умеют работать с диапазонами из стандартной библиотеки, что делает код короче и надёжнее.

Как: для последовательностей берите std::span<T> (если не владеете) или std::vector<T> (если владеете); сырой указатель T* — только для одиночного объекта. Если нужно передать подмножество — берите span<T> с указанием границ.

Практическая подсказка. std::span появился в C++20. До него для невладеющих представлений использовались пары «указатель + размер», std::array_view из GSL или контейнер целиком. Если ваш компилятор поддерживает C++20 — используйте span, это идиоматично и безопасно.

Типичная ошибка. Передавать int* p, int n и забыть обновлять n при изменении массива, либо передать nullptr и полагаться на то, что функция «как-нибудь разберётся». С span/vector эти ошибки невозможны в принципе.

// Плохо: размер в комментарии
void f(int* p, int n);

// Хорошо: размер внутри типа
void f(std::span<int> values);

R.3. Сырой указатель (T*) не владеет объектом

Оригинал: R.3 — A raw pointer (a T*) is non-owning

В C++ по умолчанию сырой указатель считается невладеющим: он лишь ссылается на объект и не несёт никакой ответственности за его освобождение. Владение должно выражаться отдельным типом — умным указателем (std::unique_ptr, std::shared_ptr) или контейнером. Это жёсткое разделение: одно дело — «указать, где лежит», другое — «я за это отвечаю». Сырой указатель — только первое.

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

Почему: если владеющие указатели всегда помечены отдельным типом, их можно надёжно освобождать автоматически, и компилятор/IDE/анализатор сразу видят: «вот это — владелец, его нельзя терять, через него нельзя делать delete вручную». А раз так — гораздо проще рассуждать о времени жизни объектов.

Как: не удаляйте объект через сырой указатель; владение — только через std::unique_ptr (единственный владелец) или std::shared_ptr (разделяемое владение). Если нужно передать «наблюдение» в функцию — передавайте сырой указатель или ссылку (Widget&), это явно говорит: «я просто смотрю».

Практическая подсказка. Если вы видите delete p в коде — это «красная лампочка» ревью. Скорее всего, здесь должно быть unique_ptr или shared_ptr. Исключения: внутри деструктора собственного RAII-класса (но тогда вы точно знаете, что делаете) и при работе с C-API через unique_ptr с пользовательским Deleter.

Типичная ошибка. Передать Widget* w в функцию и в ней же вызвать delete w. Это нарушает контракт «сырой указатель не владеет» и ломает все гарантии по времени жизни.

void observe(Widget* w);                 // только смотрит, не владеет

void own()
{
    auto w = std::make_unique<Widget>();
    observe(w.get());                    // передаём наблюдение
}   // w освобождается здесь

R.5. Предпочитайте объекты в области видимости, не выделяйте память в куче без нужды

Оригинал: R.5 — Prefer scoped objects, don't heap-allocate unnecessarily

Объект в области видимости (stack object, локальный объект) создаётся при входе в блок { ... } и уничтожается автоматически при выходе из него — в прямом и обратном порядке, что полностью совпадает с правилами RAII. Его время жизни строго ограничено областью видимости, и компилятор гарантирует, что деструктор сработает в любом случае, включая исключения. Куча (heap, динамическая память) нужна только тогда, когда объект обязан жить дольше одного блока/функции или когда его размер на момент компиляции неизвестен.

Аналогия из жизни. Локальный объект — это как тарелка в кафе: вы её взяли, поели, официант забрал. Всё автоматически. Куча — это как арендованная ячейка на складе: вы её сняли, положили вещи, и теперь обязаны сами не забыть её закрыть, продлить и в конце освободить. Чем больше таких ячеек — тем больше шанс забыть одну из них.

Почему: стек быстрее кучи (не нужен аллокатор, кэш процессора работает эффективнее, нет фрагментации) и не требует ручного освобождения. Кроме того, размер объекта на стеке часто известен заранее — компилятор может даже разместить его в регистрах.

Как: создавайте объекты в области видимости (int value = 42;, std::vector<int> values;). В кучу выносите только то, что должно жить дольше блока (make_unique/make_shared), либо коллекции с динамическим размером (std::vector, std::string, std::map).

Практическая подсказка. Если вы написали new/make_unique для одного-единственного объекта, который никуда не «убегает» из функции — это почти наверняка лишнее. Замените на локальную переменную.

Типичная ошибка. Писать auto p = std::make_unique<Widget>(); только ради того, чтобы потом положить в контейнер, — а сам контейнер уже сам владеет. Достаточно std::vector<Widget> v; v.emplace_back(...);.

// Плохо: куча без необходимости
auto value = std::make_unique<int>(42);

// Хорошо: объект в области видимости
int value = 42;

// Хорошо: динамическая коллекция
std::vector<int> values{1, 2, 3};

R.10. Избегайте malloc() и free()

Оригинал: R.10 — Avoid malloc() and free()

malloc и free — это функции, доставшиеся C++ из языка C. Они умеют выделять и освобождать только сырую память (последовательность байт), причём не вызывают конструкторы и деструкторы объектов. Для типов C++ — особенно нетривиальных, у которых есть поля с собственными конструкторами (например, std::string, std::vector) — это фатально: объект будет создан «кусок памяти заполнен», но не инициализирован. Кроме того, смешивать malloc/free с new/delete в одном объекте нельзя: они используют разные аллокаторы и могут вызвать сбой или повреждение кучи.

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

Почему: ручное управление памятью через malloc игнорирует жизненный цикл объекта: конструкторы, деструкторы, виртуальные таблицы, исключения. Любая попытка «привести результат malloc к типу C++» почти наверняка приведёт к UB (undefined behavior) или утечкам вложенных ресурсов. А ещё — new/delete в C++ умеют выбрасывать исключения при нехватке памяти, а malloc возвращает nullptr, и это легко забыть проверить.

Как: используйте new/delete только в редких случаях (например, в реализации собственного аллокатора или RAII-обёртки), а в прикладном коде — только умные указатели (make_unique/make_shared) или контейнеры (vector, string, map и т. д.).

Практическая подсказка. Если вам попался код с malloc — это «археологический» код, перенесённый из C. Почти всегда его можно заменить на std::vector<char>(n) или std::string(n, '\0'), либо на умный указатель с нужным размером: std::unique_ptr<T[]> arr(new T[n]);.

Типичная ошибка. Считать, что malloc(sizeof(Widget)) + static_cast<Widget*> «почти как new». Это UB: конструктор не вызван, поля не инициализированы, любое обращение к методам или полям объекта — неопределённое поведение.

// Плохо: память без вызова конструктора
Widget* w = static_cast<Widget*>(std::malloc(sizeof(Widget)));   // конструктор не вызван

// Хорошо: объект создаётся корректно
auto w = std::make_unique<Widget>();

R.11. Избегайте явных вызовов new и delete

Оригинал: R.11 — Avoid calling new and delete explicitly

Указатель, возвращённый оператором new, — это «голый» владелец памяти. По правилам Core Guidelines он должен сразу попасть во владеющий дескриптор (unique_ptr/shared_ptr). Если же записать его в обычный сырой указатель (T* p = new T;), объект становится «ничьим»: компилятор не знает, кто его освобождает, а программист обязан вручную расставить delete во всех ветках кода. В большой кодовой базе голый delete — почти гарантированный баг: вы физически не можете проверить, что все пути освобождения учтены, особенно когда функция большая или кто-то добавит новый return/throw посередине.

Аналогия из жизни. Оператор new — это как завести кота. delete — это отдать его в приют. Если вы «завели» кота в одной функции, а «отдать» должны в другой — рано или поздно вы забудете. Умный указатель — это «кошачий ошейник с авто-возвратом»: когда unique_ptr выходит из области видимости, кот автоматически отправляется куда надо. Вам не нужно помнить.

Почему: количество вызовов new и delete в программе должно совпадать, и каждый new должен быть слинкован с ровно одним delete на всех возможных путях выполнения. Это трудно гарантировать вручную даже в маленькой функции, а в большом проекте — почти невозможно. Каждое расхождение — либо утечка, либо двойное удаление (UB).

Как: используйте std::make_unique<T>(...) и std::make_shared<T>(...) для создания, и никогда не пишите delete в прикладном коде. Если вы ловите себя на мысли «тут нужен delete» — остановитесь и спросите: «а почему не unique_ptr?». Если ответ «потому что мне нужно вернуть указатель из функции» — возвращайте unique_ptr (он move-only, но это нормально). Если ответ «потому что у меня C-API» — оберните в unique_ptr<T, decltype(&api_free)>.

Практическая подсказка. Идиома, которой учат опытные C++-программисты: «delete пишет только тот, кто пишет собственный RAII-класс». Если вы не пишете свою обёртку над ресурсом — delete вам не нужен.

Типичная ошибка №1. Создать объект через new и сохранить в сырой указатель, потому что «так удобнее передавать». Удобнее — не значит безопаснее; правильное решение — std::unique_ptr с автоматической передачей владения через std::move.

Типичная ошибка №2. Писать delete p в деструкторе собственного класса, забыв, что компилятор уже мог синтезировать копирующий конструктор/оператор присваивания, которые тоже «удалили» бы объект → двойное удаление → UB. Решение — либо запретить копирование (= delete), либо использовать std::unique_ptr как поле.

// Плохо
Widget* w = new Widget;
// ... где-то должен быть delete w; — легко забыть

// Хорошо
auto w = std::make_unique<Widget>();

R.12. Немедленно передавайте результат явного выделения менеджеру ресурса

Оригинал: R.12 — Immediately give the result of an explicit resource allocation to a manager object

Определение. Менеджер ресурса (RAII-обёртка) — это объект, который владеет низкоуровневым ресурсом (файлом, памятью, сокетом и т.п.) и автоматически освобождает его в своём деструкторе. В C++ самый надёжный способ управлять ресурсом — обернуть его в такой объект сразу же в момент выделения.

Аналогия из жизни. Представьте, что вы открыли бутылку дорогого вина. Если вы сразу передадите пробку штопору-закрывашке (менеджеру), всё будет в порядке. Если же вы поставите открытую бутылку на стол и начнёте искать закрывашку, вино может пролиться (ресурс «утечёт»). В программировании любой код между выделением и обёрткой — это и есть тот промежуток, когда ресурс «открыт».

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

Почему так. Любая операция между захватом ресурса и помещением его в обёртку — это окно для утечки. Достаточно одного исключения, раннего return, break или даже простой арифметической ошибки, чтобы дескриптор «осиротел». С RAII-обёрткой деструктор сработает при раскрутке стека, а голый дескриптор никто не закроет.

Как делать правильно.

  • Оборачивайте ресурс сразу при создании — в той же строке, где он выделяется.
  • Не разделяйте выделение и обёртку даже временной переменной, если в этом нет острой необходимости.
  • Предпочитайте готовые RAII-типы из стандартной библиотеки (std::ifstream, std::vector, std::unique_ptr) собственным обёрткам.

Типичная ошибка.

  • Считать, что между fopen и fclose «ничего страшного не случится». В больших функциях любая строка может бросить исключение, и fclose окажется недостижим.
// Плохо: buf может не выделиться и «уронить» файл
void func(const std::string& name)
{
    FILE* f = std::fopen(name.c_str(), "r");
    std::vector<char> buf(1024);   // если бросит — f утечёт
    std::fclose(f);
}

// Хорошо: ресурс управляется сразу
void func(const std::string& name)
{
    std::ifstream f{name};         // владеет файлом
    std::vector<char> buf(1024);
}

R.20. Используйте unique_ptr или shared_ptr для выражения владения

Оригинал: R.20 — Use unique_ptr or shared_ptr to represent ownership

Определение. Умный указатель — это шаблонный класс-обёртка над «сырым» указателем, который автоматически вызывает delete (или иной освобождающий код), когда выходит из области видимости. Два основных вида: std::unique_ptr (единственный владелец) и std::shared_ptr (разделяемое владение через счётчик ссылок).

Аналогия из жизни. Обычный указатель — это записка с адресом квартиры: она ничего не гарантирует, и если вы потеряете записку, квартира так и не будет освобождена. unique_ptr — это владелец квартиры с одним ключом. shared_ptr — это ключи, розданные нескольким жильцам: пока хотя бы один держит ключ, квартира «занята», и только когда последний отдаст свой, её можно закрыть.

Умные указатели выражают владение в типе и автоматически освобождают объект, предотвращая утечки.

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

Как делать правильно.

  • Для единственного владельца — std::unique_ptr.
  • Для совместного владения несколькими независимыми частями кода — std::shared_ptr.
  • Сырой T*/T& оставьте для случаев «не владею, просто наблюдаю» или «владение берёт на себя внешний код».

Типичная ошибка.

  • Смешивать владение: одна часть кода освобождает объект через delete, другая — хранит указатель и думает, что объект ещё жив. Умный указатель делает владение однозначным.
void f()
{
    X* p1 = new X;                   // плохо: p1 утечёт
    auto p2 = std::make_unique<X>(); // хорошо: единоличное владение
    auto p3 = std::make_shared<X>(); // хорошо: общее владение
}

R.21. Предпочитайте unique_ptr вместо shared_ptr, если не нужно разделять владение

Оригинал: R.21 — Prefer unique_ptr over shared_ptr unless you need to share ownership

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

Аналогия из жизни. unique_ptr — это как личный автомобиль: ключи в одних руках, момент «парковки» предсказуем. shared_ptr — это каршеринг со счётчиком пользователей: пока хотя бы один клиент числится активным, машина не уезжает на стоянку, и за каждый «запрос на использование» нужно атомарно обновить счётчик, что стоит времени.

unique_ptr концептуально проще (момент разрушения предсказуем) и быстрее (нет счётчика ссылок). shared_ptr нужен только при реальном совместном владении.

Почему так. Счётчик ссылок — это атомарные операции инкремента/декремента при каждом копировании и уничтожении shared_ptr. Эти операции дороже обычных, особенно в многопоточных программах. Кроме того, shared_ptr требует отдельного управляющего блока, что увеличивает расход памяти и усложняет кэш-попадания. Наконец, shared_ptr создаёт иллюзию, что владение «общее», хотя на практике часто нужен лишь один владелец, а остальные — наблюдатели.

Как делать правильно.

  • По умолчанию берите unique_ptr.
  • Переходите на shared_ptr только тогда, когда владение реально разделяется и время жизни объекта определяется «последним выходящим».
  • Если нужно поделиться объектом, но владение должно остаться у одного — отдавайте shared_ptr (или const T&/T*) из функции, владеющей ресурсом.

Типичная ошибка.

  • Использовать shared_ptr «на всякий случай», по инерции или потому что в команде так принято. Это лишняя цена без выгоды.
// Плохо: shared_ptr там, где хватает единоличного владения
std::shared_ptr<Base> b = std::make_shared<Derived>();

// Хорошо: проще и быстрее
std::unique_ptr<Base> b = std::make_unique<Derived>();

R.22. Используйте make_shared() для создания shared_ptr

Оригинал: R.22 — Use make_shared() to make shared_ptrs

Определение. std::make_shared<T>(args...) — фабричная функция, которая в одном выделении памяти создаёт и сам объект T, и управляющий блок shared_ptr (со счётчиком ссылок и «слабым» счётчиком). После этого возвращается готовый shared_ptr<T>.

Аналогия из жизни. Это как заказать дом вместе с почтовым ящиком: адрес, дом и ящик появляются одним заказом. Без make_shared — это построить дом, а потом отдельно заказывать почтовый ящик: две сделки, два риска, что что-то пойдёт не так.

make_shared короче, позволяет разместить счётчик ссылок рядом с объектом (одно выделение вместо двух) и безопаснее в сложных выражениях.

Почему так.

  • Меньше выделений. Один new вместо двух → меньше нагрузка на аллокатор, лучше локальность данных.
  • Безопасность исключений. В выражении вроде process(shared_ptr<X>(new X), some_other_call()) компилятор может выделить X, затем вызвать some_other_call(), и лишь потом сконструировать shared_ptr. Если some_other_call() бросит, «голый» new X утечёт. make_shared связывает выделение и обёртку неразрывно.
  • Короче и понятнее. Нет повторяющегося имени типа: auto p = std::make_shared<X>(2);.

Как делать правильно.

  • Создавайте разделяемые объекты через make_shared.
  • Используйте make_shared в аргументах функций, чтобы исключить утечку из-за исключений.

Типичная ошибка.

  • Писать std::shared_ptr<X>(new X(...)) — и терять и компактность, и безопасность исключений.

Замечание. Из-за совмещённого выделения память объекта и управляющего блока не освобождается, пока существуют и обычные shared_ptr, и weak_ptr на этот объект. В редких случаях с большими объектами и долгоживущими weak_ptr это может задержать освобождение — но для большинства сценариев это преимущество.

// Плохо
std::shared_ptr<X> p1{new X{2}};

// Хорошо
auto p = std::make_shared<X>(2);

R.23. Используйте make_unique() для создания unique_ptr

Оригинал: R.23 — Use make_unique() to make unique_ptrs

Определение. std::make_unique<T>(args...) — фабричная функция (появившаяся в C++14), которая создаёт объект T через new и сразу оборачивает его в std::unique_ptr<T>.

Аналогия из жизни. Это как заказать готовый пирог, а не покупать отдельно тесто, начинку и форму: меньше шагов, меньше шансов что-то забыть. Кроме того, пирог сразу упакован в форму-«владельца», которая его не уронит.

make_unique делает создание кратким и избавляет от явного new. В отличие от make_shared, он не создаёт отдельного управляющего блока — ему это не нужно, так как владелец только один.

Почему так.

  • Короче. Не нужно повторять имя типа дважды: auto p = std::make_unique<Widget>(42);.
  • Безопаснее. Нет риска утечки из-за исключения в сложных выражениях (тот же аргумент, что и для make_shared).
  • Безопаснее от случайного «голого» new. Явный new — это самый распространённый источник утечек в C++; make_unique делает его ненужным.

Как делать правильно.

  • Создавайте единолично владеемые объекты через make_unique.
  • Не пишите unique_ptr<T>(new T(...)) без очень веской причины.

Типичная ошибка.

  • Забыть инициализировать unique_ptr в конструкторе, оставив его на потом, и случайно получить утечку через промежуточный код.
auto p = std::make_unique<Widget>(42);   // вместо unique_ptr<Widget>{new Widget{42}}

R.24. Используйте std::weak_ptr для разрыва циклов shared_ptr

Оригинал: R.24 — Use std::weak_ptr to break cycles of shared_ptrs

Определение. std::weak_ptr<T> — это наблюдатель за объектом, на который указывает shared_ptr, но без участия в подсчёте «сильных» ссылок. Чтобы получить доступ к объекту, нужно вызвать weak_ptr::lock(), который возвращает shared_ptr (или пустой указатель, если объект уже разрушен).

Аналогия из жизни. Два друга договорились: «пока оба живы, помогаем друг другу». Если дружба выражена через взаимные крепкие рукопожатия (shared_ptr), никто не разожмёт руку первым — и оба «застрянут» навечно. weak_ptr — это как дружба-«наблюдение»: «я тебя вижу, но в счёт рукопожатий не вхожу». Если один из друзей «уходит», второй спокойно отпускает руку.

Если два объекта ссылаются друг на друга через shared_ptr, счётчики никогда не обнулятся, и память утечёт. Одна из ссылок должна быть weak_ptr.

Почему так. Циклические shared_ptr образуют «остров» объектов, счётчик ссылок которого всегда ≥ 1. Стандартные средства (деструкторы, сборка мусора) не помогают, потому что деструкторы никогда не вызываются: каждый объект ждёт, пока его «ребёнок» отпустит ссылку, а тот, в свою очередь, ждёт «родителя». Это классическая утечка памяти, которую не видно ни по адресам, ни по valgrind в простых прогонах.

Как делать правильно.

  • Заменяйте одну ссылку в цикле (чаще всего «родитель → ребёнок» или «владелец → наблюдатель») на weak_ptr.
  • Перед использованием объекта через weak_ptr вызывайте lock() и проверяйте результат на nullptr.
  • Проектируйте граф владения так, чтобы он был направленным (DAG): владелец → владеемое, а обратные связи — через weak_ptr.

Типичная ошибка.

  • Превращать «родительско-дочерние» связи в обоих направлениях через shared_ptr (например, двусвязный список с двумя сильными ссылками у каждого узла). Такой список не освободится целиком, пока кто-то явно не разорвёт цикл.
struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;   // слабая ссылка разрывает цикл
};

R.30. Принимайте умные указатели как параметры только для явного выражения семантики времени жизни

Оригинал: R.30 — Take smart pointers as parameters only to explicitly express lifetime semantics

Определение. Параметр типа unique_ptr<T> или shared_ptr<T> в сигнатуре функции — это не просто «адрес объекта», а декларация о том, как функция участвует во владении объектом. Сырой указатель T* или ссылка T& говорят: «я лишь пользуюсь объектом, его время жизни — не моя забота».

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

Передача умного указателя — это утверждение о владении. Если функция лишь пользуется объектом, нужен T* или T&.

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

  • снижает гибкость (объект нельзя передать из unique_ptr или «сырого» указателя без копирования/обёртывания);
  • увеличивает накладные расходы (копирование shared_ptr — это атомарный инкремент);
  • запутывает читателя: непонятно, функция делит владение или просто использует.

Как делать правильно.

  • Принимайте std::unique_ptr<T> — если функция забирает владение (как правило, по значению или через rvalue-ссылку).
  • Принимайте std::shared_ptr<T> — только если функция реально разделяет владение (например, кладёт объект в контейнер и планирует пользоваться им дольше вызова).
  • В остальных случаях — const T& (для чтения) или T* (если nullptr — допустимое значение).

Типичная ошибка.

  • Принимать const std::shared_ptr<T>& «чтобы не копировать». Это всё равно требует от вызывающего именно shared_ptr, а простая ссылка const T& была бы и дешевле, и гибче.
void consumes(std::unique_ptr<Widget> w);   // забирает владение
void shares(std::shared_ptr<Widget> w);     // разделяет владение
void uses(const Widget& w);                 // только пользуется

R.32. Принимайте unique_ptr<widget>, чтобы выразить, что функция забирает владение объектом

Оригинал: R.32 — Take a unique_ptr<widget> parameter to express that a function assumes ownership of a widget

Определение. Параметр типа std::unique_ptr<Widget> (обычно передаваемый по значению) означает: вызывающий передаёт единственный экземпляр владения, и теперь функция отвечает за время жизни объекта — в частности, за его уничтожение при выходе из области видимости.

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

Почему так. Это правило вытекает из R.30, но для самого распространённого случая — передачи владения. Сигнатура с std::unique_ptr<Widget>:

  • Делает передачу владения явной. Читающий код сразу видит: «после этого вызова прежний владелец больше не имеет объекта».
  • Запрещает случайную «утечку по незнанию». Нельзя вызвать такую функцию, не подумав, кто теперь владелец.
  • Позволяет безопасно перемещать объект в хранилища функции (контейнеры, очереди, пулы) с гарантией единоличности владения.

Как делать правильно.

  • Передавайте std::unique_ptr<Widget> по значению, когда функция должна сохранить владение (например, положить объект в свой контейнер).
  • Если функция лишь временно пользуется объектом внутри себя — принимайте сырой Widget* или ссылку Widget&.
  • Не принимайте std::unique_ptr<Widget>& «ради передачи»: лучше явное перемещение через значение — тогда невозможно случайно оставить объект с двух сторон.

Типичная ошибка.

  • Принять unique_ptr по константной ссылке и не сразу решить, кому он принадлежит после вызова. Либо забирайте владение по значению, либо вообще не принимайте unique_ptr. Параметр типа unique_ptr<Widget> по значению означает: функция становится владельцем и уничтожит объект. Это делает передачу владения явной.

std::unique_ptr<T> — это «умная обёртка» над сырым указателем, которая единолично владеет объектом. Когда unique_ptr уничтожается, автоматически вызывается delete для объекта. Скопировать такой указатель нельзя — только переместить (std::move), тем самым явно передав владение другому.

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

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

Как: принимайте unique_ptr по значению, когда функция забирает объект. Если же функция лишь временно пользуется объектом — лучше принимать Widget*, Widget& или const Widget& (см. соседние правила).

void store(std::unique_ptr<Widget> w)   // теперь владеет w
{
    // ...
}   // w уничтожается здесь

store(std::make_unique<Widget>());      // передаём владение

** Типичная ошибка:** иногда новички пишут void store(std::unique_ptr<Widget>& w), пытаясь «избежать копирования». Но ссылки на unique_ptr не дают владения, а функция не сможет гарантировать, что объект будет уничтожен. Используйте ссылку, только если функция — временный наблюдатель, а не новый владелец.


R.37. Не передавайте указатель или ссылку, полученные из псевдонима умного указателя

Оригинал: R.37 — Do not pass a pointer or reference obtained from an aliased smart pointer

std::shared_ptr<T> — это умный указатель с подсчётом ссылок. Объект живёт, пока хотя бы один shared_ptr указывает на него. У него есть метод .get(), возвращающий сырой указатель. std::weak_ptr<T> — это «наблюдатель» за shared_ptr. Он не увеличивает счётчик ссылок и не удерживает объект от уничтожения. Чтобы безопасно им воспользоваться, нужно вызвать .lock(), который вернёт валидный shared_ptr (или пустой, если объект уже удалён). Висячий указатель (dangling pointer) — это указатель, который ссылается на уже удалённый объект. Разыменование такого указателя — неопределённое поведение (UB): программа может упасть, испортить память или «молча» выдать неверный результат.

Если несколько shared_ptr указывают на один объект, а между получением сырого указателя и его использованием счётчик ссылок может обнулиться, объект будет уничтожен, а указатель станет висячим.

Аналогия из жизни: вы сделали фото билета на концерт и ушли. Билет на руках у друга, но если друг его выбросит — фото останется, а пройти по нему уже не получится. Точно так же сырой указатель — это «фото», а shared_ptr — оригинальный билет, который держит объект «живым».

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

Как: храните shared_ptr (или weak_ptr) на всё время использования, не «заимствуйте» указатель в долгоживущий объект. Если нужно наблюдать — используйте weak_ptr и преобразуйте его в shared_ptr через .lock() только в момент непосредственного использования.

// Плохо: сырой указатель живёт дольше, чем гарантия владения
struct Holder {
    Widget* ptr;   // кто владеет?
};

// Хорошо: держим shared_ptr
struct Holder {
    std::shared_ptr<Widget> ptr;
};

** Практическая подсказка:** если поле класса хранит указатель на ресурс, которым владеет кто-то другой — почти наверняка правильным решением будет std::weak_ptr или std::shared_ptr, но не голый T*. Это делает время жизни объекта явным и проверяемым.

** Типичные ошибки:**

  1. Сохранить .get() «на потом». Получили указатель, положили в переменную, через пару строк используете — а тем временем последний shared_ptr уже вышел из области видимости. Готова мина замедленного действия.
  2. Передать .get() в колбэк или очередь событий. Колбэк вызовется позже, когда объект уже могут удалить. Используйте shared_ptr, который «удержит» объект до момента вызова.
  3. Использовать weak_ptr без .lock(). У weak_ptr нет оператора -> напрямую (точнее, он есть, но бросает исключение, если объект умер). Безопасный путь — всегда вызывать .lock() и проверять результат на nullptr.

ES. Выражения и операторы (Expressions and statements)

Что это такое? Выражения и операторы — это базовые «кирпичики» любой программы на C++. Выражение — это всё, что вычисляется в значение (например, a + b, x == 5, func()). Оператор (statement) — это законченная команда, которая говорит компилятору, что сделать (присваивание, цикл, if, объявление переменной). Проще говоря: выражение — это значение, а оператор — это действие.

Это самый низкий и прямой способ описать действия и вычисления в коде. Объявления в локальных областях видимости — тоже операторы, потому что в момент объявления переменная «рождается» и получает область видимости. Отдельные подразделы посвящены объявлениям, выражениям, операторам и арифметике. Правила про то, как объявлять переменные (инициализировать всё, не прятать имена, держать области видимости маленькими), какие конструкции предпочитать, как избегать приведений типов, магических чисел и сужающих преобразований. Здесь же — арифметика со знаковыми и беззнаковыми типами и запрет деления на ноль.

Аналогия из жизни: представьте кулинарный рецепт. Ингредиенты — это данные, а фразы вроде «возьмите 200 г муки» или «перемешайте до однородности» — это выражения и операторы. Если в рецепте написано «возьмите муку» без указания количества — повар может насыпать слишком мало или слишком много. Так же и в C++: неинициализированная переменная — это ингредиент «без количества», и результат становится непредсказуемым.

ES.1. Предпочитайте стандартную библиотеку другим библиотекам и «самописному коду»

Оригинал: ES.1 — Prefer the standard library to other libraries and to "handcrafted code"

Что такое стандартная библиотека? Это набор готовых компонентов (контейнеры, алгоритмы, строки, потоки ввода-вывода и др.), который поставляется вместе с компилятором C++ и является частью стандарта языка. STL (Standard Template Library) — её ключевая часть про контейнеры и алгоритмы.

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

Почему: стандартные средства надёжнее (их код просмотрели миллионы глаз), быстрее «из коробки» (компиляторы умеют их оптимизировать) и понятнее коллегам — любой C++-разработчик знает std::vector, а вашу собственную функцию mySuperVector придётся изучать заново. Как: прежде чем писать своё, ищите готовое в STL (<algorithm>, <numeric>, <ranges>). Используйте принцип «пишите меньше — повторяйте меньше».

// Плохо: ручной поиск максимума
int max_v = v[0];
for (std::size_t i = 1; i < v.size(); ++i) if (v[i] > max_v) max_v = v[i];

// Хорошо: стандартный алгоритм
int max_v = *std::ranges::max_element(v);

Типичная ошибка: пытаться «улучшить» стандартный алгоритм ради пары наносекунд выигрыша, не проведя профилирование. В 99% случаев стандартная реализация уже оптимальна или близка к оптимальной, а ваша «оптимизация» принесёт баги.


ES.11. Используйте auto, чтобы не повторять имена типов

Оригинал: ES.11 — Use auto to avoid redundant repetition of type names

Что такое auto? Это ключевое слово, которое говорит компилятору: «подбери тип переменной сам, исходя из того, что ей присваивается». Тип выводится на этапе компиляции, никакого «динамического» типа в рантайме не появляется — это по-прежнему строгая статическая типизация.

auto исключает повторение длинных имён типов и не даёт случайно сузить значение. При этом важно не терять const и ссылки: голый auto копирует значение, что для тяжёлых объектов (например, std::string или контейнеров) может быть накладно.

Почему: меньше «шума» в коде и меньше ошибок в длинных именах типов (особенно когда тип содержит вложенные шаблоны вроде std::map<std::string, std::vector<int>>::const_iterator). Как: применяйте auto для очевидных типов; auto&/const auto& — чтобы не копировать большие объекты; auto* — чтобы подчеркнуть, что работаете с указателем.

std::map<std::string, std::vector<int>> data;

// Плохо: длинный тип повторяется
for (std::map<std::string, std::vector<int>>::iterator it = data.begin(); it != data.end(); ++it) {}

// Хорошо
for (const auto& [key, values] : data) { /* ... */ }

Типичная ошибка: писать auto x = some_big_object; вместо const auto& x = some_big_object;. В первом случае будет создана полная копия объекта, что медленно и расходует память. Всегда спрашивайте себя: «мне нужен оригинал или ссылка?».


ES.20. Всегда инициализируйте объект

Оригинал: ES.20 — Always initialize an object

Что значит «инициализация»? Это присвоение переменной начального значения в момент её создания. В C++ переменная встроенного типа (int, double, указатель), если её не инициализировать, получает неопределённое значение — то, что случайно оказалось в памяти.

Неинициализированная переменная содержит мусор, а её чтение — неопределённое поведение (UB, undefined behavior). Это значит, что программа может делать что угодно: вывести 0, вывести 4 миллиарда, зависнуть, удалить ваш файл. Всегда давайте начальное значение.

Почему: чтение мусора даёт непредсказуемые результаты, которые сложно воспроизвести и отладить — баг может «прятаться» неделями. Как: инициализируйте при объявлении; для членов класса используйте инициализаторы по умолчанию (начиная с C++11 — прямо в объявлении поля).

// Плохо
int x;          // значение неизвестно
std::cout << x; // UB

// Хорошо
int x = 0;
double price{0.0};
std::string name;   // пустая строка — корректное значение

Типичная ошибка: считать, что int x = 0; «лишняя» строка и писать просто int x; «для скорости». Никакой выгоды в скорости это не даёт, а вот отладка потом «съест» часы. Современные компиляторы прекрасно оптимизируют инициализацию.


ES.23. Предпочитайте инициализацию фигурными скобками {}

Оригинал: ES.23 — Prefer the {}-initializer syntax

Что такое инициализация фигурными скобками? Это синтаксис вида T var{value}; или T var{val1, val2, val3};. В стандарте он называется list-initialization (списочная инициализация). Появился в C++11 и стал единым способом инициализировать что угодно: от простых чисел до сложных контейнеров.

Списочная инициализация запрещает сужающие преобразования (narrowing conversions) и позволяет единообразно инициализировать всё — от целых чисел до контейнеров. Сужающее преобразование — это когда тип значения «не помещается» в целевой тип без потерь.

Почему: {} ловит опасное сужение при компиляции, превращая коварный runtime-баг в честную ошибку сборки. Как: по умолчанию используйте {}, а не = или (). Исключение — случай, когда () действительно нужен (например, для вызова конструктора без аргументов у типа со списочной инициализацией).

// Плохо: сужение молча происходит
int x = 7.9;        // x == 7, дробная часть потеряна

// Хорошо: компилятор сообщит об ошибке
int y{7.9};         // ошибка: сужение double -> int

std::vector<int> v{1, 2, 3};   // инициализация списком

Типичная ошибка: использовать {} для типов вроде std::vector<int> v{5, 10};, ожидая «вектор из 10 элементов со значением 5». На самом деле здесь создаётся вектор из двух элементов: 5 и 10. Для «N копий значения» используется другая форма: std::vector<int> v(5, 10);.


ES.25. Объявляйте объект const или constexpr, если не собираетесь его менять

Оригинал: ES.25 — Declare an object const or constexpr unless you want to modify its value later on

Что такое const и constexpr?

  • const — «константа»: обещает, что значение переменной не будет изменено после инициализации. Компилятор запретит любую попытку присвоения.
  • constexpr — «константа времени компиляции»: гарантирует, что значение вычисляется на этапе компиляции, а не в рантайме. Это строже, чем const.

const документирует намерение не изменять объект и позволяет компилятору оптимизировать (например, подставить значение напрямую). constexpr гарантирует вычисление при компиляции и может использоваться везде, где требуются compile-time константы (размеры массивов, шаблоны и т.п.).

Почему: неизменяемые данные безопаснее (компилятор защищает от случайных изменений) и понятнее читателю кода. Как: по умолчанию пишите const, снимайте его только при реальной необходимости. Если значение известно на этапе компиляции — используйте constexpr.

const double pi = 3.14159;      // не меняется
constexpr int limit = 100;      // вычисляется при компиляции

int compute(const std::vector<int>& data);   // data не изменяется

Типичная ошибка: забывать const у параметров функций, принимающих «тяжёлые» объекты (std::string, std::vector). Без const аргумент может быть случайно изменён внутри функции, а передача по значению создаст лишнюю копию.


ES.46. Избегайте сужающих (теряющих данные) арифметических преобразований

Оригинал: ES.46 — Avoid lossy (narrowing, truncating) arithmetic conversions

Что такое сужающее преобразование? Это неявное или явное преобразование числового типа, при котором значение теряет точность или вообще не помещается в новый тип. Классические примеры: double → int (отбрасывается дробная часть), long → short (если число больше 32767, оно «оборачивается»), int → bool (любое ненулевое значение становится true).

Приведение большего типа к меньшему теряет данные: double → int, long → short. Это частый источник ошибок, потому что компилятор C++ по умолчанию разрешает такие преобразования молча.

Почему: потеря точности может быть незаметна и опасна — программа продолжает работать, но выдаёт неверные результаты. Как: используйте {} для инициализации (запрещает сужение компиляцией), gsl::narrow_cast (бросает исключение, если значение не помещается), явные проверки диапазона перед преобразованием.

double d = 1e10;

// Плохо: молчаливое сужение
int i = d;

// Хорошо: явное и проверяемое сужение
int j = gsl::narrow_cast<int>(d);   // бросит, если значение не помещается

Типичная ошибка: писать (int)some_double (C-style cast) и считать, что «так надёжнее». На самом деле C-style cast ещё опаснее: он выполняет сужение молча и без проверок. Всегда лучше явно показать компилятору и читателю, что вы делаете осознанное преобразование.


ES.47. Используйте nullptr вместо 0 или NULL

Оригинал: ES.47 — Use nullptr rather than 0 or NULL

Что такое nullptr? Это специальное ключевое слово (появилось в C++11), представляющее значение «нулевой указатель». Имеет собственный тип std::nullptr_t, который не может быть неявно преобразован в целое число.

nullptr имеет тип std::nullptr_t, поэтому не может быть спутан с целым числом. 0 и NULL в контексте перегрузки ведут себя неожиданно: 0 — это int, а NULL в одних реализациях определяется как 0, в других — как (void*)0. Из-за этого компилятор может вызвать не ту функцию.

Почему: nullptr однозначен и типобезопасен — компилятор всегда знает, что это указатель, и не перепутает его с числом. Как: всегда пишите nullptr для нулевого указателя; забудьте про 0 и NULL в новом коде.

void f(int);
void f(Widget*);

f(NULL);       // неоднозначно / вызовет f(int)
f(nullptr);    // однозначно: f(Widget*)

Типичная ошибка: писать if (ptr == 0) или if (ptr == NULL). Это работает, но стилистически устарело. Современный стиль — if (ptr == nullptr) или просто if (!ptr). Второй вариант короче и столь же надёжен.

ES.48. Избегайте приведений типов

Оригинал: ES.48 — Avoid casts

Что такое приведение типов (cast)? Это просьба к компилятору: «перестань проверять типы и просто поверь, что я знаю, что делаю». По сути, вы отключаете «страховку» типов на том участке кода, где стоит cast.

Аналогия из жизни. Представьте, что на стройке инженер подписывает бумагу: «я гарантирую прочность этой балки». Компилятор — это контролёр, который без бумаги не пропустит деталь на объект. Cast — это когда вы говорите контролёру: «не проверяй, я сам проверил». Если вы ошиблись — балка упадёт.

Приведение типов отключает проверки компилятора и часто маскирует ошибку. Если приведение «неизбежно», это сигнал пересмотреть дизайн.

Почему: каждое приведение — потенциальная дыра в типобезопасности. Компилятор C++ тратит много сил, чтобы следить, что int не попал туда, где ждут Widget*, а Cat* не оказался в переменной Dog*. Cast буквально говорит: «не смотри сюда». Программа не падает сразу — она делает вид, что всё хорошо, и взрывается позже, в другом месте.

Как: сначала пробуйте обойтись без него; если нельзя — используйте именованные приведения. Четыре именованных приведения (static_cast, dynamic_cast, const_cast, reinterpret_cast) делают намерение программиста явным: каждое имя подсказывает, что именно вы делаете.

// Плохо: C-приведение маскирует проблему
auto* w = (Widget*)raw_ptr;

// Хорошо: если класс действительно полиморфный — dynamic_cast
auto* w = dynamic_cast<Widget*>(base_ptr);
if (w) { /* ... */ }

Практический совет. В C++ есть пять видов приведений: C-style ((T)x), static_cast, const_cast, dynamic_cast, reinterpret_cast. C-style приведение — самое опасное: компилятор сам выбирает, какой из четырёх именованных кастов применить, и вы не видите этого в коде. Поэтому правило простое: если увидели C-style cast в современном коде — почти всегда это ошибка дизайна.

Типичная ошибка. Программист пишет (Derived*)base вместо dynamic_cast<Derived*>. Если объект на самом деле не Derived, указатель окажется «кривым», и при первом обращении к полю наследника произойдёт UB или порча памяти.

ES.50. Не снимайте const

Оригинал: ES.50 — Don't cast away const

Что такое const? Это обещание компилятору и читателю кода: «я не буду менять этот объект». Оно превращает объект в «контракт только для чтения».

Аналогия из жизни. Вы взяли в библиотеке книгу с пометкой «только для чтения». const_cast — это когда вы стираете эту пометку маркером и пишете карандашом в книге. Если книга была действительно «только для чтения» (хранилась на особом стенде), вас ждут неприятности.

const_cast, убирающий константность, — почти всегда признак ошибки в дизайне. Если объект изначально был объявлен const, изменение его через снятый const — неопределённое поведение.

Почему: снятие const нарушает контракт и часто ведёт к UB. Компилятор имел полное право положить const-объект в память, защищённую от записи (например, в .rodata или в регистр). Попытка туда записать — неопределённое поведение: программа может упасть, молча испортить данные или «сделать вид», что запись прошла.

Как: исправьте интерфейс, а не обходите его. Если функции нужно менять объект, значит, её параметр не должен быть const — это не баг, а уточнение контракта.

// Плохо: обходим константность
void update(const Widget& w) {
    auto& mut = const_cast<Widget&>(w);
    mut.set_value(42);   // UB, если w изначально const
}

// Хорошо: примите неконстантную ссылку, если собираетесь менять
void update(Widget& w) { w.set_value(42); }

Практический совет. Иногда снятие const оправдано: например, у вас логически один и тот же объект, но физически он хранится в const-кэше. В таких случаях исходный объект НЕ был объявлен const — он стал const только из-за интерфейса. Это безопасно. Но если исходный объект изначально const — менять его через const_cast всегда UB.

Типичная ошибка. Новичок видит ошибку компилятора «cannot bind non-const reference to const object» и сразу добавляет const_cast, не задумываясь, что проще исправить сигнатуру функции.

ES.56. Пишите std::move() только когда действительно нужно явно переместить объект в другую область

Оригинал: ES.56 — Write std::move() only when you need to explicitly move an object to another scope

Что такое std::move? Это ничего не делающая функция-маркер: она просто превращает своё значение в «rvalue» — то есть говорит компилятору: «этой штукой скоро перестанут пользоваться, можно забрать её внутренности». Реальное «перемещение» выполняют конструктор и оператор перемещения того типа, который вы передаёте.

Аналогия из жизни. std::move — это не грузовик, который перевозит вещи. Это ярлык «передать другому», который вы клеите на коробку. Дальше получатель сам разбирается, как её забрать.

std::move не перемещает сам по себе — он лишь приводит к rvalue, разрешая перемещение. Лишний std::move мешает оптимизациям и может ухудшить код.

Почему: перемещение уместно при передаче владения, а не везде. В C++ компилятор умеет делать NRVO (Named Return Value Optimization): если вы возвращаете локальную переменную по имени, он часто «встраивает» её прямо в место, где её принимают, — без копий и без перемещений. Если вы добавляете std::move в return, вы делаете переменную rvalue, и компилятор обязан выбрать перемещающий конструктор — NRVO больше не сработает. Результат: вместо ноля копий вы получаете одну.

Как: пишите std::move, только когда объект действительно передаётся «наружу» и после этого использоваться не будет.

// Плохо: move мешает NRVO
std::vector<int> f() {
    std::vector<int> v = build();
    return std::move(v);
}

// Хорошо: перемещение при передаче владения
void sink(std::string s);
std::string text = "данные";
sink(std::move(text));   // text больше не используем

Практический совет. Правило большого пальца: std::move ставьте там, где без него будет лишнее копирование, и не ставьте там, где без него компилятор сам всё оптимизирует. В return local; без std::move компилятор сам применяет NRVO или перемещение — лучше не трогать. А вот sink(std::move(text)) — уместно: локальная переменная text точно «умирает», и sink должен забрать её строковое представление.

Типичная ошибка. Студент пишет return std::move(local) «для эффективности» — и тем самым отключает NRVO. До C++17 это иногда было оправдано, но в современном C++ NRVO гарантированно работает (начиная с C++17) и обычно лучше явного перемещения.

ES.63. Не допускайте срезания (slicing)

Оригинал: ES.63 — Don't slice

Что такое срез (slicing)? Это когда объект наследника копируется в переменную (или параметр) базового типа — и часть данных, специфичная для наследника, молча «отрезается». После копирования остаётся только «базовая» часть объекта, как кочерыжка от яблока.

Аналогия из жизни. У вас есть полная медицинская карта пациента (имя, диагноз, анализы). Если вы «скопируете» её в форму «только ФИО и пол», вы потеряете диагноз и анализы — они просто не поместятся в новый бланк. Врач смотрит в карту и не понимает, чем болен пациент.

Копирование объекта наследника в переменную базового типа «срезает» производную часть: остаётся только базовая. Это скрытая потеря данных.

Почему: срез ломает полиморфизм и данные. Когда вы передаёте объект по значению в параметр базового типа, вызывается конструктор копирования Base, а он ничего не знает о полях наследника. Виртуальные методы после этого работают странно: draw() вернёт «нарисуй базовую фигуру», даже если вам передали Circle с radius.

Как: работайте через ссылку или указатель, не копируйте значение в базу. Если нужен полиморфизм — принимайте Base& или Base* (или умный указатель), но не Base.

struct Shape { virtual ~Shape() = default; };
struct Circle : Shape { double radius; };

// Плохо: срез — Circle превращается в Shape
void draw(Shape s);

// Хорошо: полиморфизм сохранён
void draw(const Shape& s);

std::vector<std::unique_ptr<Shape>> shapes;   // без среза

Практический совет. Контейнеры полиморфных объектов в C++ требуют именно умных указателей: std::vector<std::unique_ptr<Base>>. Если написать std::vector<Base>, в него попадут «нарезанные» копии, а не настоящие наследники. То же самое касается полиморфного хранения в картах, списках и любых value-контейнерах.

Типичная ошибка. Студент пишет void render(Shape s) для работы с Circle и Square — и удивляется, почему radius исчез. Исправление — одна буква: const Shape&.

ES.65. Не разыменовывайте недействительный указатель

Оригинал: ES.65 — Don't dereference an invalid pointer

Что такое «разыменование»? Это операция *p или p->field, то есть обращение к памяти, на которую указывает указатель. Перед каждым таким обращением указатель должен быть «живым» — вести на реально существующий объект.

Аналогия из жизни. Указатель — это адрес на бумажке: «дом №5, квартира 12». Если дом снесли, квартиру заняли чужие люди или бумажка вообще про другой город — приехав по адресу, вы попадёте не туда. То же и с памятью: разыменование «битого» указателя — это путешествие в чужую или разрушенную память.

Указатель может быть нулевым, висячим или указывать на освобождённую память. Разыменование такого указателя — неопределённое поведение.

Почему: ошибки доступа к памяти трудно найти и они опасны. Симптомы — самые разные: падение программы, порча соседних данных, странные значения в отладочном выводе, «работает в Debug, падает в Release». Это UB, и компилятор имеет право скомпилировать такой код как угодно — в том числе совсем не так, как вы ожидаете.

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

Widget* w = find_widget(name);
if (w != nullptr) {
    w->draw();            // проверка перед разыменованием
}

// Лучше: необработанный указатель не живёт долго
if (auto w = find_widget(name)) {
    w->draw();
}

Практический совет. Три «классических» недействительных указателя:

  1. Нулевой (nullptr) — ни на что не указывает; проверяется оператором != nullptr.
  2. Висячий (dangling) — указывает на уже разрушенный объект (например, на локальную переменную, чья функция вернула значение, или на элемент удалённого вектора).
  3. Дикий (uninitialized) — никогда не был инициализирован; содержит «случайный» адрес.

Каждый из них лечится по-своему: первый — проверкой, второй и третий — умными указателями и аккуратным владением.

Типичная ошибка. После delete ptr; забывают написать ptr = nullptr; и дальше используют ptr в другом ветвлении кода. С std::unique_ptr такой ошибки просто не возникает — деструктор обнулит указатель за вас.

ES.76. Избегайте goto

Оригинал: ES.76 — Avoid goto

Что такое goto? Это инструкция «перепрыгни в другую точку программы прямо сейчас». В ассемблере это нормально, но в высокоуровневом языке с циклами и функциями — почти всегда костыль.

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

goto делает поток управления запутанным и легко приводит к переходам через инициализацию. Единственное оправданное применение — выход из глубоко вложенных циклов, и то редко.

Почему: структурные конструкции понятнее и безопаснее. Циклы, if/else, функции и ранний return дают компилятору и читателю чёткую «лесенку» выполнения. goto же может прыгнуть через инициализацию переменной — компилятор тогда вынужден либо отказаться её инициализировать, либо выдать предупреждение, либо (что хуже всего) скомпилировать код с неинициализированной переменной, и вы получите UB.

Как: используйте циклы, функции и ранний возврат. Если хочется написать goto — значит, пора вынести кусок кода в отдельную функцию, которая сама возвращает результат или bool.

// Плохо
for (...) {
    for (...) {
        if (found) goto done;
    }
}
done:;

// Хорошо: вынести поиск в функцию и вернуться
bool search() {
    for (...) for (...) if (found) return true;
    return false;
}

Практический совет. В современном C++ goto оправдан почти исключительно в C-стиле коде для освобождения ресурсов при ошибке (так называемый «cleanup goto»). В C++ для этого есть RAII: конструктор открывает ресурс, деструктор закрывает — даже при return посередине. Если вы пишете на C++ и тяньтесь за goto — скорее всего, вы не используете RAII там, где стоило бы.

Типичная ошибка. Начинающие иногда используют goto, чтобы «перепрыгнуть» через блок обработки ошибки и не дублировать cleanup-код. Решение — RAII-обёртки (std::unique_ptr, std::lock_guard, std::ofstream) и/или отдельная функция, которая возвращает значение при ошибке через return.

ES.100. Не смешивайте знаковую и беззнаковую арифметику

Оригинал: ES.100 — Don't mix signed and unsigned arithmetic

Определение. Знаковые типы (int, long, short) хранят целые числа, которые могут быть отрицательными, нулевыми и положительными. Беззнаковые типы (unsigned, unsigned long) хранят только неотрицательные числа, но за счёт этого могут представлять вдвое больший диапазон положительных значений того же размера.

Аналогия из жизни. Представьте, что вы измеряете температуру на двух термометрах: один показывает диапазон от −50 °C до +50 °C (знаковый), а другой — от 0 °C до 100 °C (беззнаковый). Если вы вслепую переведёте показание «−10» с первого термометра на шкалу второго, не учитывая смещение, вы получите «90 °C» — абсурдно жарко. Именно это и делает компилятор C++ при неявном преобразовании отрицательного int в unsigned.

Что происходит под капотом. Когда в одном выражении встречаются int и unsigned, знаковый операнд молча (неявно) превращается в беззнаковый по правилу «modulo 2ⁿ», где n — число бит. Для 32-битного int значение −1 превращается в 4294967295 — это самое большое 32-битное беззнаковое число. После такого преобразования сравнения и арифметика дают результаты, прямо противоположные ожидаемым.

Почему: неявное преобразование знака даёт неверные результаты, потому что компилятор не предупреждает вас о «потере знака» — он просто переинтерпретирует биты, и логика программы ломается без видимых симптомов. Баг может месяцами прятаться в редкой ветке условия.

Как: не смешивайте типы в сравнениях; приводите явно и осознанно. Перед static_cast задайте себе три вопроса: (1) может ли значение быть отрицательным? (2) влезет ли оно в диапазон беззнакового типа? (3) осмысленно ли сравнение после приведения? Если хотя бы на один вопрос ответ «нет» — лучше переписать логику, а не маскировать проблему кастом.

Практические подсказки.

  • В библиотечном коде (особенно в STL) многие контейнеры и алгоритмы возвращают size_t, который беззнаковый. Это самый частый источник неожиданных смешений.
  • Предупреждения компилятора -Wsign-compare, -Wsign-conversion (GCC/Clang) ловят большинство таких случаев — включайте их в -Wall -Wextra или -Wall -Wextra -Wsign-conversion.
  • Если размер коллекции не может быть отрицательным семантически, сравнивайте int с size_t через явный static_cast<int> (если уверены, что значение влезает) или через std::cmp_* из C++20.
  • В новом коде предпочитайте std::ssize() (C++20) или сохраняйте размеры в знаковом ptrdiff_t, чтобы избежать смешений.

Типичные ошибки.

  • Цикл for (int i = 0; i < v.size(); i++) — int смешивается с size_t. При большом v.size() возможно предупреждение, а в редких случаях — зацикливание.
  • Условие if (x - y > 0) где x и y беззнаковые: при x < y результат вычитания «оборачивается» в огромное число, и условие ложно, хотя по логике должно быть истинным.
  • Использование -1 как «сентинела» в unsigned переменной: вместо «минус один» получите максимально возможное число, и любые проверки if (value == -1) никогда не сработают.
int i = -1;
unsigned u = 1;

// Плохо: -1 преобразуется в большое беззнаковое значение
if (i < u) { /* условие ложно! */ }

// Хорошо: явное приведение с пониманием последствий
if (static_cast<unsigned>(i) < u) { /* ... */ }

ES.105. Не делите на целочисленный ноль

Оригинал: ES.105 — Don't divide by integer zero

Определение. Деление на ноль — это арифметическая операция, в которой делитель равен нулю. Для целочисленных типов в C++ это неопределённое поведение (UB): стандарт не описывает, что должен сделать компилятор, и результат может быть любым — от падения программы до возврата произвольного числа.

Аналогия из жизни. Представьте, что вы хотите разложить 10 пирожных поровну между людьми. Если людей 0, вопрос не имеет смысла — нельзя «раздать пирожные нулю людей» и получить осмысленный ответ. Математика говорит «нет решения», а процессор просто не знает, что делать, и выбрасывает ловушку (аппаратное исключение) или, если ловушка отключена, возвращает мусор.

Что происходит под капотом. Процессор при целочисленном делении на ноль генерирует аппаратное исключение #DE (Divide Error) в архитектуре x86. Если операционная система не обрабатывает его корректно (или если исключение замаскировано, что бывает в некоторых встраиваемых контекстах), процессор может вернуть произвольное значение и продолжить выполнение — тихо и незаметно. Отсюда выражение «программа выдала мусор».

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

Как: проверяйте делитель перед делением или используйте тип, исключающий ноль. Способы защиты:

  • Явная проверка if (b == 0) throw ...; — самый простой и читаемый вариант.
  • Использовать тип-обёртку (например, nonzero<int>), конструктор которого отвергает ноль.
  • Для компиляции константных выражений — if (b == 0) throw ...; в constexpr/consteval контексте.
  • В математических библиотеках — возвращать std::optional<T> или std::expected<T, E> (C++23), где отсутствие значения кодирует ошибку.

Практические подсказки.

  • Проверка if (b) (без == 0) тоже работает, но if (b == 0) явно читается как «защита от деления на ноль» и понятнее коллегам при ревью.
  • Аналогичное UB существует и для остатка от деления (a % b). Правило проверки делителя то же — ноль запрещён и для /, и для %.
  • В constexpr функциях проверка делителя обязательна: компилятор обязан диагностировать потенциальное UB на этапе компиляции, иначе программа просто не соберётся.
  • Для вещественных типов (double, float) деление на ноль даёт inf или nan, а не UB — это другое правило (см. правила про числа с плавающей точкой).
  • При делении long long на int следите, чтобы промежуточные значения не переполнялись (правило ES.107).

Типичные ошибки.

  • Полагаться на то, что «ошибка закрашится и так понятно будет»: в release-сборке с оптимизацией UB может не упасть, а молча вернуть «мусор», и баг проявится только у клиента через полгода.
  • Проверка есть, но проверяется не тот делитель: например, функция принимает b, а проверяется локальная переменная tmp, которая не равна b.
  • Деление в цикле без проверки, где b берётся из контейнера, заполняемого данными пользователя или БД, — классический путь к уязвимости.
  • Забыть, что b = a - a всегда ноль, и подать его в делитель «динамически».
int divide(int a, int b)
{
    if (b == 0) {
        throw std::invalid_argument("Деление на ноль");
    }
    return a / b;
}

E. Обработка ошибок (Error handling)

Что такое обработка ошибок? Это набор приёмов, с помощью которых программа обнаруживает сбой, сообщает о нём нужному месту, сохраняет корректное состояние и не теряет ресурсы (память, файлы, соединения). Проще говоря, это «план Б» на случай, когда что-то пошло не так.

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

Аналогия из жизни. Представьте, что вы шеф-повар и готовите ужин из нескольких блюд одновременно. Если в середине готовки выяснилось, что сломался духовой шкаф, у вас должен быть план: что подать вместо горячего, как не подать полусырое блюдо и как при этом не сжечь остальные заготовки. Обработка ошибок в C++ — это такой же «план Б» для кода: что сообщить, что откатить, что не сломать.

Типичные ошибки новичков:

  • Считать, что «ошибки бывают только в чужом коде» — они будут и в вашем.
  • Писать обработку «по факту», в самом конце проекта, когда интерфейсы уже заморожены.
  • Путать исключения с обычным ветвлением: бросать throw там, где следовало написать if.
  • Забывать освобождать ресурсы при исключении и получать утечки памяти.

E.1. Разрабатывайте стратегию обработки ошибок в начале проектирования

Оригинал: E.1 — Develop an error-handling strategy early in a design

Способ сообщения об ошибках (исключения, коды возврата, expected) влияет на весь код. Решать это «потом» дорого: придётся переделывать интерфейсы. Что такое стратегия обработки ошибок? Это заранее принятый и задокументированный ответ на вопрос «что делать, когда что-то пошло не так»: какие сигналы мы используем (исключения, коды ошибок, тип std::expected), кто их обрабатывает и в каком стиле. Без такой стратегии каждая функция будет «изобретать велосипед», и в итоге проект обрастёт противоречивыми подходами.

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

Как: выберите подход заранее и следуйте ему последовательно.

Аналогия. Решите заранее, каким языком говорить на переговорах. Если половина команды говорит по-английски, а половина по-китайски, договориться будет невозможно — какие бы хорошие аргументы у вас ни были.

Практические советы:

  • Запишите стратегию в README или в архитектурный документ.
  • Согласуйте с командой до того, как написан первый интерфейс.
  • В смешанном C/C++-коде (например, с микроконтроллерами или внешними библиотеками на C) допускается комбинировать коды возврата и исключения, но в пределах каждого слоя должен быть один стиль.
  • В стандартной библиотеке C++17 появился std::expected<T, E> — хороший выбор, когда вы хотите передавать ошибку как значение, без исключений.
// Решение принято заранее: ошибки через исключения
struct ConfigError : std::runtime_error { using std::runtime_error::runtime_error; };

Config load(const std::string& path);   // бросает ConfigError при проблеме

E.2. Бросайте исключение, чтобы сообщить, что функция не может выполнить задачу

Оригинал: E.2 — Throw an exception to signal that a function can't perform its assigned task

Что такое исключение? Это объект, который «вылетает» из функции в момент невозможности продолжить работу и раскручивает стек до ближайшего подходящего catch-блока. Исключение — основной механизм в C++ для сигнала «задача не выполнена».

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

Почему: вызывающий не может случайно проигнорировать исключение. Если вы вернёте «пустой» объект или false, вызывающий код может забыть проверить результат. Исключение же, будучи выброшенным, прерывает нормальный поток выполнения и заставляет программу либо обработать его в catch, либо завершиться (через noexcept или при разворачивании стека до main).

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

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

Типичные ошибки:

  • Бросать исключения в деструкторах (это часто приводит к вызову std::terminate).
  • Бросать «общие» throw "ошибка" или throw 42 — стандартные исключения несут тип и сообщение, и это помогает при catch.
  • Возвращать код ошибки «по привычке из C» там, где логичнее использовать исключение.
Widget& registry_lookup(const std::string& name)
{
    auto it = registry_.find(name);
    if (it == registry_.end()) {
        throw std::out_of_range("Не найден виджет: " + name);
    }
    return it->second;
}

E.3. Используйте исключения только для обработки ошибок

Оригинал: E.3 — Use exceptions for error handling only

Что значит «управляющий поток»? Это когда программа вместо обычного if/while/for использует исключения как способ перейти в другую ветку кода. Например, выход из вложенного цикла через throw или сигнал «данные кончились» через исключение. Это усложняет чтение, ломает оптимизации и прячет нормальные ситуации в обработчиках ошибок.

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

Почему: исключения для управления потоком усложняют анализ и замедляют код. Механизм исключений оптимизирован под редкие, действительно ошибочные ситуации: при раскрутке стека компилятор должен корректно вызвать деструкторы, что дороже обычного return. Кроме того, читая код, программист ожидает, что в catch окажется только «плохое», а не штатное окончание цикла.

Как: для нормальных ветвлений — if/switch/алгоритмы; исключения — для сбоев.

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

Практические советы:

  • Если вам нужно «выйти из глубоко вложенного цикла», лучше вынести цикл в отдельную функцию и использовать return, либо инкапсулировать состояние в класс с RAII.
  • Для «конца потока данных» в C++ есть идиома Ranges / итераторы: for (auto value : stream) естественно завершается, без исключений.
  • В горячих участках кода (внутренние циклы, парсеры) избегайте try/catch без крайней необходимости: даже при «невыброшенных» исключениях поддержка try-блока может замедлять компиляцию.
// Плохо: исключение как ветвление
try { next(); } catch (EndOfStream&) { /* нормальное завершение */ }

// Хорошо: обычное условие
for (auto value : stream) { process(value); }   // конец — естественно

E.5. Пусть конструктор устанавливает инвариант и бросает, если не может

Оригинал: E.5 — Let a constructor establish an invariant, and throw if it cannot

Что такое инвариант класса? Это набор условий, которые всегда должны быть истинны для корректного объекта. Например, у дроби знаменатель не ноль, у вектора размер неотрицателен, у соединения сокет открыт. Инвариант устанавливается один раз — в конструкторе — и затем сохраняется на всё время жизни объекта.

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

Почему: объект либо корректен, либо его не существует. Этот принцип называют «strong exception guarantee» в применении к конструктору: если конструктор вернул управление, объект полностью готов к использованию.

Как: проверяйте инвариант в конструкторе и бросайте при нарушении.

Аналогия. Если вы собираете автомобиль и понимаете, что у вас нет одного колеса, вы не отдаёте клиенту «почти готовый» автомобиль с припиской «колесо докупите сами». Вы останавливаете сборку и сообщаете об этом. Конструктор в C++ делает то же самое: он либо доводит объект до полной готовности, либо не оставляет объект вовсе.

Типичные ошибки:

  • Создавать «пустой» объект и просить пользователя вызвать init() позже — это легко забыть.
  • Возвращать из конструктора код ошибки (в C++ это невозможно, но некоторые изобретают «конструктор с выходным параметром» или статический метод-фабрику, которая возвращает «пустышку»).
  • Проглатывать исключение внутри конструктора: если конструктор не бросил, объект как бы создан — но при этом он в недопустимом состоянии.
class Fraction {
public:
    Fraction(int numerator, int denominator)
        : num_{numerator}, den_{denominator} {
        if (denominator == 0) {
            throw std::invalid_argument("Знаменатель не может быть нулём");
        }
    }
private:
    int num_, den_;
};

E.6. Используйте RAII для предотвращения утечек

Оригинал: E.6 — Use RAII to prevent leaks

Что такое RAII? Это идиома «Resource Acquisition Is Initialization» — «захват ресурса есть инициализация». Суть: ресурс (память, файл, мьютекс) привязывается к времени жизни объекта. В конструкторе ресурс захватывается, в деструкторе — освобождается. Поскольку деструктор вызывается автоматически (в том числе при раскрутке стека из-за исключения), утечки не происходит.

Утечки обычно недопустимы, а ручное освобождение ресурсов подвержено ошибкам. RAII — самый простой и системный способ не течь. В C++11 и новее для этого есть готовые «умные» указатели и lock-guard'ы: std::unique_ptr, std::shared_ptr, std::lock_guard, std::fstream и т. д.

Почему: при исключении ручное освобождение легко пропустить. Представьте последовательность из пяти new без RAII: если на третьем шаге вылетит исключение, первые два delete так и не выполнятся. С RAII каждый ресурс «живёт» в своём объекте, и деструкторы отрабатывают в порядке, обратном построению стека.

Как: оборачивайте ресурсы в дескрипторы с деструктором.

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

Практические советы:

  • Забудьте про new/delete в прикладном коде: используйте std::make_unique и std::make_shared.
  • Для блокировок мьютексов — std::lock_guard или std::scoped_lock (C++17), но не ручной lock()/unlock().
  • Для файлов, соединений и т. п. — соответствующие RAII-обёртки из стандартной библиотеки или boost.
  • RAII защищает и от «обычного» возврата через return посреди функции, и от исключений, и от преждевременного break/continue.
// Плохо: при исключении память утечёт
void f1(int i) {
    int* p = new int[12];
    if (i < 17) throw Bad{"в f()", i};   // p утечёт
}

// Хорошо: дескриптор освободит память при раскрутке стека
void f3(int i) {
    auto p = std::make_unique<int[]>(12);
    if (i < 17) throw Bad{"в f()", i};   // память освободится
}

E.12. Используйте noexcept, когда выход из функции из-за throw невозможен или неприемлем

Оригинал: E.12 — Use noexcept when exiting a function because of a throw is impossible or unacceptable

Что такое noexcept? Это спецификатор, который говорит компилятору и пользователю: «эта функция не бросает исключений». Если функция всё-таки попытается выбросить, программа немедленно завершится вызовом std::terminate — то есть исключение «не выйдет наружу».

noexcept — обещание, что функция не бросает. Оно позволяет вызывающему коду не готовиться к исключению и даёт компилятору свободу оптимизаций. Внутри noexcept-функции компилятор может опустить часть подготовки к раскрутке стека, потому что ему не нужно поддерживать пути «а вдруг бросит». Кроме того, noexcept важен в стандартной библиотеке: например, std::vector::push_back использует перемещение, и если перемещение бросает, контейнер не может дать строгую гарантию исключений.

Почему: noexcept — часть контракта и влияет на производительность. Без noexcept стандартные алгоритмы и контейнеры вынуждены использовать «осторожный» код с копированием вместо перемещения, что медленнее.

Как: помечайте функции, которые действительно не бросают (перемещение, деструкторы, обмен).

Аналогия из жизни. Знак «не курить» на заправке — это noexcept в действии: если всё же закурить, последствия будут мгновенными и необратимыми (вызов std::terminate). Знак не защищает от нарушителя, но создаёт жёсткий контракт: либо вы следуете правилу, либо последствия катастрофические.

Типичные ошибки:

  • Ставить noexcept «для красоты», не проверив, что функция действительно не бросает (включая все вызываемые ею функции). Одно непредвиденное исключение — и std::terminate.
  • Забывать noexcept на перемещающем конструкторе: из-за этого std::vector будет копировать элементы вместо перемещения, что катастрофически снижает производительность при resize.
  • Добавлять noexcept(false) там, где noexcept было по умолчанию, без необходимости — это разрушает оптимизации и контракт.
void swap(Buffer& a, Buffer& b) noexcept;   // обмен не бросает
Buffer(Buffer&& other) noexcept;            // перемещение не бросает

E.13. Никогда не бросайте исключение, будучи прямым владельцем объекта

Оригинал: E.13 — Never throw while being the direct owner of an object

Кто такой «прямой владелец»? Это код, который удерживает ресурс через «голый» указатель, ссылку или дескриптор и сам отвечает за его освобождение. Если в этот момент вылетит исключение, владелец не успеет корректно отдать ресурс.

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

Почему: прямой владелец не освободит ресурс при исключении. Раскрутка стека вызывает деструкторы локальных переменных — но не вызывает delete для сырых указателей. Именно поэтому E.6 (RAII) и E.13 так тесно связаны: соблюдая одно, вы автоматически соблюдаете другое.

Как: не держите ресурсы в сырых указателях перед возможным throw.

Аналогия из жизни. Если вы взяли в аренду лодку и в середине прогулки у вас случился сердечный приступ, лодка останется «застрявшей» на воде. Если же у вас есть «умный» браслет, который в экстренной ситуации сам подаёт сигнал возврата лодки на базу, — это и есть RAII. Браслет (дескриптор) гарантирует, что ресурс вернётся независимо от того, что с вами случилось.

Практические советы:

  • Если очень нужен сырой указатель — оборачивайте его в std::unique_ptr/std::shared_ptr сразу же, до любого потенциального throw.
  • Для нетипичных ресурсов (дескрипторы ОС, сокеты, GPU-память) пишите собственные RAII-обёртки с деструктором.
  • Вспомогательное правило: если в функции присутствует new/malloc/fopen/lock(), далее в той же функции должен следовать эквивалентный delete/free/fclose/unlock() только в деструкторе локального объекта, а не в произвольном месте по веткам if.
// Плохо: бросаем, владея сырым ресурсом
void bad() {
    Widget* w = new Widget;
    if (error()) throw std::runtime_error("...");   // w утечёт
    delete w;
}

// Хорошо: ресурс под управлением
void good() {
    auto w = std::make_unique<Widget>();
    if (error()) throw std::runtime_error("...");   // w освободится
}

E.14. Используйте специально спроектированные пользовательские типы как исключения (не встроенные типы)

Оригинал: E.14 — Use purpose-designed user-defined types as exceptions (not built-in types)

Бросать int или const char* удобно, но такой тип несёт мало информации и легко перехватывается не тем catch. Свой тип исключения описывает ошибку и позволяет точно её ловить.

Почему: типы исключений делают обработку точной и информативной. Как: определяйте иерархии исключений от std::exception или std::runtime_error.

struct NetworkError : std::runtime_error {
    explicit NetworkError(const std::string& what) : std::runtime_error(what) {}
};

throw NetworkError("Соединение разорвано");

E.15. Бросайте по значению, перехватывайте иерархию по ссылке

Оригинал: E.15 — Throw by value, catch exceptions from a hierarchy by reference

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

Почему: перехват по значению теряет производную часть исключения. Как: catch (const Base& e) для иерархий.

try {
    risky();
} catch (const NetworkError& e) {        // по ссылке: без среза
    log(e.what());
} catch (const std::exception& e) {      // общий перехват — последним
    log(e.what());
}

E.16. Деструкторы, освобождение памяти, swap и операции копирования/перемещения исключений не должны завершаться ошибкой

Оригинал: E.16 — Destructors, deallocation, swap, and exception type copy/move construction must never fail

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

Почему: ошибка во время обработки другой ошибки фатальна. Как: делайте такие операции noexcept и не бросайте из них.

struct MyError : std::runtime_error {
    explicit MyError(const std::string& what) : std::runtime_error(what) {}
    MyError(const MyError&) noexcept = default;   // копирование не бросает
    ~MyError() noexcept = default;
};

E.17. Не пытайтесь перехватить каждое исключение в каждой функции

Оригинал: E.17 — Don't try to catch every exception in every function

Не нужно оборачивать в try/catch каждый вызов: локальный перехват часто маскирует ошибку вместо её обработки. Ловите там, где действительно можете исправить ситуацию.

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

// Плохо: перехват, который ничего не исправляет
try { save(); } catch (...) { /* молчим */ }

// Хорошо: обработка там, где есть решение
try {
    save();
} catch (const DiskFull& e) {
    rotate_logs();   // освободили место и продолжаем
    save();
}

E.18. Минимизируйте использование явных try/catch

Оригинал: E.18 — Minimize the use of explicit try/catch

Основная обработка ошибок должна быть в стратегии, а не в разбросанных try. RAII и дескрипторы снимают большинство нужд в явном catch.

Почему: явные try усложняют поток и дублируются. Как: используйте RAII, а try/catch — точечно.

// Плохо: ручной try только чтобы закрыть файл
void bad(const std::string& path) {
    std::FILE* f = std::fopen(path.c_str(), "r");
    try { work(f); } catch (...) { std::fclose(f); throw; }
    std::fclose(f);
}

// Хорошо: RAII освободит файл при исключении
void good(const std::string& path) {
    std::ifstream f{path};
    work(f);
}

E.19. Используйте объект final_action, чтобы выразить очистку, если подходящего дескриптора нет

Оригинал: E.19 — Use a final_action object to express cleanup if no suitable resource handle is available

Когда ресурс не обёрнут в класс с деструктором, можно временно применить «очистку при выходе» — объект final_action вызывает функцию в своём деструкторе.

Почему: это RAII-эквивалент finally без явного try. Как: создавайте final_action сразу после захвата ресурса.

void func(const std::string& name)
{
    FILE* f = std::fopen(name.c_str(), "r");
    auto cleanup = finally([f] { std::fclose(f); });   // закроется при выходе
    // ... даже если здесь исключение, файл закроется
}

E.30. Не используйте спецификации исключений

Оригинал: E.30 — Don't use exception specifications

Определение. Спецификация исключений (exception specification) — это часть объявления функции, которая сообщает компилятору и читателю кода, какие именно исключения функция может выбрасывать. В ранних версиях C++ для этого использовался синтаксис throw(тип1, тип2, ...) или просто throw(). Например, void f() throw(std::runtime_error) означало бы «функция может бросить только std::runtime_error».

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

Динамические спецификации исключений (throw(...)) удалены из языка и не работают. Для описания «не бросает» есть noexcept.

Почему: спецификации исключений оказались непрактичными и опасными. Как: используйте noexcept, когда функция не бросает; иначе ничего не указывайте.

// Плохо: удалённая из языка конструкция
// void f() throw(int);

// Хорошо: стандартный способ
void g() noexcept;   // не бросает

Два варианта noexcept:

  • Безусловный noexcept — функция не бросает исключений никогда. Если исключение всё же вылетит, программа немедленно вызовет std::terminate().
  • noexcept(выражение) — функция не бросает только если выражение истинно. Это позволяет писать условно-безопасные функции, например noexcept(std::is_nothrow_move_constructible_v<T>). Так удобно «пробрасывать» гарантию отсутствия исключений из зависимых операций.

Практические советы:

  • Помечайте noexcept деструкторы, конструкторы/операторы перемещения и swap() — многие алгоритмы STL (например, std::vector::reserve или перемещение элементов при реаллокации) принимают более эффективные решения, зная, что эти операции безопасны.
  • Не помечайте noexcept функции, которые выделяют память (new), работают с сетью, файлами или другими внешними ресурсами — они почти всегда могут бросить исключение.
  • Проверить, является ли выражение noexcept, можно оператором noexcept(expr) — он возвращает true, если компилятор уверен в отсутствии исключений, и false иначе. Это удобно для SFINAE и для статических проверок в шаблонах.
  • Помните, что noexcept влияет на выбор алгоритма в стандартной библиотеке: контейнеры предпочтут перемещение (move) вместо копирования (copy) только если перемещение помечено noexcept.

Типичные ошибки:

  • Пометить noexcept функцию, которая внутри вызывает потенциально бросающие функции (например, new, std::vector::push_back, операторы ввода-вывода). Если исключение всё-таки вылетит, программа аварийно завершится через std::terminate() без разворачивания стека — деструкторы локальных объектов не будут вызваны.
  • Считать, что noexcept автоматически ускоряет код — реальный выигрыш появляется только там, где контейнеры и алгоритмы STL используют эту гарантию (например, перемещение вместо копирования при росте вектора).
  • Использовать throw() (без аргументов) как замену noexcept — это устаревший синтаксис, оставленный в C++11 только для совместимости со старым кодом; в новом коде всегда пишите noexcept.
  • Путать noexcept с const или другими спецификаторами, влияющими на тип — noexcept не меняет тип функции, но является частью её сигнатуры для целей перегрузки (можно иметь две перегрузки, отличающиеся только наличием noexcept).

CP. Конкурентность и параллелизм (Concurrency and parallelism)

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

Ключевые термины для понимания раздела

  • Поток (thread) — это независимая линия выполнения внутри одной программы. У одного процесса может быть много потоков; они делят общую память, но имеют свои регистры и стек.
  • Гонка данных (data race) — ситуация, когда два потока одновременно обращаются к одной переменной, минимум один из них пишет, и нет синхронизации. Это неопределённое поведение (UB): компилятор, процессор и рантайм могут делать что угодно.
  • Синхронизация — набор приёмов (мьютексы, атомарные операции, барьеры памяти), благодаря которым потоки «договариваются» о порядке доступа к общим данным.
  • RAII (Resource Acquisition Is Initialization) — идиома C++: ресурс привязывается к времени жизни объекта. Выход из области видимости автоматически освобождает ресурс (мьютекс, файл, память).

CP.1. Считайте, что ваш код будет работать в многопоточной программе

Оригинал: CP.1 — Assume that your code will run as part of a multi-threaded program

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

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

Почему: готовность к потокам дешевле последующей переделки. Переписывать однопоточный код в многопоточный — это почти всегда пересмотр архитектуры, перевыпуск ABI и долгая отладка. Гораздо дешевле сразу заложить минимальные гарантии потокобезопасности: использовать локальные переменные, передавать данные по значению, оберегать любые глобальные состояния синхронизацией. Как: не используйте изменяемое глобальное состояние без синхронизации. Глобальные static переменные в .cpp-файлах, ленивые синглтоны без охраны, cache на уровне модуля — всё это потенциальные минные поля при многопоточном использовании.

Практические подсказки:

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

Типичная ошибка новичка. Разработчик пишет «утилитарную» функцию, использующую статический буфер для ускорения. В однопоточном тесте всё работает. Через год функцию зовут из пула потоков — и появляются случайные порчи памяти, которые невозможно воспроизвести в отладчике.

// Опасно: общий изменяемый кэш без защиты
std::map<std::string, int> cache;   // из разных потоков — гонка

// Безопасно: доступ под мьютексом
std::mutex cache_mutex;
std::map<std::string, int> cache;

CP.2. Избегайте гонок данных

Оригинал: CP.2 — Avoid data races

Гонка данных — одновременный доступ к одной ячейке, где хотя бы одна запись и нет синхронизации. Это неопределённое поведение независимо от типа данных. Заметьте: понятие «одновременно» здесь формальное — два обращения с разных потоков, между которыми не было операции синхронизации, уже образуют гонку, даже если физически они разнесены по времени.

Аналогия из жизни. Два кассира одновременно правят одну и ту же тетрадь с остатками товара. Один пишет «выдано 3», другой в эту же секунду — «выдано 5». Какая цифра «правильная»? Никакая — состояние тетради теперь не определено. То же происходит и в памяти: частичная запись, неверный размер, потерянные обновления.

Почему: гонка может разрушить программу непредсказуемо. Компилятор имеет право переупорядочить инструкции (с учётом as-if), процессор — выполнить их в собственном порядке. В итоге гонка проявляется не там, где её «ищут», а там, где удобно рантайму. Как: защищайте общие данные мьютексом или передавайте их по значению. Если данные нужны только одному потоку — сделайте так, чтобы именно этот поток был их владельцем (см. CP.3).

Дополнительные инструменты борьбы с гонками:

  • std::atomic<T> — для простых типов (счётчики, флаги, указатели).
  • Мьютексы (std::mutex, std::recursive_mutex, std::shared_mutex) — для сложных структур.
  • Локальные потоковые данные (thread_local) — каждый поток получает свою копию.
  • Каналы/очереди сообщений — потоки общаются через передачу данных, а не через общую память.
// Плохо: два потока пишут в один счётчик
int counter = 0;
std::thread t1{[&]{ for (int i = 0; i < 1000; ++i) ++counter; }};

// Хорошо: атомарная операция
std::atomic<int> counter{0};
counter.fetch_add(1);

CP.3. Минимизируйте явное совместное использование изменяемых данных

Оригинал: CP.3 — Minimize explicit sharing of writable data

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

Аналогия из жизни. Вместо того чтобы все повара крошили лук на одной общей доске и путались друг у друга, лучше раздать каждому по доске и свой лук. Очевидное решение — у каждого свой набор данных. То же и в коде: «скопировать данные в поток» зачастую дешевле, чем «синхронизировать общий доступ».

Почему: отсутствие общего состояния — лучшая синхронизация. Нечего синхронизировать — нечему ломаться. Как: копируйте данные в поток, а не делите их. В лямбде, передаваемой в std::thread, захватывайте по значению ([data]), а не по ссылке ([&data]). Это особенно важно, когда поток живёт дольше, чем переменная.

Практические подсказки:

  • По умолчанию захватывайте в лямбдах по значению. Ссылки ([&], [&var]) используйте только если точно знаете, что время жизни объекта перекрывает время жизни потока.
  • Если данные велики и копировать дорого — передавайте их через std::shared_ptr<const T> (неизменяемая разделяемая «копия»).
  • Архитектурный принцип: каждый поток владеет своими данными и общается с другими через сообщения/каналы (std::queue, библиотеки вроде folly::MPMCQueue, boost::lockfree::queue).

Типичная ошибка. Передать в std::thread лямбду, захватывающую ссылку на локальный вектор, и выйти из функции до того, как поток закончил работу. Результат — висячая ссылка, повреждение памяти, падения, иногда «работает, потому что стек ещё не затерт».

// Плохо: поток читает общий изменяемый вектор
std::vector<int> data = load();
std::thread t{[&]{ process(data); }};   // data должна жить и не меняться

// Хорошо: поток получает свою копию
std::vector<int> data = load();
std::thread t{[data]{ process(data); }};

CP.4. Думайте задачами, а не потоками

Оригинал: CP.4 — Think in terms of tasks, rather than threads

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

Аналогия из жизни. Вы — бригадир, и вам нужно покрасить 10 заборов. Можно нанять 10 маляров (потоки), а можно нанять 3 маляров и дать им список из 10 задач. Пусть сами берут следующую, когда освободятся. Это и есть модель задач поверх пула потоков: меньше людей — меньше расходов на найм, но работа делается.

Почему: задачи легче выразить и оптимизировать. Библиотека/рантайм сам решит, на скольких потоках их реально запустить, учтёт балансировку, уберёт накладные расходы на старт/остановку. Как: используйте std::async, пулы задач, параллельные алгоритмы.

Что выбрать на практике:

  • std::async(launch::async, fn) — простейший способ «запустить и забыть, потом забрать результат».
  • Параллельные алгоритмы из <execution> (std::for_each(std::execution::par, ...)) — старый добрый цикл становится параллельным без явных потоков.
  • Пул задач (ThreadPool, BS::thread_pool, Asio, TBB) — когда задач много и/или нужна гибкая отмена.

Типичная ошибка. Создавать по потоку на каждую мелкую операцию. Например, в цикле на 1000 итераций стартовать 1000 потоков. В результате время уходит не на полезную работу, а на создание/уничтожение потоков и переключение контекста.

auto future = std::async(std::launch::async, [] {
    return compute_heavy();
});
// ... другая работа ...
int result = future.get();   // здесь дождались результата

CP.8. Не пытайтесь использовать volatile для синхронизации

Оригинал: CP.8 — Don't try to use volatile for synchronization

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

Аналогия из жизни. volatile в C/C++ — это записка на стикере «не выбрасывай меня, я оптимизирующий компилятор!» Она запрещает компилятору оптимизировать обращения к переменной (например, не закэшировать её в регистре), но ничего не говорит ни процессору, ни другим потокам. Это «анти-оптимизация для одного потока», а не «синхронизация между потоками».

Почему: volatile не решает задачу гонок. Не создаются ни атомарные операции, ни барьеры памяти, ни блокировки. Два потока, читающие volatile int x, вполне могут увидеть разные значения или прочитать частично обновлённое значение. Как: берите std::atomic или мьютексы.

Памятка:

  • volatile — для memory-mapped I/O и обработки сигналов, где важно, чтобы компилятор не выкинул обращение.
  • std::atomic — для межпоточной синхронизации: операции, барьеры памяти, видимость изменений.
  • Эти два механизма не взаимозаменяемы, хотя на некоторых платформах (x86 с сильной моделью памяти) volatile случайно «работает» как атомарность для выровненных типов. Полагаться на это — путь к багам при переносе на ARM/POWER/RISC-V.

Типичная ошибка. Программист приходит из Java/C#, где volatile действительно обеспечивает видимость изменений между потоками. В C++ это не так, и такой код будет неявно гонкой.

// Плохо: volatile не делает доступ атомарным
volatile bool ready = false;

// Хорошо: атомарный флаг с упорядочиванием памяти
std::atomic<bool> ready{false};

CP.20. Используйте RAII, никогда — голые lock()/unlock()

Оригинал: CP.20 — Use RAII, never plain lock()/unlock()

Ручные lock/unlock легко забыть на одном из путей, особенно при исключении. Объект-страж гарантирует освобождение. RAII — это «страж ресурса»: его конструктор захватывает ресурс, деструктор — освобождает. Деструктор вызовется всегда, в том числе при исключениях, ранних return, переходах по goto (в пределах одной функции) и при размотке стека.

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

Почему: исключение между lock и unlock оставит мьютекс захваченным. Потоки начнут зависать один за другим, и в большом приложении это превращается в классический «дедлок на ровном месте», который иногда называют «meltdown»: программа не падает, она просто перестаёт работать. Как: используйте lock_guard, unique_lock, scoped_lock.

Какой RAII-страж когда выбрать:

  • std::lock_guard — самый простой и быстрый; захватывает один мьютекс на всё время жизни блока.
  • std::unique_lock — когда нужна отсрочка захвата, ручная разблокировка, или передача в условную переменную (condition_variable).
  • std::scoped_lock (C++17) — для нескольких мьютексов с алгоритмом, предотвращающим deadlock (см. CP.21).

Типичная ошибка. Захватить мьютекс в начале функции и отпустить перед return, забыв один из путей возврата. Любой return после lock и до unlock — потенциальный deadlock. RAII устраняет целый класс таких ошибок.

// Плохо
mtx.lock();
// ... исключение здесь — мьютекс навсегда заперт
mtx.unlock();

// Хорошо
{
    std::lock_guard<std::mutex> guard{mtx};   // освободит автоматически
    // ...
}

CP.21. Используйте std::lock() или std::scoped_lock для захвата нескольких мьютексов

Оригинал: CP.21 — Use std::lock() or std::scoped_lock to acquire multiple mutexes

Последовательный захват двух мьютексов может привести к взаимной блокировке. std::scoped_lock захватывает их без deadlock.

Аналогия из жизни. Два пешехода идут навстречу друг другу по узкому мосту. Один заходит слева, другой справа. Каждый ждёт, пока другой уступит. Результат — оба стоят. Это и есть deadlock (взаимная блокировка). Решение: назначить «правило движения» — например, всегда сначала захватывать мост с левой стороны. Так же и std::lock/std::scoped_lock навязывают общий порядок захвата мьютексов, исключая зацикливание.

Почему: deadlock — распространённая ошибка многопоточного кода. Условия для неё (по теореме Коффмана): взаимное исключение, удержание и ожидание, отсутствие прерывания, циклическое ожидание. Голые lock()/lock() нарушают последнее условие в худшую сторону — порядок захвата может различаться в разных функциях. Как: захватывайте несколько мьютексов одной операцией. std::lock(m1, m2, ...) и std::scoped_lock guard(m1, m2, ...) используют алгоритм, который сначала все мьютексы помечает как «намереваюсь захватить», а потом выполняет атомарный захват в согласованном порядке. Взаимная блокировка при таком подходе исключена.

Практические подсказки:

  • Если у вас в коде появляется последовательность из двух и более lock() подряд — это сигнал рефакторинга в пользу std::scoped_lock.
  • Когда избежать захвата нескольких мьютексов нельзя, старайтесь уменьшить область их пересечения: захватили, скопировали нужное под защитой, отпустили, работаете с копией.
  • Альтернативный подход — иерархия блокировок: каждому мьютексу присваивается «уровень», и поток может захватывать только мьютекс более высокого уровня. Это помогает локализовать проблему.

Типичная ошибка. Функция transfer(from_acc, to_acc) в одном потоке делает from.lock(); to.lock();, а в другом — to.lock(); from.lock();. Под нагрузкой программа зависает раз в час, воспроизвести не удаётся, продакшен недоволен.

// Плохо: возможен deadlock при разном порядке захвата
a.lock();
b.lock();

// Хорошо: безопасный одновременный захват
std::scoped_lock lock{a_mutex, b_mutex};

CP.22. Никогда не вызывайте неизвестный код, удерживая блокировку

Оригинал: CP.22 — Never call unknown code while holding a lock (e.g., a callback)

Что такое колбэк (callback)? Это функция, которую вы передаёте в чужой код (библиотеку, фреймворк, другой поток), чтобы он вызвал её позже в нужный момент. Типичные примеры: обработчик нажатия кнопки в GUI, функция сравнения в std::sort, лямбда, переданная в std::for_each, виртуальный метод переданного вами объекта.

Аналогия из жизни. Представьте, что вы взяли ключ от единственного туалета в офисе и зашли внутрь. В этот момент вас просят выполнить какое-то действие, о котором вы ничего не знаете (например, передать сообщение коллеге). Если этот коллега тоже захочет в тот же туалет — все застрянут. Отдайте ключ сначала, а потом спокойно выполняйте задачу — и deadlock не случится.

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

Почему это важно. Вы не контролируете, что делает чужой код. Колбэк может:

  • попытаться захватить тот же мьютекс → deadlock (взаимная блокировка потоков);
  • выполнить тяжёлое вычисление или I/O → другие потоки простаивают без пользы;
  • бросить исключение → мьютекс может остаться заблокированным нештатно;
  • обратиться к другим защищённым ресурсам → каскадные задержки по всему приложению.

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

Типичная ошибка. Копировать ссылку или указатель на данные вместо самих данных. Ссылка перестаёт быть защищённой сразу после отпускания мьютекса, и её могут изменить другие потоки. Решение — копировать значения, а не указатели/ссылки.

// Плохо: колбэк вызывается под блокировкой
{
    std::lock_guard<std::mutex> guard{mtx};
    callback();   // может снова захотеть mtx — deadlock
}

// Хорошо: данные скопированы, блокировка отпущена
Data snapshot;
{
    std::lock_guard<std::mutex> guard{mtx};
    snapshot = data_;
}
callback(snapshot);

Практический совет. Если колбэк нужен именно «из-под блокировки» (например, для атомарности группы операций), оборачивайте его вызов в отдельный поток или используйте очередь задач — тогда колбэк выполнится позже, уже без блокировки.

CP.44. Не забывайте давать имена lock_guard и unique_lock

Оригинал: CP.44 — Remember to name your lock_guards and unique_locks

Что такое RAII? std::lock_guard и std::unique_lock — это RAII-обёртки (Resource Acquisition Is Initialization, «захват ресурса есть инициализация»). Конструктор захватывает мьютекс, деструктор — отпускает. Время жизни блокировки автоматически привязывается к времени жизни объекта-стража.

Аналогия из жизни. Представьте охранника у двери. Вы наняли его со словами «охраняй до конца рабочего дня», но... не дали ему имя и не оформили пропуск. Он ушёл сразу же. Именно так и работает временный безымянный объект — он живёт ровно до точки с запятой.

Проблема подробно. Временный объект-страж, созданный без имени, уничтожается сразу, и блокировка немедленно снимается. Нужно имя, привязанное к области видимости (блоку { }), чтобы блокировка жила ровно столько, сколько нужно.

Почему это опасно. Безымянный страж не защищает критическую секцию. Если вы наивно полагаете, что мьютекс заблокирован на весь блок, а страж уже разрушен — вы спокойно пишете «защищённый» код, который на самом деле ничем не защищён. Это источник крайне опасных и трудноуловимых race condition (состояний гонки), которые проявляются только под нагрузкой.

Как правильно. Всегда давайте объектам блокировок осмысленные имена (например, guard, lock, mtx_guard). Имя связывает время жизни объекта с областью видимости переменной — компилятор сам вызовет деструктор при выходе из блока.

Типичные ошибки.

  • std::lock_guard<std::mutex>{mtx}; — безымянный временный объект, мьютекс сразу свободен;
  • auto g = std::lock_guard<std::mutex>{mtx}; — формально работает, но auto хуже читается, лучше явный тип с понятным именем;
  • передача стража в функцию по значению — блокировка отпустится раньше времени внутри вызванной функции;
  • объявление стража после использования защищаемых данных в том же блоке — мьютекс просто не успевает заблокироваться.
// Плохо: страж уничтожается сразу, мьютекс снова свободен
std::lock_guard<std::mutex>{mtx};
// ... критическая секция без защиты

// Хорошо: страж живёт до конца блока
std::lock_guard<std::mutex> guard{mtx};

Практический совет. Используйте std::scoped_lock (C++17) для нескольких мьютексов — он делает то же самое, но безопаснее борется с deadlock при множественном захвате. В современных компиляторах включайте предупреждения -Wunused-value и -Wunused-variable, чтобы случайный безымянный страж сразу подсвечивался как ошибка.

CP.50. Определяйте mutex вместе с данными, которые он защищает

Оригинал: CP.50 — Define a mutex together with the data it guards

Что такое мьютекс? std::mutex — это примитив синхронизации, который в каждый момент времени позволяет только одному потоку владеть блокировкой. Сам по себе мьютекс бесполезен — он защищает не абстрактно, а конкретные данные. Без данных мьютекс — это ключ без замка.

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

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

Почему это правило работает. Инкапсуляция (сокрытие деталей реализации) защищает только тогда, когда данные физически недостижимы без мьютекса. Глобальный мьютекс рядом с глобальной переменной — это приглашение к ошибке: легко забыть заблокировать или взять не тот мьютекс.

Как правильно. Держите мьютекс и данные вместе в одной структуре (классе), используйте synchronized_value<T> (предложение P.0293 в C++ Committee) или простую обёртку, в которой все обращения к данным автоматически лочатся.

Типичные ошибки.

  • глобальный std::mutex для разных наборов данных — легко перепутать, какой мьютекс к чему относится;
  • методы, которые принимают мьютекс параметром, а не берут его из поля класса, — нарушается связь «мьютекс + данные»;
  • mutable мьютекс в одном объекте, а данные — в другом — лишний уровень абстракции без реальной пользы;
  • разделение одного мьютекса между двумя разными классами — конфликт областей блокировки и ложное чувство защищённости.
// Хорошо: мьютекс и данные в одной структуре
struct Account {
    std::mutex mtx;
    int balance = 0;
};

void deposit(Account& account, int amount)
{
    std::lock_guard<std::mutex> guard{account.mtx};
    account.balance += amount;
}

Практический совет. Если данных много, делайте их приватными и предоставляйте публичные методы (deposit, withdraw, getBalance), которые сами захватывают мьютекс. Тогда пользователь класса вообще не знает про мьютекс — и просто не может ошибиться с синхронизацией. Ещё лучше — спрятать мьютекс в private-секции и не отдавать его наружу вовсе.

SL. Стандартная библиотека (The Standard Library)

Что такое «Стандартная библиотека»? Это набор готовых компонентов (контейнеры, алгоритмы, строки, потоки ввода-вывода, умные указатели и т. д.), который поставляется вместе с компилятором C++ и описан в самом стандарте языка. Аналогия: если язык C++ — это набор строительных инструментов (молоток, пила, дрель), то стандартная библиотека — это уже собранные из них готовые блоки: окна, двери, лестницы. Гораздо эффективнее использовать готовый блок, чем каждый раз конструировать его из кирпичиков вручную.

Используя только «голый» язык, любая задача становится утомительной — в любом языке. С подходящей библиотекой почти любая задача становится разумно простой. Стандартная библиотека C++ постепенно росла, и её описание в стандарте теперь больше описания языковых возможностей. Вероятно, этот раздел со временем сравняется по размеру со всем остальным документом. Правила просты: применяйте библиотеки везде, где можно; предпочитайте стандартную библиотеку сторонним; не добавляйте свои сущности в пространство имён std; используйте библиотеку типобезопасно.

Краткое объяснение четырёх главных принципов:

  • Применяйте библиотеки везде, где можно — «своё» решение почти всегда дороже в разработке, отладке и поддержке. Чужой, но проверенный код лучше своего «велосипеда».
  • Стандартная библиотека > сторонние библиотеки — она переносима между платформами и компиляторами, поставляется вместе с инструментарием и не тянет внешних зависимостей.
  • Не засоряйте std — добавление своих функций или типов в пространство имён std формально является неопределённым поведением, ломает переносимость и приводит к загадочным конфликтам имён.
  • Типобезопасность — стандартная библиотека спроектирована так, чтобы ошибки типов ловились на этапе компиляции, а не в рантайме. Не отключайте эту защиту «хаками» вроде reinterpret_cast или C-функций в обход обёрток.
ID Правило (оригинал) Суть
SL.1 Use libraries wherever possible Используйте библиотеки везде, где это возможно: писать и поддерживать своё там, где есть готовое, обычно дороже и опаснее. Готовый код уже отлажен, протестирован тысячами пользователей и покрыт компиляторами. Аналогия: не изобретайте велосипед — если в городе есть автобус, не стройте личный дирижабль.
SL.2 Prefer the standard library to other libraries Предпочитайте стандартную библиотеку сторонним: она переносима, документирована и сопровождается вместе с компилятором. Сторонняя библиотека может перестать поддерживаться, а стандартная — это «контракт», зафиксированный в стандарте ISO и доступный десятилетиями.
SL.3 Do not add non-standard entities to namespace std Не добавляйте свои типы и функции в std: это неопределённое поведение и ломает переносимость. Почему так: пользователи вашего кода будут ожидать в std только стандартные сущности; ваши добавки могут пересечься с будущими версиями стандарта (например, в C++17 std::byte неожиданно конфликтовал с самописными типами).
SL.4 Use the standard library in a type-safe manner Пользуйтесь библиотекой типобезопасно: не разыменовывайте итераторы за границей, не передавайте неверные типы, не полагайтесь на неопределённое поведение. Практический совет: если компилятор может поймать ошибку — пусть поймает; UB в C++ хуже, чем «падение», — оно делает программу «зомби», которая иногда работает.
SL.con.1 Prefer using STL array or vector instead of a C array Предпочитайте std::array и std::vector вместо C-массивов: они знают размер, копируются и проверяются. Типичная ошибка: передавать C-массив в функцию и считать его размер через sizeof(arr)/sizeof(arr[0]) — внутри функции вы получите размер указателя, а не массива. С std::array такой ловушки нет.
SL.con.2 Prefer using STL vector by default unless you have a reason to use a different container По умолчанию берите std::vector: это самая быстрая и простая структура; другой контейнер — только при явной причине. «Почему vector?» — данные лежат в непрерывной памяти, как в обычном массиве, что даёт отличную локальность кэша CPU. Это быстрее list для большинства реальных задач, вопреки интуиции новичков.
SL.con.3 Avoid bounds errors Избегайте выхода за границы: используйте at() для проверки, диапазонные for и span, а не ручные индексы. Лайфхак: vec[i] молча возвращает мусор при ошибке; vec.at(i) бросает исключение std::out_of_range — в отладочной сборке безопаснее всегда писать .at().
SL.str.1 Use std::string to own character sequences Для владения символами применяйте std::string: она управляет памятью и длиной сама. Аналогия: char* — это записка с номером телефона; std::string — это контакт в телефоне с именем и всеми гарантиями хранения.
SL.str.2 Use std::string_view to refer to character sequences Для ссылок на символы без владения берите std::string_view (или gsl::span<char>): дешёво и без копий. Осторожно: string_view не владеет памятью — если исходная строка удалена, «вью» смотрит в пустоту (dangling reference). Не сохраняйте string_view дольше, чем живёт исходная строка.
SL.io.2 When reading, always consider ill-formed input При чтении всегда учитывайте некорректный ввод: проверяйте состояние потока и не доверяйте внешним данным. Практический совет: после операции чтения проверяйте if (std::cin) { ... } или используйте std::cin.fail() — иначе пользователь введёт «abc» вместо числа, и программа «зациклится» или выдаст мусор.
SL.io.50 Avoid endl Избегайте std::endl: он сбрасывает буфер при каждом вызове; для перевода строки пишите '\n'. Почему: сброс буфера — это системный вызов (sync с диском/терминалом); в цикле на миллион итераций разница в скорости будет в десятки раз.
SL.C.1 Don't use setjmp/longjmp Не используйте setjmp/longjmp: они несовместимы с деструкторами, исключениями и RAII, ведите обработку ошибок штатными средствами C++. Что плохого: longjmp «прыгает» через стек вызовов, не вызывая деструкторы промежуточных объектов — это утечки памяти, незакрытые файлы и сломанные мьютексы.
// Плохо: C-массив без размера и ручное чтение
char buffer[256];
std::fgets(buffer, sizeof buffer, stdin);

// Хорошо: std::string и std::getline
std::string line;
std::getline(std::cin, line);

Разбор примера:

  • Плохой вариант:

    • Размер буфера (256) — «магическое число»: его легко забыть или ошибиться при правке.
    • char buffer[256] — обычный C-массив: не знает свою длину, не умеет корректно расширяться, при выходе за границу портит чужую память (классический «buffer overflow» — источник уязвимостей).
    • std::fgets — C-функция из 1970-х: возвращает char* (указатель на сырую память), нужно вручную убирать символ \n в конце и помнить про нуль-терминатор.
    • Никакой защиты от ошибок: если строка длиннее 255 символов, лишнее просто теряется.
  • Хороший вариант:

    • std::string line — сам управляет памятью: если строка длинная, строка «вырастет»; если короткая — не займёт лишнего.
    • std::getline(std::cin, line) читает до символа \n, умеет принимать любой разделитель и автоматически расширяется.
    • Размер не нужно задавать вручную — std::string сделает всё сам.
    • Легко проверить состояние ввода: после getline достаточно написать if (!std::cin) { /* обработка ошибки */ }.

Итог: код стал короче, безопаснее и читаемее — именно это и есть сила стандартной библиотеки C++.

Perf. Производительность (Performance)

Что такое производительность в C++? Это способность программы выполнять нужную работу за минимальное время и с минимальным расходом ресурсов (процессорного времени, оперативной памяти, пропускной способности шины). Представьте себе автомобиль: «производительность» — это то, насколько быстро и экономно он доезжает из точки А в точку Б. В C++ за производительность отвечают не только алгоритмы, но и то, как именно компилятор и процессор «видят» ваш код.

Этот раздел — для тех, кому нужна высокая производительность или низкая задержка. Речь о том, как достичь результата за предсказуемо короткое время, тратя как можно меньше ресурсов. Правила здесь более строгие и навязчивые, чем нужно большинству приложений: наивно следовать им во всём коде нельзя — достижение низких задержек требует дополнительной работы. Главный совет раздела — не оптимизировать вслепую, а измерять. И помните: преждевременная оптимизация — корень многих зол (знаменитая цитата Дональда Кнута: «premature optimization is the root of all evil»).

Практический принцип: сначала напишите простой, понятный и корректный код → запустите профилировщик (perf, VTune, Tracy, callgrind, Visual Studio Profiler) → найдите реальные узкие места → оптимизируйте точечно → измерьте эффект. Цикл «измерение → оптимизация → измерение» — основа инженерного подхода к производительности.

ID Правило (оригинал) Суть
Per.1 Don't optimize without reason Не оптимизируйте без причины: оптимизация всегда идёт в компромисс с читаемостью, надёжностью и стоимостью сопровождения. Аналогия: не стоит точить нож, которым вы ни разу не пользовались. Типичная ошибка: микро-оптимизации «на всякий случай» — код становится хуже, а выигрыш не измерен.
Per.2 Don't optimize prematurely Не оптимизируйте преждевременно: сначала добейтесь корректной работы, затем измерьте профиль нагрузки и оптимизируйте «горячие» участки. Аналогия: не настраивайте карбюратор машины, которая ещё не заводится. Принцип: «сначала заставь работать, потом — работать быстро».
Per.3 Don't optimize something that's not performance critical Не оптимизируйте некритичное: усилия вне узкого места не ускорят систему в целом. Закон Амдала: общее ускорение ограничено долей времени, которую занимает оптимизированный участок. Аналогия: расширение грунтовой дороги не спасёт от пробки на шоссе. Типичная ошибка: оптимизация редкого код-пути или холодного старта вместо горячего цикла.
Per.5 Don't assume that low-level code is necessarily faster than high-level code Не считайте низкоуровневый код автоматически быстрее: современные оптимизирующие компиляторы (GCC, Clang, MSVC) отлично справляются с высокоуровневыми абстракциями, а ручные «трюки» часто мешают автовекторизации и inlining. Аналогия: не думайте, что ручная пила всегда лучше электрической. Типичная ошибка: использование C-массивов и сырых указателей «для скорости» вместо std::vector — на современных компиляторах это часто медленнее или равноценно.
Per.6 Don't make claims about performance without measurements Не утверждайте о производительности без измерений: интуиция программистов регулярно ошибается. Аналогия: не предсказывайте погоду по ощущениям — нужен термометр. Инструменты: google-benchmark, Celero, std::chrono, Benchmark.h (Catch2). Практика: пишите бенчмарки, сравнивайте варианты на реальных данных, проверяйте разброс результатов.
Per.7 Design to enable optimization Проектируйте так, чтобы оптимизация была возможна: чистые интерфейсы, минимизация разделяемого состояния и зависимостей дают компилятору свободу для inlining, автовекторизации и аллокации регистров. Аналогия: стройте дом со съёмными перегородками — потом легче сделать перепланировку. Антипример: глобальные переменные и синглтоны мешают оптимизации и усложняют тестирование.
Per.11 Move computation from run time to compile time Переносите вычисления в compile-time: constexpr функции, шаблоны и consteval убирают работу из рантайма и превращают её в проверки на этапе компиляции. Аналогия: нарезайте овощи дома, а не при клиенте в ресторане. Осторожно: не злоупотребляйте — раздувание бинарника вредит кешу инструкций.
Per.18 Space is time Память — это время: компактные структуры данных лучше укладываются в кеш процессора (L1/L2/L3), а промахи кеша обходятся в сотни тактов. Аналогия: инструменты в ящике находятся быстрее, чем разбросанные по всему гаражу. Типичная ошибка: использовать std::list «для скорости» — на практике из-за cache miss он часто медленнее std::vector. Правило: предпочитайте плоские массивы и SoA (Structure of Arrays) вместо AoS.
// Плохо: значение вычисляется при каждом вызове
int table_size() { return 1024 * 64; }

// Хорошо: значение известно при компиляции
constexpr int table_size() { return 1024 * 64; }

Что здесь происходит и почему это важно:

  • В первом варианте компилятор может (но не обязан) вычислить 1024 * 64 заранее — например, если функция помечена inline и вызывается в той же единице трансляции. Но если функция вызывается из другой единицы трансляции (через заголовок), компилятор не сможет её встроить, и умножение произойдёт в рантайме.
  • Во втором варианте ключевое слово constexpr гарантирует, что вычисление происходит на этапе компиляции. Компилятор обязан подставить готовую константу 65536 в каждое место использования. Никакого вызова функции, никаких затрат в рантайме.

Типичная ошибка: забыть constexpr для простых функций-констант. В результате компилятор вынужден оставить вызов и теряет возможность оптимизации. Проверяйте, что ваши «чистые» функции действительно помечены constexpr (или consteval в C++20, если значение должно быть именно вычислено в compile-time).

Как проверить на практике: скомпилируйте код с флагом оптимизации (например, -O2) и посмотрите ассемблерный листинг (-S для GCC/Clang или /FA для MSVC). Для constexpr-варианта вы увидите только непосредственное значение константы, без инструкций вызова функции. Это и есть материальное подтверждение того, что вычисление перенесено в compile-time.

Антипаттерны, которые запрещают Core Guidelines

Антипаттерн — это популярный, но ошибочный приём в программировании, который на первый взгляд выглядит разумным, а на практике приводит к багам, утечкам ресурсов или просто плохо читается. Документ C++ Core Guidelines (поддерживается Бьёрном Страуструпом, Гербом Саттером и сообществом) — это свод рекомендаций от самих авторов языка: он объясняет, как писать на C++ типобезопасно, эффективно и без сюрпризов вроде неопределённого поведения (UB).

Запреты в Guidelines — это не догмы и не «суеверия»: за каждым антипаттерном стоит конкретная техническая причина (потеря ресурса при исключении, скрытое UB, непонятный контракт), которую полезно понимать, а не просто заучивать.

Ниже — типичные конструкции, которых документ прямо советует избегать. В таблице указано, каким правилом запрещён антипаттерн, почему именно он опасен (а не просто «плохой»), и чем его заменить.

Антипаттерн Почему плохо Правило Чем заменить
Голый new без владельца Между new и delete может вылететь исключение или сработать ранний return — тогда указатель просто «потеряется», а память утечёт. Кроме того, непонятно, кто вообще обязан вызвать delete R.11 std::make_unique / std::make_shared
Парные lock()/unlock() вручную Если между lock() и unlock() бросится исключение, unlock() не выполнится и блокировка останется навсегда — все остальные потоки зависнут (дедлок) CP.20 std::lock_guard / std::scoped_lock
Сырой владеющий указатель T* Владение не выражено в самом типе: из сигнатуры функции непонятно, должна ли она delete[] указатель или нет. Легко получить двойное удаление, висячий указатель или утечку R.3, I.11 unique_ptr / shared_ptr
delete в прикладном коде В большой программе парные new/delete разбросаны по разным функциям/веткам, и их число перестаёт совпадать — отсюда утечки и повторные delete R.11 RAII-дескрипторы
Публичный невиртуальный деструктор базы delete ptr через указатель на базу вызовет только ~Base(), но не ~Derived() — поля наследника не освободятся, а для классов с виртуальными функциями это прямое UB C.35 virtual ~Base() = default
Одноаргументный конструктор без explicit Компилятор неявно превращает в ваш объект любой подходящий аргумент: MyClass x = 42; «проходит» без вашего ведома, ломая замысел автора C.46 explicit
Копирование полиморфного объекта по значению Происходит срез (slicing): копируется только базовая часть, виртуальная диспетчеризация и динамический тип «отрезаются» — вы получаете уже не тот объект ES.63, C.145 Ссылка или указатель
const_cast для снятия const Обход контракта, который функция сама же пообещала соблюдать. Если записать в реально константный объект, это UB; чаще всего симптом — ошибка в дизайне интерфейса ES.50 Исправить интерфейс
C-приведение (T)x Молча объединяет в себе const_cast, static_cast и reinterpret_cast, отключая почти все проверки компилятора. Случайно можно «отбросить const», даже не заметив ES.48, ES.49 static_cast / dynamic_cast
memset/memcpy для нетривиальных типов Эти функции копируют «сырые» байты — конструкторы, деструкторы и таблица виртуальных функций не вызываются. Объект получается «мёртвым», любое обращение к нему — UB C.90 Конструкторы и присваивание
Неинициализированная переменная Чтение переменной до записи — неопределённое поведение: значение может быть любым, а компилятор вправе оптимизировать код, считая, что вы этого никогда не делаете ES.20 Инициализировать при объявлении
Смешение signed и unsigned Из-за правил integer promotion сравнения и арифметика над знаковыми и беззнаковыми числами дают неверный результат: например, -1 > 1u оказывается true ES.100 Один знаковый тип, явные приведения
volatile для синхронизации volatile лишь запрещает оптимизации обращения к переменной; он не атомарен и не упорядочивает доступ из разных потоков — компилятор и CPU всё ещё могут переставить операции CP.8 std::atomic, мьютексы
Исключение из деструктора Во время раскрутки стека от первого исключения выход из деструктора с новым исключением сразу вызывает std::terminate() — программа просто умирает C.36, E.16 noexcept и внутренняя обработка
union для псевдонима типов Чтение «неактивного» члена union — неопределённое поведение по стандарту: так называемый type punning через union запрещён C.183 std::bit_cast / memcpy

Как пользоваться этой таблицей на практике. Если в обзоре кода (code review) вы встретили конструкцию из левого столбца, не надо просто писать «нарушает Guidelines» — это вызывает отторжение. Гораздо полезнее сослаться на конкретное правило и указать техническую причину, например: «Тут голый new — если ниже кинется исключение, память утечёт (R.11). Заверни в std::unique_ptr».

Типичные ошибки при переходе «на Guidelines».

  • Слепо заменять все сырые указатели на shared_ptr, хотя для единоличного владения достаточно unique_ptr (он дешевле и подчёркивает замысел).
  • Ставить virtual ~Base() = default; всегда подряд, даже у классов, которые не участвуют в полиморфизме — это лишний расход памяти на таблицу виртуальных функций.
  • Делать конструктор explicit, когда из него потом всё равно делают неявное преобразование через static_cast, — лучше пересмотреть тип параметра.
  • Запрещать goto, но при этом оставлять голые new/delete — реальная опасность обычно там, а не в управляющих конструкциях.
  • Включать -Wall -Wextra и считать, что компилятор «поймает всё». Guidelines закрывают именно то, что компилятор формально обязан пропустить по стандарту.

Аналогия из жизни. Антипаттерны — это как привычки, которые «вроде работают» дома, но опасны на производстве: встать на табуретку, чтобы поменять лампочку, — обычно ок, но на стройке за это штрафуют. C++ — это и есть такая «стройка»: низкоуровневый доступ к памяти, многопоточность, исключения, внешние библиотеки на C. Core Guidelines формализуют те самые «штрафы», которые защищают от падений.

Частые ошибки

Эта таблица собрана по принципу «симптом → диагноз → лечение». В первой колонке — типичная ошибка, которую пишут новички (и не только). Во второй — номера правил C++ Core Guidelines, где эта ошибка разобрана подробно. В третьей — краткая рекомендация, как поступать правильно. Запомните: в C++ за почти каждой серьёзной ошибкой стоит одна и та же идея — не делайте руками то, что уже умеет делать язык или стандартная библиотека.

Ошибка Правило Как правильно
Забыли delete после new R.11 Владеть через std::make_unique / std::make_shared. Почему: «голый» new/delete — это как ключи от квартиры без адреса: выделить память легко, но вспомнить, где и когда её освобождать, почти невозможно. make_unique привязывает освобождение к времени жизни объекта — работает детерминированно, исключения безопасны.
delete дважды или через базу R.3, C.35 Один владелец и виртуальный деструктор базы. Почему: двойной delete — неопределённое поведение (обычно краш или порча кучи). Удаление через указатель на базу без virtual ~ деструктор приводит к тому, что уничтожается только «верхушка» объекта, а поля наследника остаются «висячими» — классическая утечка и UB.
Утечка при исключении R.1, E.6 RAII: захват ресурса в конструкторе, освобождение в деструкторе. Почему: если между new и delete летит исключение, delete просто не выполнится. RAII превращает «ручной» контроль в «привязанный к жизни объекта» — даже при исключении деструктор сработает автоматически.
Ручной lock/unlock CP.20 std::lock_guard / std::scoped_lock. Почему: забытый unlock — deadlock на пустом месте; исключение между lock и unlock — мьютекс навсегда залочен. RAII-обёртка гарантирует освобождение при любом выходе из скоупа.
Взаимная блокировка двух мьютексов CP.21 Одновременный захват через std::scoped_lock. Почему: поток A держит мьютекс 1 и ждёт мьютекс 2, поток B держит мьютекс 2 и ждёт мьютекс 1 — классический deadlock «поцелуй смерти». std::scoped_lock атомарно захватывает несколько мьютексов в едином порядке и предотвращает взаимное ожидание.
Гонка данных при общем счётчике CP.2 std::atomic или мьютекс. Почему: counter++ — это три операции (чтение, увеличение, запись); два потока делают это «одновременно» — теряются инкременты. std::atomic даёт гарантированно атомарную операцию без блокировок.
Срез при передаче полиморфного объекта ES.63, C.145 Передавать по ссылке или через умный указатель. Почему: передача по значению void f(Derived d) отрезает «производную» часть — вызывается копирующий конструктор базы, и в f работают только базовые поля. Это тихий баг: код компилируется, но виртуальные методы ведут себя как у базы.
const_cast, меняющий const-объект ES.50 Принимать неконстантную ссылку, если нужно менять. Почему: const_cast снимает «обещание не менять» — а если объект изначально был const, его модификация есть UB. Это почти всегда симптом плохо спроектированного API: либо меняйте интерфейс, либо используйте mutable для логически-независимого состояния.
Неявное преобразование из конструктора C.46 Объявить конструктор explicit. Почему: конструктор от одного аргумента без explicit неявно превращает любой подходящий тип в ваш — void f(Thing); f(42); скомпилируется, хотя вы этого не хотели. explicit требует явного указания типа и отлавливает случайные конверсии на этапе компиляции.
Сужение double → int ES.46 Инициализация {} и gsl::narrow_cast. Почему: double 3.7 в int молча превращается в 3, а 1e20 — в неопределённое значение. Списковая инициализация int x{3.7}; запрещает сужение компилятору, а gsl::narrow_cast явно говорит «я знаю, что теряю точность» и кидает исключение в рантайме.
Чтение неинициализированной переменной ES.20 Инициализировать при объявлении, {}. Почему: в C++ переменные встроенных типов по умолчанию содержат «мусор» — не ноль, не что-то предсказуемое, а биты, оставшиеся в памяти. Чтение такого значения — UB; компилятор может оптимизировать ветку, которая якобы никогда не выполнится.
return std::move(local) F.48 Возвращать локальную переменную напрямую. Почему: компилятор обязан применить NRVO (Named Return Value Optimization) к локальной переменной, и std::move здесь только мешает — может, наоборот, отключить оптимизацию и привести к лишнему перемещению. Пишите return local; — это и идиоматичнее, и быстрее.
memset по объекту C++ C.90 Пользоваться конструкторами и присваиванием. Почему: memset(&obj, 0, sizeof obj) затирает vptr (виртуальную таблицу) и приводит к падению при первом виртуальном вызове, плюс не вызывает конструкторы полей. Современный C++ предоставляет дефолтные значения и конструкторы — они инициализируют корректно.
Деструктор бросает исключение C.36, E.16 Пометить noexcept и обрабатывать внутри. Почему: если во время раскрутки стека (обработка исключения) летит второе исключение из деструктора, программа аварийно завершается (std::terminate). Деструктор обязан либо подавить исключения (try/catch внутри), либо пометить себя noexcept и не бросать вообще.
catch (...) на каждом вызове E.17, E.18 Ловить там, где действительно можете исправить. Почему: «проглотить всё» — антипаттерн: вы теряете информацию об ошибке и прячете баг. Ловите только те типы, которые умеете обработать; иначе позвольте исключению подняться выше, где про него знают.
union для переинтерпретации байтов C.183 std::bit_cast (C++20) или memcpy. Почему: union для type punning — это UB по стандарту (чтение неактивного члена). std::bit_cast безопасен, понятен компилятору и оптимизируется до одного mov. До C++20 — std::memcpy между unsigned char буфером и целевым типом.

Ресурсы

Прежде чем переходить к самим правилам, полезно держать под рукой набор проверенных источников. Ниже — основные из них: оригинал Guidelines, инструменты проверки, справочник по языку и библиотеке, а также материалы конференций, где авторы правил лично объясняют свои мотивы. Используйте их не только при чтении шпаргалки, но и в повседневной работе: открывать cppreference при встрече с незнакомой функцией, прогонять clang-tidy в CI, заглядывать в Compiler Explorer, чтобы понять, во что превращается ваш «красивый» код.

  • C++ Core Guidelines — оригинал — полный документ с обсуждениями и примерами. Это главный первоисточник: здесь у каждого правила есть развёрнутое обоснование («почему именно так»), примеры «хорошего» и «плохого» кода, а часто — ссылки на выступления и статьи. Рекомендуется читать правила именно в оригинале, если перевод кажется недостаточно полным.
  • isocpp/CppCoreGuidelines на GitHub — исходник в Markdown, история правок, обсуждения. Сюда стоит заглянуть, когда хочется увидеть, как именно появилось правило: в Issues и Pull Requests обсуждаются спорные случаи, альтернативные формулировки и отказы от ранее принятых идей. Это помогает понять, что правило — не догма, а результат дискуссии.
  • cppreference — точная справка по стандартной библиотеке и языку. «Словарь» C++: описания контейнеров, алгоритмов, требований к итераторам, гарантий исключений и сложности. Привычка открывать cppreference первой при любом сомнении (std::vector::erase действительно инвалидирует итераторы? а что делает std::move_if_noexcept?) спасает от множества трудноуловимых ошибок.
  • clang-tidy, проверка cppcoreguidelines-* — автоматическая проверка многих правил из этого документа. Лучший способ превратить Guidelines из теории в практику: запускайте линтер в CI, чтобы плохой код не попадал в репозиторий. Имейте в виду, что покрыты далеко не все правила (многие — про дизайн, их инструмент проверить не в состоянии), но то, что проверяется (pro-bounds-pointer-arithmetic, owning-memory, special-member-functions и т. п.), — это уже большая польза.
  • C++ Core Guidelines Checkers — обзор инструментов проверки и поддержки GSL. Сводная страница со списком анализаторов: clang-tidy, CppCoreCheck (MSVC), SonarQube-плагины и др. Полезно знать, что в крупных проектах часто используют сразу несколько инструментов — их предупреждения дополняют друг друга.
  • GSL: Guidelines Support Library — типы not_null, span, narrow_cast, поддерживающие правила. Небольшая «библиотека-помощник»: она не заменяет стандартную, а добавляет типы, делающие намерения явными. Например, gsl::not_null<T*> на этапе компиляции фиксирует контракт «указатель не может быть null» — это тот случай, когда код говорит то же, что и комментарий, только не врёт. Совет: подключайте GSL точечно, не тащите её целиком ради одной функции.
  • Compiler Explorer — посмотреть, какой код генерирует компилятор. «Песочница» для экспериментов: вставили код — увидели ассемблер разных компиляторов с разными флагами оптимизации. Незаменим, когда хочется проверить, действительно ли std::vector инлайнится, стоит ли оверхед от виртуального вызова, и не превращается ли «очевидная» конструкция в копирование.
  • CppCon: выступления Бьёрна Страуструпа и Херба Саттера — разбор правил на конференциях. Доклады мейнтейнеров Guidelines — это «расширенная версия» правил с реальными примерами из индустрии, историями из практики и обсуждением компромиссов. Если правило кажется странным или избыточным, послушайте, как его автор объясняет, зачем оно появилось, — почти всегда мотив становится понятен.

Типичная ошибка новичков: пытаться запомнить все правила сразу и злиться, что их сотни. Лучше использовать эту шпаргалку как навигатор: встретили в коде new/delete без обёртки — идёте к разделу про RAII; увидели reinterpret_cast — открываете I.* про приведение типов. А инструменты (clang-tidy, GSL) пусть ловят за вас хотя бы механические нарушения, пока вы учитесь видеть более тонкие.

Атрибуция и лицензия

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

Что это значит для вас на практике:

  • Если вы цитируете эти правила в статье, презентации или коде — ссылайтесь и на оригинал, и на данный перевод.
  • Если обнаружите расхождение между переводом и оригиналом — доверяйте оригиналу, перевод может содержать упрощения или опечатки.
  • Любые юридически значимые вопросы (включение в коммерческую документацию, преподавание и т. п.) решаются по тексту оригинальной лицензии.

Почему мы это пишем: перевод-выжимка — это производный материал: он создан на основе чужого открытого текста. Авторы оригинала (Bjarne Stroustrup, Herb Sutter и сообщество участников) вложили огромный труд в написание и рецензирование этих правил, поэтому они заслуживают упоминания. Одновременно мы снимаем с себя ответственность за возможные неточности: каждый переводчик интерпретирует смысл немного по-своему, и это нормально — важно, чтобы читатель знал об этом.

Типичная ошибка новичков: «раз это открытый текст, можно не указывать автора» — нет, лицензия MIT и культура open source требуют сохранять копирайт и уведомление о лицензии в любых производных работах. Это не формальность, а юридическое требование.

Лицензия MIT в двух словах: можно свободно использовать, копировать, изменять и распространять материал при условии сохранения уведомления о копирайте и самой лицензии. Программное обеспечение поставляется «как есть», без каких-либо гарантий.

Этот материал — неофициальный русский перевод-выжимка ключевых разделов. Оригинальный документ «C++ Core Guidelines» создан и поддерживается Bjarne Stroustrup и Herb Sutter при участии сообщества; права и лицензия указаны в репозитории isocpp/CppCoreGuidelines (лицензия MIT). Приведённые здесь правила пересказаны и сокращены; любые неточности или упрощения принадлежат данному переводу, а не авторам оригинала. Для цитирования и юридически значимого использования опирайтесь на оригинальный текст и его лицензию.

Что дальше


Связанные шпаргалки: C++, C++ для студентов, Алгоритмы, Rust