Quem estudou C++ na gradução deve conhecer os famosos pointeiros. Pointers são variáveis que armazenam o endereço de outros objetos. São referências para qualquer outro tipo de objeto, desde inteiros até instâncias alocadas dinamicamente. A complexidade de pointeiros, contudo, é muito maior do que essa simples definição. Pointeiros exigem um gerenciamente de memória por parte do desenvolvedor muito mais cuidadoso, pois o uso incorreto pode gerar leaks de memória, causar bugs inexplicáveis ou crashar o sistema.
Após a introdução da semântica de move-constructor e move-assignment no C++11, foi criada a Regra dos 5. Segundo essa regra, classes com objetos alocados dinamicamente devem implementar 5 funções especiais: copy-constructor, copy-assignment, destructor, move-constructor e move-assignment. Com essas funções implementadas corretamente, podemos garantir que não há nenhum vazamento de memória. Nesta primeira parte, vamos ver a Regra dos 3, que explora três casos: copy-constructor, copy-assignment e destructor.
RValue vs LValue
Antes de explicar como examente funcionam essas 5 funções especiais, precisamos entender o conceito de RValue e LValue.
LValue são objetos que possuem uma localização identificável. São chamados L porque podem aparecer no lado esquerdo de uma atribuição (assignment). Por exemplo, uma variável com nome é um LValue. RValue é qualquer coisa que não é um LValue. Um valor temporário é um RValue. Normamente, RValues estão do lado direito de uma atribuição e por isso são chamado RValues. Observe o código a seguir:
int main() {
int number {10 * 50};
int result = number + 10;
return 0;
}
Neste código, number é um LValue, pois é uma variável com nome e seu endereço pode ser obtido por &number. O resultado da empressão 10 * 50 é um RValue. É um valor temporário que será destruído após seu valor ser copiado para a variável number. Do mesmo modo, result é um LValue e (number+10) é um RValue.
Por que isso seria importante? Vamos ver um caso.
#include <string>
#include <iostream>
using namespace std;
void printValue(string&& message) {
cout << "Rvalue: " << message << endl;
}
void printValue(const string& message) {
cout << "LValue: " << message << endl;
}
int main() {
string hello = "Hello";
printValue("Hello"); // RValue
printValue(hello); // LValue
printValue(std::move(hello)); // move(LValue) -> RValue
return 0;
}
----------Output-------------
Rvalue: Hello
LValue: Hello
Rvalue: Hello
Nós conseguimos ter implementações diferentes dependendo se a variável for um RValue e um LValue. No namespace std, temos a função move, que serve examente para transformar um LValue em um RValue. Quando é especificado o parâmetro string&& message, estamos nos referindo ao RValue. Quando usamos string& message, está nos referindo ao LValue. Obviamente, se queremos transformar um RValue em um LValue, basta criar uma variável intermédiaria com um nome.
Novamente, vem a pergunta: por que nós precisaríamos de implementações diferentes para RValue e LValue? A resposta é que objetos com propriedades alocadas dinamicamente podem realizar operações mais otimizadas dependendo se a variável é um RValue ou LValue. De forma simplificada, se a variável é um LValue, ela tem um dono em outra parte do código. Logo, precisamos fazer uma cópia do valor para ter um objeto independente da variável original. Entretanto, se a variável é um RValue, essa variável já seria descartada após o seu uso temporário. Logo podemos simplesmente mover um RValue para o objeto da nossa classe sem se preocupar com o ownership dela.
Copy-Constructor e Copy-Assignment
Antes do C++ 11 e da criação da Regra dos 5, já existia a Regra dos 3. Segundo ela, há 3 funções que devem ser implementadas em objetos com propriedades alocadas dinamicamente: copy-constructor, copy-assignment e destrutor. Vamos para o seguinte exemplo:
#include <string>
#include <iostream>
#include <memory>
#include <utility>
using namespace std;
class MatrixCell {
public:
MatrixCell(int value = 0) : m_value {value} {}
int getValue() const {
return m_value;
}
void setValue(const int value) {
m_value = value;
}
private:
int m_value;
};
class Matrix {
public:
Matrix(int rows, int columns) : m_rows {rows}, m_columns {columns} {
cout << "Constructor Matrix " << m_rows << "x" << m_columns << endl;
m_cells = new MatrixCell*[rows];
for (int i=0; i<rows; i++) {
m_cells[i] = new MatrixCell[columns];
}
}
~Matrix(){
cout << "Destructor Matrix: ";
cleanup();
}
void cleanup() {
cout << "Deallocate Matrix " << m_rows << "x" << m_columns << endl;
for (int i = 0; i < m_rows; i++) {
delete[] m_cells[i];
m_cells[i] = nullptr;
}
delete[] m_cells;
m_cells = nullptr;
m_rows = 0;
m_columns = 0;
}
int getRows() const {
return m_rows;
}
int getColumns() const {
return m_columns;
}
int getValue(int row, int column) const {
return m_cells[row][column].getValue();
}
void setValue(const int row, const int column, const int value) {
m_cells[row][column].setValue(value);
}
private:
MatrixCell ** m_cells {nullptr};
string m_name {""};
int m_rows {0};
int m_columns {0};
};
A class Matrix parece estar implementada corretamente. Ela aloca dinamicamente m_cells no construtor e desaloca no destrutor. Contudo, essa classe não respeita a Regra dos 3 e, por isso, está vulnerável a um memory leak. Vamos testar o seguinte código:
void printMatrix(const Matrix matrix) {
cout << "printMatrix" << endl;
for (int i = 0; i < matrix.getRows(); i++) {
for (int j = 0; j < matrix.getColumns(); j++) {
cout << matrix.getValue(i, j) << " ";
}
cout << endl;
}
}
int main() {
Matrix matrix {3, 4};
matrix.setValue(1, 1, 1);
printMatrix(matrix);
cout << "End program" << endl;
return 0;
}
----------Output-------------
Constructor Matrix 3x4
printMatrix
0 0 0 0
0 1 0 0
0 0 0 0
Destructor Matrix: Deallocate Matrix 3x4
End program
Destructor Matrix: Deallocate Matrix 3x4
make: *** [Makefile:45: constructor-example] Segmentation fault (core dumped)
Aparentemente, o destrutor de Matrix foi chamado 2 vezes. Entretanto, onde isso ocorreu? Vamos analisar o código.
printMatrix(matrix);
Quando o main invoca o método printMatrix, é feita uma cópia da variável matrix e passada para a função printMatrix. Dentro da função printMatrix, ocorre a iteração para imprimir os valores da matriz. No final da função, a variável printMatrix deve ser destruída, pois vai sair do escopo em que está definida. Isso chama o primeiro destrutor. Após retornar para a função main, há o print final e o programa se encerra. Como a variável matrix está definida no escopo da main, seu destrutor será chamado. Entretanto, essa operação vai falhar, pois m_cells já havia sido destruído antes em printMatrix. Logo, o segundo destrutor causa o segmentation fault.
Esse exemplo é bem simples, mas já demonstra a facilidade de causar um problema na memória com ponteiros. No C++ moderno, praticamente não há necessidade de usar o operador new ou raw pointers, pois temos as estruturas de dados da STL e os smart pointers, que são ferramentas muito mais poderosas e seguras para gerenciar objetos. Contudo, como podemos trabalhar com código legado, ainda assim precisamos entender o gerenciamente da memória com ponteiros.
Agora, vamos analisar o que deveria ser feito para corrigir esse problema. Primeiramente, o problema ocorreu porque, quando chamamos printMatrix, foi feita uma cópia de matrix em que a referência m_cells foi compartilhada entre os dois objetos. Obviamente, poderíamos simplesmente ter alterado a assinatura de printMatrix para receber uma referência de Matrix, o que não causaria o problema.
void printMatrix(const Matrix& matrix) {
...
}
----------Output-------------
Constructor Matrix 3x4
printMatrix
0 0 0 0
0 1 0 0
0 0 0 0
End program
Destructor Matrix: Deallocate Matrix 3x4
Inclusive, essa seria a forma mais recomendada para esse método, pois printMatrix não altera a matriz original e uma referência para uma constante evitaria o uso de cópias desnecessárias. Contudo, nós precisamos de uma solução robusta que também funcione no caso de precisarmos copiar nossa Matrix. Precisamos que uma cópia de um objeto não crie referências compartilhadas, mas crie um novo objeto m_cells. Nós precisamos de um copy-constructor.
class Matrix {
public:
...
Matrix(const Matrix& src) : Matrix {src.m_rows, src.m_columns} { // Copy-constructor
cout << "Copy-Constructor Matrix " << m_rows << "x" << m_columns << endl;
for (int i = 0; i < m_rows; i++) {
for (int j = 0; j < m_columns; j++) {
m_cells[i][j] = src.m_cells[i][j];
}
}
}
...
};
void printMatrix(const Matrix matrix) {
cout << "printMatrix" << endl;
for (int i = 0; i < matrix.getRows(); i++) {
for (int j = 0; j < matrix.getColumns(); j++) {
cout << matrix.getValue(i, j) << " ";
}
cout << endl;
}
}
...
----------Output-------------
Constructor Matrix 3x4
Constructor Matrix 3x4 // Constructor called by Copy-Constructor
Copy-Constructor Matrix 3x4
printMatrix
0 0 0 0
0 1 0 0
0 0 0 0
Destructor Matrix: Deallocate Matrix 3x4
End program
Destructor Matrix: Deallocate Matrix 3x4
Nesse caso, o programa conseguiu corretamente destruir os objetos. Houve a chamada do destructor da variável copiada para função printMatrix e da variável matrix definida na função main. O problema do segmentation fault foi eliminado.
Será que essa solução já resolve todos os problemas? Vamos agora alterar o código da main para ser um pouco diferente.
int main() {
Matrix matrix {3, 4};
matrix.setValue(1, 1, 1);
Matrix matrixCopy {2, 2};
matrixCopy.setValue(1, 1, 2);
matrixCopy = matrix;
cout << "End program" << endl;
return 0;
}
----------Output-------------
Constructor Matrix 3x4
Constructor Matrix 2x2
End program
Destructor Matrix: Deallocate Matrix 3x4
Destructor Matrix: Deallocate Matrix 3x4
make: *** [Makefile:45: constructor-example] Segmentation fault (core dumped)
Nesse caso, estamos atribuíndo o valor de matrix para a variável matrixCopy. Observe nos logs que o sistema nunca desalocou a matrix 2×2. Pior do que isso, ocorreu um segmentation fault, pois a Matrix 3×4 foi desalocada duas vezes.
Quando fazemos matrixCopy = matrix, estamos copiado matrix para matrixCopy. Entretanto, isso não é um copy-constructor, pois o objeto matrixCopy já tinha sido criada por meio de um construtor. Essa operação de assignment precisa ser tratada para que seja feita uma cópia de m_cells. Por isso, precisamos de um copy-assignment.
class Matrix {
public:
...
Matrix& operator=(const Matrix& rhs) { // copy-assignment
cout << "Copy-assignment Matrix " << rhs.m_rows << "x" << rhs.m_columns << endl;
if (this == &rhs) return *this;
// Naïve version:
deallocateCells();
m_rows = rhs.m_rows;
m_columns = rhs.m_columns;
m_cells = new MatrixCell*[m_rows];
for (int i=0; i<m_rows; i++) {
m_cells[i] = new MatrixCell[m_columns];
}
for (int i = 0; i < rhs.m_rows; i++) {
for (int j = 0; j < rhs.m_columns; j++) {
m_cells[i][j] = rhs.m_cells[i][j];
}
}
return *this;
}
----------Output-------------
Constructor Matrix 3x4
Constructor Matrix 2x2
Copy-assignment Matrix 3x4
End program
Destructor Matrix: Deallocate Matrix 2x2
Destructor Matrix: Deallocate Matrix 3x4
Agora, a desalocação da matrix 2×2 ocorre conforme o esperado. Essa solução funciona na maioria dos casos, mas existe um cenário que não daria certo: e se ocorresse uma exceção no meio da execução do copy-assignment? Se ela ocorresse durante a execução do for, poderia deixar o estado da matrix inconsistente, pois parte das células poderiam ser da matrix nova e a outra parte poderia ser da matriz antiga. Até mesmo a quantidade de linhas e colunas poderia ser diferente da matrix m_cells. Precisamos garantir que essa operação seja feita de forma atômica, ou seja, ou todo a operação ocorre com sucesso ou a instância se mantém inalteada. Isso vai garantir que o código é exception safe.
Para conseguir esse resultado, precisamos seguir a regra do copy-and-swap. Para isso, nós fazemos uma cópia da propriedade e fazemos a troca com o objeto atual. Por fim, destruímos qualquer variável temporária que for necessária para essa cópia. A função std::swap() é capaz de fazer a troca de 2 valores de forma eficiente.
class Matrix {
public:
...
void swap(Matrix& rhs) noexcept {
std::swap(m_rows, rhs.m_rows);
std::swap(m_columns, rhs.m_columns);
std::swap(m_cells, rhs.m_cells);
}
Matrix& operator=(const Matrix& rhs) { // copy-assignment
cout << "Copy-assignment Matrix " << rhs.m_rows << "x" << rhs.m_columns << endl;
if (this == &rhs) return *this;
Matrix temp {rhs}; // Copy-Constructor.
swap(temp);
return *this; // temp is automatically destroyed
}
...
};
int main() {
Matrix matrix {3, 4};
matrix.setValue(1, 1, 1);
Matrix matrixCopy {2, 2};
matrixCopy.setValue(1, 1, 2);
matrixCopy = matrix;
cout << "End program" << endl;
return 0;
}
----------Output-------------
Constructor Matrix 3x4
Constructor Matrix 2x2
Copy-assignment Matrix 3x4
Constructor Matrix 3x4
Copy-Constructor Matrix 3x4 // Copy-Constructor called to create temp
Destructor Matrix: Deallocate Matrix 2x2 // Destructor of temp
End program
Destructor Matrix: Deallocate Matrix 3x4 // Destructor of matrix
Destructor Matrix: Deallocate Matrix 3x4 // Destructor of matrixCopy
Conforme esperado, a matrix 2×2 foi desalocado da memória. Observe que o método swap é marcado como noexcept. Novamente, precisamos garantir que não ocorra uma exceção durante o swap. Se ocorrer, o programa deve ser encerrado, pois o objeto pode estar em um estado inconsistente. Observe que Matrix temp {rhs} também chama o copy constructor assim como o exemplo de printMatrix. Como temp nunca tinha sido construído antes, o programa chama o copy-contructor ao invés o copy-assignment.
Com a inclusão do copy-constructor, copy-assigment e destructor, nossa classe respeita a regra dos 3 e temos garantia que não haverá problemas de gerenciamente de memória na nossa Matrix, desde que, obviamente, usada corretamente.
Conclusão
Nesta primeira parte do tutorial, vimos a importância do gerenciamente corrento de memória com ponteiros e como a Regra dos 3 pode ajudar a evitar memory leak e segmentation fault. Aprendemos a escrever um copy-constructor e um assignment constructor por meio de swap. Aprendemos também a diferença entre RValue e LValue.
Note que todos os exemplos com copy-constructor e copy-assignment que fizemos até aqui foram com LValue, pois todas as nossas variáveis (matrix e matrixCopy) eram nomeadas. Na parte 2, vamos ver a Regra dos 5, uma extensão da Regra dos 3 que permite tratar alguns outros casos de forma mais otimizada envolvendo copy e assignment com RValue.
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.