Arch 2 – Object-oriented Design in C++
The Language for Embedded Systems
Even as other computer languages such as Python and Javascript have made their way into the embedded industries, C and C++ remain dominant in the fields. The main reason is that while supporting high level constructs they are relatively efficient in interfacing with hardware, supporting bit manipulation, register access, interrupt handling, DMA, etc.
While some C++ features are costly and should be avoided in resource-constrained embedded systems, many object-oriented programming features offer great benefits with little extra overhead. As we will see, the use of statecharts to model system behaviors (with active objects, event objects, etc.) are built on top of orient-oriented design (OOD) principles. In this episode, we will review the three main OOD principles, namely encapsulation, inheritance and polymorphism.
Encapsulation
C Structure
Before object-oriented programming), we use structure to group related data together. For example if we want to represent properties of a vehicle, we would have:
enum {
VIN_SIZE = 18,
};
typedef struct {
char vin[VIN_SIZE];
uint32_t seats;
float mpg;
} Vehicle;
Functions that operate on a structure are separated from its type definition. An explicit pointer to the structure to be operated on is passed as one of the arguments to the function. For example, we may have:
float RunningCost(Vehicle const *v, float miles, float gasPrice) {
return (miles / (v->mpg)) * gasPrice;
}
C++ Class
C++ makes it easier with classes. Related data and functions operating on those data are grouped together in a single entity called class. An instance of a class is called an object of that class. Classroom textbooks usually introduce the concept of objects with examples related to our daily life, such as a table, a car or a bank account. In practice objects often refer to some abstract concepts that are harder to grasp. For example an object can be a key-value pair, a FIFO, an event, a state-machine, an active object or the entire framework. As a start, let’s get back to our Vehicle example.
Using class, the Vehicle class looks like this:
class Vehicle {
public:
enum {
VIN_SIZE = 18,
};
Vehicle(char const *vin, uint32_t seats, float mpg) :
m_seats(seats), m_mpg(mpg) {
STRBUF_COPY(m_vin, vin);
}
virtual ~Vehicle() {}
char const *GetVin() const { return m_vin; }
uint32_t GetSeats() const { return m_seats; }
float GetMpg() const { return m_mpg; }
virtual void GetInfo(char *buf, uint32_t bufSize) const {
snprintf(buf, bufSize, "To be implemented in derived classes");
}
float RunningCost(float miles, float gasPrice) const {
TEST_CODE_ASSERT(m_mpg);
return (miles/m_mpg)*gasPrice;
}
protected:
char m_vin[VIN_SIZE];
uint32_t m_seats;
float m_mpg;
};
The example usage and output look like this:
// Example.
Vehicle car1("CAR1", 5, 35.0);
Vehicle car2("CAR2", 7, 24.0);
console.Print("CAR1: A 100-mile trip costs $%f\n\r", car1.RunningCost(100, 2.89));
console.Print("CAR2: A 100-mile trip costs $%f\n\r", car2.RunningCost(100, 2.89));
[Results]
CAR1: A 100-mile trip costs $8.257
CAR2: A 100-mile trip costs $12.042
Here are some key points:
- A class is defined by the keyword class. A class is like a structure that combines related data members together. In addition, a class can contain methods (member functions) that operate on its data members.
- A class acts like a new data type. You can use it to build custom types on top of built-in types such as bool, int, float, std::string, etc.
- Once you have defined your class, you can instantiate (create) objects (instances) from it, in a similar way as you define an integer variable from the built-in type “int”. Each object has its own copy of data members in memory (SRAM). In the example above, car1 and car2 have different values of number of seats (m_seats) and gas mileage (m_mpg). Member functions exist in code memory (Flash) and there is only one copy shared by all instances of the class.
- The constructor is a member function named as the class name without any return type. It is called when an object of the class is instantiated and is responsible for initializing data members of the object (e.g. with parameters passed in to the constructor). The constructor ensures an object is properly initialized when it is created. The compiler checks if all the required parameters are provided to the constructor and the constructor validates the parameters in a common location. This is a major advantage over C struct with which data fields are assigned individually, which may easily result in partially initialized structures.
- The destructor is a member function named as the class name with a ‘~’ in the front. It performs cleanup when an object is deleted (for dynamically allocated ones) or gets out of scope (for statically allocated ones). Usually it frees up any resources allocated in the constructor, for example by deallocating memory buffers or closing files, etc.
- A class provides accessibility control with the keywords public, protected and private. “public” members are accessible anywhere in the program, just like members of a C struct. “private” members are only accessible by member functions of the same class. “protected” members behave like “private” with the exception that they are also accessible by methods of derived classes (see Inheritance section). It’s a good practice to keep data members non-public (i.e. private or protected) to promote data hiding. When necessary, public methods called getters and setters can be provided to get and set hidden data members. It provides a layer of isolation which makes it easier to modify the internal implementation of a class without affecting its users.
- Member functions (non-static) must be called using the dot operator on an object of the same class or using the arrow operator on a pointer to an object.
// RunningCost is called on the object car1 using dot operator.
Vehicle car1("CAR1", 5, 35.0);
float cost = car1.RunningCost(100, 2.89);
// RunningCost is called on the pointer pCar2 using arrow operator.
Vehicle *pCar2 = new Vehicle("CAR2", 7, 24.0);
cost = pCar2->RunningCost(100, 2.89);
delete pCar2;
This Pointer
The pointer to the object on which a member function is called is implicitly passed in to the member function as a hidden parameter named “this”, called the “this pointer”. It is through “this” pointer that a member function can access data members of the object or call other member functions on the object.
For example, this is the original Vehicle::RunningCost() method in the code:
// Note – Vehicle:: is the scope resolution operator, indicating that
// RunningCost is a member function of Vehicle.
float Vehicle::RunningCost(uint32_t miles, float gasPrice) const {
TEST_CODE_ASSERT(m_mpg);
return (miles/m_mpg)*gasPrice;
}
Under the hood, the actual implementation looks like this:
// Note – The first parameter “this” is implicitly added by the compiler.
float Vehicle::RunningCost(Vehicle *this, uint32_t miles, float gasPrice) const {
TEST_CODE_ASSERT(this->m_mpg);
return (miles/this->m_mpg)*gasPrice;
}
In our examples above, “this” is equal to &car1 (address of the object car1) in the first invocation of RunningCost() and is equal to pCar2 in the second invocation.
Inheritance
Special Kinds of Vehicles
One way of building more complex objects from simpler ones is to use inheritance. Inheritance allows us to derive a subclass from a base class, through which the subclass inherits members of the base class. Inheritance is transitive, i.e. there can be multiple levels of inheritance and the final derived class inherits from all the base classes including intermediate ones. It promotes code reuse since we do not need to rewrite anything that has already been defined in the base classes. We only need to code the differences by adding new members, or by overriding members already defined in the base classes.
Inheritance represents an “is-a” relationship, meaning that a derived class is a special kind of the base class. Continuing with our vehicle example above, we can use the class Vehicle as a base class to represent any kinds or vehicles. From it we derive a special kind of vehicles named Sedan and another kind called Suv. Note that a sedan or an SUV is still a vehicle.
class Sedan : public Vehicle {
public:
Sedan(char const *vin, uint32_t seats, float mpg, bool sporty) :
Vehicle(vin, seats, mpg), m_sporty(sporty) {}
bool IsSporty() const { return m_sporty; }
protected:
bool m_sporty;
};
class Suv : public Vehicle {
public:
Suv(char const *vin, uint32_t seats, float mpg, bool roofRack) :
Vehicle(vin, seats, mpg), m_roofRack(roofRack) {}
bool hasRoofRack() const { return m_roofRack; }
protected:
bool m_roofRack;
};
Here are the key points:
- The line “class Sedan : public Vehicle” indicates that the class Sedan is derived from the base class Vehicle. We can call Sedan a subclass, derived class or child class. We can call Vehicle a superclass, base class or parent class.
- Sedan inherits all the data members and methods of Vehicle. In addition, it defines a new member named m_sporty. Here we assume being sporty or not is a special property applicable to sedans. Similarly, Suv adds a property named m_roofRack. Again we assume having a roof rack or not is a special property applicable to SUVs.
- Apart from initializing its own data members, the constructor of a derived class is responsible for initializing the base part of the object by calling the constructor of its immediate base class and passing any required parameters to it. The order of initialization begins from the very base class through any intermediate base classes before data members of the derived class. Within a single class, the order of initialization of data members follows the order in which they are defined in the class body.
More Specialization
Now that we have derived Sedan from Vehicle, we can further create special kinds of sedans through a second level of inheritance, i.e. by deriving subclasses from Sedan. Similarly we can create special kinds of SUVs by deriving subclasses from Suv.
For example, we are a car dealership and we sell two models of vehicles, namely Model A which is a sedan and Model B which is an SUV. We can derive the class ModelA from Sedan and ModelB from Suv:
class ModelA : public Sedan {
public:
ModelA(char const *vin) :
Sedan(vin, 5, 35.0, false) {}
void GetInfo(char *buf, uint32_t bufSize) const {
snprintf(buf, bufSize, "%s: ModelA sedan, seats=%lu, mpg=%f, sporty=%d",
m_vin, m_seats, m_mpg, m_sporty);
}
};
class ModelB : public Suv {
public:
enum {
UPGRADE_SIZE = 128
};
ModelB(char const *vin, char const *upgrade = "") :
Suv(vin, 7, 24.0, true) {
STRBUF_COPY(m_upgrade, upgrade);
}
void GetInfo(char *buf, uint32_t bufSize) const {
snprintf(buf, bufSize, "%s: ModelB SUV, seats=%lu, mpg=%f, roofRack=%d,
upgrade='%s'", m_vin, m_seats, m_mpg, m_roofRack, m_upgrade);
}
private:
char m_upgrade[UPGRADE_SIZE];
};
Here are the key points:
- Like before, the line “class ModelA : public Sedan” indicates that ModelA is derived from its base class Sedan. We call Sedan an intermediate base class since it in turn is derived from its own base class Vehicle.
- The constructor of ModelA calls the constructor of its immediate base class Sedan, passing the required parameters to it. Note that some of the parameters to the constructor of Sedan are “fixed” (e.g number of seats and gas mileage) since those parameters are known properties of this special model (Model A) of sedans. However since each individual vehicle has its unique VIN (Vehicle ID), the parameter vin must be passed in by the caller instantiating the object. Here we do not need to worry about calling the constructors of any lower level base classes, since the immediate base class (Sedan) will take care of any lower level construction.
- ModelA (likewise for ModelB) overrides the method GetInfo() that has already been defined in its base class Vehicle. Overriding happens when a method of a derived class has the same signature (function name, parameters and return type) as a method of a base class. When GetInfo() is called on a ModelA object, the version ModelA::GetInfo() is invoked. When GetInfo() is called on a Vehicle object, the version Vehicle::GetInfo() is invoked. (For now, let’s ignore the “virtual” keyword.) See this example:
// Example.
Vehicle car1("VEHICLE", 5, 35.0);
ModelA car2("MODEL_A");
char info[100];
car1.GetInfo(info, sizeof(info));
console.Print("Vehicle::GetInfo() is called: %s\n\r", info);
car2.GetInfo(info, sizeof(info));
console.Print("ModelA::GetInfo() is called: %s\n\r", info);
[Results]
Vehicle::GetInfo() is called: To be implemented in derived classes
ModelA::GetInfo() is called: MODEL_A: ModelA sedan, seats=5, mpg=35.000, sporty=0
- In ModelB, which is derived from Suv, we further specialize it with a new property named m_upgrade. Here we assume upgrade options are only available to Model B; otherwise we would have added this property to its base class. This illustrates the concept of programming by difference. We factorize common properties and behaviors to base classes and implement only the differences in subclasses. In our vehicle example, we allow each model to customize its info string and add custom properties (e.g. upgrade options).
Casting
Since Sedan inherits from its base class Vehicle, it automatically gets the members m_vin, m_seats and m_mpg. On top of that it adds a new member m_sporty. A memory view of a Vehicle and a Sedan object looks like this:
As shown above, it is safe to convert/cast a pointer to a derived class object (Sedan) to a pointer to its base class object (Vehicle). This is called upcastingand the compiler automatically does it for us. It works because the memory layout of the base/bottom part of a derived object (Sedan) looks identical to that of its base class object (Vehicle).
On the contrary, the reverse, called downcasting, is not necessarily safe. We may not always convert/cast a pointer to a base class object to a pointer to a derived class object. We must be sure that the object pointed to by the base class pointer is indeed a derived class object.
The C++ feature called RTTI (Run-time Type Identification) helps but in embedded system RTTI is not encouraged due to its overhead. We can, however, encode the type information explicitly with a special data member (e.g. an enum for event ID) in the base class.
Since it is the programmers’ responsibility to ensure type-correctness when performing downcasting, the compiler requires us to use a static_cast<> operator explicitly.
Example:
// Example.
ModelA car("MODEL_A");
Vehicle *pv = &car; // OK
console.Print("VIN = %s\n\r", pv->GetVin());
// Compile error:
// invalid conversion from 'APP::Vehicle*' to 'APP::ModelA*' [-fpermissive]
//ModelA *pa = pv;
ModelA *pa = static_cast<ModelA *>(pv);
console.Print("VIN = %s\n\r", pa->GetVin());
[Results]
VIN = MODEL_A
VIN = MODEL_A
Composition
Apart from inheritance, composition is another way to build more complex objects from simpler ones, which is achieved by containing objects of other classes as its data members. Composition represents a has-a relationship.
Simple examples include a car containing (having) an engine, 4 wheels and 4 passenger doors. Continuing with our vehicle examples, we go one level up to design a car inventory containing a list of vehicles. We represent it with the class CarInventory:
class CarInventory {
public:
enum {
INFO_SIZE = 256,
};
CarInventory() = default;
~CarInventory() = default;
void Add(Vehicle *v) {
m_vehicles.push_back(v);
}
void Remove(char const *vin) {
m_vehicles.erase(std::remove_if(m_vehicles.begin(), m_vehicles.end(),
[&](Vehicle const *v) {
return STRING_EQUAL(v->GetVin(), vin);
}), m_vehicles.end());
}
void Clear() {
m_vehicles.clear();
}
void Show(Console &console, char const *msg = nullptr) {
if (msg) {
console.Print("%s\n\r", msg);
}
console.Print("Car inventory:\n\r");
console.Print("==============\n\r");
for (auto const &v: m_vehicles) {
char info[INFO_SIZE];
v->GetInfo(info, sizeof(info));
console.Print("%s\n\r", info);
}
}
private:
std::vector<Vehicle *> m_vehicles;
};
// Example.
ModelA carA1("A001");
ModelA carA2("A002");
ModelA carA3("A003");
ModelB carB1("B001");
ModelB carB2("B002", "Autodrive");
ModelB carB3("B003", "Entertainment");
CarInventory inventory;
inventory.Add(&carA1);
inventory.Add(&carA2);
inventory.Add(&carA3);
inventory.Add(&carB1);
inventory.Add(&carB2);
inventory.Add(&carB3);
inventory.Show(console);
inventory.Remove("A002");
inventory.Remove("B002");
inventory.Remove("B004");
inventory.Show(console, "After deleting some cars...");
inventory.Clear();
inventory.Show(console, "After clearing all...");
[Results]
Car inventory:
==============
A001: ModelA sedan, seats=5, mpg=35.000, sporty=0
A002: ModelA sedan, seats=5, mpg=35.000, sporty=0
A003: ModelA sedan, seats=5, mpg=35.000, sporty=0
B001: ModelB SUV, seats=7, mpg=24.000, roofRack=1, upgrade=''
B002: ModelB SUV, seats=7, mpg=24.000, roofRack=1, upgrade='Autodrive'
B003: ModelB SUV, seats=7, mpg=24.000, roofRack=1, upgrade='Entertainment'
After deleting some cars...
Car inventory:
==============
A001: ModelA sedan, seats=5, mpg=35.000, sporty=0
A003: ModelA sedan, seats=5, mpg=35.000, sporty=0
B001: ModelB SUV, seats=7, mpg=24.000, roofRack=1, upgrade=''
B003: ModelB SUV, seats=7, mpg=24.000, roofRack=1, upgrade='Entertainment'
After clearing all...
Car inventory:
==============
Here are some explanations:
- The class CarInventory contains a list of vehicles using a C++ vector, which is like a dynamic array that can grow or shrink as we add or remove items to/from it.
std::vector<Vehicle *> m_vehicles;
- CarInventory only stores pointers to the vehicle objects (Vehicle *) in the vector, rather than copies of the vehicle objects. Whenever we pass pointers around, we need to be extremely careful about ownership (i.e. which component owns the objects); otherwise we may run into dangling-pointer or memory-leak issues. In this example, we assume the vehicle objects referenced to by CarInventory are owned by its user externally, and therefore it does not delete them in its Remove() function or destructor. As the vehicle objects are allocated on the stack, they are deallocated automatically when they get out of scope at the end of the example.
- When we create the vehicle objects, we create specific models, i.e. objects of the derived classes ModelA and ModelB. We pass in unique parameters to each call of the constructors so that each object has its own unique properties (attributes) such as its VIN, upgrade options, etc.
- CarInventory, however, maintains pointers to those vehicle objects in a vector via pointers to the base class (i.e. Vehicle *) rather than pointers to the specific derived classes (such as ModelA * or ModelB *). This is called polymorphism in object-oriented programming.
Polymorphism
Polymorphism allows us to write generic code like CarInventory in our example. CarInventory does not care the exact derived classes of vehicle objects added to it, as long as they are derived from the same base class Vehicle. Recall that a compiler automatically converts a pointer to a derived class object to a pointer to its base classobject. This is what happens in the following call:
inventory.Add(&carA1);
In the CarInventory::Show() function, it loops through all the vehicle objects referenced to by its vector and calls the GetInfo() method of each object. Here it illustrates the purpose of the “virtual” keyword in the declaration of GetInfo() in the base class Vehicle, which defines GetInfo() as a virtual function.
// auto is deduced to "Vehicle *"
for (auto const &v: m_vehicles) {
char info[INFO_SIZE];
v->GetInfo(info, sizeof(info));
console.Print("%s\n\r", info);
}
A virtual function is a member function declared with the keyword virtual in a base class. Like any member functions of a base class, a virtual function can be overridden by a derived class. The unique feature of being virtual is that when it is invoked/called on an object via a pointer to the base class, the code at run-time will call the overriding function (if any) defined in the actual derived class of the object. If a virtual function is not overridden by the derived class, the version in the base class will be invoked. As shown in the results output above, the invocation of GetInfo() via Vehicle * in the for-loop calls either ModelA::GetInfo() or ModelB::GetInfo() depending on the actual derived class of an object (e.g. carA1, carB1, etc) added to CarInventory.
If we now add a new model named ModelC, do we need to modify CarInventory? This is the essence of object-oriented programming. Polymorphism is particularly useful in the design of application frameworks, which by nature need to be generic. We do not want to modify the framework code every time when we add a new application class. We will explore application framework design in details in upcoming episodes.