Referências e Ponteiros, Frente a Frente

Referências e Ponteiros, Frente a Frente

No scanf era obrigatório passar o endereço da variável; no cin a leitura funciona sem ele. Para onde foi o endereço? A resposta é a referência, que o C++ oferece onde o C só tinha ponteiro. O artigo põe as duas frente a frente: o que muda na declaração, quando cada uma serve, e por que uma virou padrão.
Linguagem C++

11 min de leitura

Encerrei o artigo A Filosofia por Trás do C++ e a Saída Elegante do printf com um enigma de propósito. No C, para ler um inteiro com scanf, você era obrigado a escrever scanf("%d", &idade) — com aquele & na frente da variável. Em C++, o std::cin >> idade leu e escreveu no idade sem & nenhum. O valor foi modificado dentro da função de leitura, então alguma coisa passou o endereço adiante — mas onde? A resposta é o assunto de hoje: as referências. Elas são a resposta do C++ para um problema que o C só sabia resolver com ponteiros, e entender a diferença entre as duas é entender uma escolha de projeto que você fará em quase toda função que escrever daqui em diante.

O que é uma referência

Uma referência é um apelido para uma variável que já existe. Onde o & aparece muda tudo: quando você viu &idade no scanf, o & era o operador "endereço de", que produz um ponteiro. Na declaração de uma referência, o & faz parte do tipo e significa "isto é um apelido para":

#include <iostream>

int main() {
    int original = 10;
    int& apelido = original;   // apelido NÃO é uma cópia; é o próprio 'original'

    apelido = 42;              // mexer no apelido é mexer no original

    std::cout << "original = " << original << '\n';  // imprime 42
    std::cout << "apelido  = " << apelido  << '\n';  // imprime 42

    // Provando que são a mesma coisa: os endereços coincidem.
    std::cout << (&original == &apelido ? "mesmo endereço\n" : "endereços diferentes\n");
    return 0;
}

Rode e verá mesmo endereço. apelido não ocupa memória própria: ele é original, só que com outro nome. Isso já revela três regras que separam a referência do ponteiro, e é aqui que a bagagem de C ajuda muito, por contraste.

As três diferenças que importam

Primeira: uma referência precisa ser inicializada e nunca pode ser nula. Um ponteiro de C pode nascer sem apontar para nada (int* p;) ou apontar para o nada (int* p = nullptr;). Uma referência, não — int& r; sequer compila, porque um apelido para coisa nenhuma não faz sentido. Isso elimina de saída uma classe inteira de bugs que você conhece bem: o null pointer dereference.

Segunda: uma referência não pode ser "reapontada". Depois de ligada a original, ela é original para sempre. Compare:

int a = 1, b = 2;

int* p = &a;   // p aponta para a
p = &b;        // agora p aponta para b — ponteiros se movem

int& r = a;    // r é apelido de a
r = b;         // CUIDADO: isto NÃO reaponta r para b!
               // Isto copia o valor de b para dentro de a. Agora a == 2.

Essa é a pegadinha número um para quem vem do C. Com ponteiro, p = &b muda para onde p aponta. Com referência, r = b mexe no valor do que r apelida, porque a referência não tem existência separada para ser reapontada. Leia esse bloco duas vezes; ele é fonte de confusão sincera.

Terceira: a referência dispensa a sintaxe de "desreferência". Com ponteiro você escreve *p para chegar ao valor e p->campo para um membro. Com referência você usa o nome direto, como se fosse a variável original. Menos ruído visual, menos * esquecido.

Passando para funções: o caso de uso rei

Em C, quando uma função precisa modificar o argumento do chamador, você passa um ponteiro:

/* Estilo C: troca o valor de dois inteiros via ponteiros */
void troca(int* x, int* y) {
    int tmp = *x;   // desreferência obrigatória
    *x = *y;
    *y = tmp;
}
/* Chamada: troca(&a, &b); — com & obrigatório */

Em C++, referências deixam isso mais limpo e mais seguro:

#include <iostream>

