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

How and why to use abstract classes in Arduino

  • 11 min

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 become increasingly interested in keeping the code clean, to make it more easily maintainable, testable, and portable between 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 dependencies between them and with the hardware.

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

It is important to emphasize that today we are not going to see how to make an MP3 player. Most of the functions today will be empty. That is because we are not interested in the code itself, but in 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 hardware, TFT, and SD. Our first attempt would be to write all the code in the program’s ‘Main’ file. 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();
}
Copied!

So that this post doesn’t become endless, you’ll see that, in a simplified way, most functions have no body. As we said, what to do inside the functions is not our interest 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 we would be using in the project.

Each of these libraries is very similar to each other, and consists of a class with a single method ‘DoSomething()’.

#pragma once

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

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

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

So that’s where you decide to extract the logic into a class (or library) so 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 into 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();
  }
};
Copied!

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

Perfect, now you have a class that you can use in other projects, or give to someone to integrate your MP3 easily 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 card.

MP3PlayerUI Class

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

So we decide to separate the Audio part of our class from the graphical representation part (UI). So we modify our MP3Player class so that it only handles 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()
  {
  }
};
Copied!

In exchange, 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 a ‘TFT_Hard’. With this, it takes care of representing the state of the 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();
  }
};
Copied!

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

Perfect, now we can have different ‘Mp3PlayerUI_XXX’ adapted to different screens. One with a TFT driver, another with a different one, one for a monochrome LCD screen, etc. We could change from one to another just by changing one line in the ‘main’, and without using a single #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 a specific hardware.

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

Abstract class IMP3Player

Now is where 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;
};
Copied!

This abstract class represents the set of methods that any MP3Player has. In fact, it represents “what for me constitutes any MP3Player”. 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()
  {
  }
};
Copied!

On the other hand (and here comes the trick) 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();
  }
};
Copied!

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

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

In this way, any combination of audio with hardware is possible. Moreover, it is now possible to make a “Mock” of our MP3Player, such as the following.

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

That is, if I have already finished the audio part (or even if another person 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 little thing left to finish this example and go for “extra credit”. We have decoupled the dependencies with audio, and the dependency with the TFT. But we still have a nasty dependency with the SD, which we have to put somewhere.

When we don’t need two objects to work together, but we don’t 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();
  }
};
Copied!

Our ‘main’ program will have to change, becoming 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();
}
Copied!

Where we see that we have instantiated a service ‘ServiceMp3PlayerSdLoader’ (call it whatever 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 an almost total independence of our project from the hardware. MP3Player is related to audio, MP3PlayerUI to the TFT, and ServiceMp3PlayerSdLoader to the SD. No object depends on more than one hardware library, and they also don’t depend on each other because all communication is done 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 like “a Lego piece”, without having to modify the rest of the program.

That’s all for this software architecture post, where we have seen different mechanisms to make our code cleaner, more maintainable, and portable between hardware. Namely, abstract classes and services.

It doesn’t mean that you are forced to structure all your programs this way. But they are interesting mechanisms that are good to know (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 you all the code from this post on Github. See you 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