Ошибиться в for-циклах довольно просто. К счастью, в C++ появляется все больше и больше вариантов, исключающих ошибки.
Технически можно перебирать последовательность элементов следующим образом:
int i = 0;
ITERATION_STEP:
if (i != records.size())
{
std::println("{}", records[i]);
++i;
goto ITERATION_STEP;
}
Вопреки некоторым ошибочным представлениям, здесь нет неопределённого поведения. При переходе назад никогда не пропускается инициализация или очистка. Мы не рекомендуем использовать goto по другой причине. Это очень низкоуровневый, универсальный и гибкий инструмент. Хотя последние два прилагательных могут показаться положительными, на самом деле это не так. Когда речь идёт о корректности и языковой безопасности, нам важны два свойства:
- Легко ли реализовать нежелательное поведение?
- Легко ли обнаружить непреднамеренные ошибки в коде?
С goto даже в примерах среднего размера слишком легко реализовать что-то непреднамеренное, а затем обнаружить ошибку становится еще сложнее. Напротив, for-цикл предлагает структуру: мы говорим: “Это итерация; ожидайте повторения, условия и инструкции увеличения”. У этих компонентов есть даже свое синтаксическое место.:
for (SETUP ; COND ; INCR)
REPEATED_STATEMENT
Это отражает намерение выполнить итерацию напрямую. Тот, кто читает этот код, сразу понимает, какое выражение повторяется, и может оценить, когда итерация завершится. Кроме того, создается новая область видимости для управляющей переменной.
Этот старый добрый for-цикл — шаг в правильном направлении, но он все еще слишком примитивный, и в нем все еще слишком легко допустить ошибку и не заметить ее. Подумайте вот о чем:
for (auto i = 0; i <= vec.size(); ++i)
use(vec[i]);
или
for (auto i = 0; i != widths.size(); ++i)
for (auto j = 0; j != heights.size(); ++i)
use(widths[i], heights[j]);
Причина этих ошибок одна и та же: классический for-цикл слишком гибок.
- вы можете указать условие, не связанное с циклом,
- можно задать условие, при котором цикл никогда не завершится,
- вы можете указать условие, которое изменит состояние программы,
- чего только нет.
И компилятор не может помешать вам делать все это, потому что, возможно, вы намеренно воспользовались всей этой гибкостью.
Этих проблем не возникает при использовании цикла for на основе диапазона:
for (Record const& rec : records)
use(rec);
Это нововведение в C++11, помимо прочих удобств, является функцией безопасности (в смысле безопасности языка): его очень сложно использовать неправильно. Оно не отличается гибкостью или универсальностью. Вам нужно передать ему диапазон, нужно присвоить имя элементу, на который ссылаетесь на каждом этапе итерации. Здесь нет «управляющей переменной» (такой как i), поэтому вы не можете неправильно выполнить операции с ней. Ряд ошибок можно предотвратить, просто используя цикл на основе диапазона.
Но что, если моя итерация более сложная? Что, если мне нужно перебрать элементы в обратном порядке? Для этого в C++20 появились диапазоны. Вы по-прежнему используете жесткий for-цикл, но задаете другой диапазон, используя исходный:
using std::views::reverse;
for (Record const& rec : reverse(records))
use(rec);
диапазонов есть свои недостатки, но при разумном использовании — без сложных вложенных конструкций — они могут снизить вероятность того, что вы допустите ошибку.
Но что, если мне нужен индекс, потому что я хочу перебирать две последовательности одновременно?
Для этого индекс не нужен. Начиная с C++23 у нас есть zip представление, которое позволяет реализовать именно такой сценарий использования:
using std::views::zip;
for (auto [name, rec] : zip(names, records))
use(name, rec);
Если вы передадите в zip несколько контейнеров одинакового размера, то получите диапазон, тип значения которого — tuple<T1, T2, ...>, где каждый Tn взят из соответствующего диапазона, а тип ссылки — tuple<T1&, T2&, ...>. Это интересный пример, демонстрирующий, почему контейнеры, итераторы и диапазоны имеют связанный тип reference, хотя, казалось бы, value_type& вполне мог бы справиться с этой задачей. Что ж, не мог: в случае с zip_view тип ссылки не является ссылкой!
Но у такого подхода есть и уязвимые места. Мы используем структурированное связывание (квадратные скобки), чтобы присвоить имена отдельным ссылкам на кортеж. Мы получаем кортеж по значению, но поскольку это кортеж ссылок, элементы не копируются. Проблема в том, что, хотя у нас есть имена отдельных элементов, мы не знаем их типов, поэтому можно присвоить неправильные имена неправильным элементам кортежа. Если вы придерживаетесь принципа «почти всегда авто», то не увидите в этом проблемы. Лично я предпочитаю принцип «почти никогда авто». Он требует больше усилий при наборе текста, но позволяет избежать ошибок.
Возвращаясь к идее замены всех циклов на основе индексов на циклы на основе диапазонов, зададимся вопросом: а что, если мне действительно нужен индекс, потому что я хочу его записать или сохранить?
for (int i = 0; i < vec.size(); ++i)
use(i, vec[i]);
В C++ есть ответ на этот вопрос.
Нужно мыслить в терминах диапазонов.
Возможность видеть следующее целочисленное значение на каждом этапе итерации эквивалентна итерации по диапазону {0, 1, 2, …}. В C++ есть представление для этого:iota.
Это
генератор: он не хранит все числа в памяти, а генерирует следующее число на лету.
В нашем случае нам нужен и индекс, и элемент с данными, поэтому мы используемiota иzip:
using std::views::iota;
using std::views::zip;
for (auto [i, rec] : zip(iota(0), records))
use(i, rec);
Первый аргумент, переданный в iota, определяет тип и начальное значение элементов. Следующие значения получаются путем применения оператора ++. iota — это практически бесконечный диапазон, в то время как records будет чем-то небольшим. Здесь мы используем свойство zip, согласно которому оно видит столько элементов, сколько элементов в самом коротком переданном ему диапазоне. Диапазон records короче, чем iota, поэтому он определяет размер заархивированного представления.
В C++23 есть сокращенная запись для приведенного выше примера:
using std::views::enumerate;
for (auto [i, rec] : enumerate(records))
use(i, rec);
В этом случае начальное значение (0) и тип индекса (difference_type of records) остаются неизменными.
Но что, если мой сценарий использования более сложный? Например, когда я определяю следующее значение индекса на основе состояния объекта, проверенного на текущем этапе?
for (int i = 0; i != records.size(); i = records[i])
use(records[i]);
В таком случае у нас нет для вас специального инструмента, и вам придется прибегнуть к более подверженному ошибкам, но в то же время более гибкому регулярномуfor-циклу.
На самом деле в редких случаях вам может понадобиться прибегнуть кgoto.
Для этого они и нужны. Но суть в том, что их следует использовать только в крайнем случае, когда у вас не осталось более надежных инструментов.