How to be a SOLID programmer

Thomas Stainer

24/01/2020

SOLID principles

  • Term originating from 2000's
  • Some ideas date back to 80's
  • Typically applied to OO programming
  • Very common in Java and C#
  • Can be applied to other languages & methodologies
  • Not really principles - more guidelines & philosophy

Why bother? - Computers only care about bytes!

  • Humans develop code, not machines (yet)
  • Humans work with other humans to develop code
  • Humans need to understand other humans' code
  • Humans writing code cost money

Changes cause other changes

Cascade of changes follow from one small change

You didn't plan for your application to change like this


Implementing one small change can break everything and be very costly!

  • S  - Single responsibility
  • O - Open-Closed
  • L  - Liskov substitution
  • I   - Interface segregation
  • D - Dependency inversion

Learn by example...

I will use C++ examples, but it can be applied to other languages

I will use I to denote an interface (pure abstract class) for names

All examples in depth (along with these slides) are available at my GitHub

A class should do one thing

It should have only one reason to change

Don't do this

struct MyBadClass{
    double calculateTheta(){
        // complex calculation
        return theta;
    }   

    void toFile(std::string filename) const{
        // formatting here
    }

    void plot() const{
        // plotting
    }   
    // ...
};

Why is this bad?

Before, we had an object responsible for logic, formatting, serialization and visualisation

If we wanted to change the file format, we must change the class, even though the logic remains the same

Forces others to adopt changes, can cause merge conflicts, dependency issues, ...

How do you test MyBadClass?

This is better

struct MyBetterClass{
    double calculateTheta(){
        // complex calculation
        return theta;
    }   
    // other theta related stuff
};
// read and write operators
istream& operator>>(istream& is, MyBetterClass& myclass);
ostream& operator<<(ostream& os, const MyBetterClass& myclass);
// create a separate plotter object
struct MyBetterClassPlotter{
    void operator()(const MyBetterClass& myclass) const{
        // do plot
    }
};

A class should be open for extension, but closed for modification

Abstraction is key!

Don't do this

void process(const std::vector< OptionEnum >& options, 
             const MyPlasmaObject& obj){
    for(auto option: options){
        switch(option){
            case DO_PLASMA_INIT:
                obj.plasma_init();
                // ...
            case DO_PLASMA_COMPUTE:
                // ...
            case DO_PLASMA_READ_FROM_FILE:
                // ...
            case DO_PLASMA_WRITE_TO_FILE:
                //.. 
            case DO_PLASMA_END:
                // ..
        }
    }
}

Why is this bad?

If we want to add another process, we needed to add another enum and switch statement to the process function

Before we know it we have 50+ switch statements, with 50+ enums!

Again, how do you test it?

Here process is not closed for modification and not open for extension

This is better

struct INonConstProcess{ // abstract base class
    virtual void go() = 0;
};

struct PlasmaComputeProcess : public INonConstProcess{
    PlasmaComputeProcess(MyPlasmaObject& obj) : _obj(obj) {}
    virtual void go() { 
        // logic here 
    }
};
struct ProcessManager{
    void add(const std::shared_ptr< INonConstProcess >& process){
        _processes.push_back(process);
    }
    void run() const{
        for(auto& process: _processes)
            process->go();
    }
    private:
        std::vector< std::shared_ptr< INonConstProcess > > _processes;
};

Why is this better?

We've now made a ProcessManager to handle looping over all processes

Code is more modular and easier to understand

Now ProcessManager is closed for modification and is open for extension - just add a new process via add

No switch statement or any enums, just types

Very easy to mock to test logic

Mocking is now easy

struct MockPlasmaComputeProcess : public INonConstProcess{
    virtual void go() { 
        std::cout << "Hello from mock!"; 
        // or asserts for testing library
    }
};

ProcessManager manager;
manager.add(std::make_shared< MockPlasmaComputeProcess >());
manager.run();

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness (no side effects) of the program

is-a or has-a relationship

Don't do this

struct Plot{
    virtual void setXLabel(std::string label){
        _xlabel = label;
    }    
    virtual void setYLabel(std::string label){
        _ylabel = label;
    }
    // getters + other stuff + member vars
}
struct NormalisedHistogramPlot : public Plot{
    NormalisedHistogramPlot() : _ylabel("counts") {}

