Feature image

Design for Change Link to this heading

Building software that does its designed task is often only half the battle. It is also the case that we need our software to adjust and be adaptable over time as requirements and needs change. One of the essential needs of good software is the ability to change it easily. Good software design is crucial to making adaptable software possible. Often with bad design patterns even the most simple change can turn into a complicated endevour as seemingy unrelated functionality breaks from your “minor change”.

Our major culprit to preventing flexible and robust software is dependencies. The coupling of different segments of code in order to form features and functionality. The interaction between code segments that we describe as dependency is a required part of writing software. But we have flexibility in how this is done, and choosing the right approach to managing dependencies goes a long way towards building better software systems.

SOLID Link to this heading

SOLID is a mnemonic acronym for five software design patterns intended to make your software more understandable, flexible, and maintainable. Most of what I will be detailing below comes from

C++ Software Design

Author: Klaus Iglberger

Book Image

I highly recommend the book, you can find it for purchase here

The acronym stands for

  1. Single-responsibility principle
  2. Open-close principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle

Single-responsibility principle Link to this heading

One of the best strategies for reducing the types of artificial dependencies and allowing for a simplification of code change is breaking down systems into small, understandable pieces. The first part of the SOLID principle directly ties in to the separation of concerns: Single-Responsibility Principle (SRP).

The SRP in its colloquial form says “Everything should just do one thing”. What this exactly means is certianly up for interpretation. But I found the example used on pg. 12 to be an extremely helpful concrete example. Consider this Document class

C++
1class Document {
2  public:
3    virtual ~Document() = default;
4    virtual void exportToJSON(/*...*/) const = 0;
5    virtual void serialize(ByteStream&, /*...*/) = 0;
6}

This purely abstract class looks eerily similar to classes I have personally written many times. On its surface there are a lot of things to like about it. This is a form of runtime polymorphism that allows our code to generically handle lots of different types without needing to know the nitty gritty details of how they implement their functionalities. Our document class appears to be well built to encapsulate and abstract away implementation details that other parts of code do not need to know.

But this class is a bad design because it contains multiple artificial dependencies. First, exportTOJSON() needs to be implemented by all derived classes (even if the derived class does not support exporting to JSON!). Often in C++, things like implementing JSON exports is left to a third party library. If that is the case, all derived classes will be seperately using this library. If the time comes where a change is made to which library is used, we now need to go into each derived class and make the change (each derived class is coupled to each other in that all of them need the same change based on a change to a library).

Another dependency is introduced in the serialize() function which will likely need to know which kind of document it needs to serialize its contents into. A common approach is to build a document type enum that each derived class uses to help understand its own state and how it should proceed with serialization.

C++
1enum class DocumentType {
2  pdf,
3  word,
4  // .... potentially many more types
5}

But now anytime a new type is added, all other derived types will be directly affected since each derived type would “know” about all other types. How do we fix this? Well by ensuring the document class only does one thing, which is to represent the most basic operations of a document.

C++
1class Document {
2  public:
3    virtual ~Document() = default;
4}

Obviously we have now lost two important features we need. We will later discuss in the Interface Segregation Principle section how to better support exporting to JSON and serialization for this document class.

Open-close principle Link to this heading

Another part of SOLID is the Open-Closed Principle (OCP). This principle states that classes should be open to extension, but closed to modification. Consider the case where you build a class to complete its single responsibility. You test the class and you ship it off to the client. But now you need to add new functionality by adding another interface. Updating the class itself means we need to re-test and ship this same class out again with the changes. Furthermore, whenever a base class needs to be changed, there can be numerous implications for the derived classes. Therefore we should instead require that classes be extended when change is required. But this extension should be easy and should not modify existing code.

Consider the example of a product class. We need to be able to filter by different criterion including color and size. A poor design pattern would be to build another class called ProductFilter with interfaces for filtering by these criteria. The reason being that over time what we need to filter for will likely change, meaning we will need to change the ProductFilter class. Instead check out the example below where we build a BetterFilter class that can be extended using different templates.

