avatarArjun Singh

Summary

This context discusses the application of SOLID principles in C/C++ programming to create robust and maintainable code.

Abstract

The SOLID principles are a set of five design principles that help in writing clean and maintainable code. These principles include Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). The context provides examples and explanations of how to apply these principles in C/C++ programming, along with bad and good code examples for each principle. The article concludes by emphasizing the importance of SOLID principles in creating high-quality software.

Bullet points

  • SOLID principles are a set of five design principles that help in writing clean and maintainable code.
  • The Single Responsibility Principle (SRP) states that a class should have only one reason to change.
  • The Open/Closed Principle (OCP) encourages software entities to be open for extension but closed for modification.
  • The Liskov Substitution Principle (LSP) states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program.
  • The Interface Segregation Principle (ISP) suggests that clients should not be forced to depend on interfaces they do not use.
  • The Dependency Inversion Principle (DIP) advocates that high-level modules should not depend on low-level modules but both should depend on abstractions.
  • The context provides examples and explanations of how to apply these principles in C/C++ programming.
  • The article concludes by emphasizing the importance of SOLID principles in creating high-quality software.

Understanding SOLID Principles in C/C++: Building Robust and Maintainable Code

SOLID Principles

Introduction

In the world of software development, writing clean and maintainable code is essential for the long-term success of a project. The SOLID principles are a set of five design principles that help achieve this goal. Originally introduced by Robert C. Martin, these principles provide guidelines for writing software that is easy to understand, modify, and extend. In this Medium post, we’ll dive into each of the SOLID principles and explore how they can be applied in the context of C programming.

I have also provided bad and good C++ code example to explain SOLID principles with much more ease. Also, you can find all the examples with explanation on my github: ArjunSingh13/SOLID-Principes-CPP: SOLID Principles explained using CPP language (github.com)

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one job or responsibility. This principle encourages us to keep our classes focused and ensures that they are easier to understand and maintain.

In C, achieving SRP can be challenging due to the absence of classes and objects. However, you can still follow this principle by organizing your code into functions and modules that each perform a single, well-defined task. Avoid creating functions or modules that try to do too much. Instead, break down complex tasks into smaller, reusable functions.

Example:

/* 
 BAD Design

square and squareUI are the example of cohesion.
* If we add square and squareUI methods in same class then cohesion will be 
less but now classes are divided into two where square class' responsibility 
is to manage measurements of square and squareUI is for rendering images of
square.
*/
class square
{
 int side = 10;

public:

 square(int side): side{side}
 { }

 int calcArea(void) {
  return side * side;
 }

 int calcPerim(void)
 {
  return 4 * side;
 }
};

class squareUI {
 
 bool isHighResolution;
public:

 squareUI(bool isHighResolution) : isHighResolution{ isHighResolution }
 {}

 void draw(void) {
  if(isHighResolution){}
  else{}
 }

 void rotate(int degree) {
  // rotate picture to provided degree

 }
};

/* 
* GOOD Design
* 
* student and studentRepository class' are example of loose coupling. student class is not dependent on database and only
* on incoming get input so its loose coupling.
* 
* Only reason for change will be setting student information. So nothing 
* related to database
*/
class student
{
 int studentId;
 std::string address;
 std::string name;

public:
 int getStudentId(void)
 {
  return studentId;
 }

 void setStudentId(int studentId) {
  this->studentId = studentId;
 }
 // more getters and setters will go here

};

/*
 only reason to change here is to store data in a database so only 
 database can change here.
*/
class studentRepository
{
 student studentId;
public:
 void save(student studentObj)
 {
  // here we pass that same studentObj that was created for 
  // getters and setters.

  // depending on what kind of storage we use we can implement
  // here to store the student object  
 }
};

2. Open/Closed Principle (OCP)

The Open/Closed Principle encourages software entities (such as classes, modules, or functions) to be open for extension but closed for modification. In C/C++, this principle can be applied by using function pointers and interfaces to allow for easy extension without altering existing code.

Example:

/*
* BAD Design
 below three classes is bad example as it does not follow open close concept. we have two classes one for each
 health and vehicle customers and now when we want to calculate discount we had to add function overloading.
 but what if now company wants to sell home insurance as well then we will have again modify class
 insuranceDiscountCalculator.
*/
class healthInsuranceCustomerProfile
{
 bool bLoyalCustomer;
public:

 healthInsuranceCustomerProfile(bool flag) : bLoyalCustomer{ flag }
 {}

 bool isLoyalCustomer(void)
 {
  if (bLoyalCustomer) return true;
  else return false;
 }
};

class vehicleInsuranceCustomerProfile
{
 bool bLoyalCustomer;
public:

 vehicleInsuranceCustomerProfile(bool flag) : bLoyalCustomer{ flag }
 {}

 bool isLoyalCustomer(void)
 {
  if (bLoyalCustomer) return true;
  else return false;
 }
};
class insuranceDiscountCalculator
{
public:
 int calculateDiscountPercent(healthInsuranceCustomerProfile customerObj)
 {
  if (customerObj.isLoyalCustomer()) return 20;
  else return 0;
 }
 int calculateDiscountPercent(vehicleInsuranceCustomerProfile customerObj)
 {
  if (customerObj.isLoyalCustomer()) return 20;
  else return 0;
 }
};

/* 
 GOOD Design

Good Example that follows open close concept, here first we will take benefit 
of inheritance, abstract class and polymorphism.

here class iDiscountCalculator's method calculateDiscountPercent is having customerProfile 
which is base class as param so any inherited class including vehicle , house, health 
insurance can be called.
*/
class customerProfile
{
public:
 virtual bool isLoyalCustomer(void) = 0;
};

// vehicle insurance
class vInsuranceCustomerProfile : public customerProfile
{
 bool bLoyalCustomer;
public:

 vInsuranceCustomerProfile(bool flag) : bLoyalCustomer{ flag }
 {}

 virtual bool isLoyalCustomer(void) override
 {
  if (bLoyalCustomer) return true;
  else return false;
 }
};

// house insurance
class hInsuranceCustomerProfile : public customerProfile
{
 bool bLoyalCustomer;
public:

 hInsuranceCustomerProfile(bool flag) : bLoyalCustomer{ flag }
 {}

 virtual bool isLoyalCustomer(void) override
 {
  if (bLoyalCustomer) return true;
  else return false;
 }
};

// Insurance discount calculator
class iDiscountCalculator
{
public:
 int calculateDiscountPercent(customerProfile customerObj)
 {
  if (customerObj.isLoyalCustomer()) return 20;
  else return 0;
 }
};

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. In C/C++, this principle can be applied by adhering to well-defined function signatures and contracts.

Example:

/*
* BAD Design
 Imagine we have car class and then we make subclass racing car. Now there can be issue in their methods.
 lets say base class car has a function name getInteriorWidth but now racing car has different kind of interior.
 So it's method can be getCockpitWidth instead. now there can be issues
*/

#include <vector>
#include <iostream>

class car
{
public:
 virtual int getInteriorWidth(void) {};
};

class racingCar : public car
{
 //getInteriorWidth stays unimplemented but implements below function instead
 int getCockpitWidth() {}

};

void testCarClass(void)
{
 car* first = new car();
 car* second = new car();
 car* third = new racingCar(); // this should work according to Liskov substitution principle.

 std::vector<car> vecCar;

 vecCar.push_back(*first);
 vecCar.push_back(*second);
 vecCar.push_back(*third);

 // now here trying to print getInteriorWidth will cause error as racing car is missing 
 // definition of getInteriorWidth
}

/*
* GOOD Design
* Method 1  Breaking with heirarchy
* 
 Solution to above problem is implementing first
 class as more generic which will be vehicle class with more generic method.

 Now using testCarClass with below classes will work just fine.
*/
class vehicle
{
public:
 virtual int getInteriorWidth(void) {}
};

class car: public vehicle
{
private:
 int interiorWidth;
public:
 virtual int getInteriorWidth(void) {
  return this->cockpitWidth();
 }

 int cockpitWidth(void)
 {
  return interiorWidth;
 }
};

class racingCar : public car
{
private:
 int cpWidth;
public:
 virtual int getInteriorWidth(void) {
  return this->cockpitWidth();
 }

