Expresiones lambda (III)

Artículos de la serie:
 

Expresiones lambda: sintaxis y uso combinado con algoritmos


Los estándares del lenguaje C++11 y posteriores proporcionan las denominadas expresiones lambda con el fin de facilitar la creación de objetos función potencialmente capaces de capturar variables de su entorno. Su utilidad fundamental consiste en la definición in situ de acciones específicas a realizar por parte de los algoritmos. Así, por ejemplo, la eliminación de los valores pares en un vector de enteros puede realizarse de dos formas alternativas:

(1) Definición tradicional de un predicado unario como un tipo de objeto función (estilo C++98/03)

Definimos una estructura is_even que sobrecarga el operador de llamada a función operator(). Éste acepta un entero como argumento y retorna un booleano que informa si el valor es par o no. El algoritmo estándar erase_if (disponible desde C++20 como sustituto de la solución idiomática erase-remove) opera entonces con un objeto función de tipo is_even:

   #include <algorithm>    #include <iostream>    #include <vector>    #include <fmt/format.h>    using namespace std;    struct is_even {       auto operator()(int n) const -> boolreturn n%2 == 0; }    };    auto main() -> int    {       auto v = vector{123456};       erase_if(v, is_even{});       for (int i : v)          fmt::print("{} ", i); // output: 1 3 5    }

(2) Empleo de una expresión lambda (C++11 y posteriores)

auto v = vector{123456};    erase_if(v, [](int n){ return n%2 == 0; });    for (int i : v)     fmt::print("{} ", i);

En este caso, la expresión lambda (un prvalue)

   [](int n){ return n%2 == 0; }

genera directamente un objeto función análogo al de la opción (1) is_even{}, si bien en el lugar mismo de su uso en el algoritmo erase_if y mediante una sintaxis simplificada. El compilador será el encargado de generar un tipo de objeto función equivalente al introducido en la opción (1), denominado tipo de clausura (closure type), cuyo nombre permanecerá desconocido para el programador.

De ser necesaria la reutilización del objeto función, podremos dotarle de nombre a través de una inicialización como la siguiente (observemos la necesidad de utilizar inferencia automática de tipos en este contexto):

   auto v = vector{123456};    auto is_even = [](int n){ return n%2 == 0; };    erase_if(v, is_even);

Por supuesto, el programador podrá también introducir un alias para el tipo de clausura de ser necesario:

   using Is_even_type = decltype(is_even);

En la sintaxis general de una expresión lambda no-genérica, cabe distinguir las siguientes secciones principales:

[capture_list] (parameters) mutable(optional) noexcept(optional) 
-> return_type { body }

De forma notable, el operador llamada a función operator()(parameters) const -> return_type de una expresión lambda es declarado constante (const) por defecto. Será, además, constexpr de cumplirse las condiciones para ello. Así pues, las expresiones lambda poseen el comportamiento que --como el tiempo ha demostrado-- debieran adoptar por defecto todas las funciones en el lenguaje, aunque esto resulte inviable por motivos obvios de retrocompatibilidad. Puede consultarse una curiosa propuesta a este respecto en la referencia bibliográfica [1].

SecciónNombrePropiedades
[capture_list]
Introductor o cláusula de captura
Lista separada por comas con cero o más capturas. Permite especificar las variables externas accesibles por el cuerpo de la función.
(parameters)
Lista de parámetros
Su omisión (equivalente a escribir ()) se permite en ausencia de parámetros siempre que no se utilicen los declaradores mutable/noexcept, atributos y/o tipo de retorno final.
mutable
Elimina la especificación const adoptada por defecto para el operador llamada a función, cuya signatura pasa a ser entonces operator()(parameter)->return_type. Ello permite modificar los objetos no-constantes capturados por valor (copia) y/o definidos en el introductor, así como llamar a sus funciones miembro no constantes.
noexcept
Indica que la función no emite excepciones.
-> return_type
Tipo de retorno
De ser omitido, el tipo es inferido a partir de las cláusulas de retorno de la función (o bien se toma como void en ausencia de las mismas).
{ body }
Cuerpo de la función lambda


Como hemos indicado, una expresión lambda es capaz de capturar variables en su ámbito mediante su introductor o cláusula de captura. Entre los modos de captura fundamentales, se distinguen los siguientes casos (más información en [2]):

ModoPropiedades
[]
Ninguna variable externa es capturada.
[=]

La expresión lambda capturará por valor todas aquellas variables de almacenamiento automático definidas en su ámbito de las que haga uso. Como excepción a esta regla, una expresión lambda en el cuerpo de una función miembro capturará al objeto *this implícitamente por referencia en este modo, comportamiento que se considera obsoleto (deprecated) en C++20 (ver más abajo) [3].

El tipo de una variable capturada por valor coincide con el de la entidad capturada si dicha entidad no es una referencia a un objeto o una función. En caso de tratarse de una referencia a un objeto, el tipo de la variable capturada coincide con el tipo del objeto referenciado. Para una referencia a función, el tipo de la variable capturada es una referencia lvalue a la función referenciada [4]. Los calificadores const y volatile de la entidad original son respetados en cualquier caso.
[&]
La expresión lambda capturará por referencia todas aquellas variables automáticas en su ámbito de las que haga uso. Ello incluye a *this en los métodos de una clase.
[capture_list]
Sólo la lista de nombres capture_list es capturada (por referencia si el nombre de la variable viene precedido del operador &). Desde C++14, se permite la presencia de capturas con inicializadores en el introductor, siguiéndose las reglas de deducción de tipos de auto (véanse los ejemplos más abajo).
[=,capture_list]
Combinación de [=] y [capture_list]. Todas las variables no incluidas en capture_list son capturadas por valor. Las capturas en capture_list deben estar precedidas de & o bien ser this (desde C++17) o *this (desde C++20). Actualmente, [=,this] resulta equivalente a [=], aunque se recomienda adoptar la primera forma (ver más abajo). Por su parte, [=,*this] realiza una copia independiente del objeto actual de la clase.
[&,capture_list]
Combinación de [&] y [capture_list]. Todas las variables no incluidas en capture_list son capturadas por referencia. Las capturas en capture_list no deben estar precedidas por el operador &[&,this] es equivalente a [&].

