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

9

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?

Blair Davidson
fuente
1
@ rafix07 En ese caso, el código de información generado ni siquiera se compilará (intenta copiar-inicializar el miembro ptr único del argumento). cppinsights es útil para obtener una idea general, pero claramente no puede responder a esta pregunta aquí.
Max Langhof
Parece suponer que hay una traducción de lambda a functors como primer paso de compilación, o simplemente está buscando un código equivalente (es decir, el mismo comportamiento)? La forma en que un compilador específico genera código (y qué código genera) dependerá del compilador, la versión, la arquitectura, los indicadores, etc. Entonces, ¿está solicitando una plataforma específica? Si no, su pregunta no es realmente responsable. Además del código generado real, probablemente será más eficiente que los functores que usted enumera (por ejemplo, constructores en línea, evitando copias innecesarias, etc.).
Sander De Dycker
2
Si está interesado en lo que el estándar de C ++ tiene que decir al respecto, consulte [expr.prim.lambda] . Es demasiado para resumir aquí como respuesta.
Sander De Dycker

Respuestas:

2

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, profundicemos [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 de copia y movimiento predeterminados 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 ]

Entonces, desde el principio, debe quedar claro que los constructores no están formalmente como se define la captura de objetos. Puede acercarse bastante (vea la respuesta de cppinsights.io), pero los detalles difieren (observe cómo el código en esa respuesta para el caso 4 no se compila).


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 xque no es una referencia ni una función), y los accesos xdentro del cuerpo lambda se transforman en accesos __x. Cuando evaluamos la expresión lambda (es decir, cuando asignamos a lambda), inicializamos directamente __x con x.

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


La captura de referencia implica [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 las 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. xen el cuerpo lambda podría referirse directamente al xexterior. Esto depende del compilador, y lo hará en alguna forma de lenguaje intermedio (que difiere de compilador a compilador), no en una transformación fuente del código C ++.


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

Una captura inicial 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 auto x = 33;y capturada explícitamente por copia. Esta variable solo es "visible" dentro del cuerpo lambda. Como se señaló [expr.prim.lambda.capture]/15anteriormente, la inicialización del miembro correspondiente del tipo de cierre ( __xpara 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 __p = std::move(unique_ptr_var)cuando se evalúa la expresión lambda (es decir, cuando lse asigna a). Los accesos a pen 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 fuente (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 :)

Max Langhof
fuente
9

Caso 1 [x](){} : El constructor generado aceptará su argumento por constreferencia posiblemente calificada para evitar copias innecesarias:

__some_compiler_generated_name(const int& x) : x_{x}{}

Caso 2 [x&](){} : Sus suposiciones aquí son correctas, xse pasan y se almacenan por referencia.


Caso 3 [x = 33](){} : De nuevo correcto, xse inicializa por valor.


Caso 4 [p = std::move(unique_ptr_var)] : El constructor se verá así:

    __some_compiler_generated_name(std::unique_ptr<SomeType>&& x) :
        x_{std::move(x)}{}

así que sí, unique_ptr_varse "trasladó" al cierre. Consulte también el Artículo 32 de Scott Meyer en Effective Modern C ++ ("Usar la captura de inicio para mover objetos a los cierres").

lubgr
fuente
" const-calificado" ¿Por qué?
cpplearner
@cpplearner Mh, buena pregunta. Supongo que inserté eso porque se activó uno de esos automatismos mentales ^^ Al menos constno puede doler aquí debido a alguna ambigüedad / mejor coincidencia cuando no, constetc. De todos modos, ¿crees que debería eliminar el const?
lubgr
Creo que const debería permanecer, ¿qué pasa si el argumento pasado es realmente const?
Aconcagua
Entonces, ¿estás diciendo que aquí ocurren dos construcciones de movimiento (o copia)?
Max Langhof
Lo siento, quiero decir en el caso 4 (para los movimientos) y el caso 1 (para las copias). La parte copia de mi pregunta no tiene sentido en función de sus declaraciones (pero cuestiono esas declaraciones).
Max Langhof
5

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í no lo son const, pero puede ver que la operator()función sí lo es. Naturalmente, si necesita modificar las capturas, marque la lambda como mutable.

sweenish
fuente
El código que muestra para el último caso ni siquiera se compila. La conclusión "un movimiento ocurre, pero no [técnicamente] en el constructor" no puede ser apoyada por ese código.
Max Langhof
El Código del caso 4 ciertamente se compila en mi Mac. Me sorprende que el código expandido generado de cppinsights no se compile. El sitio ha sido, hasta este momento, bastante confiable para mí. Plantearé un problema con ellos. EDITAR: Confirmé que el código generado no se compila; eso no estaba claro sin esta edición.
sweenish
1
Enlace al tema en caso de interés: github.com/andreasfertig/cppinsights/issues/258 Todavía recomiendo el sitio para cosas como probar SFINAE y si ocurrirán o no lanzamientos implícitos.
sweenish