 int cockpitWidth(void)
 {
  return cpWidth;
 }
};

/* 
 Second BAD Code
 Here we have example of discount on amazon products. first there are products and then in house products which has
 extra discount.
*/ 

class product
{
protected:
 double discount;
public:
 int getDiscount() {}
};

class inHouseProduct : public product
{
private:
 double extraDiscount;
public:
 // here getDiscount method is not override

 float addExtraDiscount() {
  discount = discount * 1.5;
 }
};

void testProductClass(void)
{
 product* p1 = new product();
 product* p2 = new product();
 product* p3 = new inHouseProduct();

 std::vector<product> vecProduct;

 vecProduct.push_back(*p1);
 vecProduct.push_back(*p2);
 vecProduct.push_back(*p3);

 // here printing get discount should work fine as inHouseProduct has getDiscount implemented
 for (auto v : vecProduct)
 {
  // code to compare if v is equal to p3 then 
  // call applyExtraDiscount function
  // else call getDiscount function

  /*
   above if else condition is against LiskoveSubstitutionPrinciple as we are asking here.
   Need to follow tell, dont ask rule here which is getting rid of this if else.
  */
}

/*
* GOOD Design
 Soultion to second problem
*/
class product
{
protected:
 double discount;
public:
 virtual int getDiscount() {}
};

class inHouseProduct : public product
{
private:
 double extraDiscount;
public:
 virtual int getDiscount() override {
  this->addExtraDiscount();
  return discount;
 }

 float addExtraDiscount(){
  discount = discount * 1.5;
 }
};

void testProductClass(void)
{
 product* p1 = new product();
 product* p2 = new product();
 product* p3 = new inHouseProduct();

 std::vector<product> vecProduct;

 vecProduct.push_back(*p1);
 vecProduct.push_back(*p2);
 vecProduct.push_back(*p3);

 // here printing get discount should work fine as inHouseProduct has getDiscount implemented
}

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they do not use. In C, this principle can be applied by creating smaller, specialized interfaces instead of large, monolithic ones.

Example:

/*
BAD Implementation

There is a main class for all kinds of printers and then 3 subclasses for 3 different kind of printers. 
Issue is not all printers supports all features so some classes may have empty methods which can give
false idea of supporting the feature to user that can result in code failure.
*/
class Printer
{
public:

 virtual void print() = 0 {};
 virtual void getPrinterInfo() = 0 {};
 virtual void scan() = 0 {};
 virtual void scanPhoto() = 0 {};
 virtual void fax() = 0 {};
 virtual void internetFax() = 0 {};
};

class XeroxWorkCentre : public Printer
{
public:

 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
 virtual void scan() { std::cout << "Feature supported" << std::endl; };
 virtual void scanPhoto() { std::cout << "Feature supported" << std::endl; };
 virtual void fax() { std::cout << "Feature supported" << std::endl; };
 virtual void internetFax() { std::cout << "Feature supported" << std::endl; };
};

class hpPrinterScanner : public Printer
{
public:
 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
 virtual void scan() { std::cout << "Feature supported" << std::endl; };
 virtual void scanPhoto() { std::cout << "Feature supported" << std::endl; };
 virtual void fax() { std::cout << "Feature NOT supported" << std::endl; };
 virtual void internetFax() { std::cout << "Feature NOT supported" << std::endl; };
};

class cannonPrinter : public Printer
{
public:
 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
 virtual void scan() { std::cout << "Feature NOT supported" << std::endl; };
 virtual void scanPhoto() { std::cout << "Feature NOT supported" << std::endl; };
 virtual void fax() { std::cout << "Feature NOT supported" << std::endl; };
 virtual void internetFax() { std::cout << "Feature NOT supported" << std::endl; };
};

/*
 Good Implementation

 Now to not give use surprises of feature not supported for some of the features, we need to 
 "segregate" the features into multiple classes for the base class. so printer only inherit 
 features that it can support
*/
class iPrint
{
 virtual void print() = 0 {};
 virtual void getPrintInfo() = 0 {};
};

class iScan
{
 virtual void scan() = 0 {};
 virtual void scanPhoto() = 0 {};
};

class iFax
{
 virtual void fax() = 0 {};
 virtual void internetFax() = 0 {};
};

class XeroxWorkCentre : public iPrint, public iScan, public iFax
{
public:

 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
 virtual void scan() { std::cout << "Feature supported" << std::endl; };
 virtual void scanPhoto() { std::cout << "Feature supported" << std::endl; };
 virtual void fax() { std::cout << "Feature supported" << std::endl; };
 virtual void internetFax() { std::cout << "Feature supported" << std::endl; };
};

class hpPrinterScanner : public iPrint, public iScan
{
public:
 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
 virtual void scan() { std::cout << "Feature supported" << std::endl; };
 virtual void scanPhoto() { std::cout << "Feature supported" << std::endl; };
};

class cannonPrinter : public iPrint
{
public:
 virtual void print() { std::cout << "Feature supported" << std::endl; };
 virtual void getPrinterInfo() { std::cout << "Feature supported" << std::endl; };
};

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle advocates that high-level modules should not depend on low-level modules but both should depend on abstractions. In C/C++, this can be achieved through the use of function pointers and interfaces, allowing for dependency injection and decoupling.

Example:

/*
 BAD Design

 Issue with below design is higher layer lets say store is dependent on lower layer Stripe. and according
 to principle both should depend on abstraction. Lets say if we want to change our payment gateway from 
 stripe to google pay that will result in change in store class itself which is not what we want.
*/
class googlePay
{
public:
 void makePayment(int amount) {
  std::cout << "payment under process with fee of 3%" << std::endl;
 }
};

class stripe
{
public:
 void makePayment(int amount) {
  std::cout << "payment under process with fee of 5%" << std::endl;
 }
};

class store
{
private:
 stripe stripeObj;
 int bikePrice = 100;
 int helmetPrice = 10;
public:

 store(stripe stripeObj) {
  this->stripeObj = stripeObj;
 }

 void purchaseBike(int qty)
 {
  stripeObj.makePayment(qty * bikePrice);
 }
};

/*
 Good Design:
 To fix above issue we need to make our higher layer which has class store and lower layer which is stripe
 or google pay both dependent on an abstraction. For good architecture we should never be dependent on 
 concrete classes when we are developing a project.

 As googlePay has cheaper fees than Stipe so in below example we dont need to make change in design of our 
 Store class and we can use any gateway as we like. 

 So in below example dependency inversion is followed as both classes are dependent on Abstract class and we
 call it dependency inversion because before

 Store Class ----> stripe gateway

 Now : 

 Store Class ----> Payment gateway <------ stripe gateway
*/

class paymentGateway
{
public:
 virtual void makePayment(int amount) = 0 {}
};

class googlePay : public paymentGateway
{
public:
 virtual void makePayment(int amount) override {
  std::cout << "payment under process with fee of 3%" << std::endl;
 }
};

class stripe : public paymentGateway
{
public:
 virtual void makePayment(int amount) override {
  std::cout << "payment under process with fee of 5%" << std::endl;
 }
};

class store
{
private:
 paymentGateway* paymentObj;
 int bikePrice = 100;
 int helmetPrice = 10;
public:


 store(paymentGateway* paymentObj) {
  this->paymentObj = paymentObj;
 }

 void purchaseBike(int qty)
 {
  paymentObj->makePayment(qty * bikePrice);
 }
};

/*
  this is a test function and for convenience its definition is left
  in header file
*/
void testPayment(void)
{
 paymentGateway* pObj = new googlePay();
 store sObj(pObj);

 // should result in calling googlePay gateway
 sObj.purchaseBike(2);

}

Conclusion

By understanding and applying the SOLID principles in your C code, you can write software that is more maintainable, flexible, and easier to extend. These principles encourage you to think about code organization, modularity, and separation of concerns, ultimately leading to higher-quality software. Whether you’re working on a small project or a large-scale application, keeping SOLID in mind can help you build robust and reliable C programs.

Solid Principles
Solid Principles In C
C Programming
Embedded Software
Modular Programming
Recommended from ReadMedium