JavaRush /Курсы /C++ SELF /Углубляемся в ловушку сравнений: i < size()

Углубляемся в ловушку сравнений: i < size()

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

1. Откуда берётся ловушка в i < size()

Давайте еще раз пройдеся по проблеме «ловушка сравнений». Создатели библиотеки std хотели как лучше. Как говорится: «хотели как лучше, а получилось как всегда».

Ну сами подумайте: «Сравнить i с size() — это же самое безобидное, что бывает в программировании!» — вы мыслите как нормальный человек. А C++ — как C++: он тоже нормальный, просто у него своя нормальность.

Проблема появляется не потому, что сравнение плохое, и не потому, что if вдруг стал коварным. Проблема появляется из-за неявных преобразований типов: когда в одном выражении встречаются разные целочисленные типы, компилятор пытается привести их к «общему знаменателю». Иногда этот знаменатель оказывается беззнаковым — и тогда отрицательное число превращается… в очень даже положительное.

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

size() и тип std::size_t

Давайте сначала вспомним важный факт: длина строки не бывает отрицательной. Даже если строка — «жизнь программиста в дедлайны» — она всё равно имеет длину >= 0. Поэтому стандартная библиотека возвращает длину типом, который не умеет хранить отрицательные значения: чаще всего это std::size_t.

std::size_t — это беззнаковый целочисленный тип «для размеров и индексов». И вот тут начинается мини-драма: очень часто индекс у новичка хранится в int (потому что «ну это же число»), а размер — в std::size_t (потому что так решила библиотека). Когда они встречаются в сравнении — включается магия приведения типов.

Небольшая табличка, чтобы зафиксировать смысл, а не заучивать названия:

Сущность Типичный тип Может быть отрицательным? Пример смысла
Индекс «позиция в строке» int (часто у новичков) Да «пользователь ввёл -1»
Длина строки std::size_t Нет «строка длины 5»

Если вы хотите увидеть, почему стандарт вообще так делает (и почему разговор про signed/unsigned — это не «душнилово», а техника безопасности), то это напрямую связано с тем, что в языке отдельно выделяют signed и unsigned целые типы.

2. Как неявные преобразования ломают сравнение

Представим типичную задачу: пользователь ввёл индекс, мы хотим проверить, что индекс внутри строки.

Наивный код выглядит очень логично:

#include <iostream>
#include <string>

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

    if (i < s.size()) {
        std::cout << "Index is inside\n";
    } else {
        std::cout << "Index is outside\n";
    }
}

Человек читает это так: «-1 меньше 4? Да. Значит, внутри». Но индекса -1 внутри строки не существует — он должен быть сразу отвергнут.

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

А static_cast<std::size_t>(-1) — это не «-1», а огромное число (по сути, «обёртка» по модулю 2N). В итоге сравнение становится примерно таким по смыслу: «огромное число < 4?» — и ответ уже будет false.

Чтобы это не было магией на уровне «поверь мне», нарисуем мини-схему того, что происходит в голове компилятора:

flowchart TD
    A["Есть выражение: i < s.size()"] --> B["Тип i: int (signed)"]
    A --> C["Тип s.size(): size_t (unsigned)"]
    B --> D["Нужно привести к общему типу"]
    C --> D
    D --> E["i неявно превращается в unsigned"]
    E --> F["Если i было -1, оно становится большим числом"]
    F --> G["Сравнение даёт неожиданный результат"]

Мини-демонстрация: как «-1» превращается в «сюрприз»

Сейчас сделаем маленький эксперимент: превратим -1 в std::size_t вручную и выведем.

#include <cstddef>
#include <iostream>

int main() {
    int i = -1;
    std::size_t u = static_cast<std::size_t>(i);

    std::cout << "i = " << i << '\n'; // i = -1
    std::cout << "u = " << u << '\n'; // u = ... (очень большое число)
}

Конкретное «большое число» зависит от того, сколько бит у std::size_t на вашей платформе, но смысл стабилен: отрицательное значение в беззнаковом типе не хранится как отрицательное, оно переинтерпретируется как большое положительное.

Ключевая мысль здесь простая: проблема не в size(), не в строках и не в сравнении как таковом. Проблема в том, что вы сравниваете числа из разных “миров”: signed-мира и unsigned-мира. А на границе миров стоит компилятор-переводчик, который иногда переводит так, что смысл меняется.

4. Проверяем индекс безопасно

Теперь самая практичная часть: что писать вместо опасного i < s.size().

Если индекс хранится в int, то безопасная проверка почти всегда двухшаговая:

1) сначала убедиться, что индекс не отрицательный;
2) затем сравнить с size() уже в мире std::size_t.

Выглядит так:

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

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

    if (idx >= 0 && static_cast<std::size_t>(idx) < s.size()) {
        std::cout << "ok: " << s[static_cast<std::size_t>(idx)] << '\n';
    } else {
        std::cout << "bad index\n"; // bad index
    }
}

Обратите внимание на порядок в &&. Он здесь не «для красоты»: сначала проверка idx >= 0, и только потом мы делаем static_cast в std::size_t. Это важно, потому что приводить отрицательное к size_t безопасно только в смысле «программа не упадёт», но небезопасно в смысле «логика останется правильной».

Если вам хочется спросить: «А почему нельзя просто сделать static_cast<std::size_t>(idx) < s.size() и всё?» — потому что при idx = -1 вы получите огромное значение, и проверка превратится в бессмыслицу.

