C++ Lambda Code Generation con capturas de inicio en C++ 14




c++14 move (2)

Estoy tratando de entender / aclarar el código de código que se genera cuando las capturas se pasan a lambdas, especialmente en las capturas de inicio generalizadas agregadas en C ++ 14.

Dé los siguientes ejemplos de código enumerados a continuación, esta es mi comprensión actual de lo que generará el compilador.

Caso 1: captura por valor / captura predeterminada por valor

int x = 6;
auto lambda = [x]() { std::cout << x << std::endl; };

Equivaldría a:

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name(int x) : __x{x}{}
    void operator()() const { std::cout << __x << std::endl;}
private:
    int __x;
};

Por lo tanto, hay varias copias, una para copiar en el parámetro constructor y otra para copiar en el miembro, lo que sería costoso para tipos como vector, etc.

Caso 2: captura por referencia / captura predeterminada por referencia

int x = 6;
auto lambda = [&x]() { std::cout << x << std::endl; };

Equivaldría a:

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name(int& x) : x_{x}{}
    void operator()() const { std::cout << x << std::endl;}
private:
    int& x_;
};

El parámetro es una referencia y el miembro es una referencia, por lo que no hay copias. Agradable para tipos como vector, etc.

Caso 3:

Captura inicializada generalizada

auto lambda = [x = 33]() { std::cout << x << std::endl; };

Mi comprensión es que esto es similar al Caso 1 en el sentido de que se copia en el miembro.

Supongo que el compilador genera código similar a ...

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name() : __x{33}{}
    void operator()() const { std::cout << __x << std::endl;}
private:
    int __x;
};

También si tengo lo siguiente:

auto l = [p = std::move(unique_ptr_var)]() {
 // do something with unique_ptr_var
};

¿Cómo sería el constructor? ¿También lo mueve al miembro?


Esta pregunta no se puede responder completamente en código. Es posible que pueda escribir código algo "equivalente", pero el estándar no se especifica de esa manera.

Con eso fuera del camino, vamos a sumergirnos en [expr.prim.lambda] . Lo primero a tener en cuenta es que los constructores solo se mencionan en [expr.prim.lambda.closure]/13 :

El tipo de cierre asociado con una expresión lambda no tiene un constructor predeterminado si la expresión lambda tiene una captura lambda y un constructor predeterminado predeterminado. Tiene un constructor de copia predeterminado y un constructor de movimiento predeterminado ([class.copy.ctor]). Tiene un operador de asignación de copia eliminado si la expresión lambda tiene una captura lambda y los operadores de asignación predeterminados de copia y movimiento de lo contrario ([class.copy.assign]). [ Nota: Estas funciones miembro especiales se definen implícitamente como de costumbre y, por lo tanto, pueden definirse como eliminadas. - nota final ]

De manera inmediata, debe quedar claro que los constructores no son formalmente cómo se define la captura de objetos. Puede acercarse bastante (consulte la respuesta de cppinsights.io), pero los detalles difieren (observe cómo no se compila el código en esa respuesta para el caso 4).

Estas son las principales cláusulas estándar necesarias para analizar el caso 1:

[expr.prim.lambda.capture]/10

[...]
Para cada entidad capturada por copia, se declara un miembro de datos no estático sin nombre en el tipo de cierre. El orden de declaración de estos miembros no está especificado. El tipo de dicho miembro de datos es el tipo referenciado si la entidad es una referencia a un objeto, una referencia de valor al tipo de función referenciada si la entidad es una referencia a una función, o el tipo de la entidad capturada correspondiente de lo contrario. Un miembro de una unión anónima no será capturado por copia.

[expr.prim.lambda.capture]/11

Cada expresión de identificación dentro de la declaración compuesta de una expresión lambda que es un uso odr de una entidad capturada por copia se transforma en un acceso al miembro de datos correspondiente sin nombre del tipo de cierre. [...]

[expr.prim.lambda.capture]/15

Cuando se evalúa la expresión lambda, las entidades que se capturan mediante copia se utilizan para inicializar directamente cada miembro de datos no estático correspondiente del objeto de cierre resultante, y los miembros de datos no estáticos correspondientes a las capturas de inicio se inicializan como indicado por el inicializador correspondiente (que puede ser una copia o inicialización directa). [...]

Apliquemos esto a su caso 1:

Caso 1: captura por valor / captura predeterminada por valor

int x = 6;
auto lambda = [x]() { std::cout << x << std::endl; };

El tipo de cierre de este lambda tendrá un miembro de datos no estático sin nombre (llamémoslo __x ) de tipo int (ya que x no es una referencia ni una función), y los accesos a x dentro del cuerpo lambda se transforman en accesos a __x . Cuando evaluamos la expresión lambda (es decir, al asignar a lambda ), direct-initialize __x con x .

En resumen, solo se realiza una copia . El constructor del tipo de cierre no está involucrado, y no es posible expresarlo en C ++ "normal" (tenga en cuenta que el tipo de cierre tampoco es un tipo agregado ).

La captura de referencia involucra [expr.prim.lambda.capture]/12 :

Una entidad se captura por referencia si se captura implícita o explícitamente pero no se captura por copia. No se especifica si se declaran miembros de datos no estáticos adicionales sin nombre en el tipo de cierre para entidades capturadas por referencia. [...]

Hay otro párrafo sobre la captura de referencias de referencias, pero no lo estamos haciendo en ninguna parte.

Entonces, para el caso 2:

Caso 2: captura por referencia / captura predeterminada por referencia

int x = 6;
auto lambda = [&x]() { std::cout << x << std::endl; };

