C++ Comportamento Indefinido

Em quase toda linguagem, um erro de programação dá uma mensagem: "índice fora do limite", "variável sem valor". Em C++, alguns erros não dão mensagem nenhuma. O programa compila, roda e mostra um número qualquer. Ou funciona hoje e quebra amanhã. Ou funciona no seu computador e quebra no do colega. Isso tem um nome oficial: comportamento indefinido (em inglês, undefined behavior, ou só UB). Esta aula mostra o que é, os cinco casos que mais aparecem e, principalmente, como se proteger.

Você vai aprender
  • O que é comportamento indefinido e por que o compilador "pode fazer qualquer coisa".
  • Por que o mesmo programa dá resultados diferentes com -O0 e com -O2.
  • Os cinco casos clássicos: variável não inicializada, índice fora do array, estouro de inteiro com sinal, ponteiro nulo e referência para variável local.
  • Como se proteger: avisos, sanitizers, at(), std::vector e bons hábitos.

Todos os programas desta aula foram compilados com o g++ 15 na máquina fictícia da Ana, de dois jeitos: com o comando padrão do guia (g++ -std=c++23 -Wall -Wextra arquivo.cpp -o programa) e com -O2 no fim, que liga as otimizações (veja Compilar com g++). Os resultados que você vai ver são os daquele dia, naquela máquina. Esse é justamente o ponto: com UB, outro dia ou outra máquina podem dar outra coisa. O editor do guia usa outro compilador (o Clang) e roda o programa de outro jeito, então lá os resultados podem ser diferentes.

No editor do guia, o UB costuma passar calado

O editor roda o programa como WebAssembly, dentro do navegador, onde a memória é organizada de outro jeito. Lá, usar um ponteiro nulo derruba o programa (Falha de segmentação, código 139, como no Linux), mas índices fora do array e memória já liberada passam sem nenhum sinal. Por isso as falhas desta aula aparecem só nos terminais do laboratório. Nas missões, o objetivo é sempre corrigir o código, não ver o crash.


A ideia: um contrato com o compilador

A linguagem C++ é definida por um documento, o padrão (veja Versões do C++). Ele funciona como um contrato. Para cada coisa que você escreve, o padrão diz o que deve acontecer: 2 + 3 dá 5; v.at(10) num vetor de 3 lança std::out_of_range.

Mas para algumas situações, o padrão diz: "isto o programa não pode fazer; se fizer, não prometo nada". Ler uma variável que nunca recebeu valor, acessar a posição 10 de um array de 4, estourar um int. É como um contrato de aluguel que diz "é proibido fazer churrasco na varanda": o contrato não explica o que acontece se você fizer, porque partiu do princípio de que você não vai fazer.

E o compilador leva o contrato ao pé da letra. Para gerar um programa mais rápido, ele supõe que o UB nunca acontece. Se acontece, as suposições dele viram mentiras, e o resultado pode ser qualquer coisa:

Tipo de problemaQuando apareceExemplo
Erro de compilaçãoAo compilar, sempreFaltou um ;
Erro com aviso em tempo de execuçãoAo rodar, sempre do mesmo jeitov.at(10) lança uma exceção (veja Exceções)
Comportamento indefinidoTalvez nunca, talvez às vezes, talvez de jeitos diferentesv[10]: pode mostrar lixo, pode travar, pode "funcionar"
"Funcionou" não prova nada

Com UB, um programa que deu a resposta certa no seu teste não está certo: ele só teve sorte desta vez. Mudar a otimização, o compilador, a versão do compilador ou até uma linha sem relação pode mudar o resultado. Por isso o objetivo não é "entender o que o UB faz", e sim nunca escrever UB.


1. Variável não inicializada

Uma variável local sem valor inicial não vale zero: vale o que estava naquele pedaço da memória (veja Variáveis e Como a Memória Funciona). Ler esse valor é UB. Veja como o "lixo" pode ser bem concreto:

#include <iostream>

void sorteio() {
    int numero_da_sorte = 777;
    std::cout << "Número da sorte: " << numero_da_sorte << '\n';
}

void placar() {
    int pontos;
    std::cout << "Pontos: " << pontos << '\n';
}

int main() {
    sorteio();
    placar();
}
$ g++ -std=c++23 -Wall -Wextra naoinit2.cpp -o naoinit2
naoinit2.cpp: In function ‘void placar()’:
naoinit2.cpp:10:32: warning: ‘pontos’ is used uninitialized [-Wuninitialized]
   10 |     std::cout << "Pontos: " << pontos << '\n';
      |                                ^~~~~~
