Quem é o culpado por esse intervalo com base em mais de uma referência a temporário?

15

O código a seguir parece bastante inofensivo à primeira vista. Um usuário usa a função bar()para interagir com algumas funcionalidades da biblioteca. (Isso pode até ter funcionado por um longo tempo desde que bar()retornou uma referência a um valor não temporário ou similar.) Agora, no entanto, está simplesmente retornando uma nova instância de B. Bnovamente tem uma função a()que retorna uma referência a um objeto do tipo iterateable A. O usuário deseja consultar este objeto, o que leva a um segfault, pois o Bobjeto temporário retornado por bar()é destruído antes do início da iteração.

Eu sou indeciso quem (biblioteca ou usuário) é o culpado por isso. Todas as classes fornecidas pela biblioteca parecem limpas para mim e certamente não estão fazendo nada diferente (retornando referências a membros, retornando instâncias de pilha, ...) do que muitos outros códigos existentes. O usuário parece não fazer nada de errado também, ele está apenas repetindo algum objeto sem fazer nada a respeito da vida útil do objeto.

(Uma pergunta relacionada pode ser: Se alguém estabelecer a regra geral de que o código não deve "basear-se na iteração" sobre algo recuperado por mais de uma chamada em cadeia no cabeçalho do loop, pois qualquer uma dessas chamadas pode retornar um rvalue?)

#include <algorithm>
#include <iostream>

// "Library code"
struct A
{
    A():
        v{0,1,2}
    {
        std::cout << "A()" << std::endl;
    }

    ~A()
    {
        std::cout << "~A()" << std::endl;
    }

    int * begin()
    {
        return &v[0];
    }

    int * end()
    {
        return &v[3];
    }

    int v[3];
};

struct B
{
    A m_a;

    A & a()
    {
        return m_a;
    }
};

B bar()
{
    return B();
}

// User code
int main()
{
    for( auto i : bar().a() )
    {
        std::cout << i << std::endl;
    }
}
hllnll
fonte
6
Quando você descobriu quem culpar, qual será o próximo passo? Gritando com ele / ela?
JensG
7
Não, por que eu faria? Na verdade, estou mais interessado em saber onde o processo de desenvolvimento desse "programa" falhou em evitar esse problema no futuro.
hllnll
Isso não tem nada a ver com rvalues ​​ou com base em intervalo para loops, mas com o usuário não entendendo a vida útil do objeto corretamente.
James
Observação do site: este é o CWG 900, que foi fechado como Não é um defeito. Talvez a ata contenha alguma discussão.
Dyp 9/14
7
Quem é o culpado por isso? Bjarne Stroustrup e Dennis Ritchie, em primeiro lugar.
Mason Wheeler

Respostas:

14

Eu acho que o problema fundamental é uma combinação de recursos de linguagem (ou falta dela) do C ++. O código da biblioteca e o código do cliente são razoáveis ​​(como evidenciado pelo fato de que o problema está longe de ser óbvio). Se a vida útil temporáriaB fosse adequada estendida (até o final do loop), não haveria problema.

Tornar a vida temporária apenas o tempo suficiente, e não mais, é extremamente difícil. Nem mesmo um ad-hoc "todos os temporários envolvidos na criação do intervalo para um intervalo de transmissão ao vivo até o final do ciclo" ficaria sem efeitos colaterais. Considere o caso de B::a()retornar um intervalo independente do Bobjeto por valor. Então o temporárioB pode ser descartado imediatamente. Mesmo se alguém pudesse identificar com precisão os casos em que uma extensão vitalícia é necessária, como esses casos não são óbvios para os programadores, o efeito (destruidores chamado muito mais tarde) seria surpreendente e talvez uma fonte igualmente sutil de erros.

Seria mais desejável detectar e proibir tais bobagens, forçando o programador a elevar explicitamente bar()a uma variável local. Isso não é possível no C ++ 11 e provavelmente nunca será possível porque requer anotações. Rust faz isso, onde a assinatura .a()seria:

fn a<'x>(bar: &'x B) -> &'x A { bar.a }
// If we make it as explicit as possible, or
fn a(&self) -> &A { self.a }
// if we make it a method and rely on lifetime elision.

Aqui 'xestá uma variável ou região vitalícia, que é um nome simbólico pelo período de tempo em que um recurso está disponível. Francamente, as vidas são difíceis de explicar - ou ainda não descobrimos a melhor explicação -, então vou me restringir ao mínimo necessário para este exemplo e encaminhar o leitor inclinado para a documentação oficial .

O verificador de empréstimo notaria que o resultado da vida bar().a()útil precisa durar o ciclo. Formulada como uma restrição na vida 'x, podemos escrever: 'loop <= 'x. Ele também notaria que o receptor da chamada de método,, bar()é temporário. Os dois ponteiros estão associados ao mesmo tempo de vida, portanto, 'x <= 'temphá outra restrição.

Essas duas restrições são contraditórias! Precisamos , 'loop <= 'x <= 'tempmas 'temp <= 'loopque captura o problema com muita precisão. Por causa dos requisitos conflitantes, o código do buggy é rejeitado. Observe que essa é uma verificação em tempo de compilação e o código Rust geralmente resulta no mesmo código de máquina que o código C ++ equivalente; portanto, você não precisa pagar um custo em tempo de execução por isso.

