Object Oriented Programming (OOP) has been around for over four decades since Alan Kay and his colleagues introduced it to the world. Languages have flourished and died in it’s wake imparting sound practices that stood the test of time.
Robert C. Martin (Uncle Bob), in the early 2000s, compiled a set of principles to write maintainable software and detect code smells. These eventually came to be collectively known as the SOLID principles.
SOLID is a mnemonic acronym that stands for:
- Single Responsibility Principle
- Open Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Single Responsibility Principle (SRP)
A class must have only one responsibility. Often a good way to ensure this is to have a comment on the top that defines the intention of the class. If you think you need to use ‘and’ in the comment, there is a potential chance for a new class.
Consider the following system where you’re modelling a library.
class Book attr_accessor :title, :content, :shelf_position def move_to(new_position) shelf_position = new_position end end
Here apart from
Book being spatially aware, the behaviour of how it should move around also resides in
Book. We could have a situation later where a book could know how to
Book could turn into a monolith and unwieldy to manage.
In the above code sample,
Shelf would be a good candidate to store book locations and house the behaviour of moving books.
class Book attr_reader :title, :content end class Shelf attr_reader :books def move_book(new_position, book) books[new_position] = book end end
Open Closed Principle (OCP)
You must have heard that code should be open for extension but closed for modification. The gist of it is that rather than modifying existing objects, favour creating new objects. This leads to having a more stable application as your not changing existing contracts. If you spot decorators, presenters, policy objects and such, it’s the mark of OCP.
Let’s take our previous
Book example. We have requirement to print books in a specific format. Rather than having a humungous print function in Book, this could be delegated to a BookPrinter object, like so.
class Book attr_accessors :content, :title def print BookPrinter.new(self).print end end class BookPrinter def initialize(book) @book = book end def print # printing logic goes here end end
This keeps the API surface area for the book object clean as you don’t have a proliferation of print function and their helpers sitting in every
Book instance. In a lot of aways, it’s a throw back to SRP.
Liskov Substitution Principle (LSP)
Anywhere you see inheritance, the first question you should consider is if it’s possible to shift to a better composable approach. LSP is a good litmus test to see if your inheritance model is sound.
The general idea is to validate if the subtype (child class if it’s inheritance) can be substituted in place of the parent type. This is applicable even for duck-typing approaches.
I’m going to demonstrate this with the classic example.
class Rectangle attr_accessors :width, :height def area width * height end end class Square < Rectangle attr_accessors :side def height=(side) @height = side @width = side end def width=(side) @height = side @width = side end def area side * side end end shapes = [Rectangle.new, Square.new] shapes.each do |shape| shape.height = 4 shape.height = 5 puts shape.area end # 20 for rectangle - correct # 25 for square - wut
Clearly inheritance has forced us to mutate Square’s internal state (setters for height and width) which throws everything out of the window. Conceptually, Square may be a Rectangle but the code clearly defines two separate contracts. If you see
if checks, for a particular subtype if your method calls, that could potentially be a violation of LSP.
Interface Segregation Principle (ISP)
The idea is a class should mix in only required behaviour and nothing more. This sort of ties into languages with interface constructs, as you inherit the complete API when you implement the interface in your class. Let’ see how this can affect our
module Utils def title_cased(text) text.titlecase end def log(message, level) Logger.send(level, message); end end class Book include Utils attr_accessors :content, :title end
Book might need only title_cased behaviour but mixing in Utils brings in logger to. Better split into separate interfaces and require only what’s needed.
module Utils module TextFormatter def title_cased(text) text.titlecase end end module Logger def log(message, level) Logger.send(level, message); end end end
Dependency Inversion Principle (DI)
I’ll dive right into the code by taking our Book example. From OCP we’ve seen, the benefits of splitting out printer object.
class Book attr_accessors :content, :title def print BookPrinter.new(self).print end end
On the outset, this looks clean, but we can clearly see that
Book is strongly coupled with
Let’s fix that.
class Book attr_accessors :content, :title def print(printer = BookPrinter) printer.new(self).print end end
This introduces the flexibility of injecting other custom printer classes into
Book. Test driving your code forces you in a lot of ways to follow DI.
Following the guidelines outlined in these principles is a good way forward in writing maintainable code. This does not mean you should religiously stick to them. There might be situations where it’s warranted in breaking a few. You’re good as long as you don’t lose the big picture.
If you find any of the examples above or my understanding of something atrociously wrong, do drop a comment.