Signals and Slots con Boost (C++)

boost

Una de las herramientas más útiles que tengo a la hora de comunicar eventos que ocurren en una clase a otros sitios es el patrón de Observador, que en ActionScript 3 se manifiesta con el sólido sistema de eventos que forma parte de la librería básica de este lenguaje.

Lamentablemente, el sistema de eventos es prácticamente único dentro de AS3. Portear código basado en eventos a otras plataformas que no tengan esto incorporado no es sencillo. Así que una manera que he encontrado de trabajar más naturalmente ha sido los AS3 Signals, de Robert Penner, o su homólogo en Javascript, por Miller Medeiros. Basado en el sistema de Signals and Slots de la librería QT, se definen los signals como miembros públicos que se colocan dentro de un objeto. Otros objetos se suscriben a estas señales con una función de llamada de vuelta, que es llamada cuando la señal se emite. A estas funciones se les conoce como slots.

Esto es muy parecido a como funciona el sistema de eventos, pero a diferencia del de ActionScript 3, no requiere un sistema central que maneje los eventos. Cada señal mantiene registro de las funciones a las que debe llamar.

En C++ he conseguido que la implementación de signals con Boost, llamada signals2, es igualmente de útil y flexible que sus símiles en AS3 y JavaScript. Además de esto, la librería es thread-safe y se comporta bien con muchas plataformas, por lo que el código resultante es bastante porteable.

Boost es un compendio de librerías extremadamente grande, y que apenas estoy comenzando a explorar. El objetivo de este artículo es simplemente explorar lo que se puede hacer con signals2 para facilitar la comunicación entre clases. No está de más decir que necesitas comprender medianamente bien C++ para entender el tutorial.

Presentemos el caso

Supongamos que tenemos un juego en el que un personaje se mueve constantemente de un punto a otro en línea recta. El juego debe reaccionar cuando el personaje llega a su destino. Dicho esto, implementaremos este comportamiento en una clase separada de la clase encargada de dibujar la pantalla.

Nuestro código hipotético interpola linealmente la posición de un personaje de un punto a otro
Nuestro juego hipotético interpola linealmente la posición de un personaje de un punto a otro

En código podemos bocetear esto:

//Character.h

class Character {
public:
    Character();
    void update();
    float position;
}

//Character.cpp

Character::Character()
: position(0.0f) { }

void Character::update() {
    if (position < 1.0f) {
        position += 0.1f;
    } else {
        position = 0.0f;
    }
}

La clase principal del juego contiene el loop principal del juego, el cual ejecuta la función Character::update() para mover al personaje. El objetivo es que debemos ejecutar una función una vez que el personaje llegue al valor 1.0f de la posición. Notemos que la función no va a pertenecer a la clase Game (todavía). Un boceto de código sería algo como:

//Game.h

void playerLlegoAlFinal();

class Game {
public:
    Game();
    void update();
    Character *player;
}

//Game.cpp

Game::Game() {
    player = new Character();
}

void Game::update() {
    player->update();
}

void playerLlegoAlFinal() {
    //Función para llamar cuando el jugador llegue a la posición final
}

Vemos ahora que el problema es cuándo saber que el personaje llegó a la posición final. Podríamos comprobarlo con un if dentro del update, pero vemos que la posición es un float, ¿qué valor deberíamos comprobar para llamar a la función?, en la clase cuando Character llega al valor 1.0f reiniciar el valor. Podríamos también crear en Character un miembro que sea un apuntador a una función, de tal manera que Character la pueda llamar una vez que la posición sea 1.0f. Pero, ¿qué tal si pudiésemos llamar a una o más funciones cuando ocurra esto?

Entran las signals

Las signals son unas estructuras en Boost que agrego a una clase "A", generalmente de manera pública. Las clases que necesiten que se llame a una función desde esa clase "A" agregan esa función al signal. Estas funciones pasan a llamarse slots. Cuando la clase "A" despacha la señal, esta estructura en consecuencia llama a todos los slots que se han agregado hasta el momento.

Notemos que esto se parece al sistema de eventos de ActionScript 3, pero la diferencia radica en que las funciones que agrego a las señales están fuertemente tipadas. Las funciones que se agregan deben tener la firma que se declara junto a la señal.

Boost trae dos implementaciones de signals and slots, signals y signals2. La primera necesita hacer un trabajo de compilación previo a su utilización, mientras que la segunda emplea únicamente los headers que se incluyen en nuestro proyecto, además de que la segunda implementación es thread-safe.

Agreguemos una señal a nuestra clase Character:

//Character.h

#include 

class Character {
public:
    Character();
    void update();
    float position;

    boost::signals2::signalcharacterLlegoAlFinal;
}

//Character.cpp

Character::Character()
: position(0.0f) { }

void Character::update() {
    if (position < 1.0f) {
        position += 0.1f;
    } else {
        characterLlegoAlFinal();
        position = 0.0f;
    }
}

La señal se declara en el header de la clase, detallando también el tipo de funciones que acepta la señal. Para despachar la señal llamamos a la señal como una función cualquiera. En este ejemplo inicial llamaremos a funciones que no aceptan ningún parámetro. Veamos ahora cómo agregar una función desde Game.cpp.

//Game.cpp

Game::Game() {
    player = new Character();
    player->characterLlegoAlFinal.add(playerLlegoAlFinal);
}

Ahora cada vez que el personaje llegue a la posición 1.0f, se despachará la señal y en consecuencia se llamará a la función playerLLegoAlFinal(). Podría agregar otra función más tarde, y esta función se llamaría después de la primera.

