Cómo y por qué usar clases abstrastas en Arduino


Hoy vamos a ver como usar clases abstractas en C++ y cómo emplearlos en proyectos de MCU como Arduino o similares para mejorar nuestro código

A medida que un proyecto crece cada vez tendremos más interés en mantener el código limpio, para que sea más fácilmente mantenible, testeable y portable entre distintos hardware.

Aquí es donde las clases abstractas, o interfaces en otros lenguajes, ocupan un lugar fundamental. Las clases abstractas nos permiten mantener débil el acoplamiento entre clases, reduciendo las dependencias entre ellas y con el hardware.

Para verlo de la mejor forma vamos a emplear un ejemplo simulado. Para este caso, vamos a elegir a coger un proyecto de reproductor MP3. Este usará una TFT para el interface de pantalla y cargará las canciones desde una SD.

Anuncio:

Es importante remarcar que hoy no vamos a ver cómo hacer un reproductor MP3. La mayor parte de las funciones hoy estarán vacías. Eso es así porque no nos interesa el código en sí, si no cómo organizar y estructurar el código.

Así que sin más preámbulos, vamos a empezar viendo el ejemplo que nos acompañará en esta entrada. Al final de la misma tenéis todo el código para descargar desde Github.

Ejemplo 0

Supongamos que estamos haciendo nuestro reproductor MP3, con nuestro hardware de audio, TFT y SD específicos. Nuestro primera tentativa sería escribir todo el código en el fichero 'Main' del programa. Es decir, algo así.

#include "libs/AudioLibraries.hpp"
#include "libs/TFTLibraries.hpp"
#include "libs/SDLibraries.hpp"

Audio_Hard audio;
SD_Hard sd;
TFT_Hard tft;

std::string CurrentSong;
std::vector<std::string> PlayList;
bool IsPlaying;

// Funciones de dibujar en la TFT
void DrawBackground()
{
	tft.DoSomething();
}

void DrawButtons() {}
void DrawList() {}

void Draw() {
	DrawBackground();
	DrawButtons();
	DrawList();
}

// Funciones de audio
void Init() {}

void Play()
{
	audio.DoSomething();
}

void Stop() {}
void NextSong() {}

void PrevSong() {}

// Funciones de SD
void LoadPlaylistFromSD()
{
	sd.DoSomething();
}

// Setup
void setup()
{
	// Iniciar el hardware
	Init();

	// Cargas playlist desde la SD	
	LoadPlaylistFromSD();
}

// Loop, reducida para el ejemplo
void loop()
{
	// Aquí haríamos las acciones a realizar
	// en el MP3 al pulsar botones, etc...
	Play();
	Stop();

	// FInal
	Draw();
}

Para que esta entrada no se haga interminable veréis que, de forma simplificada, la mayoría de funciones no tienen cuerpo. Como decíamos, lo que hacer dentro de las funciones hoy no nos interesa, si no cómo ordenar el código.

Por otro lado, tenemos tres librerías "simuladas" AudioLibraries, TFTLibraries, y SDLibraries. Estos objetos "falsos" nos van a servir en el ejemplo para emular las librerías reales de hardware que estaríamos usando en el proyecto.

Cada una de estas y librerías son muy similares entre ellas, y están formadas por una clase con un único método 'DoSomething()'.

#pragma once

class Audio_Hard
{
public: 
	void DoSomething()
	{
	}
};

En el cuerpo del programa tenemos unas cuantas funciones relacionada con la TFT (Draw...()), unas cuantas relacionadas con el Audio, y una función relacionada con la SD. Seguro necesitaríamos más funciones, pero para el propósito del ejemplo así es suficiente.

Suponiendo que todas las funciones están rellenas y funcionan ¡Ya habríamos terminado el proyecto! Pero ahora piensas.. me he metido una currada haciendo mi MP3, y quiero poder reusarlo más fácilmente en otros proyectos o hardware.

Así que es donde decides sacar la lógica a una clase (o librería) para que puedas reusarlo en otros proyectos.

