Move-Constructor e Move-Assigment em C++

Unrecognizable couple wearing jeans standing carrying stacked carton boxes out of apartment during renovation on daytime

Na primeira parte do tutorial, nós aprendemos a Regra dos 3 e a importância do Copy-Constructor e Copy-Assigment para o gerenciamento correto das propriedades alocadas dinamicamente. C++11 introduziu um novo conceito de movimentação de objetos e a Regra dos 3 foi expandido para a Regra dos 5. Segundo essa regra, as classes com objetos alocados dinamicamente devem implementar o Move-Constructor e Move-Assignment além dos métodos da Regra dos 3.

Vamos adicionar 2 novos método na nossa classe Matrix: cleanup e moveFrom. O método cleanup será usado pelo destrutor para desalocar a variável de m_cells, e moveFrom irá mover as propriedades de um objeto origem para um objeto destino e o objeto origem será resetado.

class Matrix {
    public:
        ...
        void cleanup() noexcept {
            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;
        }

        void moveFrom(Matrix& src) noexcept {
            // Primitives
            m_rows = exchange(src.m_rows, 0);
            m_columns = exchange(src.m_columns, 0);
            m_cells = exchange(src.m_cells, nullptr);

            // Object
            m_name = move(src.m_name);
        }

        ...

    private:
        MatrixCell ** m_cells {nullptr};
        string m_name {""};
        int m_rows {0};
        int m_columns {0};
};

No move-constructor e move-assigment, precisamos substituir as propriedades do objeto pelas propriedades do source e resetar o source para um estado inicial. No move construtor, a class Matrix substitui todas as propriedades do objeto com o método moveFrom. Como estamos construíndo o objeto pela primeira vez, não há necessidade de nenhuma desalocação de variável. Para facilitar, podemos utilizar o método exchange da library <utility> para trocar os valores.

 Matrix(Matrix&& src) noexcept { // Move-Constructor
      cout << "Move-Constructor Matrix " << src.m_rows << "x" << src.m_columns << endl;
      moveFrom(src);
  }

No caso do move-assigment, precisamos tomar cuidado para desalocar o objeto atual antes de substituir pelo novo objeto:

Matrix& operator=(Matrix&& rhs) { // Move-Assignment
    cout << "Move-assignment Matrix " << rhs.m_rows << "x" << rhs.m_columns << endl;
    if (this == &rhs) return *this;
    cleanup();
    moveFrom(rhs);
    return *this;
}

Alternativamente, podemos escrever o move-assigment e move-constructor com a função swap. Essa versão é melhor, pois é mais simples de dar manutenção, uma vez que a lógica do swap é centralizada em um único método. Quando uma variável for adicionada ou removida, basta alterar o método swap para que todos os métodos continuem funcionando.

Matrix(Matrix&& src) noexcept { // Move-Constructor
     this->swap(src);                    
}

Matrix& operator=(Matrix&& rhs) { // move-assignment
    this->swap(rhs);
    return *this;                        
}

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);
    std::swap(m_name, rhs.m_name);
}

Alterando o main para testar o move-constructor e move-assignment, temos:

int main() {  
    cout << "Move-Constructor" << endl;
    vector<Matrix> matrices;
    matrices.push_back(Matrix{1, 1});
    matrices.push_back(Matrix{2, 2});

    cout << "\nMove-Assigment" << endl;
    Matrix m1 {3, 3};
    m1 = Matrix {4, 4};

    cout << "End program" << endl;
    return 0;
}

----------Output------------- 
Move-Constructor
Constructor Matrix 1x1
Move-Constructor Matrix 1x1
Destructor Matrix: Deallocate Matrix 0x0
Constructor Matrix 2x2
Move-Constructor Matrix 2x2 // Data moved to another vector
Move-Constructor Matrix 1x1
Destructor Matrix: Deallocate Matrix 0x0
Destructor Matrix: Deallocate Matrix 0x0

Move-Assigment
Constructor Matrix 3x3
Constructor Matrix 4x4
Move-assignment Matrix 4x4
Deallocate Matrix 3x3
Destructor Matrix: Deallocate Matrix 0x0
End program
Destructor Matrix: Deallocate Matrix 4x4
Destructor Matrix: Deallocate Matrix 1x1
Destructor Matrix: Deallocate Matrix 2x2

No primeiro exemplo do move-constructor, observe que utilizamos um vector de Matrix. Conforme fazemos push_back, o vector precisa redimensionar o seu tamanho para comportar os novos objetos. Para isso, vector precisa mover todos os objetos para o novo vector e, por isso, ele chama o move-constructor das matrizes. Como não é feita nenhuma cópia dos dados, esse move é feito de forma eficiente.

No exemplo do move-assigment, nós estamos atribuíndo um RValue para uma variável Matrix. Como essa variável é temporária, ela não precisa ser copiada para a variável. O programa desaloca os dados da matrix 3×3 e move a matriz 4×4 para a variável m1. Assim, evitamos copiar desnecessariamente os dados dessa matriz.

