Em C++, uma classe deve ser responsável por gerenciar completa e eficientemente os recursos que referencia.
Memória, threads, conexões com o banco de dados, mutexes, são apenas alguns exemplos de recursos, potencialmente escassos, que, uma vez alocados, se não forem adequadamente liberados, podem levar o ambiente produtivo a exaustão. Pior ainda, se forem liberados “antes do tempo”, podem conduzir o programa a um comportamento inválido.
Como prática eficiente, nos últimos anos, convencionou-se que classes que precisam lidar com recursos potencialmente escassos devem implementar comportamentos especiais:
- Destruição – liberando recursos sob “gerência” do objeto;
- Construção com cópia– evitando compartilhamento de controle sobre “recursos escassos” em objetos criados “copiando” um objeto existente;
- Atribuição com cópia – evitando compartilhamento de controle sobre “recursos escassos” em objetos com valores copiados de um objeto existente;
- Construção com transferência– evitando alocações desnecessárias de recursos escassos, quando um objeto novo estiver sendo criado “copiando” dados de outro, pré-existente, que não será mais utilizado;
- Atribuição com transferência – evitando alocações desnecessárias de recursos escassos, quando um objeto estiver “copiando” dados de outro, pré-existente, que não será mais utilizado;
Destruição
Se pegar emprestado, devolva, meu filho.
minha mãe |
Comecemos com uma regra bem simples: se um objeto alocar qualquer recurso potencialmente escasso, deverá garantir sua liberação.
#include <initializer_list> #include <iostream> class Buffer { public: Buffer(const std::initializer_list<float>& values) : _size{values.size()} { _data_ptr = new float[values.size()]; std::copy(values.begin(), values.end(), _data_ptr); } auto begin() const { return _data_ptr; } auto end() const { return _data_ptr + _size; }; ~Buffer() { delete [] _data_ptr; _data_ptr = nullptr; } private: size_t _size{0}; float* _data_ptr{nullptr}; }; int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); for (auto elem : data) { std::cout << elem << std::endl; } }
No exemplo acima, memória, um recurso escasso, é alocada pelo construtor e liberada pelo destrutor.
Construção com cópia
Não estrague o brinquedo do amiguinho.
minha mãe |
A classe Buffer
, implementada no exemplo acima, parece estar fazendo “tudo certo” – ela “aloca” um determinado espaço na memória em sua inicialização, no método construtor, e “libera” memória, mais tarde, ao sair de contexto, no método destrutor. Entretanto, este código não está protegido contra cópias.
int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); { auto copy = data; } for (auto elem : data) { std::cout << elem << std::endl; // FAIL! } }
No código acima, a variável copy
recebe uma “cópia” do objeto apontado em data
. Afinal, o programador fez a opção por realizar a alocação na stack. Assim, todas as referências presentes no objeto data
são “replicadas” no objeto copy
e, infelizmente, serão destruídas assim que o contexto de copy
for encerrado – gerando comportamento inválido.
#include <initializer_list> #include <iostream> class Buffer { public: Buffer(const std::initializer_list<float>& values) : _size{values.size()} { _data_ptr = new float[values.size()]; std::copy(values.begin(), values.end(), _data_ptr); } // copy constructor Buffer(const Buffer& other) : _size{other._size} { _data_ptr = new float[_size]; std::copy(other._data_ptr, other._data_ptr + _size, _data_ptr); } auto begin() const { return _data_ptr; } auto end() const { return _data_ptr + _size; }; ~Buffer() { delete [] _data_ptr; _data_ptr = nullptr; } private: size_t _size{0}; float* _data_ptr{nullptr}; };
A saída, como é indicado no código acima, é implementar um construtor de cópia que “duplica” as referências protegendo-as.
Eventualmente, outra alternativa seria “deletar” o construtor de cópia tornando tal comportamento a cópia não autorizada.
Buffer::Buffer(const Buffer& other) = delete; int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); { // No copies! auto copy = data; } for (auto elem : data) { std::cout << elem << std::endl; } }
Atribuição com cópia
C++ é tremendamente eficiente em “poupar memória”, mas, as vezes, essa “obsessão” da linguagem acaba se revertendo em dor de cabeça.
A implementação da classe Buffer
, com um construtor de cópia, ainda resulta em comportamento indesejado no código que segue:
int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); { auto copy = data; data = copy; } for (auto elem : data) { std::cout << elem << std::endl; // FAIL } }
No exemplo, ao reatribuir valor a variável data
, no lugar de ser criada uma nova instância de Buffer
, C++, por padrão, copia os valores de copy
para data
. Obviamente, o array referenciado em copy
é destruído gerando comportamento potencialmente inesperado.
A saída é gerar uma sobrecarga do operador de atribuição.
#include <initializer_list> #include <iostream> class Buffer { public: Buffer(const std::initializer_list<float>& values) : _size{values.size()} { _data_ptr = new float[values.size()]; std::copy(values.begin(), values.end(), _data_ptr); } // copy constructor Buffer(const Buffer& other) : _size{other._size} { _data_ptr = new float[_size]; std::copy(other._data_ptr, other._data_ptr + _size, _data_ptr); } // copy assignment auto& operator=(const Buffer& other) { delete [] _data_ptr; _data_ptr = new float[other._size]; _size = other._size; std::copy(other._data_ptr, other._data_ptr + _size, _data_ptr); return *this; } auto begin() const { return _data_ptr; } auto end() const { return _data_ptr + _size; }; ~Buffer() { delete [] _data_ptr; _data_ptr = nullptr; } private: size_t _size{0}; float* _data_ptr{nullptr}; };
Outra alternativa seria impedir atribuições.
auto& operator=(const Buffer& other) = delete;
Construção e atribuição com transferência
Qual seria o resultado lógico do código abaixo?
void print(Buffer data) { for (auto elem : data) { std::cout << elem < < std::endl; } } int main() { print(Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f})); return 0; }
Por padrão, o comportamento seria a criação de um objeto na stack associado a função main
que seria, imediatamente, copiado para um novo objeto na stack associado a função print
.
Felizmente, quase todos os compiladores modernos reconhecem tal desperdício e “evitam” a cópia.
Use referências sempre que possível
O código a seguir possui só méritos. A função print
recebe uma referência somente leitura para um objeto.
void print(const Buffer& data) { for (auto elem : data) { std::cout << elem << std::endl; } } int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); print(data); return 0; }
Sempre que possível utilize referências evitando cópias desnecessárias.
Eventualmente, entretanto, o compilador não conseguira detectar tais desperdícios, competindo ao programador fazer a gestão.
void print(Buffer data) { for (auto elem : data) { std::cout << elem << std::endl; } } int main() { auto data = Buffer({1.0f, 2.0f, 3.0f, 4.0f, 5.0f}); print(std::move(data)); return 0; }
No exemplo, utilizou-se std::move
como forma a indicar que o que se deseja fazer é uma transferência de recursos escassos. Tal função, utiliza-se de implementações de construção e movimentação por transferência para evitar desperdícios.
#include <initializer_list> #include <iostream> class Buffer { public: Buffer(const std::initializer_list<float>& values) : _size{values.size()} { _data_ptr = new float[values.size()]; std::copy(values.begin(), values.end(), _data_ptr); } // copy constructor Buffer(const Buffer& other) : _size{other._size} { _data_ptr = new float[_size]; std::copy(other._data_ptr, other._data_ptr + _size, _data_ptr); } // move constructor Buffer(Buffer&& other) : _size{other._size}, _data_ptr{other._data_ptr} { other._data_ptr = nullptr; } // copy assignment auto& operator=(const Buffer& other) { delete [] _data_ptr; _data_ptr = new float[other._size]; _size = other._size; std::copy(other._data_ptr, other._data_ptr + _size, _data_ptr); return *this; } // move assignment auto& operator=(Buffer&& other) { std::cout << "move copy"; _size = other._size; _data_ptr = other._data_ptr; other._data_ptr = nullptr; return *this; } auto begin() const { return _data_ptr; } auto end() const { return _data_ptr + _size; }; ~Buffer() { delete [] _data_ptr; _data_ptr = nullptr; } private: size_t _size{0}; float* _data_ptr{nullptr}; };
Bem além da memória…
Todos os exemplos desse apêndice mostraram a aplicação da “regra dos cinco” em uma classe que gerenciava a memória (um exemplo de recurso escasso). Entretanto, de todos os recursos, memória é, potencialmente, o mais fácil de gerenciar.
As coisas podem ficar realmente desafiadoras na gestão de recursos como threads que não podem ser simplesmente “duplicadas” ou transferidas. Daí a importância de entender melhor cada cenário dar ao programador “poder para decidir”.
O compilador pode e deve ajudar, sempre que possível, a evitar enganos. Mas, nem só com receitas prontas são feitos programas, principalmente em systems programming.