12. The Interface Segregation Principle (ISP)

This principle deals with the disadvantages of “fat” interfaces. Classes whose interfaces are not cohesive have “fat” interfaces. In other words, the interfaces of the class can be broken up into groups of methods. Each group serves a different set of clients. Thus, some clients use one group of methods, and other clients use the other groups.

ISP acknowledges that there are objects that require noncohesive interfaces; however, it suggests that clients should not know about them as a single class. Instead, clients should know about abstract base classes that have cohesive interfaces.

Interface Pollution

Consider a security system in which Door objects can be locked and unlocked and know whether they are open or closed. (See Listing 12-1.) This Door is coded as an interface so that clients can use objects that conform to the Door interface without having to depend on particular implementations of Door.


Listing 12-1. Security Door

public interface Door
{
  void Lock();
  void Unlock();
  bool IsDoorOpen();
}


Now consider that one such implementation, TimedDoor, needs to sound an alarm when the door has been left open for too long. In order to do this, the TimedDoor object communicates with another object called a Timer. (See Listing 12-2.)


Listing 12-2.

public class Timer
{
  public void Register(int timeout, TimerClient client)
  {/*code*/}
}

public interface TimerClient
{
  void TimeOut();
}


When an object wishes to be informed about a timeout, it calls the Register function of the Timer. The arguments of this function are the time of the timeout and a reference to a TimerClient object whose TimeOut function will be called when the timeout expires.

How can we get the TimerClient class to communicate with the TimedDoor class so that the code in the TimedDoor can be notified of the timeout? There are several alternatives. Figure 12-1 shows a common solution. We force Door, and therefore TimedDoor, to inherit from TimerClient. This ensures that TimerClient can register itself with the Timer and receive the TimeOut message.

Figure 12-1. TimerClient at top of hierarchy

image

The problem with this solution is that the Door class now depends on TimerClient. Not all varieties of Door need timing. Indeed, the original Door abstraction had nothing whatever to do with timing. If timing-free derivatives of Door are created, they will have to provide degenerate implementations for the TimeOut method a potential violation of LSP. Moreover, the applications that use those derivatives will have to import the definition of the TimerClient class, even though it is not used. That smells of needless complexity and needless redundancy.

This is an example of interface pollution, a syndrome that is common in statically typed languages, such as C#, C++, and Java. The interface of Door has been polluted with a method that it does not require. It has been forced to incorporate this method solely for the benefit of one of its subclasses. If this practice is pursued, every time a derivative needs a new method, that method will be added to the base class. This will further pollute the interface of the base class, making it “fat.”

Moreover, each time a new method is added to the base class, that method must be implemented or allowed to default in derived classes. Indeed, an associated practice is to add these methods to the base class, giving them degenerate, or default, implementations specifically so that derived classes are not burdened with the need to implement them. As we learned previously, such a practice can violate LSP, leading to maintenance and reusability problems.

Separate Clients Mean Separate Interfaces

Door and TimerClient represent interfaces that are used by complely different clients. Timer uses TimerClient, and classes that manipulate doors use Door. Since the clients are separate, the interfaces should remain separate, too. Why? Because clients exert forces on their server interfaces.

When we think of forces that cause changes in software, we normally think about how changes to interfaces will affect their users. For example, we would be concerned about the changes to all the users of TimerClient if its interface changed. However, there is a force that operates in the other direction. Sometimes, the user forces a change to the interface.

For example, some users of Timer will register more than one timeout request. Consider the TimedDoor. When it detects that the Door has been opened, it sends the Register message to the Timer, requesting a timeout. However, before that timeout expires, the door closes, remains closed for a while, and then opens again. This causes us to register a new timeout request before the old one has expired. Finally, the first timeout request expires, and the TimeOut function of the TimedDoor is invoked. The Door alarms falsely.

We can correct this situation by using the convention shown in Listing 12-3. We include a unique timeOutId code in each timeout registration and repeat that code in the TimeOut call to the TimerClient. This allows each derivative of TimerClient to know which timeout request is being responded to.

