Esto es algo que siempre me ha estado molestando como una característica de las expresiones lambda de C ++: el tipo de expresión lambda de C ++ es único y anónimo, simplemente no puedo escribirlo. Incluso si creo dos lambdas que son sintácticamente exactamente iguales, los tipos resultantes se definen para ser distintos. La consecuencia es que a) las lambdas solo se pueden pasar a las funciones de plantilla que permiten que el tiempo de compilación, el tipo inefable se pase junto con el objeto, yb) que las lambdas solo son útiles una vez que se borran el tipo mediante std::function<>
.
Bien, pero así es como lo hace C ++, estaba listo para descartarlo como una característica molesta de ese lenguaje. Sin embargo, acabo de enterarme de que Rust aparentemente hace lo mismo: cada función o lambda de Rust tiene un tipo único y anónimo. Y ahora me pregunto: ¿Por qué?
Entonces, mi pregunta es la siguiente:
¿Cuál es la ventaja, desde el punto de vista del diseñador de idiomas, de introducir el concepto de un tipo único y anónimo en un idioma?
fuente
std::function
. Una lambda que se ha pasado a una función de plantilla se puede llamar directamente sin involucrarstd::function
. Luego, el compilador puede incorporar la lambda en la función de plantilla, lo que mejorará la eficiencia del tiempo de ejecución.{ int i = 42; auto foo = [&i](){ return i; }; } { int i = 13; auto foo = [&i](){ return i; }; }
ya que la variable a la que se refiere es diferente, aunque textualmente sean iguales. Si simplemente dice que todos son únicos, no tiene que preocuparse por tratar de averiguarlo.lambdas_type = decltype( my_lambda);
[](auto) {}
? ¿Debería tener un tipo, para empezar?Respuestas:
Muchos estándares (especialmente C ++) adoptan el enfoque de minimizar cuánto exigen a los compiladores. Francamente, ¡ya exigen suficiente! Si no tienen que especificar algo para que funcione, tienden a dejar la implementación definida.
Si las lambdas no fueran anónimas, tendríamos que definirlas. Esto tendría que decir mucho sobre cómo se capturan las variables. Considere el caso de una lambda
[=](){...}
. El tipo tendría que especificar qué tipos realmente fueron capturados por la lambda, lo que podría ser no trivial de determinar. Además, ¿qué pasa si el compilador optimiza con éxito una variable? Considerar:static const int i = 5; auto f = [i]() { return i; }
Un compilador de optimización podría reconocer fácilmente que el único valor posible
i
que podría capturarse es 5 y reemplazarlo porauto f = []() { return 5; }
. Sin embargo, si el tipo no es anónimo, esto podría cambiar el tipo o forzar al compilador a optimizar menos, almacenandoi
aunque en realidad no lo necesite. Esta es una bolsa completa de complejidad y matices que simplemente no es necesaria para lo que las lambdas debían hacer.Y, en el caso de que realmente necesite un tipo no anónimo, siempre puede construir la clase de cierre usted mismo y trabajar con un functor en lugar de una función lambda. Por lo tanto, pueden hacer que lambdas maneje el caso del 99% y dejar que usted codifique su propia solución en el 1%.
El deduplicador señaló en los comentarios que no abordé la singularidad tanto como el anonimato. Estoy menos seguro de los beneficios de la singularidad, pero vale la pena señalar que el comportamiento de lo siguiente es claro si los tipos son únicos (la acción se instanciará dos veces).
int counter() { static int count = 0; return count++; } template <typename FuncT> void action(const FuncT& func) { static int ct = counter(); func(ct); } ... for (int i = 0; i < 5; i++) action([](int j) { std::cout << j << std::endl; }); for (int i = 0; i < 5; i++) action([](int j) { std::cout << j << std::endl; });
Si los tipos no fueran únicos, tendríamos que especificar qué comportamiento debería ocurrir en este caso. Eso puede ser complicado. Algunas de las cuestiones que se plantearon sobre el tema del anonimato también levantan su fea cabeza en este caso por la singularidad.
fuente
Las lambdas no son solo funciones, son una función y un estado . Por lo tanto, tanto C ++ como Rust los implementan como un objeto con un operador de llamada (
operator()
en C ++, los 3Fn*
rasgos en Rust).Básicamente,
[a] { return a + 1; }
en C ++ se convierte en azúcar a algo comostruct __SomeName { int a; int operator()() { return a + 1; } };
luego usando una instancia de
__SomeName
donde se usa el lambda.Mientras que en Rust,
|| a + 1
en Rust desugar a algo como{ struct __SomeName { a: i32, } impl FnOnce<()> for __SomeName { type Output = i32; extern "rust-call" fn call_once(self, args: ()) -> Self::Output { self.a + 1 } } // And FnMut and Fn when necessary __SomeName { a } }
Esto significa que la mayoría de las lambdas deben tener diferentes tipos.
Ahora, hay algunas formas en que podríamos hacer eso:
Fn*
rasgos en Rust. Ninguno de los dos lenguajes te obliga a borrar lambdas para usarlos (std::function
en C ++ oBox<Fn*>
en Rust).También tenga en cuenta que ambos lenguajes están de acuerdo en que las lambdas triviales que no capturan el contexto se pueden convertir en punteros de función.
Es bastante común describir características complejas de un idioma usando una característica más simple. Por ejemplo, tanto C ++ como Rust tienen ciclos de rango para, y ambos los describen como azúcar de sintaxis para otras características.
C ++ define
for (auto&& [first,second] : mymap) { // use first and second }
como equivalente a
{ init-statement auto && __range = range_expression ; auto __begin = begin_expr ; auto __end = end_expr ; for ( ; __begin != __end; ++__begin) { range_declaration = *__begin; loop_statement } }
y Rust define
for <pat> in <head> { <body> }
como equivalente a
let result = match ::std::iter::IntoIterator::into_iter(<head>) { mut iter => { loop { let <pat> = match ::std::iter::Iterator::next(&mut iter) { ::std::option::Option::Some(val) => val, ::std::option::Option::None => break }; SemiExpr(<body>); } } };
que si bien parecen más complicados para un humano, son más simples para un diseñador de lenguaje o un compilador.
fuente
std::function
hace esostd::function
(Agregando a la respuesta de Caleth, pero demasiado largo para caber en un comentario).
La expresión lambda es simplemente azúcar sintáctica para una estructura anónima (un tipo de Voldemort, porque no puede decir su nombre).
Puede ver la similitud entre una estructura anónima y el anonimato de una lambda en este fragmento de código:
#include <iostream> #include <typeinfo> using std::cout; int main() { struct { int x; } foo{5}; struct { int x; } bar{6}; cout << foo.x << " " << bar.x << "\n"; cout << typeid(foo).name() << "\n"; cout << typeid(bar).name() << "\n"; auto baz = [x = 7]() mutable -> int& { return x; }; auto quux = [x = 8]() mutable -> int& { return x; }; cout << baz() << " " << quux() << "\n"; cout << typeid(baz).name() << "\n"; cout << typeid(quux).name() << "\n"; }
Si eso sigue siendo insatisfactorio para una lambda, tampoco debería serlo para una estructura anónima.
Algunos lenguajes permiten un tipo de escritura pato que es un poco más flexible, y aunque C ++ tiene plantillas que realmente no ayudan a crear un objeto a partir de una plantilla que tiene un campo miembro que puede reemplazar una lambda directamente en lugar de usar una
std::function
envoltura.fuente
int& operator()(){ return x; }
a esas estructurasauto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }
, porque fuera de lafoo
nada se puede usar el identificadorDarkLord
Porque hay casos en los que los nombres son irrelevantes y no son útiles o incluso contraproducentes. En este caso, la capacidad de abstraer su existencia es útil porque reduce la contaminación de nombres y resuelve uno de los dos problemas difíciles de la informática (cómo nombrar las cosas). Por la misma razón, los objetos temporales son útiles.
La unicidad no es una cosa lambda especial, ni siquiera una cosa especial para tipos anónimos. También se aplica a tipos con nombre en el idioma. Considere lo siguiente:
struct A { void operator()(){}; }; struct B { void operator()(){}; }; void foo(A);
Tenga en cuenta que no puede pasar
B
enfoo
, a pesar de que las clases son idénticos. Esta misma propiedad se aplica a los tipos sin nombre.Hay una tercera opción para un subconjunto de lambdas: las lambdas que no capturan se pueden convertir en punteros de función.
Tenga en cuenta que si las limitaciones de un tipo anónimo son un problema para un caso de uso, entonces la solución es simple: se puede usar un tipo con nombre en su lugar. Las lambdas no hacen nada que no se pueda hacer con una clase con nombre.
fuente
La respuesta aceptada de Cort Ammon es buena, pero creo que hay un punto más importante que hacer sobre la implementabilidad.
Supongamos que tengo dos unidades de traducción diferentes, "one.cpp" y "two.cpp".
// one.cpp struct A { int operator()(int x) const { return x+1; } }; auto b = [](int x) { return x+1; }; using A1 = A; using B1 = decltype(b); extern void foo(A1); extern void foo(B1);
Las dos sobrecargas de
foo
usan el mismo identificador (foo
) pero tienen diferentes nombres mutilados. (En el Itanium ABI utilizado en sistemas POSIX-ish, los nombres alterados son_Z3foo1A
y, en este caso particular,._Z3fooN1bMUliE_E
)// two.cpp struct A { int operator()(int x) const { return x + 1; } }; auto b = [](int x) { return x + 1; }; using A2 = A; using B2 = decltype(b); void foo(A2) {} void foo(B2) {}
El compilador de C ++ debe asegurarse de que el nombre mutilado de
void foo(A1)
en "two.cpp" sea el mismo que el nombre mutilado deextern void foo(A2)
en "one.cpp", de modo que podamos vincular los dos archivos objeto juntos. Este es el significado físico de dos tipos que son "el mismo tipo": se trata esencialmente de compatibilidad ABI entre archivos de objeto compilados por separado.El compilador de C ++ no es necesario para garantizar que
B1
yB2
sean "del mismo tipo". (De hecho, es necesario asegurarse de que sean de diferentes tipos, pero eso no es tan importante en este momento).¿Qué mecanismo físico utiliza el compilador para asegurarse de que
A1
yA2
son "del mismo tipo"?Simplemente busca en typedefs y luego mira el nombre completo del tipo. Es un tipo de clase llamado
A
. (Bueno,::A
ya que está en el espacio de nombres global). Así que es del mismo tipo en ambos casos. Eso es fácil de entender. Más importante aún, es fácil de implementar . Para ver si dos tipos de clases son del mismo tipo, toma sus nombres y haz unstrcmp
. Para convertir un tipo de clase en el nombre mutilado de una función, escribe el número de caracteres en su nombre, seguido de esos caracteres.Por tanto, los tipos con nombre son fáciles de modificar.
¿Qué mecanismo físico podría usar el compilador para asegurarse de que
B1
yB2
son "del mismo tipo", en un mundo hipotético donde C ++ requiere que sean del mismo tipo?Bueno, no podría usar el nombre del tipo, porque el tipo no tiene nombre.
Quizás de alguna manera podría codificar el texto del cuerpo de la lambda. Pero eso sería un poco incómodo, porque en realidad
b
en "one.cpp" es sutilmente diferente deb
en "two.cpp": "one.cpp" tienex+1
y "two.cpp" tienex + 1
. Entonces tendríamos que idear una regla que diga que esta diferencia de espacios en blanco no importa, o que sí (lo que los convierte en tipos diferentes después de todo), o que tal vez sí (tal vez la validez del programa esté definida por la implementación , o tal vez está "mal formado, no se requiere diagnóstico"). De todas formas,A
La forma más fácil de salir de la dificultad es simplemente decir que cada expresión lambda produce valores de un tipo único. Entonces, dos tipos lambda definidos en diferentes unidades de traducción definitivamente no son del mismo tipo . Dentro de una sola unidad de traducción, podemos "nombrar" los tipos lambda contando desde el principio del código fuente:
auto a = [](){}; // a has type $_0 auto b = [](){}; // b has type $_1 auto f(int x) { return [x](int y) { return x+y; }; // f(1) and f(2) both have type $_2 } auto g(float x) { return [x](int y) { return x+y; }; // g(1) and g(2) both have type $_3 }
Por supuesto, estos nombres solo tienen significado dentro de esta unidad de traducción. Esta TU
$_0
es siempre un tipo diferente de otras TU$_0
, aunque esta TUstruct A
es siempre del mismo tipo que algunas otras TUstruct A
.Por cierto, observe que nuestra idea de "codificar el texto de la lambda" tenía otro problema sutil: las lambdas
$_2
y$_3
constan exactamente del mismo texto , ¡pero claramente no deberían considerarse del mismo tipo!Por cierto, C ++ requiere que el compilador sepa cómo manipular el texto de una expresión C ++ arbitraria , como en
template<class T> void foo(decltype(T())) {} template void foo<int>(int); // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_
Pero C ++ no requiere (todavía) que el compilador sepa cómo manipular una instrucción C ++ arbitraria .
decltype([](){ ...arbitrary statements... })
todavía está mal formado incluso en C ++ 20.También observe que es fácil dar un alias local a un tipo sin nombre usando
typedef
/using
. Tengo la sensación de que su pregunta podría haber surgido al intentar hacer algo que podría resolverse de esta manera.auto f(int x) { return [x](int y) { return x+y; }; } // Give the type an alias, so I can refer to it within this translation unit using AdderLambda = decltype(f(0)); int of_one(AdderLambda g) { return g(1); } int main() { auto f1 = f(1); assert(of_one(f1) == 2); auto f42 = f(42); assert(of_one(f42) == 43); }
EDITADO PARA AGREGAR: Al leer algunos de sus comentarios sobre otras respuestas, parece que se pregunta por qué
int add1(int x) { return x + 1; } int add2(int x) { return x + 2; } static_assert(std::is_same_v<decltype(add1), decltype(add2)>); auto add3 = [](int x) { return x + 3; }; auto add4 = [](int x) { return x + 4; }; static_assert(not std::is_same_v<decltype(add3), decltype(add4)>);
Eso es porque las lambdas sin captura son construibles por defecto. (En C ++ solo a partir de C ++ 20, pero siempre ha sido conceptualmente cierto).
template<class T> int default_construct_and_call(int x) { T t; return t(x); } assert(default_construct_and_call<decltype(add3)>(42) == 45); assert(default_construct_and_call<decltype(add4)>(42) == 46);
Si lo intentara
default_construct_and_call<decltype(&add1)>
,t
sería un puntero de función inicializado por defecto y probablemente segfault. Eso es, como, no útil.fuente
Las lambdas de C ++ necesitan tipos distintos para operaciones distintas, ya que C ++ se enlaza estáticamente. Solo se pueden copiar / mover, por lo que la mayoría de las veces no necesita nombrar su tipo. Pero eso es todo un detalle de implementación.
No estoy seguro de si las lambdas de C # tienen un tipo, ya que son "expresiones de función anónimas", y se convierten inmediatamente en un tipo de delegado compatible o un tipo de árbol de expresión. Si es así, probablemente sea un tipo no pronunciable.
C ++ también tiene estructuras anónimas, donde cada definición conduce a un tipo único. Aquí el nombre no es impronunciable, simplemente no existe en lo que respecta al estándar.
C # tiene tipos de datos anónimos , que prohíbe cuidadosamente que escapen del ámbito en el que están definidos. La implementación también les da un nombre único e impronunciable.
Tener un tipo anónimo le indica al programador que no debe hurgar dentro de su implementación.
Aparte:
Usted puede dar un nombre a un tipo de lambda.
auto foo = []{}; using Foo_t = decltype(foo);
Si no tiene ninguna captura, puede usar un tipo de puntero de función
void (*pfoo)() = foo;
fuente
Foo_t = []{};
, soloFoo_t = foo
y nada más.¿Por qué utilizar tipos anónimos?
Para los tipos que son generados automáticamente por el compilador, la opción es (1) cumplir con la solicitud de un usuario para el nombre del tipo, o (2) dejar que el compilador elija uno por su cuenta.
En el primer caso, se espera que el usuario proporcione explícitamente un nombre cada vez que aparezca una construcción de este tipo (C ++ / Rust: siempre que se defina una lambda; Rust: siempre que se defina una función). Este es un detalle tedioso que el usuario debe proporcionar cada vez y, en la mayoría de los casos, nunca se vuelve a mencionar el nombre. Por lo tanto, tiene sentido dejar que el compilador descubra un nombre para él automáticamente y usar características existentes como
decltype
inferencia de tipo o para hacer referencia al tipo en los pocos lugares donde se necesita.En el último caso, el compilador debe elegir un nombre único para el tipo, que probablemente sería un nombre oscuro e ilegible como
__namespace1_module1_func1_AnonymousFunction042
. El diseñador del lenguaje podría especificar con precisión cómo se construye este nombre con un detalle glorioso y delicado, pero esto expone innecesariamente al usuario un detalle de implementación en el que ningún usuario sensato podría confiar, ya que el nombre es sin duda frágil frente a refactores menores. Esto también restringe innecesariamente la evolución del lenguaje: futuras adiciones de características pueden hacer que el algoritmo de generación de nombres existente cambie, lo que lleva a problemas de compatibilidad con versiones anteriores. Por lo tanto, tiene sentido simplemente omitir este detalle y afirmar que el usuario no puede pronunciar el tipo generado automáticamente.¿Por qué utilizar tipos únicos (distintos)?
Si un valor tiene un tipo único, entonces un compilador de optimización puede rastrear un tipo único en todos sus sitios de uso con fidelidad garantizada. Como corolario, el usuario puede estar seguro de los lugares donde el compilador conoce completamente la procedencia de este valor particular.
Como ejemplo, el momento en que el compilador ve:
let f: __UniqueFunc042 = || { ... }; // definition of __UniqueFunc042 (assume it has a nontrivial closure) /* ... intervening code */ let g: __UniqueFunc042 = /* some expression */; g();
el compilador tiene plena confianza en que
g
necesariamente debe tener su origenf
, sin siquiera saber la procedencia deg
. Esto permitiríag
desvirtualizar la llamada . El usuario también lo sabría, ya que ha tenido mucho cuidado de preservar el tipo único de af
través del flujo de datos que condujo ag
.Necesariamente, esto limita lo que puede hacer el usuario
f
. El usuario no tiene la libertad de escribir:let q = if some_condition { f } else { || {} }; // ERROR: type mismatch
ya que eso conduciría a la unificación (ilegal) de dos tipos distintos.
Para solucionar este problema, el usuario podría convertir el
__UniqueFunc042
al tipo no único&dyn Fn()
,let f2 = &f as &dyn Fn(); // upcast let q2 = if some_condition { f2 } else { &|| {} }; // OK
La compensación hecha por este tipo de borrado es que los usos de
&dyn Fn()
complican el razonamiento del compilador. Dado:let g2: &dyn Fn() = /*expression */;
el compilador tiene que examinar minuciosamente el
/*expression */
para determinar si seg2
origina enf
o alguna otra función (es), y las condiciones bajo las cuales se cumple esa procedencia. En muchas circunstancias, el compilador puede darse por vencido: quizás el ser humano podría decir queg2
realmente proviene def
en todas las situaciones, pero la ruta def
ag2
fue demasiado complicada para que el compilador la descifre, lo que resultó en una llamada virtual ag2
con un rendimiento pesimista.Esto se vuelve más evidente cuando dichos objetos se entregan a funciones genéricas (plantilla):
fn h<F: Fn()>(f: F);
Si uno llama a
h(f)
wheref: __UniqueFunc042
, entoncesh
está especializado en una instancia única:Esto permite al compilador generar código especializado para
h
, adaptado al argumento particular def
, yf
es muy probable que el envío a sea estático, si no en línea.En el escenario opuesto, donde se llama
h(f)
conf2: &Fn()
,h
se instancia comoh::<&Fn()>(f);
que se comparte entre todas las funciones de tipo
&Fn()
. Desde dentroh
, el compilador sabe muy poco acerca de una función opaca de tipo&Fn()
y, por lo tanto, solo podría llamar de manera conservadoraf
con un envío virtual. Para distribuir estáticamente, el compilador tendría que insertar la llamadah::<&Fn()>(f)
en su sitio de llamada, lo que no está garantizado sih
es demasiado complejo.fuente
void(*)(int, double)
puede no tener un nombre, pero puedo escribirlo. Yo lo llamaría un tipo sin nombre, no un tipo anónimo. Y yo llamaría cosas crípticas como__namespace1_module1_func1_AnonymousFunction042
alteración de nombres, que definitivamente no está dentro del alcance de esta pregunta. Esta pregunta trata sobre los tipos que están garantizados por el estándar como imposibles de escribir, en lugar de introducir una sintaxis de tipos que pueda expresar estos tipos de manera útil.Primero, lambda sin captura se puede convertir en un puntero de función. Así que proporcionan alguna forma de genérico.
Ahora bien, ¿por qué las lambdas con captura no se pueden convertir en puntero? Debido a que la función debe acceder al estado de la lambda, este estado debería aparecer como un argumento de función.
fuente
std::function<>
.Para evitar colisiones de nombres con el código de usuario.
Incluso dos lambdas con la misma implementación tendrán tipos diferentes. Lo cual está bien porque también puedo tener diferentes tipos de objetos, incluso si su diseño de memoria es igual.
fuente
int (*)(Foo*, int, double)
no corre ningún riesgo de colisión de nombres con el código de usuario.void(*)(void)
haciavoid*
y hacia atrás en C / C ++ estándar.