Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr

Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr

Quando várias partes do programa precisam legitimamente compartilhar um recurso, o unique_ptr não serve. O shared_ptr resolve com contagem de referências, e cobra: bloco de controle, contagem atômica e a armadilha dos ciclos, que nunca liberam nada. O weak_ptr existe para quebrar esse laço.
Linguagem C++

12 min de leitura

Fechei o artigo Um Dono, Um Recurso — o std::unique_ptr com uma limitação clara do unique_ptr: ele impõe um dono só. Mas há problemas em que várias partes do programa precisam legitimamente compartilhar a posse de um mesmo recurso, sem que nenhuma seja a única responsável — um nó de um grafo referenciado por vários outros, um objeto de configuração usado por módulos independentes que podem terminar em qualquer ordem. Para esses casos existe o std::shared_ptr, que reparte a posse por contagem de referências. Ele é poderoso e conveniente, mas — e aqui vou ser bem franco — mais caro e mais traiçoeiro que o unique_ptr. Hoje você aprende a usá-lo, a reconhecer a armadilha do ciclo de referências que ele pode criar, e a desarmá-la com seu parceiro, o std::weak_ptr. Com isto fechamos a família de ponteiros inteligentes.

Contagem de referências: como o shared_ptr sabe quando liberar

Um shared_ptr mantém, ao lado do recurso, um contador de quantos shared_ptr apontam para ele. Copiar um shared_ptr incrementa o contador; destruir um decrementa. Quando o contador chega a zero — ninguém mais aponta —, o recurso é liberado. É posse coletiva com um porteiro que só apaga a luz quando o último sai:

#include <memory>
#include <iostream>

struct Config {
    Config()  { std::cout << "Config criada\n"; }
    ~Config() { std::cout << "Config destruída\n"; }
};

int main() {
    auto a = std::make_shared<Config>();   // contador = 1
    std::cout << "contagem: " << a.use_count() << '\n';   // 1
    {
        auto b = a;                        // CÓPIA permitida! contador = 2
        std::cout << "contagem: " << a.use_count() << '\n';   // 2
    }                                      // b morre → contador = 1
    std::cout << "contagem: " << a.use_count() << '\n';   // 1
    return 0;                              // a morre → contador = 0 → destrói
}

Saída:

Config criada
contagem: 1
contagem: 2
contagem: 1
Config destruída

Diferente do unique_ptr, o shared_ptr pode ser copiado — cada cópia é mais um co-dono. A criação idiomática é std::make_shared (que, além de conveniente, aloca o objeto e o contador juntos, com melhor desempenho). A destruição do recurso acontece exatamente quando o último co-dono some.

O custo honesto do shared_ptr

Antes de você se apaixonar pela conveniência, o trade-off — porque prometer que "compartilhar é de graça" seria mentira. O shared_ptr é maior que um ponteiro cru (guarda o ponteiro e um controle para o contador) e as operações de cópia/destruição mexem num contador que precisa ser atômico (seguro entre threads), o que custa alguns ciclos a mais que um simples incremento. Nada disso é proibitivo, mas é real, e some completamente se você usar unique_ptr. Daí a regra de ouro: use unique_ptr por padrão; recorra a shared_ptr só quando a posse for genuinamente compartilhada. Muita gente que vem de linguagens com coletor de lixo abusa do shared_ptr como se fosse "o ponteiro seguro"; em C++ idiomático, ele é a exceção, não a regra.

A armadilha: ciclos de referência que nunca liberam

Agora o perigo que dá nome à aula. A contagem de referências tem um ponto cego mortal: se dois objetos apontam um para o outro via shared_ptr, seus contadores nunca chegam a zero, mesmo que nada de fora os alcance. Cada um mantém o outro vivo — um abraço fatal que vaza memória:

#include <memory>
#include <iostream>

struct No {
    std::shared_ptr<No> proximo;   // aponta para outro No
    ~No() { std::cout << "No destruído\n"; }
};

int main() {
    auto a = std::make_shared<No>();
    auto b = std::make_shared<No>();
    a->proximo = b;   // a aponta para b (contador de b = 2)
    b->proximo = a;   // b aponta para a (contador de a = 2)

    return 0;
    // a e b locais morrem: contadores caem para 1 cada, NÃO para zero.
    // Nenhum destrutor roda. Os dois Nós VAZAM.
    // (Nenhum "No destruído" é impresso!)
}

Rode e verá que "No destruído" nunca é impresso — os objetos vazaram. O contador de a não zera porque b->proximo ainda o segura, e vice-versa. Este é o calcanhar de Aquiles da contagem de referências, e é onde muitos iniciantes em shared_ptr tropeçam sem perceber.