Clearly, this change will affect all the users of TimerClient. We accept this, since the lack of the timeOutId is an oversight that needs correction. However, the design in Figure 12-1 will also cause Door, and all clients of Door, to be affected by this fix! This smells of rigidity and viscosity. Why should a bug in TimerClient have any affect on clients of Door derivatives that do not require timing? This kind of strange interdependency chills customers and managers to the bone. When a change in one part of the program affects other, completely unrelated parts of the program, the cost and repercussions of changes become unpredictable, and the risk of fallout from the change increases dramatically.


Listing 12-3. Timer with ID

public class Timer
{
  public void Register(int timeout,
                       int timeOutId,
                       TimerClient client)
  {/*code*/}
}

public interface TimerClient
{
  void TimeOut(int timeOutID);
}



The Interface Segregation Principle

Clients should not be forced to depend on methods they do not use.


When clients are forced to depend on methods they don’t use, those clients are subject to changes to those methods. This results in an inadvertent coupling between all the clients. Said another way, when a client depends on a class that contains methods that the client does not use but that other clients do use, that client will be affected by the changes that those other clients force on the class. We would like to avoid such couplings where possible, and so we want to separate the interfaces.

Class Interfaces versus Object Interfaces

Consider the TimedDoor again. Here is an object that has two separate interfaces used by two separate clients: Timer and the users of Door. These two interfaces must be implemented in the same object, since the implementation of both interfaces manipulates the same data. How can we conform to ISP? How can we separate the interfaces when they must remain together?

The answer lies in the fact that clients of an object do not need to access it through the interface of the object. Rather, they can access it through delegation or through a base class of the object.

Separation Through Delegation

One solution is to create an object that derives from TimerClient and delegates to the TimedDoor. Figure 12-2 shows this solution. When it wants to register a timeout request with the Timer, the TimedDoor creates a DoorTimerAdapter and registers it with the Timer. When the Timer sends the TimeOut message to the DoorTimerAdapter, the DoorTimerAdapter delegates the message back to the TimedDoor.

Figure 12-2. Door timer adapter

image

This solution conforms to ISP and prevents the coupling of Door clients to Timer. Even if the change to Timer shown in Listing 12-3 were to be made, none of the users of Door would be affected. Moreover, TimedDoor does not have to have the exact same interface as TimerClient. The DoorTimerAdapter can translate the TimerClient interface into the TimedDoor interface. Thus, this is a very general-purpose solution. (See Listing 12-4.)


Listing 12-4. TimedDoor.cs

public interface TimedDoor : Door
{
  void DoorTimeOut(int timeOutId);
}

public class DoorTimerAdapter : TimerClient
{
  private TimedDoor timedDoor;

  public DoorTimerAdapter(TimedDoor theDoor)
  {
    timedDoor = theDoor;
  }

  public virtual void TimeOut(int timeOutId)
  {
    timedDoor.DoorTimeOut(timeOutId);
  }
}


However, this solution is also somewhat inelegant. It involves the creation of a new object every time we wish to register a timeout. Moreover, the delegation requires a very small, but still nonzero, amount of runtime and memory. In some application domains, such as embedded real-time control systems, runtime and memory are scarce enough to make this a concern.

Separation Through Multiple Inheritance

Figure 12-3 and Listing 12-5 show how multiple inheritance can be used to achieve ISP. In this model, TimedDoor inherits from both Door and TimerClient. Although clients of both base classes can make use of TimedDoor, neither depends on the TimedDoor class. Thus, they use the same object through separate interfaces.

Figure 12-3. Multiply inherited TimedDoor

image


Listing 12-5. TimedDoor.cpp

public interface TimedDoor : Door, TimerClient
{
}


This solution is my normal preference. The only time I would choose the solution in Figure 12-2 over that in Figure 12-3 is if the translation performed by the DoorTimerAdapter object were necessary or if different translations were needed at different times.

