Перейти к основному содержимому

5.06. Циклы C++

Разработчику Архитектору

Циклы в C++

Циклические конструкции — одна из фундаментальных управляющих структур любого императивного языка программирования, и C++ не является исключением. Циклы позволяют многократно выполнять блок инструкций до тех пор, пока выполняется заданное условие. Их корректное применение напрямую влияет на выразительность, надёжность и эффективность программного кода. В отличие от функциональных языков, где итерация часто выражается через рекурсию или функции высшего порядка, в C++ предпочтение отдаётся императивным, явным циклам, подчёркивающим поток управления и временные зависимости между операциями.

С точки зрения семантики, цикл в C++ — это синтаксическая обёртка над переходами управления, реализуемых на уровне машинного кода (в частности, через безусловные и условные переходы — jmp, jne, jl и т.п.). Компилятор транслирует циклические конструкции в последовательности инструкций, при этом стремясь оптимизировать тело цикла и саму схему итерации — например, вынося неизменные выражения за пределы цикла, развёртывая тело при известной длине, или заменяя цикл на векторизованные инструкции SIMD.

Общие принципы и терминология

Любой цикл в C++ характеризуется следующими компонентами:

  • Инициализация — выражение или инструкция, выполняемая один раз до входа в цикл. Служит для подготовки переменных состояния (например, счётчиков, итераторов, флагов).
  • Условие продолжения — логическое выражение, проверяемое перед (в for и while) или после (в do-while) каждой итерации. Является основным механизмом контроля продолжительности цикла.
  • Обновление состояния — действие, изменяющее переменные, от которых зависит условие. Обычно это инкремент, декремент, сдвиг итератора или изменение флага. Нарушение инварианта обновления — частая причина бесконечных циклов.
  • Тело цикла — последовательность операторов, выполняемых на каждой итерации. Может быть пустым (в том числе намеренно), но в подавляющем большинстве случаев содержит полезную логику: обработку данных, модификацию структур, вывод, вызов функций, изменение состояния программы.

Важно подчеркнуть, что в C++ циклы не вводят собственную область видимости по умолчанию — за исключением тех случаев, когда переменная объявляется непосредственно в заголовке цикла for. Это свойство имеет прямые последствия для жизненного цикла переменных, их повторного использования и потенциальных утечек состояния между итерациями.


Традиционный цикл for

Цикл for — наиболее универсальная и гибкая форма итерации в C++. Его синтаксис имеет три явно выделяемых части, разделённых точкой с запятой:

for (инициализация; условие; обновление) {
// тело цикла
}

Классический пример — итерация с целочисленным счётчиком:

for (int i = 0; i < 5; i++) {
std::cout << i << std::endl;
}

Здесь:

  • int i = 0 — инициализация: объявление и присваивание начального значения переменной i в локальной области видимости цикла;
  • i < 5 — условие продолжения: цикл будет выполняться, пока i строго меньше пяти;
  • i++ — обновление: постфиксный инкремент, выполняемый после каждой итерации тела;
  • Тело цикла выводит текущее значение i.

Семантически, компилятор преобразует for в эквивалентную последовательность while, но с важным отличием: переменные, объявленные в секции инициализации, видны только внутри цикла, включая все три секции и тело. Это свойство повышает безопасность — предотвращает случайное использование счётчика после завершения цикла в неопределённом состоянии, а также позволяет повторно использовать идентификаторы в разных циклах без коллизий.

Любая из трёх секций for может быть опущена — при этом точка с запятой остаётся обязательной. Например:

int i = 0;
for (; i < 5; ) {
std::cout << i++ << std::endl;
}

Этот код эквивалентен циклу while, но демонстрирует, что for не привязан к наличию счётчика — он лишь предоставляет удобную структуру для группировки трёх логических фаз итерации.

Инициализация for поддерживает множественные объявления через запятую, но с ограничением: все объявленные переменные должны иметь один и тот же тип. Например, допустимо:

for (int i = 0, j = 10; i < j; i++, j--) {
// ...
}

Но следующее приведёт к ошибке компиляции:

// Ошибка: нельзя объявить int и double в одной секции инициализации
for (int i = 0, double x = 1.0; i < 10; i++) { /* ... */ }

