Avoiding Interface Pollution with the Interface Segregation Principle
October 09, 2018
One of the themes that has popped up throughout our SOLID series is that of decoupling. In short, this theme argues that entities (objects, modules, functions, etc.) in a software program should be loosely coupled so as to prevent changes in one place from propagating to another. The reason this is desirable is that loosely coupled entities are easier to maintain, more flexible, and more mobile. We reviewed some of the reasons why this is the case in part 2 of the series, which covered the Open/Closed Principle, and in part 3, which covered the Liskov Substitution Principle. And yet, decoupling is so important that there is still more to say on the topic, namely, how to avoid so-called “interface pollution,” wherein classes are unnecessarily forced to implement behaviors that they don’t need. It is here that our next SOLID principle appears: the Interface Segregation Principle.
A Quick Refresher on SOLID
SOLID is an acronym for a set of five software development principles, which if followed, are intended to help developers create flexible and clean code. The five principles are:
- The Single Responsibility Principle—Classes should have a single responsibility and thus only a single reason to change.
- The Open/Closed Principle—Classes and other entities should be open for extension but closed for modification.
- The Liskov Substitution Principle—Objects should be replaceable by their subtypes.
- The Interface Segregation Principle—Interfaces should be client specific rather than general.
- The Dependency Inversion Principle—Depend on abstractions rather than concretions.
The Interface Segregation Principle
As we discussed in our review of the Open/Closed Principle, interfaces are a means of programming with abstractions rather than concretions. An interface serves as a kind of contract between two objects that interact with one another. Rather than depending directly on one another, each object instead depends on the intermediary interface. The client object (the one using another object’s behavior) doesn’t have any knowledge of how the service object (the one that implements some behavior) is structured. For its part, the service object merely guarantees that it will implement behavior described in the interface without bothering to reveal how it will do so. As a result, the two objects are effectively decoupled since neither has a direct dependency on the other. Further, interfaces allow for the creation of multiple service objects that all implement some guaranteed behavior, meaning that a client object can exploit many different behaviors depending on which service object is being used (and all without ever knowing that different kinds of service objects even exist.)
As useful as interfaces are, they raise an interesting conundrum: what happens when you want to create a service object that doesn’t actually need all of the behaviors defined on its interface? Because an interface is a contract, you would be forced to define behaviors that are effectively useless. This is known colloquially as “interface pollution” because a class may become polluted with behaviors that it doesn’t need. Worse yet, that pollution would propagate to any subclasses of a polluted superclass. This is a particularly insidious kind of coupling because it creates dependencies that don’t do anything even marginally useful.
As part of his SOLID principles, Robert C. Martin proposed a solution to this problem, which he called the Interface Segregation Principle (ISP) [1]. Martin argued that interface pollution was primarily the result of “fat interfaces”—that is, interfaces with a large number of prescribed methods. To counter the effects of fat interfaces, Martin defined the ISP as follows:
Clients should not be forced to depend upon interfaces that they do not use.
If fat interfaces are problematic, then what’s the alternative? Martin advocates for the use of so-called “role interfaces”, which are small interfaces that only contain methods that are of interest to the objects that use them. A fat interface may therefore be broken down into smaller role interfaces that guarantee specific related behaviors. Clients that require behaviors from multiple role interfaces may simply implement each of them. Meanwhile, clients that only need limited behaviors are not forced to live with unnecessary interface pollution. In other words, separate clients can and should have separate interfaces, which in turn limits coupling and cascading breakage.
Interface Pollution in Action
Interface pollution is ultimately a question of desired behavior. If an interface exclusively defines behaviors that an implementing object actually desires, then pollution won’t be a problem. However, if an interface is broad enough as to define behaviors that won’t be used universally, then it’s probably worth investigating options to break it down. Consider the following program.
using System;
namespace isp_1
{
class Program
{
static void Main(string[] args)
{
var basicStudent = new BasicMathStudent();
Console.WriteLine(basicStudent.Calculate("add", 3, 4));
Console.WriteLine(basicStudent.Calculate("subtract", 3, 4));
Console.WriteLine(basicStudent.Calculate("multiply", 3, 4));
Console.WriteLine(basicStudent.Calculate("divide", 3, 4));
Console.WriteLine("---------------------------");
var advancedStudent = new AdvancedMathStudent();
Console.WriteLine(advancedStudent.Calculate("add", 3, 4));
Console.WriteLine(advancedStudent.Calculate("subtract", 3, 4));
Console.WriteLine(advancedStudent.Calculate("multiply", 3, 4));
Console.WriteLine(advancedStudent.Calculate("divide", 3, 4));
Console.WriteLine(advancedStudent.Calculate("power", 3, 4));
Console.WriteLine(advancedStudent.Calculate("squareroot", 25));
}
}
interface ICalculator
{
int Add(int num1, int num2);
int Subtract(int num1, int num2);
int Multiply(int num1, int num2);
double Divide(int num1, int num2);
double Power(double num, double power);
double SquareRoot(double num);
}
class BasicCalculator : ICalculator
{
public int Add(int num1, int num2)
{
return num1 + num2;
}
public int Subtract(int num1, int num2)
{
return num1 - num2;
}
public int Multiply(int num1, int num2)
{
return num1 * num2;
}
public double Divide(int num1, int num2)
{
return (double)num1 / (double)num2;
}
public double Power(double num, double power)
{
throw new NotSupportedException();
}
public double SquareRoot(double num)
{
throw new NotSupportedException();
}
}
class AdvancedCalculator : ICalculator
{
public int Add(int num1, int num2)
{
return num1 + num2;
}
public int Subtract(int num1, int num2)
{
return num1 - num2;
}
public int Multiply(int num1, int num2)
{
return num1 * num2;
}
public double Divide(int num1, int num2)
{
return (double)num1 / (double)num2;
}
public double Power(double num, double power)
{
return Math.Pow(num, power);
}
public double SquareRoot(double num)
{
return Math.Sqrt(num);
}
}
class BasicMathStudent
{
private BasicCalculator Calculator;
public BasicMathStudent()
{
this.Calculator = new BasicCalculator();
}
public double Calculate(string operation, int operand1, int operand2)
{
switch (operation.ToLower())
{
case "add":
return this.Calculator.Add(operand1, operand2);
case "subtract":
return this.Calculator.Subtract(operand1, operand2);
case "multiply":
return this.Calculator.Multiply(operand1, operand2);
case "divide":
return this.Calculator.Divide(operand1, operand2);
default:
throw new ArgumentException();
}
}
}
class AdvancedMathStudent
{
private AdvancedCalculator Calculator;
public AdvancedMathStudent()
{
this.Calculator = new AdvancedCalculator();
}
public double Calculate(string operation, int number)
{
if (operation.ToLower() == "squareroot")
{
return this.Calculator.SquareRoot(number);
}
else
{
throw new ArgumentException();
}
}
public double Calculate(string operation, int operand1, int operand2)
{
switch (operation.ToLower())
{
case "add":
return this.Calculator.Add(operand1, operand2);
case "subtract":
return this.Calculator.Subtract(operand1, operand2);
case "multiply":
return this.Calculator.Multiply(operand1, operand2);
case "divide":
return this.Calculator.Divide(operand1, operand2);
case "power":
return this.Calculator.Power(operand1, operand2);
default:
throw new ArgumentException();
}
}
}
}
Here we have a C# program that describes the calculation abilities of two different classes: BasicMathStudent
, which uses a BasicCalculator
; and, AdvancedMathStudent
, which uses an AdvancedCalculator
. Both types of calculators implement the ICalculator
interface, which defines the following behaviors: Add
; Subtract
; Multiply
; Divide
; Power
; and, SquareRoot
. When either type of student is asked to Calculate
something, it uses a switch statement to identify the appropriate behavior and then uses its Calculator
member to carry out the necessary operation. (Note: For the sake of brevity, we’re setting aside a few obvious improvements like an IMathStudent
interface, a possible Calculator
class hierarchy, etc.)
The above code works just fine and both of our student types are able to carry out the required behaviors; however, if you look at the BasicCalculator
class you will see that it is polluted by having to unnecessarily implement the Power
and SquareRoot
methods. Our BasicMathStudent
is not expected to deal with exponents and thus does not need a calculator capable of those functions. This might seem innocent enough, but consider what would happen if either BasicCalculator
or BasicMathStudent
had subclasses—the pollution would propagate to each of them, thus creating unnecessary dependencies. Furthermore, what if we decided that our AdvancedCalculator
should be even more capable—perhaps with a few geometry-focused methods like Cos
, Sin
, and Tan
? We would have to add corresponding contract definitions to ICalculator
and then implement yet more unnecessary methods on BasicCalculator
.
The above is of course a fairly contrived example, but it illustrates the danger of fat interfaces that require behavior that may not be necessary to all of its implementing classes.
Using Role Interfaces
In order to make our math student program ISP-adherent, we should consider breaking down the ICalculator interface into more behavior-specific role interfaces. Leaving the rest of our program unchanged, we can do this by updating our interface definitions and only implementing them on classes that will actually use the behavior that they guarantee.
interface IArithmetic
{
int Add(int num1, int num2);
int Subtract(int num1, int num2);
int Multiply(int num1, int num2);
double Divide(int num1, int num2);
}
interface IExponents
{
double Power(double num, double power);
double SquareRoot(double num);
}
class BasicCalculator : IArithmetic
{
public int Add(int num1, int num2)
{
return num1 + num2;
}
public int Subtract(int num1, int num2)
{
return num1 - num2;
}
public int Multiply(int num1, int num2)
{
return num1 * num2;
}
public double Divide(int num1, int num2)
{
return (double)num1 / (double)num2;
}
}
class AdvancedCalculator : IArithmetic, IExponents
{
public int Add(int num1, int num2)
{
return num1 + num2;
}
public int Subtract(int num1, int num2)
{
return num1 - num2;
}
public int Multiply(int num1, int num2)
{
return num1 * num2;
}
public double Divide(int num1, int num2)
{
return (double)num1 / (double)num2;
}
public double Power(double num, double power)
{
return Math.Pow(num, power);
}
public double SquareRoot(double num)
{
return Math.Sqrt(num);
}
}
In this version, instead of an ICalculator
interface we have two role interfaces: IArithmetic
; and, IExponents
. The BasicCalculator
only implements IArithmetic
whereas AdvancedCalculator
implements both IArithmetic
and IExponents
. Note how our BasicCalculator
is now free of pollution and yet we haven’t lost any functionality in our AdvancedCalculator
. Indeed, the program executes just as it did in the first version, except now we have fewer unnecessary couplings. Furthermore, if we wanted to add new functionality to our AdvancedCalculator
we could do so by defining new role interfaces, such as IGeometry
, and implementing them as needed. Meanwhile, our BasicMathStudent
and AdvancedMathStudent
classes have services that are specifically suited to their needs and expose no unnecessary behavior.
Better to start with a flexible architecture than to lock yourself in to one that is overly rigid.
Using role interfaces may seem excessive, particularly in cases that result in little pollution; however, they are an excellent way to prime your program for future changes. Better to start with a flexible architecture than to lock yourself in to one that is overly rigid.
TL;DR
The fourth of the SOLID principles, the Interface Segregation Principle (ISP), says that “clients should not be forced to depend upon interfaces that they do not use.” The intent of the ISP is to guard against so-called “interface pollution,” which is when an object implements an interface that guarantees behaviors beyond those needed by the particular object. Interface pollution is typically caused by “fat interfaces”, which are those that have too many behaviors defined in a single place. In order to adhere to the ISP, one should consider instead using “role interfaces”, which define limited sets of related behaviors, which can then be implemented only where they are needed. By adhering to the ISP, a program can further eliminate unnecessary coupling, thereby increasing long-term flexibility, maintainability, and mobility.
References
- Book: Clean Code; Martin, Robert C.
- Paper: The Interface Segregation Principle; Martin, Robert C.
- Blog: Role Interface; Fowler, Martin
- Wikipedia: Interface segregation principle
We’re approaching the end of our series on the SOLID principles! I hope you enjoyed this article on the ISP. Stay tuned for the final article, which will discuss the Dependency Inversion Principle.
Note: This article was originally published on Medium.