¿Quién tiene la culpa de este rango basado en una referencia a temporal?

15

El siguiente código parece bastante inofensivo a primera vista. Un usuario usa la función bar()para interactuar con alguna funcionalidad de biblioteca. (Esto puede haber funcionado durante mucho tiempo desde que bar()devolvió una referencia a un valor no temporal o similar). Ahora, sin embargo, simplemente está devolviendo una nueva instancia de B. Bnuevamente tiene una función a()que devuelve una referencia a un objeto del tipo iterable A. El usuario desea consultar este objeto, lo que conduce a una falla predeterminada, ya que el Bobjeto temporal devuelto por bar()se destruye antes de que comience la iteración.

No estoy decidido a quién (biblioteca o usuario) tiene la culpa de esto. Todas las clases proporcionadas por la biblioteca me parecen limpias y ciertamente no están haciendo nada diferente (devolviendo referencias a miembros, devolviendo instancias de pila, ...) que tanto otro código por ahí hace. El usuario no parece hacer nada mal también, solo está iterando sobre algún objeto sin hacer nada relacionado con la vida útil de ese objeto.

(Una pregunta relacionada podría ser: ¿Debería establecerse la regla general de que el código no debe "basarse en el rango para iterar" sobre algo que es recuperado por más de una llamada encadenada en el encabezado del bucle ya que cualquiera de estas llamadas puede devolver un 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
fuente
66
Cuando descubriste a quién culpar, ¿cuál será el siguiente paso? ¿Gritarle a él / ella?
JensG
77
¿No, porque yo debería? En realidad, estoy más interesado en saber dónde el proceso de pensamiento de desarrollar este "programa" no pudo evitar este problema en el futuro.
hllnll
Esto no tiene nada que ver con valores o rangos basados ​​en bucles, pero con el usuario que no comprende la vida útil del objeto correctamente.
James
Comentario del sitio: este es el CWG 900 que se cerró como No es un defecto. Quizás las minutas contengan alguna discusión.
dyp
8
¿Quién tiene la culpa de esto? Bjarne Stroustrup y Dennis Ritchie, ante todo.
Mason Wheeler

Respuestas:

14

Creo que el problema fundamental es una combinación de características del lenguaje (o falta de ellas) de C ++. Tanto el código de la biblioteca como el código del cliente son razonables (como lo demuestra el hecho de que el problema está lejos de ser obvio). Si la vida útil de lo temporal Bse extendiera adecuadamente (hasta el final del ciclo) no habría problema.

Hacer que la vida temporal sea lo suficientemente larga y ya no es extremadamente difícil. Ni siquiera un "ad hoc" más bien "todos los temporales involucrados en la creación del rango para un rango basado en vivo hasta el final del ciclo" carecería de efectos secundarios. Considere el caso de B::a()devolver un rango que es independiente delB objeto por valor. Entonces lo temporal Bpuede descartarse inmediatamente. Incluso si uno pudiera identificar con precisión los casos en los que es necesaria una extensión de por vida, ya que estos casos no son obvios para los programadores, el efecto (los destructores se llamaron mucho más tarde) sería sorprendente y quizás una fuente igualmente sutil de errores.

Sería más deseable detectar y prohibir tales tonterías, forzando al programador a elevarse explícitamente bar()a una variable local. Esto no es posible en C ++ 11, y probablemente nunca será posible porque requiere anotaciones. Rust hace esto, donde la firma de .a()sería:

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.

Aquí 'xhay una variable o región de por vida, que es un nombre simbólico para el período de tiempo que un recurso está disponible. Francamente, las vidas son difíciles de explicar, o aún no hemos descubierto la mejor explicación, por lo que me limitaré al mínimo necesario para este ejemplo y remitiré al lector inclinado a la documentación oficial .

El verificador de préstamos se daría cuenta de que el resultado de las bar().a()necesidades debe vivir mientras se ejecute el ciclo. Expresarse como una limitación en el tiempo de vida 'x, se escribe: 'loop <= 'x. También se daría cuenta de que el receptor de la llamada al método,bar() , es temporal. Los dos punteros están asociados con la misma vida útil, por 'x <= 'templo tanto, hay otra restricción.

¡Estas dos restricciones son contradictorias! Necesitamos 'loop <= 'x <= 'temppero'temp <= 'loop , que captura el problema con bastante precisión. Debido a los requisitos en conflicto, se rechaza el código con errores. Tenga en cuenta que esta es una verificación en tiempo de compilación y el código Rust generalmente resulta en el mismo código de máquina que el código C ++ equivalente, por lo que no necesita pagar un costo de tiempo de ejecución.

Sin embargo, esta es una gran característica para agregar a un idioma, y ​​solo funciona si todo el código lo usa. el diseño de las API también se ve afectado (algunos diseños que serían demasiado peligrosos en C ++ se vuelven prácticos, otros no se pueden hacer para jugar bien con las vidas). Por desgracia, eso significa que no es práctico agregar a C ++ (o cualquier lenguaje realmente) de forma retroactiva. En resumen, la culpa está en la inercia que tienen los lenguajes exitosos y el hecho de que Bjarne en 1983 no tuvo la bola de cristal y la previsión para incorporar las lecciones de los últimos 30 años de investigación y experiencia en C ++ ;-)

