Post

Post #1.1: OOP Fundamentals

Welcome to Post #1.1 of the Mastering Object-Oriented Programming (OOP), C++, and Embedded Systems Series!

This series will take you through the journey of mastering OOP and C++ concepts and applying them in embedded systems development. Each topic is split into two posts: the first part focuses on core C++ concepts, while the second part shows how to apply those concepts to embedded systems using the ESP32-S3 microcontroller.

Note: This series is based on my personal learning path. If you’re completely new to OOP, you may find some parts challenging, but if you have a basic understanding of OOP, you’ll find useful applications of these principles in embedded systems.

Check out the full series here.

In this post, we start with setting up your development environment and exploring the fundamentals of OOP and C++.

Setting Up Your Environment for C++ Development in VS Code

To get started with C++ development in Visual Studio Code (VS Code), follow these steps:

  1. Install VS Code:
  2. Install the C++ Extension:
    • Open VS Code, navigate to the Extensions view, and install the C/C++ extension by Microsoft.
  3. Install a C++ Compiler and Configure VS Code:
  4. Install PlatformIO:
    • PlatformIO is an open-source ecosystem for embedded systems and IoT development. To install it:
      1. Open VS Code.
      2. Go to the Extensions view (Ctrl+Shift+X).
      3. Search for “PlatformIO” and install the PlatformIO IDE extension.
      4. Once installed, you’ll find a new PlatformIO icon on the Activity Bar. Click it to open the PlatformIO home screen and follow the prompts to complete the setup.

With these tools in place, you’re ready to dive into both OOP and embedded systems projects.

Understanding Classes and Objects

Object-oriented programming is an approach to programming where you first create objects to represent real-world things, and then use those objects to model and solve your problem.

In C++, a class is a blueprint for creating objects. It encapsulates data and functions that operate on the data. An object is an instance of a class.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;

class Car {
public:
    // Data members
    string brand;
    int year;

    // Member function
    void displayInfo() {
        cout << "Brand: " << brand << ", Year: " << year << endl;
    }
};

int main() {
    // Create an object of Car
    Car myCar;
    myCar.brand = "Toyota";
    myCar.year = 2020;
    myCar.displayInfo();

    return 0;
}

The Single Responsibility Principle (SRP) is one of the SOLID principles of object-oriented design and programming. It states:

A class should have only one reason to change.

In other words, a class should have only one job or responsibility. If a class has more than one responsibility, it becomes more complex and harder to maintain. Changes in one responsibility may affect others.

Example of SRP Violation:

Consider a class that manages both car data and car-related notifications:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Car {
public:
    // Data members
    string brand;
    int year;

    // Member functions
    void displayInfo() {
        cout << "Brand: " << brand << ", Year: " << year << endl;
    }

    void sendNotification() {
        // Code to send a notification (e.g., email, alert)
    }
};

In this example, the Car class has two responsibilities: managing car data and sending notifications, violating SRP.

Adhering to SRP:

To follow SRP, separate these responsibilities into different classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Car {
public:
    // Data members
    string brand;
    int year;

    // Member function
    void displayInfo() {
        cout << "Brand: " << brand << ", Year: " << year << endl;
    }
};

class NotificationSender {
public:
    void sendNotification() {
        // Code to send a notification (e.g., email, alert)
    }
};

Here, the Car class focuses solely on managing car data, while the NotificationSender class handles notifications. This separation adheres to SRP, making each class simpler and more maintainable.

Constructors and Destructors

Constructors and destructors are special member functions in C++ that play a crucial role in managing the lifecycle of objects and resources:

  • Constructor: A constructor is a special member function that is automatically called when an object is created. Its primary purpose is to initialize the object. You can define custom constructors to initialize object attributes or allocate resources.

  • Destructor: A destructor is a special member function that is automatically called when an object is destroyed. It is used to release resources that were allocated during the object’s lifetime. Even if you do not explicitly define a destructor, C++ provides a default version that will be automatically invoked.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Resource {
public:
    // Constructor
    Resource() {
        cout << "Resource acquired." << endl;
    }

    // Destructor
    ~Resource() {
        cout << "Resource released." << endl;
    }
};

int main() {
    Resource res; // Constructor is called here

    // Some operations with 'res'

    return 0; // Destructor is called here
}

In this example, the Resource class has a constructor that prints a message when an object is created and a destructor that prints a message when the object is destroyed. These messages help demonstrate when the constructor and destructor are invoked.

