Nos tutoriais anteriores (Parte 1, Parte 2 e Parte 3), aprendemos que não é recomendado utilizar raw pointers em projetos com C++ moderno, pois eles são uma fonte fácil de bugs. Então como resolvemos caso seja necessário criar objetos alocados dinamicamente? Nós utilizamos smart pointers.
Smart pointers são wrappers para os raw pointers, com a diferença que eles podem desalocar automaticamente objetos da memória quando eles saem de escopo. Existem 3 tipos de smart pointers:
- unique_ptr: são ponteiros que apontam para objetos de forma única, ou seja, nenhum outro smart pointer pode apontar para esses objetos. Logo, esses objetos podem ser automaticamente destruídos quando o unique_ptr sai de escopo
- shared_ptr: são ponteiros para objetos compartilhados, em que mais de um shared_ptr pode apontar. O objeto poderá ser destruído apenas quando não houver nenhum outro shared_ptr apontando para ele. Internamente, shared_ptr utilizam contadores para saber quando o objeto poderá ser destruído.
- weak_ptr: são ponteiros para objetos compartilhados, mas não possuem a posse do objeto. weak_ptr podem apontar para recursos de outro shared_ptr, mas weak_ptr não previne que o objeto não seja desalocado dinamicamente quando shared_ptr sai de escopo. Para acessar o recurso de um weak_ptr, é necessário converter para shared_ptr com o método lock()
Vamos para um exemplo com unique_ptr
#include <iostream>
#include <memory>
class Node {
public:
explicit Node(int value) : m_value {value} {}
~Node() {
std::cout << "Destroying Node " << m_value << std::endl;
}
private:
int m_value = 0;
};
void main(){
std::cout << "Start" << endl;
auto node { std::make_unique<Node>(1) };
std::cout << "End" << endl;
return 0;
}
---------------Output---------------------
Start
End
Destroying Node 1
O retorno do método std::make_unique é um unique_ptr apontando para um Node alocado dinamicamente na heap. Obviamente, após o término da main, o objeto pode ser desalocado. Como node sai de escopo, o unique_ptr se encarrega de chamar o destrutor de Node. A vantagem do unique_ptr é garantir essa desalocação sempre que sair de escopo. Isso inclui o caso de exceptions!
No nosso segundo exemplo, nosso Node será utilizado para representar uma lista ligada.
#include <iostream>
#include <memory>
class Node {
public:
explicit Node(int value) : m_value {value} {}
~Node() {
std::cout << "Destroying Node " << m_value << std::endl;
}
void setNext(shared_ptr<Node> next) {
m_next = next;
}
auto getValue(){
return m_value;
}
auto getNextNode(){
return m_next.lock();
}
private:
int m_value = 0;
std::weak_ptr<Node> m_next;
};
int nodeExample(){
std::cout << "Start" << endl;
auto node1 { std::make_shared<Node>(1) };
auto node2 { std::make_shared<Node>(2) };
auto node3 { std::make_shared<Node>(3) };
node1->setNext(node2);
node2->setNext(node3);
auto currentNode {node1};
while(currentNode) {
std::cout << "Node " << currentNode->getValue() << endl;
currentNode = currentNode->getNextNode();
}
std::cout << "End" << endl;
return 0;
}
---------------Output---------------------
Start
Node 1
Node 2
Node 3
End
Destroying Node 3
Destroying Node 2
Destroying Node 1
A propriedade next do Node é do tipo weak_ptr, pois o next pode ser desalocado independente do Node que está apontando para ele. Node1, node2 e node3 são do tipo shared_ptr, pois eles serão compartilhados com outros shared_ptr e weak_ptr. No final do código main, todos os nós são destruídos corretamente. Analisando esse código, pode surgir uma dúvida: a propriedade next do Node poderia ser do tipo shared_ptr? Considerando que todos os nós saem de escopo juntas, isso não deveria ser um problema, correto? Vamos analisar o seguinte código:
#include <iostream>
#include <memory>
class Node {
public:
...
auto getNextNode(){
return m_next;
}
private:
int m_value = 0;
std::shared_ptr<Node> m_next;
};
int main() {
std::cout << "Start" << std::endl;
auto node4 { std::make_shared<Node>(4) };
auto node5 { std::make_shared<Node>(5) };
node4->setNext(node5);
node5->setNext(node4);
std::cout << "End" << std::endl;
return 0;
}
---------------Output---------------------
Start
End
Observe que há uma referência circular. Fazendo a contagem, há 2 shared_ptr apontando para o objeto do Node 4 e 2 shared_ptr apontando para o objeto do Node 5. Quando as variáveis node4 e node 5 saem de escopo, esse contadores são decrementados, ou seja, o programa finaliza com 1 shared_ptr apontando para cada objeto devido a referência circular. Por isso, o sistema não chama o destrutor desses Nodes. Logo, shared_ptr não poderia ser utilizado nesse caso. Precisa ser um weak_ptr:
class Node {
public:
...
auto getNextNode(){
return m_next.lock();
}
private:
int m_value = 0;
std::weak_ptr<Node> m_next;
};
int main() {
std::cout << "Start" << endl;
auto node4 { std::make_shared<Node>(4) };
auto node5 { std::make_shared<Node>(5) };
node4->setNext(node5);
node5->setNext(node4);
std::cout << "End" << endl;
return 0;
}
---------------Output---------------------
Start
End
Destroying Node 5
Destroying Node 4
Nesse caso, os nodes 4 e 5 foram desalocados corretamente após o término do programa.
Conclusão
Nesta primeira parte do tutorial de smart pointers, tivemos uma introdução de como podemos utilizar essas estruturas para subtituir os raw pointers com a vantagem de desalocação automática de recursos. No próximo tutorial, vamos ver o caso de uma class ter propriedades do tipo unique_ptr, que exige um tratamento especial com a semântica de move.
Referências
- Gregoire, Marc. Professional C++. 5 Edição. Wrox
- Sehr, Viktor; Andrist Bjorn. C++ High Performance: Boost and optimize the performance of your C++17 code. Packt.