weak_ptr: observar sem possuir

A solução é o std::weak_ptr. Ele é uma referência fraca: aponta para um objeto gerenciado por shared_ptr, mas não incrementa o contador — não mantém o objeto vivo. É um observador, não um dono. Para quebrar o ciclo, uma das direções vira weak_ptr:

#include <memory>
#include <iostream>

struct No {
    std::shared_ptr<No> proximo;   // posse "para a frente"
    std::weak_ptr<No>   anterior;  // observação "para trás" — NÃO possui
    ~No() { std::cout << "No destruído\n"; }
};

int main() {
    auto a = std::make_shared<No>();
    auto b = std::make_shared<No>();
    a->proximo = b;      // a possui b (forte)
    b->anterior = a;     // b apenas OBSERVA a (fraco) — não incrementa o contador

    return 0;
    // a local morre → contador de a chega a zero → a é destruído.
    // Isso libera b->anterior (era fraco). b também é destruído. Sem vazamento.
}

Agora "No destruído" aparece duas vezes: o ciclo foi quebrado. Para usar o que um weak_ptr observa, você precisa primeiro convertê-lo em shared_ptr com .lock(), que devolve um shared_ptr válido se o objeto ainda existe, ou nulo se ele já foi destruído — um jeito seguro de perguntar "você ainda está aí?":

std::weak_ptr<No> w = a->proximo;   // observa sem possuir
if (auto s = w.lock()) {            // tenta obter posse temporária
    std::cout << "objeto ainda vivo\n";
    // use s com segurança aqui: enquanto 's' existe, o objeto não some
} else {
    std::cout << "objeto já foi destruído\n";
}

Esse padrão — weak_ptr para relações que não devem manter o objeto vivo (o "para trás" numa estrutura, um cache, um observador), e .lock() para acessá-lo com segurança — é a ferramenta certa para quebrar ciclos e para modelar referências que podem "morrer" sob seus pés.

O shared_ptr resolve a posse legitimamente compartilhada e cobra por isso: um bloco de controle à parte, contagem atômica a cada cópia e, sobretudo, a possibilidade de um ciclo — dois objetos que se apontam mantêm a contagem em um para sempre e nenhum dos dois morre. O weak_ptr existe para quebrar exatamente esse laço, observando sem contar. A regra prática que sobrevive é começar pelo unique_ptr e só migrar para posse compartilhada quando ela for um fato do problema, não uma dúvida sobre quem deveria liberar.

Esta fase muda a relação com a memória em C++. Entra-se nela chamando new e delete à mão e torcendo para não esquecer a liberação; sai-se com o RAII amarrando o fim do recurso ao fim de vida do objeto, com a Regra dos Três e dos Cinco explicando o que uma classe que possui recursos precisa controlar, e com a Regra do Zero apontando o melhor destino de todos — a classe que não precisa controlar nada, porque seus membros já sabem cuidar de si.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/memory/shared_ptr e /w/cpp/memory/weak_ptr: as referências completas dos dois tipos, incluindo use_count, lock e a semântica do bloco de controle.
  • Scott Meyers, Effective Modern C++ (2014), Itens 19 ("Use std::shared_ptr for shared-ownership") e 20 ("Use std::weak_ptr for shared_ptr-like pointers that can dangle"): a discussão canônica, incluindo o custo do contador atômico.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre ponteiros inteligentes: a colocação de shared_ptr como recurso de exceção, não de regra.
  • ISO C++ Core Guidelines, regras R.20/R.21 ("Prefer unique_ptr over shared_ptr unless you need to share ownership") e R.24 (uso de weak_ptr para quebrar ciclos): as diretrizes que resumem a lição desta aula.
  • Herb Sutter, "GotW #89: Smart Pointers": um artigo clássico sobre quando cada ponteiro inteligente é a escolha certa, ótimo complemento prático.

Exercícios

Exercício 1

Crie um std::shared_ptr<int>, faça uma cópia dentro de um bloco { }, e imprima use_count() antes, dentro e depois do bloco, comentando por que o número muda.

Ver resposta

✓ Resposta: A contagem em três momentos:

#include <memory>
#include <iostream>
int main() {
    auto p = std::make_shared<int>(10);
    std::cout << "antes:  " << p.use_count() << '\n';   // 1
    {
        auto q = p;                                     // cópia: co-dono
        std::cout << "dentro: " << p.use_count() << '\n'; // 2
    }                                                   // q morre
    std::cout << "depois: " << p.use_count() << '\n';   // 1
    return 0;
}

O número sobe para 2 quando q copia p (mais um co-dono) e volta para 1 quando q sai de escopo. O recurso só será liberado quando a contagem chegar a zero, no fim do main.

