JavaRush /Курсы /C++ SELF /Безопасные циклы: range-for, итераторы, std::ssize

Безопасные циклы: range-for, итераторы, std::ssize

C++ SELF
8 уровень , 5 лекция
Открыта

1. Цикл по контейнеру — не такая простая вещь

Когда новичок видит строку "Hello", возникает естественное желание: “Ну это же пять символов, чего там думать?” И действительно, если бы все строки в мире были длиной 5, программисты жили бы в гармонии, а отладчик ушёл бы на пенсию. Но в реальности строка может быть пустой, может быть длиной 1, а может быть длиной миллион — и именно на границах (0, size()-1, size()) чаще всего рождаются ошибки.

Проблема в том, что цикл — это не только “повторить N раз”, но и договор о границах. В этом договоре участвуют типы (int, std::size_t), выражения (i < size()), а иногда ещё и арифметика (size()-1). Если в договоре есть пункт “а если строка пустая?”, а вы его не читали — вас ждёт сюрприз.

Давайте разложим инструменты обхода контейнера по степени “безопасности для нервной системы”:

Подход Как выглядит Когда хорош Основной риск
range-for
for (char c : s)
когда не нужен индекс индекс недоступен напрямую
Индексы (std::size_t)
for (size_t i=0; i<s.size(); ++i)
когда нужен индекс обратный цикл и size()-1 на пустом
Итераторы
for (auto it=s.begin(); it!=s.end(); ++it)
когда нужен контроль и единый стиль “звёздочка” *it пугает новичков

2. Базовые способы обхода строки

Range-for: самый спокойный способ пройти по строке

range-for (он же “цикл по диапазону”) — это когда вы говорите компилятору: “Дорогой, пройди по всем элементам вот этой штуки, а я в это время буду заниматься полезным делом — например, печатать символы”. Это похоже на ситуацию, когда вы не сами перебираете каждую книгу в библиотеке по индексу полки, а просто идёте вдоль стеллажей и берёте книги по одной: меньше шансов промахнуться мимо последней.

Синтаксис выглядит так:

for (тип_элемента переменная : контейнер) {
    // тело
}
Общая форма range-for

Для строки контейнер — это std::string, а тип элемента — char.

Пример: печатаем символы строки по одному

#include <iostream>
#include <string>

int main() {
    std::string s = "Hello";

    for (char ch : s) {
        std::cout << ch << '\n';
    }
}

Если запустить, вы увидите:

// H
// e
// l
// l
// o

Обратите внимание: тут вообще нет индексов, нет size(), нет опасных сравнений int и std::size_t. Если строка пустая, цикл просто выполнится ноль раз — и это ровно то, что мы хотим.

Пример: считаем количество цифр в строке

#include <iostream>
#include <string>

int main() {
    std::string s = "a1b22c";
    int digits = 0;

    for (char ch : s) {
        if (ch >= '0' && ch <= '9') {
            digits += 1;
        }
    }

    std::cout << digits << '\n'; // 3
}

Важный нюанс, который мы пока не развиваем: range-for умеет работать “по ссылке” (char&), чтобы менять элементы на месте. Но ссылки у нас — отдельная большая тема позже. Сейчас держим range-for как инструмент “прочитать элементы безопасно”.

Индексный цикл и std::size_t: когда нужен номер позиции

Иногда вам мало видеть символ; вам нужен его номер. Например, вы хотите вывести в стиле “0: H”, “1: e”, или проверить “символ на позиции i”. Тут range-for уже не так удобен: он даёт элементы, но не даёт индекс.

И тогда мы возвращаемся к индексному циклу, но делаем это аккуратно: если граница — это s.size(), то и индекс логично хранить в типе, который с этим размером совместим. Обычно это std::size_t.

Пример: печать “индекс: символ” безопасным прямым обходом

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";

    for (std::size_t i = 0; i < s.size(); ++i) {
        std::cout << i << ": " << s[i] << '\n';
    }
}

Вывод:

// 0: a
// 1: b
// 2: c
// 3: d

Здесь важно, что i и s.size() живут в “одном мире типов”. Нет лишних предупреждений, нет неявных преобразований int unsigned прямо внутри сравнения.

Итераторы: универсальный способ обхода

Слово “итератор” звучит так, будто его придумали, чтобы пугать людей на собеседованиях. На практике итератор — это просто “штука, которая указывает на текущий элемент” и умеет сдвигаться на следующий. Это похоже на закладку в книге: закладка показывает текущую страницу, а вы можете перелистнуть дальше.

Почему итераторы вообще нужны? Потому что в C++ стандартная библиотека исторически строится вокруг идеи: “контейнер предоставляет begin() и end(), а дальше ты можешь обходить элементы единым способом”. Даже если контейнер не поддерживает индексы так удобно, как строка.

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

for (auto it = s.begin(); it != s.end(); ++it) {
    char ch = *it;
}

Тут появляется новый символ: *it. Это не умножение. Это операция “взять элемент, на который итератор сейчас указывает”. Да, та же звёздочка, что в математике — поэтому мозг сначала протестует. Но привыкнете: мозг вообще быстро привыкает, особенно когда его регулярно кормят компиляторными ошибками.

