1. O que é UB e por que é perigoso
Se você alguma vez encontrou um bug do tipo “às vezes está tudo bem, às vezes tudo cai”, então já conhece emocionalmente o tema de hoje. Em um erro lógico comum o programa se comporta de forma previsível, apenas produz um resultado incorreto (por exemplo, a média foi calculada errado). Já UB — é a situação em que você violou as regras da linguagem C++, e a partir daí a linguagem não garante absolutamente nada: o programa pode “funcionar por acaso”, pode cair, pode imprimir lixo, ou até parecer correto — até o primeiro release.
UB é importante não porque “isso é um perigo raro para olimpíadas”. É importante porque é justamente o UB que transforma a depuração em um detetive: você vê os efeitos, mas a causa pode estar longe e manifestar‑se de maneiras diferentes em máquinas, compiladores e configurações distintas.
Por que UB é mais assustador que um “bug comum”
Em um erro lógico o programa mantém as garantias da linguagem: expressões são avaliadas pelas regras, a memória continua sendo memória, contêineres continuam sendo contêineres. Sim, você pode ter somado números de forma errada — mas o comportamento será repetível.
Em UB você quebra o contrato da linguagem. E essa é a diferença chave: depois de UB o compilador e o runtime não lhe devem nada.
Definição prática de Undefined Behavior
Imagine que C++ é um jogo de tabuleiro com regras muito rígidas. Enquanto você joga segundo as regras, o dado tem valores 1–6, as cartas são lidas da mesma forma e a vitória é contabilizada de forma justa. UB é o momento em que você pega o dado, corta‑o ao meio e diz: “Agora saiu 9”. Depois disso ninguém é obrigado a explicar o que acontece — você saiu do contrato do jogo.
Formalmente, o padrão C++ usa formulações diretas: “the behavior is undefined” (“o comportamento não é definido”).
Consequência prática importante: UB não significa “o programa vai certamente cair”. UB significa “a linguagem deixou de oferecer garantias”. E sem garantias o compilador pode fazer otimizações que quebram suas expectativas, porque você, implicitamente, prometeu: “eu não faço isso”.
2. Por que UB “às vezes funciona”
Para um iniciante parece lógico: “Se uma linha de código está errada, ela deveria quebrar sempre do mesmo modo”. Mas C++ é uma linguagem onde o compilador reorganiza muito o código em nome da performance, especialmente em Release. E é nesse ponto que UB vira tóxico: o compilador otimiza assumindo que UB não acontece (porque um programa correto não deve gerar UB).
Segue uma esquema simples de como isso costuma ser percebido na prática:
flowchart TD
A[Existe um erro no código] --> B{É um erro lógico?}
B -->|Sim| C[Resultado incorreto, mas comportamento estável]
B -->|Não, é UB| D[Comportamento sem garantia]
D --> E[Em Debug 'funciona']
D --> F[Em Release quebra]
D --> G[Quebra 'não onde' a causa está]
D --> H[Em outro compilador manifesta diferente]
Com UB frequentemente surge o chamado “efeito fantasma”: você altera um std::cout em um lugar (apenas uma impressão!), e de repente a queda “desaparece”. Não porque você corrigiu o problema, mas porque mudou a disposição do código/memória, e o UB começou a se manifestar de outra forma.
3. Fontes típicas de UB sem new/delete e ponteiros
Boa notícia: para provocar UB você não precisa de new, ponteiros ou “coisas de sistema” assustadoras. A má notícia: bastam operações bem básicas que já usamos o tempo todo.
Hoje vamos ver quatro fontes mais comuns que aparecem em programas educacionais (e em produção): acesso fora dos limites, divisão por zero, signed overflow e shift incorreto.
Para referência — uma tabela compacta “o que aconteceu → por que é UB”:
| Situação | Exemplo | Por que é UB |
|---|---|---|
| Acesso fora dos limites de array/vector | |
você acessa memória que não é elemento do contêiner |
| Divisão inteira por 0 | |
a operação viola os requisitos da linguagem |
| Overflow de int | |
signed overflow em C++ — é UB |
| Shift incorreto | |
o operador de shift tem requisitos estritos sobre os argumentos |
Agora vamos analisar cada ponto com pequenos exemplos. Importante: as linhas perigosas serão “bloqueadas” para que por padrão não sejam executadas. Não queremos que seu programa vire um gerador de números aleatórios disfarçado de estatística.
Acesso fora dos limites de std::vector
Normalmente um estudante encontra isso assim: vê size() e pensa “ah, esse é o último elemento”. Mas size() é a quantidade de elementos, e os índices começam em zero. Então o último índice é size() - 1, e só se o contêiner não estiver vazio.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
std::size_t i = v.size(); // i == 3, mas índices válidos: 0..2
#if 0
std::cout << v[i] << '\n'; // UB: acesso fora dos limites via operator[]
#endif
std::cout << v[0] << '\n'; // 10
}
O mais traiçoeiro aqui é que operator[] não é obrigado a checar limites. Ele é rápido — e confia em você. Se você erra, está lendo memória “alheia” e daí podem surgir efeitos imprevisíveis.
Divisão por zero em tipos inteiros
Divisão por zero em tipos inteiros não é “apenas matemática falou que não dá”. Em C++ isso é exatamente uma violação dos requisitos da operação. Em alguns ambientes você terá término anormal, em outros um resultado estranho, em outros ainda o otimizador pode reordenar o código de modo que as consequências apareçam em outro lugar.
#include <iostream>
int main() {
int a = 10;
int b = 0;
#if 0
int c = a / b; // UB: division by zero (para tipos inteiros)
std::cout << c << '\n';
#endif
std::cout << a << '\n'; // 10
}
A prática simples aqui é: se o divisor pode ser zero, trate isso explicitamente com uma condição (ou garanta logicamente que zero é impossível).
Signed overflow
Muitos esperam comportamento “como numa calculadora de 8 bits”: passou do máximo, virou o mínimo. Mas em C++ o overflow de tipo signed (int, long long, quando signed) é Undefined Behavior. Ou seja, não se pode escrever código que dependa desse wraparound.
#include <iostream>
#include <limits>
int main() {
int x = std::numeric_limits<int>::max();
#if 0
int y = x + 1; // UB: signed overflow
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 2147483647 (normalmente, mas depende de int)
}
Se você pensa “é só +1, o que pode acontecer” — esse é o caminho clássico para UB. Especialmente quando esse “+1” está dentro de um loop com milhões de iterações.
Shifts incorretos
Shifts parecem “multiplicação por dois no sistema binário”, mas têm regras rígidas: o deslocamento não deve ser negativo e não pode ser maior ou igual à largura do tipo (por exemplo, para um int de 32 bits não se deve deslocar 32 ou mais). Quebrar isso — UB.
#include <iostream>
int main() {
int x = 1;
int shift = -1;
#if 0
int y = x << shift; // UB: shift negativo
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 1
}
Shifts aparecem em máscaras, flags, codificação de estados. Mesmo sem escrever código “baixo nível”, você pode encontrar isso em lógica normal.
4. Exemplo prático: StudyLog sem UB
Para que UB não seja abstrato, vamos ligá‑lo a algo do dia a dia. Suponha que temos um app de console educacional StudyLog: ele guarda uma lista de sessões de estudo (tópico + quantos minutos estudados) e depois calcula estatísticas simples. Já fizemos coisas assim antes: struct, std::vector, funções e entrada cuidadosa — tudo familiar.
Começamos pelo modelo de dados:
#include <string>
struct StudySession {
std::string topic;
int minutes = 0;
};
Agora uma função “obter elemento com segurança por índice”. Aqui justamente surge o risco de acesso fora dos limites. Não vamos usar exceções como tópico separado — basta evitar UB.
#include <vector>
#include <optional>
std::optional<StudySession> GetSession(const std::vector<StudySession>& sessions,
std::size_t index) {
if (index >= sessions.size()) {
return std::nullopt;
}
return sessions[index];
}
Repare na ideia: não tentamos “quase certo” ler o elemento. Ou retornamos o valor, ou dizemos honestamente “não existe”. Isso reduz bastante a probabilidade de UB, porque o problema é capturado antes do acesso perigoso.
Agora a média. Aqui a armadilha é diferente: divisão por zero, se a lista estiver vazia.
#include <vector>
double AverageMinutes(const std::vector<StudySession>& sessions) {
if (sessions.empty()) {
return 0.0; // combinamos: vazio → média 0
}
int total = 0;
for (const auto& s : sessions) {
total += s.minutes;
}
return static_cast<double>(total) / sessions.size();
}
Aqui é importante que tratemos explicitamente o contêiner vazio. Se tivéssemos escrito total / sessions.size() sem verificação, quando size() fosse 0 teríamos UB.
Resta a armadilha mais sutil: overflow de total. Suponha que alguém carregou dados de vários anos e o total de minutos ficou muito grande. Em dados pequenos isso não aparece, mas depois de repente “quebra” em Release — o enredo clássico de UB.
Uma forma simples de reduzir o risco é acumular em um tipo mais amplo:
#include <vector>
long long TotalMinutes(const std::vector<StudySession>& sessions) {
long long total = 0;
for (const auto& s : sessions) {
total += static_cast<long long>(s.minutes);
}
return total;
}
Isso não é “cura mágica para tudo”, mas para tarefas educativas é bem mais sensato do que somar tudo em int e torcer.
E, por fim, um pequeno exemplo bônus sobre shifts. Suponha que você queira armazenar flags “o que foi feito”: leu, resolveu exercícios, assistiu aula. Dá para codificar numa máscara de bits, mas não se pode permitir que usuário ou código desloquem por qualquer valor.
#include <cstdint>
std::uint32_t MakeFlag(int bit) {
if (bit < 0 || bit >= 32) {
return 0u; // bit incorreto → retornamos 0, sem UB
}
return 1u << bit;
}
A ideia de todos esses trechos é a mesma: UB aparece onde a operação tem pré‑condições e nós não as checamos nem garantimos.
5. Como distinguir UB de um erro comum
Há um ponto irritante: UB raramente chega com uma plaquinha “Olá, sou UB”. Normalmente você vê sintomas. Se aprender a reconhecê‑los, vai começar a pensar na direção certa mais rápido: primeiro excluir UB, depois procurar erro lógico.
Um erro lógico comum se comporta de forma estável: por exemplo, você sempre calcula a média errada em 1. UB age como se “vivesse por conta própria”: hoje funciona, amanhã não; após adicionar um std::cout de repente “consertou”; e em Release tudo cai.
Aqui lembramos do trabalho com o debugger: a call stack é útil porque o sintoma pode aparecer em um lugar e a causa estar em outro. UB adora “quebrar” mais tarde: você saiu do vetor, corrompeu memória, e cai 200 linhas depois em outra função.
Diagnóstico de UB: por que usar sanitizers
Quando você suspeita de UB, quer uma ferramenta que diga: “Aqui você fez algo proibido”. Em execução normal o programa pode silenciar e continuar — e é isso que torna UB perigoso. Por isso existem modos de build/execução diagnósticos que adicionam checagens em tempo de execução.
No mundo C++ esses instrumentos frequentemente são os sanitizers. Em essência eles fazem algo simples: o compilador instrumenta o programa, adicionando checagens em pontos perigosos, e ao violar condições geram um relatório.
Basta guardar uma ideia: sanitizers não consertam o código, eles ajudam a capturar o UB mais próximo do local onde ocorreu. Como ativar ASan/UBSan e interpretar os relatórios é assunto para outra palestra.
6. Erros típicos
Erro nº1: “Se funcionou uma vez — então está ok”.
Esse é um dos hábitos mais caros em C++. UB pode “não se manifestar” com dados pequenos, em Debug, na sua máquina e até a primeira mudança de compilador. Se o código viola a pré‑condição de uma operação (limites, divisor, intervalo do tipo), ele continua incorreto mesmo quando hoje parece “normal”.
Erro nº2: confusão entre size() e “último índice”.
size() é quantidade de elementos, não um índice. Ao indexar são válidos valores de 0 até size()-1, se o contêiner não estiver vazio. O erro v[v.size()] parece pequeno, mas pode ser catastrófico porque operator[] não é obrigado a checar limites.
Erro nº3: divisão por zero, “esperando que artefatos matemáticos resolvam”.
Em aritmética inteira a divisão por zero não é “um erro de cálculo”, é UB. Não deixe isso “na esperança”. O divisor deve ser verificado ou garantido pela lógica, caso contrário você constrói uma casa sobre areia.
Erro nº4: aritmética nos limites de int, como se overflow fosse só “dar a volta”.
Em C++ o signed overflow é UB, portanto código que depende de wraparound está logicamente incorreto. Se há risco de valores grandes, use um tipo maior para as somas intermediárias e conheça os intervalos.
Erro nº5: shifts de bits sem controlar os argumentos.
Shifts exigem intervalo correto: não se deve deslocar por número negativo nem por um valor excessivo. Se shift vem de dados (ou é calculado), limite‑o. Caso contrário o erro parecerá aleatório: “ontem funcionou, hoje imprime zeros”.
GO TO FULL VERSION