naoinit2.cpp:9:9: note: ‘pontos’ was declared here
    9 |     int pontos;
      |         ^~~~~~
$ ./naoinit2
Número da sorte: 777
Pontos: 777                « o "lixo" é o número que a função anterior deixou na memória
$ g++ -std=c++23 -Wall -Wextra -O2 naoinit2.cpp -o naoinit2-O2
«… o mesmo aviso …»
$ ./naoinit2-O2
Número da sorte: 777
Pontos: 0                  « com -O2, outro valor

Sem otimização, as funções sorteio e placar usaram o mesmo pedaço da memória, um depois do outro, e pontos "herdou" o 777. Com -O2, o compilador organizou a memória de outro jeito, e apareceu 0. Nenhum dos dois está "certo": o programa está errado. Desta vez o g++ avisou (‘pontos’ is used uninitialized = "pontos é usada sem ter sido inicializada"), mas nem sempre ele consegue perceber: na aula de Structs, campos sem valor deram lixo sem aviso nenhum.

Como evitar: dê valor a toda variável ao criá-la: int pontos = 0;. E trate esse aviso como erro.

Missão: este programa até mostra Pontos: 50 por sorte, mas o compilador avisa que pontos é usada sem valor. Corrija a declaração para o aviso sumir e a saída continuar Pontos: 50.

#include <iostream>

int main() {
    int pontos;
    int bonus = 50;
    pontos += bonus;
    std::cout << "Pontos: " << pontos << '\n';
}
#include <iostream>

int main() {
    int pontos = 0;
    int bonus = 50;
    pontos += bonus;
    std::cout << "Pontos: " << pontos << '\n';
}

2. Índice fora do array

Pelo padrão, o operador [ ] dos arrays e do std::vector não precisa conferir se a posição existe (veja Arrays). Pedir uma posição que não existe é UB: o programa lê (ou pior, escreve) a memória que estiver logo depois. O erro clássico é o <= no lugar do <:

#include <iostream>

int main() {
    int notas[4] = {7, 8, 9, 10};
    int soma = 0;
    for (int i = 0; i <= 4; ++i) {
        soma += notas[i];
    }
    std::cout << "Soma: " << soma << '\n';
}
$ g++ -std=c++23 -Wall -Wextra fora.cpp -o fora
$ ./fora
Soma: 73762                « deveria ser 34
$ g++ -std=c++23 -Wall -Wextra -O2 fora.cpp -o fora-O2
fora.cpp: In function ‘int main()’:
fora.cpp:7:24: warning: iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations]
    7 |         soma += notas[i];
      |                 ~~~~~~~^
