¿Es este un error conocido de C ++ 11 para bucles?

89

Imaginemos que tenemos una estructura para contener 3 dobles con algunas funciones miembro:

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;
  }
  // ...
};

Esto es un poco artificial para simplificar, pero estoy seguro de que está de acuerdo en que existe un código similar. Los métodos le permiten encadenar convenientemente, por ejemplo:

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

O incluso:

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

Ahora, si proporcionamos las funciones begin () y end (), podríamos usar nuestro Vector en un bucle for de nuevo estilo, digamos para recorrer las 3 coordenadas x, y y z (sin duda puede construir más ejemplos "útiles" reemplazando Vector con, por ejemplo, String):

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

Incluso podemos hacer:

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

y también:

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

Sin embargo, lo siguiente (me parece a mí) está roto:

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

Si bien parece una combinación lógica de los dos usos anteriores, creo que este último uso crea una referencia colgante, mientras que los dos anteriores están completamente bien.

  • ¿Es esto correcto y ampliamente apreciado?
  • ¿Qué parte de lo anterior es la parte "mala" que debe evitarse?
  • ¿Se mejoraría el lenguaje cambiando la definición del bucle for basado en el rango de modo que los temporales construidos en la expresión for existan durante la duración del bucle?
ndkrempel
fuente
Por alguna razón, recuerdo que se hizo una pregunta muy similar antes, aunque olvidé cómo se llamaba.
Pubby
Considero esto un defecto del lenguaje. La vida de los temporales no se extiende a todo el cuerpo del bucle for, sino solo para la configuración del bucle for. No es solo la sintaxis de rango la que sufre, también la sintaxis clásica. En mi opinión, la vida de los temporales en la instrucción init debería extenderse por la vida completa del bucle.
edA-qa mort-ora-y
1
@ edA-qamort-ora-y: Tiendo a estar de acuerdo en que hay un ligero defecto de lenguaje acechando aquí, pero creo que es específicamente el hecho de que la extensión de la vida ocurre implícitamente cada vez que vincula directamente un temporal a una referencia, pero no en ninguna otra situación: esto parece una solución a medias para el problema subyacente de las vidas temporales, aunque eso no quiere decir que sea obvio cuál sería una mejor solución. Quizás una sintaxis explícita de 'extensión de por vida' al construir el temporal, lo que hace que dure hasta el final del bloque actual, ¿qué opinas?
ndkrempel
@ edA-qamort-ora-y: ... esto equivale a vincular el temporal a una referencia, pero tiene la ventaja de ser más explícito para el lector de que se está produciendo una 'extensión de vida', en línea (en una expresión , en lugar de requerir una declaración por separado) y no solicitarle que nombre el temporal.
ndkrempel
1
posible duplicado de objeto temporal en rango basado en
ildjarn

Respuestas:

64

¿Es esto correcto y ampliamente apreciado?

Sí, tu comprensión de las cosas es correcta.

¿Qué parte de lo anterior es la parte "mala" que debe evitarse?

La parte mala es tomar una referencia de valor l a un valor temporal devuelto por una función y vincularla a una referencia de valor r. Es tan malo como esto:

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

El Vector{1., 2., 3.}tiempo de vida del temporal no se puede extender porque el compilador no tiene idea de que el valor de retorno de lo hace normalizereferencia.

¿Se mejoraría el lenguaje cambiando la definición del bucle for basado en el rango de modo que los temporales construidos en la expresión for existan durante la duración del bucle?

Eso sería muy inconsistente con el funcionamiento de C ++.

¿Evitaría ciertos errores cometidos por personas que usan expresiones encadenadas en temporales o varios métodos de evaluación perezosa para expresiones? Si. Pero también requeriría un código de compilador de casos especiales, además de ser confuso en cuanto a por qué no funciona con otras construcciones de expresión.

Una solución mucho más razonable sería alguna forma de informar al compilador que el valor de retorno de una función siempre es una referencia a this, y por lo tanto, si el valor de retorno está vinculado a una construcción de extensión temporal, entonces extenderá el temporal correcto. Sin embargo, esa es una solución a nivel de idioma.

Actualmente (si el compilador lo admite), puede hacer que normalize no se pueda llamar de forma temporal:

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;
};

Esto provocará Vector{1., 2., 3.}.normalize()un error de compilación, mientras v.normalize()que funcionará bien. Obviamente, no podrá hacer cosas correctas como esta:

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

Pero tampoco podrá hacer cosas incorrectas.

Alternativamente, como se sugiere en los comentarios, puede hacer que la versión de referencia rvalue devuelva un valor en lugar de una referencia:

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;
  }
};

Si Vectorfuera un tipo con recursos reales para moverse, podría usar Vector ret = std::move(*this);en su lugar. La optimización del valor de retorno con nombre hace que esto sea razonablemente óptimo en términos de rendimiento.

