Операторы и управление потоком
Инкременты, порядок вычисления выражений, условные конструкции и циклы.
Условные конструкции
Условная конструкция нужна, когда программа должна действовать по-разному в зависимости от данных. Условие не описывает действие само по себе: оно вычисляет ответ, который приводится к bool. Например, age >= 18 проверяет ограничение, а тело if задаёт, что сделать при успешной проверке. Даже короткие ветви удобно оформлять фигурными скобками, чтобы их границы были очевидны.
Control statements меняют порядок выполнения инструкций. if выбирает ветку по условию, switch выбирает метку, циклы повторяют тело по правилам проверки условия.
Условие if / else
Условие приводится к bool. Тело может быть одной инструкцией или блоком { ... }.
else if — удобная последовательность проверок без лишних уровней вложенности.
В цепочке if / else if / else срабатывает первая подходящая ветвь. Несколько отдельных if работают иначе: программа проверяет каждый из них, поэтому может выполнить сразу несколько действий. Выбирайте форму по смыслу задачи. Для оценки «плохо, достаточно, хорошо» обычно нужен один из взаимоисключающих вариантов; для нескольких независимых проверок могут понадобиться отдельные условия.
if (score >= 90) {
std::cout << "Отлично";
} else if (score >= 60) {
std::cout << "Зачёт";
} else {
std::cout << "Пересдача";
}Не обязательно писать else, если обе ветки повторяют одинаковые действия. Часто удобнее вынести общий код после условия.
Переключатель switch
Сопоставляет целочисленное (или enum) выражение с метками case.
Без break выполнение проваливается к следующим инструкциям, даже если следующая метка не совпадает (fallthrough).
case 1:std::cout << "A";
break;
case 2:std::cout << "B";
break;
default:std::cout << "C";
switch (x) {
case 1:
std::cout << "A";
// нет break: fallthrough
case 2:
std::cout << "B";
break;
default:
std::cout << "C";
}Fallthrough (проваливание ветвей) — Когда управление переходит из тела одной case в последующую без выхода из switch. Может быть намеренным; тогда уместен [[fallthrough]]; (C++17).
Циклы и переходы
Цикл позволяет описать повторяющуюся работу один раз: прочитать несколько чисел, пройти по строке или накопить сумму. У любого конечного цикла должна быть понятна причина остановки. Перед запуском проверьте, какое состояние есть в начале, что меняется после каждого шага и почему условие когда-нибудь перестанет выполняться. Это помогает заметить бесконечное повторение ещё до отладки.
Три способа повторения
while проверяет условие до первого шага. do...while проверяет после шага, поэтому тело выполнится хотя бы один раз.
for объединяет инициализацию, условие и действие после итерации.
while (condition()) {
work();
}
do {
work();
} while (condition());
for (int i = 0; i < 5; ++i) {
std::cout << i;
}У цикла for (int i = 0; i < 3; ++i) сначала один раз создаётся i, затем проверяется условие, выполняется тело и только потом делается шаг ++i. В тело попадут значения 0, 1 и 2. Инструкция continue пропускает остаток текущего тела, после чего у такого for выполняется шаг цикла; break завершает цикл целиком.
Выбирайте цикл по задаче: обход всех элементов удобно выразить range-for, а поиск с досрочным завершением — циклом с break. continue пропускает остаток текущей итерации, сохраняя сам цикл.
for (int i=0; i<3; ++i) {
sum += i;
}init: Начальное состояние: i=0, sum=0; тело ещё не выполнялось.
Трасса переменных
| Действие | i | sum | Тело |
|---|---|---|---|
| init | 0 | 0 | 0 |
После освоения цикла: break и continue
#include <iostream>
#include <vector>
int main() {
std::vector<int> values{2, 4, 6};
int total = 0;
for (int value : values) {
total += value;
}
std::cout << total << '\n';
}#include <iostream>
int main() {
for (int value = 1; value <= 8; ++value) {
if (value == 6) {
break;
}
if (value % 2 == 0) {
continue;
}
std::cout << value << ' ';
}
std::cout << '\n';
}| Ключевое слово | В чём эффект | Где допустимо |
|---|---|---|
break; | Немедленно выйти из ближайшего цикла или switch | Циклы и switch |
continue; | Перейти к следующей итерации ближайшего цикла | Только циклы |
goto label; | Перейти к метке в той же функции с ограничениями | Функции; не произвольно через инициализации |
Структура цикла for
Три части в заголовке могут быть пропущены. for (;;) означает цикл без условия завершения.
Вторая часть при отсутствии считается истинной. Третья может содержать любое допустимое выражение, не только i++.
for (int i = 0; i < 3; ++i) {
if (i == 1) continue;
if (i == 2) break;
}
// C++17: инициализация в условном операторе
if (int x = calculate(); x > 0) {
use(x);
}int x = 0; в заголовке for — объявление с инициализацией, а не выражение x = 0 с оператором присваивания. Запятая в int x = 0, y = 5; также разделяет декларации, а не является оператором запятая.
Обычно предпочтительнее структурированные циклы, break и continue. goto — редкий специальный инструмент; прыжок, обходящий запрещённую инициализацию локальной переменной, отвергается компилятором.
Управляющая конструкция (control-flow statement) — Инструкция, меняющая прямолинейное исполнение программы: условия, циклы, переходы, switch.
Разобранный пример и самостоятельная проверка
Цикл описывает повторение, но важно, когда он решает, выполнять ли тело. While сначала проверяет условие. Если оно ложно уже в начале, тело не выполняется ни разу. Do while сначала выполняет тело и только затем проверяет продолжение. Поэтому даже при ложном начальном условии один проход уже произойдёт. В программе ниже переменные before и after считают фактически выполненные тела. При n, равном нулю, получатся разные результаты. Измените n на один и три и заранее составьте таблицу значений после каждого прохода. For обычно объединяет начальное состояние, условие и шаг, но порядок его обычной работы также начинается с проверки. Нажатие «следующий шаг» в модели должно показывать конкретную инструкцию, а не просто увеличивать число проходов. До первого шага счётчик выполненных тел равен нулю у всех трёх циклов. Условие остановки нужно объяснять изменением состояния.
#include <iostream>
int main() {
const int n = 0;
int before = 0;
while (before < n) {
++before;
}
int after = 0;
do {
++after;
} while (after < n);
std::cout << before << ' ' << after << '\n';
}Инкремент и декремент
Инкремент увеличивает переменную на единицу, а декремент уменьшает. Важно различать два результата: новое состояние переменной и значение самого выражения с ++. Если выражение записано отдельной инструкцией, как ++count;, обычно нужен только факт увеличения. Когда его результат присваивается другой переменной или передаётся дальше, разница между префиксной и постфиксной формой становится заметной.
++x изменяет x и возвращает сам изменённый объект (lvalue, для встроенного int). x++ изменяет x, но результат выражения — прежнее значение (prvalue). Аналогично работают --x и x--.
Пусть x равно пяти. После отдельной инструкции int old = x++; переменная old содержит пять, а x — шесть. Если затем выполнить int current = ++x;, сначала x станет равно семи, и это же значение получит current. Разбирайте эти шаги по одному: так проще не перепутать сохранённое прежнее значение с уже изменившейся переменной.
Префиксная и постфиксная формы одинаково изменяют переменную, но дают разное значение выражения. Разделяйте изменения по отдельным инструкциям: так проще видеть порядок действий и избегать неоднозначных для читателя выражений.
x = 6сам x#include <iostream>
int main() {
int value = 5;
int before = value++;
int after = ++value;
std::cout << before << ' ' << after << ' ' << value << '\n';
}#include <iostream>
int main() {
int total = 0;
for (int i = 0; i < 4; ++i) {
total += i;
}
std::cout << total << '\n';
}Что происходит с результатом?
При ++x результат обозначает сам x, поэтому ++x = 9 синтаксически допустимо (если x изменяемый int).
После x++ остаётся прежнее значение, а не изменяемая переменная, поэтому x++ = 9 — ошибка компиляции.
int x = 5;
int a = ++x; // x == 6, a == 6
int b = x++; // b == 6, x == 7
++x = 9; // корректно: меняем x
// x++ = 9; // ошибка компиляции| Выражение | x после | Результат | Категория (int) |
|---|---|---|---|
++x | x + 1 | новое x | lvalue |
x++ | x + 1 | старое x | prvalue |
--x | x − 1 | новое x | lvalue |
x-- | x − 1 | старое x | prvalue |
lvalue и rvalue (value categories) — Для этой лекции достаточно запомнить: x и ++x — lvalue; число 5 и результат x + 1 — prvalue. Формально категорий больше; не всякому lvalue можно присвоить (например, const-объекту).
Не пишите выражения, где одну и ту же переменную изменяют несколько раз без гарантированного порядка, например i++ + i++: для встроенных операторов это неопределённое поведение.
Условный оператор
Условный оператор полезен, когда из двух вариантов нужно получить одно значение: подпись, число или результат расчёта. В записи score >= 60 ? "passed" : "retry" условие отвечает за выбор одной из строк. Это именно выражение (его результат можно присвоить переменной), поэтому оно отличается от конструкции if, которая прежде всего управляет выполнением инструкций.
условие ? выражение_1 : выражение_2 вычисляет условие; при истине вычисляется только первое выражение, при лжи — только второе. Оператор действительно имеет три операнда.
То, что одна ветвь не вычисляется, не означает, что компилятор может полностью игнорировать её запись. Он всё равно должен проверить корректность выражения и определить тип результата. Если в обеих ветвях нужны длинные действия, несколько присваиваний или вложенные проверки, обычный if / else будет понятнее. Условный оператор лучше оставлять коротким выбором значения, который легко прочитать целиком.
Условный оператор удобен, когда нужно выбрать одно из двух значений. Если ветви содержат несколько действий, обычный if читается легче. Во втором примере видно, что вычисляется только выбранная ветвь.
trueleft()#include <iostream>
#include <string>
int main() {
int score = 72;
std::string status = score >= 60 ? "passed" : "retry";
std::cout << status << '\n';
}#include <iostream>
int left() {
std::cout << "left\n";
return 10;
}
int right() {
std::cout << "right\n";
return 20;
}
int main() {
bool choose_left = false;
int result = choose_left ? left() : right();
std::cout << result << '\n';
}Тип известен при компиляции
Для flag ? 1 : 0.5 общий тип — double независимо от фактического значения flag.
Категория значения тоже определяется правилами языка заранее, а не по выбранной ветке.
bool ok = true;
int x = 2, y = 5;
(ok ? x : y) = 10; // x = 10 (lvalue)
double a = ok ? 1 : 0.5; // тип выражения double
// (ok ? x : x + 1) = 3; // ошибка компиляцииТернарный оператор (conditional operator ?:) — Встроенный оператор выбора одного из двух выражений. Другой операнд не вычисляется, что удобно для безопасного обращения к значениям, зависящего от условия.
Группировка операторов
Сложное выражение сначала нужно правильно прочитать, расставив подразумеваемые скобки. Это задача приоритета и ассоциативности. Например, 10 - 3 - 2 означает (10 - 3) - 2, поэтому результат равен пяти. Если написать 10 - (3 - 2), получится девять. Те же символы и числа дали другой результат из-за другой структуры выражения.
Приоритет определяет группировку операторов разных уровней, а ассоциативность — группировку цепочки операторов одного приоритета. Ни то ни другое само по себе не задаёт хронологию вычисления операндов.
Левая ассоциативность
a - b - c читается как (a - b) - c. То же относится к сложению, умножению и большинству бинарных операторов.
Для дробных чисел и пользовательских типов скобки могут влиять на результат.
Правая ассоциативность
a = b = c означает a = (b = c). Все основные операторы присваивания группируются справа налево.
Присваивание встроенных типов возвращает lvalue левого операнда, поэтому цепочка корректна.
После расстановки скобок ещё нельзя автоматически рассказывать, что процессор «сначала посчитает левую часть». Скобки показывают, какие результаты используются какими операциями, но не заменяют правила порядка вычислений. Когда выражение содержит вызовы функций или изменение переменных, эти вопросы нужно разобрать отдельно. Если структура не очевидна читателю, явные скобки обычно полезнее попытки запомнить всю таблицу приоритетов.
a - b - c(a - b) - cЛевая группировка для бинарного «−».
Ассоциативность — это структура выражения, а не обещание, что левая часть вычислится раньше правой. Порядок вычисления разбирается отдельно ниже.
Ассоциативность (associativity) — Правило синтаксической группировки операторов равного приоритета. Не следует путать с математической ассоциативностью операции (a+b)+c = a+(b+c).
Запятая и sizeof
У одного символа в C++ могут быть разные роли. Запятая иногда разделяет независимые части записи, а иногда сама является оператором с собственным результатом. Чтобы различить случаи, смотрите на конструкцию целиком: список аргументов функции — одна грамматическая форма, а выражение в скобках (first, second) может означать последовательное вычисление через оператор запятой.
Оператор запятая
(a, b) вычисляет a, затем b, а результатом становится b (с его категорией значения).
Но запятая между аргументами вызова функции или в объявлении переменных — не оператор запятая!
aзначение отбрасываетсяbстановится результатомint x = 1, y = 2; // запятая-разделитель
int z = (x++, y + 5); // оператор запятая: x=2, z=7
f(x, y); // два аргумента, НЕ оператор
f((x++, y)); // ОДИН аргумент = yАргументы f(a(), b()) не обязаны вычисляться слева направо. Начиная с C++17 их вычисления не перемешиваются, но порядок выбора аргумента всё ещё не задан. Не путайте это с настоящим оператором запятая.
Оператор запятая (comma operator) — Бинарное выражение (e1, e2): гарантирует вычисление e1 перед e2 и имеет результат и категорию значения e2. В списках аргументов запятые служат разделителями, а не этим оператором.
С sizeof полезно задавать вопрос: «Размер чего именно я сейчас узнаю?». Для int x = 5; выражение sizeof(x++) не увеличит x, потому что этот операнд не вычисляется. Для vector размер самого объекта контейнера не включает отдельно выделенную память его элементов. А количество хранимых значений узнают через size(), а не через sizeof.
Оператор sizeof
Возвращает размер типа или объекта в байтах. Результат имеет тип std::size_t и обычно вычисляется на этапе компиляции.
sizeof(v) и v.size() означают принципиально разные вещи. Почему вектор не занимает столько же, сколько его элементы?sizeof(v) — размер только самого объекта. v.size() — число реально содержащихся элементов, а v.capacity() — сколько элементов поместится до очередного перевыделения памяти.
Конкретное значение sizeof(v) зависит от реализации и платформы. Здесь показано его постоянство, а не реальный размер в байтах.
#include <vector>
#include <cstddef>
std::vector<int> v = {1, 2, 3};
std::size_t bytes = sizeof(v); // размер объекта v
std::size_t count = v.size(); // 3 элемента
std::size_t capacity = v.capacity(); // >= 3sizeof (sizeof operator) — Специальный оператор языка, возвращающий размер типа / объекта в единицах sizeof(char). Для обычных типов C++ не вычисляет само выражение-операнд sizeof(expr).
Приоритет и порядок вычислений
В выражении бывают не только вычисления чисел, но и действия: функция что-то печатает, переменная увеличивается, контейнер меняется. Такие изменения называют побочными эффектами (они остаются в программе помимо полученного значения). Когда несколько действий помещены в одну строку, необходимо понимать, какие из них язык гарантированно упорядочивает, а какие не обещает выполнять в выбранной вами последовательности.
f() + g() * h() группируется как f() + (g() * h()), но сам по себе приоритет не говорит, в каком порядке вызовутся f, g, h.
Если важно сначала вызвать read(), затем transform() и только после этого сложить результаты, запишите эти шаги отдельными инструкциями. Это одновременно делает намерение очевидным и позволяет отдельно проверить каждое промежуточное значение. Не пытайтесь устанавливать правила языка по одному запуску сложного выражения: другой компилятор или другой режим сборки может выбрать иной допустимый порядок.
f() + (g() * h())f → g → h| Высокий → низкий | Группы операторов | Ассоциативность |
|---|---|---|
1 | Постфиксные x++, (), [], ., -> | слева → направо |
2 | Префиксные ++x, !x, ~x, sizeof | справа → налево |
3 | * / %, затем + - | слева → направо |
4 | << >>, сравнения, == != | слева → направо |
5 | Побитовые & ^ |, затем логические && || | слева → направо |
6 | ?:, = += -= *= и другие присваивания | справа → налево |
7 | Оператор запятая , | слева → направо |
Value computation и side effect
Вычисление значения — получение результата выражения. Побочный эффект — изменение состояния, например увеличение x, запись в поток, изменение памяти.
В x++ прежнее значение становится результатом, а увеличение x — побочным эффектом.
old = xx = x+1oldЭто смысловая модель, а не точная программа компилятора.
A && BA раньше B; если A — false, B не вычисляется.A || BA раньше B; если A — true, B не вычисляется.c ? a : bУсловие сначала; вычисляется лишь одна выбранная ветка.(a, b)a обязательно перед b (именно оператор запятая).int i = 0;
// int bad = i++ + i++; // UB: два неупорядоченных изменения
int a = i++;
int b = i++; // два отдельных полных выражения: корректно
bool ok = false;
if (ok && (++i > 0)) { /* ++i не выполнится */ }Если две модификации одного скалярного объекта не упорядочены относительно друг друга, результат может стать UB. Запись i++ + i++ — небезопасна; а f(i++, i++) с C++17 не UB из-за самого порядка аргументов, но результат зависит от неуказанного порядка.
Sequenced before (sequencing) — Отношение между вычислениями, которое требует, чтобы одно полностью предшествовало другому в рамках абстрактной машины C++. Не тождественно приоритету и ассоциативности.
Токены и жадный разбор
Компилятор не читает исходный код как человек, угадывающий намерения автора. Сначала он выделяет токены (значимые кусочки записи), и только затем собирает из них выражения и инструкции. Поэтому несколько соседних плюсов могут превратиться не в те операции, которые вы мысленно подразумевали. Пробелы и понятное форматирование помогают явно показать нужные границы.
Токен (лексема) — минимальная значимая единица исходного текста: идентификатор, ключевое слово, оператор, литерал, знак пунктуации. Лексический анализ, как правило, выбирает максимально длинный допустимый токен.
Сравните a++ + b и a + ++b. В первой записи увеличивается a, но в сумму попадает его прежнее значение. Во второй сначала увеличивается b, и в сумму идёт уже новое значение b. Слитная запись a+++b разбирается как первый вариант. Если результат неожиданен, сначала выпишите отдельные токены, а уже потом применяйте правила приоритета.
(a++) + bЛексер сначала берёт ++, затем одиночный +.
Зачем нужны пробелы?
a+++b разбивается на a++ и +b. Пробелы позволяют явно управлять границами токенов: a + ++b — иное выражение.
После разбиения включаются правила грамматики: выражение должно быть не только разбито на токены, но и синтаксически корректно.
Например, ++++x читается как ++(++x), а x++++ — как (x++)++ и не компилируется для int.
int a = 1, b = 2;
int q = a+++b; // a++ + b
int r = a + ++b; // другая группировка
// a++++; // (a++)++ — ошибка
++++a; // ++(++a) — корректно для intЖадный лексический разбор (maximal munch) — Лексер выбирает максимально длинную последовательность символов, которая образует допустимый токен. Поэтому слитная запись нескольких + не обязательно означает ожидаемые бинарные и унарные операторы.
Дополнительно
Что полезно знать дополнительно
Раздел факультативный. Базовых правил выше достаточно для чтения большинства типичных задач.
Sequencing в разных стандартах (C++11 / C++17) — Правила порядка вычисления менялись. Например, с C++17 аргументы разных параметров функции вычисляются последовательно друг относительно друга, хотя их порядок остаётся неуказанным. Это отличается от прежнего неупорядоченного случая.
int i = 0;
auto f = [](int a, int b) { return a - b; };
int r = f(i++, i++); // C++17: не UB из-за порядка
// но порядок аргументов не задан
// i++ + i++ — по-прежнему UBПерегруженные операторы: подводный камень
Перегруженные operator&& и operator|| не выполняют логическое короткое замыкание: оба операнда будут вычислены до вызова функции-оператора. Начиная с C++17 для записи через оператор применяются уточнённые правила последовательности вычисления, но ленивого пропуска правой части нет.
Не считай, что перегруженный && автоматически ведёт себя как встроенный bool && bool. Это отдельные правила.
Почему у sizeof и size() разные задачи?
sizeof(std::vector<int>) зависит от ABI и реализации; v.size() — изменяемое число элементов, а v.capacity() — вместимость без нового выделения памяти.
Маленькие рекомендации по стилю
Не складывай несколько побочных эффектов в одной строке без необходимости. Пиши скобки, если группировка может быть неочевидна. Для простого выбора значения хорош ?:, для действий над несколькими строками — if.
Термины
Группировка операторов одного приоритета; это не порядок вычисления.
Примеры кода
Цикл: ноль, один и несколько проходов
#include <iostream>
int main() {
int n = 0;
if (!(std::cin >> n) || n < 0 || n > 20) {
std::cerr << "Invalid count\n";
return 1;
}
int sum = 0;
for (int i = 0; i < n; ++i) sum += i;
std::cout << sum << '\n';
}Сначала предскажите число проходов при n=0, затем при n=1 и n=3.
#include <iostream>
int main() {
const int n = 0;
int before = 0;
while (before < n) {
++before;
}
int after = 0;
do {
++after;
} while (after < n);
std::cout << before << ' ' << after << '\n';
}Обсудить лекцию с ИИ
Скопируйте промпт в свой ИИ-чат. В него уже включены материал этой лекции и примеры кода — можно сразу задавать вопросы и тренироваться.