fora.cpp:6:23: note: within this loop
    6 |     for (int i = 0; i <= 4; ++i) {
      |                     ~~^~~~
$ ./fora-O2
Soma: 73762

As posições válidas são 0, 1, 2 e 3; o laço também leu notas[4], que é a memória vizinha, e somou o que havia lá. Repare em duas coisas:

  • Sem -O2, o g++ não avisou nada. Com -O2, ele analisa o código mais a fundo e percebeu: iteration 4 invokes undefined behavior = "a volta 4 do laço provoca comportamento indefinido". Compilar de vez em quando com -O2 revela avisos escondidos.
  • Ler fora do array mostrou lixo. Escrever fora do array é pior: estraga outras variáveis em silêncio, e é a origem de muitas falhas de segurança famosas.

A proteção: at(), ou nem usar índice

Com std::vector (ou std::array), o at() confere a posição e lança uma exceção em vez de ler lixo. O mesmo erro do <=, agora com at():

#include <iostream>
#include <vector>

int main() {
    std::vector<int> notas = {7, 8, 9, 10};
    int soma = 0;
    for (std::size_t i = 0; i <= notas.size(); ++i) {
        soma += notas.at(i);
    }
    std::cout << "Soma: " << soma << '\n';
}
$ g++ -std=c++23 -Wall -Wextra seguro.cpp -o seguro
$ ./seguro
terminate called after throwing an instance of 'std::out_of_range'
  what():  vector::_M_range_check: __n (which is 4) >= this->size() (which is 4)
Abortado                   (imagem do núcleo gravada) ./seguro
$ echo $?
134

O programa ainda está errado, mas agora o erro aparece, com a explicação: "a posição pedida (4) é maior ou igual ao tamanho (4)". Melhor ainda é não precisar de índice: o for de intervalo (for (int n : notas)) percorre exatamente os elementos que existem.

Uma ajuda do g++ 15: o [ ] do std::vector conferido sem -O2

A biblioteca do g++ 15 (a libstdc++) liga sozinha umas conferências extras quando você compila sem otimização. O mesmo erro do <=, agora com um std::vector e o [ ] (estouro.cpp, igual ao seguro.cpp com notas[i] no lugar de notas.at(i)):

$ g++ -std=c++23 -Wall -Wextra estouro.cpp -o estouro
$ ./estouro
/usr/include/c++/15/bits/stl_vector.h:1263: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) [with _Tp = int; _Alloc = std::allocator<int>; reference = int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.
Abortado                   (imagem do núcleo gravada) ./estouro
$ g++ -std=c++23 -Wall -Wextra -O2 estouro.cpp -o estouro-O2
$ ./estouro-O2
Soma: 34                   « com -O2, nenhuma conferência: leu além do fim e, por sorte, somou 0

No meio do texto comprido, o que importa é o fim: Assertion '__n < this->size()' failed = "a conferência 'posição < tamanho' falhou". Ótimo para achar o erro, mas não conte com isso: com -O2, em outros compiladores e no editor do guia, o [ ] não confere nada. O at() confere sempre.


3. Estouro de inteiro com sinal

Um int vai até 2.147.483.647 (INT_MAX, veja Inteiros). Passar disso num tipo com sinal (int, long…) é UB. Na prática, o número costuma "dar a volta" e virar negativo, mas o compilador tem o direito de supor que isso nunca acontece, e ele usa esse direito:

#include <climits>
#include <iostream>

bool cabe_mais_um(int x) {
    int proximo = x + 1;
    return proximo > x;
}

int main() {
    int x = INT_MAX;
    std::cout << "INT_MAX = " << x << '\n';
    std::cout << "INT_MAX + 1 = " << x + 1 << '\n';
    std::cout << "cabe mais um? " << cabe_mais_um(x) << '\n';
}
$ g++ -std=c++23 -Wall -Wextra overflow.cpp -o overflow
$ ./overflow
INT_MAX = 2147483647
INT_MAX + 1 = -2147483648      « deu a volta
cabe mais um? 0                « coerente: -2147483648 não é maior que INT_MAX
$ g++ -std=c++23 -Wall -Wextra -O2 overflow.cpp -o overflow-O2
$ ./overflow-O2
INT_MAX = 2147483647
INT_MAX + 1 = -2147483648
cabe mais um? 1                « com -O2, a resposta mudou!

Com -O2, o compilador raciocinou assim: "x + 1 > x é sempre verdade, porque estouro não existe". E trocou a conta inteira por true. O mesmo programa, as mesmas entradas, duas respostas. E nenhum aviso.

Pode ficar pior. Este laço dobra i até ele "estourar" e ficar negativo:

#include <iostream>

int main() {
    int passos = 0;
    for (int i = 1; i > 0; i *= 2) {
        ++passos;
    }
    std::cout << "Passos: " << passos << '\n';
}
$ g++ -std=c++23 -Wall -Wextra overflow2.cpp -o overflow2
$ ./overflow2
Passos: 31
$ g++ -std=c++23 -Wall -Wextra -O2 overflow2.cpp -o overflow2-O2
$ timeout 3 ./overflow2-O2
$ echo $?
124                            « o timeout teve de matar o programa: laço infinito

Sem otimização, depois de 31 dobras o i deu a volta, ficou negativo, e o laço parou. Com -O2, o compilador concluiu que "um número positivo dobrado continua positivo" (o que é verdade, se não houver estouro) e removeu o teste i > 0: o laço virou infinito. O timeout 3 (que mata o programa depois de 3 segundos e devolve o código 124) foi o que impediu a máquina de ficar presa.

Como evitar: escolha um tipo que caiba o resultado (long long vai até 9,2 × 10¹⁸) e confira antes de fazer contas perto do limite. Os tipos sem sinal (unsigned) não têm UB no estouro (eles dão a volta de um jeito definido), mas isso também costuma ser um erro.

Missão: a lojinha da Ana vendeu R$ 1 bilhão em cada um de três meses (sonhar não custa). Rode e veja o total negativo: a soma estourou o int. Troque o tipo de total por um que caiba 3 bilhões, para a saída ser Total: 3000000000.

#include <iostream>

int main() {
    int vendas[3] = {1000000000, 1000000000, 1000000000};
    int total = 0;
    for (int v : vendas) {
        total += v;
    }
    std::cout << "Total: " << total << '\n';
}
#include <iostream>

int main() {
    int vendas[3] = {1000000000, 1000000000, 1000000000};
    long long total = 0;
    for (int v : vendas) {
        total += v;
    }
    std::cout << "Total: " << total << '\n';
}

4. Ponteiro nulo

Um ponteiro nulo (nullptr) não aponta para lugar nenhum (veja nullptr). Usar o que ele aponta, com *p ou p->, é UB. No Linux, quase sempre termina assim:

#include <iostream>

int main() {
    int* p = nullptr;
    std::cout << "Antes\n";
    *p = 5;
    std::cout << "Depois: " << *p << '\n';
}
$ g++ -std=c++23 -Wall -Wextra nulo.cpp -o nulo
$ ./nulo
Antes
Falha de segmentação     (imagem do núcleo gravada) ./nulo
$ echo $?
139

Falha de segmentação (segmentation fault, ou segfault, num sistema em inglês): o programa tentou mexer numa parte da memória que não é dele, e o sistema o matou com o sinal SIGSEGV. O código 139 é 128 + 11, o número desse sinal. Nenhuma mensagem diz onde foi; para descobrir, use o GDB, que mostra a linha exata.

Aqui o crash é até uma sorte: o erro aparece na hora. Mas como é UB, nada garante nem isso. O compilador pode, por exemplo, apagar um if (p == nullptr) que vem depois de um *p, porque "se o programa chegou aqui depois de usar *p, p não é nulo".

Como evitar: confira antes de usar (if (p != nullptr)), prefira referências a ponteiros quando o valor sempre existe, e use ponteiros inteligentes.


5. Referência (ou ponteiro) para variável local

As variáveis locais de uma função deixam de existir quando ela termina (veja Escopo). Devolver uma referência para uma delas é entregar o endereço de uma casa que acabou de ser demolida:

#include <iostream>
#include <string>

const std::string& saudacao(const std::string& nome) {
    std::string texto = "Olá, " + nome + "!";
    return texto;
}

int main() {
    const std::string& s = saudacao("Ana");
    std::cout << s << '\n';
}
$ g++ -std=c++23 -Wall -Wextra reflocal2.cpp -o reflocal2
reflocal2.cpp: In function ‘const std::string& saudacao(const std::string&)’:
reflocal2.cpp:6:12: warning: reference to local variable ‘texto’ returned [-Wreturn-local-addr]
    6 |     return texto;
      |            ^~~~~
reflocal2.cpp:5:17: note: declared here
    5 |     std::string texto = "Olá, " + nome + "!";
      |                 ^~~~~
reflocal2.cpp: In function ‘int main()’:
reflocal2.cpp:10:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   10 |     const std::string& s = saudacao("Ana");
      |                        ^
reflocal2.cpp:10:37: note: ‘const std::string’ {aka ‘const std::__cxx11::basic_string<char>’} temporary created here
   10 |     const std::string& s = saudacao("Ana");
      |                                     ^~~~~
$ ./reflocal2
Falha de segmentação     (imagem do núcleo gravada) ./reflocal2
$ echo $?
139

Dois avisos: reference to local variable ‘texto’ returned = "foi devolvida uma referência para a variável local texto", e possibly dangling reference = "possível referência pendurada", uma referência que aponta para algo que já não existe. O programa compilou, e quebrou ao usar a referência.

Com int& em vez de std::string, o C++23 já transforma isso em erro: int& maior(int a, int b) { int m = …; return m; } dá error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’. Com -std=c++17, a mesma função compila só com o aviso reference to local variable ‘m’ returned, e o programa terminou em falha de segmentação. É mais um motivo para usar o padrão mais novo.

Como evitar: devolva por valor (std::string saudacao(…)). Devolver por valor é barato no C++ moderno (veja Retorno). Só devolva referência para algo que continua existindo depois da função, como um elemento de um vetor recebido por referência.


Como se proteger

Você não precisa decorar a lista completa de UBs do padrão (são centenas). Precisa de hábitos que evitam os comuns e de ferramentas que acham o resto:

ProteçãoO que pegaOnde aprender
Compilar com -Wall -Wextra e ler todos os avisos. Compilar de vez em quando também com -O2.Variável não inicializada, referência para local, alguns índices fora do array.Avisos
-Werror: transforma avisos em erros.Impede que um aviso passe despercebido.Avisos
Sanitizers: -fsanitize=address,undefinedÍndice fora, ponteiro nulo, estouro de inteiro, uso de memória liberada, na hora em que acontecem, com a linha.Sanitizers
Inicializar tudo: int x = 0;, Aluno a{};Variável não inicializada.Variáveis
std::vector/std::array com at() ou for (x : v)Índice fora (vira exceção ou nem acontece).std::vector
Smart pointers, referências e checagem de nullptrPonteiro nulo, memória já liberada.Ponteiros Inteligentes
Tipos com folga (long long) e conferência antes de contas no limiteEstouro de inteiro.Inteiros
Devolver por valorReferência para variável local.Retorno

Os sanitizers merecem destaque: eles transformam o UB silencioso em uma mensagem clara, com o nome do arquivo e a linha. Use-os sempre durante o desenvolvimento.


Quando dá errado

O resultado muda de uma execução para outra, ou com -O2

O que aconteceu: quase certamente UB: variável não inicializada, índice fora, estouro.

Como resolver: leia os avisos com -Wall -Wextra (e com -O2), depois rode com -fsanitize=address,undefined (veja Sanitizers).

"Falha de segmentação (imagem do núcleo gravada)" e código 139

O que aconteceu: o programa mexeu numa memória que não é dele: ponteiro nulo, referência para algo que já não existe, índice muito fora do array.

Como resolver: descubra a linha com o GDB (backtrace) ou com -fsanitize=address.

"Funciona no meu computador, mas não no do meu colega" (ou no editor do guia)

O que aconteceu: o programa tem UB, e cada compilador, versão ou opção tira proveito dele de um jeito.

Como resolver: não procure "o compilador errado"; procure o UB no seu código, com as mesmas ferramentas.


Referência rápida

UB clássicoExemploSintoma comumCorreção
Variável não inicializadaint x; cout << x;Valores estranhos, que mudamint x = 0;
Índice forav[4] num array de 4Lixo, outras variáveis estragadasv.at(i), for (x : v), i < tamanho
Estouro com sinalINT_MAX + 1Negativos, laço infinito com -O2long long, conferir antes
Ponteiro nuloint* p = nullptr; *p = 5;Falha de segmentação (139)if (p), referências, smart pointers
Referência para localreturn texto; em função que devolve const std::string&Lixo ou falha de segmentaçãoDevolver por valor

Exercícios

1. Inicializar

Complete a declaração para que total comece com zero:

int total

2. Acesso conferido

Complete para ler a posição i do vetor v de um jeito que lança exceção se ela não existir:

int x = ;

3. A condição certa

Complete a condição do laço que percorre um array de 4 elementos sem passar do fim:

for (int i = 0; ; ++i)

4. Um programa com comportamento indefinido mostrou a resposta certa nos seus testes. O que isso quer dizer?

5. Por que x + 1 > x deu verdadeiro com -O2, mesmo com x = INT_MAX?

Missão final: a soma das notas

Missão: este laço erra duas vezes: começa no 1 (pulando a primeira nota, a da posição 0) e vai até o 4 (uma posição depois do fim). Corrija o programa para a saída ser Soma: 34. Você pode só consertar os limites do for, mas tente a forma mais segura: um std::vector percorrido com for (int n : notas), sem índice nenhum. No editor, a posição inexistente costuma valer 0 e a soma sai 27; no g++ do terminal, sai lixo. Os dois estão errados.

#include <iostream>

int main() {
    int notas[4] = {7, 8, 9, 10};
    int soma = 0;
    for (int i = 1; i <= 4; ++i) {
        soma += notas[i];
    }
    std::cout << "Soma: " << soma << '\n';
}
#include <iostream>
#include <vector>

int main() {
    std::vector<int> notas = {7, 8, 9, 10};
    int soma = 0;
    for (int n : notas) {
        soma += n;
    }
    std::cout << "Soma: " << soma << '\n';
}

Resumo

  • Comportamento indefinido (UB) é o que o padrão proíbe sem dizer o que acontece. O compilador supõe que ele nunca ocorre, e o resultado pode ser qualquer coisa.
  • UB pode "funcionar", mostrar lixo, travar ou mudar com -O2: o mesmo código deu 777 e 0, 0 e 1, 31 passos e laço infinito.
  • Os clássicos: variável não inicializada, índice fora do array, estouro de int, ponteiro nulo, referência para variável local.
  • Proteja-se: -Wall -Wextra (e às vezes -O2), sanitizers, inicializar tudo, std::vector com at() ou for de intervalo, smart pointers, devolver por valor.
  • "Funcionou no teste" não prova que está certo.