Esta é uma armadilha conhecida do C ++ 11 para loops?

89

Vamos imaginar que temos uma estrutura para manter 3 duplos com algumas funções-membro:

struct Vector {
  double x, y, z;
  // ...
  Vector &negate() {
    x = -x; y = -y; z = -z;
    return *this;
  }
  Vector &normalize() {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  // ...
};

Isso é um pouco artificial para simplificar, mas tenho certeza de que você concorda que código semelhante existe por aí. Os métodos permitem que você encadeie convenientemente, por exemplo:

Vector v = ...;
v.normalize().negate();

Ou ainda:

Vector v = Vector{1., 2., 3.}.normalize().negate();

Agora, se fornecermos as funções begin () e end (), poderíamos usar nosso Vector em um novo estilo de loop for, digamos, para fazer um loop sobre as 3 coordenadas x, y e z (você pode, sem dúvida, construir exemplos mais "úteis" substituindo Vector por, por exemplo, String):

Vector v = ...;
for (double x : v) { ... }

Podemos até fazer:

Vector v = ...;
for (double x : v.normalize().negate()) { ... }

e também:

for (double x : Vector{1., 2., 3.}) { ... }

No entanto, o seguinte (parece-me) está quebrado:

for (double x : Vector{1., 2., 3.}.normalize()) { ... }

Embora pareça uma combinação lógica dos dois usos anteriores, acho que este último uso cria uma referência pendente, enquanto os dois anteriores são perfeitamente adequados.

  • Isso é correto e amplamente apreciado?
  • Qual das opções acima é a parte "ruim" que deve ser evitada?
  • A linguagem seria melhorada mudando a definição do loop for baseado em intervalo de forma que os temporários construídos na expressão for existissem durante o loop?
ndkrempel
fonte
Por alguma razão, eu me lembro de uma pergunta muito semelhante sendo feita antes, mas esqueci como era chamada.
Pubby de
Considero isso um defeito de linguagem. A vida dos temporários não é estendida a todo o corpo do loop for, mas apenas para a configuração do loop for. Não é apenas a sintaxe de intervalo que sofre, a sintaxe clássica também. Em minha opinião, a vida útil dos temporários na instrução init deve se estender por toda a vida útil do loop.
edA-qa mort-ora-y
1
@ edA-qamort-ora-y: Eu tendo a concordar que há um pequeno defeito de linguagem escondido aqui, mas acho que é especificamente o fato de que a extensão da vida útil acontece implicitamente sempre que você vincula diretamente um temporário a uma referência, mas não em qualquer outra situação - parece uma solução incompleta para o problema subjacente de vidas temporárias, embora isso não queira dizer que seja óbvio qual seria a melhor solução. Talvez uma sintaxe explícita de 'extensão de vida' ao construir o temporário, que o faz durar até o final do bloco atual - o que você acha?
ndkrempel
@ edA-qamort-ora-y: ... isso equivale à mesma coisa que vincular o temporário a uma referência, mas tem a vantagem de ser mais explícito para o leitor que a 'extensão de vida' está ocorrendo, inline (em uma expressão , em vez de exigir uma declaração separada) e não exigir que você nomeie o temporário.
ndkrempel
1
possível duplicata de objeto temporário baseado em intervalo para
ildjarn

Respostas:

64

Isso é correto e amplamente apreciado?

Sim, seu entendimento das coisas está correto.

Qual das opções acima é a parte "ruim" que deve ser evitada?

A parte ruim é pegar uma referência de valor l para um temporário retornado de uma função e vinculá-la a uma referência de valor r. É tão ruim quanto isso:

auto &&t = Vector{1., 2., 3.}.normalize();

O Vector{1., 2., 3.}tempo de vida do temporário não pode ser estendido porque o compilador não tem ideia de que o valor de retorno de faz normalizereferência a ele.

A linguagem seria melhorada mudando a definição do loop for baseado em intervalo de forma que os temporários construídos na expressão for existissem durante o loop?

Isso seria altamente inconsistente com o funcionamento do C ++.

Isso impediria certas pegadinhas feitas por pessoas usando expressões encadeadas em temporários ou vários métodos de avaliação preguiçosa para expressões? Sim. Mas também exigiria código do compilador de caso especial, além de ser confuso quanto ao motivo pelo qual não funciona com outras construções de expressão.

Uma solução muito mais razoável seria informar ao compilador que o valor de retorno de uma função é sempre uma referência e this, portanto, se o valor de retorno estiver vinculado a uma construção de extensão temporária, ele estenderá o temporário correto. No entanto, essa é uma solução em nível de linguagem.

Atualmente (se o compilador suportar), você pode fazer com que normalize não ser chamado temporariamente:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector &normalize() && = delete;
};

Isso causará Vector{1., 2., 3.}.normalize()um erro de compilação, enquantov.normalize() funcionará bem. Obviamente, você não será capaz de corrigir coisas como esta:

Vector t = Vector{1., 2., 3.}.normalize();

Mas você também não poderá fazer coisas incorretas.

Como alternativa, conforme sugerido nos comentários, você pode fazer com que a versão de referência rvalue retorne um valor em vez de uma referência:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector normalize() && {
     Vector ret = *this;
     ret.normalize();
     return ret;
  }
};

Se Vectorfosse um tipo com recursos reais para mover, você poderia usar em seu Vector ret = std::move(*this);lugar. A otimização do valor de retorno nomeado torna isso razoavelmente ideal em termos de desempenho.

