A Disciplina do const — Promessas que o Compilador Cobra

A Disciplina do const — Promessas que o Compilador Cobra

Em C o const é etiqueta modesta, que muita gente ignora sem prejuízo. Em C++ ele muda de estatura: vira promessa escrita no código, que o compilador cobra de quem a fez. O artigo trata da const-correctness em parâmetros, ponteiros e métodos, e desfaz a confusão que derruba até quem tem experiência.
Linguagem C++

10 min de leitura

No artigo Referências e Ponteiros, Frente a Frente o const entrou quase de fininho, colado ao padrão const std::string&. Prometi que ele merecia o palco inteiro, e é hora de pagar essa promessa. Em C você conhece o const como uma etiqueta modesta: serve para declarar constantes e pouco mais; muita gente o ignora sem grande prejuízo. Em C++ ele muda de estatura completamente. Vira uma ferramenta de projeto — um jeito de escrever no próprio código uma promessa que o compilador então obriga você a cumprir. Chamamos a prática de usar const em todo lugar em que ele cabe de const-correctness, e ela é uma das marcas que separam o código C++ amador do profissional. Hoje você aprende a fazer essas promessas e a ler as que os outros fizeram.

A ideia central: const é um contrato verificado

Marcar algo como const é dizer "isto não será modificado". A diferença em relação a um simples comentário é que o compilador checa. Se você quebrar a promessa, o programa não compila — o erro vira impossível de cometer, não apenas desaconselhado.

#include <iostream>

int main() {
    const int limite = 100;
    limite = 200;   // ERRO de compilação: assignment of read-only variable 'limite'
    std::cout << limite << '\n';
    return 0;
}

Até aqui, idêntico ao C. A riqueza do C++ aparece quando o const encontra ponteiros e, mais adiante, métodos de classe.

O ponto que confunde todo mundo: const com ponteiros

Aqui mora a confusão sincera de quem vem do C, porque const e * podem se combinar de duas formas com significados opostos. A regra que nunca falha é ler da direita para a esquerda, a partir do nome da variável:

int valor = 10;
int outro = 20;

// 1) ponteiro para int const: "p1 é ponteiro para um int constante"
const int* p1 = &valor;
// *p1 = 99;   // ERRO: não posso mudar o VALOR apontado
p1 = &outro;   // OK: posso reapontar o ponteiro

// 2) ponteiro const para int: "p2 é um ponteiro constante para int"
int* const p2 = &valor;
*p2 = 99;      // OK: posso mudar o valor apontado
// p2 = &outro; // ERRO: não posso reapontar o ponteiro

// 3) tudo const: "p3 é ponteiro constante para int constante"
const int* const p3 = &valor;
// *p3 = 99;    // ERRO
// p3 = &outro; // ERRO

O truque de leitura: encontre a variável (p1), leia para a esquerda até o * — "p1 é ponteiro para" — e o que sobrou (const int) é o alvo. No caso 2, o const está depois do *, então ele qualifica o ponteiro, não o alvo. Diga essas três frases em voz alta uma vez e elas grudam: "ponteiro para constante" (o valor é fixo, o ponteiro anda), "ponteiro constante" (o ponteiro é fixo, o valor muda), "constante para constante" (nada muda). O caso 1 — const int* — é de longe o mais comum, e é exatamente o que está por trás do const std::string& do artigo anterior: uma janela só-leitura para um dado que você não quer copiar.

Por que se dar esse trabalho

Três ganhos concretos, e vale ser honesto sobre o custo — que é quase zero, apenas disciplina.

O primeiro ganho é documentação que não mente. Quando você vê uma função void processa(const Pedido& p), a assinatura já promete: "vou olhar seu pedido, mas não vou alterá-lo". Um comentário poderia dizer o mesmo, mas comentários apodrecem; o const é verificado a cada compilação, então nunca fica desatualizado.

O segundo é pegar erros cedo. Se dentro de processa alguém, meses depois, tentar modificar p por engano, o compilador barra na hora, apontando a linha. O bug morre antes de nascer.

O terceiro é habilitar otimizações e usos. Só é possível passar um objeto temporário ou uma constante para um parâmetro const&; um parâmetro & não-const recusa esses casos. E o compilador raciocina melhor sobre código que promete não mudar.

#include <iostream>
#include <string>

void so_le(const std::string& s) {
    std::cout << "Tamanho: " << s.size() << '\n';  // .size() é seguro, não altera
}

