Motivação e uso de construtores de movimento em C ++

17

Recentemente, li sobre construtores de movimento em C ++ (veja, por exemplo, aqui ) e estou tentando entender como eles funcionam e quando devo usá-los.

Tanto quanto eu entendo, um construtor de movimentação é usado para aliviar os problemas de desempenho causados ​​pela cópia de objetos grandes. A página da wikipedia diz: "Um problema de desempenho crônico com o C ++ 03 são as cópias profundas caras e desnecessárias que podem acontecer implicitamente quando objetos são passados ​​por valor".

Eu costumo abordar essas situações

  • passando os objetos por referência ou
  • usando ponteiros inteligentes (por exemplo, boost :: shared_ptr) para passar pelo objeto (os ponteiros inteligentes são copiados em vez do objeto).

Quais são as situações em que as duas técnicas acima não são suficientes e o uso de um construtor de movimentação é mais conveniente?

Giorgio
fonte
1
Além do fato de que a semântica de movimentação pode alcançar muito mais (como dito nas respostas), você não deve perguntar quais são as situações em que a passagem por referência ou por ponteiro inteligente não é suficiente, mas se essas técnicas são realmente a melhor e mais limpa maneira fazer isso (que Deus tenha cuidado shared_ptrapenas por uma cópia rápida) e se a semântica de movimento pode conseguir o mesmo com quase nenhuma penalidade de codificação, semântica e limpeza.
Chris diz Reinstate Monica

Respostas:

16

A semântica de movimentação introduz uma dimensão inteira ao C ++ - não está lá apenas para permitir que você retorne valores mais baratos.

Por exemplo, sem mover-semântica std::unique_ptrnão funciona - veja std::auto_ptr, que foi preterido com a introdução da movimentação-semântica e removido no C ++ 17. Mover um recurso é muito diferente de copiá-lo. Permite a transferência da propriedade de um item exclusivo.

Por exemplo, não vamos olhar std::unique_ptr, pois é bastante bem discutido. Vejamos, digamos, um objeto de buffer do vértice no OpenGL. Um buffer de vértice representa memória na GPU - ele precisa ser alocado e desalocado usando funções especiais, possivelmente com restrições rígidas quanto tempo pode durar. Também é importante que apenas um proprietário o use.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Agora, isso pode ser feito com um std::shared_ptr- mas esse recurso não deve ser compartilhado. Isso torna confuso o uso de um ponteiro compartilhado. Você poderia usar std::unique_ptr, mas isso ainda requer semântica de movimentação.

Obviamente, eu não implementei um construtor de movimentos, mas você entendeu.

O importante aqui é que alguns recursos não são copiáveis . Você pode passar os ponteiros em vez de se mover, mas, a menos que você use unique_ptr, há um problema de propriedade. Vale a pena ser o mais claro possível sobre qual é a intenção do código; portanto, um construtor de movimentos é provavelmente a melhor abordagem.

Máx.
fonte
Obrigado pela resposta. O que aconteceria se alguém usasse um ponteiro compartilhado aqui?
Giorgio
Tento me responder: o uso de um ponteiro compartilhado não permitiria controlar a vida útil do objeto, enquanto é um requisito que o objeto possa viver apenas por um certo período de tempo.
Giorgio
3
@Giorgio Você poderia usar um ponteiro compartilhado, mas seria semanticamente errado. Não é possível compartilhar um buffer. Além disso, isso essencialmente faria você passar um ponteiro para um ponteiro (já que o vbo é basicamente um ponteiro exclusivo para a memória da GPU). Alguém que visualiza seu código posteriormente pode se perguntar 'Por que há um ponteiro compartilhado aqui? É um recurso compartilhado? Isso pode ser um bug! '. É melhor ser o mais claro possível sobre qual era a intenção original.
Max
@ Giorgio Sim, isso também faz parte do requisito. Quando o 'renderizador', nesse caso, deseja desalocar algum recurso (possivelmente não há memória suficiente para novos objetos na GPU), não deve haver outro identificador para a memória. Usar um shared_ptr que está fora do escopo funcionaria se você não o mantivesse em outro lugar, mas por que não torná-lo completamente óbvio quando você pode?
Max
@Giorgio Veja minha edição para outra tentativa de esclarecimento.
Max
5

A semântica de movimento não é necessariamente uma melhoria tão grande quando você retorna um valor - e quando / se você usa um shared_ptr(ou algo semelhante) provavelmente está pessimizando prematuramente. Na realidade, quase todos os compiladores razoavelmente modernos fazem o que chamamos de Return Value Optimization (RVO) e Node Return Value Optimization (NRVO). Isto significa que quando você está retornando um valor, em vez de realmente copiar o valor em tudo, eles simplesmente passam um ponteiro / referência oculta para onde o valor será atribuído após o retorno, e a função usa isso para criar o valor para o final. O padrão C ++ inclui disposições especiais para permitir isso, portanto, mesmo que (por exemplo) seu construtor de cópias tenha efeitos colaterais visíveis, não é necessário usar o construtor de cópias para retornar o valor. Por exemplo:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

A idéia básica aqui é bastante simples: crie uma classe com conteúdo suficiente, e preferimos evitar copiá-la, se possível ( std::vectorpreencheremos com 32767 ints aleatórios). Temos um copiador explícito que nos mostrará quando / se for copiado. Também temos um pouco mais de código para fazer algo com os valores aleatórios no objeto, para que o otimizador não elimine (pelo menos facilmente) tudo sobre a classe apenas porque ela não faz nada.

