Todos conocen la frase "si solo tienes un martillo, todo te parece un clavo" o alguna variación con la misma idea. A mi, con la programación me pasa algo curioso. Tengo una caja repleta de herramientas de todo tipo, pero cada vez que agrego a la caja un martillo nuevo, todo lo demás se me convierte en clavo. Mi caja está mayormente llena de C++. Diría que casi todo C++03 está en la caja, y que gradualmente he ido agregando herramientas de C++11/14. En algún punto me pasó con eso de SFINAE y los trucos que se podían hacer, y los apliqué por todos lados. En otro momento, agregé a la caja las maravillosas "Variadic Templates" y grité "I have a hammer!", y variadic templates para todos.
Ahora, la funcionalidad de C++11 que se está tornando increíblemente "martillosa", es la aparición de las funciones lambda, y como yapa de la clase auxiliar std::function. Es decir, la posibilidad de crear funciones y clausuras "al vuelo", sin siquiera ponerles tipo ni nombre, y la facilidad que provee la clase std::function para apuntarle a esos pequeños monstruitos sin que nos importen ni preocupen sus especies. En MotoGT 2 están martillando cuanto clavo (y tornillo) se les cruza. Y el resultado es una delicia. Un buen motor puede dar muchas más libertades sin agregar ruido cuando hace uso de estas cosas, y permite al programador ser mucho más productivo.
Resumiendo mucho, la idea básica de las funciones lambda en C++ es crear pequeñas funciones anónimas en una linea en cualquier lado. Por ejemplo, para una llamada como esta:
std::sort(
mi_vector.begin(),
mi_vector.end(),
[](int a, int b){ return a%100<b%100; }
);
Este ejemplo ordena los enteros del vector<int> mi_vector según los dos últimos dígitos de sus números (de ahí el %100). Es decir, le paso a sort un criterio de ordenamiento nuevo, creado al vuelo. La sintaxis es más o menos así. Primero vemos unos corchetes que salen de la nada (no hay arreglo o vector al lado como para entender otra cosa). Esos corchetes dicen "abran paso, he aquí una función lambda!". Luego, los argumentos de la función, y finalmente el cuerpo. Así de simple. Pero hay más:
std::cin >> n;
std::sort(
mi_vector.begin(),
mi_vector.end(),
[n](int a, int b){ int m=int(pow(10,n)); return a%n<b%n; }
);
y ahora los ordenamos según sus últimos n dígitos, donde n es lo que quiera en usuario (cin>>n). La n entre los corchetes está diciendo "quiero usar esa n adentro de mi función lambda". Y entonces la función lambda puede tomar copias de o referencias a variables locales del ámbito en el que se crea. Así, la función no será siempre la misma. Esto abre posibilidades infinitas.
Estas funciones, serán en realidad functores, que el compilador generará y bautizará por nosotros con nombres raros. Entonces, no sirven para pasarselas a una clase o función que espera un puntero a función clásico. Con sort funciona, porque el tipo del tercer parámetro no es "puntero a función", sino que es genérico (template), para que pueda entonces ser también un functor cualquiera. Pero esto entonces nos obligaría a meter templates por todos lados, y eso puede traer algunas complicaciones. Por suerte, aparece al rescate la biblioteca <functional> con su clase std::function. Esta es una clase genérica que se especializa con un prototipo de función y se inicializa con cualquier cosa que cumpla ese prototipo, sea función tradicional, functor del usuario, o una moderna lambda, da igual.
std::function hace su magia negra por dentro (hechizo conocido como "type erasure"), y lo lindo es cuando la usamos en una clase o función nuestra, estamos especializandola, y entonces la clase o función nuestra no necesita ser genérica:
void hacerAlgoConElVector(
std::vector<int> &un_vector,
std::function<void(int&)> que_hacer )
{
for (auto &x : un_vector)
que_hacer(x);
}
void multilpicaValores(
std::vector<int> &un_vector,
int por_cuanto )
{
hacerAlgoConElVector( un_vector,
[=](int &x){x*=por_cuanto;} );
}
En este ejemplo, la primer función recibe un "puntero" a algo que funcione como función (valga la redundancia), siempre y cuando reciba un int& y no retorne nada. La segunda usa la primera para multiplicar todo lo que halla en el vector por un factor que recibe como argumento. El [=] en la lambda significa "quiero poder usar todas las variables locales" (y "que el compilador se las arregle para ver cuales efectivamente termino usando").
En MotoGT 2 uso eso básicamente para callbacks. Por ejemplo, cuando a la clase menú le digo "agregá este ítem al menú", le paso con una función lambda qué quiero que haga el menú cuando el usuario elija ese ítem. Entonces, si tengo una buena clase Menu, con std::functions para todo evento que pueda querer configurarse, la clase puede gestionar completamente los eventos (entradas) y su despacho (reacciones), aún de forma genérica, y dejar casi ninguna responsabilidad para sus herencias. Por ejemplo, para gestionar el menú principal, hago una herencia de Menu y alcanza con poner en el constructor algo como:
Menu::StartItemsGroup(140,340,45); // posición del menú
Menu::AddItem( "Quickrace",
[&](){ Transition<QuickRace>(); } );
Menu::AddItem( "Career", [&](){
if (g_prof_manager->GetProfilesCount())
this->SetRightMenu<ProfilesMenu>();
else
this->CreateProfile();
} );
Menu::AddItem( "Settings",
[&](){ this->SetRightMenu<SettingsMenu>(); } );
Menu::AddItem( "High Scores",
[&](){ Transition<HighScores>(); } );
Menu::AddItem( "Exit", [&]() {
auto exit_scene =
std::make_shared<YesNoQuestion>(
"Leave the game?",
[](){ g_main->End(); },
[&](){ this->Return(); },
this->SetChild(exit_scene); } );
Este código es en esencia real. En menos de 20 líneas configuro todo el menú principal, aún incluyendo su comportamiento. Y noten el detalle del último ítem, que en su lambda crea una YesNoQuestion y le pasa con dos nuevas lambdas qué hacer si el usuario dice "Yes" o si dice "No". Más lambdas al ataque! Con estas estructuras es realmente muy fácil crear y modificar menúes y elementos similiares en la interfaz. Y todo queda contenido y compacto, en un código muy fácil de mantener.
¿Más ejemplos? Solo uno más para cerrar. En MotoGT hay una clase Scene que contiene un conjunto de GameObjects. Un GameObject representa un elemento (visual o no) que se actualiza en cada frame. GameObject es una clase abstracta con dos métodos virtuales, uno para actualizar su estado, y otro para renderizarse si es necesario. Pues bien, cuando aparece el menú principal que les contaba antes, primero hace su efecto de tipeo, y recién luego se selecciona un elemento del mismo. Para lograr esto, agrego a la escena un GameObject que espera 800ms y activa la selección y el procesamiento de eventos del menú. Esto se hace con una especialización (herencia) de GameObject (llamada TimeTriggeredGO), que espera un tiempo determinado y ejecuta luego una acción arbitraria (ambas cosas argumentos de su constructor). ¿Y a que no saben cómo le paso esa acción? Entonces, para que la selección ocurra 800ms después de que empieza la animación del menú, uso una de estas clases (mediante una función que la crea en un estilo similar al de std::make_shared para simplificar la sintaxis de templates), y entonces logro el efecto, otra vez, en una sola linea adicional:
AddObject( makeTimeTriggeredGO(800,[&](){this->Activate();} );
Y podría seguir todo el día mostrando cómo el uso de lambdas me permite hacer clases genéricas para luego configurar elementos del juego con solo un puñado de líneas. Realmente logro dedicarme al juego y olvidarme del resto de las complicaciones programando así. En otro momento tendría que haber usado polimorfismo e implementado unas cuantas cosas más en la clase del menú principal (como era en el MotoGT original). Ahora el polimorfismo está oculto en las entrañas de la std::function y nadie lo ve. Programar contra este nuevo motor se me hace muy placentero.
El único lado oscuro está en la depuración. Como estas funciones lambda no son exáctamente lo que parecen, y además pasan por las entrañas de clases como std::funcion, se ven horribles en el trazado inverso y hasta confunden a gdb a la hora de poner puntos de interrupción y esas cosas. Algo similar ocurre con los errores de compilación cuando erramos los tipos (por culpa de los templates que hay en medio en realidad). Pero bueno, se supone que las usamos para cosas simples y cortitas, en principio no deberíamos hacer directamente en ellas nada tan complejo como para que amerite una sesión de depuración.
En resumen, he aquí mi nuevo martillo, para darle una vuelta de rosca nueva y liberadora a mi modelo de objetos. Espero que también les resulte útil y empiecen a aprovechar estas técnicas en sus propios proyectos.
No hay comentarios:
Publicar un comentario