1. Por que “smart pointers” ainda podem vazar
Quando você ouve pela primeira vez sobre shared_ptr, quase automaticamente vem à cabeça: “Pronto, vazamentos acabaram, dá para tirar a palavra delete do dicionário, e a memória vai se resolver sozinha”. E sim, em 90% dos casos vai ser assim. Mas existe um cenário traiçoeiro em que shared_ptr age como um convidado educado demais: ele simplesmente não vai embora, porque outros shared_ptr “o seguram pela mão”.
Para entender o que falha, precisamos lembrar o mecanismo do shared_ptr: é ownership via contador. Cada novo dono incrementa o contador, cada dono que sai — decrementa. E o objeto é destruído só quando o contador chega a zero. Agora imagine que dois objetos possuem um ao outro. Seus contadores nunca chegarão a zero — mesmo quando você “liberou tudo” em main().
Ciclo de ownership: definição “na prática”
Ciclo de ownership é a situação em que existe uma cadeia fechada de objetos, e cada um segura o outro através de uma referência proprietária (no nosso caso, std::shared_ptr). Como resultado, cada objeto “tem que viver”, porque alguém o referencia como dono. E assim por diante em círculo.
Importante captar a sutileza: o problema não é que “existem referências”. Referências são normais. O problema é que são referências proprietárias. Ou seja, elas prolongam a vida do objeto. Se esse prolongamento fecha um anel, o sistema de contagem de referências não consegue “eliminar” automaticamente esse anel: os contadores nunca chegarão a 0.
A seguir — uma visualização bem prática:
flowchart LR
A[Task] -- shared_ptr --> B[Column]
B -- shared_ptr --> A
%% Resultado: contadores não chegam a 0
2. Como ver o vazamento: o destrutor como detector
A expressão “vazamento de memória” soa abstrata até que você veja ao vivo: “saí do escopo, e o destrutor não foi chamado”. Portanto hoje faremos uma coisa bem engenheira: adicionaremos no struct um destrutor que imprime uma mensagem. É quase um alarme: se “não apitou”, significa que o objeto não foi destruído.
Começamos com uma mini-struct, só para pegar o jeito:
#include <iostream>
struct Loud {
int id{};
~Loud() {
std::cout << "~Loud(" << id << ")\n";
}
};
int main() {
Loud x{1};
std::cout << "End of main\n"; // End of main
} // ~Loud(1)
Aqui tudo é esperado: x é um objeto normal na stack, ao sair de main o destrutor é chamado.
Agora fazemos o mesmo, mas “sob gestão” de shared_ptr, para lembrar que isso também deve terminar corretamente:
#include <iostream>
#include <memory>
struct Loud {
int id{};
~Loud() { std::cout << "~Loud(" << id << ")\n"; }
};
int main() {
auto p = std::make_shared<Loud>(Loud{7});
std::cout << "End of main\n"; // End of main
} // ~Loud(7)
Enquanto houver um único dono, tudo bem: p é destruído, o contador vira 0, o objeto é destruído.
3. Ciclo na prática: Task e Column em um mini-aplicativo
Imagine que estamos desenvolvendo um aplicativo de console didático (vamos chamar de forma simplificada MiniBoard): temos tarefas (Task) e colunas (Column) no estilo “Todo / Doing / Done”. A coluna guarda a lista de tarefas, e a tarefa… de repente também quer saber em qual coluna está para imprimir o status rapidamente.
No impulso de “facilitar”, é tentador escrever: “deixe Column guardar shared_ptr<Task>, e Task guardar shared_ptr<Column>”. Parece lógico: aqui a gente possui, ali também possui. Só que isso é exatamente um ciclo de ownership pronto.
Fazemos com cuidado, passo a passo. Primeiro as declarações de tipos (repare no forward declaration — já sabemos “declarar previamente” um tipo para quebrar dependência por nome):
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Column; // forward declaration
struct Task {
int id{};
std::string title;
std::shared_ptr<Column> column; // "para trás" (e aqui começa o risco)
~Task() { std::cout << "~Task(" << id << ")\n"; }
};
Agora a coluna, que possui as tarefas:
struct Column {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // "para frente" — possuímos as tarefas
~Column() { std::cout << "~Column(" << name << ")\n"; }
};
E agora — o mais interessante: criamos e vinculamos.
int main() {
auto todo = std::make_shared<Column>();
todo->name = "Todo";
auto t = std::make_shared<Task>();
t->id = 1;
t->title = "Write C++ code";
todo->tasks.push_back(t);
t->column = todo; // ciclo: Column -> Task -> Column
std::cout << "Leaving main...\n"; // Leaving main...
}
Você roda — e vê: “Leaving main...” foi impresso, mas ~Task(1) e ~Column(Todo) — não. Ou seja, do ponto de vista do nosso “alarme” os objetos não foram destruídos. O vazamento “sem delete” ocorreu porque ninguém precisa chamar delete, mas também ninguém precisa “quebrar o círculo” por você.
4. O que acontece com os contadores e por que não é mágica
Agora é importante não “decorar como feitiço”, e sim entender a mecânica. Vamos dividir em momentos no tempo, sem provas formais, mas de forma honesta:
No momento depois de criar todo, a coluna tem um dono — a variável todo.
No momento depois de criar t, a tarefa tem um dono — a variável t.
Quando fazemos todo->tasks.push_back(t), copiamos o shared_ptr para o vetor. Isso adiciona mais um dono da tarefa: agora a Task é referenciada tanto pela variável t quanto pelo elemento do vetor todo->tasks[0].
Quando fazemos t->column = todo, copiamos o shared_ptr da coluna para a tarefa. Isso adiciona mais um dono da coluna: agora a Column é referenciada tanto pela variável todo quanto pelo campo t->column.
A seguir vem o fim de main(). As variáveis locais todo e t são destruídas, e os contadores diminuem. Mas eles não chegam a zero, porque a coluna ainda é segurada pela tarefa via t->column, e a tarefa ainda é segurada pela coluna via tasks.
Formou-se um circuito fechado.
Sim, você pode inspecionar use_count() e ver os números, mas lembre: use_count() é mais uma “lanterna para depuração” do que uma base sólida para lógica de negócio. A noção de que use_count() não tem garantias fortes em todos os cenários aparece até nas discussões da standard library, então trate-o como diagnóstico.
5. Formas de ciclos e diferença entre possuir e navegar
Os ciclos mais comuns: “ida e volta” e “vizinhos”
Quando você escreve código de aplicação, ciclos raramente aparecem como “eu intencionalmente criei um ciclo”. Normalmente eles surgem como “faz sentido: objeto A conhece objeto B, e B conhece A”. Isso acontece com frequência em duas situações: estruturas duplamente ligadas e modelos pai–filho.
Um exemplo clássico com “vizinhos” — lista duplamente ligada. Nem precisa de vetor: só dois ponteiros.
#include <memory>
struct Node {
int value{};
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // se isto for shared_ptr — ciclo quase garantido
};
Se você criar dois nós e fizer a->next = b, e b->prev = a, já tem um “anel de ownership” entre os dois. De novo, destrutores não serão chamados mesmo se você sair de main.
Situação parecida em pai–filho. Por exemplo, uma pasta guarda arquivos, e o arquivo guarda referência para a pasta para compor o caminho completo. Se ambas as ligações forem proprietárias — você formou um ciclo do mundo real sem esforço.
“Referência para navegação” vs “referência para possuir”
Agora vem a ideia mais útil (e mais madura) desta palestra. Em design real é preciso distinguir dois sentidos diferentes de ligação.
A ligação para possuir o tempo de vida responde à pergunta: “quem é responsável por manter o objeto vivo?”. No nosso caso costuma ser shared_ptr.
A ligação para navegação responde à pergunta: “como encontro o objeto relacionado, se ele ainda estiver vivo?”. E aqui nem sempre é necessário possuir. Às vezes basta apenas “olhar”.
Quando fizemos Task::column, na verdade queríamos navegação: a tarefa “quer saber” onde está. Mas demos ownership: a tarefa virou copropietária da coluna. Isso gerou o ciclo.
Daí a regra prática: se a ligação “para trás” ou “lateral” serve só para conveniência de acesso, geralmente deve ser não proprietária. Como tornar não proprietária veremos na próxima aula, onde conheceremos std::weak_ptr, feito justamente para ser uma “referência sem possuir”.
O fato de weak_ptr existir na biblioteca padrão e ter evolução separada não é por acaso: é a ferramenta para consertar esses pontos arquiteturais.
6. Por que “quebrar com reset” é um remendo temporário
Ao descobrir um ciclo de ownership, dá vontade de “resolver rápido”: “ok, antes de sair chamo reset() e pronto”. Às vezes funciona… mas isso é perigoso: funciona até o primeiro caso em que o código terminar em um ponto diferente do esperado.
Por exemplo, você pode quebrar manualmente o ciclo assim:
// ... após criar e vincular:
t->column.reset(); // removemos a posse da coluna da tarefa
todo->tasks.clear(); // removemos a posse das tarefas da coluna
Depois disso os contadores podem realmente cair a zero, e os destrutores serão invocados. Mas o preço é alto: você tem que lembrar de executar o “ritual de limpeza” antes do fim, na ordem correta, e em todos os ramos do código. Isso vira um jogo de “adivinhe onde esqueci de limpar”.
Em um bom design o ciclo é resolvido não com “danças de reset()”, mas com tipos e contratos: onde se possui, e onde se observa. Por isso o próximo passo do curso é weak_ptr.
7. Erros típicos com shared_ptr e ciclos de ownership
Erro №1: “Se eu não usei delete, não pode haver vazamento”.
Essa é uma armadilha humana comum: delete se associa à memória, e parece que sem ele não há problema. Mas o ciclo de ownership é uma situação onde a memória não é liberada não por esquecimento, e sim pela lógica dos contadores: eles simplesmente não chegam a 0.
Erro №2: Tornar ambos os lados proprietários “para garantir”.
A tendência é “só para garantir”: que tudo seja shared_ptr, aí nada some. Na prática você cria um anel: A segura B, B segura A, e os dois vivem para sempre. “Por precaução” é o maior fornecedor de ciclos.
Erro №3: Tratar o ciclo com reset() em lugares aleatórios.
Às vezes consertam o ciclo assim: “em algum destrutor/limpeza eu faço reset()”. Isso transforma o código num campo minado: a limpeza depende da ordem, de quem morreu primeiro e de quais referências ainda existem. O tratamento correto é mudar o modelo de ownership, não adicionar zerações mágicas.
Erro №4: Basear a correção em use_count().
use_count() é uma luz útil para entender “por que não foi deletado”, mas um fundamento ruim para decisões como “posso deletar” ou “quem é o dono principal”. Esse método tem limitações de garantias, então use-o como saída de diagnóstico, não como condição em um if.
Erro №5: Não perceber o ciclo no código porque “são só campos”.
Ciclos de ownership muitas vezes não parecem perigosos: só dois campos shared_ptr em duas structs. Mas é exatamente esse cenário que deve acender o alerta: se um lado é shared_ptr e o outro também, pare e pergunte: “Será que os dois precisam realmente possuir o tempo de vida?”
GO TO FULL VERSION