C++
 1
 2enum class Color {red, green, blue};
 3enum class Size { small, medium, large};
 4
 5class Product {
 6  public:
 7  string name;
 8  Color color;
 9  Size size;
10}
11
12template <typename T> 
13class Specification {
14  public:
15  virtual bool is_satisfied(T* item) = 0;
16}
17
18template <typename T>
19class Filter {
20  public:
21  virtual Vector<T*> filter(vector<T*> items, Specification<T>& spec) = 0;
22}
23
24class BetterFilter: Filter<Product> {
25  public:
26  Vector<Product *> filter(vector<Product*> items, Specification<Product>&spec) override {
27    vector<Product*> result;
28    for (auto& item: items) {
29      if (spec.is_satisfied(item)) {
30        result.push_back(item);
31      }
32    }
33    return result;
34  }
35}
36
37class SizeSpecification : Specification<Product> {
38  public:
39  Size size;
40  SizeSpecification(const Size size) : size{size} {}
41
42  bool is_satisfied(Product* item) override {
43    return item->size == size;
44  }
45}
46
47class ColorSpecification: Specification<Product> {
48  public:
49  Color color;
50  ColorSpecification(Color color) : color(color) {}
51
52  bool is_satisfied(Product *item) override {
53    return item->color == color;
54  }
55}
56
57int main() {
58
59  Product apple{"Apple", color::green, Size::small};
60  Product tree{"Tree", Color::green, Size::large};
61  Product house{"House", Color::blue, Size::large};
62
63  BetterFilter bf;
64  ColorSpecification green(Color::green);
65  for (auto& item : bf.filter(items, green)) {
66    std::cout<< item->name << " is green";
67  }
68
69  SizeSpecification large(Size::large);
70  for (auto& item : bf.filter(items, large)) {
71    std::cout<< item->name << " is large";
72  }
73}

When a new filter type is needed, we can build it out without ever needing to change the BetterFilter class.

Liskov Substitution Principle Link to this heading

The Liskob Substitution principle is concerned with behavioral subtyping i.e. with the expected behavior of an abstraction. When subtyping a base class into its derived classes it is important that the expectations of the abstraction be adhered to. Consider the classic example of a Rectangle base class and a derived Square class

C++
 1class Rectangle {
 2  public:
 3  virtual ~Rectangle() = default;
 4
 5  int getWidth() const;
 6  int getHeight() const;
 7
 8  virtual void setWidth(int);
 9  virtual void setHeight(int);
10
11  virtual int getArea() const;
12
13  private:
14  int width;
15  int height;
16}
17
18class Square: public Rectangle {
19  public:
20  void setWidth(int) override;
21  void setHeight(int) override;
22
23  int getArea() const override;
24}

From a mathematical standpoint, having a square inherit from a rectangle makes sense. But consider this problem. When computing the getArea() function, we expect the width to be multiplied by the height. If we set the height to 7 and the width to 4, we expect getArea() = 24. But this will not be the case for the Square class which must have an equal width and height. For the square class, setting the height and width separately makes no sense. When dealing with abstractions of this type, we may well have a function like this:

C++
1void transform(Rectangle &rectanle) {
2  rectangle.setWidth(7);
3  rectangle.setHeight(4);
4}

Our expectaion is this rectangle has a height of 4 and a width of 7. But if the actual derived class is a square, this is not the case and unexpected results may occur.

It is important that our derived classes can always be subsituted for a based class without any issues or violation of our expectations.

Interface Segregation Principle Link to this heading

The I in SOLID stands for Interface Segregation Principle (ISP). Simply put, it means clients should not be forced to depend on methods that they do not use. In our document example used above, I mentioned that some derived classes may be force to implement methods they do not need or support. The ISP recommends decoupling interfaces by instead seperating the interfaces like so

C++
 1class JSONExportable {
 2  public:
 3    virtual ~JSONExportable = default;
 4    virtual void exportToJSON(/*...*/) const = 0;
 5};
 6class Serializable {
 7  public:
 8    virtual ~Serializable() = default;
 9    virtual void serialize(ByteStream& bs, /*...*/) const = 0;
10};
11class Document: public JSONExportable, public Serializable {
12  public:
13    virtual ~Document() = default;
14}

When structured this way, we can now minimize dependencies to only the set of functions that is actually required

