O que "comportamento indefinido" realmente significa
A página anterior mostrou como try/catch lida com os erros que o seu programa define e lança de propósito. O comportamento indefinido é o oposto: é o conjunto de operações às quais o padrão de C++ se recusa a dar qualquer significado. Não há exceção para capturar, nem código de erro, nem garantia de travamento. O compilador é livre para assumir que o UB nunca acontece e fazer o que quiser quando ele acontece.
Essa liberdade é o que torna o UB tão perigoso. A mesma linha defeituosa pode imprimir a resposta "certa" no seu notebook, retornar lixo em um servidor e ser completamente removida pelo otimizador em -O2. UB não é "comportamento que não documentamos", e sim "comportamento sobre o qual a linguagem não promete nada". Seu trabalho é nunca escrevê-lo.
int arr[3] = {1, 2, 3};
int x = arr[5]; // comportamento indefinido: ler alem do fim do array
Não há erro de compilação aqui, e em muitas execuções ele simplesmente vai te entregar um inteiro perdido. Esse sucesso aparente é a armadilha.
Ler ou escrever fora dos limites
A forma mais comum de UB é tocar uma memória que não pertence a você. Os arrays nativos e o std::vector::operator[] não fazem verificação de limites: um índice além do fim (ou um negativo) é UB instantâneo, quer você leia ou escreva.
O bug a observar é usar <= onde você queria <: quando i == v.size() você indexa um elemento além do último, o que é UB. Prefira um for baseado em intervalo (visto antes) quando não precisar do índice, já que ele não pode passar do fim. Quando você de fato indexa manualmente e quer uma rede de segurança, v.at(i) lança std::out_of_range em vez de corromper a memória silenciosamente:
Use at() enquanto caça um bug; volte para [] nos laços críticos depois de provar que os índices são válidos.
Ponteiros pendentes e use-after-free
Um ponteiro ou referência que sobrevive ao objeto para o qual aponta está pendente (dangling). Usá-lo é UB: a memória pode ter sido reutilizada, liberada ou nunca ter existido. Essa é a armadilha que os ponteiros inteligentes (do capítulo anterior) ajudam você a evitar, mas os ponteiros crus ainda deixam você cair nela.
A versão mais drástica é retornar o endereço de uma variável local. A local morre quando a função retorna, então quem chamou fica com um ponteiro para o nada:
int* makeNumber() {
int n = 42;
return &n; // retorna o endereco de uma local: ela some apos o return
}
// Desreferenciar o resultado e comportamento indefinido.
A mesma coisa acontece após um delete ou quando um vector realoca e invalida os iteradores ou ponteiros que apontam para ele:
int* p = new int(5);
delete p;
cout << *p; // use-after-free: comportamento indefinido
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // pode realocar: 'first' agora esta pendente
cout << *first; // comportamento indefinido
As defesas são as que você já conhece: mantenha os objetos vivos enquanto algum ponteiro precisar deles, prefira referências e ponteiros inteligentes em vez de ponteiros crus com posse, e obtenha novamente os ponteiros/iteradores após qualquer operação que possa redimensionar um contêiner.
Variáveis não inicializadas e overflow com sinal
Ler uma variável antes de ter dado um valor a ela é UB para os tipos nativos: não existe um 0 padrão. A variável contém os bits que já estavam naquela memória, e o otimizador pode assumir que você nunca a lê não inicializada.
Se sum tivesse sido declarado como um simples int sum;, cada sum += i leria primeiro um valor indeterminado: UB, e um bug notoriamente difícil porque muitas vezes parece que funciona. Torne a inicialização um hábito: int x = 0; ou int x{};.
Outro infrator silencioso é o overflow de inteiro com sinal. Empurrar um int com sinal além do seu máximo é UB (os tipos sem sinal dão a volta de forma previsível; os tipos com sinal não):
int big = 2147483647; // INT_MAX em um int de 32 bits
int oops = big + 1; // overflow com sinal: comportamento indefinido
Não confie em que ele "dará a volta para um número negativo": o compilador pode assumir que o overflow não pode acontecer e otimizar com base nisso. Se você precisa de uma volta definida, use um tipo sem sinal ou verifique os limites antes de somar.
Detectando UB com sanitizers e avisos
Você não consegue alcançar confiança sobre o UB à base de testes, porque uma execução bem-sucedida não garante nada. O que de fato funciona é tornar o UB barulhento em tempo de execução com os sanitizers do compilador (disponíveis no GCC e no Clang).
// AddressSanitizer: fora dos limites, use-after-free, vazamentos
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer: overflow com sinal, deref nulo, conversoes invalidas
g++ -fsanitize=undefined -g main.cpp -o app && ./app
Execute seus testes existentes com essas flags e a leitura fora dos limites, o use-after-free ou o overflow com sinal que "funcionava" se transforma em um relatório preciso que nomeia o arquivo e a linha. Combine-as com -Wall -Wextra para que o compilador também sinalize código suspeito (como uma provável leitura não inicializada) antes mesmo de você executar.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
Trate qualquer relatório do sanitizer como um bug de correção obrigatória, não como um aviso para ignorar: ele está te dizendo que o padrão não faz nenhuma promessa sobre aquela linha.
Conclusão
O comportamento indefinido é a parte de C++ onde as proteções saem: o acesso fora dos limites, os ponteiros pendentes, o use-after-free, as leituras não inicializadas e o overflow com sinal produzem código sem nenhum significado definido, e "funcionou" nunca é prova de que está correto. A forma de se manter seguro é programar de modo defensivo (inicialize cada variável, respeite os limites dos contêineres, deixe os ponteiros inteligentes serem donos da sua memória do heap) e depois verificar com -fsanitize=address, -fsanitize=undefined e -Wall -Wextra para que o UB silencioso se torne um relatório barulhento e corrigível.
Isso encerra o capítulo de Erros e Depuração. Entre exceções, try/catch e um medo saudável do UB, você agora tem as ferramentas para escrever C++ que falha de forma barulhenta e de propósito, em vez de silenciosa e por acidente.
Perguntas frequentes
O que é comportamento indefinido em C++?
Comportamento indefinido (UB) é qualquer operação à qual o padrão de C++ deixa explicitamente sem nenhum resultado definido; por exemplo, ler além do fim de um array ou desreferenciar um ponteiro pendente. O compilador pode fazer qualquer coisa: travar, retornar lixo, eliminar o código por otimização ou parecer que funciona hoje e quebrar após uma recompilação. É um bug no seu programa, não um recurso da linguagem.
Por que meu programa em C++ funciona mesmo tendo comportamento indefinido?
"Funcionou" não prova nada sobre UB. O padrão não dá nenhuma garantia em qualquer direção, então um bug de UB pode produzir o resultado que você esperava na sua máquina com o seu compilador hoje e depois travar com outro nível de otimização, outra plataforma ou outra versão do compilador. Nunca trate uma execução bem-sucedida como prova de que o UB é inofensivo: use um sanitizer para realmente detectá-lo.
Como detectar comportamento indefinido em C++?
Compile com sanitizers: -fsanitize=address (AddressSanitizer) encontra leituras/escritas fora dos limites e use-after-free, e -fsanitize=undefined (UndefinedBehaviorSanitizer) sinaliza overflow com sinal, desreferências de ponteiro nulo e conversões inválidas. Ative os avisos (-Wall -Wextra) e execute seus testes com essas flags: elas transformam o UB silencioso em um relatório claro em tempo de execução.