SOLID Design pt. 2
Inverting Dependencies and Segregating Interfaces
Welcome back to this quick demo of making breakfast with SOLID design principles. If you haven't read Part 1, check it out. It's pretty swell. Here's a quick recap:
Single Responsibility Principle: An object should only encapsulate one functionality. In this example, the Egg class' one functionality is being an egg. If I were to ask the Egg about itself, it won't tell me a list of egg recipes or over what temperature to cook it. Writing a function to do that would be simple, but that would betray the SRP.
Liskov Substitution Principle: An object should not alter the interface of any parent class from which it inherits. For variance in examples, let's consider a class MusicalInstrument.
This class would have a play function, def play
, as part of its public interface. For every type of musical instrument that inherits from this class, they must also have play
as part of their public interface. Obviously, a trombone and a drum set would have different implementation details in their respective play functions; the point of this principle is that any object expecting to receive a MusicalInstrument
to interact with shouldn't have to care about what type it receives.
All that recapping has got me hungry. Back to breakfast.
Dependency Inversion Principle: Higher level modules should dictate the implementation details of lower level modules and not the other way around. Modules should only communicate with the public interface of other modules.
To the first point, in Part 1, I mentioned that despite a Dish
and a Stove
having some similar functionality, a Stove
should not inherit from a Dish
because a dish is necessarily portable. For giggles, let's make the other decision and have Stove
inherit from Dish
. Because of physical restraints, Stove
simply does not have the portability
attribute and that violates Liskov to have a child that doesn't have its parents' attributes. To rectify that, I would have to remove portability
from Dish
. Having Stove
, the lower level module, dictate the details of Dish
, the higher level, is in violation of the Dependency Inversion Principle. Let's keep dishes and stoves as separate entities.
And the second point, here are the first draft Stove
and Pan
classes:
class Stove
def initialize
@contents = []
@on = false
end
def turn_on(heat_level)
@on = true
@contents.heat_up(heat_level)
end
def add_content(new_content)
@contents << new_content
end
end
#####
class Pan
def initialize
@contents = []
@heat_level = 0
end
def heat_up(heat_level)
@heat_level = heat_level
end
def add_content(new_content)
@contents << new_content
end
def remove_content(content)
@contents.delete_at(@contents.index(content) || @contents.length)
end
end
The DIP is demonstrated here in Stove.turn_on
. The Stove class doesn't need to know what objects are in it's contents. It could just as easily be a teapot or a hand; as long as it has a heat_up
method, it will be called. It's up to the objects receiving the heat_up
message to determine what to do with that.
The rest of the demonstration will require a new class, Person
. This Person
object we will use will have @alive = true
, @able_bodied = true
, and knowledge of pans, eggs, and stoves.
Moreover, this Person
introduces what I believe to be the key of Object Oriented Programming. It's the starter, the highest-level module that controls the action. A Stove
, Pan
, or Egg
would not have the ability to begin the breakfast making process, and including that ability within any of the classes would have been in violation of the Single Responsibility Principle (and common sense.)
This is the crucial concept for creating modular programs. Objects cannot instantiate themselves. Objects more than likely are inanimate and cannot alter themselves without outside causes. A controller class or run script must be used to begin any process.
Open/Close Principle and how this Person plays in:
I cheated a bit earlier by adding the add_content
methods without having a way to implement them. Person
will be the object sending the add_content
message to Stove
and Pan.
In addition, the Person
will need a way to see the contents of both, but as it stands, the two inanimate objects' public interfaces are not exposing their contents.
OCP states that object should be open for extension, but closed to modification. Because I know an able-bodied person should be able to access the contents of a pan and stove, I'm going to extend the current classes to include methods exposing their @contents
without otherwise changing the implementation of @contents
within the class in any way.
class Stove
.
.
.
def contents
@contents
end
end
#####
class Pan
.
.
.
def contents
@contents
end
end
#####
class Person
def initialize(opts = {})
# a lot of initialization
end
def activate(thing, level=nil)
thing.turn_on(level) if thing.respond_to?(:turn_on)
# other checks omitted
end
def add_content_to(container, content)
container.add_content(content)
end
def check_contents(thing)
thing.contents
end
end
Lastly, the Interface Segregation Principle: each user of a module should only have to interact with the portions of its interface that pertains to what the user needs. Let's talk about eggs.
Both the Pan
and the Person
will interact with the Egg
in different ways.
The Pan:
class Pan
.
.
.
def cook
@contents.cook(@heat_level)
end
end
The Person:
class Person
.
.
.
def open(thing)
thing.open
end
def consume(thing)
@energy += thing.energy
thing.dissolve
end
end
It's obvious that the Pan
does not need to know the caloric value of the Egg
whereas the Person
does not need to know the process of an Egg
receiving heat. The above examples represent Interface Segregation, but the concept also applies in how the objects interact. Person
should not interact with Egg
object through the Pan
object. The program is written in such a way the the Egg
can be put into the Pan
's @contents
and removed from the Pan
's @contents
without becoming pan.egg
, a property of the pan. This simplifies the Pan
interface.
Another aspect of Interface Segregation __ is not making users of an object specify options of the object that they do not care about. In this case, the Pan
does not need to know at all the type Egg
being put into its contents (or that it is an egg at all.) The Person
may not care about the type of Egg
, OR the Person
may care a whole lot about the type of Egg
. So let's build the Egg
so that both cases of Person
are satisfied.
class Egg
ENERGY_LEVELS = { small: 1, med: 2, large: 3, jumbo: 4 }
def initialize(color: 'white', size: :med)
@color = color
@edible = false
@energy = ENERGY_LEVELS[size]
end
def cook(heat_level)
heat_level.times { harden }
end
def open
crack_shell
@edible = true
end
def dissolve
@energy = 0
end
end
I've fleshed out the whole Egg
class, but the important part for Interface Segregation is the initialize
function. When a person first accesses their eggs, they will have the option to specify their favorite type of egg via the initialization options or simply not care at all and fallback to the default options of color: white, size: :med
.
Recap of how to make a SOLID breakfast:
Single Responsibility Principle: The classes we modeled after inanimate entities only expose their data purely. Egg
knows it can harden, but it does not know egg recipes. It can only be an egg.
Open-Closed Principle: The classes we modeled can be added to, but we must not alter their existing public interface. This is a code-specific detail. We added getters to the classes with @contents
when we added the Person
class as a user of those classes' instances. Importantly, this did not change how the previous users' interacted with the classes' instances.
Liskov Substitution Principle: A child class must not alter the existing interface of its parent class. A Person
may upgrade their Stove
to flat-top electric burners. This changes some inner, private methods of how the stove receive electricity and converts that to heat, but this does not change the public interface of the Person
sending the message stove.turn_on
and expecting the Stove
instance to begin sending heat_up
to its contents.
Interface Segregation Principle: A user of a module should only have to interact with the parts it needs. A Pan
knows an Egg
can receive heat. A Person
knows an Egg
can be consumed. Because the Person
can initialize what eggs are in its inventory, they have the option to customize the type of Egg
or not.
Dependency Inversion Principle: The higher level module determines the details of the lower level and not the other way around. This is another code-specific principle. For a concrete example, here is my thought process on coming up with Egg#open
:
It's a bit contrived to a) call it 'open' when I mean crack_shell and b) have that render the object edible. BUT I knew I wanted the Person
to have an open method for any food, e.g. bacon and bread. So I left the details to the food object to determine exactly what open
means to it. For bacon and bread, open
would have been to remove from its packaging render edible.
So, the higher level user, Person
, determined how Egg
was structured.
Happy Codings!
Written: November 28, 2016
Last updated: January 12, 2025