Обход этого ограничения возможен через std::tie, структуры или вынос переменных за пределы цикла — однако такие приёмы снижают читаемость и обычно указывают на чрезмерную сложность итерационной логики. В таких случаях рекомендуется рассмотреть рефакторинг в пользу более декларативного подхода (например, инкапсуляция в функцию-итератор или использование алгоритмов стандартной библиотеки).

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

Критически важно понимать порядок выполнения:

  1. Выполняется инициализация (один раз).
  2. Проверяется условие:
    • если ложно — цикл завершается, управление переходит к следующему оператору;
    • если истинно — выполняется тело цикла.
  3. После завершения тела, выполняется обновление.
  4. Переход к шагу 2.

Любой оператор управления потоком (break, continue, return, goto, throw) может прервать этот порядок. В частности, continue передаёт управление непосредственно к обновлению, минуя оставшуюся часть тела. Это поведение отличается от некоторых других языков (например, Python), где continue переходит к проверке условия — в C++ оно строго соответствует семантике «перейти к следующей итерации», а «следующая итерация» начинается с обновления.


Цикл while

Цикл while представляет собой минимальную форму итерации с проверкой условия до выполнения тела. Его синтаксис:

while (условие) {
// тело цикла
}

Эквивалент циклу for без явной инициализации и обновления:

int i = 0;
while (i < 5) {
std::cout << i << std::endl;
i++;
}

Здесь переменная i объявлена до цикла и остаётся доступной после его завершения — в отличие от аналога в for. Это может быть как преимуществом (например, для проверки финального состояния), так и источником ошибок (повторное использование без переинициализации).

Цикл while особенно уместен, когда:

  • количество итераций заранее неизвестно и зависит от внешних факторов (ввод пользователя, состояние файла, сетевой пакет);
  • обновление переменных состояния распределено по телу (например, чтение данных в буфер с последующей его обработкой);
  • логика итерации не сводится к линейному изменению одного счётчика.

Важный момент: условие while должно быть выражением, приводимым к bool. В C++ разрешено использовать любые скалярные типы (int, указатели, перечисления), где нулевое значение интерпретируется как false, а ненулевое — как true. Однако для повышения ясности и избежания неочевидных побочных эффектов рекомендуется использовать явные булевы выражения или приведения.

Пример потенциально опасного использования:

FILE* f = fopen("data.txt", "r");
char buffer[256];
while (fgets(buffer, sizeof(buffer), f)) { // неявное приведение указателя к bool
process_line(buffer);
}
fclose(f);

Хотя такой код корректен, он полагается на соглашение: fgets возвращает ненулевой указатель при успехе и nullptr при ошибке или достижении конца файла. Для современного C++ предпочтительнее использовать RAII-обёртки (например, std::ifstream) и алгоритмы, но в legacy-коде подобные конструкции встречаются часто.


Цикл do-while

Цикл do-while — единственная конструкция в C++, где проверка условия происходит после выполнения тела. Синтаксис:

do {
// тело цикла
} while (условие);

Обратите внимание на обязательную точку с запятой после закрывающей скобки — её пропуск является частой синтаксической ошибкой, особенно у программистов, пришедших из языков без этого требования (например, Python или JavaScript без ; в ASI-режиме).

Семантика гарантирует, что тело цикла выполнится как минимум один раз, даже если условие изначально ложно. Это делает do-while естественным выбором для сценариев, где требуется предварительное действие перед проверкой — например, ввод данных с подтверждением, инициализация ресурса с последующей проверкой корректности, или реализация конечных автоматов с начальным состоянием.

int input;
do {
std::cout << "Введите число от 1 до 10: ";
std::cin >> input;
} while (input < 1 || input > 10);

В данном примере пользователь не может «проскочить» ввод — цикл выполнится минимум раз, а повтор будет происходить до тех пор, пока условие не станет ложным (то есть пока введённое значение не окажется в допустимом диапазоне).

Несмотря на кажущуюся простоту, do-while требует особой внимательности при анализе кода:

  • Переменные, используемые в условии, должны быть инициализированы до входа в цикл или в его теле — иначе возможна неопределённая инициализация.
  • Выход из цикла происходит только по завершении тела, что может привести к выполнению нежелательных побочных эффектов даже при заведомо ложном условии (если оно вычисляется на основе внешнего состояния, изменённого в теле).