The ATM User Interface Example

Now let’s consider a slightly more significant example: the traditional automated teller machine (ATM) problem. The user interface of an ATM needs to be very flexible. The output may need to be translated into many different languages and it may need to be presented on a screen, on a braille tablet, or spoken out a speech synthesizer (Figure 12-4). Clearly, this flexibility can be achieved by creating an abstract base class that has abstract methods for all the different messages that need to be presented by the interface.

image

Figure 12-4. ATM user interface

image

Consider also that each transaction that the ATM can perform is encapsulated as a derivative of the class Transaction. Thus, we might have such classes as DepositTransaction, WithdrawalTransaction, TransferTransaction, and so on. Each of these classes invokes UI methods. For example, in order to ask the user to enter the amount to be deposited, the DepositTransaction object invokes the RequestDepositAmount method of the UI class. Likewise, in order to ask the user how much money to transfer between accounts, the TransferTransaction object calls the RequestTransferAmount method of UI. This corresponds to the diagram in Figure 12-5.

Figure 12-5. ATM transaction hierarchy

image

Note that this is precisely the situation that ISP tells us to avoid. Each of the transactions is using UI methods that no other class uses. This creates the possibility that changes to one of the derivatives of Transaction will force corresponding change to UI, thereby affecting all the other derivatives of Transaction and every other class that depends on the UI interface. Something smells like rigidity and fragility around here.

For example, if we were to add a PayGasBillTransaction, we would have to add new methods to UI in order to deal with the unique messages that this transaction would want to display. Unfortunately, since DepositTransaction, WithdrawalTransaction, and TransferTransaction all depend on the UI interface, they are all likely to be rebuilt. Worse, if the transactions were all deployed as components in separate assemblies, those assemblies would very likely have to be redeployed, even though none of their logic was changed. Can you smell the viscosity?

This unfortunate coupling can be avoided by segregating the UI interface into individual interfaces, such as DepositUI, WithdrawUI, and TransferUI. These separate interfaces can then be multiply inherited into the final UI interface. Figure 12-6 and Listing 12-6 show this model.

Figure 12-6. Segregated ATM UI interface

image

Whenever a new derivative of the Transaction class is created, a corresponding base class for the abstract UI interface will be needed, and so the UI interface and all its derivatives must change. However, these classes are not widely used. Indeed, they are probably used only by main or whatever process boots the system and creates the concrete UI instance. So the impact of adding new UI base classes is minimized.

A careful examination of Figure 12-6 shows one of the issues with ISP conformance that was not obvious from the TimedDoor example. Note that each transaction must somehow know about its particular version of the UI. DepositTransaction must know about DepositUI, WithdrawTransaction must know about WithdrawalUI, and so on. In Listing 12-6, I have addressed this issue by forcing each transaction to be constructed with a reference to its particular UI. Note that this allows me to use the idiom in Listing 12-7.

This is handy but also forces each transaction to contain a reference member to its UI. In C#, one might be tempted to put all the UI components into a single class. Listing 12-8 shows such an approach. This, however, has an unfortunate effect. The UIGlobals class depends on DepositUI, WithdrawalUI, and TransferUI. This means that a module wishing to use any of the UI interfaces transitively depends on all of them, exactly the situation that ISP warns us to avoid. If a change is made to any of the UI interfaces, all modules that use UIGlobals may be forced to recompile. The UIGlobals class has recombined the interfaces that we had worked so hard to segregate!


Listing 12-6. Segregated ATM UI interface

public interface Transaction
{
  void Execute();
}

public interface DepositUI
{
  void RequestDepositAmount();
}

public class DepositTransaction : Transaction
{
  privateDepositUI depositUI;

  public DepositTransaction(DepositUI ui)
  {
    depositUI = ui;
  }

  public virtual void Execute()
  {
    /*code*/
    depositUI.RequestDepositAmount();
    /*code*/
  }
}

public interface WithdrawalUI
{
  void RequestWithdrawalAmount();
}

