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 — это беззнаковый целочисленный тип, который подходит для размеров и индексов. Поэтому в этом дне мы стараемся писать индексы так же.
Небольшой ориентир:
| Что это | Тип чаще всего |
|---|---|
| индекс символа в строке | |
| длина строки | |
| «количество чего-то» | |
Мини-пример (просто почувствовать тип):
#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(), значит формат плохой, и это надо явно обработать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