As a developer, you are responsible for cleaning up after yourself.

This quote emphasizes the importance of properly managing resources and ensuring that objects are cleaned up correctly to avoid memory leaks and other resource management issues.

Access Specifiers and Friend Functions

In object-oriented programming, access specifiers define how the members of a class can be accessed. They help to enforce encapsulation and protect the integrity of data.

Access specifiers control the visibility of class members. There are three main access specifiers in C++:

  • public: Members are accessible from outside the class.
  • private: Members are accessible only within the class.
  • protected: Members are accessible within the class and its derived classes.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
using namespace std;

class Employee {
private:
    string name;
    double salary;

public:
    // Constructor
    Employee(string n, double s) : name(n), salary(s) {}

    // Public method to display information
    void displayInfo() {
        cout << "Name: " << name << ", Salary: $" << salary << endl;
    }

    // Friend function declaration
    friend void increaseSalary(Employee& emp, double amount);
};

// Friend function definition
void increaseSalary(Employee& emp, double amount) {
    emp.salary += amount;
}

int main() {
    Employee emp("John Doe", 50000);
    emp.displayInfo();

    // Increase salary using friend function
    increaseSalary(emp, 5000);
    emp.displayInfo();

    return 0;
}

In this example:

  • name and salary are private members of the Employee class.
  • displayInfo is a public method that allows access to private members from within the class.
  • increaseSalary is a friend function that can access private members of Employee directly.

Friend Functions

A friend function is a special function that is not a member of the class but has access to its private and protected members. While friend functions can be useful for certain operations, their use is sometimes criticized because they break the encapsulation principle by granting external functions access to a class’s internals. This can potentially lead to tightly coupled code and reduced flexibility.

Protected Members

The protected access specifier allows access to members within the class itself and its derived classes. While it can be useful for extending functionality in derived classes, it also exposes members to subclasses that might not need direct access, which can sometimes lead to unintended dependencies and maintenance challenges.

Pillars of OOP

1. Encapsulation:

Encapsulation involves bundling the data (attributes) and methods (functions) that operate on the data into a single unit called a class. It also involves restricting direct access to some of an object’s components, which helps to prevent accidental interference and misuse.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
using namespace std;

class BankAccount {
private:
    double balance;

public:
    BankAccount(double initialBalance) : balance(initialBalance) {}

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }

    double getBalance() const {
        return balance;
    }
};

int main() {
    BankAccount account(1000.0);
    account.deposit(500.0);
    account.withdraw(200.0);
    cout << "Balance: " << account.getBalance() << endl;

    return 0;
}

Explanation of Encapsulation:

  1. Private Data Members:
    • The balance variable is declared as private. This means it cannot be accessed directly from outside the BankAccount class. This restriction helps prevent unauthorized or unintended changes to the balance, ensuring that the internal state is protected from external interference.
  2. Public Methods:
    • The methods deposit, withdraw, and getBalance are declared as public. These methods provide controlled access to the private balance data. By using these methods, external code can interact with the BankAccount object without directly manipulating its internal state.
    • The deposit and withdraw methods include validation logic to ensure that operations are performed safely. For example, withdraw checks that the amount is valid and does not exceed the available balance, protecting the balance from being set to an invalid state.
  3. Constructor:
    • The BankAccount constructor initializes the balance when a new BankAccount object is created. The use of a constructor ensures that the balance is set to a valid initial value and encapsulates the process of setting up the object.
  4. Accessing Data:
    • The getBalance method allows external code to retrieve the current balance without directly accessing the balance variable. This method is marked as const, indicating it does not modify the object’s state, further emphasizing encapsulation and const-correctness.

By encapsulating the balance data and controlling how it is accessed and modified, the BankAccount class ensures that the internal state is protected and maintains the integrity of the object. This approach reduces the risk of errors and enhances the maintainability of the code.

2. Abstraction:

Abstraction is a key principle of Object-Oriented Programming (OOP) that focuses on simplifying complex systems by exposing only the essential features of an object while hiding the underlying implementation details. This allows programmers to work at a higher level of abstraction, making it easier to manage and interact with complex systems.

How Abstraction Works:

  1. Hiding Complexity:
    • Abstraction hides the complex details of an object’s implementation and only exposes the necessary interfaces to the user. This helps to reduce complexity and allows the user to interact with the object using simple and understandable methods.
  2. Abstract Classes:
    • In C++, an abstract class is a class that contains at least one pure virtual function. A pure virtual function is a method that is declared but not defined in the abstract class. It is intended to be overridden by derived classes.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