Com esses dois métodos, conseguimos mover dados de forma eficiente sem fazer cópias. Caso não tivéssemos implementado o move-constructor e move-assigment, o sistema iria recorrer ao copy-constructor e copy-assigment, que copiaria os dados. Em um cenário onde a cópia dos dados é lenta, essa operação seria muito custosa. Com todos os métodos da Regra do 5 implementados, podemos ter certeza que a nossa classe não terá problemas no gerenciamento de memória e que será movida de forma eficiente quando necessário.

Regra do Zero

Nós finalizamos a Regra dos 5 e vimos que há uma complexidade considerável para fazer a implementação correta desses métodos em uma classe simples. Então, qual seria melhor forma de evitar erros e problemas de memória em produção? A melhor estratégia é não usar nem a Regra dos 3 ou do 5, mas usar a Regra do Zero. Essa regra diz que devemos projetar as classes de forma que nunca seja necessário implementar as funções da Regra dos 5.

Para isso, basta não utilizar estruturas alocadas dinamicamente com raw pointers. A STL contém diversas estruturas de dados para trabalhar com vetores, maps, sets e muitas outras estruturas de forma que utilizar raw pointers é desnecessário na maioria dos casos. Não somente isso, caso a sua aplicação realmente precise alocar dinamicamente outros objetos, a recomendação é utilizar smart pointers, que são estruturas muito mais poderosas e seguras para esses caso. Removendo o uso de raw pointers nas classes, não é necessário implementar nenhuma das funções das Regra do 5 e, consequentemente, a probabilidade de ocorrer bugs devido a implementações incorretas dessas funções é menor.

Como devemos passar os parâmetros para uma função?

Com base em tudo que aprendemos até agora, vem a pergunta: como devemos passar os parâmetros de uma função? Devemos fazer uma cópia? Passar uma referência? Parece que a melhor resposta seria mover sempre que possível e copiar somente se for necessário. Analise o seguinte exemplo, onde temos um método setData para receber uma Matrix e salvar em uma propriedade da classe.

class DataMatrix {
    public:
        DataMatrix(int row, int column) : m_matrix {Matrix{row, column}}{}
        void setData(Matrix&& matrix) {
            m_matrix = move(matrix);
        }
        void setData(const Matrix& matrix) {
            m_matrix = matrix;
        }
    private:
        Matrix m_matrix;
};

Tratar os casos de LValue e RValue separadamente parece fazer sentido: se for um RValue, nós fazemos um move-assigment da variável; se for um LValue, nós fazemos um copy-assigment. Embora essa implementação funcione corretamente, existe uma forma muito mais simples de obter os dois comportamentos em um único método: passando a variável por valor. Veja o próximo exemplo:

class DataMatrix {
    public:
        DataMatrix(int row, int column) : m_matrix {Matrix{row, column}}{}
        void setData(Matrix matrix) {
            m_matrix = move(matrix);
        }
    private:
        Matrix m_matrix;
};

int main() {  
    DataMatrix data {1, 1};    
    
    cout << "LValue" << endl;
    Matrix matrix3 {3, 3};    
    data.setData(matrix3);

    cout << "RValue" << endl;
    data.setData(Matrix{2, 2});
}

----------Output------------- 
Constructor Matrix 1x1
LValue
Constructor Matrix 3x3
Constructor Matrix 3x3
Copy-Constructor Matrix 3x3
Move-assignment Matrix 3x3
Destructor Matrix: Deallocate Matrix 1x1
RValue
Constructor Matrix 2x2
Move-assignment Matrix 2x2
Destructor Matrix: Deallocate Matrix 3x3
Destructor Matrix: Deallocate Matrix 3x3
Destructor Matrix: Deallocate Matrix 2x2

Neste caso, temos um único método setData que recebe a matriz passada por valor.

  • Se o método for chamado passando um LValue, esse valor será copy-constructed para a variável matrix. Depois, a variável será movida para m_matrix.
  • Se o método for chamado passando um RValue, o valor temporário será criado e movido para matrix . Por fim, matrix será movido novamente para m_matrix sem realizar nenhuma cópia.

Resumindo, a regra mais recomendada para passar argumentos de uma função é a seguinte:

  • Se a variável é um valor primitivo cujo valor não precisa ser alterado dentro da função (ou seja, não precisa ser passada por referência), passe a variável por valor. Se for necessário alterar o valor, passe por referência.
  • Se a variável é um objeto que nunca será copiado, passe uma referência para uma constante.
  • Se a variável é um objeto que será copiado, passe por valor.

Conclusão

Nesse tutorial, aprendemos os 2 novos métodos da Regra dos 5: move-constructor e move-assignment. Aprendemos sobre o uso do swap para efetuar a troca das propriedades de objetos e como a aplicação pode ter um ganho de performance considerável quando esses métodos são implementados corretamente.

Por fim, apresentamos a Regra do Zero. A melhor forma de trabalhar com C++ é evitar usar variáveis alocadas dinamicamente com raw pointers, pois assim não é necessário implementar nenhum dos métodos da Regra do 5.

Na próxima parte, nós vamos estudar uma otimização chama copy-elision, para reduzir o número de cópias dos objetos.

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.