Exercício 2

Explique, em suas palavras, a diferença essencial entre unique_ptr e shared_ptr quanto à posse, e dê um critério prático para decidir qual usar num novo código.

Ver resposta

✓ Resposta: unique_ptr é posse exclusiva: exatamente um dono, sem contador, custo zero sobre um ponteiro cru, não copiável (só movível). shared_ptr é posse compartilhada: vários co-donos, um contador atômico que decide a liberação, copiável, porém mais pesado. Critério prático: comece sempre com unique_ptr; só mude para shared_ptr se você conseguir apontar concretamente duas ou mais partes do código que precisam manter o mesmo objeto vivo independentemente, sem uma dona natural. Se não consegue nomear esse compartilhamento real, não precisa de shared_ptr.

Exercício 3

O código abaixo vaza memória. Explique por que "Recurso destruído" não é impresso e conserte-o usando weak_ptr na direção apropriada.

#include <memory>
#include <iostream>
struct Recurso {
    std::shared_ptr<Recurso> par;
    ~Recurso() { std::cout << "Recurso destruído\n"; }
};
int main() {
    auto x = std::make_shared<Recurso>();
    auto y = std::make_shared<Recurso>();
    x->par = y;
    y->par = x;
    return 0;
}
Ver resposta

✓ Resposta: Não imprime porque x e y formam um ciclo: x->par segura y e y->par segura x. Ao fim do main, as variáveis locais x e y morrem e cada contador cai de 2 para 1 — nunca a zero —, então nenhum destrutor roda e ambos vazam. Correção: tornar uma das direções fraca.

#include <memory>
#include <iostream>
struct Recurso {
    std::weak_ptr<Recurso> par;   // observa, não possui — quebra o ciclo
    ~Recurso() { std::cout << "Recurso destruído\n"; }
};
int main() {
    auto x = std::make_shared<Recurso>();
    auto y = std::make_shared<Recurso>();
    x->par = y;   // fraco: não incrementa o contador de y
    y->par = x;   // fraco: não incrementa o contador de x
    return 0;     // agora ambos os contadores zeram e os dois são destruídos
}

Exercício 4

Dado um std::weak_ptr<int> w que observa um objeto possivelmente já destruído, escreva o código que acessa o valor com segurança, imprimindo-o se o objeto ainda existir e uma mensagem caso contrário. Explique o papel de .lock().

Ver resposta

✓ Resposta: O acesso seguro:

#include <memory>
#include <iostream>
void usa(std::weak_ptr<int> w) {
    if (auto s = w.lock()) {                 // tenta obter um shared_ptr válido
        std::cout << "valor: " << *s << '\n';// seguro: s mantém o objeto vivo aqui
    } else {
        std::cout << "objeto já destruído\n";
    }
}

.lock() tenta promover a referência fraca a um shared_ptr forte: se o objeto ainda existe, devolve um shared_ptr válido que o mantém vivo enquanto durar (evitando que ele seja destruído no meio do uso); se já foi destruído, devolve nulo, e o if desvia com segurança. É a forma correta de usar algo que pode ter "morrido sob seus pés".

Exercício 5

Um colega, vindo de uma linguagem com coletor de lixo, decidiu usar shared_ptr para todos os objetos do programa "por segurança". Aponte dois problemas concretos dessa decisão e diga qual deveria ser o ponteiro inteligente padrão, justificando com base nesta e na aula anterior.

Ver resposta

✓ Resposta: Dois problemas concretos: primeiro, desempenho — cada shared_ptr carrega um contador atômico cuja manutenção (a cada cópia e destruição) custa mais que um ponteiro simples, desperdício em objetos que têm um dono único óbvio. Segundo, risco de ciclos e posse difusa — espalhar shared_ptr por tudo cria oportunidades para ciclos de referência (que vazam, como vimos) e apaga a noção de quem é responsável por cada objeto, tornando o tempo de vida imprevisível. O ponteiro padrão deveria ser o std::unique_ptr (artigo Um Dono, Um Recurso — o std::unique_ptr): exclusivo, sem custo extra, com posse clara; shared_ptr fica reservado aos casos de compartilhamento genuíno. "Por segurança" o certo é o dono único explícito, não o compartilhamento indiscriminado.

Comentários

Mais em Linguagem C++

Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable
Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable

Quase sempre o que se quer não é gerenciar uma thread, e sim disparar uma…

A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas
A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas

Consultar sem escrever laço exige que o filtro chegue como parâmetro, e é aí…

Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática
Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática

Uma classe que adquire um recurso ao nascer e o devolve ao morrer, sem um…