int main() {
    so_le("literal temporário");   // SÓ funciona porque o parâmetro é const&
    // Se fosse std::string& (sem const), passar um literal não compilaria.
    return 0;
}

Esse detalhe — poder passar temporários e literais — é uma razão prática, não filosófica, para preferir const&. Guarde-o; ele reaparece quando falarmos de move semantics na Fase 2.

Uma armadilha honesta: const é uma promessa rasa

const protege o objeto que você marcou, mas não segue ponteiros automaticamente para proteger o que eles apontam. Um const num ponteiro cru diz respeito ao ponteiro, não necessariamente ao alvo — como vimos nos casos 1 e 2. E existe uma válvula de escape, const_cast, capaz de remover o const. Ela existe para casos raríssimos de interoperar com APIs antigas mal-escritas, e usá-la para de fato modificar um objeto originalmente const é comportamento indefinido. A regra de bolso: se você está escrevendo const_cast para contornar o sistema, quase certamente está fazendo algo errado. Trate-o como o goto do const — existe, mas você passará anos sem precisar.

Juntando com o que já sabemos

Um exemplo que reúne as três aulas — streams (A Filosofia por Trás do C++ e a Saída Elegante do printf), referências (Referências e Ponteiros, Frente a Frente) e const (A Disciplina do const) — trabalhando juntas, no espírito de "cada artigo é o curso em miniatura":

#include <iostream>
#include <string>

// Recebe por const& (não copia, não modifica) — o padrão idiomático.
// A função inteira é uma promessa de só-leitura sobre 'nome' e 'saudacao'.
void cumprimenta(const std::string& saudacao, const std::string& nome) {
    std::cout << saudacao << ", " << nome << "!\n";
}

// Modifica o argumento: referência NÃO-const, deixando a intenção explícita.
void enfatiza(std::string& texto) {
    texto += "!!!";   // altera a string do chamador
}

int main() {
    const std::string fixa = "Olá";   // esta saudação nunca muda
    std::string quem = "Marcelo";

    cumprimenta(fixa, quem);   // Olá, Marcelo!

    enfatiza(quem);            // quem vira "Marcelo!!!"
    std::cout << "Agora: " << quem << '\n';

    // enfatiza(fixa);  // ERRO: fixa é const, não pode ir para um std::string&
    return 0;
}

Repare na assimetria deliberada: cumprimenta promete não mexer em nada e por isso aceita a fixa const; enfatiza declara abertamente que modifica, e por isso o compilador recusa passar a fixa para ela. As assinaturas contam a história inteira antes de você ler uma linha do corpo. É isso que const-correctness compra: código que se autodocumenta e se autoprotege.

Para ler declarações com const, a regra que resolve quase tudo é ler de dentro para fora, da direita para a esquerda — e é ela que separa o ponteiro que não pode mudar de destino daquele cujo destino não pode ser alterado. Vale o esforço porque const não é decoração: cada um deles remove uma possibilidade do conjunto que o compilador precisa considerar, e o que o compilador garante você não precisa testar. A ressalva honesta é que a promessa é rasa: marcar um ponteiro como constante não protege o que está do outro lado dele.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seções sobre const e constexpr: a visão do criador sobre imutabilidade como ferramenta de projeto.
  • cppreference.com/w/cpp/language/cv: a referência técnica sobre os qualificadores const e volatile, com todas as regras de combinação.
  • Scott Meyers, Effective C++ (3ª ed.), Item 3 ("Use const whenever possible"): o argumento definitivo a favor da const-correctness, incluindo a leitura direita-para-esquerda dos ponteiros.
  • ISO C++ Core Guidelines, seção "Con" (Constants and immutability), regras Con.1 a Con.4: as diretrizes oficiais sobre quando e onde aplicar const.
  • cppreference.com/w/cpp/language/const_cast: para entender a válvula de escape e por que ela é perigosa, útil justamente para saber quando não usá-la.

Exercícios

Exercício 1

Declare uma constante const double PI = 3.14159; e escreva uma função double area_circulo(double raio) que a utilize. Por que é melhor PI ser const do que uma variável comum?

Ver resposta

✓ Resposta: A função e o motivo:

const double PI = 3.14159;
double area_circulo(double raio) {
    return PI * raio * raio;
}

