1. Introdução
Às vezes os estudantes ouvem pela primeira vez a frase "a função retorna uma referência" e sinceramente pensam que é alguma mágica do tipo "teletransporte da variável através do monitor". Na verdade é bem mais prosaico: retornar uma referência significa retornar não um novo objeto, mas acesso a um objeto já existente. Ou seja, "dar mais um nome" para o mesmo objeto.
Retornar um valor (T) é como entregar a alguém uma cópia do documento (ou mover o valor, mas aqui vamos usar o modelo "entidade separada"). Retornar uma referência (T& ou const T&) é como dar a alguém um acesso ao arquivo no arquivo muerto, onde está o original. O acesso é conveniente, mas se o arquivo queima (o objeto for destruído), o passe permanece, mas o acesso ao conteúdo desaparece.
A razão mais comum para retornar uma referência é o desejo de:
- não copiar um objeto grande (por exemplo, std::string, std::vector),
- dar ao código chamador a possibilidade de modificar um objeto específico que vive em algum lugar "fora" da função.
E é aqui que começa a zona de risco: a referência não possui a propriedade do objeto. Ela apenas aponta: "aqui está". Se "aqui" desaparecer — a referência torna-se pendente, e o programa vira uma loteria.
2. O que significam T& e const T& no tipo de retorno
O tipo de retorno com referência tem um significado muito concreto, e esse significado não é sintático, mas sim uma promessa entre o autor da função e seu usuário.
Quando você escreve T&, você de fato diz: "Vou retornar uma referência para um objeto que com certeza existe após a saída da função, e você pode modificar esse objeto através dessa referência".
Quando você escreve const T&, você diz: "Vou retornar uma referência para um objeto que com certeza existe após a saída, mas não é possível modificá-lo por essa referência — apenas ler".
Em ambos os casos a frase-chave é — "o objeto existe com certeza após a saída". O compilador não pode provar para você que você está dizendo a verdade (às vezes pode avisar, mas não há garantia). Portanto retornar referências é um lugar onde o programador deve manter em mente o modelo de tempo de vida.
Em C++ há até discussões e correções na padronização relacionadas ao "tempo de vida de temporários ligados a referências" e "full-expression and temporaries bound to references" — o assunto é realmente sutil, e isso não porque você é "burro", mas porque a linguagem é historicamente complexa.
Regra prática sobre tempo de vida
Se uma função retorna uma referência, o objeto ao qual essa referência aponta deve ser criado fora dessa função, ou deve ter "tempo de vida muito longo" (por exemplo, static), ou deve pertencer ao código chamador e ser passado para a função por referência/ponteiro.
Caso contrário você quase certamente criará uma "referência pendente" (dangling reference): a referência permanece, mas o objeto já não existe.
Para não falar de forma abstrata, continuemos com nosso projeto de console didático. Suponha que seja um simples "task tracker" (lista de tarefas) que armazena tarefas em um std::vector.
3. Mini-banco TaskTracker: modelo Task e o contêiner de tarefas
Agora precisamos de um modelo mínimo de dados para que os exemplos não pareçam "referências esféricas no vácuo". Imagine que em algum lugar do projeto já exista essa estrutura:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
E no main() guardamos as tarefas assim:
#include <vector>
int main() {
std::vector<Task> tasks;
// ...
}
O foco desta palestra hoje: escrever funções que encontrem uma tarefa e retornem uma referência — e fazer isso de forma segura.
4. Maneiras seguras de retornar uma referência
Retornar referência para um parâmetro
Esse é o cenário mais "limpo" para retorno de referência. A função não cria o objeto, ela o recebe do código chamador e o retorna como referência, às vezes após alguma verificação ou escolha.
Exemplo: função "retornar o maior de dois números" como referência. Sim, soa estranho, mas é uma demonstração clássica.
#include <iostream>
int& max_ref(int& a, int& b) {
return (a > b) ? a : b;
}
int main() {
int x = 10;
int y = 20;
max_ref(x, y) = 999;
std::cout << "x=" << x << " y=" << y << "\n"; // x=10 y=999
}
Por que isso é seguro? Porque x e y são criados em main(), e vivem pelo menos até o fim de main(). A função max_ref não cria novos objetos, ela apenas escolhe um dos já existentes.
Um detalhe importante: essa função não aceitará valores temporários, porque o parâmetro int& não pode ser ligado a um temporário. Isso é bom: o compilador nos protege de tentar retornar uma referência para algo que vive "um instante".
Retornar referência a elemento de contêiner passado de fora
Agora nos aproximamos do nosso task tracker. Queremos: encontrar uma tarefa por id e retornar uma referência a ela, para que o código externo possa alterar done ou title.
Faremos uma função que retorna Task&, mas assume que a tarefa existe com certeza. Esse é um contrato importante: se "existe com certeza" — ótimo; se não existe, devemos ou encerrar o programa, ou ter outro desenho.
Aqui está um rascunho "perigoso-didático" (não faça assim, mas é útil ver o problema com os olhos):
#include <vector>
Task& find_task_by_id(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) return t;
}
return tasks.front(); // gambiarrinha educativa: não faça assim
}
Por que retornar uma referência pode ser seguro? Porque t é uma referência a um elemento dentro de tasks, e tasks vive no código chamador. Nós retornamos uma referência para uma parte de um objeto que existe fora da função.
Mas por que esse código ainda é ruim? Porque na ausência do elemento retornamos "algo errado", e isso será um erro lógico: você modificará a tarefa errada. Isso não é um "erro de referência", é um erro de contrato.
Se a tarefa pode não existir, a opção honesta é retornar um ponteiro Task*, para indicar um contrato "nullable" via nullptr:
#include <vector>
Task* find_task_ptr(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) return &t;
}
return nullptr;
}
Uso:
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};
if (Task* t = find_task_ptr(tasks, 2)) {
t->done = true;
}
std::cout << tasks[1].done << "\n"; // 1
}
Agora atenção: estamos falando sobre retornar referências, e caminhamos para ponteiros. Isso é normal: a ideia é simples — "retornar T& só se a função garantir que o objeto existe".
Se você realmente quer Task&, torne o contrato rígido: "se não encontrar — encerramos o programa". É grosseiro, mas honesto (e em código didático isso é aceitável):
#include <iostream>
#include <vector>
Task& find_task_or_die(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) return t;
}
std::cout << "Task not found, id=" << id << "\n";
std::exit(1);
}
Uso:
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};
Task& t = find_task_or_die(tasks, 2);
t.done = true;
std::cout << tasks[1].done << "\n"; // 1
}
Essa abordagem faz o principal: transforma a "incerteza perigosa" em um contrato rígido, porém claro.
Cuidado: referência a elemento de std::vector pode estragar
Quando você retorna uma referência a um elemento de std::vector, deve lembrar: enquanto o vetor não sofre alterações "estruturais", a referência funciona. Mas se você adicionar elementos, remover elementos, fizer erase, push_back que cause realocação — a referência pode tornar-se inválida.
Não vamos agora entrar em uma tabela detalhada de invalidações (esse é outro grande tópico), mas a lição de vida é simples: referência a elemento de contêiner não é um "bilhete eterno", é um "passe válido enquanto a administração não se mudou de prédio".
Portanto retornar referência a elemento de contêiner é seguro apenas se o código chamador não armazenar essa referência "para sempre" e depois modificar o contêiner.
Retornar referência a um objeto static
Às vezes é necessário retornar um "objeto global" que existe durante todo o tempo de execução do programa. Dentro da função você pode criar uma variável static: ela é inicializada uma vez e vive até o término do programa.
Exemplo: nome da aplicação (string constante).
#include <string>
const std::string& app_name() {
static const std::string name = "TaskTracker";
return name;
}
Uso:
#include <iostream>
#include <string>
int main() {
std::cout << app_name() << "\n"; // TaskTracker
}
Por que isso é seguro? Porque name não vive na stack da função, mas na região estática de armazenamento: não desaparece ao sair de app_name().
Por que não abusar disso? Porque static é estado global. É conveniente, mas se você começar a guardar "tudo" assim, terá um programa onde tudo está acoplado, e depurar ficará desagradável. Como técnica para "retornar referência para objeto que certamente vive muito" — é uma opção honesta.
5. Anti-padrões: como obter uma referência pendente
Retornar referência para uma variável local
Isso é clássico. E se você decorar apenas uma coisa desta palestra — que seja isso.
Variáveis locais vivem na stack. Assim que a função termina — seu frame de stack é destruído. Portanto retornar referência para um objeto local é proibido.
int& bad() {
int x = 123;
return x; // magia proibida: x morre ao sair de bad()
}
Mesmo que o compilador avise (frequentemente avisa com um warning), às vezes as pessoas "suprimem o warning" ou não o observam, e depois se surpreendem: "por que às vezes funciona, às vezes imprime 0, e às vezes cai".
Esse é o exemplo típico de referência pendente: a referência ficou, mas o objeto não existe mais. O comportamento não precisa ser previsível.
Retornar const T& para um temporário a partir de return
Aqui costuma haver uma armadilha, porque você já sabe: "const T& pode ser ligado a um temporário". E realmente pode — mas há uma sutileza: o prolongamento do tempo de vida do temporário funciona quando o temporário é ligado a uma variável-referência (por exemplo, const T& ref = ...;) dentro de seu escopo. Ao fazer return você devolve a referência para fora, e essa "variável-referência" não existe mais.
Ou seja, este código é ruim:
#include <string>
const std::string& bad_title() {
return std::string("temporary"); // a string temporária morre no fim da expressão return
}
As opções corretas aqui normalmente são: ou retornar std::string por valor (e isso é aceitável!), ou retornar referência para um objeto que vive mais tempo (por exemplo, static const std::string), se você realmente precisar de const&.
Retornar referência a elemento de contêiner local
Parece "mais esperto", mas quebra pelo mesmo motivo: o contêiner é local, ele morre ao sair da função, e com ele morrem os elementos.
#include <vector>
int& bad_elem() {
std::vector<int> v{10, 20, 30};
return v[1]; // v morre, o elemento morre, a referência fica
}
Se você se pegar querendo escrever algo assim em código real, quase sempre a resposta correta é: retorne por valor, ou passe o contêiner de fora, ou mantenha o contêiner por mais tempo (por exemplo, como global/static — mas isso é uma responsabilidade arquitetural separada).
6. Cola rápida: o que retornar de uma função
Agora seria bom ter uma cola rápida "o que escolher". Vamos fazer uma pequena tabela para o cérebro se agarrar.
| O que você quer comunicar pelo tipo | Retornar | O que isso significa pelo contrato |
|---|---|---|
| "Eu devolvo um novo resultado, independente dos objetos originais" | |
Cópia/valor. Vive por si. |
| "O resultado é um objeto existente, é obrigatório e vai continuar existindo" | |
Alias para um objeto externo; pode ser modificado. |
| "O resultado é um objeto existente, é obrigatório e vai continuar existindo, mas não pode ser modificado" | |
Alias somente leitura. |
| "O resultado pode não existir" | |
Contrato nullable: verifique contra nullptr. |
Importante: às vezes estudantes pensam que "retornar por valor é sempre lento". No C++ moderno muitas vezes retornar std::string ou até std::vector por valor é normal e legível. E retornar referência não é "otimização", é mudar o significado: você está retornando acesso a um objeto alheio.
7. Prática para TaskTracker: encontrar e marcar uma tarefa
Vamos montar um pequeno cenário: o usuário digitou um id, e nós marcamos a tarefa como concluída.
Opção A: "tarefa obrigatória" — retornamos Task&.
#include <iostream>
#include <vector>
Task& find_task_or_die(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) if (t.id == id) return t;
std::cout << "No task with id=" << id << "\n";
std::exit(1);
}
int main() {
std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};
find_task_or_die(tasks, 2).done = true;
std::cout << tasks[1].done << "\n"; // 1
}
Aqui a parte agradável é a cadeia: encontramos a tarefa e imediatamente alteramos o campo, tudo legível.
Opção B: "a tarefa pode não existir" — retornamos Task*.
#include <iostream>
#include <vector>
Task* find_task_ptr(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) if (t.id == id) return &t;
return nullptr;
}
int main() {
std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};
if (Task* t = find_task_ptr(tasks, 42)) {
t->done = true;
} else {
std::cout << "Nothing to mark\n"; // Nothing to mark
}
}
E isso também é um desenho honesto: o tipo Task* força você a não esquecer do caso "não encontrado".
8. Mini-esquema do tempo de vida
Para não confiar apenas na intuição, é útil desenhar mentalmente um pequeno esquema da pilha.
Imagine:
const std::string& f() {
std::string s = "hello";
return s; // ruim
}
Modelo de tempo de vida (muito simplificado):
main():
chama f()
f():
[stack frame f]
s = "hello" <-- vive aqui
return s <-- você leva "endereço" para fora
fim de f():
stack frame f destruído
s destruída <-- o objeto não existe mais
recebeu referência que aponta "para algum lugar no passado"
É por isso que referências pendentes são tão desagradáveis: elas não precisam quebrar imediatamente. Podem "funcionar" no seu computador e em outro cair. Podem quebrar apenas em Release. Podem quebrar apenas às sextas-feiras (o bug mais místico é "só quebra quando o gerente se aproxima da minha mesa").
9. Erros típicos ao retornar referências
Erro nº 1: retornar T& para uma variável local.
Esse é o erro mais comum e mais perigoso: a variável local morre ao sair da função, e a referência fica. Mesmo que "aparentemente funcione", isso não é sucesso — é apenas uma coincidência feliz na memória, e você foi temporariamente não pego.
Erro nº 2: retornar const T& para um objeto temporário criado dentro de return.
O conhecimento de que "const& pode ser ligado a temporário" muitas vezes leva à armadilha: você pensa que o tempo de vida será prolongado, mas ao retornar para fora já não há o que prolongar. O temporário será destruído rapidamente e a referência ficará pendente.
Erro nº 3: retornar referência a elemento de contêiner local.
O contêiner (por exemplo, std::vector) também é um objeto. Se ele é local, morre com seus elementos. Portanto retornar T& para v[i], onde v é criado dentro da função, é o mesmo anti-padrão embalado de forma mais "atraente".
Erro nº 4: retornar T& quando o objeto pode não existir.
Por exemplo, "encontrar tarefa por id". Se a tarefa pode não existir, T& vai forçar você a mentir no contrato, ou retornar "alguma tarefa qualquer", ou encerrar o programa. Na maioria dos casos, para "pode não existir" é mais apropriado usar T* com nullptr, para que o tipo incentive a checagem.
Erro nº 5: guardar uma referência retornada a um elemento de std::vector e depois modificar o próprio vetor.
Mesmo que a referência fosse válida no momento do retorno, o contêiner pode mudar a memória interna ao adicionar/remover elementos. Então a referência ao elemento torna-se inválida. Em códigos simples é melhor usar a referência de forma "curta": pegar — imediatamente realizar a ação — esquecer, ao invés de armazená-la por muito tempo.
GO TO FULL VERSION