Por supuesto, eso no es nada útil para evitar el problema en el futuro (a menos que cambie a Rust y nunca vuelva a usar C ++). Se podrían evitar expresiones más largas con múltiples llamadas a métodos encadenados (lo cual es bastante limitante y ni siquiera soluciona remotamente todos los problemas de por vida). O se podría tratar de adoptar una política de propiedad más disciplinada sin la ayuda del compilador: documentar claramente que los barretornos por valor y que el resultado B::a()no debe sobrevivir Ba lo que a()se invoca. Cuando cambie una función para que regrese por valor en lugar de una referencia de mayor duración, tenga en cuenta que se trata de un cambio de contrato . Todavía es propenso a errores, pero puede acelerar el proceso de identificación de la causa cuando sucede.


fuente
14

¿Podemos resolver este problema usando las funciones de C ++?

C ++ 11 ha agregado calificadores de referencia de funciones miembro, lo que permite restringir la categoría de valor de la instancia de clase (expresión) a la que se puede llamar la función miembro. Por ejemplo:

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

foo& lvalue ();
foo  prvalue();

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

Cuando beginllamamos a la endfunción miembro, sabemos que lo más probable es que también necesitemos llamar a la función miembro (o algo así comosize , para obtener el tamaño del rango). Esto requiere que operemos con un valor l, ya que necesitamos abordarlo dos veces. Por lo tanto, puede argumentar que estas funciones miembro deben estar calificadas por lvalue-ref.

Sin embargo, esto podría no resolver el problema subyacente: alias. La función beginy endmiembro alias el objeto, o los recursos gestionados por el objeto. Si reemplazamos beginy endpor una sola función range, deberíamos proporcionar una que se pueda invocar en valores:

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

Este podría ser un caso de uso válido, pero la definición anterior de rangeno lo permite. Como no podemos abordar el temporal después de la llamada a la función miembro, podría ser más razonable devolver un contenedor, es decir, un rango de propiedad:

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 esto al caso del OP, y una ligera revisión del código

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

Esta función miembro cambia la categoría de valor de la expresión: B()es un valor priva, pero B().a()es un valor l. Por otro lado, B().m_aes un valor r. Entonces, comencemos haciendo esto consistente. Hay dos maneras de hacer esto:

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

La segunda versión, como se dijo anteriormente, solucionará el problema en el OP.

Además, podemos restringir Blas funciones miembro de:

struct A {
    // [...]

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

    int v[3];
};

Esto no tendrá ningún impacto en el código del OP, ya que el resultado de la expresión después del :bucle for basado en rango está vinculado a una variable de referencia. Y esta variable (como una expresión utilizada para acceder a sus funciones beginy endmiembros) es un valor l.

Por supuesto, la pregunta es si la regla predeterminada debería ser "alias las funciones de los miembros en los valores deberían devolver un objeto que posee todos sus recursos, a menos que haya una buena razón para no hacerlo" . El alias que devuelve se puede usar legalmente, pero es peligroso en la forma en que lo está experimentando: no se puede usar para extender la vida útil de su temporal "principal":

// 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

En C ++ 2a, creo que se supone que debe solucionar este problema (o similar) de la siguiente manera:

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

en lugar de los OP

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

La solución alternativa especifica manualmente que la vida útil de bes el bloque completo del ciclo for.

Propuesta que introdujo esta declaración init

Demo en vivo

dyp
fuente