CodeGym /Cursos /C++ SELF /Ciclos de ownership — como surge um vazamento “sem delete...

Ciclos de ownership — como surge um vazamento “sem delete”

C++ SELF
Nível 44, Lição 0
Disponível

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?”

1
Tarefa
C++ SELF, nível 44, lição 0
Bloqueado
Objeto barulhento
Objeto barulhento
1
Tarefa
C++ SELF, nível 44, lição 0
Bloqueado
Ciclo de posse
Ciclo de posse
1
Tarefa
C++ SELF, nível 44, lição 0
Bloqueado
Limpeza de referências
Limpeza de referências
1
Tarefa
C++ SELF, nível 44, lição 0
Bloqueado
Rompendo a lista
Rompendo a lista
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION