C++101 — каталог идиом и приёмов C++ в четырёх частях: Ч.1 · Ч.2 · Ч.3 · Ч.4
Оглавление этой части
- Making New Friends (сложно)
- Non-member Non-friend Function (сложно)
- Small Object Optimization (SOO/SSO)
- Prohibiting Heap-based Objects
- Storage Class Tracker
- Execute-Around Pointer
- Temporary Proxy
- Address Of
- nullptr
- Move Constructor
- Implicit conversions
- Recursive Type Composition (сложно)
- Temporary Base Class
- Boost mutant
- Multi-statement Macro
- Named Loop
- Named Parameter
- Named External Argument
- Deprecate and Delete
- Function Poisoning
- Inner Class
- Rule of Zero/Three/Five
- Most Vexing Parse
- Pass-key
- Defaulted Comparisons и оператор <=> (Spaceship)
- Что со всем этим делать
Making New Friends (сложно)
Из предыдущей секции рождается понимание, что корректные объявлении дружественных функций для шаблонных классов, могут сделать, чтобы у каждой инстанциации шаблонного класса была своя дружественная функция, правильно с ним связанная. Когда вы объявляете friend-функцию внутри шаблона, то «одна функция-шаблон, может дружить со всеми инстанциациями», а другая «своя только для отдельной инстанциации».
Каноническая схема - это определить дружественную функцию прямо в теле шаблонного класса (это тот самый приём «hidden friend», родственный трюку Бартона-Накмана), тогда для каждой инстанциации шаблона генерируется своя нешаблонная дружественная функция, видимая только через ADL и корректно работающая именно с этой инстанциацией. Это «заводит нового друга» для каждого конкретного типа, отсюда и название, но можно объявить friend-шаблон, или friend конкретной инстанциации, что требует предварительных объявлений и тонкой возни с тем, чтобы типы совпали.
Расплачиваемся мы здесь комбинаторикой взаимодействия шаблонов, дружбы, ADL и правил поиска имён и сделать «не так» очень легко, можно получить функцию, которая не находится, или находится, но не та, или конфликтует при множественных инстанциациях. Современный консенсус всегда предпочитать hidden friends (определение в теле класса), потому что они и проще, и эффективнее при разрешении перегрузок (не засоряют глобальный набор кандидатов).
Идиома описана давно и детально разбирается в Вандевурдом в его работах, а её актуальность выросла в последние годы благодаря «возрождению» hidden friends как рекомендуемой практики в работах по оптимизации времени компиляции и качества ошибок, где показано, что hidden friends заметно разгружают компилятор по сравнению с операторами, объявленными в области видимости namespace.
template <class T>
class Vector3 {
T x_, y_, z_;
public:
// hidden friend и своя нешаблонная функция для каждого Vector3<T>,
// находится только через ADL, корректно связана с этой инстанциацией
friend Vector3 operator+(const Vector3& a, const Vector3& b) {
return {a.x_ + b.x_, a.y_ + b.y_, a.z_ + b.z_};
}
friend T dot(const Vector3& a, const Vector3& b) {
return a.x_*b.x_ + a.y_*b.y_ + a.z_*b.z_;
}
};
Vector3<float> a, b;
auto c = a + b; // находит "своего" друга через ADL
Используется это при написании шаблонной математики, которой в играх полно в виде шаблонных векторов, матриц и кватернионов, параметризованных скалярным типом (float, double, half, fixed-point). Всем им нужны операторы (+, -, *, dot, cross), и определять эти операторы как hidden friends внутри шаблона будет самым чистым и эффективным способом, дающий правильную связь оператора с каждой инстанциацией и не засоряющий глобальное пространство имён.
Практическая выгода hidden friends в большой шаблонной кодовой базе не столько корректность, скролько влияение на скорость компиляции и качество ошибок, теперь операторы, спрятанные в классах, не участвуют в разрешении перегрузок для несвязанных типов, и компилятору приходится рассматривать меньше кандидатов.
В движке с обширной шаблонной математикой это складывается в ощутимую разницу времени сборки, так что making new friends уже не только про «как сделать, чтобы компилировалось», но и про «как сделать, чтобы компилировалось быстро и с понятными ошибками».
Non-member Non-friend Function (сложно)
Из двух предыдущих секций, родился принцип проектирования, гласящий, что если функцию можно реализовать как свободную функцию, не являющуюся ни членом класса, ни его другом (то есть пользуясь только публичным интерфейсом), то именно так её и следует делать. Звучит контринтуитивно, потому что мы привыкли пихать всё в методы класса.
Но инкапсуляция измеряется числом функций, которые могут сломаться при изменении приватных данных класса и каждый метод и каждый друг имеет доступ к приватным членам, а значит потенциально зависит от них и может пострадать при их изменении. Свободная функция, не являющаяся другом, доступа к приватным данным не имеет и может работать только через публичный интерфейс, поэтому изменение внутренностей класса её затронуть не может.
Следовательно, чем больше функциональности вынесено в такие свободные функции, тем меньше кода завязано на приватные детали, тем выше инкапсуляция. Парадоксально, но вынос функций из класса наружу делает класс более инкапсулированным, а не менее.
Распачиваться за это приходится уходом от «объектного» программирования в глобальные методы и требует сознательного усилия, что часто вызывает споры в команде. Плюс свободные функции нужно куда-то поместить (обычно в тот же namespace, что и класс, чтобы их находил ADL), и их становится много. А еще не всё можно вынести, но сама идея, что класс предоставляет минимальный полный набор примитивных операций как методы, а всё остальное (удобные комбинации, алгоритмы) как свободные функции поверх них, и поныне жива среди игровых разработчиков.
Принцип сформулировал и горячо отстаивал Скотт Майерс в Effective C++, и его позже поддержал Херб Саттер, под их влияением это попало в стандартную библиотеку, где алгоритмы это самые яркие примеры свободных функций, и даже многие операции над строками вынесены наружу. После С++11 это стало одним из направлений современного «interface-минималистского» дизайна классов в разработке.
class Vec3 {
float x_, y_, z_;
public:
float x() const { return x_; } // минимальный публичный интерфейс
float y() const { return y_; }
float z() const { return z_; }
void set(float x, float y, float z) { x_=x; y_=y; z_=z; }
};
// length, normalize, lerp свободные, через ПУБЛИЧНЫЙ интерфейс.
// Меняя приватную раскладку нельзя эти функции сломать.
float length(const Vec3& v) {
return std::sqrt(v.x()*v.x() + v.y()*v.y() + v.z()*v.z());
}
Vec3 lerp(const Vec3& a, const Vec3& b, float t) {
return {a.x()+(b.x()-a.x())*t, /* ... */};
}
Из стандартной библиотеки этот принцип "протек" во все современные математические и геометрические типы движков, у которых огромный набор операций. Делать length, normalize, lerp, reflect, project, angle_between методами Vec3 означало бы раздувать класс и привязывать все эти операции к его внутренностям, а вынос их в свободные функции оставляет Vec3 компактным, с минимальным набором базовых операций, а богатую библиотеку операций над ним строит снаружи, не трогая приватные данные.
Это даёт и практическую гибкость, когда новые операции над типом можно добавлять, не редактируя сам тип (важно, когда тип в чужой библиотеке), и группировать их по назначению, а не вываливать в один распухший класс. Поэтому компактные математические типы плюс обширные namespace'ы свободных функций над ними стали практическим способом держать ключевые типы движка маленькими, устойчивыми к изменениям и расширяемыми.
Small Object Optimization (SOO/SSO)
Это приём, при котором маленькие объекты хранятся прямо внутри обёртки, во встроенном буфере, вместо выделения под них памяти в куче если объект достаточно мал, чтобы поместиться в зарезервированное внутри обёртки место, то аллокация не происходит вовсе. Если же он больше, то обёртка падает обратно на кучу.
Самый известный частный случай эту Small String Optimization (SSO) Александрску, когда короткие строки (обычно до 15–22 символов) живут прямо в объекте std::string, без обращения к аллокатору.
Мотивация была избавиться от аллокаций в куче, которые дороги и непредсказуемы, а строки чаще короткие, чем длинные. Второй кандидат этоstd::function, который чаще оборачивает маленькую лямбду, чем огромный функтор. Реализуется обычно через объединение (union) встроенного буфера и указателя плюс флаг/дискриминатор «я сейчас маленький или большой», и вся логика прячется за интерфейсом.
Расплачиваться приходится сложностью реализации и бОльшими размерами класса, который специально раздувают чтобы поместить побольше данных, и больший буфер значит реже аллокации, но хуже плотность в массивах. Логика «маленький/большой» с union'ами, placement new и ручной диспетчеризацией копирования/перемещения очень нетривиальна и легко содержит баги. Плюс перемещение объекта с активным SOO означает физическое копирование буфера (нельзя просто украсть указатель), что иногда дороже, чем для heap-варианта.
Это давнее практическое знание о реализации строк и контейнеров, и SSO применялся в реализациях std::string десятилетиями (libc++, libstdc++, MSVC STL каждая со своим порогом и раскладкой), аstd::function, std::any и многие type-erasure-обёртки используют SOO, чтобы не аллоцировать под мелкие вызываемые объекты.
// Схематично: маленькое значение живёт в буфере, большое в куче
template <class T, std::size_t N = 32>
class SmallBuffer {
alignas(T) std::byte inline_[N];
T* ptr_;
bool on_heap_;
public:
template <class... Args>
SmallBuffer(Args&&... a) {
if constexpr (sizeof(T) <= N) {
// влезает кладём внутрь
ptr_ = new (inline_) T(std::forward<Args>(a)...);
on_heap_ = false;
} else {
// не влезает, аллок в кучу
ptr_ = new T(std::forward<Args>(a)...);
on_heap_ = true;
}
}
// ... деструктор, move, copy с учётом on_heap_ ...
};
В играх SOO традиционо считается однай из самых ценных оптимизаций, потому что движки одержимы избеганием аллокаций в кадре, а мелкие объекты повсюду. И к SSO идее приведены кастомные строковые типы движков, и контейнеры вроде inline_vector/fixed_vector (хранящие первые N элементов inline и уходящие в кучу только при переполнении), и даже мапы и списки, где это используется для коротких списков (несколько коллизий, пара дочерних узлов или горстка тегов), которые иначе плодили бы аллокации, а EASTL и подобные библиотеки предоставляют такие контейнеры из коробки.
Грамотно подобранный порог буфера превращает «аллокация на каждый чих» в «аллокация только для редких крупных случаев», и на профайле памяти это видно как резкое падение числа обращений к аллокатору. Поэтому понимание SOO и умение применять inline-контейнеры обычный практический навык в оптимизации, как один из самых прямых способов убрать аллокации из хотпаса.
Prohibiting Heap-based Objects
В играх часто разделяют, где некоторым объектам разрешено или запрещено жить, только в куче, только на стеке, или как угодно. Иногда дизайн требует, чтобы объекты определённого класса создавались исключительно через new (например, потому что управляются счётчиком ссылок, который вызывает delete this, а значит объект на стеке привёл бы к крашу при попытке себя удалить). А иногда наоборот, объект обязан жить только на стеке (например, scope guard, и вся суть в привязке к области видимости, и создание которого в куче было бы ошибкой использования).
Запретить создание в куче можно, сделав operator new приватным (или удалённым) — тогда new MyClass не скомпилируется, и объект можно создать только как локальную/членскую переменную. Обратным будет запретить создание на стеке, что делается приватным деструктором, и если деструктор недоступен извне, компилятор не может создать автоматический объект (ему негде вызвать деструктор при выходе из области), но создание через new работает, а удаление идёт через специальный публичный метод destroy(), который зовёт delete this изнутри, где деструктор доступен. Так объект "насильно" загоняется в кучу.
Платить за оба ограничения приходится хрупкостью и дисциплиной использования, которые нарушают ожидания пользователя («почему я не могу создать это на стеке?!»), а приватный деструктор к тому же ломает наследование и членство по значению. Идиома почти всегда значит, что у класса необычная модель времени жизни, и её стоит сопровождать жирным комментарием.
Все это подробно разобрал Скотт Майерс в More Effective C++ (отдельный пункт «как ограничить количество объектов» и связанные приёмы про размещение в куче/на стеке) и это классический метод как взять под контроль модель создания объектов, тесно связанное с reference-counting (Counted Body) и с особыми требованиями времени жизни.
// Только куча: приватный деструктор не даёт создать на стеке
class RefCountedResource {
~RefCountedResource() = default;
// приватный => нельзя на стеке
int refs_ = 1;
public:
void release() { if (--refs_ == 0) delete this; }
// удаление изнутри, где dtor доступен
};
// RefCountedResource r; // ошибка: деструктор недоступен
// auto* p = new RefCountedResource; // OK
// Только стек: удалённый operator new не даёт создать в куче
class ScopedLock {
public:
static void* operator new(std::size_t) = delete;
// нельзя через new
};
В разработке игр случай «только куча» естественно возникает для объектов с интрузивным счётчиком ссылок (ресурсы, RHI-объекты), которые управляют своим временем жизни через delete this и потому не должны жить на стеке. А «только стек» нужны для RAII-обёрток вроде scope guard'ов, профайлер-таймеров и блокировок, чья семантика жёстко привязана к области видимости, и создание которых в куче было бы логической ошибкой, отменяющей весь смысл RAII.
Но в современном движковом коде эти ограничения чаще выражают мягче и вместо приватного деструктора делают фабрику, возвращающую intrusive_ptr/shared_ptr (что и так направляет в кучу и заодно даёт безопасное владение), а вместо запрета new просто делают [[nodiscard]] на конструкторе scope guard'а и отслеживают на ревью.
Жёсткие версии идиомы оставляют для случаев, где компилятор действительно должен ловить ошибку, потому что цена неверного размещения скорее всего краш в рантайме. Знать их стоит, чтобы понимать необычные классы с приватными деструкторами и удалёнными new, и не пытаться использовать их «нормально», упираясь в загадочные ошибки компиляции, за которыми стоит намеренное ограничение модели времени жизни.
Storage Class Tracker
Редкая и довольно сложная идиома, позволяющая объекту в рантайме узнать, где он размещён: на стеке, в куче или в статической памяти. Это связано с предыдущей идиомой, но решает не «запретить», а «выяснить и среагировать», когда объект хочет вести себя по-разному в зависимости от своего класса хранения, или хотя бы убедиться, что его создали правильно.
Реализация завязана на сравнение адресов, чтобы определить примерные границы стека (например, по адресу локальной переменной в текущем фрейме) и кучи и сравнить с ними адрес this. Один из приёмов будет переопределить operator new, который устанавливает флаг «следующий объект создаётся в куче» прямо перед аллокацией, а конструктор затем проверяет этот флаг, и если он не был установлен, значит объект родился не через new , т.е. на стеке или статически, а сброс и установка флага вокруг аллокации позволяют конструктору отличить heap-объект от прочих.
Платить приходится неопределённым поведения и переносимостью, потому что сравнение адресов с границами стека/кучи опирается на предположения о раскладке памяти, которые стандарт не гарантирует, а трюк с флагом в operator new ломается при множественном наследовании, массивах и многопоточности. Это очень хрупкий приём, работающий «обычно, на этой платформе, при этих условиях», потому в продакшене его сторонятся, и чаще правильный ответ будет спроектировать класс так, чтобы он был известен по размещению (фабрики, политики владения).
Применяется скорее как любопытный приём, чем как работающий код и иллюстрирует, как далеко можно зайти в попытках узнать о собственном размещении, и заодно, почему этого обычно делать не стоит, потому что надёжного и переносимого способа в стандартном C++ нет.
// Хрупкий приём: флаг "создаюсь в куче", выставляемый operator new
class TrackedObject {
static thread_local bool heap_flag_;
bool on_heap_;
public:
static void* operator new(std::size_t n) {
heap_flag_ = true;
return ::operator new(n);
}
TrackedObject() : on_heap_(heap_flag_) {
heap_flag_ = false;
} // конструктор считывает
bool is_on_heap() const { return on_heap_; }
};
// Ненадёжно при массивах, исключениях, множественном наследовании
В играх storage class tracker не применяют в продакшене из-за его хрупкости, когда надо стабильно работать на множестве платформ и компиляторов, где объекты крайне чувствительны к платформенным различиям раскладки памяти. Любой приём, опирающийся на сравнение адресов со «стеком» и «кучей», на консоли может повести себя непредсказуемо, из-за активных методов защиты стека.
Но в отладочных и диагностических инструментах, вроде трекеров памяти и leak-детекторов, которым нужно классифицировать аллокации, можно попробовать. Но и там предпочитают более надёжные механизмы вроде кастомных аллокаторов, которые точно знают, что и где выделили, потому что сами это сделали, вместо угадывания постфактум по адресам.
Так что storage class tracker стоит знать в основном как иллюстрацию границы между «теоретически возможно» и «не стоит делать», как сам факт, что объект может попытаться узнать свой класс хранения сравнением адресов.
Execute-Around Pointer
Это идея применения «умного указателя», который выполняет некоторый код до и после каждого обращения к обёрнутому объекту, прозрачно для вызывающего. Вы пишете ptr->method() как обычно, но между вашим вызовом и реальным методом встраивается обвязка и что-то происходит перед каждым вызовом метода и что-то уже после него. Это применение идеи «execute around» (выполнить вокруг) к отдельным вызовам метода.
Теперь когда вы пишете wrapper->foo(), компилятор сначала зовёт operator-> обёртки, который возвращает не сам объект, а ещё один временный прокси-объект и у этого прокси тоже есть operator->, возвращающий уже настоящий объект. Временный прокси конструируется перед вызовом foo (его конструктор и есть «до») и уничтожается после завершения полного выражения (его деструктор «после»). Так конструктор/деструктор временного прокси оборачивают каждый вызов метода кодом «до» и «после», и это работает для любого метода объекта без перечисления их всех.
Расплачиваться придется неочевидность (двойной operator-> и временные прокси, которые трудно понять с первого взгляда) и накладными расходами на каждый вызов метода, когда создаётся и уничтожается прокси-объект. Плюс «до/после» одинаковы для всех методов и нельзя по-разному обрабатывать разные методы, не усложняя схему. Семантика времени жизни прокси (он живёт до конца полного выражения) даёт тонкости, если вызовы цепляются. Это коварный приём, уместный в узких сценариях.
Механихм описал Кевлин Хенни в статье «Function-Object and Execute-Around Pointer» как частный случай более широкой темы execute-around (к которой относятся и RAII, и scope guard), применённый именно к перехвату вызовов методов через прокси и двойной operator->. Это классический пример того, как перегрузка операторов в C++ позволяет встроить поведение в синтаксис, привычный пользователю.
template <class T>
class LockingPtr {
T* obj_;
std::mutex& mtx_;
struct Proxy {
T* obj_; std::mutex& mtx_;
Proxy(T* o, std::mutex& m) : obj_(o), mtx_(m) {
mtx_.lock();
} // "до" вызова
~Proxy() {
mtx_.unlock();
} // "после" вызова
T* operator->() { return obj_; }
};
public:
LockingPtr(T* o, std::mutex& m) : obj_(o), mtx_(m) {}
Proxy operator->() { return Proxy(obj_, mtx_); }
// временный прокси оборачивает вызов
};
LockingPtr<Inventory> inv(&inventory, inv_mutex);
inv->add_item(sword); // автоматически lock -> add_item -> unlock
В играх execute-around pointer находит применения там, где хочется прозрачно навесить обвязку на каждое обращение к объекту, вроде автоматической блокировки/разблокировки при доступе к разделяемому объекту, или логирование, или профилирование каждого вызова метода у отлаживаемого объекта, проверка инвариантов до и после модификации, dirty-флаги (пометить объект изменённым после любого мутирующего обращения).
Но в хотпасе накладные расходы на прокси-объект на каждый вызов обычно неприемлемы, поэтому все это живёт в отладочном и инфраструктурном коде, где удобство и прозрачность важнее перфа.
Temporary Proxy
Это механизм, при котором operator[] (или другой оператор доступа) возвращает не сам элемент и не ссылку на него, а временный объект-посредник, который умеет различать чтение и запись и реагировать на них по-разному. Нужна она, когда обнаружить факт записи в элемент через обычную ссылку невозможно, потому что ссылка не знает, читают через неё или пишут. Прокси же, перехватив operator= (запись) и operator T() (чтение), может различить эти ситуации.
Классические применение это реализацияstd::vector<bool>, где элементы упакованы по битам и «ссылку на бит» вернуть нельзя (бит не адресуется), поэтому operator[] возвращает прокси, который при чтении достаёт бит, а при присваивании устанавливает его. Или другой пример будет copy-on-write строки, где прокси при чтении символа не триггерит отделение копии, а при записи триггерит. Прокси здесь это «перехватчик», вставленный между синтаксисом доступа и реальными данными.
Платить приходится "прокси протеканием" иauto x = container[i] сохранит прокси, а не значение (классическая ловушка vector<bool>, из-за которой его считают «сломанным контейнером»). Прокси не является настоящей ссылкой, и код, ожидающий T&, с ним не работает, потому что адрес элемента через прокси не взять. Эти несоответствия делают прокси-контейнеры коварными, и vector<bool> главный пример того, почему «умный operator[]» бывает скорее проклятием, чем хорошим решением.
Все это разбиралось у Майерса (в том числе в контексте operator[], различающего чтение и запись, в More Effective C++) иstd::vector<bool> с его прокси reference стандартизированом еще в C++98, и служит и каноническим примером как хотят сделать, и каноническим предостережением "что получилось" одновременно.
// Прокси различает чтение и запись бита в упакованном битсете
class BitArray {
std::vector<std::uint64_t> words_;
struct BitProxy {
std::uint64_t& word; int bit;
operator bool() const {
return (word >> bit) & 1;
} // чтение
BitProxy& operator=(bool v) {
// запись
if (v) word |= (1ull << bit);
else word &= ~(1ull << bit);
return *this;
}
};
public:
BitProxy operator[](std::size_t i) { return {words_[i/64], int(i%64)}; }
};
В играх прокси-доступ встречается в упакованных и битовых структурах данных, которых немало, вроде битсетов флагов и масок, упакованных форматов цвета или нормалей, где элемент физически не адресуется как обычный объект и без прокси к нему не подобраться. Там прокси будет необходимостью, и единственный способ дать удобный []это сделать свой синтаксис поверх неадресуемых данных.
Но относиться к прокси-контейнерам надо с осторожностью, потому что есть ловушка с auto, несовместимость с кодом, ждущим настоящих ссылок, и накладные расходы прокси-объектов делают их инструментом для специальных случаев, а не для контейнеров общего назначения.
Многие движки сознательно избегают std::vector<bool> именно из-за его прокси-природы, заводя явные битсеты с понятными методами set/test вместо обманчиво-прозрачного []. Так что про temporary proxy полезно знать, когда это оправдано, и чтобы распознавать, почему некоторые [] ведут себя не как обычные, и не попадаться в связанные с этим ловушки.
Address Of
Известный механизм получения настоящего адреса объекта даже тогда, когда у его класса перегружен operator&. Да, в C++ можно перегрузить унарный оператор взятия адреса, и некоторые классы это делают (обычно ради хитрых прокси, COM-подобных умных указателей или DSL), но беда в том, что после такой перегрузки выражение &obj возвращает уже не адрес объекта, а что-то, что решил вернуть перегруженный оператор, и обобщённый код, которому нужен реальный адрес (например, чтобы сконструировать объект через placement new или сохранить указатель), оказывается обманут.
Надо обходить перегруженный operator&, добывать адрес «в обход», поэтому объект приводится к ссылке на символьный тип (через серию reinterpret_cast или const_cast), у которого operator& гарантированно не перегружен, берётся адрес уже этой символьной ссылки, и результат приводится обратно к нужному типу указателя. Цепочка приведений выглядит пугающе, но идея простая и надо просто спуститься на уровень «сырых байтов», где взятие адреса точно даёт настоящий адрес, и подняться обратно.
Платить приходится за сомнительную возможность перегружать operator&, которую многие считают ошибкой дизайна языка (перегрузка взятия адреса почти всегда приносит больше вреда, чем пользы, ломая обобщённый код и интуицию). То есть это лечение симптома плохой практики, да и реализация хрупкая и многословная, и писать её руками не нужно.
Механизм родился в дискуссиях вокруг Boost, и её канонической реализации boost::addressof, позже вошедшей в стандарт как std::addressof (C++11). Именно её используют стандартные контейнеры и аллокаторы внутри, когда им нужен настоящий адрес элемента, чтобы корректно работать даже с пользовательскими типами, перегрузившими operator&. C++17 добавил constexpr-версию, а необходимость в идиоме целиком переложена на библиотеку.
// Класс с перегруженным operator& (например, прокси) обманывает наивный &obj:
struct Sneaky {
int* operator&() { return nullptr; } // &obj вернёт nullptr, а не адрес!
};
Sneaky s;
Sneaky* wrong = &s; // nullptr — сюрприз
Sneaky* right = std::addressof(s); // настоящий адрес, в обход operator&
// Обобщённый код (контейнеры, аллокаторы) внутри всегда использует std::addressof,
// чтобы не быть обманутым перегруженным operator&
В играхstd::addressof напрямую пишут редко, но он незаметно работает внутри любого обобщённого кода, манипулирующего адресами объектов, например в кастомных контейнерах и аллокаторах, системах сериализации или пулах объектов с placement new. Везде, где код должен взять настоящий адрес произвольного пользовательского типа, чтобы разместить там объект или сохранить указатель, std::addressof будет страховкой от типов, которые зачем-то перегрузили operator&.
Address Of рабоает как метод-санитар, существующий, чтобы чинить последствия сомнительной возможности языка, и лучший способ с ней взаимодействовать будет не создавать ей работы.
nullptr
Это типизированный нулевой указатель, появившийся в C++11 и решивший застарелую болезнь представления «никуда не указывающего» указателя. До него для этого использовали либо литерал 0, либо макрос NULL (который в C++ обычно был просто 0 или 0L). Но проблема в том, что 0 это в первую очередь целое число, и его «нулевоуказательность» лишь особый случай, из-за чего возникала путаница между «число ноль» и «нулевой указатель», особенно болезненная при разрешении перегрузок.
Классический пример беды, когда есть две перегрузки, f(int) и f(char*)и вызов f(NULL) намеревался выбрать указательную версию, но NULL это 0, то есть int, поэтому выбиралась f(int) иногда без ошибки и с неверным поведением. nullptr имеет специальный тип std::nullptr_t, который конвертируется в любой указатель, но не в int, поэтому f(nullptr) однозначно выбирает указательную перегрузку. Заодно nullptr не ломает вывод шаблонных типов так, как 0, и читается яснее, прямо говоря «это нулевой указатель», а не «это, возможно, ноль, а возможно, указатель».
Редкий случай, когда за механизм не приходится платить, и единственная «опасность» в том, что старый код всё ещё пестрит NULL и 0 в роли указателей, и при модернизации их стоит заменять. До C++11 идиому пытались эмулировать самописными классами-«nullptr» (тот самый return type resolver с шаблонным operator T*), но это были полумеры. Сам nullptr это, по сути, стандартизованный и доведённый до ума такой класс.
Идиому-предшественницу (самописный nullptr) описывали Скотт Майерс и Херб Саттер ещё до C++11, а в язык nullptr вошёл по предложению Херба Саттера и Бьёрна Страуструпа именно для устранения проблем NULL/0 с перегрузками и шаблонами.
void spawn(int count);
void spawn(Entity* parent);
spawn(0); // вызывает spawn(int), а если хотели parent?
spawn(NULL); // тоже spawn(int)! NULL это 0 и это int, баг.
spawn(nullptr); // однозначно spawn(Entity*) то, что нужно
Entity* e = nullptr; // ясно читается
if (e == nullptr) { /* ... */ }
Особой «игровой специфики» тут нет и это просто общая гигиена современного C++, но в больших кодовых базах движков с тысячами указательных операций последовательное использование nullptr заметно снижает класс ошибок и улучшает читаемость намерений. Практический совет прост и в новом коде только nullptr, а при работе со старым надо заменять NULL/0-указатели на nullptr при первой возможности. Это одна из тех мелочей, которые ничего не стоят, но делают код чуть-чуть безопаснее в каждом из множества мест, где он трогает указатели.
Move Constructor
Move Constructor (и парный ему move-оператор присваивания) стал краеугольным камнем move-семантики C++11, позволяющим «переместить» ресурсы из одного объекта в другой вместо их копирования. Когда исходный объект, вроде временного (rvalue) или явно помечен как «больше не нужен» (через std::move), его внутренности (указатель на буфер, дескриптор, владение) можно не копировать, а просто украсть, т.е. перекинуть указатели в новый объект, а старый оставить в пустом, но валидном состоянии, что превращает потенциально дорогую глубокую копию в дешёвый обмен нескольких указателей.
До C++11 этого механизма не было, и возврат тяжёлого объекта из функции или вставка его в контейнер означали реальную копию (дорого), либо ухищрения вроде злополучного auto_ptr с его «копированием-перемещением». Move-семантика дала языку понятие «исходник, у которого можно отобрать содержимое», т.е. rvalue-ссылки (T&&) отличают временные/отдаваемые объекты от обычных, и move-конструктор принимает именно такую ссылку, забирая ресурсы и обнуляя источник, аstd::move это просто приведение к rvalue-ссылке, говорящее «считай этот объект отдаваемым».
Платить приходится наличием «пустого, но валидного» состояния источника после перемещения (из него больше нельзя читать осмысленные данные, но деструктор и присваивание обязаны работать), и обязательная пометка move-операций как noexcept, без которой контейнеры (как обсуждалось в non-throwing swap) откатываются на копирование. Плюс правила автогенерации move-операций все еще тонкие, и объявление деструктора или копирующих операций подавляет автоматическую генерацию move, и тогда «перемещение» превращается в копирование, которое легко не заметить.
Move-семантику в C++11 спроектировали Говард Хиннант, Бьёрн Страуструп и Дэйв Абрахамс и это было одно из самых значительных и сложных изменений языка, переосмыслившее, как C++ работает со значениями и ресурсами. Она задним числом сделала «правильным» то, чего годами добивались идиомами вроде resource return и computational constructor, и стала фундаментом, на котором стоит вся современная стандартная библиотека.
class Mesh {
std::size_t count_ = 0;
Vertex* verts_ = nullptr;
public:
// Move-конструктор: крадём буфер
// источник обнуляем. noexcept обязателен
Mesh(Mesh&& o) noexcept : count_(o.count_), verts_(o.verts_) {
o.count_ = 0; o.verts_ = nullptr;
// источник пустой, но валидный
}
Mesh& operator=(Mesh&& o) noexcept {
delete[] verts_;
count_ = o.count_; verts_ = o.verts_;
o.count_ = 0; o.verts_ = nullptr;
return *this;
}
~Mesh() { delete[] verts_; }
};
Mesh load();
// возврат тяжёлого меша теперь дёшев (move, не copy)
std::vector<Mesh> meshes;
meshes.push_back(load());
// меш перемещается в вектор без копии буфера вершин
В геймдеве move-семантику восриняли очень хорошо, потому что игровой код постоянно перемещает тяжёлые объекты, вроде меша с буферами вершин, текстур, звуковых сэмплов и контейнеров с данными кадра. Но в большинстве движков move возврат такого объекта из загрузчика или его вставка в контейнер уже были сделаны по своему и многие не стали отказываться от этих механизмов, просто добавив move-семантику как обертку над ними.
Implicit conversions
Свод знаний и предостережений о неявных преобразованиях типов, которые C++ выполняет автоматически. Язык щедро конвертирует одно в другое без спроса: int в double, производный класс в базовый, типы через конструкторы с одним аргументом, типы через операторы преобразования. Иногда это удобно и читаемо, а иногда источник багов, когда преобразование срабатывает там, где вы его не ждали, и компилируется код, который не должен был.
Главные инструменты управления это ключевые слова explicit и (с C++11) explicit на операторах преобразования. Конструктор с одним аргументом по умолчанию задаёт неявное преобразование: void f(Widget) можно нечаянно вызвать как f(42), если у Widget есть конструктор Widget(int), и компилятор молча построит временный Widget из числа. Пометив конструктор explicit, вы запрещаете это неявное преобразование, оставляя только явное Widget(42). То же с операторами преобразования: explicit operator bool() работает в условии, но не лезет в арифметику (как мы видели в safe bool).
Платить приходится балансом и читаемостью. Слишком много неявных преобразований делает код хрупким и полным сюрпризов (особенно опасны цепочки преобразований и их взаимодействие с перегрузками), а слишком мало заставляет писать утомительные явные приведения там, где преобразование очевидно и безопасно.
Общее правило, тут делать конструкторы с одним аргументом explicit, а неявность разрешать сознательно и только там, где она действительно улучшает читаемость и безопасна (например, преобразование Seconds в Duration).
Это правила были систематизированы у Майерса (несколько пунктов Effective C++ про explicit и про опасности неявных преобразований) и они эволюционируют от стандарта к стандарту, отражая, что сообщество всё больше склоняется к «явное лучше неявного» как к значению по умолчанию.
class Health {
int hp_;
public:
explicit Health(int hp) : hp_(hp) {}
// explicit: нет неявного int -> Health
};
void apply(Health h);
// apply(100); // ошибка: не путаем число и здоровье
apply(Health{100}); // явно и понятно
// А вот тут неявность УМЕСТНА и улучшает читаемость
class Seconds {
float s_;
public:
Seconds(float s) : s_(s) {}
// намеренно неявный: 2.0f -> Seconds естественно
};
void wait(Seconds);
wait(2.0f);
// читается как "подожди 2 секунды"
В играх осторожность с неявными преобразованиями исторически "болит", потому что игровой код кишит типами-обёртками над примитивами (хендлы, идентификаторы, типизированные величины, флаги), и неявные преобразования между ними и сырыми int/float часто прямой путь к UB и «передал ID сущности туда, где ждали ID компонента» или «смешал время и количество», поэтому пометка конструкторов-обёрток explicit превращает такие места в ошибки компиляции.
Практическая линия в движках обычно такая, что типизированные хендлы, ID и единицы измерения делают со explicit-конструкторами, чтобы система типов ловила путаницу, а немногие действительно естественные преобразования (например, скаляр в вектор-из-одинаковых-компонент, или литерал в безопасную обёртку) оставляют неявными сознательно.
Это часть более широкой темы «сделать неверный код некомпилируемым» и чем больше бессмысленных смешений типов компилятор отвергает, тем меньше багов доживает до рантайма. Поэтому управление неявными преобразованиями через explicit давно повседневный инструмент проектирования безопасных API движка, а не абстракция, и привычка ставить explicit по умолчанию экономит немало времени на отлове перепутанных аргументов.
Recursive Type Composition (сложно)
Это система построения типов, которые рекурсивно содержат сами себя (точнее, инстанциации того же шаблона) на этапе компиляции, образуя древовидные или списочные структуры типов. Классический пример будет список типов (typelist), где «список» представлен как пара «голова + хвост», а хвост будет снова список, и так до пустого хвоста-терминатора. Или выражение, где узел содержит поддеревья того же типа узлов и структура данных существует не в рантайме, а в системе типов, разворачиваясь рекурсивно во время компиляции.
Зачем строить структуры из типов? Потому что метапрограммирование оперирует типами, и чтобы обрабатывать наборы или деревья типов (генерировать по ним код, иерархии, хранилища), нужно их как-то структурировать. Рекурсивная композиция даёт способ представить произвольной длины список или дерево типов и рекурсивно его обходить метафункциями: обработать голову, рекурсивно обработать хвост, остановиться на терминаторе. Это, по сути, функциональное программирование на типах, со списками и рекурсией вместо циклов.
Проблема обычно в глубине рекурсии и времени компиляция. Каждый уровень рекурсии будет новая инстанциация шаблона, и для длинных списков/глубоких деревьев компилятор материализует множество промежуточных типов, что серьезно замедляет сборку и может упереться в лимит глубины инстанциации.
С приходом variadic templates (C++11) ручная рекурсивная композиция «голова+хвост» во многом устарела и пакеты параметров (Ts...) теперь представляют наборы типов напрямую, а fold-выражения (C++17) обрабатывают их без явной рекурсии.
Все это наследие Александреску и его Typelist, построенным ровно как рекурсивная композиция, и Boost.MPL, где она была фундаментом метапрограммирования нулевых, когда вариативных шаблонов ещё не существовало и наборы типов приходилось кодировать вложенными парами. Современный эквивалент этоstd::tuple и вариативные пакеты, которые делают то же самое, но заметно легче.
// Рекурсивная композиция
// typelist как голова + хвост (до C++11)
struct Nil {};
template <class Head, class Tail> struct TypeList {};
using Components =
TypeList<Transform, TypeList<Physics, TypeList<Render, Nil>>>;
// Метафункция длины и рекурсивный обход:
template <class L> struct Length;
template <> struct Length<Nil> { static constexpr int value = 0; };
template <class H, class T> struct Length<TypeList<H, T>> {
static constexpr int value = 1 + Length<T>::value;
};
// C++11+, то же самое вариативным пакетом, без ручной рекурсии
template <class... Ts> struct Components2 {
static constexpr int count = sizeof...(Ts);
};
В играх рекурсивную композицию типов в стиле Алекстандреску сегодня редко пишут руками, но её современные формы в виде вариативных шаблонов и кортежей, пронизывают обобщённую инфраструктуру в ECS-движках, которые описывают наборы компонентов системы вариативными пакетами (System<Transform, Velocity>) и разворачивают по ним доступ к хранилищам.
Так что recursive type composition надо понимать как идею, которая никуда не делась, лишь сменив форму. Принцип «представить набор/дерево типов как данные для компайл-тайм-обработки» жив и централен для обобщённого кода движков, просто инструментарий стал гуманнее, а вариативные шаблоны и fold-выражения вместо рекурсивных пар стали заменой спискам типов.
Temporary Base Class
Старая и почти полностью устаревшая идиома оптимизации, связанная с expression templates и борьбой с временными объектами в выражениях. Её цель была в том, чтобы промежуточные результаты сложных выражений (над матрицами, векторами) представлять специальными временными типами-обёртками, наследующими общий базовый класс, что позволяло алгоритмам и операторам единообразно работать с этими временными и избегать ненужных копий и аллокаций при вычислении составных выражений.
Идея перекликается с expression templates, но вместо немедленного создания тяжёлого временного объекта для каждой подоперации, выражение представляется лёгкими временными узлами, которые откладывают вычисление. «Временный базовый класс» давал этим узлам общий интерфейс, через который конечная операция (присваивание) могла их вычислить за один проход. Это была одна из ранних попыток решить проблему временных в численном C++ до того, как expression templates и move-семантика оформились окончательно.
Механизм реализации сложен и хрупок и сегодня почти полностью вытеснен Move-семантикой, которая убрала бóльшую часть боли от временных объектов (временный теперь дёшево перемещается, а не копируется), а copy elision устранила многие временные объекты вовсе. Поэтому temporary base class представляет в основном как исторический интерес и ступень эволюции на пути к современным решениям.
Идея родилась в девяностых, она из того же круга идей, что Blitz++ и ранние работы по высокопроизводительной линейной алгебре, где каждый временный объект старались убрать, а инструментов языка для борьбы с ними было ещё мало.
// Концептуально: временные узлы выражения с общим базовым интерфейсом,
// чтобы присваивание вычислило всё за один проход
// без тяжёлых промежуточных матриц
struct MatExprBase { /* общий интерфейс вычисления элемента */ };
template <class L, class R>
struct MatSumExpr : MatExprBase {
// лёгкий временный узел вместо полной матрицы
const L& l; const R& r;
float at(int i, int j) const { return l.at(i,j) + r.at(i,j); }
};
// Сегодня это делают expression templates + move-семантика,
// а не отдельный temporary base
Boost mutant
Откровенно «хакерская» идиома, позволяющая получить доступ к одному и тому же набору данных через разные структуры, интерпретируя одну и ту же память то как один тип, то как другой. Название отсылает к её происхождению из недр Boostа. Классический пример будет пара (first, second), к которой хочется обращаться и как к (first, second), и как к (second, first) (перевёрнутой), не дублируя данные, а накладывая на одну и ту же память две разные структуры-«вида».
Реализуется это, как правило, через union структур с идентичной раскладкой или через reinterpret_cast между типами, про которые программист уверен, что они имеют одинаковое расположение полей в памяти. Т.е. иметь несколько «фасадов» над одними байтами, выбирая удобный для конкретной операции, без физического дублирования или копирования данных. Это позволяет, например, переиспользовать алгоритмы, написанные под одну раскладку, для данных в другой, «переименовав» поля через мутант.
Проблема в самой идее и балансировании на грани (а нередко и за гранью) неопределённого поведения, когда доступ к объекту через указатель/ссылку несовместимого типа нарушает алиасинг (strict aliasing), а интерпретация одной структуры как другой через union имеет тонкие правила «активного члена», и всё это компилятор вправе оптимизировать способами, ломающими ваши ожидания. Идиома работает «на этом компиляторе при этих флагах», но переносимость и корректность под вопросом. Это типичный «умный хак», от которого современный код стараются держать подальше.
Как я уже сказал, идея пришла из Boost (отсюда имя) скорее как любопытный экспонат, чем как рекомендация и отражает дух раннего Boost, где ради эффективности и выразительности шли на приёмы, которые сегодня сочли бы слишком рискованными, и где границы неопределённого поведения исследовались довольно смело.
// Концептуально: два "вида" на одни и те же байты (рискованно, на грани UB)
struct Pair { int first; int second; };
struct ReversedPair { int second; int first; };
// та же раскладка, поля наоборот
union Mutant {
Pair pair;
ReversedPair reversed;
};
Mutant m;
m.pair = {1, 2};
// доступ через m.reversed трактует те же байты как (second=1, first=2)
// переносимость и корректность под большим вопросом
Но несмотря на хаки, идея в играх прижилась, хотя большинство движков его сторонятся, потому что это приводит к багам, которые проявляются только в релизе с агрессивной оптимизацией. В основном это делают "легально" при работе с сырыми данными, вроде парсинга бинарных форматов, сетевых пакетов, GPU-буферов, когда делают побитовое преобразование между типами одинакового размера без нарушения алиасинга, или копирование через memcpy, которое компилятор отлично оптимизирует.
Так что boost mutant стоит знать как пример того, как не надо, и как повод вспомнить про std::bit_cast более легальную и переносимую замену для случаев, когда действительно нужно посмотреть на одни и те же байты под разными типами. Подобные хаки с union и reinterpret_cast всеже наследие эпохи, когда безопасных инструментов для этого не было.
Multi-statement Macro
Это идея оформления макросов, которые разворачиваются в несколько инструкций. Проблема в том, что препроцессор C/C++ тупо подставляет текст, не понимая синтаксиса, и макрос из нескольких операторов ломается в самых обычных контекстах. Если макрос это do_a(); do_b();, то его использование в if (cond) MACRO; без фигурных скобок развернётся так, что только первый оператор попадёт под if, а второй выполнится всегда, классический баг.
Каноническое лечение будет обернуть тело макроса в do { ... } while(0). Эта конструкция группирует несколько операторов в один синтаксический блок, который при этом ведёт себя как одна инструкция и корректно требует точку с запятой после себя (MACRO;), естественно встраиваясь в if/else и циклы. while(0) гарантирует ровно одну итерацию, а компилятор тривиально его выкидывает, так что накладных расходов нет. Это идиоматический, узнаваемый способ сказать что «этот макрос есть единый составной оператор».
Расплата? Макросы вообще зло, и эта идиома лишь делает одно из их проявлений менее опасным, не устраняя коренных проблем препроцессора в виде отсутствия области видимости, многократного вычисления аргументов или невидимости для отладчика. Так чтоdo/while(0) решает скорее узкую проблему «несколько операторов как один», но не делает макрос хорошей идеей сам по себе и современный C++ (inline-функции, шаблоны, constexpr, лямбды) позволяет заменить большинство макросов нормальным кодом, что почти всегда правильнее.
Идея восходит к ранним дням языка и кодовой базе ядра Unix/Linux, где do { } while(0) в макросах встречается повсеместно, но в C++ она перешла по наследству и это, пожалуй, самый узнаваемое сишное «рукопожатие», если увидел do/while(0) вокруг тела макроса, то понимаешь что автор пришел из сишного мира.
// Плохо: ломается в if без скобок
#define LOG_AND_COUNT(msg) log(msg); ++log_count_
// if (verbose) LOG_AND_COUNT("hi");
// ++log_count_ выполнится ВСЕГДА — баг!
// Хорошо: do/while(0) делает макрос единым оператором
#define LOG_AND_COUNT(msg) do { log(msg); ++log_count_; } while(0)
if (verbose) LOG_AND_COUNT("hi"); // теперь корректно: оба под if
else do_nothing(); // и else работает
В геймдеве многострочные макросы всё ещё встречаются часто, несмотря на общее «макросы это зло», потому что у них есть ниши, где альтернатив мало, вроде отладочных и логирующих макросов (которым нужен __FILE__/__LINE__ и условной компиляции), макросы регистрации (компонентов, тестов, рефлексии), платформенные обёртки, ассерты. Во всех этих случаях, если макрос содержит несколько операторов, do/while(0) обязателен, иначе он подложит мину в чей-то if. Это одна из тех мелочей, незнание которой приводит к багам, которые невозможно объяснить, глядя на место использования (там всё выглядит правильно, а баг прячется в раскрытии макроса), а узнаваемый do/while(0) признак того, что автор макроса прошёл школу ночной отладки.
Named Loop
Идея получить помеченные циклы и управление вложенными циклами, которого в C++ нет в том виде, как, например, в Java (где можно написать break outer; и выйти сразу из внешнего цикла). В C++ break и continue действуют только на ближайший окружающий цикл, и когда вложенных циклов несколько, выйти разом из всех или перейти к следующей итерации внешнего — нетривиально. Идиома предлагает способы это выразить.
Исторически и практически тут было несколько подходов и самый прямой и "честный" было использовать goto на метку после внешнего цикла: да, тот самый goto, который обычно считают дурным тоном, но для выхода из глубоко вложенных циклов это, как ни странно, один из самых чистых вариантов, и даже Дейкстра не возражал против такого ограниченного применения. Альтернативами стали флаг-булевы переменные, проверяемая в условиях всех циклов (многословно и засоряет логику), или вынос вложенных циклов в отдельную функцию, из которой return выходит сразу из всего (часто самый чистый вариант, заодно улучшающий структуру кода).
Каждый подход имеет свою цену иgoto пугает неподготовленных читателей и в больших количествах действительно вреден, хотя для выхода-вперёд из циклов безопасен. Флаги размазывают логику выхода по нескольким местам и легко содержат ошибки, а вынос в функцию иногда искусственно дробит связный код. C++ так и не обзавёлся помеченными break/continue (предложения были, но не прошли), так что идиома остаётся набором компромиссов, и выбор между ними только вопрос вкуса и контекста.
Дискуссия вокруг практики применения стала частью вечного спора об уместности goto, восходящего к знаменитому письму Дейкстры «Go To Statement Considered Harmful» ажно 1968 года, и о том, что «вредный в общем случае» не означает «вредный всегда».
// Поиск в 2D-сетке с выходом сразу из обоих циклов
bool found = false;
for (int y = 0; y < height && !found; ++y) // вариант с флагом
for (int x = 0; x < width; ++x)
if (grid[y][x] == target) {
found = true;
break;
}
// Вариант с goto — для выхода-вперёд это чисто и читаемо:
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x)
if (grid[y][x] == target)
goto done;
done:;
// Часто лучший вариант просто вынести в функцию и return
std::optional<Cell> find_in_grid(...) {
... return Cell{x,y};
... return std::nullopt;
}
В играх вложенные циклы часто повседневность и тот же обход 2D/3D-сеток (тайлмапы, воксели, клетки навигации), перебор пар объектов для коллизий, поиск в матрицах будут всегда, поэтому необходимость «выйти сразу из всего при первом совпадении» возникает регулярно.
На практике движки чаще всего предпочитают самый чистый из вариантов вроде выноса вложенных циклов в отдельную функцию с return, потому что это и решает проблему выхода, и обычно улучшает читаемость и тестируемость кода, давая осмысленное имя операции поиска.
Но и goto-к-метке вполне жив в коде движков, где вынос в функцию нежелателен (например, чтобы не мешать инлайнингу или не таскать много контекста через параметры), и где goto done; для выхода-вперёд из горячего вложенного цикла будет самым прямым и быстрым способом без накладных расходов. Так что named loop это в основном про осознанный выбор между «вынести в функцию» (чисто, по умолчанию) и «честный goto вперёд» (когда важна каждая мелочь в хотпасе), и про понимание, что табу на goto есть общее правило с законными исключениями, а не религиозная догма.
Named Parameter
Механизм, дающий C++ что-то похожее на именованные аргументы функций, которых в языке нет (в отличие от, скажем, Python с его func(width=100, height=50)). Когда у функции или конструктора много параметров, особенно однотипных и с значениями по умолчанию, позиционный вызов превращается в нечитаемую вереницу вроде create(800, 600, true, false, true, 4, false), где невозможно понять, что есть что, и легко перепутать порядок.
Самая распространённая реализация это method chaining через объект-builder, где вы создаёте промежуточный объект параметров, у которого методы-сеттеры возвращают ссылку на себя, что позволяет сцеплять их в цепочку .width(800).height(600).fullscreen(true), а затем передаёте этот объект в саму функцию. Каждый «параметр» называется явно своим методом, порядок становится неважен, а указываются только нужные (остальные берут значения по умолчанию). Это та же идея, что named template parameters, но для рантайм-аргументов функций.
Платить надо многословностью реализации, когда нужен целый класс-builder с методами на каждый параметр и небольшими накладными расходами на создание промежуточного объекта (обычно устраняемые оптимизатором). Плюс это не настоящие именованные аргументы и компилятор не заставит указать обязательные, так что в C++20 дали частичную альтернативу для агрегатов в виде designated initializers (Config{.width=800, .height=600}), которые позволяют инициализировать поля структуры по имени, что для конфигов-структур закрывает большую часть потребностей.
Параметр-builder этр давняя практика, и на уровне библиотек её довёл до совершенства Boost.Parameter, где это один из самых частых ответов на вопрос «почему в C++ нельзя вызвать функцию с именованными аргументами». Нельзя напрямую, но можно сэмулировать.
// Builder с method chaining
// имена параметров возвращаются в вызов
struct WindowDesc {
int width_ = 1280, height_ = 720;
bool fullscreen_ = false, vsync_ = true;
WindowDesc& width(int w) { width_ = w; return *this; }
WindowDesc& height(int h) { height_ = h; return *this; }
WindowDesc& fullscreen(bool f){ fullscreen_ = f; return *this; }
WindowDesc& vsync(bool v) { vsync_ = v; return *this; }
};
Window create_window(const WindowDesc&);
// Вместо create_window(800, 600, true, false)
// читаемо и без путаницы порядка
auto win = create_window(WindowDesc{}.width(800).height(600).fullscreen(true));
// C++20 для агрегатов проще
auto win = create_window({.width_=800, .height_=600, .fullscreen_=true});
В играх named parameter в виде builder'ов и конфиг-структур почти классический приём, потому что движки полны API с множеством параметров, от создание окна до настройки пайплайна рендеринга и дескрипторы текстур. У всех них десятки настроек, большинство с разумными значениями по умолчанию, и позиционный вызов был бы нечитаемым кошмаром, где true, false, true ничего не говорит читателю.
А графические API нового поколения (Vulkan, D3D12, Metal) целиком построены на дескрипторах-структурах, заполняемых по полям, это и есть named parameter в форме конфиг-структуры, и движки поверх них продолжают ту же традицию. Современный геймдев всё чаще использует C++20 designated initializers для таких дескрипторов, потому что они дают именованную инициализацию без написания builder-класса, что и короче, и эффективнее.
Так что named parameter в играх - это в основном про конфиг-структуры и дескрипторы, и про выбор между классическим builder'ом (работает везде, можно валидировать) и designated initializers (проще, C++20). В обоих случаях цель одна, чтобы вызов с десятком параметров можно было прочитать и не перепутать.
Named External Argument
Близкая к предыдущей, но более узкая идея сделать «именованными» отдельные аргументы за счёт типов-обёрток, несущих смысл аргумента в своём имени. Вместо того чтобы передавать голый bool или int, чьё назначение в точке вызова неясно, вы заворачиваете его в маленький именованный тип, и тогда вызов сам себя документирует: не set_visible(true) с неочевидным true, а нечто, где намерение явно выражено типом аргумента.
Теперь такой «ярлык» придает аргументу смысл, и это может быть тег-обёртка (Visible{true} вместо true), сильный тип-алиас (отдельный тип Width поверх int), или именованная константа-флаг. Особенно ценно это для булевых аргументов, печально известных своей нечитаемостью: цепочка true, false, true в вызове будет загадкой, а Visible::Yes, Cached::No, Async::Yes читается без обращения к документации и заодно сильные типы предотвращают перестановку аргументов одинакового базового типа.
Платить нужно типами-обёртками, и если их много, появляется некоторый шум из мелких типов. Плюс это не полноценные именованные параметры (порядок всё ещё важен), а скорее «самодокументирующиеся аргументы», но в сочетании с explicit и сильной типизацией все это даёт ощутимый прирост и читаемости, и безопасности, пресекая класс ошибок «перепутал, какой из трёх bool за что отвечает».
// Голые bool — нечитаемо и легко перепутать:
// player.respawn(true, false, true); // что есть что???
// Named external arguments — тип несёт смысл аргумента:
enum class Invulnerable : bool { No, Yes };
enum class KeepInventory : bool { No, Yes };
enum class AtCheckpoint : bool { No, Yes };
void respawn(Invulnerable, KeepInventory, AtCheckpoint);
// Вызов документирует сам себя и защищён от перестановки:
player.respawn(Invulnerable::Yes, KeepInventory::No, AtCheckpoint::Yes);
В геймдеве эта часто является основой движка, потому что игровые API изобилуют булевыми флагами и однотипными числовыми параметрами, и нечитаемые вызовы вроде spawn(pos, true, false, true, 3) явлеяется частым источником багов при перестановке аргументов. Поэтому заворачивание флагов в именованные enum class (которые, как мы видели в type safe enum, ещё и типобезопасны) превращает такие вызовы в самодокументирующиеся и устойчивые к ошибкам.
Сильные типы для величин (отдельные типы Health, Damage, EntityId, Seconds вместо голых int/float) та же идея, защищающая от смешения семантически разных, но одинаковых по представлению значений, и экономит немало времени на отладке «перепутал ID с количеством» и «передал миллисекунды туда, где ждали секунды». Так что named external argument хорошая практика «не передавай голые примитивы там, где тип может нести смысл», и она прекрасно сочетается с enum class, explicit и сильными типами в общую культуру «сделать неверный вызов некомпилируемым или хотя бы очевидно неправильным при чтении». Это дёшево, ничего не стоит в рантайме и заметно повышает и читаемость, и надёжность кода.
Deprecate and Delete
Идея в том, что нельзя просто взять и убрать функцию, которой пользуются, потому что это сломает чужой код, а правильный путь будет сначала пометить её как устаревшую (deprecated), чтобы компилятор выдавал предупреждение при использовании, давая пользователям время мигрировать, а потом, в следующей крупной версии, удалить или явно запретить.
Оба механизма работают на разных стадиях. [[deprecated("используйте X")]] (атрибут C++14, и ранее компиляторные __declspec_(deprecated)/__attribute__((deprecated))) помечает функцию как нежелательную, но код с ней всё ещё компилируется, а компилятор предупреждает, часто с вашим сообщением, куда мигрировать. А= delete (C++11) идёт дальше и явно запрещает функцию, а попытка её вызвать даёт ошибку компиляции. delete применяют и для окончательного «выпиливания» устаревшего, и (это его основное применение) для запрета нежелательных операций, вроде копирования, опасных перегрузок, неявных преобразований.
Расплата это в основном дисциплина процесса, а не техническое решение и нужно выдержать цикл «пометил → подождал → удалил», не торопясь с удалением и не забывая про deprecated-предупреждения навечно. = delete мощнее, чем кажется и им можно запретить конкретную перегрузку (например, = delete для f(double), чтобы запретить вызов целочисленной функции с дробным аргументом и не дать молча сконвертировать), что тонко управляет разрешением перегрузок.
Оба механизма пришли в C++11 (атрибут [[deprecated]] — в C++14) из давних компиляторных расширений. А= delete был спроектирован в том числе для замены идиомы «приватный необъявленный метод» (как в boost::noncopyable) и раньше копирование запрещали приватным необъявленным конструктором, а теперь ясным = delete с понятной ошибкой. Это часть общего движения языка к выражению намерений явными средствами языка вместо идиоматических обходов.
class Texture {
public:
Texture(const Texture&) = delete;
// запрет копирования — ясной ошибкой
Texture& operator=(const Texture&) = delete;
[[deprecated("используйте load_async вместо load")]]
void load(const char* path);
// ещё работает, но предупреждает
void load_async(const char* path);
};
void bad(double);
void bad(int) = delete; // запретить вызов с int: bad(5) и ошибка компиляци
Управляемое устаревание критично для движков, у которых есть пользователи, другие команды, моддеры, лицензиаты, и ломать чужой код при каждом обновлении API недопустимо. [[deprecated]] даёт цивилизованный путь миграции, когда старая функция помечается, документируется замена, пользователи получают предупреждения и время на переход, и только потом, в мажорной версии, функция удаляется. Unreal и другие большие движки систематически используют deprecation-макросы именно для этого.
= delete же в играх давно повседневный инструмент проектирования безопасных типов и запрет копирования ресурсов и не-копируемых объектов (ясной ошибкой вместо линковочной), запрет опасных неявных преобразований и нежелательных перегрузок, явное «эта операция бессмысленна для данного типа».
Это всё та же сквозная тема «сделать неверный код некомпилируемым» и = delete превращает целый класс потенциальных ошибок использования в ошибки компиляции с понятным сообщением. Так что deprecate-and-delete в играз это и про вежливую эволюцию публичного API движка (deprecated), и про жёсткое пресечение неверного использования типов (delete), и оба механизма давно стали стандартной часть инструментария проектировщика движка.
Function Poisoning
Это идиома намеренного «отравления» определённых функций, чтобы их использование стало ошибкой компиляции или хотя бы предупреждением, но в отличие от deprecate-and-delete, нацеленного на эволюцию вашего собственного API, poisoning обычно направлен на запрет опасных, небезопасных или запрещённых в данном проекте функций. Чаще всего чужих, стандартных или системных, которые вы по какой-то причине не хотите видеть в своей кодовой базе.
Способов несколько и самый прямой на уровне инструментов будет#pragma GCC poison имя, который заставляет компилятор отвергать любое появление указанного идентификатора (буквально «отравляет» имя). Второй это на уровне кода объявить запрещённую функцию с = delete или с [[deprecated]], либо перекрыть её собственной версией, которая не компилируется или выдаёт static_assert. Цель та же, пресечь использование функций вроде небезопасных strcpy/sprintf/gets, неугодных malloc/free (если в проекте всё должно идти через свой аллокатор), printf (если запрещён прямой вывод), или любых других, которые в данном проекте под запретом.
Расплата за отравление чужих, особенно стандартных будет, что это хрупко и непереносимо и тот же #pragma poison есть не у всех компиляторов и может конфликтовать с самими заголовками, где «отравленное» имя легитимно используется (отравишь malloc и стандартные заголовки, использующие его внутри, перестанут компилироваться). Поэтому poisoning требует аккуратности в том, где именно он включён, и часто ограничивается специальным заголовком, подключаемым только в «своём» коде, но не там, где включаются системные хедеры.
Идиома описана в работах по расширениям GCC/Clang, и появившееся как инструмент для запрета небезопасных функций в кодовых базах с жёсткими требованиями безопасности. Это часть более широкой практики enforced coding standards как принудительного соблюдения стандартов кодирования средствами компилятора, а не только ревью.
// forbidden.h подключается в своём коде (но не там, где системные заголовки)
#pragma GCC poison strcpy strcat sprintf gets // любое использование — ошибка
// В проекте, где вся память идёт через свой аллокатор,
// можно запретить голый new:
void* operator new(std::size_t) = delete;
// заставляет использовать движковые аллокаторы
// Запрет конкретной небезопасной перегрузки через = delete:
char* strcpy(char*, const char*) = delete;
// "не используйте, есть safe_copy"
В играх function poisoning применяют обычно на консолях, а это, по сути, все серьёзные движки и особенно сертификация и запрещают небезопасные C-функции работы со строками (источник переполнений буфера), прямые системные аллокации в обход движкового аллокатора (чтобы вся память была учтена и шла через нужные пулы), запрещённые на консолях системные вызовы, и медленные или непереносимые функции.
Это часть инфраструктуры «принудительной чистоты» кодовой базы, когда вместо того чтобы полагаться на то, что каждый разработчик помнит все запреты и что ревьюер их выловит, движок отравляет запрещённые функции, и компилятор ловит нарушения автоматически, ещё до ревью.
Для больших команд это становится стандартом кодирования, который соблюдается сам собой, надёжнее любого документа, который все «прочитали». Так что function poisoning это про автоматическое принуждение к правилам проекта средствами компилятора, и хотя приём технически хрупковат (особенно отравление стандартных имён), в дисциплинированной кодовой базе с правильно изолированным заголовком запретов он экономит много сил и предотвращает целые классы проблем, которые на консоли могли бы стоить провала сертификации.
Inner Class
Использование вложенных (внутренних) классов нужно для решения задач инкапсуляции, реализации интерфейсов и организации тесно связанных типов. В C++ вложенный класс будет полноценным классом в области видимости внешнего, имеющий (с некоторыми оговорками) доступ к его приватным членам через объект и логически принадлежащий ему.
Применений несколько и первое как раз скрытие деталей реализации, когда внутренний класс, объявленный в приватной секции, не виден снаружи и служит вспомогательной структурой (узел списка, итератор контейнера, состояние конечного автомата), не засоряя внешнее пространство имён. Второе сделан для реализации интерфейса «изнутри», когда внешний класс не наследует интерфейс сам (чтобы не раздувать свой публичный контракт и не плодить конфликты), а заводит внутренний класс, который реализует интерфейс и имеет доступ к внутренностям внешнего, это способ «реализовать несколько интерфейсов» без множественного наследования и его проблем. И третье - это группировка тесно связанных типов под именем-владельцем (Graph::Node, Graph::Edge).
Платить приходится сложностью объявления (особенно при шаблонах: typename Outer<T>::Inner с его typename-церемониями), и важно помнить, что вложенность - это про область видимости и доступ, а не про время жизни. Объект внутреннего класса не привязан автоматически к объекту внешнего и может жить отдельно, а чрезмерная вложенность ухудшает читаемость. Плюс есть исторические тонкости с тем, какой именно доступ внутренний класс имеет к приватным членам внешнего (правила уточнялись от C++98 к C++11).
Вложенные классы это давняя часть языка, а их использование (особенно для итераторов контейнеров и для реализации интерфейсов изнутри) стандартная практика, видная в любой реализации STL, где iterator реализован через вложенный тип контейнера.
template <class T>
class LinkedList {
struct Node {
// вложенный, скрытый: деталь реализации
T value;
Node* next;
};
Node* head_ = nullptr;
public:
class iterator {
// вложенный, публичный: часть интерфейса контейнера
Node* cur_;
public:
T& operator*() { return cur_->value; }
iterator& operator++() { cur_ = cur_->next; return *this; }
bool operator!=(const iterator& o) const { return cur_ != o.cur_; }
};
iterator begin() { return {head_}; }
};
В играх вложенные классы также повседневный инструмент организации кода и кастомные контейнеры движка заводят вложенные iterator/const_iterator (как требует идиома generic container) и скрытые Node-структуры, а конечные автоматы прячут состояния как вложенные классы. Системы заводят вложенные типы дескрипторов и хендлов под именем системы (Renderer::CommandBuffer, Physics::Contact) и все это держит тесно связанные типы вместе, в области видимости их владельца, и убирает их из глобального пространства имён, где они только мешали бы.
Rule of Zero/Three/Five
Правило трёх, пяти и нуля - это свод рекомендаций, какие специальные функции класса нужно определять вместе, чтобы класс корректно управлял своими ресурсами. Правило трёх (эпоха C++98) гласит, что если вам понадобилось определить хотя бы одну из тройки «деструктор, конструктор копирования, оператор копирующего присваивания», то почти наверняка нужны все три, потому что их наличие сигнализирует о ручном управлении ресурсом, и компиляторные версии остальных двух сделают неправильно (поверхностное копирование указателя ведёт к двойному освобождению).
Правило пяти - это расширение тройки на эпоху C++11 и к деструктору и копирующим операциям добавились move-конструктор и move-оператор присваивания, теперь «всё или ничего» распространяется на всю пятёрку. Если класс управляет ресурсом вручную и вы определяете деструктор с копированием, стоит определить и перемещение, иначе оно либо не сгенерируется (и перемещение тихо станет копированием, теряя производительность), либо сгенерируется некорректно.
А правило нуля переворачивает всё предыдущее и лучший способ соблюсти правила трёх и пяти будет не писать ни одной из этих функций вообще. Тогда каждый член класса сам корректно управляет своим временем жизни (это std::vector, std::string, std::unique_ptr, а не сырые указатели и дескрипторы), и компиляторные версии всех пяти функций автоматически окажутся правильными.
Правило трёх достаточно давнее правило, которое Марк Клайн и другие фиксировали ещё в девяностых, а правило пяти оформилось с move-семантикой C++11, а правило нуля сформулировал и популяризировал Р. Мартиньо Фернандес в 2012 году, и его подхватили Core Guidelines как предпочтительный подход.
// Правило пяти: класс с сырым ресурсом обязан определить все пять
class Buffer {
float* data_; std::size_t size_;
public:
~Buffer() { delete[] data_; } // 1
Buffer(const Buffer&); // 2
Buffer& operator=(const Buffer&); // 3
Buffer(Buffer&&) noexcept; // 4
Buffer& operator=(Buffer&&) noexcept; // 5
};
// Правило нуля: ничего не пишем и члены сами управляют собой, и это правильно
class Mesh {
std::vector<float> vertices_;
// сам копируется, перемещается, чистится
std::string name_;
// ни деструктора, ни copy/move — компилятор сделает всё корректно
};
Most Vexing Parse
Это не приём, а ловушка синтаксиса C++, знать о которой обязательно, чтобы не потерять час на загадочной ошибке. Суть в правиле грамматики, если что-то можно разобрать и как объявление функции, и как определение объекта с инициализацией, стандарт предписывает разобрать это как объявление функции. То есть когда вы пишете то, что вам кажется созданием объекта, компилятор иногда видит объявление функции и происходит это в самых невинных на вид местах.
Каноничный пример: вы хотите создать объект Widget w(Gadget());, передав в конструктор временный Gadget. Но компилятор разбирает эту строку как объявление функции w, которая возвращает Widget и принимает... указатель на функцию без аргументов, возвращающую Gadget. Никакого объекта не создаётся и объявлена функция, а дальнейшее использование w как объекта даёт каскад непостижимых ошибок, в которых ни слова про то, что w на самом деле функция. Ещё коварнее пустые скобки: Widget w(); это уже не «создать Widget конструктором по умолчанию», а объявление функции w, возвращающей Widget.
Лечится это устранением неоднозначности в пользу «это точно объект» и добавить лишние скобки вокруг аргумента (Widget w((Gadget()));), которые делают разбор как функции невозможным. Современный и куда более чистый способ, и надо использовать фигурные скобки списковой инициализации (Widget w{Gadget{}}; или Widget w{}; для дефолтного), которые в принципе не могут быть разобраны как объявление функции и потому начисто убивают most vexing parse.
Имя «most vexing parse» придумал Скотт Майерс в Effective STL и оно мгновенно прижилось, потому что точно передаёт ощущение, что это самый раздражающий из всех способов, которыми грамматика C++ может вас обмануть.
struct Gadget {};
struct Widget { Widget(Gadget); Widget(); void use(); };
// Ловушка: это ОБЪЯВЛЕНИЯ ФУНКЦИЙ, а не создание объектов!
Widget a(); // функция a(), возвращающая Widget не объект!
Widget b(Gadget()); // функция b(указатель на функцию) не объект!
// a.use(); // ошибка: a это функция, у неё нет .use()
// Решения:
Widget c; // объект (без скобок) — конструктор по умолчанию
Widget d{}; // объект (фигурные скобки) — однозначно
Widget e{Gadget{}}; // объект + фигурные скобки убивают неоднозначность
Pass-key
Это элегантный способ дать доступ к конкретному методу класса строго определённым другим классам, не делая их полноценными друзьями и не открывая им всё приватное. Это решает ту же проблему «дружба в C++ слишком груба», что и Attorney-Client, но другим, во многих случаях более чистым приёмом. Метод остаётся публичным, но требует в аргументах «ключ» и объект крошечного типа, сконструировать который может только разрешённый класс.
Заводится пустой класс-ключ с приватным конструктором, и он объявляет своими друзьями ровно те классы, которым позволено им пользоваться. Целевой публичный метод принимает этот ключ параметром, а поскольку создать ключ может только друг ключа, то и вызвать метод способен лишь тот, кто умеет сконструировать ключ, то есть только разрешённые классы.
Все остальные видят публичный метод, но не могут создать ключ для его вызова и получают ошибку компиляции. Дружба тем самым становится тесной и "золотой ключик" открывает доступ к одному конкретному методу конкретным классам, а не ко всему классу сразу.
Преимущество перед обычным friend и перед Attorney-Client в точечности и отсутствие транзитивности, если обычный друг видит вообще всё приватное, то pass-key открывает ровно один метод и не нужен отдельный класс-посредник с переадресацией, достаточно крошечного типа-ключа.
Идиома получила имя и популярность в C++-сообществе начала 2010-х (её описывали в обсуждениях на Stack Overflow и в блогах под именами «passkey» и «pass-key»), как более лёгкая альтернатива attorney-client для частого случая «дать доступ к конструктору или одному методу только фабрике или менеджеру».
class Entity;
class EntityKey {
// ключ: создать может только EntityManager
EntityKey() = default;
friend class EntityManager;
};
class Entity {
public:
// метод публичный, но требует ключ
// значит звать может только друг ключа
void set_id(int id, EntityKey) { id_ = id; }
private:
int id_ = 0;
};
class EntityManager {
public:
void assign(Entity& e, int id) { e.set_id(id, EntityKey{}); }
// умеет создать ключ
};
// любой другой код: e.set_id(5, EntityKey{});
// ошибка, конструктор ключа недоступен
В играх pass-key хорошо ложится на распространённый паттерн «только менеджер/фабрика имеет право на эту операцию». Только EntityManager должен присваивать ID сущностям; только ResourceManager может менять внутреннее состояние ресурса, или только система загрузки может вызывать «сырой» инициализатор объекта. Pass-key выражает это на уровне типов, не делая менеджер всеобъемлющим другом каждого класса и не распахивая всю инкапсуляцию ради одной доверенной операции.
Это обходит классическую проблему «приватный конструктор не дружит с make_unique». Так что pass-key будет практичным инструментом для менеджеров и фабрик, где надо аккуратно ограничить, кто имеет право на чувствительные операции, и он часто оказывается чище и легче, чем и голый friend, и полноценный Attorney-Client.
Defaulted Comparisons и оператор <=> (Spaceship)
Defaulted comparisons - это возможность C++20 попросить компилятор сгенерировать операторы сравнения за вас, и идиома «не пиши сравнения руками, если они тривиальны» была исторической болью и чтобы сделать тип сравнимым, нужно было определить до шести операторов (==, !=, <, >, <=, >=), причём руками, согласованно и без ошибок, и так для каждого сравнимого типа.
Это были горы шаблонного бойлерплейта, в котором легко ошибиться (написать < несогласованно с ==) и каждый раздувал класс своей реализацией. Оператор <=> (формально «трёхстороннее сравнение», в народе «spaceship» за внешний вид) и defaulted == решают это тем, что вы пишете auto operator<=>(const T&) const = default; и компилятор генерирует все операции порядка (<, >, <=, >=), сравнивая члены лексикографически в порядке объявления.
Отдельно bool operator==(const T&) const = default; даёт == и !=, так что одна-две строки вместо шести рукописных операторов, и они гарантированно согласованы между собой. <=> к тому же возвращает не bool, а специальный тип «категории сравнения» (strong_ordering, weak_ordering, partial_ordering), точно выражающий, какого рода это упорядочивание. Оператор <=> спроектировал Херб Саттер (вместе с Джен Маурер) и ввёл в C++20 именно чтобы покончить с бойлерплейтом сравнений, одной из самых занудных рутин в C++.
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default;
// даёт <, >, <=, >= разом
bool operator==(const Version&) const = default;
// даёт == и !=
};
Version a{1, 2, 0}, b{1, 3, 0};
bool older = a < b;
// работает: лексикографически по major, minor, patch
bool same = a == b;
// тоже работает
// std::sort, std::set, std::map<Version,...>
// теперь работают без рукописных операторов
Что со всем этим делать
Похоже у меня есть материал для продолжения Game++ Ж) А если серьезно, то проступают несколько сквозных линий, которые важнее любой отдельной идиомы, идеи или механизма.
Добрая половина этих идиом - это костыли. Костыли, которые программисты вытачивали вручную, потому что языку не хватало возможности, а работу надо было делать сегодня. Safe bool ждал explicit operator bool. Resource return и computational constructor ждали move-семантику. Int-to-type и enable-if ждали if constexpr и концепты. Type generator ждал alias-шаблоны. Nullptr-обёртки ждали nullptr. И когда язык наконец догонял, идиома схлопывалась в одну строчку или растворялась в синтаксисе, оставляя после себя только археологический слой в старом коде. Читать эти идиомы стоит в том же двойном смысле, что и архитектуры памяти консолей из одной из моих статей, как исторический артефакт, объясняющий, почему древний код выглядит именно так, и как живой приём там, где старый стандарт всё ещё в строю.
Вторая линия будет про вечный размен между гибкостью и скоростью, и игрострой в нём занимает очень определённую сторону. Виртуальные функции, type erasure, polymorphic value types, acyclic visitor: всё это покупает рантайм-гибкость ценой индирекции, аллокаций и кеш-промахов, и всё это движки сознательно выпихивают из себя в инструменты и в редакторы, не пуская в ядро движка. А там правят CRTP, traits, tag dispatching, policy-based design, concrete data types и небольшая армия приемов, которые переносят решения из рантайма в компайл-тайм, чтобы процессор не делал ничего лишнего.
И последнее правило «сделай неверный код некомпилируемым». Non-copyable, = delete, explicit, type safe enum, named external argument, сильные типы, function poisoning, checked delete: все они про то, чтобы ошибку поймал компилятор, а не рантайм, QA или сертификация. В большой команде, где код пишут десятки людей разного опыта, это, возможно, самая ценная категория идиом и каждая запрещённая на уровне типов глупость будет неродившимся в проде багом.
Все это способы сказать компилятору про наши намерения побольше, чтобы он сделал за нас побольше, а в рантайме осталось поменьше и какие-то из них язык уже сделал ненужными, а какие-то переживут нас всех.
З.Ы. @Boomburum Ты мне выдашь ачивку за самую длинную статью на хабре за 20 лет? А то я тут чуть-чуть не дожал.
З.З.Ы. Кажется я сломал редактор Хабра, и теперь он не хочет сохранять такой длинный текст.
З.З.З.Ы. А нет, спустя десять минут сохранил... Фух...
З.З.З.З.Ы. Хабрторт! Спасибо всем, кто присылает исправления.
Если понравилось - заходите в https://t.me/game_cpp_book, пишу редко
← Все статьи