Сравнительно редкое применение do-while в современном C++ объясняется тем, что многие задачи, решаемые через него, могут быть выражены через while с дублированием инициализации или через функции с ранним возвратом. Однако в системном программировании, драйверах, парсерах и играх этот цикл остаётся востребованным.


Range-based for loop: итерация как абстракция

Начиная с C++11, в язык была введена конструкция range-based forfor, основанный на диапазоне. Эта форма цикла представляет собой важный шаг в сторону декларативного стиля программирования: вместо того чтобы управлять счётчиками или итераторами вручную, программист объявляет намерение — «перебрать все элементы этой коллекции» — и делегирует детали реализации компилятору.

Синтаксис:

for (объявление : выражение_диапазона) {
// тело цикла
}

Классический пример:

std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
std::cout << num << std::endl;
}

С первого взгляда — лаконичность и прозрачность. Однако за этой простотой скрывается строгая и гибкая семантическая модель, основанная на итерируемости, а не на конкретных типах.

Как работает range-based for

Компилятор не обрабатывает range-based for как встроенную «магию». Вместо этого он транслирует его в эквивалентный код с использованием begin() и end()свободных функций или методов-членов соответствующего типа. Это ключевой момент: поддержка range-based for не требует наследования от какого-либо интерфейса или реализации конкретного класса. Достаточно, чтобы для типа T, на который ссылается выражение_диапазона, существовали допустимые перегрузки begin(T) и end(T), возвращающие объекты, удовлетворяющие требованиям итератора (в широком смысле — см. ниже).

Формально, цикл:

for (init-statement? type var : range) {
// body
}

(где init-statement — дополнение C++17, о нём позже)
раскрывается компилятором в следующую форму:

{
auto && __range = range;
auto __begin = begin-expr;
auto __end = end-expr;
for (; __begin != __end; ++__begin) {
type var = *__begin;
// body
}
}

Здесь:

  • __range, __begin, __end — внутренние имена, недоступные пользователю;
  • begin-expr и end-expr определяются согласно правилу поиска:
    1. Если range имеет метод .begin() и .end(), они вызываются в приоритете;
    2. Иначе — ищутся свободные функции begin(range) и end(range) в том же пространстве имён (ADL — argument-dependent lookup);
    3. Для встроенных массивов — используются встроенные перегрузки std::begin, std::end.

Таким образом, range-based for — это синтаксический сахар над идиомой «получить итератор начала и конца, и пройти от одного к другому». Эта модель позволяет легко интегрировать пользовательские типы — например, деревья, потоки, ленивые последовательности — без изменения ядра языка.

Требования к итераторам

Для корректной работы range-based for необходимо, чтобы возвращаемые begin() и end() объекты поддерживали:

  • оператор != (для условия продолжения),
  • унарный ++ (префиксный инкремент — для продвижения),
  • унарный * (разыменование — для получения значения).

Это минимальный набор, соответствующий Input Iterator в терминах STL (до C++20) или weakly incrementable + indirectly readable в терминах Concepts (C++20). Важно: range-based for не требует поддержки двунаправленной или случайной навигации — только последовательного продвижения вперёд. Это делает его применимым даже к таким структурам, как односвязный список (std::forward_list) или итератор по файлу, читающий по строке.

Управление семантикой копирования и ссылок

Одна из самых частых ошибок при использовании range-based for — непреднамеренное копирование элементов. Рассмотрим:

std::vector<std::string> lines = {"alpha", "beta", "gamma"};
for (std::string s : lines) {
s += " (modified)";
std::cout << s << std::endl; // "alpha (modified)" и т.д.
}
// lines остаётся неизменённым: ["alpha", "beta", "gamma"]

Здесь sкопия элемента контейнера. Изменения в теле цикла не затрагивают исходные данные. Это может быть желаемым поведением (например, при обработке без побочных эффектов), но часто — ошибкой.

Для модификации оригинальных элементов следует использовать ссылку:

for (std::string& s : lines) {
s += " (changed)";
}
// Теперь lines: ["alpha (changed)", "beta (changed)", "gamma (changed)"]

Если модификация не предполагается, но копирование дорого (например, для больших объектов), предпочтителен const&:

for (const std::string& s : lines) {
std::cout << s.size() << ": " << s << std::endl;
}

Это избегает копирования и гарантирует неизменяемость. Правило хорошего тона: всегда явно указывайте & или const&, если не уверены, что копирование лёгкое и намеренное.

