JavaRush /Курсы /C++ SELF /Разбиение строки на токены циклами

Разбиение строки на токены циклами

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

1. Введение

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

Есть и практическая причина, почему мы делаем это вручную. В стандартной библиотеке C++ нет «одной волшебной функции split()», которая всегда делает то, что вам нужно, и к тому же остаётся удобной в учебном курсе. Были отдельные обсуждения и предложения улучшить «разделение строк» в стандарте, но это не превращается в «простую кнопку» для новичка. Поэтому сегодня мы учимся базовой механике: берём строку и выделяем токены сами, используя циклы и substr().

Термины: токен, разделитель и границы

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

Токен — непрерывный кусок строки между разделителями. Например, в строке add milk 2 токены будут add, milk, 2.

Разделитель — то, что токены разделяет. Сегодня чаще всего это пробел ' ', иногда — символ вроде '=' в формате key=value.

Граница строки — условие i < s.size(). Это ваша «линия жизни». Нарушите — и программа начнёт вести себя как кот, который «просто хотел понюхать свечку».

Индексы в строке и почему почти всегда это std::size_t

Когда вы идёте по строке циклом, у вас есть индекс. И очень хочется сделать int i = 0; — потому что так «проще». Но методы строки (size(), find()) работают с типом std::size_t — это беззнаковый целочисленный тип, который подходит для размеров и индексов. Поэтому в этом дне мы стараемся писать индексы так же.

Небольшой ориентир:

Что это Тип чаще всего
индекс символа в строке
std::size_t
длина строки
std::size_t
«количество чего-то»
std::size_t

Мини-пример (просто почувствовать тип):

#include <iostream>
#include <string>

int main() {
    std::string s = "abc";
    std::size_t n = s.size();
    std::cout << n << '\n'; // 3
}

substr(pos, count): второй параметр — это длина

Это место, где студенты регулярно ловят «тихий баг»: хочется думать, что substr(i, j) значит «от i до j». Но в C++ это не так.

substr(pos, count) означает: взять count символов, начиная с pos.

То есть, если у нас есть границы токена [i, j) (как в математике и почти во всех нормальных алгоритмах), то длина токена равна j - i, и правильная запись такая:

token = s.substr(i, j - i);

Мини-пример:

#include <iostream>
#include <string>

int main() {
    std::string s = "hello world";
    std::cout << s.substr(0, 5) << '\n'; // hello
}

3. Базовый алгоритм токенизации: «пропусти → захвати → повтори»

В ручной токенизации есть очень приятная вещь: она почти всегда сводится к одному и тому же каркасу. Мы не пытаемся «понять весь текст сразу» — мы двигаемся по строке слева направо, и на каждом шаге решаем две задачи. Первая: пропустить разделители. Вторая: найти границу токена. В конце мы вырезаем токен через substr() и продолжаем дальше. Это похоже на чтение текста глазами: вы не пытаетесь прочитать всю страницу одним взглядом, вы прыгаете по словам.

Ключевая идея — работать с границами и не читать s[i], пока не проверили i < s.size().

Два индекса: i и j

Самая удобная модель для новичка — два индекса.

i — где мы сейчас находимся.
j — куда «дотянулся» токен (то есть первая позиция после токена).

Тогда токен — это диапазон [i, j). А значит:

  • начало токена = i
  • конец токена (не включая) = j
  • длина токена = j - i
  • сам токен = s.substr(i, j - i)

Вот мини-скелет без деталей:

std::size_t i = 0;
while (i < s.size()) {
    // 1) пропустить разделители
    // 2) найти j
    // 3) token = substr(i, j - i)
    // 4) i = j
}

Мини-блок-схема алгоритма

Иногда полезно один раз увидеть это как процесс, а не как строчки кода.

flowchart TD
    A[Начало: i = 0] --> B{"i < s.size()?" }
    B -- нет --> Z[Конец]
    B -- да --> C["Пропускаем разделители: пока s[i] == ' ' -> i++"]
    C --> D{" i >= s.size()? "}
    D -- да --> Z
    D -- нет --> E["Ищем конец токена: j = i; пока j < size && s[j] != ' ' -> j++"]
    E --> F["Вырезаем токен: substr(i, j - i)"]
    F --> G[Сдвигаем i = j]
    G --> B