C++
1void exportDocument(JSONExportable const& exportable) {
2  exportable.exportToJSON(/*pass arguments*/);
3}

Now the JSONExportable functionality no longer depends on the serialization functionality or the ByteStream class.

Dependency Inversion Principle Link to this heading

The Dependency Inversion Principle simples states that for the sake of dependencies, you should depend on abstractions instead of concrete types or implementation details. This principle can be broken down into two rules:

  1. High-level modules (code that is stable, with low dependency), should not depend on low-level modules (malleable, volitile, high dependency). Both should depend on abstractions.
  2. Abstractions should not depend on details. Instead details should depend on abstractions.

Consider this example that has a high level and low level module.

C++
 1enum class Relationship {
 2  parent, 
 3  child, 
 4  sibling
 5};
 6
 7class Person {
 8  public:
 9  string name;
10};
11
12// low-level
13class Relationships {
14  vector<tuple<Person, Relationship, Person>> relations;
15
16  void add_parent_and_child({const Person& parent, const Person& child}) {
17    relations.push_back({parent, Relationship::parent, child});
18    relations.push_back({child, Relationship::child, parent});
19  }
20};
21
22// High-level
23class Research {
24  public:
25  Research(Relationships& relationships) {
26    auto& relations = relationships.relations;
27    for (auto&& [first, rel, second] : relations) {
28      if (first.name == "John" && rel == Relationship::parent) {
29        std::cout<< "John has a child called " << second.name << std::endl;
30      }
31    }
32  }
33};
34
35int main() {
36  Person parent{"John"};
37  Person child1{"Chris"}, child2{"Matt"};
38
39  Relationships relationships;
40  relationships.add_parent_and_child(parent, child1);
41  relationships.add_parent_and_child(parent, child2);
42
43  Research _(relationships);
44  return 0;
45}

Here we have a high level module recieving a low level module as a dependency. The research class is aware of the Relationships class and implements its part using the vector within the Relationships class. But since Relationships is low-level, it is subject to change. Perhaps it stops using a vector or moves that vector to be private. Both changes will brake our high level code.

To fix this, we need to introduce another abstraction. In this example I create the RelationshipBrowser class (a pure virtual class) which will act as our abstraction that mediates and removes the dependency of our high level code on our low level implementation details.

C++
 1class RelationshipBrowser {
 2  public:
 3  virtual vector<Person> final_all_children_od(const string& name) = 0;
 4}
 5
 6enum class Relationship {
 7  parent, 
 8  child, 
 9  sibling
10};
11
12class Person {
13  public:
14  string name;
15};
16
17// low-level
18class Relationships: RelationshipBrowser {
19  public:
20  vector<tuple<Person, Relationship, Person>> relations;
21
22  void add_parent_and_child({const Person& parent, const Person& child}) {
23    relations.push_back({parent, Relationship::parent, child});
24    relations.push_back({child, Relationship::child, parent});
25  }
26
27  vector<Person> find_all_children_of(const string &name) override {
28    vector<Person> results;
29    for (auto&& [first, rel, second] : relations) {
30      if (first.name == name && rel == Relationship::parent) {
31        result.push_back(second);
32      }
33    }
34    retutn results;
35  }
36};
37
38// High-level
39class Research {
40  public:
41  Research(RelationshipBrowser& browser) {
42    for (auto& child : browser.find_all_children_of("John")) {
43      std::cout<< "John has a child called " << child.name << endl;
44    }
45  }
46};
47
48int main() {
49  Person parent{"John"};
50  Person child1{"Chris"}, child2{"Matt"};
51
52  Relationships relationships;
53  relationships.add_parent_and_child(parent, child1);
54  relationships.add_parent_and_child(parent, child2);
55
56  Research _(relationships);
57  return 0;
58}

Now Research no longer has any knowledge of the Relationship class and will continue to work even if changes are made to the Relationship class.

Conclusion Link to this heading

Like most design patterns, SOLID is a tool to write better code. An engineers goal should never be to write code that is SOLID, but to use SOLID to help write code. Engineers must be flexible to all the factors that make up the software they are working on. Sometimes, using SOLID in your source code is a bad idea. Software is a means to an end, not the end itself. The same applies to SOLID.