Начиная с C++17, допустимо использовать auto в объявлении переменной цикла:

for (const auto& item : container) { /* ... */ }

Это особенно полезно при работе с шаблонными контейнерами или сложными типами (std::map<int, std::vector<std::pair<std::string, double>>>). Однако злоупотребление auto без & также ведёт к неожиданным копиям:

std::vector<std::unique_ptr<Resource>> resources;
// ОШИБКА: unique_ptr некопируемый → компиляция упадёт
for (auto ptr : resources) { /* ... */ }

// Правильно: ссылка
for (const auto& ptr : resources) { /* только чтение */ }
for (auto& ptr : resources) { /* модификация через умный указатель */ }

C++17: инициализация в заголовке цикла

C++17 расширил синтаксис range-based for, добавив возможность объявления переменной до диапазона — аналогично init-statement в if и switch. Это позволяет локализовать временные объекты, используемые только для итерации:

for (std::vector<int> data = compute_data(); const auto& x : data) {
process(x);
}
// data уничтожается сразу после цикла

Практическая польза:

  • избегается утечка временного объекта в охватывающую область;
  • упрощается управление временем жизни (RAII внутри цикла);
  • повышается читаемость за счёт группировки логически связанных операций.

Альтернатива без этой функции — объявление data выше цикла, что увеличивает область видимости и потенциальный «шум» в коде.

Взаимодействие с std::initializer_list

Литералы в фигурных скобках ({1, 2, 3}) имеют тип std::initializer_list<T>, который сам по себе является итерируемым диапазоном. Это позволяет писать:

for (int x : {1, 3, 5, 7, 9}) {
std::cout << x << " ";
}

Компилятор автоматически создаёт временный initializer_list<int>, для которого begin() и end() возвращают указатели на внутренний массив. Такие циклы эффективны и часто используются для простых переборов без необходимости объявления контейнера.

Важно: initializer_list хранит только ссылки на элементы (точнее — указатели на временный массив), поэтому его нельзя использовать для захвата изменяемых локальных переменных по значению в лямбдах или для долгоживущих диапазонов.


Сравнение с традиционными циклами: когда что использовать

Выбор между for, while, do-while и range-based for — это решение, основанное на:

  • знании количества итераций,
  • структуре данных,
  • требованиях к производительности,
  • гарантиям безопасности,
  • поддерживаемости кода.

Вот практические рекомендации:

СценарийПредпочтительная конструкцияОбоснование
Итерация по стандартному контейнеру (вектор, список, множество) без изменения индекса в процессеrange-based forМинимизирует ошибки (выход за границы, некорректный инкремент), выражает намерение наиболее чётко, совместим с алгоритмами (std::for_each).
Нужен индекс и значение (например, для параллельной обработки с offset’ом)Традиционный for с size_t i или std::views::enumerate (C++23)range-based for сам по себе не предоставляет индекс; явный счётчик контролируем.
Количество итераций неизвестно заранее (ввод, сетевой поток, состояния автомата)whileЕстественно отражает «пока условие истинно — делай».
Требуется гарантированное однократное выполнение перед проверкойdo-whileЕдинственная конструкция с пост-условием.
Работа с C-массивами фиксированного размераrange-based for (предпочтительно) или for с std::sizerange-based for работает с массивами «из коробки»; std::size(arr) безопаснее sizeof(arr)/sizeof(arr[0]).
Модификация контейнера во время итерации (например, удаление элементов)while с итераторами и eraserange-based for не предназначен для изменения структуры диапазона — приведёт к неопределённому поведению.

Критически важно: range-based for не поддерживает изменение структуры контейнера во время итерации. Даже если std::vector::erase возвращает валидный итератор, внутренняя реализация цикла не может его использовать — он полагается на фиксированные __begin и __end. Попытка вызвать vec.erase(it) изнутри for (auto& x : vec) приведёт к разыменованию инвалидированного итератора.

Корректный подход — использовать явный итераторный цикл:

for (auto it = vec.begin(); it != vec.end(); ) {
if (should_remove(*it)) {
it = vec.erase(it); // erase возвращает следующий валидный итератор
} else {
++it;
}
}

Или, лучше — алгоритм std::erase_if (C++20):

std::erase_if(vec, [](const auto& x) { return should_remove(x); });