Este es un caso suficientemente simple, casos en los que tengamos que llamar a funciones que no pertenecen a ninguna clase. Pero en la realidad solemos tener que pasar funciones miembro de una clase. Acá nos enfrentamos a otro problema. En lenguajes de alto nivel, las funciones son ciudadanos de primera clase, por lo que yo podría pasar directamente el nombre de la función, haciendo algo como "player.characterLLegoAlFinal.add(this.playerLLegoAlFinal)". Pero esto es C++, donde las funciones en realidad son direcciones en memoria que debo asociar a la dirección de un objeto.

Boost nos ofrece "bind", una herramienta muy poderosa que resuelve fácilmente este problema. A bind le pasamos la dirección del objeto, la dirección de la función, y nos devuelve un apuntador que asocia ambos datos. Cuando la señal se despacha, la función sabrá a que objeto se está refiriendo.

Veamos de nuevo el ejemplo anterior, pero la función playerLlegoAlFinal() será ahora un miembro de Game:

//Game.h

class Game {
public:
    Game();
    void update();
    void playerLlegoAlFinal();
    Character *player;
}

//Game.cpp

Game::Game() {
    player = new Character();
    player->characterLlegoAlFinal.add(boost::bind(&Game::playerLlegoAlFinal, this));
}

void Game::update() {
    player->update();
}

void Game::playerLlegoAlFinal() {
    //Función para llamar cuando el jugador llegue a la posición final
}

Ahora cuando la señal despache characterLlegoAlFinal, se llamará a la función playerLlegoAlFinal correspondiente a Game.

Agregando parámetros

¿Qué pasa si tenemos que pasar más datos con esa señal? Imaginemos ahora que el personaje cuenta las veces que ha llegado al final, y que eso es un parámetro que le pasaremos a los slots que llamemos. Veamos la nueva clase Character:

//Character.h

#include 

class Character {
public:
    Character();
    void update();
    float position;
    int vecesLlegadas;

    boost::signals2::signalcharacterLlegoAlFinal;
}

//Character.cpp

Character::Character()
: position(0.0f)
, vecesLlegadas(0)  { }

void Character::update() {
    if (position < 1.0f) {
        position += 0.1f;
    } else {
        vecesLlegadas += 1;
        characterLlegoAlFinal(vecesLlegadas);
        position = 0.0f;
    }
}

Ahora que hemos cambiado la firma de los slots de la señal, tenemos que cambiar la función en Game a void playerLLegoAlFinal(int veces). Pero ¿cómo hacemos con la función que le vamos a pasar a bind? A bind hay que especificarle que la función que le estamos pasando tiene un parámetro que no se conocerá hasta que la señal sea despachada. Veremos que bind tiene una característica que la hace extremadamente útil en otros casos distintos a emplear la función como slot. Veamos qué debemos hacer en nuestra clase Game:

//Game.h

class Game {
public:
    Game();
    void update();
    void playerLlegoAlFinal(int veces);
    Character *player;
}

//Game.cpp

Game::Game() {
    player = new Character();
    player->characterLlegoAlFinal.add(boost::bind(&Game::playerLlegoAlFinal, this, _1));
}

void Game::update() {
    player->update();
}

void Game::playerLlegoAlFinal(int veces) {
    //Función para llamar cuando el jugador llegue a la posición final
}

Los nombres _1, _2, _3, _4... hasta el _9 son marcadores de posición (placeholders), indican que la nueva función que estamos creando en este caso se le pasará el parámetro del número correspondiente en la posición del argumento de bind. Es decir, en este ejemplo estamos creando una función con bind que tiene asociados la dirección del objeto, la dirección de la función, y que acepta un solo argumento. Este argumento es de tipo el primer argumento de la función que estamos pasando, es decir, int veces.

Con esto vemos que con signals2 y bind podemos crear señales que comunican datos entre clases elegantemente. Pero antes de cerrar este tutorial exploremos un poco más bind, y veamos lo que es capaz de hacer.

Explorando bind

Vemos que la función bind es capaz de pasar parámetros placeholder, pero bind no nos limita a esto. Supongamos que tenemos una función de dos parámetros que ahora queremos agregar como slot a la señal de Character.

void Game::playerLLegoAlFinalYEstaFeliz(int veces, bool estaFeliz);

La señal sigue siendo la misma, characterLlegoAlFinal, que llama a slots que aceptan un solo parámetro. Haremos entonces que se llame a esta señal, asumiendo que el player llega siempre feliz. Agreguemos la función con bind:

//Game.cpp

Game::Game() {
    player = new Character();
    player->characterLlegoAlFinal.add(boost::bind(&Game::playerLlegoAlFinal, this, _1));
    player->characterLlegoAlFinal.add(boost::bind(&Game::playerLlegoAlFinalYEstaFeliz, this, _1, true));
}

Para fines de la señal, este segundo slot acepta un solo parámetro. La función playerLLegoAlFinalYEstaFeliz será llamada con el número de veces indicada por la señal despachada, y el segundo parámetro siempre con true. Con bind podríamos incluso cambiar de posición los parámetros, y muchas otras cosas más. Boost ofrece un montón de funcionalidades que serían propias de lenguajes de alto nivel, con la eficiencia de C++. Quedan muchísimas más por conocer.

6 comentarios en «Signals and Slots con Boost (C++)»

Responder a Nelson Andrés Loyola SalasCancelar respuesta