Clase MP3Player

Manos a la obra. Vamos a coger toda la lógica de tu Mp3 y meterlo en una clase llamada 'MP3Player', que quedaría así.

#pragma once

#include "libs/AudioLibraries.hpp"
#include "libs/TFTLibraries.hpp"
#include "libs/SDLibraries.hpp"

class MP3Player
{
public:
	Audio_Hard audio;
	SD_Hard sd;
	TFT_Hard tft;

	std::string CurrentSong;
	std::vector<std::string> PlayList;
	bool IsPlaying;

	void DrawBackground()
	{
		tft.DoSomething();
	}

	void DrawButtons()
	{
	}

	void DrawList()
	{
	}

	void Draw()
	{
		DrawBackground();
		DrawButtons();
		DrawList();
	}

	void Init()
	{
	}

	void Play()
	{
		audio.DoSomething();
	}

	void Stop()
	{
	}

	void NextSong()
	{
	}

	void PrevSong()
	{
	}

	void LoadPlaylistFromSD()
	{
		sd.DoSomething();
	}
};

Ahora, tu programa principal quedaría mucho más sencillo.

#include "MP3Player.hpp"

MP3Player mp3Player;

void setup()
{
	mp3Player.Init();
	mp3Player.LoadPlaylistFromSD();
}

void loop()
{
	mp3Player.Play();
	mp3Player.Stop();

	mp3Player.Draw();
}

Perfecto, ahora tienes una clase que puedes usar en otros proyectos, o darle a alguien para que integre tu MP3 de forma sencilla en su programa. Parte de la portabilidad ya la tenemos resuelta. Pero, tu MP3 sigue siendo totalmente dependiente de tu hardware. Sólo funciona con esa TFT, ese hardware de Audio, y con una SD.

Clase MP3PlayerUI

Empieza la parte divertida, de desacoplar nuestras clases entre ellas y con las de terceros. En este caso, empecemos por las librerías de hardware. La primera dependencia evidente que "nos molesta" es la dependencia con la TFT.

Así que decidimos independizar la parte de Audio de nuestra clase, de la de representación gráfica (UI). Así que modificamos nuestra clase MP3Player para que únicamente se encargue de la parte relacionada con el audio.

#pragma once

class Mp3Player
{
public:
	Audio_Hard audio;

	std::string CurrentSong;
	std::vector<std::string> PlayList;
	bool IsPlaying;

	void Init()
	{
	}

	void Play()
	{
		audio.DoSomething();
	}

	void Stop()
	{
	}

	void NextSong()
	{
	}

	void PrevSong()
	{
	}
};

A cambio, creamos una nueva clase 'MP3PlayerUI'. Esta clase será la encargada de recibir un objeto 'MP3Player' y mostrarlo en una pantalla TFT.

Para ello, esta clase necesita que le pasemos en el constructor una referencia a un 'MP3Player' y a una 'TFT_Hard'. Con esto, ella se encarga de representar el estado del MP3 en la pantalla. Algo así.

#pragma once

#include "Mp3Player.hpp"

class Mp3PlayerUI
{
public:
	Mp3Player& _mp3Player;
	TFT_Hard& _tft;

	Mp3PlayerUI(Mp3Player& mp3Player, TFT_Hard& tft) : _mp3Player(mp3Player), _tft(tft)
	{
	}

	void DrawBackground()
	{
		tft.DoSomething();
	}

	void DrawList()
	{
		for (auto song : _mp3Player.PlayList)
		{
		}
	}

	void Draw()
	{
		DrawBackground();
		DrawList();
	}
};

Nuestro programa principal quedaría así

#include "libs/AudioLibraries.hpp"
#include "libs/TFTLibraries.hpp"
#include "libs/SDLibraries.hpp"

#include "Mp3Player.hpp"
#include "Mp3PlayerUI.hpp"

TFT_Hard tft;
SD_Hard sd;

Mp3Player mp3Player;
Mp3PlayerUI mp3PlayerUI(mp3Player, tft);