Это безопаснее и выразительнее: логика фильтрации отделена от механики итерации.


Эволюция: от C++11 к C++20 и std::ranges

C++20 ввёл концепцию диапазонов (std::ranges) — фундаментальную перестройку подхода к последовательной обработке данных. Хотя range-based for не требует std::ranges для работы, он стал её естественным пользовательским интерфейсом.

В частности:

  • Любой range (тип, удовлетворяющий std::ranges::range) автоматически совместим с range-based for.
  • Появились ленивые представления (views), такие как std::views::filter, std::views::transform, которые можно комбинировать в цепочки без промежуточных контейнеров:
std::vector<int> data = {1, 2, 3, 4, 5, 6};
auto evens_squared = data
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });

for (int val : evens_squared) {
std::cout << val << " "; // 4 16 36
}

Здесь evens_squaredадаптер итерации. Никакой памяти под квадраты чётных чисел не выделяется до момента фактического разыменования в цикле. Это резко снижает потребление памяти и позволяет работать с потенциально бесконечными последовательностями.

Даже без std::ranges, range-based for остаётся мощным инструментом. Но с C++20 он встраивается в более широкую экосистему построения конвейеров обработки данных, где цикл — лишь конечная точка потребления.


Типичные ошибки и антипаттерны

  1. Копирование вместо ссылки
    → Используйте const auto& по умолчанию, если не требуется копия.

  2. Изменение контейнера внутри range-based for
    → Приводит к неопределённому поведению. Используйте алгоритмы или явные итераторы.

  3. Смешение знаковых и беззнаковых типов в счётчиках

    std::vector<int> v(10);
    for (int i = 0; i < v.size(); ++i) { /* ... */ } // предупреждение: сравнение int и size_t

    → Лучше size_t i, или std::ssize(v) (C++20) для знакового размера.

  4. Предположение о порядке итерации
    Для std::set, std::map — упорядоченно (по ключу);
    Для std::unordered_set, std::unordered_mapнеопределённо.
    → Не полагайтесь на порядок в хеш-контейнерах.

  5. Использование auto без & для полиморфных объектов

    std::vector<std::unique_ptr<Base>> items;
    for (auto item : items) { /* срезка! */ }

    → Всегда ссылка: const auto& item.


Обработка исключений внутри циклов

Циклы и исключения — две мощные, но потенциально конфликтующие управляющие структуры. Поведение программы при возникновении исключения в теле цикла регулируется правилами раскрутки стека (stack unwinding), определёнными в стандарте C++. Корректное применение этих правил критично для обеспечения безопасности ресурсов и предсказуемости состояния программы.

Что происходит при throw в теле цикла?

Когда исключение выбрасывается внутри тела цикла (независимо от типа — for, while, do-while, range-based for), выполнение немедленно прерывается. Управление передаётся ближайшему обработчику catch, а перед этим выполняется раскрутка стека:
— вызываются деструкторы всех автоматических объектов, созданных в текущей области видимости и во всех охватывающих областях до точки входа в try;
— состояние циклических переменных (счётчики, итераторы, локальные объекты) при этом не сохраняется — цикл завершается аварийно, как если бы был прерван break, но с дополнительной семантикой RAII.

Пример:

std::vector<Resource> resources(5);
try {
for (auto& r : resources) {
r.acquire(); // может выбросить исключение
process(r); // может выбросить исключение
r.release(); // если process выбросил — release не вызовется!
}
} catch (...) {
// Обработка ошибки
}

Здесь, если process(r) выбросит исключение на третьей итерации, r.release() для этого объекта не выполнится — ресурс останется захваченным. Это нарушает принцип RAII.