Nicol Bolas
fuente
1
Lo que podría hacer que esto sea más un "error" es que el nuevo bucle for está ocultando sintácticamente el hecho de que el enlace de referencia está sucediendo bajo las sábanas, es decir, es mucho menos evidente que los ejemplos "igual de malos" anteriores. Es por eso que parecía plausible sugerir la regla de extensión de vida útil adicional, solo para el nuevo bucle for.
ndkrempel
1
@ndkrempel: Sí, pero si va a proponer una función de idioma para solucionar esto (y por lo tanto tiene que esperar hasta 2017 al menos), preferiría que fuera más completo, algo que pudiera resolver el problema de la extensión temporal en todas partes .
Nicol Bolas
3
+1. En el último enfoque, en lugar de deleteproporcionar una operación alternativa que devuelva un rvalue: Vector normalize() && { normalize(); return std::move(*this); }(creo que la llamada al normalizeinterior de la función se enviará a la sobrecarga de lvalue, pero alguien debería verificarlo :)
David Rodríguez - dribeas
3
Nunca he visto esta &/ &&calificación de métodos. ¿Es esto de C ++ 11 o es una extensión de compilador propietaria (tal vez generalizada)? Da interesantes posibilidades.
Christian Rau
1
@ChristianRau: es nuevo en C ++ 11, y es análogo a las calificaciones "const" y "volátil" de C ++ 03 de funciones miembro no estáticas, en el sentido de que califica "esto" en cierto sentido. Sin embargo, g ++ 4.7.0 no lo admite.
ndkrempel
25

para (doble x: Vector {1., 2., 3.}. normalizar ()) {...}

Eso no es una limitación del idioma, sino un problema con su código. La expresión Vector{1., 2., 3.}crea un temporal, pero la normalizefunción devuelve un lvalue-reference . Debido a que la expresión es un valor l , el compilador asume que el objeto estará vivo, pero debido a que es una referencia a un temporal, el objeto muere después de que se evalúa la expresión completa, por lo que queda una referencia pendiente.

Ahora, si cambia su diseño para devolver un nuevo objeto por valor en lugar de una referencia al objeto actual, entonces no habría ningún problema y el código funcionaría como se esperaba.

David Rodríguez - dribeas
fuente
1
¿Una constreferencia ampliaría la vida útil del objeto en este caso?
David Stone
5
Lo que rompería la semántica claramente deseada de normalize()una función mutante en un objeto existente. De ahí la pregunta. Que un temporal tenga una "vida útil prolongada" cuando se usa para el propósito específico de una iteración, y no de otra manera, es un error confuso.
Andy Ross
2
@AndyRoss: ¿Por qué? Cualquier enlace temporal a una referencia de valor r (o const&) tiene su vida extendida.
Nicol Bolas
2
@ndkrempel: Aún así, no una limitación del bucle basado en la gama, el mismo problema vendría si se unen a una referencia: Vector & r = Vector{1.,2.,3.}.normalize();. Su diseño tiene esa limitación, y eso significa que está dispuesto a devolver por valor (lo que podría tener sentido en muchas circunstancias, y más aún con rvalue-reference y move ), o bien debe manejar el problema en el lugar de llamada: crea una variable adecuada, luego úsala en el ciclo for. También tenga en cuenta que la expresión Vector v = Vector{1., 2., 3.}.normalize().negate();crea dos objetos ...
David Rodríguez - dribeas
1
@ DavidRodríguez-dribeas: el problema de la vinculación a const-reference es este: T const& f(T const&);está completamente bien. T const& t = f(T());está completamente bien. Y luego, en otra TU descubres eso T const& f(T const& t) { return t; }y lloras ... Si operator+opera sobre valores, es más seguro ; entonces el compilador puede optimizar la copia (¿Quiere velocidad? Pasar por valores), pero eso es una ventaja. El único enlace de temporales que permitiría es el enlace a referencias de valores r, pero las funciones deberían devolver valores por seguridad y depender de Copiar elisión / Movimiento de semántica.
Matthieu M.
4

En mi humilde opinión, el segundo ejemplo ya es defectuoso. Que los operadores modificadores regresen *thises conveniente en la forma que mencionaste: permite el encadenamiento de modificadores. Se puede usar simplemente para transmitir el resultado de la modificación, pero hacer esto es propenso a errores porque puede pasarse por alto fácilmente. Si veo 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));

No sospecharía automáticamente que las funciones se modifican vcomo efecto secundario. Por supuesto que podrían , pero sería confuso. Entonces, si tuviera que escribir algo como esto, me aseguraría de que se vmantenga constante. Para su ejemplo, agregaría funciones gratuitas

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

y luego escribe los bucles

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

y

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

Eso es IMO mejor legible y es más seguro. Por supuesto, requiere una copia adicional, sin embargo, para los datos asignados al montón, esto probablemente podría hacerse en una operación de movimiento de C ++ 11 barata.

izquierda
fuente
Gracias. Como de costumbre, hay muchas opciones. Una situación en la que su sugerencia puede no ser viable es si Vector es una matriz (no asignada al montón) de 1000 dobles, por ejemplo. Una compensación de eficiencia, facilidad de uso y seguridad de uso.
ndkrempel
2
Sí, pero rara vez es útil tener estructuras con un tamaño> ≈100 en la pila, de todos modos.
izquierda rotonda alrededor