I've got a couple of boards, a TinyPico and a generic dev board.
Let's create a blink sketch for both boards.
For the generic dev board, this is as easy.
In the setup function, we set the GPIO pin to output and then in the loop we just switch
the pin between high and low.
The TinyPICO uses an APA102 Dotstar for its built-in LED and comes with a little helper
library to make this a bit easier to use.
We create the TinyPICO object and then use that in the loop to set turn the LED on and
then off.
These are both trivial sketches, but what if we want to have one sketch that will deal
with either of the boards with minimal code changes?
How do we do that?
A typical approach we see in a lot of Arduino code is to use preprocessor statements to
switch in different implementations.
Here we have a #define at the top of our file that tells us which board we are targetting.
We can switch between our generic board and our TinyPICO board.
We then have various #ifdef statements to switch in and out functionality.
If we're using the TinyPICO board then we include the TinyPICO library and create a
variable to hold it.
For the Generic board, we set up the led pin to output in the setup function.
In the loop, we have two different code paths for turning the LED on and off.
For the Generic board we set the LED pin high and low, for the TinyPICO we power the DotStar
on and off and set the DotStar colour.
This works perfectly and is fine for small bits of code, but it rapidly starts to get
hard to understand when you have larger codebases.
Here's an example that I recently came across - we have multiple nested preprocessor directives.
It's really hard to work out what this code is doing.
Let's have a look at how we can make our multi-board blink sketch a bit easier to understand.
We'll start with a bit of a recap on classes and inheritance in C++ I'm not going to go
into a huge amount of detail as there are a lot of resources available if you want to
have a deep dive.
We'll then use what we've learnt to implement a concrete example with our blink sketch (and
yes, there is a joke in there about abstract and concrete classes).
We'll then have a quick chat about what the phrase "prefer composition over inheritance"
means. And how that impacts how we architect things.
But first, a word about the channel sponsor PCBWay.
PCBWay have been supporting the channel for a while, they offer PCB Production, CNC and
3D Printing, PCB Assembly and much much more.
I've made quite a few boards with them and they are great to work with.
You can find a link to their details in the description.
This is a pretty standard example of inheritance.
We have a base class that represents an Animal.
The terms "parent" class or "super" class are often used interchangeably with "base class", but
they all mean the same thing.
We've given our Animal class some behaviours and said that it can "walk" and "talk".
From our base class, we've derived a Dog class.
This is known as a subclass and it inherits any behaviour present in the base class.
It can either keep the existing behaviour or override it to behave in a new way.
In the Dog's case, we're saying that a dog says "Woof".
We've also got a Duck class.
We've said that when a duck talks it says "Quack".
We've also added some additional behaviour to a Duck and says that ducks can fly.
Looking at how this is implemented in C++ we can see some interesting things in our
base class.
The "talk" method has been declared as a virtual function.
This tells the C++ compiler that it needs to resolve the implementation of the function
at runtime.
We've also added this piece of code to the end of the method declaration, this turns
the method into a "pure virtual function".
This tells the compiler that our derived classes, in our case Dog and Duck, must provide an
implementation for the function.
If they don't then the compiler will generate an error.
Adding a pure virtual function to a class in C++ turns the class into something called
an "abstract class".
In C++ we can have a mixture of pure virtual functions and normal methods on a class.
Our two animals the Dog and the Duck provide implementations for the abstract class - they
are called Concrete classes.
Our Dog says "woof" and our Duck says "Quack".
Both our Dog and Duck classes derive from the Animal class.
You can see here that we specify public before Animal.
C++ supports three different access-specifiers public, protected and private.
It's highly unlikely that you'll ever need or want to use anything other than public when doing inheritance,
but Private and Protected access specifiers can be used to hide the public methods in
the parent class.
To override the talk method from the base class we simply create a method with the same
signature and provide our implementation for it.
Taking a look at the code that is using our Animal class you can see that it doesn't know
or care if the Animal is a Dog or a Duck, it just calls the talk method and the correct
thing happens.
Our variable is declared as an Animal, but the program knows that it should use the talk
implementation from Dog.
If we change our Animal to point to a Duck the program now knows that it should get the
implementation from Duck.
This is all very interesting, but whats the relevance to our LED problem?
If we look at what our loop function is trying to do then we can see that all it wants is
a way to turn the LED on and off, it doesn't care how that is implemented.
This gives us a good clue as to what our base class needs.
We'll create an abstract base class that has an "on" and an "off" method.
Our loop code can now just be changed to use this class.
All we need now is our two implementations.
For the generic dev board, we set up the GPIO pins in the constructor and then provide an
implementation for the on and off methods that just write to the GPIO pin.
For the TinyPico, we create the TinyPICO helper and for switching the LED on and off we use
the helper function to turn the DotStar LED on and off.
In our setup function, we pick the implementation that we want to use.
There's no real way here to get away from the preprocessor but at least it's all in
one place and not all over our code.
We've now got a nice easy to understand piece of code in our loop function and we can immediately
see what the intention of the code is.
If we get a new dev board with a different kind of built-in LED then it would be easy
to add a new implementation without searching through the code and updating all the #ifdef
blocks.
Now I've seen some arguments against this kind of approach that say you end up with
extra space being used from the unused classes, but the compiler is very good at stripping
out unused code, so if the derived class is never instantiated the dead code will be removed.
We can easily demonstrate this by comparing the code sizes for the two different implementations.
If we use the generic version then we get a size of 261.15-kilobytes for our program.
If we switch over to the TinyPICO version then we get a size of 262.44-kilobytes.
So you can see that if the TinyPICO version is not used then it doesn't get included in
the firmware.
We can double check this by dumping out the symbols from the firmware - if we dump the symbols
without creating the TinyPICO version then we don't find any references to our TinyPicoLED
class.
If we compile it with the TinyPico enabled then we do find a reference.
This was a very trivial example, what about more complex code.
For example, we might have a temperature sensor, an ADC reading from a potentiometer and a
buzzer to sound an alarm when the temperature goes above some level.
This could be implemented using a fairly common pattern where the business logic is implemented
in the base class with derived classes filling in the blanks to provide specific implementations
for parts of the logic.
Here we can see our function that implements our logic.
And here we have some pure virtual functions that can be overridden to provide different
implementations.
This would allow us to support a variety of different ways to read the temperature, get
the potentiometer value and sound the alarm.
We just need to create derived classes that implement the required behaviour.
So for example if we have some hardware that has a DS18B20 temperature sensor, a piezo
speaker and a built-in ADC we can create a derived class that interfaces with these three
bits of hardware.
If we get some new hardware, say one that has a TMP36 we could derive another class
to handle this.
But now we've got duplicated code for the buzzer and threshold reading.
The temptation would be to say, I'll just derive from the class that has that functionality
already implemented, but you can see this is going to get messy quite quickly and it's
going to get difficult to know which bit of code is being run.
This is where the phrase "prefer composition over inheritance" comes in.
It's much better in this situation to make the different functionality available via
smaller specialised classes.
These smaller classes derive from an abstract class that defines the behaviour they need
to offer.
So a temperature class may have a method that reads the current temperature in centigrade.
Our concrete classes would implement that method.
A buzzer class may have a method that will start and stop playing a sound.
And the potentiometer class may have a method that returns the current value on a scale
between 0 and 1.
The concrete implementations would be injected into our class and then used by the business
logic.
This pattern makes it very easy to change the implementations of parts of our system
without rewriting large amounts of code.
This can lead to very powerful architectures that are very flexible and also very testable.
It's easy to stub out parts of the system and see if they are working correctly.
So, that's it for this video - I hope this one was interesting.
Let me know what you think in the comments.
I'm sure there will be some interesting debate.