Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Операторы, ветвления и циклы

This lesson is not translated yet – the Russian original is below.

Синтаксис ветвлений и циклов C++ унаследовал от C, а оттуда его переняли Java, C#, JavaScript и Go. Если вы писали на любом из них, читать эту главу будет легко – ловушки в другом: в целочисленном делении, в неявных преобразованиях и в том, что if тут принимает не только bool.

Арифметика

int a = 7;
int b = 2;

a + b;    // 9
a - b;    // 5
a * b;    // 14
a / b;    // 3  – не 3.5
a % b;    // 1  – остаток

Деление двух целых даёт целое. Дробная часть отбрасывается, а не округляется: 7 / 2 – это 3, -7 / 2 – это -3. Чтобы получить дробь, дробным должен быть хотя бы один операнд:

static_cast<double>(a) / b;   // 3.5
a / 2.0;                      // 3.5

Самая частая версия этой ошибки – проценты: count / total * 100 при count = 1, total = 4 даст ноль, потому что 1 / 4 посчиталось до умножения. Порядок спасает: count * 100 / total.

Деление на ноль – неопределённое поведение для целых (обычно аварийное завершение) и определённое для дробных: получится inf или nan.

Инкремент

int i = 5;

++i;      // префиксный: увеличить, вернуть новое значение
i++;      // постфиксный: запомнить старое, увеличить, вернуть старое

Когда результат не используется, разницы для int нет. Для пользовательских типов и итераторов есть: постфиксная форма обязана сохранить копию «себя до изменения», чтобы её вернуть.

Отсюда привычка писать ++i в заголовке цикла. С int компилятор всё равно сгенерирует одинаковый код, но привычка бесплатна, а на итераторе тяжёлого контейнера – уже нет.

Несколько изменений одной переменной в одном выражении (i = i++ + ++i;) – классический пример неопределённого поведения. Не потому что сложно, а потому что стандарт не задаёт порядок. Не пишите так, и не верьте задачам на собеседованиях, где такое спрашивают.

Сравнение и логика

a == b;   a != b;   a < b;   a <= b;

x && y;   // и: если x ложно, y не вычисляется
x || y;   // или: если x истинно, y не вычисляется
!x;       // не

Логические операторы ленивы – это не оптимизация, а гарантия стандарта, на которой строят проверки:

if (ptr != nullptr && ptr->IsValid()) { ... }   // безопасно: разыменования не будет при nullptr

Побитовые & и | ленью не обладают и вычисляют оба операнда всегда. Опечатка в одну черту превращает безопасную проверку в падение.

Всё, что не ноль, – истина

if принимает не bool, а любое значение, приводимое к bool. Ноль и nullptr – ложь, всё остальное – истина.

int count = 5;
if (count) { ... }        // истина
if (!errors) { ... }      // «ошибок нет»

Из-за этого живёт классическая ошибка, которую в C# и Java компилятор не пропустил бы:

if (x = 5) { ... }        // присваивание, а не сравнение: условие всегда истинно

Компиляторы предупреждают об этом (-Wall), но по умолчанию собирают.

if и его формы

if (temperature > 30) {
    std::cout << "жарко";
} else if (temperature > 15) {
    std::cout << "нормально";
} else {
    std::cout << "холодно";
}

С C++17 в if можно объявить переменную, видимую только внутри ветвления:

if (auto it = users.find(id); it != users.end()) {
    std::cout << it->second;
}   // it здесь уже не существует

Полезно не ради экономии строк, а ради области видимости: переменная, нужная только для проверки, не торчит наружу.

Фигурные скобки при одной инструкции формально не обязательны. Их всё равно ставят: слишком легко дописать вторую строку и получить тело, которое выглядит вложенным, а не является им.

switch

switch (command) {
    case 'a':
        Add();
        break;
    case 'd':
        Delete();
        break;
    default:
        std::cout << "неизвестная команда";
}

Работает только с целыми и перечислениями – строку в switch подать нельзя, в отличие от C# и Java.

break обязателен. Без него выполнение проваливается в следующий case и идёт дальше, пока не встретит break или конец. Иногда это то, что нужно:

switch (level) {
    case 3:
    case 2:
        EnableExtras();
        [[fallthrough]];      // C++17: «я знаю, что делаю»
    case 1:
        EnableBasics();
        break;
}

Атрибут [[fallthrough]] ничего не меняет в поведении – он гасит предупреждение компилятора и сообщает читателю, что провал намеренный.

Циклы

for – когда известны границы:

for (int i = 0; i < 10; ++i) {
    std::cout << i << " ";
}

range-for – когда нужны все элементы:

std::vector<std::string> names = {"Anya", "Boris", "Vera"};

for (const auto& name : names) {   // const& – без копирования
    std::cout << name << "\n";
}

for (auto& name : names) {         // без const – можно менять элементы
    name += "!";
}
foreach (var name in names) { ... }
for _, name := range names { ... }

Обратите внимание на &. for (auto name : names) копирует каждую строку на каждой итерации – для int неважно, для строк и объектов уже заметно. Привычка писать const auto& по умолчанию экономит копии.

while – когда границы неизвестны:

while (std::getline(input, line)) {
    Process(line);
}

do-while – когда тело обязано выполниться хотя бы раз. Встречается редко.

break, continue и почему не goto

for (const auto& user : users) {
    if (user.banned) continue;    // к следующей итерации
    if (user.id == target) break; // выйти из цикла
    Process(user);
}

break выходит из одного цикла – самого внутреннего. Из вложенных выходят либо флагом, либо вынесением цикла в отдельную функцию с return. Второе обычно чище: если внутренностям цикла нашлось имя, его стоило дать.

goto в языке есть, работает и почти никогда не нужен. Единственное живое применение – выход из глубоко вложенного цикла в коде на C; в C++ для этого есть функции, исключения и RAII.