Thomas Stainer
24/01/2020
Why bother? - Computers only care about bytes!
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!
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){
// ...
}
}
Think before you write
Full example code for each S-O-L-I-D and these slides available at https://github.com/thomasms/solid_talk