Nicol Bolas
fonte
1
O que pode tornar isso mais um "pegadinho" é que o novo loop for está sintaticamente ocultando o fato de que a ligação de referência está acontecendo nos bastidores - ou seja, é muito menos gritante do que os exemplos "tão ruins" acima. É por isso que parecia plausível sugerir a regra de extensão da vida útil extra, apenas para o novo loop for.
ndkrempel
1
@ndkrempel: Sim, mas se você vai propor um recurso de linguagem para corrigir isso (e, portanto, tem que esperar até 2017, pelo menos), eu preferiria que fosse mais abrangente, algo que pudesse resolver o problema de extensão temporária em todos os lugares .
Nicol Bolas
3
+1. Na última abordagem, ao invés de deletevocê poderia fornecer uma operação alternativa que retorna um rvalue: Vector normalize() && { normalize(); return std::move(*this); }(Eu acredito que a chamada para normalizedentro da função irá despachar para a sobrecarga de lvalue, mas alguém deve verificar :)
David Rodríguez - dribeas
3
Nunca vi isso &/ &&qualificação de métodos. É do C ++ 11 ou é alguma (talvez muito difundida) extensão de compilador proprietário. Oferece possibilidades interessantes.
Christian Rau de
1
@ChristianRau: É novo no C ++ 11 e análogo ao C ++ 03 "const" e qualificações "voláteis" de funções-membro não estáticas, no sentido de que qualifica "isso" em algum sentido. O g ++ 4.7.0 não oferece suporte para isso.
ndkrempel
25

for (double x: Vector {1., 2., 3.}. normalize ()) {...}

Isso não é uma limitação da linguagem, mas um problema com seu código. A expressão Vector{1., 2., 3.}cria um temporário, mas a normalizefunção retorna uma referência de lvalue . Porque a expressão é um lvalue , o compilador assume que o objeto estará vivo, mas como é uma referência a um temporário, o objeto morre depois que a expressão completa é avaliada, então você fica com uma referência pendente.

Agora, se você alterar seu design para retornar um novo objeto por valor, em vez de uma referência ao objeto atual, não haverá nenhum problema e o código funcionará conforme o esperado.

David Rodríguez - dribeas
fonte
1
Uma constreferência estenderia a vida útil do objeto neste caso?
David Stone
5
O que quebraria a semântica claramente desejada de normalize()uma função mutante em um objeto existente. Daí a questão. O fato de um temporário ter uma "vida útil estendida" quando usado para o propósito específico de uma iteração, e não de outra forma, é, eu acho, uma característica confusa.
Andy Ross
2
@AndyRoss: Por quê? Qualquer limite temporário a uma referência de valor r (ou const&) tem seu tempo de vida estendido.
Nicol Bolas
2
@ndkrempel: Ainda assim, não uma limitação do alcance baseada em loop, o mesmo problema viria se ligam a uma referência: Vector & r = Vector{1.,2.,3.}.normalize();. Seu design tem essa limitação, e isso significa que ou você está disposto a retornar por valor (o que pode fazer sentido em muitas circunstâncias, e mais ainda com rvalue-referências e mover ), ou então você precisa lidar com o problema no lugar de call: crie uma variável apropriada e, em seguida, use-a no loop for. Observe também que a expressão Vector v = Vector{1., 2., 3.}.normalize().negate();cria dois objetos ...
David Rodríguez - dribeas
1
@ DavidRodríguez-dribeas: o problema com a vinculação a const-reference é este: T const& f(T const&);está tudo bem. T const& t = f(T());está completamente bem. E aí, em outra TU você descobre isso T const& f(T const& t) { return t; }e chora ... Se operator+opera com valores, é mais seguro ; então o compilador pode otimizar a cópia (Quer velocidade? Passar por valores), mas isso é um bônus. A única vinculação de temporários que eu permitiria é a vinculação a referências de valores r, mas as funções devem então retornar valores para segurança e contar com Copiar Elisão / Semântica de Movimento.
Matthieu M.
4

IMHO, o segundo exemplo já está falho. O retorno dos operadores modificadores *thisé conveniente da maneira que você mencionou: permite o encadeamento de modificadores. Ele pode ser usado simplesmente para entregar o resultado da modificação, mas fazer isso está sujeito a erros porque pode ser facilmente esquecido. Se eu vir algo como

Vector v{1., 2., 3.};
auto foo = somefunction1(v, 17);
auto bar = somefunction2(true, v, 2, foo);
auto baz = somefunction3(bar.quun(v), 93.2, v.qwarv(foo));

Eu não suspeitaria automaticamente que as funções se modificam vcomo um efeito colateral. Claro, eles poderiam , mas seria confuso. Então, se eu fosse escrever algo assim, faria com que isso vpermanecesse constante. Para seu exemplo, eu adicionaria funções gratuitas

auto normalized(Vector v) -> Vector {return v.normalize();}
auto negated(Vector v) -> Vector {return v.negate();}

e então escrever os loops

for( double x : negated(normalized(v)) ) { ... }

e

for( double x : normalized(Vector{1., 2., 3}) ) { ... }

É IMO melhor legível e mais seguro. Obviamente, isso requer uma cópia extra; no entanto, para dados alocados em heap, isso provavelmente poderia ser feito em uma operação de movimentação barata do C ++ 11.

leftaround sobre
fonte
Obrigado. Como de costume, existem muitas opções. Uma situação em que sua sugestão pode não ser viável é se Vector for uma matriz (não alocada no heap) de 1000 duplos, por exemplo. Uma troca de eficiência, facilidade de uso e segurança de uso.
ndkrempel
2
Sim, mas raramente é útil ter estruturas com tamanho> ≈100 na pilha, de qualquer maneira.
esquerda por volta de