void setup()
{
	mp3Player.Init();
}

void loop()
{
	mp3Player.Play();
	mp3Player.Stop();
	
	mp3PlayerUI.Draw();
}

Perfecto, ahora podemos tener distintos 'Mp3PlayerUI_XXX' adaptados a diferentes pantallas. Una con un driver TFT, otra con otro, una para una pantalla LCD monocromo, etc. Podríamos cambiar de uno a otro únicamente cambiando una línea del 'main', ¡y sin usar ni un #define!

Pero, ¿que pasa ahora si también tenemos distintos hardware de audio? Porque nuestros objetos 'MP3PlayerUI_XXX' tienen una dependencia con 'MP3Player', que funciona únicamente con un hardware específico.

¿Tendríamos que crear todas las posibles combinaciones de objetos audio, con objetos UI? Por supuestísimo que no. Aquí es donde entran en juego las clases abstractas.

Clase abstracta IMP3Player

Ahora es donde la cosa se pone de verdad interesante. Nos creamos una clase abstracta que llamaremos 'IMP3Player', por ejemplo.

#pragma once

class IMp3Player
{
public:
	std::string CurrentSong;
	std::vector<std::string> PlayList;
	bool IsPlaying;

	virtual void Init() = 0;
	virtual void Play() = 0;
	virtual void Stop() = 0;
	virtual void NextSong() = 0;
	virtual void PrevSong() = 0;
};

Esta clase abstracta representa el conjunto de métodos que tiene cualquier MP3Player. De hecho, representa "lo que para mí conforma un MP3Player cualquiera". De ahí su nombre, clase abstracta, porque es una abstracción de un MP3Player cualquiera.

Ahora modificamos nuestra clase MP3Player para que implemente IMP3Player. Es decir, para que diga "yo soy un IMP3Player".

#pragma once

#include "IMp3Player.hpp"

class Mp3Player : public IMp3Player
{
public:
	Audio_Hard audio;

	std::string CurrentSong;
	std::vector<std::string> PlayList;
	bool IsPlaying;

	void Init()
	{
	}

	void Play()
	{
		audio.DoSomething();
	}

	void Stop()
	{
	}

	void NextSong()
	{
	}

	void PrevSong()
	{
	}
};

Por otro lado (y aquí viene la gracia) modificamos MP3PlayerUI para que use un IMP3Player, en lugar de un MP3Player concreto.

#pragma once

#include "IMp3Player.hpp"

class Mp3PlayerUI
{
public:
	IMp3Player& _mp3Player;
	TFT_Hard& _tft;

	Mp3PlayerUI(IMp3Player& mp3Player, TFT_Hard& tft) : _mp3Player(mp3Player), _tft(tft)
	{
	}

	void DrawBackground()
	{
		tft.DoSomething();
	}

	void DrawList()
	{
		for (auto song : _mp3Player.PlayList)
		{
		}
	}

	void Draw()
	{
		DrawBackground();
		DrawList();
	}
};

Reflexión, en realidad Mp3PlayerUI no necesita un objeto concreto. Solo necesita algo que le garantice que tiene ciertos métodos. Es decir, le vale con una clase abstracta (a.k.a. Interface).

Ahora hemos conseguido desacoplar nuestras clases. MP3Player gestiona lo relacionado con el audio, y MP3PlayerUI con la representación gráfica. Entre ellas se comunican usando IMP3Player.

De esta forma, cualquier combinación de audio con hardware es posible. Es más, ahora es posible hacer un "Mock" de nuestro MP3Player, como por ejemplo el siguiente.

#pragma once

#include "IMp3Player.hpp"

class Mp3Player_Mock : public IMp3Player
{

public:
	void Init()
	{
		Serial.println("Init");
	}

	void Play()
	{
		Serial.println("Play");
	}

	void Stop()
	{
		Serial.println("Stop");
	}

	void NextSong()
	{
		Serial.println("NextSong");
	}

	void PrevSong()
	{
		Serial.println("PrevSong");
	}