PI ser const garante, checado pelo compilador, que nenhum trecho do programa vai reatribuí-lo por engano — uma constante física não deveria mudar em tempo de execução. Além da segurança, comunica intenção a quem lê e permite ao compilador otimizar, sabendo que o valor é fixo. (No C++ moderno, constexpr double PI = ... seria ainda melhor, por garantir o valor em tempo de compilação; veremos constexpr adiante.)

Exercício 2

Leia cada declaração abaixo em português ("é um ponteiro constante para...", etc.) e diga qual das duas atribuições comentadas é ilegal em cada caso:

int x = 1, y = 2;
const int* a = &x;   // (i)  *a = 9;   (ii) a = &y;
int* const b = &x;   // (i)  *b = 9;   (ii) b = &y;
Ver resposta

✓ Resposta: Leituras e ilegalidades: - const int* a → "a é ponteiro para int constante". Legal reapontar, ilegal mudar o valor. Portanto (i) *a = 9; é ilegal; (ii) a = &y; é permitido. - int* const b → "b é ponteiro constante para int". Legal mudar o valor, ilegal reapontar. Portanto (ii) b = &y; é ilegal; (i) *b = 9; é permitido. São exatamente os casos 1 e 2 da aula, invertidos entre si.

Exercício 3

A função abaixo deveria apenas ler o vetor, mas nada impede que ela o modifique. Corrija a assinatura para expressar essa intenção e explique o que muda para quem chama.

#include <string>
int comprimento_total(std::string& s) {
    return static_cast<int>(s.size());
}
Ver resposta

✓ Resposta: Basta adicionar const (e, por consistência com o padrão anterior, note que size() é uma operação só-leitura):

#include <string>
int comprimento_total(const std::string& s) {
    return static_cast<int>(s.size());
}

Para quem chama, muda o contrato: agora a função promete não alterar s, o compilador impede qualquer modificação acidental dentro dela, e passa a ser possível chamá-la com literais e strings const — o que a versão sem const recusaria.

Exercício 4

Explique por que a chamada comentada não compila e o que isso revela sobre a relação entre const e passagem por referência:

void muda(std::string& s) { s += "?"; }
int main() {
    const std::string pergunta = "Como vai";
    // muda(pergunta);   // por que isto é rejeitado?
    return 0;
}
Ver resposta

✓ Resposta: A chamada é rejeitada porque pergunta é const std::string, e o parâmetro de muda é std::string& — uma referência não-const, que é uma promessa de que a função pode modificar o argumento. Ligar uma referência modificável a um objeto const permitiria burlar a imutabilidade de pergunta, então o compilador proíbe. Isso revela a regra central: um objeto const só pode ser passado para parâmetros const& (ou por valor). É o mecanismo que faz o const "pegar" na fronteira das funções.

Exercício 5

Escreva uma função void trocar_se_maior(int& a, int& b) que deixa a sempre menor ou igual a b, trocando-os se preciso. Depois responda: por que os parâmetros aqui não podem ser const&, ao contrário do padrão de leitura que temos usado?

Ver resposta

✓ Resposta: A função:

#include <iostream>
void trocar_se_maior(int& a, int& b) {
    if (a > b) {
        int tmp = a;
        a = b;
        b = tmp;
        // No C++ idiomático usaríamos std::swap(a, b); veremos na STL.
    }
}

int main() {
    int x = 9, y = 4;
    trocar_se_maior(x, y);
    std::cout << x << " <= " << y << '\n';  // 4 <= 9
    return 0;
}

Os parâmetros não podem ser const& porque a função precisa modificar a e b — trocá-los é o objetivo. const& serve para argumentos que você só lê; quando o propósito é alterar o argumento do chamador, a referência tem de ser não-const. É a mesma lição do exercício 4, vista pelo lado de dentro: a assinatura declara honestamente que haverá escrita.

Comentários

Mais em Linguagem C++

O Capstone dos Jogos — Breakout e o Fim da Jornada
O Capstone dos Jogos — Breakout e o Fim da Jornada

O último jogo reúne tudo o que a trilha construiu: recursos em RAII, entidades…

Redes de Segurança Automáticas — Sanitizers e clang-tidy
Redes de Segurança Automáticas — Sanitizers e clang-tidy

Um observa o programa rodando, o outro lê o código sem executá-lo: sanitizers…

Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores
Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores

Somar com mais, comparar com igual, imprimir direto no cout: operadores são…