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