	void LoadPlaylistFromSD()
	{
		Serial.println("LoadPlaylistFromSD");
	}
};

Es decir, si ya he terminado la parte de audio (o incluso si otra persona está haciendo otra parte) puedo seguir trabajando en la parte de UI con un objeto 'Mock', que ni siquiera necesita tener hardware de audio real. Esto es una gran ventaja de cara a desarrollos colaborativos, testeos, o mantenibilidad de nuestro proyecto.

Servicio SDFiller

Nos queda una cosita para rematar este ejemplo e ir a "por nota". Hemos desacoplado las dependencias con audio, y la dependencia con la TFT. Pero nos queda una feisima dependencia con la SD, que en algún lado tendremos que meter.

Cuando no necesitamos que dos objetos trabajen juntos, pero no queremos que se conozcan entre ellos, una forma de resolverlo es usando un servicio. Por ejemplo, de la siguiente forma.

#pragma once

class IPlaylistFillerService
{
public:
	virtual void FillPlaylist() = 0;
};

class ServiceMp3PlayerSdLoader : public IPlaylistFillerService
{
public:
	IMp3Player& _mp3;
	SD_Hard& _sd;

	ServiceMp3PlayerSdLoader(IMp3Player mp3, SD_Hard sd) : _mp3(mp3), _sd(sd)
	{
	}
	
	void FillPlaylist()
	{
		_mp3.PlayList;
		_sd.DoSomething();
	}
};

Nuestro programa 'main' tendrá que cambiar, quedando así

#include "libs/AudioLibraries.hpp"
#include "libs/TFTLibraries.hpp"
#include "libs/SDLibraries.hpp"

#include "Mp3Player.hpp"
#include "Mp3PlayerUI.hpp"
#include "ServiceMp3PlayerSdLoader.hpp"

TFT_Hard tft;
SD_Hard sd;

Mp3Player mp3Player;
Mp3PlayerUI mp3PlayerUI(mp3Player, tft);
ServiceMp3PlayerSdLoader serviceMp3PlayerSdLoader(mp3Player, sd);

void setup()
{
	mp3Player.Init();
	serviceMp3PlayerSdLoader.FillPlaylist();
}

void loop()
{
	mp3Player.Play();
	mp3Player.Stop();
	
	mp3PlayerUI.Draw();
}

Donde vemos que hemos instanciado un servicio 'ServiceMp3PlayerSdLoader' (ponedle el nombre que queráis), que recibe una objeto IMP3Player y una SD. Después el método FillPlaylist() usa la SD para cargar la playlist en el MP3Player.

Con esto hemos conseguido una independencia casi total de nuestro proyecto con el hardware. MP3Player está relacionado con el audio, MP3PlayerUI con la TFT, y ServiceMp3PlayerSdLoader con la SD. Ningún objeto depende de más de una librería de hardware, y tampoco depende entre ellos porque toda la comunicación se realiza entre clases abstractas.

Esto nos permite poder crear nuevas clases para adaptar el proyecto a otros hardware. Después, podemos cambiar un objeto por otro como si fuera "un lego", sin tener que modificar el resto del programa.

Hasta aquí esta entrada de arquitectura de software, donde hemos visto distintos mecanismos para hacer nuestro código más limpio, mantenible y portable entre hardware. A saber, clases abstractas y servicios.

No significa que por obligación tengáis que estructurar todos vuestros programas así. Pero son mecanismos interesantes que conviene conocer (sobre todo las clases abstractas... por favor, usad clases abstractas y no #define)

En próximas entradas veremos algún truquito más para estructurar nuestro código, como clases factoría, o service locator. Os dejo todo el código de esta entrada en Github ¡Hasta la próxima!

Descarga el código

Todo el código de esta entrada está disponible para su descarga en Github.

https://github.com/luisllamasbinaburo/taller-clases-abstractas-arduino

Anuncio:

Previous Arduino Mega + ESP8266 en un único dispositivo
Next MagicaVoxel, programa gratuito para crear 'pixel art' en 3D