Пример: печать символов через итераторы

#include <iostream>
#include <string>

int main() {
    std::string s = "Hi";

    for (auto it = s.begin(); it != s.end(); ++it) {
        std::cout << *it << '\n';
    }
}

Вывод:

// H
// i

Пока что это выглядит как более длинная версия range-for. И это нормально: в базовой версии итераторы действительно “многословнее”. Их сила проявится позже, когда контейнеры станут разнообразнее, а обход будет не только “вперёд до конца”.

3. Обратный обход: где чаще всего ломается unsigned

Обратный цикл — чемпион по количеству ошибок на квадратный сантиметр кода. Причина простая: когда индекс беззнаковый (а std::size_t именно такой), идея “идти вниз до -1” перестаёт работать, потому что “-1” в беззнаковом мире — это не “минус один”, а очень большое число.

Самая распространённая ошибка выглядит так (так делать не надо):

// ПЛОХО: может стать бесконечным циклом
for (std::size_t i = s.size() - 1; i >= 0; --i) {
    std::cout << s[i] << '\n';
}

Проблем тут две. Во-первых, s.size() - 1 ломается на пустой строке. Во-вторых, условие i >= 0 для беззнакового i всегда истинно, потому что “меньше нуля” там не бывает.

Безопасный шаблон: идём от size() и берём i - 1

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";

    for (std::size_t i = s.size(); i > 0; --i) {
        std::size_t idx = i - 1;
        std::cout << s[idx] << '\n';
    }
}

Вывод:

// d
// c
// b
// a

Здесь ключ в условии i > 0: оно реально останавливает цикл. Мы не пытаемся сравнивать i с нулём через >=, мы делаем проверку “пока не дошли до нуля”.

std::ssize: когда нужен signed-размер

Иногда хочется писать обратный цикл в стиле “i — обычный signed-счётчик, идём до 0 включительно”. Но size() возвращает беззнаковый тип, и вот тут появляется аккуратный помощник: std::ssize(...). Он возвращает размер как signed-число (обычно это std::ptrdiff_t), чтобы можно было честно получать значения вроде -1 при “ещё шаг вниз”.

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

Пример: обратный обход через std::ssize с защитой от пустой строки

#include <cstddef>
#include <iostream>
#include <iterator> // std::ssize
#include <string>

int main() {
    std::string s = "abcd";

    if (!s.empty()) {
        for (auto i = std::ssize(s) - 1; i >= 0; --i) {
            std::cout << s[static_cast<std::size_t>(i)] << '\n';
        }
    }
}

Вывод:

// d
// c
// b
// a

Сначала мы проверяем !s.empty(), потому что выражение std::ssize(s) - 1 при пустой строке даст -1, и дальше уже важно не полезть индексировать s[-1].

А вот static_cast<std::size_t>(i) — это честное признание: индексация строки ожидает беззнаковую позицию, а наш i — signed. Мы явно говорим компилятору: “Да, я знаю, что делаю”, и это делается безопасно, потому что i в этом месте гарантированно не отрицательный.

4. Мини‑приложение TextScope: три вида обхода строки

Сейчас хочется сделать что-то практическое, чтобы все эти циклы не выглядели набором заклинаний. Представим, что у нас есть маленькое консольное приложение “TextScope”: оно читает строку и показывает три вещи. Во-первых, печатает символы “как есть” (через range-for). Во-вторых, печатает “индекс: символ” (через индексный цикл). В-третьих, печатает строку в обратном порядке безопасным способом.

Начнём с базового чтения строки. Мы уже знаем, что std::getline удобнее для строк с пробелами.

Шаг 1: читаем строку и печатаем символы через range-for

#include <iostream>
#include <string>

int main() {
    std::string s;
    std::getline(std::cin, s);

    for (char ch : s) {
        std::cout << ch << ' ';
    }
    std::cout << '\n';
}

Если ввести "Hi!", получится:

// H i !

Шаг 2: добавляем печать индексов через std::size_t

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s;
    std::getline(std::cin, s);

    for (std::size_t i = 0; i < s.size(); ++i) {
        std::cout << i << ": " << s[i] << '\n';
    }
}

Если ввести "abc", увидите:

// 0: a
// 1: b
// 2: c

Шаг 3: печать строки в обратном порядке безопасным шаблоном для size_t

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s;
    std::getline(std::cin, s);

    for (std::size_t i = s.size(); i > 0; --i) {
        std::cout << s[i - 1];
    }
    std::cout << '\n';
}

Если ввести "abcd", вывод будет:

// dcba

Заметьте, как аккуратно мы избегаем s.size() - 1 в инициализации счётчика. Мы стартуем с s.size() и отступаем назад.

5. Как выбирать стиль обхода

Иногда вопрос звучит так: “Так чем же пользоваться всегда?” И честный ответ: всегда — ничем. Как в кухне: нож — классный инструмент, но суп ножом есть неудобно (хотя очень уверенно выглядит со стороны).