No sabemos si un miembro se agrega al tipo de cierre. x en el cuerpo lambda podría referirse directamente a la x afuera. Esto depende del compilador, y lo hará en alguna forma de lenguaje intermedio (que difiere de un compilador a otro), no en una transformación fuente del código C ++.

Las capturas de Init se detallan en [expr.prim.lambda.capture]/6 :

Una captura de inicio se comporta como si declara y captura explícitamente una variable de la forma auto init-capture ; cuya región declarativa es la declaración compuesta de la expresión lambda, excepto que:

  • (6.1) si la captura es por copia (ver a continuación), el miembro de datos no estático declarado para la captura y la variable se tratan como dos formas diferentes de referirse al mismo objeto, que tiene la vida útil de los datos no estáticos miembro, y no se realiza ninguna copia y destrucción adicional, y
  • (6.2) si la captura es por referencia, la vida útil de la variable finaliza cuando finaliza la vida útil del objeto de cierre.

Dado eso, veamos el caso 3:

Caso 3: captura inicializada generalizada

auto lambda = [x = 33]() { std::cout << x << std::endl; };

Como se dijo, imagine esto como una variable creada por auto x = 33; y explícitamente capturado por copia. Esta variable solo es "visible" dentro del cuerpo lambda. Como se señaló en [expr.prim.lambda.capture]/15 anteriormente, la inicialización del miembro correspondiente del tipo de cierre ( __x para la posteridad) es realizada por el inicializador dado al evaluar la expresión lambda.

Para evitar dudas: esto no significa que las cosas se inicializan dos veces aquí. El auto x = 33; es un "como si" heredara la semántica de capturas simples, y la inicialización descrita es una modificación de esa semántica. Solo ocurre una inicialización.

Esto también cubre el caso 4:

auto l = [p = std::move(unique_ptr_var)]() {
  // do something with unique_ptr_var
};

El miembro de tipo de cierre se inicializa con __p = std::move(unique_ptr_var) cuando se evalúa la expresión lambda (es decir, cuando se asigna l ). Los accesos a p en el cuerpo lambda se transforman en accesos a __p .

TL; DR: solo se realiza el número mínimo de copias / inicializaciones / movimientos (como cabría esperar / esperar). Supongo que las lambdas no se especifican en términos de una transformación de origen (a diferencia de otro azúcar sintáctico) exactamente porque expresar cosas en términos de constructores requeriría operaciones superfluas.

Espero que esto resuelva los temores expresados ​​en la pregunta :)


Hay menos necesidad de especular, usando cppinsights.io .

Caso 1:
Código

#include <memory>

int main() {
    int x = 33;
    auto lambda = [x]() { std::cout << x << std::endl; };
}

El compilador genera

#include <iostream>

int main()
{
  int x = 6;

  class __lambda_5_16
  {
    int x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_5_16(const __lambda_5_16 &) = default;
    // inline /*constexpr */ __lambda_5_16(__lambda_5_16 &&) noexcept = default;
    public: __lambda_5_16(int _x)
    : x{_x}
    {}

  };

  __lambda_5_16 lambda = __lambda_5_16(__lambda_5_16{x});
}

Caso 2:
Código

#include <iostream>
#include <memory>

int main() {
    int x = 33;
    auto lambda = [&x]() { std::cout << x << std::endl; };
}

El compilador genera

#include <iostream>

int main()
{
  int x = 6;

  class __lambda_5_16
  {
    int & x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_5_16(const __lambda_5_16 &) = default;
    // inline /*constexpr */ __lambda_5_16(__lambda_5_16 &&) noexcept = default;
    public: __lambda_5_16(int & _x)
    : x{_x}
    {}

  };

  __lambda_5_16 lambda = __lambda_5_16(__lambda_5_16{x});
}

Caso 3:
Código

#include <iostream>

int main() {
    auto lambda = [x = 33]() { std::cout << x << std::endl; };
}

El compilador genera

#include <iostream>

int main()
{

  class __lambda_4_16
  {
    int x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_4_16(const __lambda_4_16 &) = default;
    // inline /*constexpr */ __lambda_4_16(__lambda_4_16 &&) noexcept = default;
    public: __lambda_4_16(int _x)
    : x{_x}
    {}

  };

  __lambda_4_16 lambda = __lambda_4_16(__lambda_4_16{33});
}

Caso 4 (extraoficialmente):
Código

#include <iostream>
#include <memory>

int main() {
    auto x = std::make_unique<int>(33);
    auto lambda = [x = std::move(x)]() { std::cout << *x << std::endl; };
}

El compilador genera

// EDITED output to minimize horizontal scrolling
#include <iostream>
#include <memory>

int main()
{
  std::unique_ptr<int, std::default_delete<int> > x = 
      std::unique_ptr<int, std::default_delete<int> >(std::make_unique<int>(33));

  class __lambda_6_16
  {
    std::unique_ptr<int, std::default_delete<int> > x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x.operator*()).operator<<(std::endl);
    }

    // inline __lambda_6_16(const __lambda_6_16 &) = delete;
    // inline __lambda_6_16(__lambda_6_16 &&) noexcept = default;
    public: __lambda_6_16(std::unique_ptr<int, std::default_delete<int> > _x)
    : x{_x}
    {}

  };

  __lambda_6_16 lambda = __lambda_6_16(__lambda_6_16{std::unique_ptr<int, 
                                                     std::default_delete<int> >
                                                         (std::move(x))});
}

Y creo que este último código responde a su pregunta. Se produce un movimiento, pero no [técnicamente] en el constructor.

Las capturas en sí mismas no son const , pero puede ver que la función operator() es. Naturalmente, si necesita modificar las capturas, marca la lambda como mutable .







move