5.06. Операторы C++
Операторы в C++
Операторы — это символы или комбинации символов, которые обозначают операции, совершаемые над данными (операндами). В C++ операторы играют центральную роль в выражениях — конструкциях, вычисляющих значения. От простейших арифметических действий до сложных манипуляций с памятью и типами — язык предоставляет широкий, хорошо продуманный инструментарий, отражающий его философию: близость к аппаратным возможностям при сохранении высокоуровневых абстракций.
Понимание операторов в C++ требует знания их синтаксиса и осознания приоритетов, ассоциативности, порядка вычисления операндов, а также различий между встроенными (built-in) операторами и перегруженными (overloaded). В этой главе рассматриваются встроенные операторы — те, которые определены в самом языке и работают с фундаментальными конструкциями: числами, указателями, логическими значениями и т. п. Особое внимание уделено семантике: то, что делает оператор, зачастую важнее того, как он записывается.
Арифметические операторы
Арифметические операторы позволяют выполнять базовые математические действия. В C++ они применимы преимущественно к арифметическим типам: целочисленным (int, long, char, и их беззнаковым вариантам) и вещественным (float, double, long double).
-
+(плюс) и-(минус) могут использоваться как в бинарной, так и в унарной форме.
В бинарной формеa + bиa - bпредставляют сложение и вычитание соответственно.
В унарной форме+aобычно не меняет значение (но может участвовать в преобразованиях типа), а-aвозвращает арифметическое отрицание — то есть число, противоположное по знаку. Унарный минус корректно определён для всех арифметических типов; для беззнаковых типов он производит вычитание из нуля с последующим беззнаковым преобразованием — что формально разрешено, но требует осторожности. -
*(звёздочка) обозначает умножение. Его применение интуитивно:a * bдаёт произведение. При работе с целыми числами результат усекается до целой части, при работе с вещественными — сохраняется дробная часть. Важно помнить, что переполнение при умножении целых типов приводит к неопределённому поведению в случае знаковых и к модульному арифметическому поведению в случае беззнаковых. -
/(косая черта) — оператор деления. Его поведение зависит от типов операндов:- Если хотя бы один из операндов имеет вещественный тип, результат — вещественное число, и деление выполняется с сохранением дробной части (например,
5.0 / 2 → 2.5). - Если оба операнда целочисленные, деление выполняется целочисленно: дробная часть отбрасывается (не округляется!), то есть
5 / 2 → 2,-5 / 2 → -2. Это может вызывать неожиданности у тех, кто ожидает математического округления вниз (как в некоторых других языках).
Деление на ноль — неопределённое поведение; компилятор не обязан его диагностировать, и выполнение программы может завершиться аварийно.
- Если хотя бы один из операндов имеет вещественный тип, результат — вещественное число, и деление выполняется с сохранением дробной части (например,
-
%(процент) — остаток от деления (modulo). Этот оператор применим только к целочисленным операндам. Результатa % bимеет тот же знак, что и делимоеa, а не делительb. Например:
7 % 3 → 1,
-7 % 3 → -1,
7 % -3 → 1,
-7 % -3 → -1.
Это поведение гарантируется стандартом C++11 и новее; до C++11 знак остатка мог зависеть от реализации. Важно: выражениеa == (a / b) * b + a % bвсегда должно быть истинным приb ≠ 0, что и определяет корректное поведение%. -
++и--— операторы инкремента и декремента. Они увеличивают или уменьшают значение переменной на единицу. Ключевая особенность — различие между префиксной (++x,--x) и постфиксной (x++,x--) формами:- Префиксная форма возвращает значение после изменения:
int x = 5; int y = ++x; // x == 6, y == 6 - Постфиксная форма возвращает значение до изменения:
int x = 5; int y = x++; // x == 6, y == 5Эти операторы применимы не только к целочисленным переменным, но и к указателям — при этом изменение значения происходит наsizeof(указатель_на_тип)байт, что логично для арифметики указателей.
Хотя++xиx += 1часто эквивалентны, их не следует считать взаимозаменяемыми. Например, для пользовательских типов с перегруженными операторами семантика может отличаться, а для встроенных типов — различается в контекстах, где важна производительность: постфиксный оператор требует создания временной копии (для возврата старого значения), что теоретически может быть дороже.
- Префиксная форма возвращает значение после изменения:
Порядок вычисления подвыражений в сложных выражениях с ++ и -- не специфицирован до C++17, а до C++11 — даже порядок побочных эффектов от операторов инкремента/декремента мог быть неопределённым. Например, выражение int x = 0; f(x++, x++); ведёт к неопределённому поведению, поскольку между точками следования происходят несколько побочных эффектов над одной переменной. Начиная с C++17 порядок вычисления аргументов функции слева направо гарантируется, но выражения вида i = i++ + 1 по-прежнему неопределённы — следует избегать подобных конструкций.
Операторы сравнения
Операторы сравнения используются для проверки отношений между значениями. Их результат всегда имеет тип bool: true, если условие выполнено, и false — в противном случае.
-
==и!=проверяют равенство и неравенство. Для арифметических типов сравнение выполняется по значению. Для указателей — по адресу: два указателя равны, если они указывают на один и тот же объект (или оба равныnullptr). Сравнение указателей на разные объекты, не входящие в один массив, не определено строго (результат может быть любым, но не вызывает неопределённого поведения, кроме случаев с объектами разного типа безreinterpret_cast).
СравнениеNaN(Not a Number) с чем-либо через==всегда даётfalse, включаяNaN == NaN. Это свойство используется для проверки:x != xистинно, только еслиx—NaN. -
<,>,<=,>=— операторы строгого и нестрогого порядка. Они корректно работают с числами и указателями, входящими в один массив или структуру (в последнем случае — для указателей на члены одного объекта, с учётом их расположения в памяти). Сравнение указателей вне этих условий даёт реализация-зависимый результат (но не UB).
Для вещественных чисел сравнения учитывают отрицательный ноль:-0.0 == 0.0истинно, хотя битовое представление различается. Однако-0.0 < 0.0ложно,0.0 < -0.0также ложно — они считаются равными.
Все операторы сравнения имеют левую ассоциативность, но цепочки вида a < b < c работают не так, как в математике: a < b < c интерпретируется как (a < b) < c, т. е. сначала вычисляется bool, затем сравнивается с c — что почти всегда бессмысленно. В C++20 появились three-way comparison (<=>), но это — отдельная тема.
Логические операторы
Логические операторы работают с операндами типа bool (или преобразуемыми к нему), но в C++ логически ложным считается любое значение, равное нулю (включая nullptr, 0.0, '\0'), а истинным — любое ненулевое.
-
!— унарное логическое отрицание.!xравноtrue, еслиxложно, и наоборот. -
&&и||— бинарные операторы логического И и логического ИЛИ. Они обладают важным свойством — ленивым вычислением (short-circuit evaluation):- В выражении
a && b, еслиaложно,bне вычисляется вообще. - В выражении
a || b, еслиaистинно,bне вычисляется.
Это критически важно: оно позволяет писать безопасные проверки, например:
if (ptr != nullptr && ptr->valid()) { … }Здесь вторая часть условия не будет выполнена, если указатель нулевой — избегаем разыменования
nullptr. - В выражении
Оба оператора возвращают bool, а не целое число (в отличие от C). Кроме того, && и || являются точками следования (до C++11) и sequenced before (начиная с C++11): побочные эффекты левого операнда завершаются до начала вычисления правого. Это делает конструкции вида f() && g() безопасными даже при наличии побочных эффектов в f() и g().
Побитовые операторы
Побитовые операторы работают непосредственно с двоичным представлением целочисленных значений. Их применение требует понимания того, как числа кодируются в памяти (обычно — в дополнительном коде для знаковых типов), и особенностей представления беззнаковых типов. Эти операторы не применимы к вещественным типам и не определены для указателей (за исключением ограниченного набора преобразований через reinterpret_cast).
-
~— унарный побитовый оператор дополнения (bitwise NOT). Он инвертирует каждый бит операнда: нули становятся единицами, единицы — нулями.
Например, дляunsigned char x = 0b00001101;значение~xбудет0b11110010.
Для знаковых типов результат зависит от представления отрицательных чисел. При дополнительном коде~xэквивалентно-x - 1, но полагаться на это следует с осторожностью — стандарт гарантирует поведение только через битовую инверсию. -
&,|,^— бинарные операторы побитового И, ИЛИ и исключающего ИЛИ (XOR) соответственно. Каждый из них применяется поразрядно к соответствующим битам операндов:a & bдаёт 1 в позиции, где оба бита — 1;a | bдаёт 1 там, где хотя бы один бит — 1;a ^ bдаёт 1 там, где биты различаются.
Эти операции фундаментальны для работы с флагами, масками, битовыми полями. Например, проверка установленного бита:
if (flags & MASK_ACTIVE) { … } // истина, если бит MASK_ACTIVE установленУстановка бита:
flags |= MASK_VISIBLE; // устанавливаем бит, не затрагивая остальныеСброс бита:
flags &= ~MASK_LOCKED; // инвертируем маску и применяем ИИнверсия бита:
flags ^= MASK_DEBUG; // переключаем состояние бита -
<<и>>— операторы побитового сдвига влево и вправо.a << nсдвигает битовое представлениеaнаnпозиций влево, освободившиеся младшие разряды заполняются нулями. Это эквивалентно умножению на2ⁿ— если результат представим в типеa.a >> nсдвигает вправо. Для беззнаковых типов — освобождённые старшие разряды заполняются нулями (логический сдвиг). Для знаковых типов — поведение зависит от реализации: большинство компиляторов выполняют арифметический сдвиг (старшие разряды заполняются копией знакового бита), но стандарт до C++20 не гарантировал этого. Начиная с C++20, для знаковых типов сдвига вправо определено арифметическое поведение: результат эквивалентен делению на2ⁿс округлением вниз (для отрицательных — к минус бесконечности), но только если значение неотрицательно или тип использует дополнительный код (что повсеместно).
Важные ограничения:
- Если
n < 0илиn >=количество бит в типе операнда — поведение неопределено. - Если результат сдвига не представим в типе (например, переполнение при
INT_MAX << 1) — неопределённое поведение для знаковых типов; для беззнаковых — результат берётся по модулю2^бит.
Следует подчеркнуть: побитовые операторы имеют меньший приоритет, чем арифметические и операторы сравнения. Например, a & b == c интерпретируется как a & (b == c), а не (a & b) == c. Это частый источник ошибок — всегда используйте скобки при смешении операторов разных групп.
Операторы присваивания
Оператор = — фундаментальный механизм изменения состояния переменной. В отличие от многих языков, в C++ присваивание — это выражение, а не только оператор. Это означает, что a = b не только изменяет значение a, но и возвращает ссылку на a (точнее, T&, где T — тип a). Благодаря этому возможны цепочки: a = b = c;, что эквивалентно a = (b = c);.
Семантика присваивания зависит от типа:
- Для фундаментальных типов — копирование битового представления (с преобразованием, если типы различаются).
- Для пользовательских типов — вызывается оператор присваивания (
operator=), который по умолчанию выполняет почленное копирование (shallow copy), но может быть перегружен.
В дополнение к простому присваиванию, C++ предоставляет составные операторы присваивания: +=, -=, *=, /=, %=, а также их побитовые аналоги &=, |=, ^=, <<=, >>=.
Каждый из них сочетает бинарную операцию и присваивание:
a += b эквивалентно (но не всегда тождественно) a = a + b.
Различие проявляется, когда a — сложное выражение с побочными эффектами. Например:
int arr[2] = {10, 20};
int i = 0;
arr[i++] += 5; // arr[0] становится 15, i == 1
// в раскрытом виде: arr[i] = arr[i] + 5; i++; — но это НЕ то же самое!
// потому что i++ вычисляется один раз, а не дважды
Таким образом, составные операторы лаконичны и могут быть эффективнее и безопаснее.
Стоит отметить, что оператор = — правоассоциативный: a = b = c читается как a = (b = c), что позволяет инициализировать несколько переменных одним значением.
Условный (тернарный) оператор
Оператор ?: — единственный тернарный оператор в C++, то есть принимающий три операнда. Его синтаксис:
condition ? expression_if_true : expression_if_false
Он предоставляет компактную альтернативу полной конструкции if–else, когда требуется выбрать значение, а не выполнить действия.
Семантически:
- Вычисляется
condition. Оно не обязано бытьbool— происходит неявное преобразование кbool(как вif). - Если
conditionистинно, вычисляется и возвращаетсяexpression_if_true;expression_if_falseпри этом не вычисляется. - Если
conditionложно, вычисляется и возвращаетсяexpression_if_false;expression_if_trueне вычисляется.
Это — ещё один пример ленивого вычисления, аналогичного && и ||.
Тернарный оператор возвращает значение, и это значение может использоваться в любом контексте, где ожидается выражение:
int max = (a > b) ? a : b;
std::string status = is_ready ? "готово" : "ожидание";
Важные нюансы:
- Тип результата определяется по правилам обычных арифметических преобразований и преобразования типов выражений. Если типы ветвей различаются, компилятор пытается найти общий тип; если это невозможно — ошибка.
- Обе ветви должны быть вычислимы синтаксически, даже если одна из них никогда не выполнится. Например,
true ? 42 : "hello"— ошибка компиляции, потому чтоintиconst char*несовместимы. - Можно вкладывать тернарные операторы, но это снижает читаемость. Рекомендуется ограничиваться одним уровнем или использовать скобки:
int sign = (x > 0) ? 1 : (x < 0 ? -1 : 0);
Тернарный оператор часто используется для инициализации const-переменных, когда значение должно быть определено условно, но неизменно после этого.
Оператор sizeof
Оператор sizeof возвращает размер (в байтах) типа или объекта во время компиляции. Это один из ключевых инструментов для работы с памятью, особенно в низкоуровневом коде, сериализации, работе с буферами.
Синтаксис:
sizeof(type)— размер типа (например,sizeof(int));sizeof expression— размер результата выражения (например,sizeof arr,sizeof(*ptr)).
Особенности:
- Результат имеет тип
std::size_t— беззнаковый целочисленный тип, достаточный для представления размера любого объекта в памяти. - Аргумент
sizeofне вычисляется (за исключением случая с переменной длиной массива в C, но в C++ VLA нестандартны). Например:Это свойство позволяет безопасно использоватьint x = 0;
sizeof(++x); // x остаётся 0, потому что ++x не выполняетсяsizeofс выражениями, которые невозможно или опасно вычислять. - Для массивов
sizeofвозвращает общий размер в байтах:int arr[10]; sizeof(arr) == 10 * sizeof(int). Это — один из немногих способов определить количество элементов в статическом массиве:sizeof(arr) / sizeof(arr[0]). - Для пустых классов размер не может быть нулём (чтобы каждый объект имел уникальный адрес), поэтому
sizeof(EmptyClass) >= 1. - Размер указателя не зависит от типа, на который он указывает (в пределах одной архитектуры):
sizeof(int*) == sizeof(void*) == sizeof(double*).
Оператор sizeof часто применяется в связке с malloc, memcpy, fread и другими функциями, работающими с сырыми байтами.
Оператор typeid
Оператор typeid является частью механизма RTTI (Run-Time Type Information) — информации о типах, доступной во время выполнения. Он позволяет получить объект типа std::type_info, описывающий тип операнда.
Синтаксис:
typeid(type)— информация о статическом типе;typeid(expression)— информация о динамическом типе, но только еслиexpression— lvalue полиморфного типа (то есть класса с хотя бы одной виртуальной функцией). В остальных случаях возвращается статический тип.
Пример:
class Base { virtual ~Base() = default; };
class Derived : public Base {};
Base* ptr = new Derived();
std::cout << typeid(*ptr).name(); // может вывести "7Derived" (зависит от компилятора)
Свойства:
- Результат — константная ссылка на
const std::type_info&, живущая в течение всей программы. std::type_infoпредоставляет методы:.name()— реализация-зависимая строка с именем типа (часто деманглированная утилитами вродеc++filt);.before()— для упорядочения типов;.hash_code()— хеш-код типа (начиная с C++11);- оператор
==и!=— для сравнения типов.
Важно: RTTI по умолчанию включён, но может быть отключён компилятором (например, флагом -fno-rtti в GCC/Clang), что делает использование typeid ошибкой компиляции. В ресурсоограниченных или высокопроизводительных системах RTTI часто отключают, полагаясь на альтернативные механизмы (например, вручную управляемые идентификаторы типов).
typeid не следует использовать для замены виртуальных функций или dynamic_cast. Его основное назначение — диагностика, отладка, сериализация, системы плагинов, где требуется проверка или идентификация типа во время выполнения.
Указатели и ссылки как операции
Хотя *, &, -> формально являются операторами, в контексте объявления переменных они выступают как часть синтаксиса объявления, а не как выражения. Тем не менее, их семантика настолько важна, что требует отдельного рассмотрения.
-
&в объявлении (int& ref = x;) создаёт ссылку — псевдоним для уже существующего объекта. Ссылка должна быть инициализирована при объявлении и не может быть «перенаправлена» на другой объект. Любая операция со ссылкой на самом деле выполняется над исходным объектом. Это делает ссылки безопаснее указателей: они не могут быть нулевыми (если только не получены черезreinterpret_cast, что не определено), не требуют явного разыменования. -
*в объявлении (int* ptr;) объявляет указатель — переменную, хранящую адрес другого объекта. Указатель может быть изменён, может быть нулевым (nullptr), может указывать на элемент массива и поддерживает арифметику. -
&как унарный оператор (&x) возвращает адрес объектаx, то есть указатель на него. Результат — rvalue указателя. -
*как унарный оператор (*ptr) — разыменование: получение объекта, на который указываетptr. Результат — lvalue (еслиptr— lvalue), что позволяет присваивать:*ptr = 42;. -
->(ptr->member) эквивалентен(*ptr).member, но удобнее и, для перегруженных операторов, может вести себя иначе.
Ссылки и указатели — не просто синтаксический сахар. Они отражают две стратегии управления доступом к данным:
- Ссылки — для обязательного, непрозрачного, безопасного доступа (например, параметры функций по ссылке).
- Указатели — для опционального, прозрачного, гибкого доступа (например, динамические структуры данных, интерфейсы с C).
Приоритет и ассоциативность операторов
Каждому оператору в C++ сопоставлен уровень приоритета и направление ассоциативности (левая или правая). Эти правила определяют, как компилятор группирует операнды в выражениях, где отсутствуют скобки.
Приоритет — это иерархия: операторы с более высоким приоритетом связываются с операндами раньше, чем операторы с более низким. Ассоциативность применяется внутри одного уровня приоритета: при наличии нескольких операторов одного приоритета подряд, выражение группируется слева направо (левая ассоциативность) или справа налево (правая).
Например:
a + b * c→a + (b * c), потому что*имеет более высокий приоритет, чем+.a - b - c→(a - b) - c, потому что-левоассоциативен.a = b = c→a = (b = c), потому что=правоассоциативен.
Вот упрощённая, но практически полезная последовательность групп (от высшего к низшему приоритету):
- Постфиксные операторы:
(),[],.,->,++,--,typeid,dynamic_cast,static_cast, и т. п. - Унарные операторы:
++,--,+,-,!,~,*,&,sizeof,new,delete. - Мультипликативные:
*,/,%. - Аддитивные:
+,-. - Сдвиги:
<<,>>. - Сравнения по порядку:
<,<=,>,>=. - Сравнения на равенство:
==,!=. - Побитовое И:
&. - Побитовое исключающее ИЛИ:
^. - Побитовое ИЛИ:
|. - Логическое И:
&&. - Логическое ИЛИ:
||. - Условный:
?:. - Присваивания:
=,+=,-=, и все составные. - Запятая:
,.
Обратите внимание: побитовые операторы (&, ^, |) находятся ниже операторов сравнения. Это — исторически сложившаяся особенность, идущая от языка B, и частый источник ошибок:
if (flags & MASK_ACTIVE == true) { … } // ОШИБКА!
// На самом деле: flags & (MASK_ACTIVE == true)
// т.е. flags & 1 — почти наверняка не то, что задумывалось
Правильно:
if ((flags & MASK_ACTIVE) != 0) { … }
// или, что лучше:
if (flags & MASK_ACTIVE) { … } // неявное преобразование к bool
Ещё один важный момент: запятая как оператор (a, b, c) имеет самый низкий приоритет и вычисляет операнды слева направо, возвращая значение последнего. Это отличается от запятой в списках аргументов (f(a, b)), где это просто разделитель синтаксиса, и порядок вычисления аргументов — не указан (до C++17) или слева направо (начиная с C++17). Оператор запятой редко нужен в повседневном коде, но используется в for-циклах для инициализации/инкремента нескольких переменных:
for (int i = 0, j = n-1; i < j; ++i, --j) { … }
Если приоритет неочевиден — ставьте скобки. Это проявление инженерной ответственности. Компилятору всё равно, а человеку — понятнее.
Порядок вычисления и точки следования
Долгое время в C++ (до C++11) использовалось понятие точки следования (sequence points) — моментов в выполнении программы, к которым все побочные эффекты от предыдущих вычислений должны быть завершены. Между двумя точками следования порядок вычисления подвыражений и побочных эффектов не специфицирован, а множественное изменение одного объекта без точки следования — ведёт к неопределённому поведению.
С C++11 понятие заменено на sequenced before, unsequenced, и indeterminately sequenced — более строгую модель, основанную на частичном упорядочении.
Ключевые правила:
- Операторы
&&,||,,(запятая),?:(условный), а также вызов функции — вводят упорядочение:- В
a && bвычислениеasequenced before вычисленияb. - В
a ? b : cвычислениеasequenced beforebилиc(ноbиcмежду собой unsequenced).
- В
- Вычисление аргументов функции indeterminately sequenced: порядок не определён, но все завершатся до входа в тело функции (гарантия с C++17 — слева направо).
- В выражениях вида
f(a, b) + g(c, d)порядок вызововfиg, а также порядок вычисленияa,b,c,d— не определён. Это позволяет компилятору оптимизировать.
Классическая ошибка:
int i = 0;
int arr[2] = {10, 20};
int x = arr[i++] + arr[i++]; // НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ
Почему? Потому что между точками следования (;) переменная i изменяется дважды (i++ два раза), и стандарт не определяет, в каком порядке произойдут инкременты и обращения к массиву. Результат может быть 10 + 10, 10 + 20, 20 + 20 или даже аварийное завершение.
Контраст:
int i = 0;
int x = arr[i] + arr[++i]; // ТОЖЕ НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ!
Хотя изменение i происходит один раз (++i), чтение i в arr[i] и модификация в ++i не упорядочены относительно друг друга. Стандарт требует, чтобы чтение значения объекта для определения результата модификации было упорядочено — в данном случае это не так.
Корректно:
int i = 0;
int x = arr[i] + arr[i + 1]; // без побочных эффектов
i += 2;
Или:
int i = 0;
int x = arr[i++];
x += arr[i++]; // два отдельных выражения, разделённых ; — точки следования
Правило большого пальца: никогда не меняйте и не читайте одну и ту же переменную в одном выражении, если только это не предусмотрено оператором явно (например, i = i + 1, где чтение i нужно для вычисления нового значения — это разрешено и безопасно).
Операторы и категории значений (value categories)
Операторы в C++ взаимодействуют с категориями значений: lvalue, rvalue, xvalue, prvalue, glvalue. Хотя это понятие выходит за рамки простого списка операторов, оно критически важно для понимания, что возвращает тот или иной оператор.
Кратко:
- lvalue — «левостороннее значение», обозначает объект с идентичностью (имеет адрес). Примеры: именованные переменные, разыменованный указатель
*ptr, возвращаемое значение функции, возвращающей ссылку. - rvalue — «правостороннее значение», временное, без идентичности. Делится на prvalue (pure rvalue: литералы, результат большинства встроенных операторов, например,
a + b) и xvalue (expiring value: результатstd::move, возвращаемое значение функции, возвращающей rvalue-ссылку).
Какие операторы что возвращают:
a = b,++a,--a,*ptr,arr[i],ptr->mem,T{}с пользовательским конструктором — возвращают lvalue (или xvalue для некоторых перегрузок).a + b,a % b,a && b,a || b,!a,~a,sizeof,typeid— возвращают prvalue.a++,a--— возвращают prvalue (копию старого значения).static_cast<T&&>(x)— xvalue, еслиT— ссылка на rvalue.
Это важно, потому что:
- Только lvalue можно взять адрес (
&x). - Только lvalue можно присвоить (в левой части
=), за исключением случаев с перегруженными операторами. - Rvalue можно превратить в lvalue через привязку к rvalue-ссылке, что лежит в основе семантики перемещения.
Например:
int x = 5;
int y = 6;
(x + y) = 10; // ОШИБКА: (x + y) — prvalue, нельзя присвоить
x++ = 10; // ОШИБКА: x++ возвращает prvalue
++x = 10; // ДОПУСТИМО: ++x возвращает lvalue (ссылку на x)
Эта система обеспечивает безопасность: запрет присваивания временным объектам предотвращает ошибки вроде (a + b) = c.
Перегрузка операторов
Перегрузка операторов — это механизм, позволяющий определять или изменять поведение встроенных операторов (+, ==, <<, [], и др.) для пользовательских типов (классов и перечислений). Это не добавляет новых операторов, а лишь расширяет применимость существующих.
Суть перегрузки проста: компилятор, встречая выражение вида a @ b, где @ — оператор, а a, b — объекты пользовательского типа, ищет функцию с именем operator@, подходящую по сигнатуре. Эта функция может быть:
- не-статической функцией-членом класса (первый операнд — неявный
*this); - свободной (non-member) функцией, часто объявленной как
friend, если нужен доступ к закрытым членам.
Например:
class Vector2D {
double x_, y_;
public:
Vector2D(double x, double y) : x_(x), y_(y) {}
// Перегрузка + как функция-член
Vector2D operator+(const Vector2D& other) const {
return Vector2D(x_ + other.x_, y_ + other.y_);
}
};
// Или как свободная функция:
Vector2D operator+(const Vector2D& a, const Vector2D& b) {
return Vector2D(a.x() + b.x(), a.y() + b.y());
}
Оба варианта работают, но свободная функция предпочтительнее в случае симметричных операторов (вроде +, ==), поскольку она позволяет неявные преобразования для обоих операндов. Например:
Vector2D v = Vector2D(1, 2) + Vector2D(3, 4); // работает в обоих случаях
Vector2D u = Vector2D(1, 2) + 5; // работает ТОЛЬКО если есть
// конструктор Vector2D(double)
// И оператор перегружен как свободная функция
Почему? Если operator+ — член, левый операнд обязан быть Vector2D, а правый может быть преобразован. Если свободная — оба могут быть преобразованы. Это особенно критично для смешанных типов (например, int + Complex).
Фундаментальные ограничения
Стандарт C++ накладывает чёткие ограничения на перегрузку:
-
Нельзя изменять арность оператора.
Все бинарные операторы остаются бинарными, унарные — унарными. Нельзя сделать++трёхаргументным. -
Нельзя создавать новые операторы.
Можно только перегружать существующие символы:+,[],->,new, и т. д. Нельзя ввести**для возведения в степень (хотяoperator"" _pow— пользовательские литералы — частично решают эту задачу). -
Нельзя менять приоритет и ассоциативность.
Даже если вы перегрузили&как логическое И, он всё равно будет иметь приоритет побитового И — ниже сравнения, выше^. Это неизменное свойство языка. -
Некоторые операторы нельзя перегружать вовсе:
::(разрешение области видимости),
.(доступ к члену),
.*(указатель на член),
?:(тернарный условный),
sizeof,typeid,alignof,noexcept,new[],delete[]— частично (см. ниже).
Также нельзя перегружать препроцессорные символы (#,##) — они обрабатываются до компиляции. -
Специальные операторы
newиdelete.
Хотяnewиdeleteформально перегружаются, это не обычные операторы, а функции выделения/освобождения памяти. Их перегрузка влияет на то, где и как выделяется память (куча, пул, стек), но не заменяетmalloc.operator new(size_t)— выделяет память, не вызывает конструктор.operator new[](size_t)— для массивов.operator delete(void*)— освобождает память, не вызывает деструктор.
Важно: при перегрузкеdeleteдолжен соответствоватьnew(иначе — неопределённое поведение).
Рекомендации по стилю и семантике
Перегрузка операторов — мощный инструмент выразительности, но злоупотребление им ведёт к нечитаемому, непредсказуемому коду. Следует придерживаться принципа наименьшего удивления: поведение перегруженного оператора должно соответствовать ожиданиям, выработанным встроенным использованием.
Что можно и нужно перегружать:
==и!=— для проверки эквивалентности. Должны быть рефлексивными, симметричными, транзитивными.
Пример: две строки равны, если их содержимое идентично.- Операторы сравнения (
<,<=,>,>=) — если тип естественно упорядочиваем (например, дата, точка на плоскости по лексикографии).
В C++20 рекомендуется перегружать только three-way comparisonoperator<=>, от которого компилятор автоматически генерирует остальные. +,-,*,/— для алгебраических типов (векторы, матрицы, комплексные числа).
+и*должны быть ассоциативны и (часто) коммутативны;+и-— быть обратными.<<и>>— для потоков ввода-вывода. По соглашению,operator<<дляstd::ostreamвозвращаетostream&, что позволяет цепочки:cout << a << b << c.[]— для контейнероподобных типов. Должен предоставлять доступ по индексу, желательно с проверкой границ в отладочной сборке.()— оператор вызова (function call operator). Позволяет создавать функторы — объекты, ведущие себя как функции. Это основа для лямбда-выражений, замыканий, стратегий.->— для «умных указателей» и итераторов. Должен вести к цепочке разыменований до встроенного указателя.
Что перегружать с осторожностью или избегать:
&&,||,,— потому что теряется ленивое вычисление.
Если вы перегрузитеoperator&&как функцию, оба операнда будут вычислены всегда, независимо от значения первого. Это нарушает фундаментальное свойство этих операторов и может привести к разыменованиюnullptr, делению на ноль и т. п.
Решение: не перегружайте&&и||. Вместо этого используйтеif,std::optional, или специальные методы (and_then,or_elseв стиле функционального программирования).&,|,^,~— только если тип действительно представляет битовую маску или логическое множество. Перегрузка для логических операций — плохая идея (лучшеand,or,not— альтернативные лексемы, стандартизированные в<ciso646>).=— лучше полагаться на правило пяти (или правило нуля): явно определить или удалить конструктор копирования, оператор копирующего присваивания, конструктор перемещения, оператор перемещающего присваивания и деструктор — если управляет ресурсами.++и--— если тип поддерживает notion «следующего» или «предыдущего» состояния (итераторы, даты, счётчики). Обязательно реализовать обе формы (префиксную и постфиксную), причём постфиксная должна вызывать префиксную:Обратите внимание: постфиксная форма принимает фиктивный параметрclass Counter {
int value_;
public:
Counter& operator++() { ++value_; return *this; } // префикс
Counter operator++(int) { auto old = *this; ++(*this); return old; } // постфикс
};int— это синтаксический приём для различения сигнатур.
Тонкости реализации
Возврат значений
- Присваивающие (
+=,-=и др.) должны возвращать ссылку на*this(T&), чтобы поддерживать цепочки:a += b += c. - Бинарные не-присваивающие (
+,==,<) должны возвращать значение (Tилиbool), а не ссылку — результат временный. - Унарные
++,--(префиксные) возвращаютT&; постфиксные —T(копию старого состояния). - Операторы
<<,>>для потоков возвращают ссылку на поток (std::ostream&,std::istream&) — это позволяет цепочки.
const-корректность
Если оператор не изменяет объект, он должен быть помечен const:
bool operator==(const Vector2D& other) const; // не меняет this
double length() const; // тоже
И наоборот, изменяющие операторы (+=, ++) — const не должны.
Перегрузка через friend
Если оператору нужен доступ к приватным полям, но он должен быть свободной функцией (например, <<), объявите его friend:
class Vector2D {
double x_, y_;
public:
friend std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
return os << "(" << v.x_ << ", " << v.y_ << ")";
}
};
Это не нарушает инкапсуляции: friend — часть интерфейса класса, явно заявленная в его определении.
Пример: минимальный «умный указатель»
Рассмотрим, как перегрузка операторов создаёт интуитивный интерфейс:
template<typename T>
class UniquePtr {
T* ptr_;
public:
explicit UniquePtr(T* p = nullptr) : ptr_(p) {}
~UniquePtr() { delete ptr_; }
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
UniquePtr(UniquePtr&& other) noexcept : ptr_(other.ptr_) {
other.ptr_ = nullptr;
}
UniquePtr& operator=(UniquePtr&& other) noexcept {
if (this != &other) {
delete ptr_;
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
// Операторы разыменования
T& operator*() const { return *ptr_; } // возвращает lvalue
T* operator->() const { return ptr_; } // возвращает указатель
// Проверка на "истинность"
explicit operator bool() const { return ptr_ != nullptr; }
// Отдача владения
T* release() noexcept {
T* p = ptr_;
ptr_ = nullptr;
return p;
}
void reset(T* p = nullptr) noexcept {
delete ptr_;
ptr_ = p;
}
};
Здесь:
operator*иoperator->делаютUniquePtrнеотличимым от встроенного указателя в выражениях:ptr->method(),*ptr = 42.explicit operator bool()позволяет писатьif (ptr) { … }, но запрещает неявное преобразование кint(защита отint x = ptr;).- Запрет копирования и реализация перемещения — следствие семантики единственного владения.
Перегрузка — способ реализовать гарантии владения и безопасности на уровне интерфейса.
Распространённые ловушки
-
Нарушение инвариантов.
Еслиa == bистинно, тоa < bиb < aдолжны быть ложны. Еслиa < bиb < c, тоa < cдолжно быть истинно. Нарушение транзитивности ломает алгоритмы (std::sort,std::map). -
Отсутствие симметрии у
==.
a == bдолжно быть эквивалентноb == a. Еслиoperator==— член, а второй операнд требует неявного преобразования, симметрия может быть нарушена. -
Возврат ссылки на локальную переменную или временный объект.
Vector2D& operator+(const Vector2D& b) const { // ОШИБКА!
Vector2D tmp(x_ + b.x_, y_ + b.y_);
return tmp; // ссылка на уничтоженный объект
}Правильно: возвращать
Vector2D(значение). -
Игнорирование
noexcept.
Операторы перемещения, деструкторы,swapдолжны бытьnoexcept, иначе стандартные контейнеры не смогут использовать перемещение (откатятся к копированию).
Операторы ввода и вывода: << и >>
В C++ ввод и вывод организованы вокруг абстракции потока (stream): последовательности байтов с источником (ввод) или приёмником (вывод). Классы std::istream и std::ostream (и их производные, такие как std::cin, std::cout, std::ifstream, std::stringstream) предоставляют интерфейс для последовательного чтения и записи данных.
Операторы << (stream insertion) и >> (stream extraction) — это перегруженные функции, определённые в стандартной библиотеке для фундаментальных типов, а также предоставляющие механизм для расширения.
Семантика и сигнатура
Стандартные перегрузки для встроенных типов выглядят так (упрощённо):
std::ostream& operator<<(std::ostream& os, int value);
std::ostream& operator<<(std::ostream& os, const char* str);
std::ostream& operator<<(std::ostream& os, double value);
// ... и т.д.
std::istream& operator>>(std::istream& is, int& value);
std::istream& operator>>(std::istream& is, std::string& str);
// ... и т.д.
Ключевые особенности сигнатуры:
- Первый параметр — ссылка на поток (
ostream&илиistream&), неconst, потому что операция меняет внутреннее состояние потока (например, позицию в файле, флаги ошибок). - Второй параметр:
- Для
<<—const T&(илиT, для маленьких типов), так как данные только читаются. - Для
>>—T&, потому что поток записывает прочитанное значение в переменную.
- Для
- Возвращаемое значение — ссылка на тот же поток (
ostream&/istream&). Это позволяет цепочку:std::cout << "x = " << x << ", y = " << y << "\n";
// группируется как: ((((cout << "x = ") << x) << ", y = ") << y) << "\n"
Порядок вычисления в цепочке — строго слева направо (гарантия начиная с C++17), что важно, если x или y — вызовы функций с побочными эффектами.
Перегрузка для пользовательских типов
Чтобы std::cout << myObject заработало, нужно определить соответствующий operator<<.
Рекомендуемый способ — свободная функция friend
class Rational {
int num_, den_;
static int gcd(int a, int b) { /* … */ }
void normalize() {
int g = gcd(num_, den_);
num_ /= g; den_ /= g;
if (den_ < 0) { num_ = -num_; den_ = -den_; }
}
public:
Rational(int n = 0, int d = 1) : num_(n), den_(d) {
if (d == 0) throw std::invalid_argument("denominator is zero");
normalize();
}
// Доступ к данным (для оператора)
int numerator() const { return num_; }
int denominator() const { return den_; }
// Перегрузка вывода — как friend
friend std::ostream& operator<<(std::ostream& os, const Rational& r) {
if (r.denominator() == 1)
os << r.numerator();
else
os << r.numerator() << '/' << r.denominator();
return os;
}
// Перегрузка ввода — как friend
friend std::istream& operator>>(std::istream& is, Rational& r) {
int n, d;
char slash = 0;
is >> n; // читаем числитель
if (is.peek() == '/') {
is >> slash >> d; // читаем '/' и знаменатель
} else {
d = 1; // только целое число — знаменатель 1
}
if (is && d != 0) {
r = Rational(n, d); // конструируем через каноническую форму
} else {
is.setstate(std::ios::failbit); // помечаем поток как ошибочный
}
return is;
}
};
Почему friend?
- Оператор должен иметь доступ к закрытым данным (
num_,den_), если публичных геттеров нет. - Свободная функция позволяет
<<работать с любымostream(включая производные, например,std::ofstream), а не только сstd::cout. - Сохраняется симметрия: левый операнд — поток, правый — объект.
Обработка ошибок во вводе
Оператор >> должен корректно обрабатывать ошибочные ситуации, иначе вызовущий код не сможет отличить успешное чтение от сбоя.
Стандартный подход:
- Пытаться прочитать данные в промежуточные переменные.
- Проверить состояние потока после каждой операции чтения (
if (is) { … }). - При ошибке — вызвать
is.setstate(std::ios::failbit), что установит флагfail(). - Никогда не оставлять объект в неопределённом состоянии. Либо полностью инициализировать, либо оставить без изменений.
Пример: если во входном потоке "3/0", оператор должен:
- Прочитать
3,/,0; - Обнаружить
d == 0; - Установить
failbit; - Не изменять
r(оставить прежнее значение).
Это позволяет писать надёжный код:
Rational r(1, 1);
std::cin >> r;
if (std::cin) {
std::cout << "Прочитано: " << r << "\n";
} else {
std::cerr << "Ошибка ввода\n";
std::cin.clear(); // сброс флагов
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // пропустить строку
}
Расширенные возможности потоков
Потоки поддерживают манипуляторы — функции, изменяющие формат вывода. Примеры: std::hex, std::setw, std::fixed.
std::cout << std::hex << 255 << "\n"; // выведет "ff"
std::cout << std::setw(10) << "hello"; // выведет " hello"
Манипуляторы работают, потому что имеют сигнатуру std::ostream& (*)(std::ostream&), и для них перегружен operator<<:
std::ostream& operator<<(std::ostream& os, std::ostream& (*pf)(std::ostream&)) {
return pf(os); // просто вызываем функцию
}
Это позволяет создавать пользовательские манипуляторы:
struct hex_rational_t {};
constexpr hex_rational_t hex_rational{};
std::ostream& operator<<(std::ostream& os, hex_rational_t) {
os.setf(std::ios::hex, std::ios::basefield);
return os;
}
// Использование:
std::cout << hex_rational << Rational(255, 16); // можно внутри operator<< проверять fmtflags
Однако для сложного форматирования лучше использовать локали (std::locale) или отдельные функции/классы форматирования, чтобы не засорять глобальное состояние потока.
Почему << и >> — не функции-члены?
Если бы operator<< был членом std::ostream, его сигнатура выглядела бы так:
std::ostream& std::ostream::operator<<(const Rational& r);
Это означало бы, что для каждого нового типа пришлось бы модифицировать класс std::ostream — что невозможно (он в стандартной библиотеке) и нежелательно (нарушает принцип открытости/закрытости).
Свободная функция — идеальное решение: расширяемость без изменения существующего кода. Это пример применения паттерна Type Erasure и ADL (Argument-Dependent Lookup): при вызове cout << r компилятор ищет operator<< в глобальной области и в пространствах имён, связанных с типами аргументов (std для cout, и пространство Rational для r).
Особенности operator>>: локализация и лексемы
Оператор ввода должен учитывать:
- Пробельные символы: по умолчанию
>>пропускает leading whitespace (пробелы, табы,\n) перед чтением. Это поведение можно изменить черезstd::noskipws. - Локаль: разделитель дробной части (
.или,) зависит отstd::locale. Для работы с фиксированным форматом (например, CSV) лучше использоватьstd::getlineи парсинг вручную. - Атомарность: стандарт не гарантирует, что
>>является атомарной операцией. В многопоточной среде доступ к одному потоку из нескольких потоков требует внешней синхронизации.
Альтернативы: std::format (C++20) и std::print (C++23)
Начиная с C++20, появился модуль <format>, предоставляющий безопасное, компилируемое форматирование без потоков:
#include <format>
std::string s = std::format("x = {}, y = {}", x, y);
А в C++23 — std::print:
std::print("Rational: {}\n", r); // требует operator<< для r или специализации formatter
Для поддержки пользовательских типов в std::format нужно специализировать std::formatter<T> — это более строгий, но и более мощный механизм, не зависящий от глобального состояния потока.
Однако операторы <</>> остаются актуальными:
- Для совместимости с унаследованным кодом.
- Для интеграции с библиотеками, построенными на потоках (логгеры, сериализация в бинарные потоки).
- Когда необходим контроль над буферизацией, состоянием ошибок, локалью — на уровне потока.
Практические рекомендации
- Всегда возвращайте ссылку на поток. Без этого цепочки работать не будут.
- Не выбрасывайте исключения из
operator>>без включенияexceptions()— по умолчанию потоки устанавливаютfailbit, а не бросают. - Избегайте побочных эффектов в операторах ввода-вывода — они должны быть чистыми по отношению к логике объекта (только сериализация/десериализация).
- Тестируйте граничные случаи: пустой ввод, переполнение, некорректный формат, EOF.
- Для бинарных данных используйте
read()/write(), не>>/<<— последние предназначены для текстового представления.
Операторы приведения типов
В C++ приведение типов (cast) — это механизм, позволяющий явно изменить тип выражения. В отличие от C, где существует единый синтаксис (type)expression, C++ предоставляет четыре именованных оператора приведения, каждый со строго определённой семантикой и ограниченной областью применения:
static_castdynamic_castconst_castreinterpret_cast
Этот подход отражает философию языка: разделять и властвовать — каждый оператор решает одну задачу, делая намерения программиста прозрачными как для компилятора, так и для читающего код.
static_cast: безопасные и обратимые преобразования
static_cast — наиболее часто используемый оператор приведения. Он предназначен для преобразований, проверяемых на этапе компиляции, без обращения к информации о типах во время выполнения.
Синтаксис:
static_cast<target_type>(expression)
Что поддерживает static_cast (основные сценарии):
-
Преобразования между арифметическими типами
Целочисленное расширение/сужение, преобразование целое ↔ вещественное:double d = 3.14;
int i = static_cast<int>(d); // 3 — дробная часть отбрасывается
char c = static_cast<char>(256); // реализация-зависимое поведение при переполненииКомпилятор не проверяет, умещается ли значение в целевой тип — это ответственность программиста.
-
Связанные указатели и ссылки в иерархии наследования
- Преобразование вверх по иерархии (к базовому классу) — всегда безопасно, часто не требует
static_cast(неявно разрешено). - Преобразование вниз (к производному классу) — разрешено, но не проверяется во время выполнения. Безопасно, только если объект действительно является экземпляром целевого типа (или его потомка):
struct Base { virtual ~Base() = default; };
struct Derived : Base { int extra; };
Base* b = new Derived();
Derived* d = static_cast<Derived*>(b); // OK — b указывает на Derived
// Но если b = new Base(); — d будет некорректным указателем
- Преобразование вверх по иерархии (к базовому классу) — всегда безопасно, часто не требует
-
Преобразование указателя в
void*и обратноint x = 42;
void* vp = &x;
int* ip = static_cast<int*>(vp); // восстановление типа — безопасно при соответствии -
Вызов явного и неявного конструктора
Аналогично прямой инициализации:class Wrapper {
public:
explicit Wrapper(int x) : value_(x) {}
int value() const { return value_; }
private:
int value_;
};
Wrapper w = static_cast<Wrapper>(10); // вызов explicit конструктора -
Преобразование lvalue ↔ rvalue
int x = 5;
int&& r = static_cast<int&&>(x); // превращение lvalue в xvalue (std::move делает то же)
Что static_cast НЕ делает:
- Не снимает
const/volatile(для этого —const_cast). - Не выполняет кросс-кастинг (преобразование между ветвями иерархии) — только вверх/вниз по одной линии.
- Не проверяет динамический тип — для этого
dynamic_cast.
Практическое правило: если преобразование кажется «логичным» и обратимым, и не связано с нарушением const или reinterpretацией битов — скорее всего, нужен static_cast.
dynamic_cast: безопасное нисходящее преобразование
dynamic_cast — единственный оператор приведения, задействующий RTTI (Run-Time Type Information). Он предназначен для безопасного преобразования указателей и ссылок в иерархии полиморфных типов.
Синтаксис:
dynamic_cast<target_type*>(ptr) // для указателей
dynamic_cast<target_type&>(ref) // для ссылок
Ключевые условия:
- Тип должен быть полиморфным, то есть содержать хотя бы одну виртуальную функцию (обычно — виртуальный деструктор).
target_typeдолжен быть указателем или ссылкой на класс, находящийся в той же иерархии, что и исходный тип.
Поведение:
-
Для указателей:
- Если преобразование возможно (объект действительно имеет динамический тип
target_typeили его потомка), возвращается корректный указатель. - Если невозможно — возвращается
nullptr.
Base* b1 = new Derived();
Base* b2 = new Base();
Derived* d1 = dynamic_cast<Derived*>(b1); // OK, d1 != nullptr
Derived* d2 = dynamic_cast<Derived*>(b2); // d2 == nullptr - Если преобразование возможно (объект действительно имеет динамический тип
-
Для ссылок:
- Если преобразование невозможно, выбрасывается исключение
std::bad_cast.
try {
Derived& d = dynamic_cast<Derived&>(*b2);
} catch (const std::bad_cast& e) {
// обработка ошибки
} - Если преобразование невозможно, выбрасывается исключение
Дополнительные возможности:
- Кросс-кастинг — преобразование между разными ветвями множественного наследования (при наличии виртуального наследования):
struct A { virtual ~A() = default; };
struct B : virtual A { };
struct C : virtual A { };
struct D : B, C { };
D d;
B* bp = &d;
C* cp = dynamic_cast<C*>(bp); // OK — проходит через виртуальный A
Недостатки:
- Накладные расходы: требуется таблица виртуальных функций и проверка типа во время выполнения.
- Зависимость от RTTI: если компилятор собран с
-fno-rtti,dynamic_castнедоступен. - Не работает с не-полиморфными типами.
Когда использовать:
Когда безопасность важнее производительности, и вы не можете гарантировать динамический тип статически. Часто применяется в системах плагинов, сериализации, фреймворках с обобщённой обработкой объектов.
Альтернатива: Tag Dispatch, Visitor, variant — статические механизмы, исключающие RTTI.
const_cast: управление квалификаторами const и volatile
const_cast — узкоспециализированный оператор, предназначенный исключительно для добавления или снятия квалификаторов const и/или volatile.
Синтаксис:
const_cast<target_type>(expression)
Что разрешено:
- Снять
constс указателя или ссылки:const int x = 10;
int* p = const_cast<int*>(&x); // OK синтаксически
*p = 20; // НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ: x изначально const! - Добавить
const(редко нужно, так как происходит неявно):int y = 5;
const int* cp = const_cast<const int*>(&y); // то же, что &y
Критически важное правило:
- Изменение объекта, изначально объявленного как
const, через снятыйconst— неопределённое поведение, даже если компилятор не ругается.
Компилятор имеет право разместить такой объект в read-only памяти или оптимизировать доступ, полагая, что значение не меняется.
Единственный безопасный сценарий:
Когда объект не объявлен как const, но попал в const-контекст (например, через параметр функции), и вы точно знаете, что владеете исходным не-const объектом:
void log_and_modify(const std::string& s) {
// Мы знаем, что s — ссылка на локальную строку, которую можно менять
std::string& mutable_s = const_cast<std::string&>(s);
mutable_s += " [logged]";
}
Это используется, например, в реализациях std::string::c_str() (внутренне строка может быть мутируемой), но в прикладном коде — крайне редко.
Практический совет: если вы регулярно используете const_cast, пересмотрите дизайн — возможно, интерфейсы должны быть не-const там, где ожидается модификация.
reinterpret_cast: низкоуровневая reinterpretация битов
reinterpret_cast — самый опасный и наименее переносимый оператор. Он выполняет низкоуровневую reinterpretацию битового представления одного типа как другого. Гарантии минимальны: стандарт определяет лишь несколько «безопасных» сценариев; всё остальное — implementation-defined или undefined.
Синтаксис:
reinterpret_cast<target_type>(expression)
Гарантированно безопасные (и часто используемые) преобразования:
-
Указатель ↔ целое число (и обратно), если целое достаточно велико (например,
std::uintptr_t):int x = 42;
std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(&x);
int* p = reinterpret_cast<int*>(addr); // OK, если sizeof(addr) >= sizeof(void*)Используется в системном программировании, драйверах, сериализации адресов.
-
Указатель ↔
void*(хотяstatic_castпредпочтительнее для этого). -
Указатель на T ↔ указатель на U, если:
- T и U — стандартные layout типы, и
- доступ идёт только к общему начальному подпоследовательностям (common initial sequence).
Это основа для «union type punning» с оговорками.
-
Функция ↔ указатель на функцию (но не функция ↔
void*— это UB в C++!).
Классический (но UB!) пример — type punning через reinterpret_cast:
float f = 3.14f;
int bits = *reinterpret_cast<int*>(&f); // НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ в C++
Почему? Нарушает strict aliasing rule: объект типа float читается через указатель на int, что запрещено.
Правильные альтернативы:
std::bit_cast(C++20):int bits = std::bit_cast<int>(f); // безопасно, оптимизируемо, no UBmemcpy:int bits;
std::memcpy(&bits, &f, sizeof(f)); // гарантированно OKunion(с оговорками, только для POD/standard-layout типов):union { float f; int i; } u;
u.f = 3.14f;
int bits = u.i; // implementation-defined, но часто работает
Когда reinterpret_cast оправдан:
- Работа с «сырой» памятью (буферы, сетевые пакеты, hardware registers).
- Системное программирование (например, приведение
void*к указателю на структуру, полученную от ядра). - Взаимодействие с C API, где типы «спрятаны» за
void*.
Предупреждение: reinterpret_cast отключает проверки типов. Его использование — сигнал для ревьюера: «здесь потенциально опасное место; проверьте дважды».
Сравнение с C-приведением (T)x
C-приведение — это «универсальный ключ», который компилятор пытается интерпретировать как:
static_cast,- если не удалось —
static_cast+const_cast, - если не удалось —
reinterpret_cast, - если не удалось —
reinterpret_cast+const_cast.
Это делает его:
- Непрозрачным — невозможно понять по коду, какое преобразование происходит.
- Опасным — легко допустить
reinterpret_castтам, где ожидалиstatic_cast. - Трудным для поиска — в кодовой базе сложно найти «все reinterpret-приведения», если они маскируются под
(int*)p.
Рекомендация стандарта и сообщества:
Никогда не используйте C-приведение в C++-коде. Всегда предпочитайте именованные операторы.
Обобщённая таблица применения
| Оператор | Проверка времени | Убирает const | Интерпретирует биты | RTTI | Типичное применение |
|---|---|---|---|---|---|
static_cast | компиляции | ❌ | ❌ | ❌ | Арифметика, up/down-cast (гарантированный), вызов конструктора |
dynamic_cast | выполнения | ❌ | ❌ | ✅ | Безопасный down/cross-cast в полиморфных иерархиях |
const_cast | компиляции | ✅ | ❌ | ❌ | Работа с legacy API, где забыли const |
reinterpret_cast | компиляции | ❌¹ | ✅ | ❌ | Системное программирование, сериализация, low-level access |
¹ — можно комбинировать с const_cast, но отдельно не снимает.