Copy-Elision em C++

Close-up of balanced smooth stones on asphalt representing stability and calm.

Nas parte 1 e na parte 2, nós estudamos todos os métodos da Regra do 5 e sua importância para uma aplicação sem problemas de memória. Nesta terceira parte, vamos estudar otimização que o compilador faz chamada copy-elision. Vamos ver o seguinte exemplo compilado com a versão do C++14 com a flag no-elide-constructors.

Matrix matrixLValue(){
    Matrix matrix {1, 1};
    return matrix;
}

Matrix matrixRValue(){
    return Matrix {1, 1};
}

int main() {  
    cout << "Exemplo 1" << endl;
    Matrix matrix1 {matrixLValue()};

    cout << "Exemplo 2" << endl;
    Matrix matrix2 {matrixRValue()};

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


----------Output------------- 
// g++ -std=c++14 -fno-elide-constructors
Exemplo 1
Constructor Matrix 1x1                    // matrix {1, 1} is constructed
Constructor Matrix 1x1                    // constructor called by copy-constructor
Copy-Constructor Matrix 1x1               // temp Matrix from matrixLValue is copied to temp matrix from main
Destructor Matrix: Deallocate Matrix 1x1  // temp Matrix from matrixLValue is destroyed
Move-Constructor Matrix 1x1               // temp matrix from main is move-constructed to matrix1
Destructor Matrix: Deallocate Matrix 0x0  // temp matrix from main is destroyed

Exemplo 2
Constructor Matrix 1x1                    // temp Matrix matrix {1, 1} constructed
Move-Constructor Matrix 1x1               // temp matrix from matrixRValue is moved to temp matrix from main
Destructor Matrix: Deallocate Matrix 0x0  // temp matrix from matrixRValue is destroyed
Move-Constructor Matrix 1x1               // temp matrix from main is move-constructed to matrix2
Destructor Matrix: Deallocate Matrix 0x0  // temp matrix from main is destroyed

End program
Destructor Matrix: Deallocate Matrix 1x1  // matrix1 destroyed
Destructor Matrix: Deallocate Matrix 1x1  // matrix2 destroyed

----------Output------------- 
// g++ -std=c++14
Exemplo 1
Constructor Matrix 1x1                    // NRVO. Matrix {1,1} is directed contructed in matrix1

Exemplo 2
Constructor Matrix 1x1                    // NRO. Matrix {1,1} is directed contructed in matrix2
End program
Destructor Matrix: Deallocate Matrix 1x1  // matrix1 destroyed
Destructor Matrix: Deallocate Matrix 1x1  // matrix2 destroyed

É possível perceber que a primeira versão com a flag no-elide-constructors fez diversas operações de move-constructor e copy-constructor enquanto a segunda versão implementa uma otimização chamada copy-elision. Vamos analisar os 2 exemplos:

Matrix matrixLValue(){
    Matrix matrix {1, 1};
    return matrix;
}

O método matrixLValue retorna um LValue, pois matrix tem um endereço na memória. A otimização de evitar cópias e construir o objeto diretamente na variável matrix1 é chamada NRVO ou Named Return Value Optimisation. Observe que algumas condições precisam ser cumpridas para essa otimização ocorrer:

  • O returno deve ser do tipo return object;. Caso o retorno seja uma expressão, não ocorrerá essa otimização. Por exemplo, se houver um ternário, o compilador irá recorrer ao copy-constructor tradicional:
Matrix matrixRValue(){
    Matrix matrix {1, 1};
    return true ? matrix : matrix;
}

int main() {  
    cout << "Exemplo 1" << endl;
    Matrix matrix1 {matrixRValue()};

    return 0;
}

----------Output------------- 
Exemplo 1
Constructor Matrix 1x1
Constructor Matrix 1x1
Copy-Constructor Matrix 1x1
Destructor Matrix: Deallocate Matrix 1x1
Destructor Matrix: Deallocate Matrix 1x1

  • O retorno deve ser uma variável local ou um parâmetro da função. Retornar uma propriedade de um objeto impede essa otimização.
  • Mesmo se o copy-constructor ou move-constructor foram suprimidos pela otimização, é importante que eles existam para que essa otimização seja possível. O objeto deve copiável e movível.
  • No C++17, não é obrigatório a ocorrência de NRVO.

Vamos agora analisar o segundo exemplo:

Matrix matrixRValue(){
    return Matrix {1, 1};
}

Neste caso, o retorn é um RValue. A otimização para evitar o copy-constructor e move-constructor para esse caso é chamado RVO ou Retun Value Optimization. Algumas observações sobre o RVO são:

  • O retorno deve ser do tipo return value; igual no NRVO. Não pode haver uma expressão
  • No C++17, a otimização RVO é garantida.

Copy-elision consegue suprimir também o caso de múltiplas chamadas de move-constructor. Veja o seguinte exemplo:

int main() {  
    Matrix matrix {Matrix{Matrix{Matrix{1, 1}}}};
    return 0;
}

----------Output------------- 
// g++ -std=c++14 -fno-elide-constructors
Constructor Matrix 1x1
Move-Constructor Matrix 1x1
Move-Constructor Matrix 1x1
Move-Constructor Matrix 1x1
Destructor Matrix: Deallocate Matrix 0x0
Destructor Matrix: Deallocate Matrix 0x0
Destructor Matrix: Deallocate Matrix 0x0
Destructor Matrix: Deallocate Matrix 1x1

----------Output------------- 
// g++ -std=c++14
Constructor Matrix 1x1
Destructor Matrix: Deallocate Matrix 1x1

Nessse caso, temos várias instâncias de Matrix criadas, que precisam ser move-constructed em sequência até a atribuição da variável matriz. O compilador consegue identificar que esses move-constructors não seriam necessários e os elimina, de forma que somente o construtor é chamado sem nenhum cópia ou movimentação. Obviamente, se o seu código dependesse desses move-constructors para funcionar, haveria um problema. Entretanto, esse deveria ser um cenário anormal, pois idealmente o seu código não deveria depender desse comportamento.

Conclusão

Copy-elision é uma otimização muito eficiente para eliminar cópias desnecessárias de objetos. Para os exemplos demonstrados, pode ser que a diferença de performance não seja relevante. Contudo, essa otimização pode ser muito impactante se o objeto for complexo para copiar como, por exemplo, na hora de redimensionar um vector.

Referências