    virtual void setYLabel(std::string label) override{
        // problem: Throws exception 
        // since normalised histogram y label (in this case)
        // has a fixed ylabel 
        throw std::runtime_error(
            "NormalisedHistogramPlot::setYLabel - Should not be called!");
    }
}

At call site

// a silly test for demonstration purposes only
void testPlot(std::shared_ptr< Plot >& plot, 
              std::string property)
{
    plot->setXLabel("Energy (eV)");
    // this will throw an exception for NormalisedHistogramPlot
    plot->setYLabel(property);

    assert(plot->getXLabel() == "Energy (eV)");
    assert(plot->getYLabel() == property);
}        
                            

Why is this bad?

Fundamental architecture problem

NormalisedHistogramPlot is not a Plot

Not a is-a relationship

It is a has-a relationship - use composition

Better to rethink your object relationships and architecture

No client should be forced to depend on methods it does not use or need

Favour smaller, concise interfaces over larger interfaces

Don't do this

// very abstract interface
struct IEverything{
    virtual void validate() const = 0;
    virtual void reset() = 0;

    virtual int operator()() const = 0;
    virtual void operator()(int value) = 0;
    virtual operator bool() const = 0;

    // many others
};
                            

Why is this bad?

We should look at the single responsibility principle again

And the Liskov substitution principle

What if we want to build something that can be reset but not validated?

Again, testing is difficult - we must implement every method

This is better

// beware the 'ible's
struct IResetible{
    virtual void reset() = 0;
};

struct IConstValidatible{
    virtual void validate() const = 0;
};

struct ISimpleAccess{
    virtual int operator()() const = 0;
    virtual void operator()(int value) = 0;
    virtual operator bool() const = 0;
};
                                
// multiple inheritance for interfaces
struct NoCheatGuesser : public IConstValidatible,
                        public ISimpleAccess // no IResetible to avoid resetting guess count
{
    //...
}
                            

*Inversion, not injection.

Depend on abstractions (interfaces) not concretions (implementation)

High level modules should not depend on low level modules

This does not just mean abstracting our implementation (creating an interface)

The interface 'owns' the implementation, not the other way around

If A depends on B, we don't want the changes in B to affect A

A should dictate the interface for B to conform to

Define in terms of A's needs, not B's behaviour

Don't do this

// our class depends on the implementation of VerySimpleLogger
struct MyCoolClass{

    void method1(){
        _logger.addMessage("Called method1!");
        //other stuff
    }
    // more stuff ...

  private:
    // to change this logger to another i.e. BetterLogger
    // from external library would cause
    // us to change the implementation in this class
    VerySimpleLogger _logger;
};                       

Why is this bad?

We are strongly coupled to implementation of VerySimpleLogger

We therefore break Open/Closed principle

What if we want to change our logging library to something better?

Did I mention testing?

This is better

// remember the I - only provide an interface for what you need
// we dictate the interface we need,
// we invert the dependency
// by depending on the interface,
// not the specific implementation of a logger
struct ISimpleLogger{
    virtual void append(std::string message) = 0;
    virtual size_t size() const = 0;
    virtual std::string get(int index) const = 0;
    virtual operator bool() const = 0;
};

This is better

// Wraps the simple logger
class SimpleLoggerAdapter : public ISimpleLogger{
    VerySimpleLogger _logger;
    // ...
};
// wraps the another logger 
struct AnotherLoggerAdapter : public ISimpleLogger{
    // ...
};
// our class depends on the interface we dictated
struct MyCoolClass{
    // create the class using construction injection
    MyCoolClass(ISimpleLogger& logger) : _logger(logger) {}
    // ...
  private:
    // this is now an interface
    // set by our code not the external library
    ISimpleLogger& _logger;
};

Now at the call site it is clear

{
    // we can easily replace the logger now 
    SimpleLoggerAdapter logger;
    // AnotherLoggerAdapter logger;
    // MockNoErrorLogger logger;

    // class that depends on logger
    MyCoolClass cc(logger);

    // has error?
    if(logger){
        // ...
    }
}

Summary

Think before you write

  • SOLID is a guideline for writing good, maintainable code
  • SOLID can be applied to OO - Java, C#. Even C++, Fortran and Python
  • SOLID code can make development easier in the long run!

Full example code for each S-O-L-I-D and these slides available at https://github.com/thomasms/solid_talk