public class WithdrawalTransaction : Transaction
{
  private WithdrawalUI withdrawalUI;

  public WithdrawalTransaction(WithdrawalUI ui)
  {
    withdrawalUI = ui;
  }

  public virtual void Execute()
  {
    /*code*/
    withdrawalUI.RequestWithdrawalAmount();
    /*code*/
  }
}

public interface TransferUI
{
  void RequestTransferAmount();
}

public class TransferTransaction : Transaction
{
  private TransferUI transferUI;

  public TransferTransaction(TransferUI ui)
  {
    transferUI = ui;
  }

  public virtual void Execute()
  {
    /*code*/
    transferUI.RequestTransferAmount();
    /*code*/
  }
}

public interface UI : DepositUI, WithdrawalUI, TransferUI
{
}



Listing 12-7. Interface initialization idiom

UI Gui; // global object;

void f()
{
    DepositTransaction dt = new DepositTransaction(Gui);
}



Listing 12-8. Wrapping the Globals in a class

public class UIGlobals
{
  public static WithdrawalUI withdrawal;
  public static DepositUI deposit;
  public static TransferUI transfer;

  static UIGlobals()
  {
    UI Lui = new AtmUI(); // Some UI implementation
    UIGlobals.deposit = Lui;
    UIGlobals.withdrawal = Lui;
    UIGlobals.transfer = Lui;
  }
}


Consider now a function g that needs access to both the DepositUI and the TransferUI. Consider also that we wish to pass the user interfaces into this function. Should we write the function declaration like this:

void g(DepositUI depositUI, TransferUI transferUI)

Or should we write it like this:

void g(UI ui)

The temptation to write the latter (monadic) form is strong. After all, we know that in the former (polyadic) form, both arguments will refer to the same object. Moreover, if we were to use the polyadic form, its invocation might look like this:

g(ui, ui);

Somehow this seems perverse.

Perverse or not, the polyadic form is often preferable to the monadic form. The monadic form forces g to depend on every interface included in UI. Thus, when WithdrawalUI changes, g and all clients of g could be affected. This is more perverse than g(ui,ui)! Moreover, we cannot be sure that both arguments of g will always refer to the same object! In the future, it may be that the interface objects are separated for some reason. The fact that all interfaces are combined into a single object is information that g does not need to know. Thus, I prefer the polyadic form for such functions.

Clients can often be grouped together by the service methods they call. Such groupings allow segregated interfaces to be created for each group instead of for each client. This greatly reduces the number of interfaces that the service has to realize and prevents the service from depending on each client type.

Sometimes, the methods invoked by different groups of clients will overlap. If the overlap is small, the interfaces for the groups should remain separate. The common functions should be declared in all the overlapping interfaces. The server class will inherit the common functions from each of those interfaces but will implement them only once.

When object-oriented applications are maintained, the interfaces to existing classes and components often change. Sometimes, these changes have a huge impact and force the recompilation and redeployment of a very large part of the system. This impact can be mitigated by adding new interfaces to existing objects rather than changing the existing interface. If clients of the old interface wish to access methods of the new interface, they can query the object for that interface, as shown in Listing 12-9.


Listing 12-9.

void Client(Service s)
{
  if(s is NewService)
  {
    NewService ns = (NewService)s;
    // use the new service interface
  }
}


As with all principles, care must be taken not to overdo it. The specter of a class with hundreds of different interfaces, some segregated by client and other segregated by version, is frightening indeed.

Conclusion

Fat classes cause bizarre and harmful couplings between their clients. When one client forces a change on the fat class, all the other clients are affected. Thus, clients should have to depend only on methods that they call. This can be achieved by breaking the interface of the fat class into many client-specific interfaces. Each client-specific interface declares only those functions that its particular client or client group invoke. The fat class can then inherit all the client-specific interfaces and implement them. This breaks the dependence of the clients on methods that they don’t invoke and allows the clients to be independent of one another.

Bibliography

[GOF95] Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1995.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.233.223.189