Введение
Операторы — базовые «кирпичики» любого выражения в C/C++ — в этой статье систематизируем основные категории операторов (арифметические, логические, побитовые) и разберём приоритет и ассоциативность, определяющие порядок вычисления выражений с несколькими операторами без явных скобок.
Концепция
Арифметические операторы (+, -, *, /, %) выполняют стандартные математические операции, с важной деталью — / для целочисленных операндов выполняет целочисленное деление (отбрасывание остатка, а не округление), а % (остаток от деления) определён только для целочисленных типов. Логические операторы (&&, ||, !) работают с булевыми значениями (или значениями, неявно преобразуемыми в булевы — любое не равное нулю значение считается истинным), и &&/|| обладают важным свойством «короткого замыкания» (short-circuit evaluation) — второй операнд не вычисляется вовсе, если результат уже однозначно определён первым операндом. Побитовые операторы (&, |, ^, ~, <<, >>) работают непосредственно с отдельными битами целочисленного представления значения, независимо от его «логического» смысла — это принципиально иной уровень операций по сравнению с логическими операторами, несмотря на схожий внешний вид некоторых символов (& против &&). Приоритет определяет, какой оператор применяется первым в выражении с несколькими операторами без явных скобок, а ассоциативность определяет порядок применения операторов одинакового приоритета (большинство — слева направо, но некоторые, как присваивание, справа налево).
Пример кода
#include <iostream>
int main()
{
std::cout << 7 / 2 << std::endl; // 3 — целочисленное деление ОТБРАСЫВАЕТ остаток, не округляет
std::cout << 7 % 2 << std::endl; // 1 — остаток от деления
std::cout << 7.0 / 2 << std::endl; // 3.5 — деление с ХОТЯ БЫ одним вещественным операндом даёт вещественный результат
// Короткое замыкание — функция rightOperand() НЕ будет вызвана вовсе
bool leftIsFalse = false;
auto rightOperand = []() { std::cout << "Вызвана!" << std::endl; return true; };
if (leftIsFalse && rightOperand()) { /* ... */ } // rightOperand() НЕ выполнится — вывода "Вызвана!" не будет
return 0;
}
// Побитовые операторы — работа с отдельными битами, независимо от "логического" смысла значения
#include <iostream>
int main()
{
unsigned int flags = 0b0101; // битовая маска: установлены биты 0 и 2
unsigned int withBit1Set = flags | 0b0010; // установка бита 1 — ИЛИ
unsigned int withoutBit0 = flags & ~0b0001; // снятие бита 0 — И с инвертированной маской
unsigned int toggledBit2 = flags ^ 0b0100; // переключение (XOR) бита 2
std::cout << (flags << 2) << std::endl; // сдвиг влево на 2 — эквивалент умножения на 4 для целых
std::cout << (flags >> 1) << std::endl; // сдвиг вправо на 1 — эквивалент целочисленного деления на 2
return 0;
}
// Приоритет и ассоциативность — выражение без скобок может быть неочевидно по реальному порядку вычисления
int main()
{
int result = 2 + 3 * 4; // 14, НЕ 20 — умножение имеет более высокий приоритет, чем сложение
int result2 = (2 + 3) * 4; // 20 — явные скобки переопределяют приоритет по умолчанию
int a = 1, b = 2, c = 3;
a = b = c; // присваивание — ассоциативность СПРАВА НАЛЕВО: сначала b = c, затем a = (результат b = c)
return 0;
}
Пояснения к коду
7 / 2 против 7.0 / 2 наглядно демонстрирует принципиально разное поведение оператора деления в зависимости от типов операндов — это частый источник тонких, неочевидных ошибок, когда разработчик ожидает вещественный результат, но получает обрезанное, целочисленное значение из-за того, что оба операнда были целочисленными. Демонстрация короткого замыкания с rightOperand, не вызываемой вовсе, показывает важное практическое следствие этого свойства — код часто намеренно пишется так, чтобы пользоваться этим поведением (pointer != nullptr && pointer->someMethod() безопасно избегает вызова метода на нулевом указателе именно благодаря короткому замыканию &&). Пример с побитовыми операциями показывает типичные паттерны работы с битовыми масками — установка, снятие и переключение отдельных битов через комбинацию |/&/^ с соответствующими масками, что принципиально отличается от логических операций, работающих с целым значением как единым булевым понятием, а не с отдельными его битами.
Подводные камни
- Путаница между логическими (
&&,||) и побитовыми (&,|) операторами — случайное использование одного символа&вместо двух&&(особенно вероятное опечаткой) даёт совершенно иную семантику операции, и хотя для булевых операндов результат побитового&/|часто совпадает с логическим&&/||для простых случаев, отсутствие короткого замыкания у побитовых операторов означает, что ВСЕ операнды вычисляются всегда, что может привести к нежелательным побочным эффектам или ошибкам, если один из операндов содержит вызов функции с побочным эффектом или потенциально неопределённое поведение (как обращение к нулевому указателю), которое было бы безопасно избежано через короткое замыкание&&. - Полагание на конкретный приоритет операторов без явных скобок в сложных, многооператорных выражениях, особенно при смешивании арифметических, побитовых и логических операторов в одном выражении — хотя приоритет операторов формально точно определён стандартом языка, код, требующий от читателя точного знания всей таблицы приоритетов для понимания смысла выражения, менее читаем по сравнению с тем же выражением, дополненным явными, поясняющими скобками, даже там, где они формально избыточны с точки зрения компилятора.
- Неопределённое поведение при сдвиге на величину, равную или превышающую размер типа в битах (например,
someInt << 32для 32-битногоint) — стандарт C++ явно определяет это как неопределённое поведение, и хотя на практике многие платформы дают некий, кажущийся «разумным» результат, рассчитывать на конкретное поведение в таком случае некорректно и потенциально приведёт к разному, непредсказуемому результату на разных платформах/компиляторах/уровнях оптимизации. - Использование побитового сдвига отрицательных знаковых чисел — поведение оператора
>>(сдвиг вправо) для отрицательных знаковых чисел зависит от реализации компилятора (может быть как арифметическим сдвигом, сохраняющим знак, так и логическим, заполняющим освобождающиеся биты нулями) и для случаев, где это поведение принципиально важно, стоит явно работать с беззнаковыми типами или явно учитывать конкретное, документированное поведение используемого компилятора, а не предполагать универсальное, переносимое поведение.