4. Разбиение по пробелам: печатаем все токены

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

Пример ниже печатает каждый токен отдельно. Это полезно не только как «задача», но и как отладочный инструмент: если вы сомневаетесь, правильно ли вы понимаете ввод, сначала распечатайте токены.

#include <iostream>
#include <string>

int main() {
    std::string s = "  add   milk   2  ";

    std::size_t i = 0;
    while (i < s.size()) {
        while (i < s.size() && s[i] == ' ') ++i; // пропуск пробелов
        if (i >= s.size()) break;

        std::size_t j = i;
        while (j < s.size() && s[j] != ' ') ++j; // конец токена

        std::string token = s.substr(i, j - i);
        std::cout << "token=[" << token << "]\n";
        i = j;
    }
}

Ожидаемый вывод:

token=[add]
token=[milk]
token=[2]

Обратите внимание на «двойную проверку границ». Мы проверяем i < s.size() и в цикле пропуска, и в цикле поиска конца токена. Это выглядит немного занудно, но зато программа не падает от пустой строки или строки из пробелов.

5. Несколько разделителей: пробелы и '='

Теперь усложним ситуацию, но всё ещё без «потокового парсинга». Представим команду вроде: set user=alice.

Здесь пробел делит строку на «части команды», а '=' делит один токен на ключ и значение. В реальной жизни это встречается постоянно: конфиги, параметры, простые DSL-форматы, и даже URL-строки (там чуть сложнее, но идея та же).

Сначала выделим токены по пробелу, а потом один токен разрежем по '='. Начнём со второго шага — разрезания user=alice, но сделаем это тоже «вручную», без find(), чтобы закрепить навыки.

#include <iostream>
#include <string>

int main() {
    std::string pair = "user=alice";

    std::size_t k = 0;
    while (k < pair.size() && pair[k] != '=') ++k;

    if (k == pair.size()) {
        std::cout << "bad pair\n";
    } else {
        std::string key = pair.substr(0, k);
        std::string value = pair.substr(k + 1);
        std::cout << "key=[" << key << "], value=[" << value << "]\n";
        // key=[user], value=[alice]
    }
}

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

6. Мини-приложение TaskShell: понимаем команды

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

Мы сделаем команды:

  • echo ... — печатает токены-аргументы
  • set key=value — печатает, что ключ и значение распознаны
  • exit — выход

Читаем команду строкой

Чтобы токенизировать команду, её надо прочитать целиком. Обычно для этого нужен std::getline.

#include <iostream>
#include <string>

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

    std::cout << "line=[" << line << "]\n";
}

Если пользователь введёт:

echo hello world

то программа напечатает:

line=[echo hello world]

Первый токен — команда, остальные — аргументы

Теперь добавим разбор. Важно: мы не будем сохранять аргументы в массив (потому что контейнеры — позже). Мы просто пройдёмся по ним и распечатаем.

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = (i < line.size()) ? line.substr(i, j - i) : "";
    std::cout << "cmd=[" << cmd << "]\n"; // cmd=[echo]
}

Сейчас мы добыли только команду. Следующий шаг — аргументы. Логика такая: после того как мы прочитали cmd, мы продолжаем токенизацию с позиции i = j.

Ниже — версия, которая печатает команду и все последующие токены:

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    int token_index = 0;

    while (i < line.size()) {
        while (i < line.size() && line[i] == ' ') ++i;
        if (i >= line.size()) break;

        std::size_t j = i;
        while (j < line.size() && line[j] != ' ') ++j;

        std::string token = line.substr(i, j - i);
        std::cout << token_index << ": [" << token << "]\n";
        // 0: [echo], 1: [hello], 2: [world]
        ++token_index;
        i = j;
    }
}

Это уже практически «универсальный принтер токенов». Его удобно держать в голове как минимальную проверку: если токены печатаются правильно — значит, вы уже наполовину написали парсер.

