Циклы
Цикл — одна из фундаментальных конструкций: он описывает повторение. Но в C++ это слово скрывает целый набор разных механизмов с разной семантикой, разными гарантиями и разными компромиссами. Выбор между for, while, range-based for и алгоритмом из <algorithm> — это не вопрос стиля, это вопрос правильного выражения намерения.
Классический for говорит: «я управляю счётчиком». while говорит: «я жду условия». Range-based for говорит: «я обхожу диапазон, мне не нужен индекс». std::transform говорит: «это преобразование, а не цикл». Чем точнее выражено намерение, тем меньше ошибок и тем больше у компилятора поводов для оптимизации.
for
Классический цикл с явным счётчиком:
for (инициализация; условие; шаг) {
тело
}
Три части выполняются так: инициализация — один раз перед первой итерацией; условие — перед каждой итерацией, если ложно — выход; шаг — после каждого тела.
for (int i = 0; i < 10; ++i) {
std::cout << i << " ";
}
// Вывод: 0 1 2 3 4 5 6 7 8 9
Предпочитайте ++i вместо i++ для итераторов и нетривиальных типов — постфиксный инкремент создаёт временную копию. Для int разницы нет, но привычка читается лучше.
Все три части опциональны. Бесконечный цикл:
for (;;) {
// выполняется до break или return
if (done) break;
}
Вложенные циклы с несколькими переменными в инициализаторе (C++17):
for (int i = 0, j = 9; i < j; ++i, --j) {
std::cout << i << " " << j << "\n";
}
while
Цикл с предусловием — условие проверяется до тела. Если условие ложно изначально, тело не выполнится ни разу.
int n = 1;
while (n < 100) {
n *= 2;
}
// n == 128
while удобен, когда число итераций заранее неизвестно и управляется внешним условием:
std::string line;
while (std::getline(std::cin, line)) {
process(line); // пока поток открыт
}
do-while
Цикл с постусловием — тело выполняется хотя бы один раз, условие проверяется после:
int input;
do {
std::cout << "Введите число от 1 до 10: ";
std::cin >> input;
} while (input < 1 || input > 10);
Используется реже всего. Хорошо подходит для retry-логики и циклов ввода, где первая попытка всегда нужна.
Range-based for (C++11)
Самый читаемый способ обойти контейнер — когда индекс не нужен:
std::vector<int> v = {1, 2, 3, 4, 5};
for (int x : v) // копия каждого элемента
std::cout << x << " ";
for (int& x : v) // ссылка — изменяем оригинал
x *= 2;
for (const int& x : v) // const-ссылка — читаем без копии
std::cout << x << " ";
for (auto& x : v) // рекомендуется: auto выводит тип автоматически
x += 10;
Правило: используйте auto& по умолчанию для изменения, const auto& для чтения тяжёлых объектов, auto по значению только для дешёвых примитивов.
Что генерирует компилятор
Range-based for — синтаксический сахар. Компилятор разворачивает for (auto x : c) в:
{
auto&& __range = c;
auto __begin = begin(__range); // ADL: std::begin или метод .begin()
auto __end = end(__range); // ADL: std::end или метод .end()
for (; __begin != __end; ++__begin) {
auto x = *__begin;
// тело
}
}
Structured bindings в range-for (C++17)
Range-based for отлично сочетается со структурными привязками:
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 82}};
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
}
// Вектор пар
std::vector<std::pair<int, std::string>> items = {{1, "one"}, {2, "two"}};
for (auto& [id, label] : items) {
label += "!"; // изменяем через ссылку
}
break и continue
break — немедленный выход из текущего цикла:
for (int i = 0; i < 100; ++i) {
if (i * i > 50) {
break;
}
}
continue — пропустить оставшуюся часть тела и перейти к следующей итерации:
for (int i = 0; i < 10; ++i) {
if (i % 2 == 0) continue; // пропускаем чётные
std::cout << i << " "; // 1 3 5 7 9
}
Оба оператора работают только с ближайшим охватывающим циклом. Для выхода из вложенных циклов — флаг или вынос в функцию:
// Флаг — читается, но добавляет переменную
bool found = false;
for (int i = 0; i < n && !found; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] == target) {
found = true;
break;
}
}
}
// Лямбда — чище, сохраняет локальность
auto search = [&]() -> std::optional<std::pair<int,int>> {
for (int i = 0; i < n; ++i)
for (int j = 0; j < m; ++j)
if (grid[i][j] == target)
return {{i, j}};
return std::nullopt;
};
auto pos = search();
Типичные ловушки
Сравнение знакового и беззнакового счётчика
std::vector<int> v = {1, 2, 3};
// Предупреждение: сравнение int и size_t (unsigned)
for (int i = 0; i < v.size(); ++i) { ... }
// Правильно: явное приведение или size_t
for (std::size_t i = 0; i < v.size(); ++i) { ... }
// Или: кастование в int (если вектор гарантированно маленький)
for (int i = 0; i < (int)v.size(); ++i) { ... }
Когда int i равен -1, а v.size() возвращает size_t, сравнение i < v.size() даёт false — потому что -1 в знаковом представлении при преобразовании к size_t становится огромным числом.
Копия вместо ссылки
std::vector<std::string> words = {"hello", "world"};
for (auto word : words) // копирует каждую строку — дорого
word += "!"; // и бесполезно: оригинал не меняется
for (auto& word : words) // ссылка — изменяет оригинал
word += "!";
Изменение контейнера во время итерации
std::vector<int> v = {1, 2, 3, 4, 5};
// НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ — erase инвалидирует итератор,
// которым управляет range-for
for (auto x : v) {
if (x % 2 == 0) v.erase(/* ... */); // UB
}
// Правильный идиоматичный способ: erase-remove idiom
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }),
v.end());
// C++20: std::erase_if
std::erase_if(v, [](int x) { return x % 2 == 0; });
Shadowing переменной цикла
int i = 100;
for (int i = 0; i < 10; ++i) { // внешний i скрыт
std::cout << i; // 0..9, а не 100
}
// Внешний i всё ещё 100, но читабельность пострадала
Алгоритмы как замена циклов
<algorithm> содержит высокоуровневые операции, которые устраняют целые классы ошибок цикла — off-by-one, забытый break, неверное накопление.
#include <algorithm>
#include <numeric>
std::vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6};
// Сумма — вместо цикла накопления
int sum = std::accumulate(nums.begin(), nums.end(), 0);
// Поиск первого подходящего
auto it = std::find(nums.begin(), nums.end(), 5);
if (it != nums.end())
std::cout << "Найден на позиции " << std::distance(nums.begin(), it);
// Проверка условия
bool has_negative = std::any_of(nums.begin(), nums.end(),
[](int x) { return x < 0; });
// Преобразование всех элементов
std::transform(nums.begin(), nums.end(), nums.begin(),
[](int x) { return x * 2; });
// Применение действия к каждому элементу
std::for_each(nums.begin(), nums.end(),
[](int& x) { x += 1; });
Алгоритм передаёт намерение точнее: std::accumulate — это «сумма», а не «цикл с суммой». Компилятор получает контекст для векторизации и оптимизации.
Производительность: тело цикла как критический путь
Если цикл выполняется миллионы раз, его тело — горячий путь. Несколько принципов:
Предсказание переходов. Ветвления внутри цикла мешают CPU предсказывать следующую инструкцию. По возможности убирайте условия из горячего пути — используйте [[likely]]/[[unlikely]] (C++20) как подсказку компилятору.
Векторизация. Компилятор может заменить цикл SIMD-инструкциями, если тело не содержит неаналитических зависимостей. Избегайте указателей с потенциальным алиасингом в теле — добавьте __restrict__ или [[assume(...)]] при необходимости.
Loop unrolling. Компилятор может развернуть тело цикла, уменьшив накладные расходы на счётчик. #pragma GCC unroll N или атрибуты Clang дают явную подсказку.
// Пример: цикл без ветвлений — дружелюбен к векторизации
std::vector<float> a(N), b(N), c(N);
for (std::size_t i = 0; i < N; ++i)
c[i] = a[i] + b[i]; // компилятор превратит в SIMD при -O2
// Цикл с условием — хуже для векторизации
for (std::size_t i = 0; i < N; ++i)
if (a[i] > 0) c[i] = a[i] + b[i]; // требует маскирования
Инвалидация итераторов и производительность. std::vector гарантирует непрерывную память — итерация по нему дружелюбна к кэшу. std::list или std::map дают промахи кэша на каждой итерации. Если вам нужно быстро обойти — std::vector почти всегда лучше, даже если вставка дороже.
Ранние прерывания и бесконечные циклы
Ранние прерывания с помощью стандартных алгоритмов:
// std::find останавливается на первом совпадении
auto pos = std::find(v.begin(), v.end(), target);
// std::any_of останавливается при первом true
bool ok = std::any_of(v.begin(), v.end(), pred);
// std::all_of останавливается при первом false
bool all = std::all_of(v.begin(), v.end(), pred);
Бесконечный цикл в потоке — типичный паттерн для воркера:
#include <thread>
void worker() {
while (!stop_flag.load(std::memory_order_acquire)) {
if (!queue.empty()) {
process(queue.front());
queue.pop();
} else {
std::this_thread::yield(); // уступаем квант времени
}
}
}
std::this_thread::yield() сообщает планировщику ОС, что поток готов уступить процессор — полезно в spin-wait петлях. Без него такой цикл занимает 100% ядра.
Значение для собеседований
Циклы — одна из первых тем на любом техническом интервью по C++, но интервьюер смотрит не на знание синтаксиса.
Что проверяют:
- Понимаете ли вы, как range-based
forразворачивается в begin/end/increment/compare — и что из этого следует - Знаете ли правила инвалидации итераторов для конкретных контейнеров
- Используете ли алгоритмы там, где они уместнее цикла
- Видите ли разницу между
for (auto x : v)(копия) иfor (auto& x : v)(ссылка) - Понимаете ли проблему сравнения signed/unsigned счётчиков
Популярные направления вопросов:
- Что произойдёт, если вызвать
erase()на элементе внутри range-based for? — Ожидаемый ответ: UB из-за инвалидации итератора; правильный способ — erase-remove idiom илиstd::erase_if. - Чем отличается
for (auto x : v)отfor (auto& x : v)— когда какой использовать? - Как работает begin/end lookup в range-based for — почему он работает для сырых массивов и пользовательских типов?
- Когда
std::transformлучше ручного циклаfor? - Что такое sentinel-тип в C++20 и зачем он нужен для range-for?
Типичная ошибка кандидата: удалять элементы из контейнера внутри range-based for — это неопределённое поведение. Часто кандидаты знают, что «нельзя», но не могут объяснить, почему: __end вычисляется до начала цикла, erase инвалидирует итераторы, следующий ++__begin ведёт в неопределённую память.