// x e y são APELIDOS para os argumentos do chamador.
// Modificá-los aqui modifica lá fora — sem * e sem & na chamada.
void troca(int& x, int& y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int main() {
    int a = 1, b = 2;
    troca(a, b);        // sem &! A referência cuida disso.
    std::cout << "a = " << a << ", b = " << b << '\n';  // a = 2, b = 1
    return 0;
}

Some o * de dentro da função e o & da chamada. E como referência não pode ser nula, troca nunca recebe um endereço inválido — algo que a versão com ponteiro não garante. Agora ficou claro o enigma do std::cin >> idade: o operador >> recebe idade por referência, escreve o valor lido diretamente no apelido, e por isso o & que o scanf exigia desapareceu.

A referência const: passar sem copiar, sem deixar modificar

Existe um segundo motivo, tão importante quanto, para passar por referência: evitar cópias caras. Passar um int por valor é trivial — é copiar 4 bytes. Mas imagine passar por valor um std::string com um milhão de caracteres, ou um objeto grande: cada chamada copiaria tudo. A solução idiomática é passar por referência para não copiar, e marcar como const para prometer que a função não vai modificar o original:

#include <iostream>
#include <string>

// const std::string& → "um apelido só-leitura para a string do chamador".
// Não copia o texto inteiro e não deixa a função alterá-lo.
void imprime_saudacao(const std::string& nome) {
    std::cout << "Bem-vindo, " << nome << "!\n";
    // nome = "outro"; // ← ERRO de compilação: nome é const, não pode mudar
}

int main() {
    std::string usuario = "Ana Ribeiro";
    imprime_saudacao(usuario);   // passa por referência: nada é copiado
    return 0;
}

Esse padrão — const T& para argumentos de leitura que você não quer copiar — é talvez a assinatura de função mais comum em todo o C++ idiomático. Você vai escrevê-lo milhares de vezes. Ele entrega o melhor dos dois mundos: a eficiência de passar um endereço (como no C) e a garantia, verificada pelo compilador, de que o dado do chamador está protegido. Esse const é a ponta de um iceberg chamado const-correctness, que é o tema inteiro do próximo artigo.

Uma armadilha honesta: referências pendentes

Referência não é bala de prata. Como ela apelida algo que existe em outro lugar, se esse "outro lugar" morrer antes da referência, você fica com uma referência pendente — um apelido para memória que já não é sua, tão perigoso quanto um ponteiro solto (dangling pointer) do C. O erro clássico é retornar uma referência para uma variável local:

int& perigo() {
    int local = 5;
    return local;   // ERRO GRAVE: 'local' morre ao fim da função.
                    // A referência retornada aponta para o vazio.
}
// Usar o resultado de perigo() é comportamento indefinido.

Bons compiladores avisam (-Wall costuma pegar este caso), mas a lição é conceitual: referência não prolonga a vida de nada. Ela só empresta um nome. Quem cuida do tempo de vida dos objetos é outro mecanismo — e esse mecanismo, RAII, é o assunto que abre a Fase 2. Por enquanto, a regra de bolso: nunca retorne referência para algo local.

A escolha entre ponteiro e referência quase sempre cabe numa pergunta: isto pode ser nulo? Podendo, é ponteiro, e o código precisa tratar essa possibilidade; não podendo, é referência, e o que antes era convenção passa a ser garantido pelo compilador. Daí o const T& ser tão comum nas assinaturas: ele diz, de uma vez, que a função precisa do objeto, não vai copiá-lo e não vai alterá-lo. A armadilha que fica de pé é a referência pendente — ela garante que existe alguém do outro lado no momento da ligação, não que esse alguém continue vivo depois.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre referências e passagem de argumentos: a exposição canônica da distinção entre & de referência e & de endereço.
  • cppreference.com/w/cpp/language/reference: a referência técnica completa sobre referências lvalue, com todas as regras de inicialização.
  • Scott Meyers, Effective C++ (3ª ed.), Item 20 ("Prefer pass-by-reference-to-const to pass-by-value"): a justificativa clássica do padrão const T&, com medições de custo.
  • ISO C++ Core Guidelines, regras F.16 e F.17: quando passar por valor, por const& e por & não-const — o guia oficial para decidir a assinatura.
  • cppreference.com/w/cpp/language/lifetime: para entender por que a referência pendente é comportamento indefinido, ligando ao conceito de tempo de vida.

Exercícios

Exercício 1

Escreva uma função void dobra(int& n) que multiplica por 2 o inteiro passado, sem retornar nada, e um main que a demonstre imprimindo o valor antes e depois.

Ver resposta

✓ Resposta: A referência permite modificar o original diretamente:

#include <iostream>
void dobra(int& n) { n *= 2; }   // n é apelido do argumento

int main() {
    int x = 21;
    std::cout << "antes: " << x << '\n';   // 21
    dobra(x);
    std::cout << "depois: " << x << '\n';  // 42
    return 0;
}

Sem o & na assinatura, dobra receberia uma cópia e o x do main ficaria intacto — exatamente o comportamento de passagem por valor do C.

Exercício 2

Sem rodar, diga o que o trecho abaixo imprime e explique com base na regra de que r = b não reaponta:

int a = 5, b = 9;
int& r = a;
r = b;
b = 100;
std::cout << a << " " << b << '\n';
Ver resposta

✓ Resposta: Imprime 9 100. Passo a passo: int& r = a; faz r ser apelido de a (que vale 5). r = b; não reaponta r para b; copia o valor de b (9) para dentro de a, então agora a == 9. Depois b = 100; mexe só em b. No fim, a vale 9 e b vale 100. A referência continuou ligada a a o tempo todo.

Exercício 3

Escreva uma função void primeira_maiuscula(std::string& s) que transforma a primeira letra de s em maiúscula, alterando a string original. (Dica: s[0] acessa o primeiro caractere; existe uma função std::toupper em <cctype>.)

Ver resposta

✓ Resposta: A modificação acontece no original porque s é referência não-const:

#include <iostream>
#include <string>
#include <cctype>   // std::toupper

void primeira_maiuscula(std::string& s) {
    if (!s.empty())                        // evita acessar s[0] de string vazia
        s[0] = std::toupper(s[0]);         // altera o caractere in-place
}

int main() {
    std::string nome = "maria";
    primeira_maiuscula(nome);
    std::cout << nome << '\n';             // Maria
    return 0;
}

O if (!s.empty()) é a versão C++ do reflexo de C de nunca indexar antes de checar o tamanho.

Exercício 4

A função abaixo recebe um std::string por valor e o compilador não reclama, mas ela desperdiça uma cópia a cada chamada. Reescreva a assinatura para evitar a cópia, mantendo a função só-leitura, e explique sua escolha.

int conta_vogais(std::string texto) {
    int total = 0;
    for (char c : texto)
        if (c=='a'||c=='e'||c=='i'||c=='o'||c=='u') ++total;
    return total;
}
Ver resposta

✓ Resposta: Troque std::string texto por const std::string& texto:

int conta_vogais(const std::string& texto) {   // sem cópia, só-leitura
    int total = 0;
    for (char c : texto)
        if (c=='a'||c=='e'||c=='i'||c=='o'||c=='u') ++total;
    return total;
}

A escolha é const& porque a função apenas a string: a referência evita copiar o texto inteiro a cada chamada, e o const garante — verificado pelo compilador — que a função não altera o argumento do chamador. É o padrão do Item 20 de Meyers.

Exercício 5

Explique por que a função a seguir é perigosa e proponha duas correções diferentes (uma que devolve por valor, outra que recebe o destino por referência):

std::string& monta_nome(const std::string& a, const std::string& b) {
    std::string completo = a + " " + b;
    return completo;
}
Ver resposta

✓ Resposta: A função é perigosa porque retorna uma referência a uma variável local (completo), que é destruída assim que a função termina — o chamador recebe um apelido para memória inválida (referência pendente, comportamento indefinido). Duas correções:

// Correção A: devolver por valor. Simples e correto.
// (Graças a move semantics, que veremos na Fase 2, isso nem é caro.)
std::string monta_nome(const std::string& a, const std::string& b) {
    return a + " " + b;
}
// Correção B: o chamador fornece o destino por referência não-const.
void monta_nome(const std::string& a, const std::string& b, std::string& destino) {
    destino = a + " " + b;   // escreve numa variável que vive no chamador
}

A opção A é a idiomática no C++ moderno; a B lembra o estilo de C de "passar o buffer de saída" e às vezes é útil, mas quase sempre A é mais limpa. O que jamais se faz é retornar std::string& de um objeto local.

Comentários

Mais em Linguagem C++

Quando o Contrato se Quebra — Exceções e a Aliança com o RAII
Quando o Contrato se Quebra — Exceções e a Aliança com o RAII

Lançar uma exceção separa o caminho do erro do caminho normal, e é o RAII que…

Do Comando Único ao Projeto Real — Construindo com CMake
Do Comando Único ao Projeto Real — Construindo com CMake

Compilar com uma linha de g++ deixa de servir bem antes do esperado: bastam…

Um Valor que Pode Não Existir — std::optional
Um Valor que Pode Não Existir — std::optional

Combinar que -1 significa ausência funciona até alguém esquecer de verificar…