using namespace std;

// Abstract base class
class Shape {
public:
    // Pure virtual function
    virtual void draw() const = 0; // This makes Shape an abstract class
};

// Derived class representing a Circle
class Circle : public Shape {
public:
    // Overriding the pure virtual function
    void draw() const override {
        cout << "Drawing a circle." << endl;
    }
};

// Derived class representing a Square
class Square : public Shape {
public:
    // Overriding the pure virtual function
    void draw() const override {
        cout << "Drawing a square." << endl;
    }
};

int main() {
    Circle circle;  // Create a Circle object
    Square square;  // Create a Square object
    
    // Use polymorphism to call the draw method
    circle.draw();  // Outputs: Drawing a circle.
    square.draw();  // Outputs: Drawing a square.

    return 0;
}

Explanation of the Example Code:

  1. Abstract Base Class Shape:
    • Shape is an abstract class because it contains a pure virtual function draw(). This means that Shape cannot be instantiated directly, and any derived class must provide an implementation for the draw() function.
  2. Derived Classes Circle and Square:
    • Both Circle and Square inherit from Shape and provide their own implementations of the draw() function. This is an example of how abstraction allows different derived classes to provide specific behavior while adhering to a common interface defined by the base class.
  3. Using Abstraction:
    • In the main() function, objects of Circle and Square are created. Although the exact implementation of draw() is different for each class, the code interacts with these objects using the abstract Shape interface.
    • This allows for flexibility and scalability, as new shapes can be added by simply extending Shape and implementing the draw() function, without modifying the existing code that uses Shape.

3. Inheritance:

Inheritance is a fundamental concept in Object-Oriented Programming (OOP) that allows a class to inherit attributes and methods from another class. This mechanism promotes code reuse, simplifies maintenance, and establishes a natural hierarchy between classes.

How Inheritance Works:

  1. Base Class:
    • The base class (or parent class) is the class that provides common attributes and methods. It serves as the foundation for other classes.
  2. Derived Class:
    • The derived class (or child class) inherits from the base class and can use its attributes and methods. It can also add new attributes and methods or override existing ones.
  3. Access Specifiers:
    • In C++, the access specifiers (public, protected, private) control how the members of the base class are inherited by the derived class.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;

// Base class
class Animal {
public:
    // Method in the base class
    void eat() {
        cout << "This animal is eating." << endl;
    }
};

// Derived class
class Dog : public Animal {
public:
    // Method in the derived class
    void bark() {
        cout << "Woof! Woof!" << endl;
    }
};

int main() {
    Dog myDog;  // Create an object of the derived class Dog

    // Access inherited method
    myDog.eat(); // Outputs: This animal is eating.

    // Access method of the derived class
    myDog.bark(); // Outputs: Woof! Woof!

    return 0;
}

Explanation of the Example Code:

  1. Base Class Animal:
    • The Animal class has a public method eat() that prints a message indicating that the animal is eating. This method is common to all animals and will be inherited by any class derived from Animal.
  2. Derived Class Dog:
    • The Dog class inherits from Animal using public inheritance. This means that all public and protected members of Animal are accessible in Dog in the same access level.
    • The Dog class adds its own method bark() that prints a message specific to dogs.
  3. Using Inheritance in main():
    • An object of the Dog class, myDog, is created.
    • myDog can call both the inherited eat() method from Animal and the bark() method defined in Dog.
    • This demonstrates how the Dog class reuses the functionality provided by the Animal class while adding its own specific functionality.

Benefits of Inheritance:

  • Code Reuse: Inheritance allows you to reuse code from existing classes, which reduces redundancy and improves maintainability. For example, Dog inherits eat() from Animal without having to redefine it.
  • Hierarchy: Inheritance helps establish a natural hierarchy among classes, making it easier to model real-world relationships. For instance, Dog is a specialized form of Animal.
  • Extensibility: You can extend base classes by adding new functionality in derived classes, allowing for the flexible evolution of your software. New classes can be created by extending existing ones, leveraging the existing functionality and adding new features as needed.

4. Polymorphism:

Polymorphism is a core concept in Object-Oriented Programming (OOP) that allows objects to be treated as instances of their base class rather than their actual derived class. It enables a single interface to be used for different underlying data types, allowing for flexible and reusable code.

