Hands-On Embedded Programming with C++17
上QQ阅读APP看书,第一时间看更新

Classes

Object-oriented programming (OOP) has been around since the days of Simula, which was known for being a slow language. This led Bjarne Stroustrup to base his OOP implementation on the fast and efficient C programming language.

C++ uses C-style language constructs to implement objects. This becomes obvious when we take a look at C++ code and its corresponding C code.

When looking at a C++ class, we see its typical structure:

namespace had { 
using uint8_t = unsigned char; 
const uint8_t bufferSize = 16;  
    class RingBuffer { 
        uint8_t data[bufferSize]; 
        uint8_t newest_index; 
        uint8_t oldest_index;  
        public: 
        enum BufferStatus { 
            OK, EMPTY, FULL 
        };  
        RingBuffer();  
        BufferStatus bufferWrite(const uint8_t byte); 
        enum BufferStatus bufferRead(uint8_t& byte); 
    }; 
} 

This class is also inside of a namespace (which we will look at in more detail in a later section), a redefinition of the unsigned char type, a namespace-global variable definition, and finally the class definition itself, including a private and public section.

This C++ code defines a number of different scopes, starting with the namespace and ending with the class. The class itself adds scopes in the sense of its public, protected, and private access levels.

The same code can be implemented in regular C as follows:

typedef unsigned char uint8_t; 
enum BufferStatus {BUFFER_OK, BUFFER_EMPTY, BUFFER_FULL}; 
#define BUFFER_SIZE 16 
struct RingBuffer { 
   uint8_t data[BUFFER_SIZE]; 
   uint8_t newest_index; 
   uint8_t oldest_index; 
};  
void initBuffer(struct RingBuffer* buffer); 
enum BufferStatus bufferWrite(struct RingBuffer* buffer, uint8_t byte); 
enum BufferStatus bufferRead(struct RingBuffer* buffer, uint8_t *byte); 

The using keyword is similar to typedef, making for a direct mapping there. We use a const instead of a #define. An enum is essentially the same between C and C++, only that C++'s compiler doesn't require the explicit marking of an enum when used as a type. The same is true for structs when it comes to simplifying the C++ code.

The C++ class itself is implemented in C as a struct containing the class variables. When the class instance is created, it essentially means that an instance of this struct is initialized. A pointer to this struct instance is then passed with each call of a function that is part of the C++ class.

What these basic examples show us is that there is no runtime overhead for any of the C++ features we used compared to the C-based code. The namespace, class access levels (public, private, and protected), and similar are only used by the compiler to validate the code that is being compiled.

A nice feature of the C++ code is that, despite the identical performance, it requires less code, while also allowing you to define strict interface access levels and have a destructor class method that gets called when the class is destroyed, allowing you to automatically clean up allocated resources.

Using the C++ class follows this pattern:

had::RingBuffer r_buffer;  
int main() { 
    uint8_t tempCharStorage;     
    // Fill the buffer. 
    for (int i = 0; r_buffer.bufferWrite('A' + i) == 
had::RingBuffer::OK; i++) { // } // Read the buffer. while (r_buffer.bufferRead(tempCharStorage) == had::RingBuffer::OK)
{ // } }

This compares to the C version like this:

struct RingBuffer buffer;  
int main() { 
    initBuffer(&buffer); 
    uint8_t tempCharStorage;  
    // Fill the buffer. 
    uint8_t i = 0; 
    for (; bufferWrite(&buffer, 'A' + i) == BUFFER_OK; i++) {          
// } // Read the buffer. while (bufferRead(&buffer, &tempCharStorage) == BUFFER_OK) { // } }

Using the C++ class isn't very different from using the C-style method. Not having to do the manual passing of the allocated struct instance for each functional call, but instead calling a class method, is probably the biggest difference. This instance is still available in the form of the this pointer, which points to the class instance.

While the C++ example uses a namespace and embedded enumeration in the RingBuffer class, these are just optional features. One can still use global enumerations, or in the scope of a namespace, or have many layers of namespaces. This is very much determined by the requirements of the application.

As for the cost of using classes, versions of the examples in this section were compiled for the aforementioned Code Craft series for both the Arduino UNO (ATMega328 MCU) and Arduino Due (AT91SAM3X8E MCU) development boards, giving the following file sizes for the compiled code:

 

Optimization settings for these code file sizes were set to -O2.

Here, we can see that C++ code is identical to C code once compiled, except when we perform initialization of the global class instance, on account of the added code to perform this initialization for us, amounting to 38 bytes for the Uno.

Since only one instance of this code has to exist, this is a constant cost we only have to pay once: in the first and last line, we have one and four class instances or their equivalent, respectively, yet there is only an additional 38 bytes in the Uno firmware. For the Due firmware, we can see something similar, though not as clearly defined. This difference is likely affected by some other settings or optimizations.

What this tells us is that sometimes we don't want to have the compiler initialize a class for us, but we should do it ourselves if we need those last few bytes of ROM or RAM. Most of the time this will not be an issue, however.