Как обеспечить безопасность?

  1. Инкапсуляция ресурса в RAII-обёртку
    Самый надёжный подход — объект должен управлять своим состоянием в деструкторе:

    class ScopedResource {
    Resource* ptr;
    public:
    ScopedResource(Resource& r) : ptr(&r) { ptr->acquire(); }
    ~ScopedResource() { if (ptr) ptr->release(); }
    Resource& get() { return *ptr; }
    // запрет копирования, перемещение — по необходимости
    };

    for (auto& r : resources) {
    ScopedResource guard(r); // acquire в конструкторе
    process(guard.get()); // если исключение — деструктор вызовет release
    } // guard уничтожается на каждой итерации
  2. Локальный try-catch внутри цикла
    Если ошибка на одной итерации не должна прерывать остальные:

    for (size_t i = 0; i < resources.size(); ++i) {
    try {
    resources[i].use();
    } catch (const std::exception& e) {
    log_error("Iteration ", i, " failed: ", e.what());
    // продолжаем со следующей итерации
    }
    }

    Здесь исключение локализуется, раскрутка не выходит за пределы итерации. Деструкторы локальных объектов текущей итерации вызываются, цикл продолжает работу.

  3. noexcept и гарантии завершения
    Если логика тела цикла критична и не должна прерываться (например, освобождение ресурсов в деструкторе), следует помечать такие функции как noexcept. Компилятор может применить более агрессивные оптимизации, а стандартная библиотека — выбирать более эффективные реализации (например, std::vector::resize использует memcpy, а не поэлементное копирование, если деструктор noexcept).

Важно: break, continue, return внутри catch, вложенного в цикл, ведут себя как обычно — передают управление за пределы try, но остаются в рамках итерационной структуры. Только неперехваченное исключение полностью прерывает цикл.


Циклы и многопоточность

В многопоточной среде циклы приобретают новое измерение: помимо логики итерации, возникает вопрос распараллеливания. C++ предоставляет как низкоуровневые, так и высокоуровневые средства для этого.

OpenMP (вне стандарта, но широко поддерживаем)

Хотя OpenMP не является частью ISO C++, он интегрирован в GCC, Clang, MSVC и часто используется в HPC. Простейшая директива:

#pragma omp parallel for
for (int i = 0; i < N; ++i) {
result[i] = heavy_computation(i);
}

Компилятор автоматически разделяет итерации между потоками пула. Требования:

  • итерации должны быть независимы (нет гонок по данным);
  • переменная цикла — целочисленная, с шагом ±1;
  • N должно быть вычислимо до входа в цикл.

Нарушение этих условий ведёт к неопределённому поведению. Например, следующий код некорректен:

int sum = 0;
#pragma omp parallel for
for (int i = 0; i < N; ++i) {
sum += data[i]; // гонка за sum!
}

Правильное решение — редукция:

int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < N; ++i) {
sum += data[i];
}

Здесь компилятор создаёт локальную копию sum для каждого потока и объединяет результаты в конце.

std::execution (C++17, уточнено в C++20)

Стандартная библиотека предлагает декларативный способ указать политику выполнения для алгоритмов:

std::vector<int> data(1'000'000);
std::for_each(std::execution::par, data.begin(), data.end(),
[](int& x) { x *= 2; });

Хотя это не цикл в синтаксическом смысле, он заменяет императивный for. Преимущество — компилятор и библиотека могут адаптировать стратегию (параллельно/последовательно/векторизованно) в зависимости от данных, платформы и настроек.

Можно ли использовать range-based for с execution::par?
Напрямую — нет. range-based for — это синтаксис, не ассоциированный с алгоритмами. Но его легко заменить на std::ranges::for_each (C++23):

std::ranges::for_each(data, std::execution::par,
[](int& x) { x *= 2; });

Это сочетает выразительность диапазонов с мощью параллелизма.

Низкоуровневый контроль: std::thread и разделение диапазона

При необходимости полного контроля (например, балансировка нагрузки, зависимые задачи) приходится вручную разбивать диапазон:

const size_t n_threads = std::thread::hardware_concurrency();
const size_t chunk = (N + n_threads - 1) / n_threads; // ceil(N / n_threads)

std::vector<std::thread> threads;
for (size_t t = 0; t < n_threads; ++t) {
size_t start = t * chunk;
size_t end = std::min(start + chunk, N);
threads.emplace_back([&, start, end]{
for (size_t i = start; i < end; ++i) {
compute(i);
}
});
}
for (auto& th : threads) th.join();

Здесь цикл for (size_t i = start; i < end; ++i)локальный в каждом потоке. Это безопасно, если compute(i) не модифицирует общие данные без синхронизации.

Рекомендация: предпочитайте std::execution и алгоритмы явному управлению потоками. Явные std::thread оправданы только при сложной логике координации (например, конвейерная обработка, work-stealing).


Оптимизации компилятора: что происходит «под капотом»