Практический пример: мини-программа CharAt

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

Наивная версия

#include <iostream>
#include <string>

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

    int idx = 0;
    std::cin >> idx;

    if (idx < s.size()) {
        std::cout << s[idx] << '\n';
    } else {
        std::cout << "bad index\n";
    }
}

Эта версия компилируется, часто даже «работает» на обычных вводах, и именно поэтому она опасна: баг прячется до первого отрицательного индекса.

Кроме того, даже если вы не вводите отрицательные числа специально, они могут появляться как промежуточный результат вычисления: например, вычитание, смещение, «индекс минус 1».

Безопасная версия

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

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

    int idx = 0;
    std::cin >> idx;

    if (idx >= 0 && static_cast<std::size_t>(idx) < s.size()) {
        std::cout << s[static_cast<std::size_t>(idx)] << '\n';
    } else {
        std::cout << "bad index\n"; // bad index
    }
}

Да, кода стало чуть больше. Зато теперь поведение устойчивое: отрицательные индексы отсекаются, большие индексы отсекаются, и главное — сравнение больше не зависит от «магии» signed/unsigned.

Частая псевдоправка: «давайте сделаем индекс size_t»

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

Но если индекс приходит от пользователя, то есть нюанс: пользователь может ввести -1. И тогда при чтении в size_t получится то же самое большое число, только уже без явного static_cast: всё произойдёт «само».

Посмотрим:

#include <cstddef>
#include <iostream>

int main() {
    std::size_t idx = 0;
    std::cin >> idx;

    std::cout << idx << '\n'; // если ввести -1, увидите большое число
}

То есть вы не избавились от проблемы отрицательных значений — вы просто потеряли возможность их обнаружить как отрицательные. Поэтому практическое правило звучит так: если по смыслу пользователь может ошибиться и ввести отрицательное — принимайте индекс как int, проверяйте idx >= 0, и только потом переводите в size_t.

5. Индексные циклы и выбор типа

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

Очень многие пишут так:

#include <iostream>
#include <string>

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

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

На практике это почти всегда работает, потому что i начинается с 0 и не уходит в минус. Но компилятор может выдавать предупреждение про сравнение signed и unsigned. И компилятор тут не зануда — он как друг, который говорит: «Смотри, сейчас всё нормально, но конструкция потенциально опасная. Ты уверен, что тут никогда не будет минуса?»

Более согласованный вариант — сделать i того же типа, что и size():

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

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

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

Это хороший вариант именно для прямого обхода от 0 вверх. Но помните, что std::size_t — беззнаковый: если вы захотите «пойти назад» и будете писать условия вида i >= 0, вы попадёте в другую ловушку.

Короткий чек-лист для сравнений с size()

Если у вас в сравнении участвует size() (строки или любого контейнера), держите в голове, что это почти наверняка беззнаковый тип. Если второй операнд — int, то при сравнении возможны неявные преобразования, и отрицательные значения могут превратиться в большие положительные.

Поэтому, когда индекс приходит извне или получается вычислением, где возможен минус, лучше сначала проверить >= 0, а затем сравнивать с size() уже после явного static_cast в std::size_t. Это выглядит чуть более многословно, но зато делает намерение очевидным и защищает от багов на границах.

6. Типичные ошибки

Ошибка №1: проверять только idx < s.size(), забывая про idx >= 0.
Это самая частая проблема: условие выглядит математически правдоподобно, но из‑за signed/unsigned приведения начинает жить своей жизнью. В результате отрицательный индекс может либо пройти проверку, либо не пройти «по странной причине» — и оба варианта одинаково плохи, потому что программа перестаёт быть предсказуемой.

Ошибка №2: делать static_cast<std::size_t>(idx) без предварительной проверки idx >= 0.
Такой каст иногда пишут, чтобы «заткнуть предупреждение» компилятора. Но если idx может быть отрицательным, каст не решает проблему, а прячет её: -1 превращается в огромное значение, и сравнение теряет смысл. Явное приведение должно выражать намерение, а не маскировать сомнительный участок.

Ошибка №3: менять тип индекса на std::size_t, когда индекс приходит от пользователя.
Кажется, что так «правильнее», ведь size() тоже size_t. Но при вводе -1 вы не получите «минус один», вы получите большое число, и дальше программа может попытаться работать с этим как с нормальным индексом. Если пользовательский ввод допускает ошибку, удобнее держать его в int, чтобы можно было нормально отсеивать отрицательные значения.

Ошибка №4: использовать int в индексном цикле и игнорировать предупреждения signed/unsigned.
Цикл for (int i = 0; i < s.size(); ++i) обычно работает, но он создаёт «скользкое место» для будущих правок: сегодня i всегда от 0, завтра кто-то добавит i = start - 1, и внезапно поведение станет странным. Гораздо спокойнее, когда тип индекса согласован с типом size() — хотя бы в тех местах, где минус невозможен по логике.

1
Задача
C++ SELF, 8 уровень, 3 лекция
Недоступна
Валидный курсор
Валидный курсор
1
Задача
C++ SELF, 8 уровень, 3 лекция
Недоступна
Символ по адресу
Символ по адресу
1
Задача
C++ SELF, 8 уровень, 3 лекция
Недоступна
Окно фрагмента
Окно фрагмента
1
Задача
C++ SELF, 8 уровень, 3 лекция
Недоступна
Запросы символов
Запросы символов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