El siguiente código [3] clarifica el comportamiento poco intuitivo (e, insistimos, considerado obsoleto en C++20) de la captura implícita de *this por referencia en el modo [=]:

   struct S {      int x;      void fn(int n) {        auto f = [=]{ x = n; }; // copia de n; x no es una copia sino this->x (deprecated)        auto g = [=, this]{ x = n; }; // forma equivalente recomendada para la lambda anterior      }    };


Ejemplo de uso


Como aplicación de la tabla de modos anterior, consideremos el siguiente código:

   auto a = int{0};    auto const b = int{1};    auto p = make_unique<int>(3);        auto f = [&a, x = b, q = move(p)]() mutable { // (A)       ++x;        a += x + *q;       return x;    };    assert(!p); // (B)    auto const c = f();    fmt::print("a={}, b={}, c={}\n", a, b, c); // (C) output: a=5, b=1, c=2

En (A), el introductor de la expresión lambda toma una referencia del entero a, realiza una copia del entero b de nombre x y mueve el contenido del puntero inteligente std::unique_ptr<int> p a un nuevo puntero q del mismo tipo. El especificador mutable permite la modificación, dentro del cuerpo de la función, de las variables x y q definidas en su introductor. Observemos que el objeto referenciado a es no-constante, pudiendo ser modificado por la lambda se incluya o no el especificador mutable. Tras la definición del objeto función f, el puntero p queda vacío, tal y como verifica la aserción en (B). Durante la ejecución f(), el valor original de a (variable capturada por referencia) se ve modificado de 0 a 5, mientras que el entero b permanece invariable al haber sido capturado por valor. Así se comprueba en (C).

La definición del objeto función f en (A) será transformada por el compilador en un código similar al siguiente:

   struct /* nombre desconocido */ {       int& a;       int x;       unique_ptr<int> q;       /* nombre desconocido */(int& i, int j, unique_ptr<int>&& r)          : a{i}, x{j}, q{move(r)} { }       auto operator()() { // función no-constante como consecuencia de 'mutable'          ++x;          a += x + *q;          return x;       }    };    auto f = /* nombre desconocido */{a, b, move(p)};

En relación al código original con la expresión lambda, supongamos que hubiésemos hecho uso de un introductor de la forma [&a, b, q = move(p)], donde b es pasado por valor pero no se proporciona un nombre alternativo x para la copia. Ello hubiese producido un error de compilación de intentar realizar una operación como ++b en el cuerpo de la lambda. En efecto, aun declarando la lambda como mutable, el tipo de la nueva variable b capturada por valor sería int const, al ser la entidad original de dicho tipo. Ello hubiese impedido en la práctica la modificación de dicha copia dentro del cuerpo de la función. La inicialización x = b en nuestro código no sólo proporciona un nuevo nombre a la copia (facilitando la comprensión del código) sino que sigue las reglas de deducción de tipos de auto, de forma que el tipo de x resulta ser simplemente int (el especificador const de primer nivel de la entidad original es eliminado).

Finalmente, si quisiéramos definir una referencia lvalue a objeto constante para una entidad no-constante, podríamos hacer uso de la función std::as_const disponible a partir de C++17 en el fichero de cabecera <utility> [5]:

   auto i = int{0};    auto g = [&r = i,               // r es una referencia a int no-constante              &cr = as_const(i)] {  // cr es una referencia a int constante       ++r;  // i ahora vale 1 (aun cuando la función lambda no es mutable)       ++cr; // error de compilación: increment of read-only reference 'cr'    };


Expresiones lambda genéricas


El lenguaje permite también la introducción de expresiones lambda genéricas parametrizadas con la palabra clave auto, de forma que sea el compilador el encargado de inferir los tipos de forma automática. Ello facilita enormemente la creación de algoritmos genéricos y, en muchos casos, simplifica sustancialmente la sintaxis de nuestro código, tal y como demuestra el siguiente ejemplo de ordenación de un vector de punteros inteligentes vector<unique_ptr<S>>, siendo S una clase de objetos comparables:

(a) Versión con expresión lambda específica:

   sort(v.begin(), v.end(), [](unique_ptr<S> const& u1,                 unique_ptr<S> const& u2){ return *u1 < *u2; });

(b) Versión con expresión lambda genérica:

   sort(v.begin(), v.end(), [](auto const& u1, auto const& u2){ return *u1 < *u2; });


Referencias bibliográficas:
  1. All the defaults are backwards - Phil Nash - Meeting C++ 2019 Lightning Talks -https://youtu.be/UJwC3GTbSCE
  2. Cppreference - Lambda expressions - https://en.cppreference.com/w/cpp/language/lambda
  3. https://eel.is/c++draft/depr#capture.this
  4. https://eel.is/c++draft/expr#prim.lambda.capture-10.2
  5. Cppreference - std::as_const - https://en.cppreference.com/w/cpp/utility/as_const
  6. MSDN - Lambda expressions in C++ - https://msdn.microsoft.com/en-us/library/dd293608.aspx
  7. isocpp.org - Generic lambdas - https://isocpp.org/wiki/faq/cpp14-language#generic-lambdas
  8. isocpp.org - Codexpert link - https://isocpp.org/blog/2014/10/hello-lambda

2 comentarios: