Language: EN

como-y-por-que-usar-clases-abstrastas-en-arduino

How and why to use abstract classes in Arduino

Today we are going to see how to use abstract classes in C++ and how to use them in MCU projects like Arduino or similar to improve our code.

As a project grows, we will have more interest in keeping the code clean, making it easier to maintain, test, and portable across different hardware.

This is where abstract classes, or interfaces in other languages, play a fundamental role. Abstract classes allow us to keep the coupling between classes weak, reducing the dependencies between them and the hardware.

To see it in the best way we are going to use a simulated example. For this case, we are going to choose a MP3 player project. This will use a TFT for the screen interface and load songs from an SD.

It is important to note that today we will not see how to make an MP3 player. Most of the functions today will be empty. This is because we are not interested in the code itself, but how to organize and structure the code.

So without further ado, let’s start by looking at the example that will accompany us in this post. At the end of it, you have all the code to download from Github.

Example 0

Suppose we are making our MP3 player, with our specific audio, TFT, and SD hardware. Our first attempt would be to write all the code in the ‘Main’ file of the program. That is, something like this.

#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;

// TFT drawing functions
void DrawBackground()
{
  tft.DoSomething();
}

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

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

// Audio functions
void Init() {}

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

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

void PrevSong() {}

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

// Setup
void setup()
{
  // Initialize hardware
  Init();

  // Load playlist from SD  
  LoadPlaylistFromSD();
}

// Loop, shortened for the example
void loop()
{
  // Here we would do the actions to perform
  // in the MP3 when pressing buttons, etc...
  Play();
  Stop();

  // Draw
  Draw();
}

To prevent this post from becoming endless, you will see that, in a simplified way, most functions do not have a body. As we said, what to do inside the functions does not interest us today, but how to organize the code.

On the other hand, we have three “simulated” libraries AudioLibraries, TFTLibraries, and SDLibraries. These “fake” objects will serve us in the example to emulate the real hardware libraries that we would be using in the project.

Each of these and libraries are very similar to each other, and are formed by a class with a single method ‘DoSomething()‘.

#pragma once

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

In the body of the program we have several functions related to the TFT (Draw…()), several related to the Audio, and a function related to the SD. We would surely need more functions, but for the purpose of the example, this is enough.

Assuming that all the functions are filled in and work, we would have finished the project! But now you think… I have put a lot of effort into making my MP3, and I want to be able to reuse it more easily in other projects or hardware.

So this is where you decide to extract the logic to a class (or library) so that you can reuse it in other projects.

MP3Player Class

Let’s get to work. We are going to take all the logic of your MP3 and put it in a class called ‘MP3Player’, which would look like this.

#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();
  }
};

Now, your main program would be much simpler.

#include "MP3Player.hpp"

MP3Player mp3Player;

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

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

  mp3Player.Draw();
}

Perfect, now you have a class that you can use in other projects, or give it to someone to easily integrate your MP3 into their program. Part of the portability is already solved. But, your MP3 is still totally dependent on your hardware. It only works with that TFT, that Audio hardware, and with an SD.

MP3PlayerUI Class

The fun part begins, to decouple our classes from each other and from third-party classes. In this case, let’s start with the hardware libraries. The first obvious dependency that “bothers us” is the dependence on the TFT.

So we decide to independently manage the audio part of our class from the graphical representation (UI). So we modify our MP3Player class to only handle the part related to 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()
  {
  }
};

In return, we create a new class ‘MP3PlayerUI’. This class will be responsible for receiving an ‘MP3Player’ object and displaying it on a TFT screen.

To do this, this class needs us to pass in the constructor a reference to an ‘MP3Player’ and to a ‘TFT_Hard’. With this, it is responsible for representing the state of MP3 on the screen. Something like this.

#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();
  }
};

Our main program would look like this

#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();
}

Perfect, now we can have different ‘Mp3PlayerUI_XXX’ adapted to different screens. One with a TFT driver, another with another, one for a monochrome LCD screen, etc. We could switch from one to another by simply changing a line in the ‘main’, and without using any #define!

But, what happens now if we also have different audio hardware? Because our ‘MP3PlayerUI_XXX’ objects have a dependency on ‘MP3Player’, which only works with specific hardware.

Would we have to create all possible combinations of audio objects with UI objects? Of course not. This is where abstract classes come into play.

Abstract Class IMP3Player

Now is when things get really interesting. We create an abstract class that we will call ‘IMP3Player’, for example.

#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;
};

This abstract class represents the set of methods that any MP3Player has. In fact, it represents “what constitutes any MP3Player for me”. Hence its name, abstract class, because it is an abstraction of any MP3Player.

Now we modify our MP3Player class to implement IMP3Player. That is, to say “I am an 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()
  {
  }
};

On the other hand (and here comes the fun part), we modify MP3PlayerUI to use an IMP3Player, instead of a specific MP3Player.

#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();
  }
};

Reflection, in reality Mp3PlayerUI does not need a specific object. It only needs something that guarantees it has certain methods. That is, it is enough for it to have an abstract class (a.k.a. Interface).

Now we have managed to decouple our classes. MP3Player manages what is related to the audio, and MP3PlayerUI with the graphical representation. They communicate using IMP3Player.

In this way, any combination of audio with hardware is possible. Furthermore, it is now possible to create a “Mock” of our MP3Player, as in the following example.

#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");
  }
};

That is, if I have already finished the audio part (or even if someone else is doing another part), I can continue working on the UI part with a ‘Mock’ object, which doesn’t even need to have real audio hardware. This is a great advantage for collaborative development, testing, or maintainability of our project.

SDFiller Service

We have one more thing left to finish this example and go for a top grade. We have decoupled the dependencies with audio, and the dependency with the TFT. But we have an ugly dependency with the SD, which we will have to solve somewhere.

When we do not need two objects to work together, but we do not want them to know each other, one way to solve it is by using a service. For example, in the following way.

#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();
  }
};

Our ‘main’ program will have to change, looking like this

#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();
}

Where we see that we have instantiated a ‘ServiceMp3PlayerSdLoader’ service (give it the name you want), which receives an IMP3Player object and an SD. Then the FillPlaylist() method uses the SD to load the playlist into the MP3Player.

With this, we have achieved almost total independence of our project from the hardware. MP3Player is related to the audio, MP3PlayerUI to the TFT, and ServiceMp3PlayerSdLoader to the SD. No object depends on more than one hardware library, and they do not depend on each other because all communication takes place between abstract classes.

This allows us to create new classes to adapt the project to other hardware. Then, we can change one object for another as if it were “a lego”, without having to modify the rest of the program.

This is the end of this software architecture post, where we have seen different mechanisms to make our code cleaner, maintainable, and portable across hardware. Namely, abstract classes and services.

This does not mean that you have to structure all your programs like this. But they are interesting mechanisms that are worth knowing (especially abstract classes… please use abstract classes and not #define).

In future posts, we will see some more tricks to structure our code, such as factory classes, or service locator. I leave all the code for this post on Github. Until next time!

Download the code

All the code from this post is available for download on Github.

github-full

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