Если вам нужно просто прочитать каждый элемент и сделать что-то одинаковое, range-for почти всегда самый читаемый вариант. Если вам нужен индекс, а контейнер поддерживает быстрый доступ по [] (например строка), индексный цикл с std::size_t будет прямолинейным. Если вы хотите единый стиль, который одинаково работает для разных контейнеров, или хотите потренироваться в “стандартном” мышлении C++, итераторы — хорошая инвестиция.

В обратных обходах базовый безопасный шаблон для std::size_t обычно легче читать, чем хитрые конструкции, но std::ssize может быть удобен, если вам психологически проще мыслить signed‑счётчиком.

6. Типичные ошибки при обходе контейнеров

Ошибка №1: писать обратный цикл с std::size_t через условие i >= 0.
Это выглядит логично, если думать “как про int”, но для беззнакового типа это условие не выполняет роль стоп‑крана: оно истинно всегда. В итоге цикл либо становится бесконечным, либо уходит в очень большие значения из-за “оборачивания”.

Ошибка №2: делать s.size()-1 без проверки empty().
На непустой строке это нормально, на пустой — вы получаете большое беззнаковое число (потому что 0 - 1 в unsigned‑арифметике уходит “по кругу”). Даже если программа не упадёт сразу, логика становится мусорной.

Ошибка №3: сравнивать signed‑индекс с size() напрямую.
Проверка if (idx < s.size()) при int idx ломается уже на idx = -1: компилятор приводит типы, и отрицательное значение превращается в большое беззнаковое. Правильный шаблон — сначала idx >= 0, потом сравнение через приведение к std::size_t.

Ошибка №4: использовать std::ssize(s) - 1 без защиты от пустой строки.
std::ssize даёт signed‑размер, и это удобно, но если строка пустая, стартовая позиция станет -1. Сама по себе это не ошибка, ошибка начинается, когда вы пытаетесь индексировать строку по -1. Поэтому либо делайте if (!s.empty()), либо стройте цикл так, чтобы он не индексировал при i < 0.

Ошибка №5: путать “итератор” и “индекс” и пытаться сравнивать их напрямую.
Итератор — это не число. Это отдельная сущность, которую сравнивают с end(), а не с 0 или size(). Если вы ловите себя на мысли “а какой у итератора индекс?”, лучше либо перейти на индексный цикл, либо держать отдельный счётчик рядом (но это уже чуть более продвинутая техника).

1
Задача
C++ SELF, 8 уровень, 5 лекция
Недоступна
Расшифровка по буквам
Расшифровка по буквам
1
Задача
C++ SELF, 8 уровень, 5 лекция
Недоступна
Лог с индексами
Лог с индексами
1
Задача
C++ SELF, 8 уровень, 5 лекция
Недоступна
Первая цифра
Первая цифра
1
Задача
C++ SELF, 8 уровень, 5 лекция
Недоступна
Обратная распечатка
Обратная распечатка
1
Опрос
signed/unsigned, 8 уровень, 5 лекция
Недоступен
signed/unsigned
signed/unsigned
Комментарии (5)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Alex Blizz Уровень 15
27 мая 2026
у меня в последней задаче

#include <iostream>
#include <iterator>  // std::ssize
#include <string>

int main() {
    std::string s;
    std::getline(std::cin, s);

    if (s.empty()) {
        std::cout << "EMPTY\n";
        return 0;
    }

    for (int i = static_cast<int>(std::ssize(s) - 1); i >= 0; --i) {
        std::cout << i << ": " << s[static_cast<std::size_t>(i)] << '\n';
    }
    return 0;
}
#include <iterator> - не активен, и ssize() - горит красным, как это исправить?
Alex Blizz Уровень 15
28 апреля 2026
какая разница между

for (std::size_t i = s.size() - 1; i >= 0; --i) {
    std::cout << s[i] << '\n';
}
и

for (std::size_t i = s.size(); i > 0; --i) {
        std::size_t idx = i - 1;
        std::cout << s[idx] << '\n';
    }
и там и там используется индекс

s.size() -1
зачем во втором примере лишняя переменная?
Дмитрий Сысоев Уровень 23
14 апреля 2026
int main() { std::string s = "abcd"; if (!s.empty()) { for (auto i = std::ssize(s) - 1; i >= 0; --i) { std::cout << s[static_cast<std::size_t>(i)] << '\n'; } } } здесь проверка if (!s.empty()) - лишняя. т.к даже при пустой строке условие внутри for(..; i >= 0; ...) не даст сработать циклу
Андрей Асеев Уровень 12
12 апреля 2026
Последняя задача перед опросом выдаёт ошибку: /ru/javarush/cpp/core/level08/task20/solution.cpp: In function ‘int main()’: /ru/javarush/cpp/core/level08/task20/solution.cpp:21:25: error: ‘ssize’ is not a member of ‘std’; did you mean ‘size’? 21 | for (auto i = std::ssize(s) - 1; i >= 0; --i) { | ^~~~~ | size Но проверку проходит.
17 мая 2026
та же беда, и не пойму как поменять стандарт на C++23 :(