О чём лекция

У переменной кроме типа и значения есть ещё два свойства: где она существует и где к ней можно обратиться по имени. Без этих правил код с вложенными блоками, одинаковыми именами и глобальными переменными превращается в угадайку. Вторая половина — циклы: как повторять действие, когда число повторений неизвестно на этапе компиляции, и почему для этого не нужен goto.

Маршрут

  1. Блоки → Выражения и операторы (составной оператор — тоже оператор)
  2. Где объект существует → Область действия
  3. Как объявить имя, не создавая объект → Объявление и определение
  4. Где объект доступен по имени → Область видимости
  5. Повторение действий → Циклы
  6. Чего опасаться → Неопределённое поведение (пустой бесконечный цикл)
  7. Чего не писать → goto, см. раздел 7 и Кодстайл курса

1. Составной оператор

Составной оператор, или блок (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;  // 1
std::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';

Бесконечный цикл, объявление в условии и пустой цикл:

while (true) std::cout << 0;
 
x = -5;
while (int sqr = x * x) {
  ++x;
  std::cout << sqr << '\n';
}
 
while (x);

Последний цикл — Неопределённое поведение, если 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 (;;) ...
 
// аналог while
for (; 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 в курсе запрещён и чем его заменить.