How Polymorphism Works:

  1. Base Class Pointer or Reference:
    • You use a base class pointer or reference to refer to objects of derived classes. This allows the program to use a single interface to handle different types of objects.
  2. Virtual Functions:
    • To enable polymorphism, you need to declare the base class method as virtual. This tells the compiler to use the derived class’s implementation of the method when it is called through a base class pointer or reference.
  3. Method Overriding:
    • Derived classes override the base class’s virtual method to provide specific implementations. The override keyword in C++11 and later is used to indicate that a method is intended to override a virtual method in the base class.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
using namespace std;

// Base class
class Base {
public:
    // Virtual function
    virtual void show() {
        cout << "Base class" << endl;
    }
};

// Derived class
class Derived : public Base {
public:
    // Override virtual function
    void show() override {
        cout << "Derived class" << endl;
    }
};

int main() {
    Base* bptr;           // Base class pointer
    Derived derived;      // Derived class object
    bptr = &derived;      // Base class pointer points to Derived class object
    bptr->show();         // Calls Derived's show() method

    return 0;
}

Explanation of the Example Code:

  1. Base Class Base:
    • The Base class has a virtual method show(). The virtual keyword indicates that this method can be overridden in derived classes.
  2. Derived Class Derived:
    • The Derived class inherits from Base and overrides the show() method. The override keyword is used to specify that this method is intended to override the Base class’s virtual method.
  3. Polymorphism in main():
    • A pointer to the Base class, bptr, is created.
    • An object of the Derived class, derived, is instantiated.
    • The bptr pointer is assigned the address of the derived object. This means bptr now points to a Derived object but is treated as a Base pointer.
    • When bptr->show() is called, the show() method of the Derived class is invoked, not the one in the Base class. This is because the show() method is virtual, allowing the program to call the most derived version of the method.

Benefits of Polymorphism:

  • Flexibility: Polymorphism provides flexibility by allowing the same function call to behave differently based on the object type. This is useful in scenarios where you need to work with different derived classes using a common interface.

  • Code Reusability: You can write more generic and reusable code. For example, you can use a base class pointer to handle different types of derived class objects in a uniform way.

  • Maintainability: Polymorphism allows for easier maintenance and extensibility of code. If new derived classes are added, they can be integrated into existing code without changing the base class or the code that uses base class pointers or references.

Binding Techniques: Early Binding vs. Late Binding

Early Binding (Static Binding)

Definition:

  • Early binding, also known as static binding, occurs when the method or function call is resolved at compile time. The compiler determines the exact method to be invoked based on the type of the reference or pointer used to make the call.

Characteristics:

  • Compile-Time Resolution: The method or function to be called is determined during compilation.
  • Efficiency: Early binding is generally more efficient because it involves direct method calls and does not require runtime checks.
  • Function Overloading and Operator Overloading: Both function overloading (same function name but different parameters) and operator overloading (customizing operator behavior) are examples of early binding.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class Example {
public:
    void display(int i) {  // Function overload
        cout << "Integer: " << i << endl;
    }
    
    void display(double d) {  // Function overload
        cout << "Double: " << d << endl;
    }
};

int main() {
    Example ex;
    ex.display(10);    // Calls display(int)
    ex.display(10.5);  // Calls display(double)

    return 0;
}

In this example, which display() method is called is determined at compile time based on the type of argument passed.

Late Binding (Dynamic Binding)

Definition:

  • Late binding, also known as dynamic binding, occurs when the method or function call is resolved at runtime. The exact method to be invoked is determined based on the actual object type pointed to or referenced, not the type of the reference or pointer.

Characteristics:

  • Run-Time Resolution: The method or function to be called is determined during runtime.
  • Flexibility: Late binding allows for more flexible and dynamic behavior, such as calling overridden methods in derived classes.
  • Virtual Functions: To achieve late binding, methods must be declared as virtual in the base class.

Example Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

class Base {
public:
    virtual void show() {  // Virtual function
        cout << "Base class" << endl;
    }
};

class Derived : public Base {
public:
    void show() override {  // Override virtual function
        cout << "Derived class" << endl;
    }
};

int main() {
    Base* bptr;
    Derived derived;
    bptr = &derived;  // Base class pointer to Derived class object
    bptr->show();     // Calls Derived's show() method

    return 0;
}

In this example, the method show() is called on a Base class pointer (bptr) that actually points to a Derived class object. The Derived class’s show() method is called, even though the pointer is of type Base*. This decision is made at runtime, showcasing late binding.

This post is licensed under CC BY 4.0 by the author.