SOLID: Interface Segregation Principle (ISP)
The Interface Segregation Principle is about designing thin, cruft-free interfaces which results in reduced coupling.
Introduction
The Interface Segregation Principle is one that Bathrobe Bob designed himself. On the surface it looks like it only applies to static languages, which it shares somewhat with the previous entry in SOLID: The Liskov Substitution Principle. Let's see how we can apply it to duck-typed languages.
Definition
From Bob's own paper, "The Interface Segregation Principle" (1996):
Clients should not be forced to depend upon interfaces that they do not use.
I take interfaces to mean explicit or implicit sets of public-facing methods.
A detour into statically-typed languages
The Interface Segregation Principle is all about interfaces, so it's helpful to know how they work in static languages. If you already do, you can skip this section.
We discussed typing as part of the Liskov Substitution Principle. The important takeaway was that we don't have any sort of strict "type" or "interface" in duck-typed languages like Ruby, instead requiring the programmer to conform to implicit — not explicit — contracts about what objects do.
Interfaces, as found in languages such as C#, are programming constructs which represent explicit contracts.
public interface IBird
{
void Fly();
}
Interface for a bird in C#
The above interface is for a bird. That is to say that all classes implementing the IBird
interface must respond to the method Fly()
. Here is how a Sparrow
might implement this interface:
public class Sparrow : IBird
{
public void Fly()
{
Console.WriteLine("The sparrow flits quickly through the air.");
}
}
A Sparrow class in C#, implementing IBird.
But there are birds that don't fly, like an Emu. An Emu should implement IBird
because it is a bird, but given that it can't fly, perhaps our interface doesn't make sense.
public class Emu : IBird
{
}
An Emu class in C#. Invalid because it doesn't implement Fly().
This fails at compile time, because any classes that implement an interface must implement all of its methods.
'Emu' does not implement interface member 'IBird.Fly()'
Obviously we have misrepresented birds by writing an interface in which they are all expected to fly.
In practice, this is useful because we usually want to be able to substitute one class for another — see the Liskov Substitution Principle — provided they implement a specific interface. In the case of a bird, as incorrectly defined above, we know that it can always fly.
public class BirdLauncher
{
public void Launch(IBird bird)
{
Console.WriteLine("Launching a bird!");
bird.Fly();
}
}
Remember: Ruby doesn't have explicit interfaces like C# does, but duck-typing itself is conceptualized around objects with implicit contract parity being interchangeable.
Now that we understand explicit interfaces in static languages, let's turn back to Ruby and how the interface substitution principle applies to it.
Violating the principle
Let's think about coffee machines. My coffee needs are simple and direct: it should taste good enough and be easy to make when I'm not awake. So I use an all-in-one unit, which takes beans and water and produces coffee.
class CoffeeMachine
def add_water = puts "Water added."
def add_beans = puts "Beans added."
def make_coffee = puts "Enjoy!"
end
Given a coffee machine, a sleepy human can now make coffee.
class SleepyHuman
def make_coffee(coffee_maker)
coffee_maker.add_water
coffee_maker.add_beans
coffee_maker.make_coffee
end
end
But there are plenty of other ways to make coffee. A capsule machine, for example, takes capsules instead of beans. Let's extend the CoffeeMachine
to account for this.
class CapsuleMachine < CoffeeMachine
def add_capsule = puts "Capsule added."
end
SleepyHuman
should now also work with a capsule machine. One way to do this is check what the object responds to.
class SleepyHuman
def make_coffee(coffee_maker)
coffee_maker.add_water
if coffee_maker.respond_to?(:add_capsule)
coffee_maker.add_capsule
else
coffee_maker.add_beans
end
coffee_maker.make_coffee
end
end
But CapsuleMachine
violates the Interface Segregation Principle, because it includes methods it doesn't use: by extending CoffeeMaker
, it has an add_beans
method, even though it cannot accept beans.
Conforming to the principle
A better way would be to share only methods that we know all coffee makers need. We could do this by inheriting from a more generic base class, but we'll change it up a little by including methods via a module instead. Either approach works at this stage.
module CoffeeMaker
def add_water = puts "Water added."
def make_coffee = puts "Enjoy!"
end
class AllInOneCoffeeMachine
include CoffeeMaker
def add_beans = puts "Beans added."
end
class CapsuleMachine
include CoffeeMaker
def add_capsule = puts "Capsule added."
end
Conformance to ISP by not including unused methods.
This conforms to ISP because neither class includes methods that aren't used by that class.
Inevitably, we will add more coffee makers in the future. One such machine might be hooked up to a water supply and not need water adding to it.
We can improve our solution by embracing smaller interfaces, which is what the ISP guides us towards. These may also be called role interfaces.
At this stage it becomes more important that we're using modules, or mixins, rather than inheritance, as we can only inherit from one class in Ruby and most other languages.
module AcceptsBeans
def add_beans = puts "Beans added."
end
module AcceptsCapsules
def add_capsule = puts "Capsule added."
end
module AcceptsWater
def add_water = puts "Water added."
end
module MakesCoffee
def make_coffee = puts "Enjoy!"
end
class AllInOneCoffeeMachine
include AcceptsWater
include AcceptsBeans
include MakesCoffee
end
class CapsuleMachine
include AcceptsWater
include AcceptsCapsules
include MakesCoffee
end
class PlumbedInCoffeeMachine
include AcceptsBeans
include MakesCoffee
end
Using interfaces as roles to conform to ISP across more classes.
Then, the SleepyHuman
need only call what the objects respond to:
class SleepyHuman
def make_coffee(coffee_maker)
coffee_maker.add_water if coffee_maker.respond_to?(:add_water)
coffee_maker.add_capsule if coffee_maker.respond_to?(:add_capsule)
coffee_maker.add_beans if coffee_maker.respond_to?(:add_beans)
coffee_maker.make_coffee
end
end
respond_to? checks are used to call only the methods that are implemented.
Though the coffee makers themselves pass ISP, they do violate other SOLID principles like OCP. There are several patterns that can help resolve this. Since we're talking about ISP, I'll include just one option: inverting the preparation back to the coffee makers themselves.
module MakesCoffee
def make_coffee = puts "Enjoy!"
end
class AllInOneCoffeeMachine
include MakesCoffee
include AcceptsWater
include AcceptsBeans
def prepare
add_water
add_beans
make_coffee
end
end
class CapsuleMachine
include MakesCoffee
include AcceptsWater
include AcceptsCapsules
def prepare
add_water
add_capsule
make_coffee
end
end
class PlumbedInCoffeeMachine
include MakesCoffee
include AcceptsBeans
def prepare
add_beans
make_coffee
end
end
class SleepyHuman
def make_coffee(coffee_maker)
coffee_maker.prepare
end
end
Delegating preparation to the coffee makers themselves means the human need know nothing about the coffee maker other than that it has a prepare
method.
Summary
The Interface Segregation Principle is concerned with keeping interfaces lightweight by not including methods they don't use, which leads to reduced coupling.
The principle is more commonly noticed and clearly violated in statically-typed languages, where a compiler loudly complains about missing methods. It remains a valid principle in duck-typed languages when viewing interfaces as implicit contracts.
As with most of these principles, my opinion is that we need not adhere strictly to ISP immediately, so long as we bear in mind the issues violation brings as we iterate. That is: if all we have is a single type of CoffeeMaker
, tight coupling between it and a SleepyHuman
might be acceptable. When this changes, it's time to re-evaluate.