No entanto, esse é um grande recurso a ser adicionado a um idioma e só funciona se todo o código o usar. o design das APIs também é afetado (alguns designs que seriam muito perigosos em C ++ tornam-se práticos, outros não podem ser feitos para funcionar bem com a vida útil). Infelizmente, isso significa que não é prático adicionar retroativamente ao C ++ (ou a qualquer idioma). Em resumo, a falha está na inércia que as línguas bem-sucedidas têm e no fato de Bjarne em 1983 não ter a bola de cristal e a previsão de incorporar as lições dos últimos 30 anos de pesquisa e experiência em C ++ ;-)

Obviamente, isso não ajuda em nada a evitar o problema no futuro (a menos que você mude para Rust e nunca use C ++ novamente). Pode-se evitar expressões mais longas com várias chamadas de método encadeadas (o que é bastante limitante e nem mesmo corrige remotamente todos os problemas da vida). Ou pode-se tentar adotar uma política de propriedade mais disciplinada sem a assistência do compilador: documente claramente que barretorna por valor e que o resultado deB::a() não deve sobreviver mais do Bque o a()invocado. Ao alterar uma função para retornar por valor, em vez de uma referência de vida mais longa, lembre-se de que essa é uma mudança de contrato . Ainda propenso a erros, mas pode acelerar o processo de identificação da causa quando isso acontece.


fonte
14

Podemos resolver esse problema usando os recursos do C ++?

O C ++ 11 adicionou a função de membro ref-qualifiers, que permite restringir a categoria de valor da instância da classe (expressão) na qual a função de membro pode ser chamada. Por exemplo:

struct foo {
    void bar() & {} // lvalue-ref-qualified
};

foo& lvalue ();
foo  prvalue();

lvalue ().bar(); // OK
prvalue().bar(); // error

Ao chamar a beginfunção membro, sabemos que provavelmente também precisaremos chamar a endfunção membro (ou algo parecido size, para obter o tamanho do intervalo). Isso requer que operemos com um valor l, pois precisamos corrigi-lo duas vezes. Portanto, você pode argumentar que essas funções de membro devem ser qualificadas para lvalue-ref.

No entanto, isso pode não resolver o problema subjacente: alias. A função membro begine endalias do objeto ou os recursos gerenciados pelo objeto. Se substituirmos begineend por uma única função range, devemos fornecer uma que possa ser chamada em rvalues:

struct foo {
    vector<int> arr;

    auto range() & // C++14 return type deduction for brevity
    { return std::make_pair(arr.begin(), arr.end()); }
};

for(auto const& e : foo().range()) // error

Esse pode ser um caso de uso válido, mas a definição acima rangenão o permite. Como não podemos resolver o temporário após a chamada da função de membro, pode ser mais razoável retornar um contêiner, ou seja, um intervalo de propriedade:

struct foo {
    vector<int> arr;

    auto range() &
    { return std::make_pair(arr.begin(), arr.end()); }

    auto range() &&
    { return std::move(arr); }
};

for(auto const& e : foo().range()) // OK

Aplicando isso ao caso do OP, e uma leve revisão do código

struct B {
    A m_a;
    A & a() { return m_a; }
};

Essa função de membro altera a categoria de valor da expressão: B()é um prvalue, mas B().a()é um lvalue. Por outro lado, B().m_aé um rvalue. Então, vamos começar tornando isso consistente. Existem duas maneiras de fazer isso:

struct B {
    A m_a;
    A &  a() &  { return m_a; }

    A && a() && { return std::move(m_a); }
    // or
    A    a() && { return std::move(m_a); }
};

A segunda versão, como dito acima, corrigirá o problema no OP.

Além disso, podemos restringir Bas funções de membro de:

struct A {
    // [...]

    int * begin() & { return &v[0]; }
    int * end  () & { return &v[3]; }

    int v[3];
};

Isso não terá nenhum impacto no código do OP, pois o resultado da expressão após o :loop for baseado no intervalo está vinculado a uma variável de referência. E essa variável (como expressão usada para acessar suabeginend funções e membro) é um valor l.

Obviamente, a questão é se a regra padrão deve ou não "funções de membro de alias em rvalues ​​devem retornar um objeto que possui todos os seus recursos, a menos que haja uma boa razão para não" . O alias que ele retorna pode ser legalmente usado, mas é perigoso da maneira que você o experimenta: não pode ser usado para prolongar a vida útil do temporário "pai":

// using the OP's definition of `struct B`,
// or version 1, `A && a() &&;`

A&&      a = B().a(); // bug: binds directly, dangling reference
A const& a = B().a(); // bug: same as above
A        a = B().a(); // OK

A&&      a = B().m_a; // OK: extends the lifetime of the temporary

No C ++ 2a, acho que você deve solucionar esse problema (ou semelhante) da seguinte maneira:

for( B b = bar(); auto i : b.a() )

em vez dos OP

for( auto i : bar().a() )

A solução alternativa especifica manualmente que o tempo de vida de b é o bloco inteiro do loop for.

Proposta que introduziu esta declaração de inicialização

Demonstração ao vivo

dyp
fonte