Temos então algum código para retornar um desses objetos de uma função e, em seguida, usamos a soma para garantir que o objeto seja realmente criado, e não apenas ignorado completamente. Quando executamos, pelo menos com os compiladores mais recentes / modernos, descobrimos que o construtor de cópias que escrevemos nunca é executado - e sim, tenho certeza de que mesmo uma cópia rápida com a shared_ptrainda é mais lenta do que não copiar. em absoluto.

Mover permite que você faça um bom número de coisas que você simplesmente não poderia fazer (diretamente) sem elas. Considere a parte "mesclar" de uma classificação de mesclagem externa - você tem, digamos, 8 arquivos que serão mesclados. Idealmente, você gostaria de colocar todos os 8 desses arquivos em um vector- mas como vector(a partir do C ++ 03) precisa copiar elementos e ifstreamnão pode ser copiado, você está preso a alguns unique_ptr/ shared_ptr, ou algo nessa ordem para poder colocá-los em um vetor. Observe que, mesmo que (por exemplo) espaçemos o reserveespaço vectorpara que tenhamos certeza de que nossos ifstreams nunca serão realmente copiados, o compilador não saberá disso, portanto o código não será compilado, mesmo sabendo que o construtor de cópias nunca será usado de qualquer maneira.

Mesmo que ainda não possa ser copiado, no C ++ 11 um ifstream pode ser movido. Neste caso, os objetos provavelmente não vai nunca ser movido, mas o fato de que eles poderiam ser, se necessário, mantém o feliz compilador, para que possamos colocar nossos ifstreamobjetos em um vectordiretamente, sem qualquer hacks ponteiro inteligente.

Um vetor que se expande é um exemplo bastante decente de um tempo em que a semântica de movimento realmente pode ser / é útil. Nesse caso, o RVO / NRVO não ajudará, porque não estamos lidando com o valor de retorno de uma função (ou qualquer coisa muito semelhante). Temos um vetor que contém alguns objetos e queremos movê-los para um novo pedaço maior de memória.

No C ++ 03, isso foi feito criando cópias dos objetos na nova memória e destruindo os objetos antigos na memória antiga. Fazer todas essas cópias apenas para jogar fora as antigas, no entanto, era uma perda de tempo. No C ++ 11, você pode esperar que eles sejam movidos. Isso normalmente nos permite, em essência, fazer uma cópia superficial em vez de uma cópia profunda (geralmente muito mais lenta). Em outras palavras, com uma string ou vetor (por apenas alguns exemplos), apenas copiamos o (s) ponteiro (s) nos objetos, em vez de fazer cópias de todos os dados a que esses ponteiros se referem.

Jerry Coffin
fonte
Obrigado pela explicação muito detalhada. Se eu entendi corretamente, todas as situações em que a movimentação entra em ação podem ser tratadas por ponteiros normais, mas não seria seguro (complexo e propenso a erros) programar todo o malabarismo do ponteiro a cada vez. Portanto, em vez disso, existe um unique_ptr (ou mecanismo semelhante) sob o capô e a semântica de movimentação garante que, no final do dia, haja apenas algumas cópias de ponteiro e nenhuma cópia de objeto.
Giorgio
@Giorgio: Sim, isso é praticamente correto. A linguagem realmente não adiciona semântica de movimento; adiciona referências a rvalue. Uma referência rvalue (obviamente, o suficiente) pode vincular a um rvalue; nesse caso, você sabe que é seguro "roubar" sua representação interna dos dados e apenas copiar seus ponteiros em vez de fazer uma cópia profunda.
Jerry Coffin
4

Considerar:

vector<string> v;

Ao incluir cadeias de caracteres em v, ele será expandido conforme necessário e, a cada realocação, as cadeias de caracteres deverão ser copiadas. Com os construtores de movimentação, isso é basicamente um problema.

Claro, você também pode fazer algo como:

vector<unique_ptr<string>> v;

Mas isso funcionará bem apenas porque os std::unique_ptrimplementadores movem o construtor.

O uso std::shared_ptrfaz sentido apenas em situações (raras) quando você realmente possui uma propriedade compartilhada.

Nemanja Trifunovic
fonte
mas e se, em vez de stringtermos uma instância de Foo30 membros de dados? A unique_ptrversão não seria mais eficiente?
Vassilis
2

Os valores de retorno são onde eu mais gostaria de passar por valor em vez de algum tipo de referência. Ser capaz de retornar rapidamente um objeto 'na pilha' sem uma penalidade de desempenho maciça seria bom. Por outro lado, não é particularmente difícil contornar isso (ponteiros compartilhados são tão fáceis de usar ...), por isso não tenho certeza de que vale a pena fazer um trabalho extra em meus objetos apenas para poder fazer isso.

Michael Kohne
fonte
Normalmente, também uso ponteiros inteligentes para quebrar objetos retornados de uma função / método.
Giorgio
1
@Giorgio: Isso é definitivamente ofuscante e lento.
DeadMG 31/05
Compiladores modernos deve executar um movimento automático, se você voltar um simples on-the-stack objeto, por isso não há necessidade de ptrs compartilhados etc.
Christian Severin