1. Introdução
Quando você começa a programar, a string parece algo inteiro: "aqui está o texto, eu o mostrei, feliz". Mas muito rápido fica claro que a string é um container de caracteres, e o sentido aparece só quando sabemos como extrair partes: palavras, comandos, parâmetros. Tokenização é como cortar legumes antes da sopa: se você jogar a cenoura inteira, tecnicamente ela também fica "na sopa", mas será difícil de comer (mais ou menos como fazer parsing da entrada sem dividir em pedaços).
Há também uma razão prática para fazermos isso manualmente. Na biblioteca padrão do C++ não existe uma "função mágica split()" que sempre faça exatamente o que você precisa e seja adequada para um curso introdutório. Houve discussões e propostas para melhorar "divisão de strings" no standard, mas isso não virou um "botão simples" para iniciantes. Portanto hoje vamos aprender a mecânica básica: pegamos uma string e extraímos os tokens nós mesmos, usando loops e substr().
Termos: token, delimitador e limites
Antes de escrever código, é útil concordar sobre as palavras — caso contrário vamos discutir não sobre o programa, mas sobre o significado das palavras, o que é especialmente frustrante quando ainda há o que discutir sobre o programa. Tokenização é o processo de extrair tokens, token é um fragmento de string, e delimitador é o caractere (ou conjunto de caracteres) que separa tokens. E sim: a razão mais comum de bugs em tokenização é sair dos limites da string.
Token — um trecho contínuo da string entre delimitadores. Por exemplo, na string add milk 2 os tokens são add, milk, 2.
Delimitador — aquilo que separa os tokens. Hoje o mais comum é o espaço ' ', às vezes é um caractere como '=' no formato key=value.
Limite da string — a condição i < s.size(). Essa é sua "linha de vida". Quebre-a — e o programa começa a se comportar como um gato que "só queria cheirar a vela".
Índices na string e por que quase sempre usar std::size_t
Quando você percorre a string com um loop, tem um índice. Dá muita vontade de usar int i = 0; — porque parece "mais simples". Mas os métodos da string (size(), find()) usam o tipo std::size_t — um tipo inteiro sem sinal adequado para tamanhos e índices. Por isso, neste material tentamos declarar índices da mesma forma.
Pequeno guia:
| O que é | Tipo mais comum |
|---|---|
| índice de caractere na string | |
| comprimento da string | |
| "quantidade de algo" | |
Mini-exemplo (só para sentir o tipo):
#include <iostream>
#include <string>
int main() {
std::string s = "abc";
std::size_t n = s.size();
std::cout << n << '\n'; // 3
}
substr(pos, count): o segundo parâmetro é o comprimento
Aqui é onde alunos pegam um "bug silencioso": tende-se a pensar que substr(i, j) significa "de i até j". Mas em C++ isso não é verdade.
substr(pos, count) significa: pegar count caracteres começando em pos.
Ou seja, se temos limites de token [i, j) (como em matemática e na maioria dos algoritmos), o comprimento é j - i, e a escrita correta é:
token = s.substr(i, j - i);
Mini-exemplo:
#include <iostream>
#include <string>
int main() {
std::string s = "hello world";
std::cout << s.substr(0, 5) << '\n'; // hello
}
3. Algoritmo básico de tokenização: "pular → capturar → repetir"
Na tokenização manual há uma coisa muito agradável: quase sempre ela se reduz ao mesmo esqueleto. Não tentamos "entender o texto inteiro de uma vez" — movemo-nos da esquerda para a direita, e em cada passo resolvemos duas tarefas. Primeira: pular delimitadores. Segunda: encontrar o limite do token. No final cortamos o token com substr() e seguimos. Isso é parecido com ler texto com os olhos: você não tenta ler a página inteira de uma olhada, você salta de palavra em palavra.
A ideia chave é trabalhar com limites e não acessar s[i] antes de checar i < s.size().
Dois índices: i e j
O modelo mais conveniente para iniciantes é usar dois índices.
i — onde estamos agora.
j — até onde o token "atingiu" (ou seja, a primeira posição após o token).
Então o token é o intervalo [i, j). Assim:
- início do token = i
- fim do token (exclusive) = j
- comprimento do token = j - i
- o token em si = s.substr(i, j - i)
Aqui o esqueleto sem detalhes:
std::size_t i = 0;
while (i < s.size()) {
// 1) pular delimitadores
// 2) encontrar j
// 3) token = substr(i, j - i)
// 4) i = j
}
Mini-diagrama de fluxo do algoritmo
Às vezes é útil ver isso como um processo, e não apenas linhas de código.
flowchart TD
A[Início: i = 0] --> B{"i < s.size()?" }
B -- não --> Z[Fim]
B -- sim --> C["Pulamos delimitadores: enquanto s[i] == ' ' -> i++"]
C --> D{" i >= s.size()? "}
D -- sim --> Z
D -- não --> E["Procuramos fim do token: j = i; enquanto j < size && s[j] != ' ' -> j++"]
E --> F["Cortamos o token: substr(i, j - i)"]
F --> G[Avançamos i = j]
G --> B
4. Divisão por espaços: imprimindo todos os tokens
Vamos começar pelo caso mais comum: tokens separados por espaços. E já concordamos numa coisa importante: não vamos exigir "entrada perfeita". O usuário pode colocar três espaços, pode começar com espaço, pode terminar com espaço — e o programa não deve transformar isso numa tragédia. Nossa tokenização vai primeiro pular espaços, depois ler o token, e assim até o fim.
O exemplo abaixo imprime cada token separadamente. Isso é útil não só como "exercício", mas como ferramenta de debug: se você tem dúvida se entendeu corretamente a entrada, primeiro imprima os tokens.
#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; // pular espaços
if (i >= s.size()) break;
std::size_t j = i;
while (j < s.size() && s[j] != ' ') ++j; // fim do token
std::string token = s.substr(i, j - i);
std::cout << "token=[" << token << "]\n";
i = j;
}
}
Saída esperada:
token=[add]
token=[milk]
token=[2]
Note a "dupla verificação de limites". Verificamos i < s.size() tanto no loop de pular quanto no loop de achar o fim do token. Parece um pouco pedante, mas garante que o programa não cai em strings vazias ou compostas só por espaços.
5. Vários delimitadores: espaços e '='
Agora complicamos um pouco, ainda sem "parsing em fluxo". Imagine um comando como: set user=alice.
Aqui o espaço separa as "partes do comando", e o '=' separa chave e valor dentro de um token. Na prática isso é comum: configs, parâmetros, DSLs simples e até URLs (lá é um pouco mais complicado, mas a ideia é a mesma).
Primeiro extraímos tokens por espaço, depois dividimos um token por '='. Começamos pelo segundo passo — cortar user=alice — fazendo também "manualmente", sem find(), para consolidar a técnica.
#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]
}
}
Neste exemplo tratamos explicitamente o caso "não há delimitador". Isso é importante: mesmo que o formato "deveria" estar correto, o código precisa ter um plano B, caso contrário os usuários vão conseguir inserir algo inesperado. Usuários são como água: em vez de rachaduras procuram bugs.
6. Mini-aplicativo TaskShell: entendendo comandos
Vamos juntar a tokenização numa história clara. Vamos começar um aplicativo de console TaskShell que aceita comandos em uma linha. Por enquanto ele não armazena tarefas (vetores como std::vector virão depois), mas já consegue "entender" comandos e separar parâmetros. Esse é um estágio honesto do desenvolvimento: primeiro você ensina o programa a entender a linguagem de comandos, depois acrescenta a "memória".
Faremos os comandos:
- echo ... — imprime os tokens-argumentos
- set key=value — indica que chave e valor foram reconhecidos
- exit — sair
Lendo a linha de comando
Para tokenizar um comando, é preciso ler a linha inteira. Normalmente usamos std::getline.
#include <iostream>
#include <string>
int main() {
std::string line;
std::getline(std::cin, line);
std::cout << "line=[" << line << "]\n";
}
Se o usuário digitar:
echo hello world
o programa imprimirá:
line=[echo hello world]
Primeiro token — comando, os demais — argumentos
Agora adicionamos o parsing. Importante: não vamos armazenar os argumentos num array (isso fica para quando aprendermos containers). Vamos apenas percorrê-los e imprimi-los.
#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]
}
Agora extraímos apenas o comando. O próximo passo são os argumentos. A lógica é: depois de ler cmd, continuamos a tokenização a partir da posição i = j.
Abaixo — versão que imprime o comando e todos os tokens seguintes:
#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;
}
}
Isso já é praticamente um "impressor universal de tokens". É útil tê-lo na cabeça como verificação mínima: se os tokens são impressos corretamente, você já escreveu metade do parser.
Parse de set key=value em uma só passada
Agora um pedaço de lógica do TaskShell: reconhecer set e tratar o próximo token como key=value. Faremos isso sem armazenar argumentos, lendo apenas o primeiro token e o segundo token.
#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]
}
Agora o segundo passo: dividir arg por '=':
#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
}
}
Sim, o código ficou maior do que "no sonho". Mas esse é o preço honesto pelo entendimento: você está fazendo manualmente o que ferramentas mais avançadas farão depois.
Como tornar a tokenização robusta
Quando as pessoas escrevem tokenização pela primeira vez, costumam fazê-la para "entrada perfeita". Tipo: "vai ter exatamente um espaço, por que verificar". Depois a entrada vem como: " set user=alice ", ou uma linha vazia, e tudo quebra.
Robustez na tokenização é geralmente alcançada por disciplina, não por magia: sempre pular delimitadores com um loop separado, sempre checar i < size, e nunca acessar line[i] sem ter certeza de que i está dentro da string. Mesmo se você mais tarde fizer trim ou colapsar múltiplos espaços, a "tokenização segura" continua valiosa: protege contra casos não previstos e facilita o debug.
Outro ponto importante: tokenização não precisa criar necessariamente uma lista de tokens. Hoje muitas vezes tokenizamos no fluxo, como uma linha de produção: obteve token → processou imediatamente → seguiu em frente. Isso é conveniente em fases iniciais do curso, quando ainda não usamos containers para armazenamento.
7. Erros típicos na tokenização manual
Erro nº1: acessar s[i] sem checar i < s.size().
Esse é o meio mais comum de obter comportamento indefinido e sintomas estranhos. Na tokenização você quase sempre move i, e na última iteração é fácil "ultrapassar" o limite. Regra simples: qualquer acesso a s[i] deve ocorrer apenas depois de verificar o limite, mesmo que pareça "com certeza tem um caractere ali".
Erro nº2: esquecer de pular delimitadores antes de ler o token.
Se você não fizer o passo "pular espaços", numa string com múltiplos espaços você pode obter tokens vazios, um loop infinito, ou ficar chamando substr(i, 0) repetidamente. Um algoritmo bem formado sempre tem um loop separado que move i sobre os espaços.
Erro nº3: confundir substr(i, j - i) e tentar escrever substr(i, j).
Essa é uma armadilha lógica: j normalmente significa "posição após o último caractere do token". Mas substr() espera não a "posição final", e sim o "comprimento". Portanto a expressão correta quase sempre usa j - i. Se você escrever substr(i, j), pode parecer certo em testes curtos mas falhar em dados reais.
Erro nº4: fazer i = j + 1 e esperar que haja sempre só um espaço.
Esse código funciona até a primeira entrada com dois espaços seguidos ou espaço no final. Muito mais seguro é fazer i = j e pular espaços com um loop separado. Assim o número de espaços deixa de ser um problema.
Erro nº5: não tratar o caso "não há delimitador" em key=value.
Se você parseia key=value mas não verifica que '=' foi realmente encontrado, então substr(eq + 1) pode se tornar "cortar depois do fim da string". Às vezes isso resulta em string vazia e erro silencioso; outras vezes em efeitos mais desagradáveis. O cenário correto: se eq == arg.size(), o formato está ruim e isso deve ser tratado explicitamente.
GO TO FULL VERSION