Разбор set key=value в одном проходе

Теперь сделаем кусок логики TaskShell: распознать set и обработать следующий токен как key=value. Мы сделаем это без хранения аргументов, просто прочитав первый токен и второй токен.

#include <iostream>
#include <string>

int main() {
    std::string line = "set user=alice";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = line.substr(i, j - i);
    i = j;

    while (i < line.size() && line[i] == ' ') ++i;
    j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string arg = (i < line.size()) ? line.substr(i, j - i) : "";

    std::cout << "cmd=[" << cmd << "], arg=[" << arg << "]\n";
    // cmd=[set], arg=[user=alice]
}

А теперь второй шаг: разобрать arg по '=':

#include <iostream>
#include <string>

int main() {
    std::string arg = "user=alice";

    std::size_t eq = 0;
    while (eq < arg.size() && arg[eq] != '=') ++eq;

    if (eq == arg.size()) {
        std::cout << "bad format\n";
    } else {
        std::string key = arg.substr(0, eq);
        std::string value = arg.substr(eq + 1);
        std::cout << "set " << key << " to " << value << '\n';
        // set user to alice
    }
}

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

Как сделать токенизацию устойчивой

Когда люди впервые пишут токенизацию, они часто пишут её под «идеальный ввод». Типа: «ну там же ровно один пробел, зачем проверки». А потом ввод оказывается таким: " set user=alice ", или вообще пустая строка, и всё начинает ломаться.

Устойчивость в токенизации обычно достигается не магией, а дисциплиной: вы всегда пропускаете разделители отдельным циклом, всегда проверяете i < size, и никогда не делаете line[i], если не уверены, что i внутри строки. Даже если потом вы сделаете trim и схлопывание пробелов отдельным шагом, «безопасная токенизация» остаётся полезной: она защищает вас от неучтённых случаев и облегчает отладку.

Ещё один важный момент: токенизация — это не обязательно «сделать список токенов». Сегодня мы часто токенизируем в процессе, как конвейер: получили токен → сразу обработали → пошли дальше. Это удобно именно на ранних этапах курса, пока у нас нет контейнеров для хранения.

7. Типичные ошибки при ручной токенизации

Ошибка №1: доступ к s[i] без проверки i < s.size().
Это самый популярный способ получить неопределённое поведение и очень странные симптомы. В токенизации вы почти всегда двигаете i, и на последней итерации легко «уехать» за границу. Правило простое: любое обращение к s[i] должно происходить только после проверки границы, даже если вам кажется, что «там точно есть символ».

Ошибка №2: забыть пропустить разделители перед чтением токена.
Если вы не делаете шаг «пропускаем пробелы», то на строке с несколькими пробелами вы либо получите пустые токены, либо бесконечный цикл, либо будете вырезать substr(i, 0) снова и снова. В нормальном алгоритме всегда есть отдельный цикл, который двигает i через пробелы.

Ошибка №3: путаница в substr(i, j - i) и попытка написать substr(i, j).
Это логическая ловушка: j в токенизации обычно значит «позиция после последнего символа токена». Но substr() ждёт не «позицию конца», а «длину». Поэтому корректное выражение почти всегда j - i. Если вы написали substr(i, j), строка может выглядеть «почти правильно» на коротких тестах, но ломаться на реальных данных.

Ошибка №4: делать i = j + 1 и надеяться, что пробел всегда один.
Такой код работает ровно до первого ввода с двумя пробелами подряд или пробелом в конце строки. Намного надёжнее делать i = j, а пробелы пропускать отдельным циклом. Тогда количество пробелов вообще перестаёт быть проблемой.

Ошибка №5: не обрабатывать случай «разделителя нет» в key=value.
Если вы разбираете key=value, но не проверяете, что '=' действительно найден, то substr(eq + 1) может превратиться в «отрезать после конца строки». Иногда это даст пустую строку и тихую ошибку, а иногда — более неприятные эффекты. Корректный сценарий: если eq == arg.size(), значит формат плохой, и это надо явно обработать.

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