Programando con C++20 (Parte II): Lambdas 'templatizadas'

Artículos de la serie:

Introducción


En C++20, las expresiones lambda genéricas pueden adoptar una sintaxis análoga a la de las plantillas de función (function templates) tradicionales [1]:
   [capture_list] <template_parameter_list>(optional C++20)  (parameters)(optional)    mutable-constexpr-consteval(optional)  noexcept(optional)       -> return_type(optional)       requires(optional C++20)    { body }

En la especificación general anterior, template_parameter_list es una lista no vacía de parámetros de plantilla --expresada entre llaves angulares-- que podemos emplear de forma opcional para dotar de nombres específicos a los parámetros de una expresión lambda genérica. Su inclusión vendrá acompañada típicamente por el uso de conceptos que impongan ligaduras sobre los tipos (cláusula requires).

Gracias a esta sintaxis, podemos requerir, por ejemplo, que varios argumentos de una lambda sean del mismo tipo genérico (en el código inferior, el tipo I se encuentra restringido por el concepto std::input_iterator):

   auto f = []<std::input_iterator I>(I first, I last) { /* ... */ };

Como ejemplo adicional, la siguiente lambda genérica opera únicamente con contenedores de tipo std::array:

   auto g = []<typename T, std::size_t N>(std::array<T, N>& m) { /* ... */ };


Un ejemplo más elaborado


Definamos un functor que mida el tiempo de ejecución de un objeto invocable arbitrario (función libre, función miembro, expresión lambda, etc), cualesquiera que sean sus parámetros y tipo de retorno. En primer lugar, introduzcamos una clase de nombre Scoped_timer, cuyo constructor registrará el instante de creación de una instancia, mientras que el destructor imprimirá en la terminal su tiempo de vida (es decir, el intervalo transcurrido desde su creación hasta el momento de su destrucción). Scoped_timer constituye, pues, una aplicación típica de la técnica RAII:
 
   using namespace std;    using namespace std::chrono;    class Scoped_timer {       steady_clock::time_point const start_ = steady_clock::now();    public:       explicit Scoped_timer() noexcept = default;       ~Scoped_timer()  {          auto const end = steady_clock::now();          fmt::print("time: {} ms\n", duration_cast<milliseconds>(end - start_).count());       }    };

A continuación, introduciremos un objeto función de nombre timer que se valga de la clase Scoped_timer para, dado un objeto invocable cualquiera fn:
  1. Retornar una lambda variádica capaz de invocar fn con una lista de argumentos apropiada proporcionada por el usuario. Dicha lambda retornará el valor producido por fn, de existir alguno.
  2. Mostrar por la terminal el tiempo transcurrido en dicha invocación a fn.
   auto timer = []<typename Fn>(Fn&& fn) {       return [t = tuple<Fn>{forward<Fn>(fn)}]<typename... Args>(Args&&... args) mutable noexcept(is_nothrow_invocable_v<Fn, Args...>) -> decltype(auto)         requires invocable<Fn, Args...>        {           auto _ = Scoped_timer{};           return invoke(get<0>(t), forward<Args>(args)...); }; };

En el código anterior, tanto la lambda genérica externa como la interna introducen nombres concretos para sus parámetros de plantilla, con el fin de reutilizarlos con posterioridad. En el caso de la lambda interna, la variable t almacenará una referencia lvalue al objeto a invocar cuando la referencia de reenvío Fn&& decaiga a referencia lvalue. Por contra, t se hará cargo de la propiedad de fn cuando Fn&& sea deducida como referencia rvalue. Puede consultarse el artículo [2] como justificación del uso de std::tuple para la captura de objetos de reenvío en lambdas.

Proporcionaremos finalmente algunos ejemplos puramente ilustrativos de uso del objeto timer. Cada invocación produce un resultado e imprime en la terminal su correspondiente tiempo de ejecución. Observemos, en particular, la sintaxis tan conveniente facilitada por timer, que sirve de wrapper para cualquier objeto invocable:

   // invocación de una lambda:    auto add = [](auto const& i, auto const& j){ return i + j; };    auto n = timer(add)(5, 2.1);    assert(n == 7.1);    // invocación de una función miembro pública de la clase std::string:    auto mssg = string{"Somewhere in La Mancha..."};    auto const sz = timer(&string::size)(mssg);    assert(sz == 25);    // acceso a un dato miembro público:    struct S { int i; };    auto s = S{.i = 0};    timer(&S::i)(s) = 1;    assert(s.i == 1);

Por supuesto, en la práctica sólo tendría sentido emplear el cronómetro timer con invocaciones de larga duración. Asimismo, el código implementado resulta, aunque sencillo, excesivamente limitado: sería conveniente proporcionar al usuario la posibilidad de escoger la representación temporal (microsegundos, nanosegundos, etc), así como decidir cuándo llamar a count() para calcular los intervalos temporales (posibilitando el cálculo de medias para varias llamadas a función, por ejemplo). Véase la discusión a este respecto en [3] para más detalles.


Referencias bibliográficas
  1. Cppreference - Lambda expressions - https://en.cppreference.com/w/cpp/language/lambda
  2. Vittorio Romeo - Capturing perfectly-forwarded objects in lambdas - https://vittorioromeo.info/index/blog/capturing_perfectly_forwarded_objects_in_lambdas.html
  3. https://stackoverflow.com/questions/2808398/easily-measure-elapsed-time

No hay comentarios:

Publicar un comentario