Invariantes de tempo de vida do objeto vs. semântica de movimento


13

Quando eu aprendi C ++ há muito tempo, enfatizou-me fortemente que parte do ponto do C ++ é que, assim como os loops têm "invariantes em loop", as classes também têm invariantes associados ao tempo de vida do objeto - coisas que devem ser verdadeiras enquanto o objeto estiver vivo. Coisas que devem ser estabelecidas pelos construtores e preservadas pelos métodos. O controle de acesso / encapsulamento existe para ajudá-lo a impor os invariantes. RAII é uma coisa que você pode fazer com essa ideia.

Desde o C ++ 11, agora temos a semântica de movimentação. Para uma classe que apóia a movimentação, a movimentação de um objeto não termina formalmente sua vida útil - a ação deve deixá-lo em um estado "válido".

Ao projetar uma classe, é uma prática inadequada projetá-la para que os invariantes da classe sejam preservados apenas até o ponto em que foram movidos? Ou está tudo bem se isso permitir que você vá mais rápido.

Para torná-lo concreto, suponha que eu tenha um tipo de recurso não copiável, mas móvel, da seguinte forma:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

E, por qualquer motivo, preciso criar um invólucro copiável para esse objeto, para que possa ser usado, talvez em algum sistema de despacho existente.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

Nesse copyable_opaqueobjeto, uma invariante da classe estabelecida na construção é que o membro o_sempre aponta para um objeto válido, pois não há um ctor padrão, e o único que não é um copiador garante isso. Todos os operator()métodos assumem que esse invariante se mantém e preservam depois.

No entanto, se o objeto for movido, o_ele apontará para nada. E depois desse ponto, chamar qualquer um dos métodos operator()causará falha no UB / a.

Se o objeto nunca for movido, a invariante será preservada até a chamada do dtor.

Suponhamos que, hipoteticamente, eu escrevi essa classe e, meses depois, meu colega de trabalho imaginário tenha experimentado a UB porque, em alguma função complicada em que muitos desses objetos estavam sendo embaralhados por algum motivo, ele se mudou de uma dessas coisas e depois chamou uma das seus métodos. Claramente, a culpa é dele no final do dia, mas essa classe é "mal projetada?"

Pensamentos:

  1. Geralmente, é uma má forma no C ++ criar objetos zumbis que explodem se você os tocar.
    Se você não pode construir algum objeto, não pode estabelecer os invariantes, lance uma exceção do ctor. Se você não conseguir preservar os invariantes de algum método, sinalize um erro de alguma forma e faça a reversão. Isso deve ser diferente para objetos movidos de?

  2. É suficiente apenas documentar "depois que esse objeto foi movido, é ilegal (UB) fazer algo com ele além de destruí-lo" no cabeçalho?

  3. É melhor afirmar continuamente que é válido em cada chamada de método?

Igual a:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

As afirmações não melhoram substancialmente o comportamento e causam uma desaceleração. Se o seu projeto usa o esquema "release build / debug build", em vez de sempre executar com asserções, acho que isso é mais atraente, pois você não paga pelas verificações na versão. Se você realmente não tem compilações de depuração, isso parece pouco atraente.

  1. É melhor tornar a classe copiável, mas não móvel?
    Isso também parece ruim e causa um impacto no desempenho, mas resolve o problema "invariável" de maneira direta.

Quais você consideraria as "melhores práticas" relevantes aqui?



Respostas:


20

Geralmente, é uma má forma no C ++ criar objetos zumbis que explodem se você os tocar.

Mas não é isso que você está fazendo. Você está criando um "objeto zumbi" que explodirá se você tocá-lo errado . O que, em última análise, não é diferente de qualquer outra pré-condição baseada em estado.

Considere a seguinte função:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Esta função é segura? Não; o usuário pode passar um vazio vector . Portanto, a função possui uma pré-condição de fato que vpossui pelo menos um elemento. Caso contrário, você receberá UB ao ligar func.

Portanto, esta função não é "segura". Mas isso não significa que está quebrado. Ele é quebrado apenas se o código que o utiliza violar a pré-condição. Talvez funcseja uma função estática usada como auxiliar na implementação de outras funções. Localizada dessa maneira, ninguém a chamaria de maneira a violar suas pré-condições.

Muitas funções, com escopo no namespace ou membros da classe, terão expectativas sobre o estado de um valor no qual operam. Se essas pré-condições não forem atendidas, as funções falharão, normalmente com o UB.

A biblioteca padrão C ++ define uma regra "válida, mas não especificada". Isso diz que, a menos que o padrão diga o contrário, todos os objetos que forem movidos serão válidos (é um objeto legal desse tipo), mas o estado específico desse objeto não será especificado. Quantos elementos possui uma mudança de vector? Isso não diz.

Isso significa que você não pode chamar nenhuma função que tenha qualquer condição prévia. vector::operator[]tem a condição prévia de que vectortenha pelo menos um elemento. Como você não conhece o estado do vector, não pode chamá-lo. Não seria melhor do que ligar funcsem primeiro verificar se o vectornão está vazio.

Mas isso também significa que as funções que não têm pré-condições são boas. Este é o código C ++ 11 perfeitamente legal:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assignnão tem pré-condições. Ele funcionará com qualquer vectorobjeto válido , mesmo que tenha sido movido.

Então você não está criando um objeto que está quebrado. Você está criando um objeto cujo estado é desconhecido.

Se você não pode construir algum objeto, não pode estabelecer os invariantes, lance uma exceção do ctor. Se você não conseguir preservar os invariantes de algum método, sinalize um erro de alguma forma e faça a reversão. Isso deve ser diferente para objetos movidos de?

Lançar exceções de um construtor de movimento é geralmente considerado ... rude. Se você mover um objeto que possui memória, estará transferindo a propriedade dessa memória. E isso geralmente não envolve nada que possa jogar.

Infelizmente, não podemos impor isso por várias razões . Temos que aceitar que o arremesso é uma possibilidade.

Também deve ser observado que você não precisa seguir o idioma "válido ainda não especificado". É simplesmente assim que a biblioteca padrão C ++ diz que o movimento para tipos padrão funciona por padrão . Certos tipos de biblioteca padrão têm garantias mais rigorosas. Por exemplo, unique_ptré muito claro o estado de uma unique_ptrinstância movida de : é igual a nullptr.

Assim, você pode optar por fornecer uma garantia mais forte, se desejar.

Lembre-se: o movimento é uma otimização de desempenho , que geralmente está sendo feita em objetos que estão prestes a serem destruídos. Considere este código:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Isso passará vpara o valor de retorno (assumindo que o compilador não o elide). E não há como referenciar vapós a conclusão da mudança. Portanto, qualquer trabalho que você fez para colocar vem um estado útil não tem sentido.

Na maioria dos códigos, a probabilidade de usar uma instância de objeto movido de é baixa.

É suficiente apenas documentar "depois que esse objeto foi movido, é ilegal (UB) fazer algo com ele além de destruí-lo" no cabeçalho?

É melhor afirmar continuamente que é válido em cada chamada de método?

O objetivo de ter pré-condições é não verificar essas coisas. operator[]tem uma condição prévia de que vectortenha um elemento com o índice especificado. Você recebe UB se tentar acessar fora do tamanho do vector. vector::at não tem essa pré-condição; ele lança explicitamente uma exceção se vectornão tiver esse valor.

Existem condições prévias por motivos de desempenho. Eles são para que você não precise verificar as coisas que o chamador poderia ter verificado por si próprio. Toda chamada para v[0]não precisa verificar se vestá vazia; somente o primeiro faz.

É melhor tornar a classe copiável, mas não móvel?

Não. De fato, uma classe nunca deve ser "copiável, mas não móvel". Se puder ser copiado, poderá poder ser movido chamando o construtor de cópia. Esse é o comportamento padrão do C ++ 11 se você declarar um construtor de cópias definido pelo usuário, mas não declarar um construtor de movimentação. E é o comportamento que você deve adotar se não desejar implementar uma semântica de movimento especial.

A semântica de movimentação existe para resolver um problema muito específico: lidar com objetos que possuem grandes recursos em que a cópia seria proibitivamente cara ou sem sentido (ou seja: identificadores de arquivo). Se o seu objeto não se qualificar, copiar e mover são iguais para você.


5
Agradável. +1. Eu observaria que: "O objetivo de ter pré-condições é não verificar essas coisas". - Eu não acho que isso vale para afirmações. Afirmações são IMHO uma boa e uma ferramenta válida para condições de verificação (na maioria das vezes, pelo menos.)
Martin Ba

3
A confusão de copiar / mover pode ser esclarecida ao perceber que um movedor pode deixar o objeto de origem em qualquer estado, inclusive idêntico ao novo objeto - o que significa que os resultados possíveis são um superconjunto do de um copiador.
MSalters 15/02/16
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.