¿A quién se debe culpar por este rango basado en una referencia a temporal?

14

El siguiente código parece bastante inofensivo a primera vista. Un usuario utiliza la función bar() para interactuar con algunas funciones de la biblioteca. (Esto incluso 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 . B nuevamente 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 un fallo de seguridad, ya que el objeto B devuelto por bar() se destruye antes de que comience la iteración.

Soy indeciso a quien (la biblioteca o el usuario) tiene la culpa de esto. Todas las clases proporcionadas por la biblioteca me parecen limpias y ciertamente no hacen nada diferente (devolviendo referencias a miembros, devolviendo instancias de pila, ...) de lo que muchos otros códigos hacen. El usuario no parece hacer nada malo también, solo está iterando sobre un objeto sin hacer nada con respecto a la vida útil de los objetos.

(Una pregunta relacionada podría ser: ¿Debería establecerse la regla general de que el código no debería "basado en rango para iteración" sobre algo que se recupera mediante más de una llamada encadenada en el encabezado del bucle ya que cualquiera de estas llamadas? podría devolver un valor?)

#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;
    }
}
    
pregunta hllnll 09.11.2014 - 14:55

2 respuestas

13

Creo que el problema fundamental es una combinación de características del lenguaje (o la 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 dista mucho de ser obvio). Si el tiempo de vida del B temporal fuera adecuado extendido (hasta el final del bucle) no habría ningún problema.

Hacer que la vida de los temporales sea lo suficientemente larga, y ya no, es extremadamente difícil. Ni siquiera un "ad-hoc" (todos los temporarios involucrados en la creación del rango para un rango basado en tiempo real hasta el final del ciclo) no tendrían efectos secundarios. Considere el caso de que B::a() devuelva un rango que es independiente del objeto B por valor. Entonces el B temporal se puede descartar 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 (llamado más tarde por los destructores) sería sorprendente y quizás sea una fuente de errores igualmente sutil.

Sería más deseable simplemente detectar y prohibir esas tonterías, lo que obligaría al programador a elevar 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í 'x es una variable o región de por vida, que es un nombre simbólico para el período de tiempo en 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 recomendaré al lector inclinado a documentación oficial .

El verificador de préstamos notará que el resultado de bar().a() necesita vivir mientras el bucle se ejecute. Expresado como una restricción en la vida útil 'x , escribimos: 'loop <= 'x . También notaría que el receptor de la llamada al método, bar() , es temporal. Los dos punteros están asociados con la misma vida útil, por lo tanto, 'x <= 'temp es otra restricción.

¡Estas dos restricciones son contradictorias! Necesitamos 'loop <= 'x <= 'temp pero 'temp <= 'loop , que captura el problema con bastante precisión. Debido a los requisitos conflictivos, el código de buggy es rechazado. Tenga en cuenta que se trata de una verificación de tiempo de compilación y el código de Rust generalmente da como resultado el mismo código de máquina que el código de C ++ equivalente, por lo que no necesita pagar un costo de tiempo de ejecución por ello.

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 pueden jugarse bien con la vida útil). Por desgracia, eso significa que no es práctico agregar a C ++ (o cualquier idioma realmente) de manera retroactiva. En resumen, la falla está en la inercia que tienen los idiomas exitosos y el hecho de que Bjarne en 1983 no tuvo la bola de cristal ni 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 útil para evitar el problema en el futuro (a menos que cambie a Rust y nunca vuelva a usar C ++). Uno podría 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 la vida útil). O se podría intentar adoptar una política de propiedad más disciplinada sin la asistencia del compilador: documente claramente que bar devuelve por valor y que el resultado de B::a() no debe superar el B en el que se invoca a() . Al cambiar una función para devolverla por valor en lugar de una referencia de mayor duración, tenga en cuenta que se trata de un cambio de contrato . Aún es propenso a errores, pero puede acelerar el proceso de identificación de la causa cuando suceda.

    
respondido por el user7043 09.11.2014 - 17:41
7

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

C ++ 11 ha agregado ref-calificadores de función 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

Al llamar a la función miembro begin , sabemos que lo más probable es que también tengamos que llamar a la función miembro end (o algo así como size , para obtener el tamaño del rango). Esto requiere que operemos en un lvalor, ya que necesitamos abordarlo dos veces. Por lo tanto, puede argumentar que estas funciones miembro deben ser lvalue-ref-calificar.

Sin embargo, esto podría no resolver el problema subyacente: aliasing. Las funciones de miembro begin y end forman el alias del objeto o los recursos administrados por el objeto. Si reemplazamos begin y end por una sola función range , deberíamos proporcionar una que se pueda llamar en 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

Este podría ser un caso de uso válido, pero la definición anterior de range no lo permite. Dado que 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 a la revisión leve 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 prvalue, pero B().a() es un lvalue. Por otro lado, B().m_a es un rvalor. Así que vamos a empezar haciendo esto consistente. Hay dos formas 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 las funciones de los miembros de B :

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 : en el 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 miembro begin y end ) es un lvalue.

Por supuesto, la pregunta es si la regla predeterminada debe ser "aliasing. las funciones de los miembros en rvalues deben 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 experimentas: no se puede usar para prolongar la vida útil de su "padre" temporal:

// 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
    
respondido por el dyp 09.11.2014 - 22:45

Lea otras preguntas en las etiquetas