У переменной кроме типа и значения есть ещё два свойства: где она существует
и где к ней можно обратиться по имени. Без этих правил код с вложенными
блоками, одинаковыми именами и глобальными переменными превращается в угадайку.
Вторая половина — циклы: как повторять действие, когда число повторений
неизвестно на этапе компиляции, и почему для этого не нужен goto.
Составной оператор, или блок (compound statement), — последовательность
операторов, заключённых в фигурные скобки:
{ <statement> <statement> ...}
Блок сам является оператором. Поэтому его можно поставить туда, где синтаксис
ожидает один оператор, — например, после if, while или for.
int main() { int x = 1; { // начало первого блока int y = 2; std::cout << x + 3 * y << '\n'; { // начало второго блока return 0; } // конец второго блока } // конец первого блока {} // пустой блок}
Блоки бывают вложенными и состоят из любого количества операторов, в том числе
из нуля.
2. Где переменная существует
Блок ограничивает время жизни
Важное свойство блока: он ограничивает время жизни переменных, объявленных
внутри него.
int main() { int x = 1; { int y = 2; std::cout << x + 3 * y << '\n'; } // здесь y уже уничтожена return y; // ошибка: имени y здесь нет}
После закрывающей скобки y не существует, обратиться к ней нельзя. Компилятор
скажет примерно так:
main.cpp: In function 'int main()':main.cpp:9:12: error: 'y' was not declared in this scope return y;
Автоматические переменные
Обычная локальная переменная действует с места объявления до ближайшего конца
блока, в котором она объявлена. Такие переменные называются автоматическими:
int main() { int x; { float y; { bool b; { char c; } // здесь заканчивается действие c } // здесь заканчивается действие b } // здесь заканчивается действие y} // здесь заканчивается действие x
«Автоматическая» значит, что программист не пишет отдельную команду освобождения
памяти: объект появляется при входе в нужную часть программы и исчезает при
выходе из неё. Подробнее — Область действия.
Глобальные переменные
Переменные можно объявлять и вне функций. Такие переменные называются
глобальными:
int x = 1;int main() { int y = 2; std::cout << x + 3 * y << '\n'; return 0;}
Глобальная переменная существует всё время работы программы. Но это не значит,
что к ней можно обратиться из любого места файла: имя должно быть объявлено
до использования.
int main() { int y = 2; std::cout << x + 3 * y << '\n'; // ошибка return 0;}int x = 1;
x действительно глобальная, но в момент чтения строки с std::cout компилятор
ещё не знает, что такое x.
3. Объявление и определение
Чтобы предыдущий пример заработал, имя x можно заранее объявить, а определить
переменную позже. Для этого есть ключевое слово extern:
int main() { int y = 2; extern int x; // объявление без определения std::cout << x + 3 * y << '\n'; return 0;}int x = 1; // определение x
Объявление (declaration) сообщает компилятору, что такое имя существует.
Определение (definition) действительно создаёт объект.
В учебном коде проще писать глобальные переменные до функций. Но различие
объявления и определения дальше будет встречаться постоянно: в функциях,
заголовочных файлах и раздельной компиляции. Подробнее —
Объявление и определение.
4. Область видимости
Будет ли ошибка в такой программе?
int x = 0;int main() { int x = 1; std::cout << x << '\n'; return 0;}
Ошибки не будет, программа выведет 1.
Область видимости (scope) переменной — часть кода, из которой возможен
обычный доступ к переменной по её имени. Она не может быть больше области
действия: нельзя увидеть по имени объект, который уже не существует. А вот
меньше — может. Здесь глобальная x продолжает существовать во время
выполнения main, но локальная переменная с тем же именем скрывает
глобальное имя.
Разрешение области видимости
Чтобы обратиться именно к глобальной переменной, есть операция разрешения
области видимости :::
int x = 0;int main() { int x = 1; std::cout << ::x << '\n'; // 0 return 0;}
:: перед именем говорит компилятору: искать имя нужно в глобальной области.
Вложенные области
int x = 0;int main() { int x = 1; { int x = 2; std::cout << x << '\n'; // 2 std::cout << ::x << '\n'; // 0 } return 0;}
Поиск имени идёт от наиболее вложенной области к менее вложенной. Компилятор
сначала ищет x в самом внутреннем блоке, находит int x = 2; и использует её.
К глобальной переменной обратиться можно — через ::x. А к промежуточной
локальной x = 1 по имени уже никак: внутренняя x её скрывает, а ::x ведёт
сразу в глобальную область. Подробнее — Область видимости.
5. Область действия и область видимости
Два понятия нельзя смешивать:
область действия отвечает на вопрос: где объект существует?
область видимости отвечает на вопрос: где имя обозначает именно этот
объект?
Иногда они почти совпадают:
int main() { int x = 1; std::cout << x << '\n';}
А иногда объект продолжает существовать, но его имя временно скрыто:
int x = 0;int main() { int x = 1; std::cout << x << '\n'; // локальная переменная std::cout << ::x << '\n'; // глобальная переменная}
Зачем нужны блоки
Не только потому, что так устроен синтаксис if и циклов. Блоки позволяют
ограничивать область действия переменных:
int main() { int answer = 0; { int temporary = 40 + 2; answer = temporary; } std::cout << answer << '\n';}
После блока имя temporary исчезает. Это хорошо: чем меньше часть программы,
в которой видна переменная, тем проще понять, где её могут изменить.
6. Циклы
Проблема
Сумма трёх чисел — три переменные. Сумма десяти — можно выписать десять
одинаковых строк:
int sum = 0;int x;std::cin >> x; sum += x; // 1std::cin >> x; sum += x; // 2// ...std::cin >> x; sum += x; // 10
А если сначала вводится n, а потом n чисел? Можно даже написать скрипт,
который генерирует код под фиксированное n. Но что делать, когда n
неизвестно на этапе компиляции?
Цикл (loop) — оператор, который организует повторяющееся выполнение другого
оператора. Подробнее по всем видам — Циклы.
while
while (<condition>) <statement>
condition — выражение либо объявление переменной с инициализатором.
В любом случае значение должно приводиться к bool.
statement — оператор, может быть составным.
Решение задачи про n чисел:
int n;std::cin >> n;int sum = 0;while (n > 0) { int x; std::cin >> x; sum += x; --n;}std::cout << sum << '\n';
Бесконечный цикл, объявление в условии и пустой цикл:
Последний цикл — Неопределённое поведение, если x не меняет своего
результата и не имеет побочных действий.
do-while
do <statement> while (<condition>);
То же, что while, но тело выполняется до проверки условия. Поэтому цикл
гарантированно совершит хотя бы одну итерацию.
int x;do { std::cin >> x; std::cout << x * x << '\n';} while (x);
do-while используется реже, чем while и for, но есть задачи, где он
выражает происходящее естественнее всего. Например, читать числа до первого
нуля: до первого чтения проверять попросту нечего.
int x = 0;int sum = 0;do { std::cin >> x; sum += x;} while (x != 0);std::cout << sum << '\n';
Ключевой момент: while и for могут не выполнить тело ни разу, если условие
ложно с самого начала. При выборе цикла полезно спросить себя: логика задачи
построена вокруг условия входа или условия выхода?
for
Вернёмся к сумме n чисел, но со счётчиком:
int n;std::cin >> n;int sum = 0;int i = 0;while (i < n) { int x; std::cin >> x; sum += x; ++i;}std::cout << sum << '\n';
Что здесь не так: счётчик инициализируется и виден вне цикла; неясно, сколько
будет итераций; увеличение счётчика легко забыть или не найти в коде.
for ([init]; [condition]; [expression]) <statement>
init — выражение либо объявление. Область действия объявленной сущности
совпадает с областью оператора for.
condition — выражение либо объявление переменной с инициализатором,
значение должно приводиться к bool.
expression — произвольное выражение, выполняется в конце итерации.
statement — оператор, выполняющийся в цикле.
for (int i = 0; i < n; ++i) ...
for эквивалентен такому while:
{ [init]; while ([condition]) { // while (true), если condition пуст <statement> [expression]; }}
но читается гораздо лучше, поэтому на практике чаще используют for.
Примеры:
for (int i = 0; i < n; ++i) std::cout << i << '\n';for (int i = 0; i < n; i += 2) { std::cout << i << '\n';}for (std::cin >> x; x != 0; std::cin >> x) { std::cout << x * x << '\n';}// бесконечный циклfor (;;) ...// аналог whilefor (; x;) ...for (int i = 0, j = 0; i < n && j < m; ++i, ++j) ...
break и continue
break досрочно завершает весь цикл:
for (int i = 0; i < n; ++i) { int x; std::cin >> x; if (x == 0) { std::cout << "Division by zero\n"; break; } std::cout << y / x << '\n';}
continue досрочно завершает текущую итерацию, и цикл идёт дальше:
for (int i = 0; i < n; ++i) { int x; std::cin >> x; if (x == 0) { std::cout << "Division by zero\n"; continue; } std::cout << y / x << '\n';}
Код одинаковый, кроме одного слова. После первого нуля break прекращает
обработку чисел, а continue пропускает только этот ноль.
7. Оператор goto
Запрещён на территории курса
В лонгриде — «оператор, запрещённый на территории нашего курса, выполняющий
функции иностранного агента». Разбирается, чтобы понимать, почему.
goto выполняет «прыжок» в произвольное место функции, помеченное меткой:
int main() { int x; std::cin >> x; goto label; int y; std::cin >> y; std::cout << x + y << '\n';label: return 0;}
if может пропустить блок, цикл — вернуться к началу, но условие и границы
у них видны из структуры программы. goto переходит к метке без всяких условий.
Именно эта свобода и создаёт проблему: условие, начало и конец перехода больше
не видны, и читателю приходится самому восстанавливать возможные пути выполнения.
Через goto можно реализовать цикл, а ещё он лежит в основе switch:
for (int i = 0; i < n; ++i) ...// <=> int i = 0;loop: if (i < n) { ... ++i; goto loop; }
Вопрос из лонгрида
Что вы думаете про эти два куска кода?
int x = 0;label: int y = 1; ... goto label;
goto label; int x = 0; int y = 1;label: std::cout << x * y << '\n';
Ответ лонгрида: в первом всё корректно, переменная проинициализируется заново.
Во втором — UB: использование переменных без инициализации.
Не из лекции
Второй пример в таком виде не компилируется. Проверено на GCC 16.2
и clang, -std=c++20:
error: jump to label 'label'note: crosses initialization of 'int y'
Стандарт запрещает переход, который попадает в область видимости переменной
в обход её объявления с инициализатором. Если убрать инициализаторы
(int x; и int y;), код соберётся, и тогда чтение x * y — то самое
UB из лонгрида, см. Инициализация переменных.
Почему goto плох
визуальный порядок строк перестаёт отражать порядок выполнения;
читателю приходится восстанавливать граф переходов по всей функции;
легко пропустить инициализацию или изменение состояния;
усложняется пошаговая отладка.
В повседневном C++ вместо goto используют циклы, break, continue, ранний
return, флаг состояния или отдельную функцию. Обойтись без него можно во всех
ситуациях, даже безвыходных.
“…the quality of programmers is a decreasing function of the density of
go to statements in the programs they produce.”
— Edsger Dijkstra, Go To Statement Considered Harmful, 1968
Грабли
Переменная из блока после блока не существует.'y' was not declared in this scope — не опечатка в имени, а обращение за пределами блока.
Глобальная переменная, определённая ниже main, в main не видна.
Имя должно быть объявлено до использования: либо перенести определение выше,
либо объявить через extern.
Локальная переменная с тем же именем молча скрывает глобальную. Ошибки
не будет, будет другое значение.
К промежуточной скрытой переменной не добраться.::x ведёт сразу
в глобальную область, минуя все локальные.
while и for могут не выполниться ни разу, do-while выполнится хотя
бы однажды.
while (x); без побочных эффектов — не просто зависание, а
неопределённое поведение.
break и continue отличаются одним словом, а поведение цикла — целиком.
После этой лекции должно получаться
объяснить, чем область действия отличается от области видимости, и привести
пример, где объект существует, но по имени недоступен;
предсказать вывод программы с тремя вложенными x и ::x;
объяснить, зачем нужен extern int x; и чем объявление отличается
от определения;
переписать for через while и обратно;
выбрать между while и do-while по тому, где у задачи условие — на входе
или на выходе;
сказать, что изменится в цикле, если заменить break на continue;
объяснить, почему goto в курсе запрещён и чем его заменить.