Компилятор рассматривает циклы как главную точку приложения оптимизаций. Знание этих механизмов помогает писать код, который не мешает компилятору, а, напротив, помогает ему генерировать эффективный машинный код.

Loop-invariant code motion (вынос инвариантов)

Если выражение в теле цикла не зависит от переменных итерации, компилятор выносит его за пределы:

for (int i = 0; i < n; ++i) {
double factor = std::sqrt(2.0); // инвариант
result[i] = data[i] * factor;
}

→ оптимизируется в:

double factor = std::sqrt(2.0);
for (int i = 0; i < n; ++i) {
result[i] = data[i] * factor;
}

Рекомендация: не пытайтесь «помочь» компилятору, вынося всё вручную — это снижает читаемость. Доверяйте оптимизатору, но избегайте скрытых побочных эффектов (например, вызова функции с состоянием в инварианте).

Loop unrolling (развёртывание цикла)

Компилятор может заменить цикл на N копий тела, чтобы уменьшить накладные расходы на проверку условия и инкремент:

for (int i = 0; i < 4; ++i) result[i] = data[i] * 2;

→ может стать:

result[0] = data[0] * 2;
result[1] = data[1] * 2;
result[2] = data[2] * 2;
result[3] = data[3] * 2;

Для больших N применяется частичное развёртывание (например, по 4 итерации за раз). Эффективно при работе с векторными типами.

Векторизация (SIMD)

Современные процессоры поддерживают инструкции SIMD (Single Instruction, Multiple Data) — например, AVX, NEON. Компилятор может автоматически заменить скалярный цикл на векторный:

for (int i = 0; i < n; ++i) {
a[i] = b[i] + c[i];
}

→ может скомпилироваться в последовательность vaddps (сложение 8 float за такт на AVX-512).

Условия для векторизации:

  • отсутствие зависимостей по данным (каждая итерация независима);
  • выравнивание памяти (компилятор часто вставляет пролог/эпилог для выравнивания);
  • простые арифметические операции;
  • известная или предсказуемая длина.

Подсказка компилятору:
-O3 -march=native в GCC/Clang;
#pragma omp simd (OpenMP);
[[gnu::optimize("tree-vectorize")]] (атрибуты, осторожно!).

Loop interchange и cache blocking

При работе с многомерными массивами порядок вложенных циклов критичен для локальности кэша:

// Плохо: stride-доступ по строкам (если matrix[i][j] — row-major)
for (int j = 0; j < N; ++j)
for (int i = 0; i < N; ++i)
sum += matrix[i][j];

// Хорошо: последовательный доступ
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
sum += matrix[i][j];

Компилятор может автоматически менять порядок вложенных циклов (loop interchange), но не всегда — особенно если есть побочные эффекты или неизвестные зависимости. Явное соблюдение row-major порядка — простая, но мощная оптимизация.


Создание пользовательских итерируемых типов

Чтобы собственный класс поддерживал range-based for, достаточно реализовать пару begin()/end(). Это может быть сделано двумя способами.

Методы-члены

class Counter {
int start_, end_;
public:
Counter(int s, int e) : start_(s), end_(e) {}

struct Iterator {
int value;
bool operator!=(const Iterator& other) const { return value != other.value; }
int operator*() const { return value; }
Iterator& operator++() { ++value; return *this; }
};

Iterator begin() const { return {start_}; }
Iterator end() const { return {end_}; }
};

// Использование:
for (int x : Counter(1, 5)) {
std::cout << x << " "; // 1 2 3 4
}

Свободные функции и ADL

Если класс закрыт (например, сторонняя библиотека), можно определить begin/end в том же пространстве имён:

namespace third_party {
struct LegacyList { /* ... */ };
}

// В своём пространстве имён:
namespace third_party { // для ADL
auto begin(LegacyList& lst) { return /* итератор начала */; }
auto end(LegacyList& lst) { return /* итератор конца */; }
}

Теперь for (auto& x : lst) будет работать.

Требования к итератору (минимальные)

Для range-based for достаточно:

  • operator*() → любой тип (не обязательно ссылка);
  • operator++() (префиксный);
  • operator!= (или operator==, если есть перегрузка для != по умолчанию).

Постфиксный operator++, operator->, operator--, operator+, operator[]не обязательны. Это позволяет создавать лёгкие, специализированные итераторы без «тяжёлого» наследования от std::iterator (устаревшего с C++17).