Вебинар: Каждая идиома когда-то была проблемой - 11.09
Почему он кажется таким сложным? И почему настолько популярен при всех своих недостатках?

Есть на YouTube видео на пятьдесят минут с хвостиком с гордым названием "Худший язык программирования всех времён". Не удивлюсь, если вы подумаете, что оно про C++. Оно действительно про плюсы, и я его смотрел где-то с полгода назад. Ну как смотрел... пробежался на x2 с перемотками, мало ли что обиженный джун там наговорил. Но добрый @alyokhin опять про него напомнил, и теперь я его посмотрел полностью. И знаете, что самое неприятное? Если убрать интонацию обиженного джуна и оставить только аргументы, то процентов семьдесят там будет правдой. Не "спорно", не "зависит от контекста", а буквально правда, которую любой разработчик, проведший с языком пару лет, подтвердит вам не задумываясь.
Парадокс в том, что это видео сняли про язык, на котором написана половина мира вокруг нас: браузер, в котором вы это читаете; движок игры — куда уж без игр в моих статьях, — в которую вы вчера играли; прошивка железа, на котором всё это крутится; и компилятор, которым собрали и браузер, и движок, и прошивку.
Жанр "почему C++ ужасен" на Хабре выжжен дотла, и про Init-зоопарк; перегруженный static; vector, названный неправильно; std::move, который не move; супер медленный regex; медленный unordered_map вы всё читали раз по двадцать. Сам по себе список этих болей давно не новость. От себя добавлю, что все жалобы и примеры ниже — это следствия одного решения, и я к нему приду. Или открывайте спойлеры, там скрыта история, почему каждая часть языка получалась так, как получалась.
Начнём с самых адов (не ошибка) — с начала любой программы, т. е. с создания переменной. И если в других языках это обычно одна операция, то в C++ про это пишут целые книги и проводят исследования, вытаскивая потом это всё на конференции, как будто других проблем мало. Помогайте. Я ничего не забыл?
int f; // automatic storage, мусор
static int f_static; // но в namespace/static scope уже ноль
// (zero-init на старте)
std::string s; // вызов default-конструктора
int* p = new int; // куча, мусор
int e{}; // 0
int e2 = {}; // 0
int* p = new int{}; // 0
int* q = new int(); // тоже 0
auto t = T{}; // временный, value-init
int b(5);
std::string s("hello");
T obj(a1, a2);
auto p = new T(a1, a2);
int x = static_cast<int>(3.5); // каст, это тоже direct-init
int y(int(5)); // functional notation
int a = 5;
std::string s = "hello";
T obj = other;
f(arg); // передача по значению,
// инициализируется ПАРАМЕТР функции
// невидимая переменная
f({1, 2, 3}); // то же
return x; // возврат по значению
return {1, 2, 3}; // тоже, если не сработает copy-elision
throw x; // инициализируется объект исключения
int c{5}; // direct-list
int d = {5}; // copy-list
std::vector<int> v{1, 2, 3};
std::vector<int> v2 = {1, 2, 3};
std::vector<int> a(10); // 10 элементов, все нули
std::vector<int> b{10}; // ОДИН элемент со значением 10
struct Point { int x, y; };
Point p{1, 2}; // можно так
Point p2 = {1, 2}; // а можно эдак
int arr[3] = {1, 2, 3}; // и с нулями, и без них
int arr2[3] = {}; // все нули
Point p3{.x = 1, .y = 2}; // designated initializers, C++20
Point p4{1}; // x=1, y=0 (хвост value-init)
int& r = x;
const int& cr = 5; // привязка к временному + продление жизни
int&& rr = 10;
constexpr int n = 42; // обязана быть constant-init
const int m = foo(); // может быть как constant, так и dynamic
constinit int g = bar(); // C++20: гарантирует static-init, но НЕ делает const
static int s = compute(); // dynamic-init, со своим Static Init Order Fiasco
struct S {
int x = 5; // default member initializer
int y{10};
S() : y{2}, x(1) {} // member initializer list
// (порядок по объявлению, не по списку)
};
auto [a, b] = std::pair{1, 2}; // structured bindings, C++17
for (int v : arr) { } // range-based for тоже инициализирует v
Если посчитать "семантику поведения" инициализации, то их выходит штук десять (= v, (v), {v}, = {v}, {}, = {}, () в new, и т.д.), а семантических категорий будет девять, и отображение между ними не один к одному, и одна запись {} живёт сразу в value-init, list-init и aggregate-init в зависимости от типа справа. По теме инициализации написана отдельная книга на триста страниц. И это не шутка. Мне её рекомендовал как-то один знакомый: она реально существует и реально на триста страниц. Как говорится, наслаждайтесь.
И каждый метод делает чуть-чуть разное. Особенно мне нравится разница между e и f, где наличие или отсутствие пары фигурных скобок определяет, лежит у вас в переменной ноль или то, что осталось в этом куске стека от прошлого вызова функции.
struct S {
int x = 5; // default member initializer
int y{10};
};
NSDMI (Non-Static Data Member Initializer) — инициализатор нестатического члена данных прямо в объявлении класса. В точке int x = 5; объекта ещё не существует и ничего не инициализируется. Это default member initializer, т.е. просто заготовка, которая будет использована при конструировании, ЕСЛИ конструктор не проинициализировал член сам в mem-init-list. То есть это "инициализатор про запас", а не инициализация. Формально это brace-or-equal-initializer члена.
auto [a, b] = std::pair{1, 2};
А здесь инициализируется скрытый безымянный объект (hidden object или decomposition object, назовём его e), а a и b будут не отдельными переменными — это имена-привязки к частям безымянного объекта. Инициализация применяется к e, а не к a / b. Тонкость, но раз уж придираемся. Повторюсь, это не совсем настоящие переменные, а поля структуры, и сам e напрямую недоступен, но физически хранение примерно такое:
e
+---------+
| first | <--- a
| second | <--- b
+---------+
auto e = std::pair{1, 2};
auto& a = e.first;
auto& b = e.second;
int k, e;
int main() {
auto [a, b] = std::pair{k, e};
return a + b;
}
----------------------------------------
leaq -12(%rbp), %rdi
leaq k(%rip), %rsi
leaq e(%rip), %rdx
callq std::pair<int, int>::pair<int&, int&, true>(int&, int&)
Вот тут создаётся скрытый объект structured binding. rdi — это первый аргумент конструктора, т. е. адрес места, куда конструируется pair.
main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl $0, -4(%rbp)
leaq -12(%rbp), %rdi <<<<<
leaq k(%rip), %rsi <<<<<
leaq e(%rip), %rdx <<<<<
callq std::pair<int, int>::pair<int&, int&, true>(int&, int&) <<<<<
leaq -12(%rbp), %rdi
callq tuple_element<0ul, std::pair<int, int>>::type &&
std::get<0ul, int, int>(std::pair<int, int>&&)
movq %rax, -24(%rbp)
leaq -12(%rbp), %rdi
callq tuple_element<1ul, std::pair<int, int>>::type &&
std::get<1ul, int, int>(std::pair<int, int>&&)
movq %rax, -32(%rbp)
movq -24(%rbp), %rax
movl (%rax), %eax
movq -32(%rbp), %rcx
addl (%rcx), %eax
addq $32, %rsp
popq %rbp
retq
k:
.long 0
e:
.long 0
И как только вы решили, что разобрались, выясняется, что фигурные скобки в одном контексте — это агрегатная инициализация, а в другом — std::initializer_list, и одна и та же строчка кода может вернуть разный тип в зависимости от версии стандарта, под которую вы собираетесь. Не разный результат, а разный тип. Добро пожаловать.
auto i = 1; // int всегда
auto j = {1}; // initializer_list<int> всегда (copy-list-init)
auto k{1}; // C++11: initializer_list<int>; C++17+: int
Зоопарк инициализаций вырос именно из попытки этот зоопарк закрыть, и в 2011 году комитет искренне попытался свести всё к одному синтаксису, а в итоге добавил ещё один вольер с типами.
От C достались для обычного присваивания при объявлении (int a = 5;) и фигурные скобки для агрегатов вроде массивов и структур (int arr[] = {1,2,3};). Скобки в C умели разложить значения по полям POD-агрегата, и ни на что больше не претендовали.
Потом C++ принёс конструкторы, а раз есть конструктор, то его надо как-то вызвать в точке создания объекта. И самым естественным синтаксисом оказались круглые скобки, потому что вызов конструктора визуально похож на вызов функции, поэтому Widget w(args);. Логично? Логично.
Ровно до того дня, когда вы пишете Widget w(); и обнаруживаете, что объявили функцию, возвращающую Widget. Это знаменитый most vexing parse, и это не баг чьей-то реализации, а прямое следствие грамматики, унаследованной от C, где "объявление выглядит как использование", но объявление функции синтаксически неотличимо от создания объекта с пустыми скобками.
Дальше подъехал C++ 11 и большая идея под названием uniform initialization. Замысел был хороший, с попыткой ввести единый синтаксис {}, который работает везде: и для агрегатов, и для классов с конструкторами, и для скаляров, и для контейнеров.
Заодно он бы чинил most vexing parse, потому что Widget w{}; нельзя распарсить как функцию, и ещё запрещал сужающие преобразования вроде int x{3.5};, что было бы ошибкой, а int x = 3.5; отрезало бы дробную часть.
То есть {} задумывался не как "ещё один способ", а как тот самый, единственно правильный и расово верный, к которому все должны перейти.
Тут включается то самое, к чему всё в этом языке сводится и о чём я написал в конце. Чтобы {} стал единственным, надо было выкинуть = и (), а на них держатся миллиарды строк кода, движки, либы, чужие API.
Получается, что выкинуть нельзя, поэтому новый универсальный синтаксис не заменил старый, а поселился в свой вольер, и способов стало больше. Инструмент, придуманный, чтобы прекратить зоопарк, стал самым заметным его обитателем.
А чтобы было совсем весело, те же фигурные скобки нагрузили вторым смыслом через std::initializer_list. Теперь, если у типа есть конструктор от списка инициализации, скобки начинают означать его, причём в разрешении перегрузки он выигрывает.
Отдельная история это различие между int e{}; (ноль) и int f; (мусор). Это осталось от дедушки C, где автоматические переменные не обнулялись. Не потому, что забыли, а потому, что обнуление стоит тактов, а философия C была "ты не платишь за то, чем не пользуешься", и, если хочешь ноль, напиши ноль сам.
Поэтому int f; оставляет на стеке то, что осталось от прошлого вызова, и это by design — фича вроде из 1972 года. И Страуструп позже захотел дать безопасный нулевой дефолт, но навязать его не мог, потому что это сломало бы и совместимость, и саму идею "не платить за лишнее". Так и вышло, что безопасный вариант есть, но он не по умолчанию, потому что само правило по умолчанию зафиксировали полвека назад ради скорости.
Собрать всё обратно в один синтаксис теперь не получится, и на каждом способе висит свой код и свои правила работы с ним. Поэтому, когда по теме инициализации выходит очередная книга на триста страниц и блок-схемы на пол-экрана, то это не авторы развлекаются — это цена того, что в язык насыпали за сорок лет из-за его самой главной фичи.
Хрестоматийный пример сложной простоты — это случайное число, и где-нибудь в Java или Python вы просто напишете random.randint(1, 100) и пойдёте дальше кодить, но не здесь. Это слишком просто, чтобы быть правдой.
std::random_device rd;
std::mt19937 gen(rd()); // а что такое mt19937?
std::uniform_int_distribution<int> dist(1, 100); // а почему отдельно?
int value = dist(gen); // ну наконец-то
Код не то чтобы нечитаемый, но приходится через раз его смотреть на cppreference, когда он реально нужен. Потом я иду гуглить, что такое этот mt19937. Это вихрь Мерсенна, живите теперь с этим знанием, и да, вам теперь придётся знать название генератора псевдослучайных чисел, чтобы просто кинуть кубик.
До C++ 11 в языке жил rand(), унаследованный от C. Он отдавал число в диапазоне до RAND_MAX, который стандарт гарантирует от 32767, то есть на некоторых платформах вы физически не можете получить равномерное число в большом диапазоне.
Привычное rand() % 100 даёт смещение, потому что 100 не делит нацело число возможных значений, так что одни числа выпадают чаще других, а последовательность становится невоспроизводимой между компиляторами. Т. е. это была простая функция, чтобы выстрелить себе в ногу.
Поэтому в C++ 11 затащили принципиально новый дизайн из Boost.Random, который намеренно разнёс то, что rand() свалил в кучу: теперь отдельно сам движок генерации, или источник сырых битов (mt19937, тот самый вихрь Мерсенна Мацумото и Нисимуры из 1997); отдельно идёт распределение, которое превращает сырые биты в равномерное-в-диапазоне без всякого смещения; и отдельно идёт источник сида.
Это спроектировали люди, которым нужны были воспроизводимые прогоны Монте-Карло для научных симуляций, и для их задач это работало идеально. Беда в том, что построили красивый собор, а маленькую дверь для "брось кубик от 1 до 100" так и не приделали, и в стандарте до сих пор нет однострочного randint.
Но даже "правильное" заклинание тоже неправильное, потому что mt19937 хранит почти двадцать тысяч бит состояния, а мы засеиваем его одним 32-битным числом из random_device, то есть недосеваем. Это раз.
А сам random_device стандарт разрешает делать детерминированным, и на старом MinGW он годами выдавал одну и ту же последовательность при каждом запуске. То есть длинная корректная мантра в реальности и длинная и корректная, но не везде работает, хоть rand() % 100 и короче, и хотя бы работает.
Кастинг — это вообще отдельная песня. Там, где в Java вы пишете значение в скобочках и всё, в C++ этих кастов целый набор на любой вкус и цвет: static_cast, dynamic_cast, reinterpret_cast, const_cast, bit_cast, и каждый для своего случая, и каждый надо набирать руками целиком. Ещё есть скрытый rvalue_cast, но о нём чуть ниже.
double d = 3.9;
int i = static_cast<int>(d); // 3, дробная часть отброшена
Base* b = new Derived;
auto* der = static_cast<Derived*>(b); // даун-каст БЕЗ проверки
// ты ручаешься, что там Derived
Base* b = get_something();
if (Derived* d = dynamic_cast<Derived*>(b)) {
d->derived_only(); // сюда зашли, только если это реально Derived
} // иначе d == nullptr
int x = 42;
std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(&x); // адрес как число
void legacy_api(char* s); // не трогает строку, но забыл const
const std::string str = "hello";
legacy_api(const_cast<char*>(str.c_str())); // ок, раз api реально не меняет
float f = 1.0f;
auto bits = std::bit_cast<std::uint32_t>(f); // 0x3F800000, без UB
// как делали до C++20:
std::uint32_t old;
std::memcpy(&old, &f, sizeof f); // то же самое, но руками
int i = (int)d; // компилируется, выглядит знакомо
Новички так от этого устают, что заводят себе короткий алиас, и это считается плохой практикой, потому что теперь ваш код написан на вашем личном диалекте C++, который никто кроме вас не знает.
// антипаттерн: "мне надоело писать static_cast"
template <class T, class U>
constexpr T sc(U&& u) { return static_cast<T>(std::forward<U>(u)); }
int i = sc<int>(3.9); // коротко? да.
// правильно? нет.
// или ещё хуже:
#define CAST(T, x) static_cast<T>(x)
Тут вообще засада, и часто правильный C++ выглядит неправильно, потому что короткое и красивое решение почти всегда оказывается глючным и некорректным, а корректное выглядит как будто вы зачем-то усложнили жизнь свой команде. Интуиция "как должен выглядеть хороший код" нарабатывается годами, и пока она не наработана, вы живёте с ощущением, что делаете что-то не так. Спойлер: вы и правда делаете что-то не так, просто язык такой.
В C есть один каст (с-style cast) (T)expr, и он делает всё: меняет значение, переинтерпретирует указатель, снимает const, режет тип — и всё это выглядит абсолютно одинаково. А значит, вы не можете найти в кодовой базе места, где кто-то снимает константность, потому что они неотличимы от любого другого приведения.
C++ разбил этот швейцарский нож на четыре именованные операции из-за их неразличимости: static_cast, dynamic_cast, const_cast и reinterpret_cast.
Всё для того, чтобы намерение было явным и const_cast, который "я тут подрываю const", ловился глазками на ревью.
Чтобы это грепалось и можно было найти все reinterpret_cast и проверить каждый.
Касты сами по себе зло, поэтому их сделали уродливыми специально. Как сказал Александреску, "каст — это запах кода", и Страуструп делал синтаксис громоздким, чтобы приведение было больно набирать, а когда вы всё же приводите один тип к другому явно, это бросается в глаза на ревью. Старый C-каст был слишком лёгким и невесомым, и именно эта лёгкость делала его опасным.
Так и выходит, что правильное выглядит неправильным буквально по проекту, а не по случайности. Короткое и красивое решение почти всегда то самое сломанное наследие, которое нельзя удалить ради самой главной фичи языка, а корректное специально сделали многословным и неудобным, чтобы вы споткнулись и задумались, набирая каст руками.
В нормальном мире ключевое слово описывает то, что оно делает, но в C++ это стало опциональной нагрузкой. Вспомните static.
А вспомнили, сколько у него значений? Сделать переменную, которая живёт между вызовами функции, — это раз. Сделать переменную или метод общими для всех экземпляров класса — это два. Третье значение, когда static перед функцией в .cpp-файле делает её невидимой снаружи этого файла, то есть это private, но назвали static. Почему не internal, не private, не file_local? А потому что исторически так сложилось, что является ответом примерно на половину вопросов в этой статье.
void counter() {
static int calls = 0; // инициализируется ОДИН раз при первом заходе
int local = 0; // обычная локальная, каждый вызов заново
++calls;
++local;
std::cout << "static: " << calls << ", local: " << local << '\n';
}
И есть поведение static, которое пришло после С++11. Теперь ваш static означает ещё и light mutex guarded. Знали про это?
Logger& logger() {
static Logger instance; // одна строчка
return instance;
}
Logger& logger() {
// БЫСТРЫЙ путь: младший байт guard != 0 → уже проинициализировано
if ((reinterpret_cast<volatile char&>(__guard_for_instance)) == 0) {
// МЕДЛЕННЫЙ путь: сюда заходим только в первый раз и под синхронизацией
if (__cxa_guard_acquire(&__guard_for_instance)) {
// acquire вернул 1 → именно этот поток обязан инициализировать.
// Остальные потоки сейчас СПЯТ внутри __cxa_guard_acquire.
try {
::new (&__instance_storage) Logger(); // вызов конструктора
__cxa_guard_release(&__guard_for_instance); // ставим флаг,
// будим спящих
__cxa_atexit(&destroy_logger, ...); // регистрируем уничтожение
} catch (...) {
__cxa_guard_abort(&__guard_for_instance);
throw;
}
}
// проигравшие гонку потоки вышли из acquire уже после release
}
return reinterpret_cast<Logger&>(__instance_storage);
}
В C у static уже было два значения. Первое — обычное, и оно про время жизни, когда переменная живёт всю программу.
Второе было про внутреннее связывание, когда имя не было видно за пределами единицы трансляции. И пусть семантически это вещи из разных вселенных, и одна про память, а другая про видимость, но K&R их сделали одним ключевым словом, потому что ключевых слов в языке должно быть мало, а слово static было близко под "статически размещённое".
C++ унаследовал эти значения целиком, не глядя, потому что отказаться от куска C означало отказаться от совместимости с C, а как раз этого хотели избежать. Дальше Страуструпу понадобились члены класса, общие для всех экземпляров, и можно было бы ввести что-то вроде shared или classwide, по аналогии с private и protected что угодно описательное. Но каждое новое ключевое слово в языке — это мина для разработчиков.
В мире уже обязательно кто-то назвал переменную shared, internal или classwide, и в день, когда это слово станет ключевым, их код перестанет компилироваться. Поэтому, прежде чем выдумывать новое слово, сначала старались переиспользовать существующее, а static уже жил в языке и уже был мутным, поэтому на него можно было навесить ещё мутности без риска сломать кому-то прод. Так появилось третье значение. Не потому, что оно подходит по смыслу, а потому что слово было под рукой и ничего не ломало.
Четвёртое поведение подъехало в C++ 11, когда инициализацию функционально-локальной static захотели сделать потокобезопасной, и компилятор начал вставлять скрытый guard при первом проходе.
Язык один раз попытался это разгрести, и в C++ 98 файловый static для внутреннего связывания объявили нежелательным и сказали пользоваться безымянными неймспейсами. Но в C++ 11 эту рекомендацию отменили, потому что отучить людей от привычного слова оказалось сильно дороже, чем поправить язык, т. е. язык не смог выкинуть даже то значение, которое сам же признал лишним.
Теперь ни одно из четырёх снять уже не получается, и на каждом висит чей-то код, какой-то локальный счётчик, приватная функция или же член класса в API, который используют сто тысяч человек.
inline когда-то просил компилятор заинлайнить функцию, но сегодня компилятор умный и инлайнит сам, когда считает нужным, а inline теперь решает проблемы линковки и One Definition Rule. И inline на функции и inline на переменной делают семантически противоположные вещи: у функции — разрешение размножить код, а у переменной — запрет размножить данные.
// math.h
inline int square(int x) { return x * x; }
// a.cpp
#include "math.h"
int use_a() { return square(3); }
// b.cpp
#include "math.h"
int use_b() { return square(4); }
// counter.h
inline int g_calls = 0; // ровно один экземпляр на всю программу
// a.cpp
#include "counter.h"
void hit_a() { ++g_calls; }
// b.cpp
#include "counter.h"
void hit_b() { ++g_calls; }
// config.h
struct Config {
static inline int instances = 0; // один счётчик на все объекты, прямо в .h
};
inline constexpr double kPi = 3.14159265358979; // header-only константа,
// один объект
В раннем C++ это была типобезопасная замена #define-макросу вроде "подставь тело функции прямо в точку вызова вместо call". И это была буквально оптимизация разворачивания. Отсюда имя, отсюда и народное поверье, что inline — это про "сделать быстрее". Забудьте, это уже лет пятнадцать как не про это.
Сегодня его несущий смысл уже не про оптимизацию, а про ODR и линковку. inline-сущность разрешено определять в нескольких единицах трансляции (то есть положить определение в заголовок и включать его всюду), и линкер обязан слить эти определения в одно, а не упасть с multiple definition.
Как inline относится к настоящему инлайнингу? Да почти никак. Как подсказка оптимизатору, он чисто совещательный, и современные компиляторы его часто игнорируют, имея cost-модели, через которые они спокойно встраивают функции без inline и отказываются встраивать помеченные, а с LTO встраивают и через границы TU вне зависимости от слова.
Единственная связь inline с реальным инлайнингом сейчас косвенная, и теперь это ключевое слово позволяет положить тело в заголовок, не словив ошибку линковки, а видимое тело оптимизатор может разместить в отдельном TU, потому что без этого LTO сделать нельзя.
Методы, определённые прямо в теле класса, уже неявно помечены inline, и все constexpr-функции тоже неявно сделаны inline, но народ всё равно дописывает ключевое слово "на всякий случай", хотя оно там ничего не меняет.
Встраивать теперь надо через forceinline (MSVC), attribute__((always_inline)) и [[gnu::always_inline]], да и те часто отказывают на рекурсии, varargs или взятии адреса. Это имя осталось рудиментом эпохи, когда оно и правда так работало, но теперь оно про линкер.
const, который вроде бы про неизменяемость, можно писать с любой стороны от типа, и обе записи означают одно и то же, просто чтобы вы не расслаблялись. А ещё mutable const — это иногда валидный код. А ещё константность можно снять кастом, и это плохая практика, но физически это возможно.
Чтобы понять, к чему относится const в объявлении указателя — к значению или к самому указателю, — есть официальное правило "читай справа налево". Напомню, что большинство людей читают слева направо.
const int a = 5; // "west const"
int const b = 5; // "east const" ровно то же самое, оба константы
const int* p = &a; // указатель на const int
int const* q = &a; // и это то же самое
struct Cache {
mutable int hits = 0; // можно менять даже у const-объекта
int value = 0;
};
const Cache c;
c.hits++; // ОК, mutable
// c.value++; // ошибка, обычный член const-объекта
struct S {
mutable const int* p = nullptr; // ВАЛИДНО
};
struct Bad {
mutable const int x = 5; // НЕВАЛИДНО, mutable нельзя на const-член
};
// Случай 1: объект РЕАЛЬНО не const, ок
int x = 5;
const int& cref = x;
const_cast<int&>(cref) = 10; // законно, x действительно менялся, x == 10
// Случай 2: объект РЕАЛЬНО const, Undefined Behavior
const int y = 5;
const_cast<int&>(y) = 10; // компилируется, но это UB
// y может остаться 5, упасть, или что угодно
int x = 0, y = 0;
const int* p1 = &x; // p1: указатель на const int
int* const p2 = &x; // p2: const-указатель на int
const int* const p3 = &x; // p3: const-указатель на const int
*p1 = 5; // ошибка: значение const
p1 = &y; // ок: указатель можно перенаправить
*p2 = 5; // ок: значение менять можно
p2 = &y; // ошибка: указатель const
*p3 = 5; // ошибка
p3 = &y; // ошибка
Это одно из немногих ключевых слов, которое родилось в C++ и было экспортировано в C, а не наоборот. Страуструп завёл его ещё в "C с классами" (поначалу под рабочим именем readonly), а оттуда оно уехало в стандарт C89 и зажило там самостоятельной жизнью. Но слово, придуманное, чтобы навести порядок, тут же унаследовало беспорядок грамматики, в которую его вставили.
Начнём с того, что const int и int const — это буквально одно и то же, и const — это просто квалификатор типа, и сидит он в той части объявления, которую грамматика называет decl-specifier-seq, т. е. последовательностью спецификаторов.
А эта последовательность была неупорядоченной задолго до появления const ровно по той же причине, по которой unsigned long и long unsigned означают один тип. Теперь уже const наступил на старые грабли, когда порядок слов и так не имел значения. Более того, "правильная" форма" — это как раз int const, потому что const относится к тому, что стоит слева от него, а const int — это грамматическая поблажка, потому что такое разрешали старые компиляторы.
Именно поэтому существует целое движение "east const" (его в своё время раскручивал Джон Калб, а до него про распутывание объявлений много писал Дэн Сакс). Два написания никто не проектировал, они просто выпали из грамматики, потому что так было удобно, и это не ломало старый код.
const int* p; // указатель на const int, меняй указатель, не значение
int* const p; // const-указатель на int, меняй значение, не указатель
Тут есть разница, по какую сторону от звёздочки стоит const, потому что слева от звёздочки он попадает в ту самую последовательность спецификаторов и квалифицирует то, на что указывают.
А справа от звёздочки он уже будет частью декларатора и квалифицирует сам указатель. Объявление специально сделали похожим на выражение, которое потом будет работать с переменной. Для int* p это изящно, для int (*f)(int) — уже издевательство, и const здесь усложняем грамматику, которая трудна сама по себе.
И собрать это в единое целое нельзя, потому что грамматику const намертво фиксирует совместимость с дедушкой C, и поменять то, как он парсится, означало бы сломать и C, и сорок лет кода на обоих языках. Так и выходит, что слово выглядит как замок на двери, а на деле это табличка "просьба не входить", повёрнутая буквами к двери.
Сколько в C++ целочисленных типов? Около пятидесяти, если считать вместе с фиксированными псевдонимами. И не у всех из них размер фиксирован: он зависит от компилятора и платформы.
bool // да, bool — тоже целочисленный (integral) тип
char // ОТДЕЛЬНЫЙ тип
signed char // ОТДЕЛЬНЫЙ тип
unsigned char // ОТДЕЛЬНЫЙ тип, это три разных типа, не два
char8_t // C++20
char16_t // C++11
char32_t // C++11
wchar_t
short
unsigned short
int
unsigned int
long
unsigned long
long long // C++11
unsigned long long // C++11
short, short int, signed short, signed short int // 4 написания → 1 тип
int, signed, signed int // 3 написания → 1 тип
long, long int, signed long, signed long int // 4 → 1
long long, long long int, signed long long, signed long long int // 4 → 1
unsigned, unsigned int // 2 → 1
int8_t int16_t int32_t int64_t // точная ширина (опциональны)
uint8_t uint16_t uint32_t uint64_t // 8 штук
int_least8_t ... int_least64_t // минимальная ширина
uint_least8_t ... uint_least64_t // 8 штук (обязательны)
int_fast8_t ... int_fast64_t // "быстрая" ширина
uint_fast8_t ... uint_fast64_t // 8 штук
intmax_t uintmax_t // самый широкий
intptr_t uintptr_t // под указатель (опциональны)
std::size_t // <cstddef>
std::ptrdiff_t // <cstddef>
std::sig_atomic_t // <csignal>
std::wint_t // <cwchar>
std::streamsize, std::streamoff // <ios>
int — это не "32 бита", а "хотя бы 16 бит, но может и 32", и гарантируется только цепочка short <= int <= long <= long long. На 64-битном Linux long — это 64 бита, а на 64-битном Windows long — это всё ещё 32 бита, потому что обратная совместимость (запомните это словосочетание). Чтобы получить предсказуемый 64-битный тип, есть int64_t, и я искренне не понимаю, зачем там суффикс _t. Мы вроде уже не в девяностых, но изменить не получится (об этом под спойлером).
Отдельно прекрасны символьные типы, которых семь штук, и вы рано или поздно упрётесь в вопрос, почему символ вообще бывает знаковым и беззнаковым, чем char отличается от signed char и unsigned char (а это три разных типа, не два), что такое wchar_t и в чём разница между std::string и std::wstring, и почему из-за неё у вас на ровном месте поедет кодировка. Но об этом в другой раз.
Зоопарк типов является палеонтологической летописью всех машин, на которых C когда-либо работал, и чтобы понять, почему int не "32 бита", надо вспомнить, на чём сам C рос.
А рос он в начале 70-х, когда железо было очень разное, и тот же PDP-11 имел 16-битные слова, а Honeywell, на который C портировали одним из первых, пользовался 36-битными словами, и в некоторых режимах использовались 6-битные или 9-битные символы. Исторически byte в C не обязательно равен 8 битам, в стандарте C CHAR_BIT — количество бит в минимальной адресуемой единице памяти, то есть вполне легальная машина с CHAR_BIT == 9.
У CDC слова по 60 бит, что-то было в дополнительном коде, что-то в обратном, и Ритчи принял понятное тогда решение не фиксировать размеры вообще. Т. е. int — это просто "естественное машинное слово, то, с чем процессор работает быстрее всего, но не меньше 16 бит".
Язык гарантировал только минимальные диапазоны и порядок short <= int <= long <= long long, а конкретные размеры отдавал на откуп платформе, и благодаря этому один и тот же исходник эффективно собирался и на 16-битном PDP-11, и на 36-битном Honeywell, и ещё на чёртовой дюжине машин, а нефиксированный тип int был тем самым свойством, которое позволяло C работать на любом железе.
Потом пришли 32-битные машины, и int стал 32, а когда пришли 64-битные, int так и застрял на 32, потому что мир уже был завален кодом, где sizeof(int) == 4 зашито в конфигах, и расширять int означало всё это сломать и заодно навлечь на себя поломку будущих ABI.
Но Microsoft оставила long 32-битным. Почему? Большая база кода, продуктов и пользователей, что коротко называется обратная совместимость.
Тонны кода и сам Win32 API считали long четырёхбайтовым, гоняли его наравне с int и DWORD, сериализовали на диск и в сеть как четыре байта, и сломать это сочли дороже, чем смириться с расколом. То есть long означает разное на двух платформах ровно потому, что в конце 90-х две экосистемы сделали разные ставки на совместимость.
int64_t и его суффикс _t, по сути, признание ошибки, что язык не смог сделать базовые типы предсказуемыми, поэтому десятилетия спустя прикрутил сбоку второй набор — уже с фиксированной шириной и отдельным заголовком <stdint.h>. _t — это и не 90-е вовсе, это 70-е, и память о соглашении Unix про typedef-имена (size_t, time_t, wchar_t), где _t зарезервирован за реализацией, чтобы стандарт мог и дальше добавлять типы, не сталкиваясь с вашими идентификаторами.
Если бы я хотел максимально запутать новичка, я бы назвал вещи именно так, как они названы в STL. Самый используемый контейнер называется vector, и это у нас динамический массив, но вектор в обычном понимании — это величина с направлением, и сам Александр Степанов, автор STL, признавал, что имя было ошибкой.
Если вы хотели хеш-таблицу, то придётся брать std::map, но это сбалансированное дерево с логарифмическим поиском. А настоящая хеш-таблица — это std::unordered_map, которой, спойлер, тоже лучше не пользоваться, потому что медленно, и это не "реализация ленивая", а это зашито в сам стандарт. Гарантии, которые std::unordered_map обязан давать, не оставляют разработчику libstdc++/libc++ выбора, кроме как сделать его медленным.
std::map<std::string, int> ordered;
ordered["banana"] = 1;
ordered["apple"] = 2;
ordered["cherry"] = 3;
for (auto& [k, v] : ordered)
std::cout << k << ' '; // ВСЕГДА: apple banana cherry,
// по возрастанию ключа
std::unordered_map<std::string, int> hashed;
hashed["banana"] = 1;
hashed["apple"] = 2;
hashed["cherry"] = 3;
for (auto& [k, v] : hashed)
std::cout << k << ' '; // порядок произвольный,
// зависит от хеша и бакетов
std::unordered_map<std::string, int> m;
m.insert({"key", 1});
m.insert({"key", 2}); // НЕ перезаписало, а вернуло {итератор, false}
std::cout << m["key"]; // 1, а не 2
m.insert_or_assign("key", 2); // после C++17 вот это перезапишет
std::cout << m["key"]; // 2
m["key"] = 2; // или просто так
Стандарт фактически требует separate chaining (раздельное хранение) с узлами, и unordered_map обязан гарантировать стабильность ссылок и указателей на элементы после insert / erase (кроме удалённого), чтобы указатель на элемент оставался валидным, даже когда контейнер рехешится.
А это возможно только если каждый элемент отдельно выделен на куче (std::pair<const Key, Value> плюс указатель на следующий), а ведро (bucket) сделано как связный список таких элементов. То есть по стандарту это не "хеш-таблица в массиве", а "массив указателей на разбросанные по куче списки". Или другой прикол с оными же:
std::unordered_map<std::string, int> m;
if (m["ключ"] == 0) { } // если ключа НЕ было, то он только что вставился
// с default-значением 0.
// Проверка "есть ли ключ" его и создала.
operator[] на отсутствующем ключе молча вставляет default-значение, поэтому проверять наличие надо через find/contains, а не через []. Мелочь, конечно, но новички наступают на эти грабли регулярно.
А ещё у контейнеров есть empty(). Выглядит, как почистить, но это у нас вопрос: "Пустой ли контейнер?" По-человечески было бы is_empty(), но нет, зато есть remove(), который вообще ничего не удаляет, зато сдвигает элементы в конец и возвращает итератор, а удалять вы будете отдельно (привет, erase-remove идиома). А ещё есть std::stoi, std::stol, std::stoll, std::stof, std::stod, std::stold, и вы должны просто знать, что это. Ведь знаете?
Чтобы понять, почему стандартная библиотека будто бы спроектирована против разработчика, надо вспомнить, что её придумал математик. Александр Степанов десятилетиями вынашивал идею обобщённого программирования про то, что алгоритмы надо писать через абстрактные требования к типам, а не привязывать к конкретным структурам данных.
До плюсов он пробовал сделать это в Scheme, в Ada на пару с Массером, потом пришёл в C++, и шаблоны, придуманные совсем для другого, оказались достаточно мощными, чтобы выразить все его идеи. В 93–94-м все свои наработки он принёс тогда ещё не комитету, а группе разработки языка, и что очень редкий случай, затащил её в C++ 98 почти целиком.
Отсюда vector, потому что в численных вычислениях Scheme "вектор" означал одномерный непрерывный массив, так что в контексте Степанова имя было логичным. Потом спохватились и хотели назвать array , но имя уже примелькалось в стандарте и проектах, поэтому переименовать опять было нельзя. Опять то же самое, что и везде в языке.
И map из той же оперы, но имя честно описывает абстракцию "отображение ключ > значение", просто люди приходят из Java и Python, где "map/dict" по умолчанию хеш, и подсознательно ждут того же. А когда настоящую хеш-таблицу наконец добавили в C++11, очевидное имя hash_map нельзя было брать, потому что его расхватали несовместимые между собой вендорные расширения от SGI, Microsoft, Dinkumware и других компаний. Чтобы не сломать весь этот зоопарк уже написанных своих hash_map, комитет взял свободное, пусть и корявое имя — unordered_map. Так что уродливое название — просто ещё один шрам от выбранной дороги к обратной совместимости.
А чтобы вам было совсем хорошо, вспомните, что есть lvalue, rvalue, glvalue, prvalue и xvalue. Вспомнили?
А ещё есть ситуации, где std::move делает копию, и что иногда нужен не std::move, а std::move_if_noexcept. Раз уж зашла речь про перемещение, то std::move исторически имеет неправильное название, и он ничего не перемещает, а просто кастует значение к rvalue-ссылке, разрешая ему привязаться к перемещающему конструктору. Надо было назвать rvalue_cast, ах блин... тогда у нас кастов прибавится.
В C было два понятия: lvalue и rvalue. В C++ 11 появилась move-семантика, и двух категорий перестало хватать, так как понадобилось различать "именованный объект, который трогать нельзя" и "временный, у которого можно отобрать значение". Но это два независимых свойства, и их комбинации дают пять категорий, и появляется xvalue (eXpiring, "истекающий") — это объект с идентичностью, из которого можно забрать значение.
Чтобы помечать так xvalue-объекты, понадобилась отдельная семантика, назвать которую стоило rvalue_cast, но добавлять ещё один каст комитет не захотел, поэтому Говард Хиннант и компания выбрали имя по намерению, и теперь в точке вызова move надо читать как "я с этим закончил, можешь забирать".
А ещё в стандартной библиотеке есть второй std::move, который как раз честно двигает элементы, так что не перепутайте тот, что двигает, с тем, что не двигает. И мне кто-то даже показывал pdf'ку на 70 страниц, как и когда правильно пользоваться обоими std::move. Жаль, я название не запомнил.
И это я ещё не докапываюсь до названий идиом. Помните RAII из статьи про С++101? Расшифровывается как Resource Acquisition Is Initialization, т. е. захват ресурса есть его инициализация, но описывает оно ровно противоположный момент, и в идиоме главное не захват ресурса, а его автоматическое освобождение в деструкторе при выходе из области видимости. Т. е. это будет CADR (Constructor Acquires, Destructor Releases — "конструктор захватывает, деструктор освобождает"), а не RAII (создаёт ресурс в конструкторе).
void process() {
FileHandle file("data.txt"); // открыли
if (nothing_to_do())
return; // ранний выход — файл закрыт автоматически
might_throw(); // кинуло исключение — файл ВСЁ РАВНО закрыт
} // обычный конец scope — деструктор закрыл файл
Или другой пример — CRTP. Вы вчитывались в название идиомы? CRTP — это Curiously Recurring Template Pattern, "любопытно повторяющийся шаблон", и название описывает не то, что паттерн делает, а то, что человек, который его придумал, несколько раз на него натыкался в проектах и удивлялся этому совпадению, а потом так назвал паттерн.
Слышали, что надо учить современный С++? Но когда вы гуглите, что такое современный С++, попадаете на книги ХХ-надцатилетней давности.
Modern C++ Design: Generic Programming and Design Patterns Applied
Modern C++: Efficient and Scalable Application Development
C++ 11 был первым "современным": он принёс умные указатели, лямбды и move-семантику. Потом появился 14, 17, 20, 23, и каждый объявлял себя самым новым и уж теперь точно "современным". И вот уже самый продаваемый учебник по современному С++ "Effective Modern C++" уже больше не modern, а местами даже не особо effective. А индустрия в это время живёт где-то на C++ 17, потому что переписывать миллионы строк рабочего кода под "модерновее-модернового" никто не желает, ибо страшно.
Итог такой, что вы вынуждены знать все версии стандарта сразу (а они отличаются, уж поверьте), потому что старый код на работе написан на одном диалекте, новый на другом, в учебнике третий, в туториале на YouTube четвёртый, и все они не modern.
Когда вы наконец что-то компилируете, вас встречают сообщения об ошибках. C++ умеет на одну неправильно поставленную закорючку вывалить тысячу строк нечитаемого мусора, в котором настоящая причина похоронена где-то посередине, но мозг её уже не может воспринять, потому что половина текста — это утёкшие наружу кишочки стандартной библиотеки.
Ошибка инстанцирования шаблона — это китайский нетрадиционный из угловых скобок, который занимает столько горизонтального места, что оно уже не влезает на 4K-монитор. Я серьёзно начал понимать смысл сверхшироких мониторов именно глядя на эти ошибки, а ещё со временем стал отключать перенос строк, потому что с ним всё становилось только хуже. Неудивительно, что у многих C++ разработчиков UltraWide моники, очки и больная шея.
Шаблон это не код. Когда вы подставляете в него конкретные типы, компилятор инстанцирует его, вставляя ваши типы внутрь, и только потом проверяет получившееся на ошибки. Инстанцирует весь шаблон, везде подставив ваш тип.
Теперь, когда проверка идёт в глубине алгоритма (и ваш тип не подходит для sort), код уже развёрнут на десять слоёв вниз, и выбраться наверх можно только вывалив это всё вам на экран. По сути, это утиная типизация, только перенесённая на этап компиляции. Шаблон просто работает, если операции, которые он внутри себя использует, оказались валидны. Но и режим вывода ошибок у утиной типизации тоже один: она срабатывает не там, где вы ошиблись, а где-то глубоко внутри чужой библиотеки в точке использования, и шаблон вообще без понятия, что он вызывает в этом месте, потому что самого кода нет, есть только проверка "подходит — не подходит".
Тридцать лет шаблоны не имели механизма выражения требования к параметру. Ну нельзя было написать "этому шаблону нужен сравнимый тип", потому что при утиной типизации требования всегда неявные.
Компилятор физически не мог сказать "вы нарушили требование X", потому что никакого X нигде не записано, а мог только дотащить вас за шиворот на строку 4212 в недрах <algorithm> и показать, что там для вашего типа не определён operator<, а чтобы вы поверили, приходится выложить весь стек инстанцирования по дороге.
Концепты — та самая возможность назвать требования — были идеей ещё со Степановских неформальных concept'ов конца 80-х: их готовили в C++0x и вырезали из C++ 11, сочтя дизайн слишком сложным (я об этом писал в одной из статей). Но долетели они только к C++ 20, поэтому и называются requires (требования).
C++ последние лет двадцать продаёт себя как язык абстракций с нулевой стоимостью, но любые абстракции уже не бесплатны, и std::unique_ptr медленнее сырого указателя: у него нетривиальный деструктор, а такой тип по Itanium ABI нельзя передать в регистре, только через стек, тогда как голый указатель улетел бы в регистр.
И это не только про умные указатели, а про любой тип с нетривиальным деструктором: shared_ptr, string, vector муваются по значению через память по той же причине. Move-семантика не бесплатна: проблема в деструкторе, когда используется неразрушающее перемещение, поэтому у объекта, из которого вы переместили, всё равно отрабатывает деструктор, и эквивалентный ручной код был бы быстрее.
Регулярки в стандартной библиотеке считаются одной из худших реализаций в природе, местами в десятки и сотни раз медленнее альтернатив, а одно только подключение <regex> добавляет каждой единице трансляции хорошо если секунду компиляции.
unordered_map медленный, потому что медленный и недружелюбный к кешу, и, если вам нужна скорость, вы тащите flat_hash_map из гугловского Abseil или F14 из фейсбучного* Folly. Заметили закономерность? Куча вещей в C++ могла бы стать значительно быстрее, но не станет. Потому что это сломало бы ABI-совместимость. И вот мы подошли к главному.
Все претензии выше — от кривых имён до медленных контейнеров, от зоопарка static до невидимых копий — сходятся в одну точку. Люди думают, что C++ — это язык, который ставит во главу угла производительность, но на самом деле он ставит во главу угла обратную совместимость. Пофиг на производительность, пофиг на эргономику разработки, пофиг на опыт разработчика, пофиг вообще на всё.
Именно приверженность совместимости превратила язык в монстра. Когда нельзя переименовать vector, потому что сломается миллиард строк кода, и нельзя ускорить unordered_map, потому что изменится ABI, и абсолютно точно нельзя сделать разрушающее перемещение, потому что момент упущен лет десять назад, а ломать существующее нельзя. Каждое уродство языка — это каменный цветок в одном месте неправильного решения из прошлого, который нельзя выкинуть, ибо застрял, потому что на нём уже что-то стоит: чья-то либа, движок, игра или пайплайн.
Но ровно то свойство, за которое C++ ругают, это то самое свойство, благодаря которому на нём написана половина мира. Совместимость — это и болезнь, и причина выживания, потому что код, который вы написали двадцать лет назад, с некоторыми танцами и ударом в бубен всё ещё будет собираться. Библиотека, которую забросили в 2008, всё ещё линкуется. И для игровой индустрии, где стоимость переписывания измеряется человеко-десятилетиями, это не баг, а несущая стена.
Меня тут просили в комментариях высказаться про Rust. Надеюсь, что обойдёмся без религиозных войн. Он просто другой.
Rust по дизайну на порядок лучше: стандартный компилятор; стандартная сборка; стандартный пакетный менеджер; никаких заголовочных файлов; лучшие, что я видел, сообщения об ошибках; нормальные значения по умолчанию; отсутствие неявных преобразований; нормальный UTF-8; sum-типы и безопасность памяти на этапе компиляции. Многие проблемы, которые в C++ не решены и вряд ли будут решены в ближайшие десять лет, в Rust решены вчера.
Но "лучше по дизайну" не равно "надо брать". Разработка игр — это быстрая итерация, творческий бардак и "давай попробуем вот так", а Rust этого не даёт, и, если честно, этого уже и С++ не даёт, какой бы новый и современный он ни был. Поэтому игры перешли на скрипты, DSL и декларативное программирование.
А Rust по своей сути про корректность и дисциплину, и этот конфликт фундаментальный, приведший к тому, что есть растущая куча игр, которые начинали на Rust'е и ушли с него. Плюс экосистема: как бы вы не крутились, а CUDA, движки, тулинг, дофигиллиарды строк готового кода — всё это C++, и будет им ещё очень долго.
С++ — ужасный язык: многословный где не надо и молчаливый тоже где не надо; с зоопарком типов, врущими ключевыми словами, невидимыми копиями, нечитаемыми ошибками и адом сборки.
Чтобы писать на нём правильно, нужна энциклопедическая память на исключения, а когда выучили исключения, то отдельно на исключения из исключений. Но C++ живее всех живых, и разменяет ещё не один десяток, потому что у него чудовищная инерция и он один из немногих языков, кто выбрал совместимость любой ценой. Вероятно, ценой всего. И этот выбор сделал его одновременно невыносимым и незаменимым. Полмира работает на нём и будет работать ещё долго, нравится мне это или нет.
Я пишу на этом ужасном языке, романтизируя его сложность и доказывая самому себе, что я умный. И продолжу писать на нём, потому что в моей области он пока единственный, кто выдаёт нужную производительность, став несущей стеной. А заменить эту несущую стену в "жилом" и "живом" доме — это не просто "переписать пару комнат на модном языке", это снести нафиг весь дом и на его месте строить новый, оставшись жить пару лет на улице в будочке.
Так что да, язык ужасный, поэтому открывайте уже свою ужасную IDE и продолжайте писать на худшем языке программирования.
*Компания Meta, которой принадлежит Facebook, признана экстремистской и запрещена на территории Российской Федерации.
0