9

From Agile Software Development, Principles, Patterns, and Practices: Pearson New International Edition:

Sometimes, the methods invoked by different groups of clients will overlap. If the overlap is small, then 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 it will implement them only once.

Uncle Bob, talks about the case when there is minor overlap.

What should we do if there is significant overlap?

Say we have

Class UiInterface1;
Class UiInterface2;
Class UiInterface3;

Class UiIterface : public UiInterface1, public UiInterface2, public UiInterface3{};

What should we do if there is significant overlap between UiInterface1 and UiInterface2?

  • When I come across a heavily overlapping interfaces, I create a parent interface, which groups the common methods and then inherit from this common one to create specializations. BUT! If you never want anyone to use the common interface without the specialization, then you actually need to go for the code duplication, because if you introduce the parrent common interface, people could use that one. – Andy Jan 11 '16 at 12:26
  • The question is a bit vague to me, one could answer with many different solutions depending of cases. Why did the overlap grow ? – Arthur Havlicek Jan 11 '16 at 13:04
1

Casting

This is almost certainly going to be a complete tangent to the cited book's approach, but one way to conform better to ISP is to embrace a casting mindset at one central area of your codebase using a QueryInterface COM-style approach.

A lot of the temptations to design overlapping interfaces in a pure interface context often comes from the desire to make interfaces "self-sufficient" more than performing one precise, sniper-like responsibility.

For example, it might seem odd to design client functions like this:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `position` and `parenting` parameters should point to the 
// same object.
Vec2i abs_position(IPosition* position, IParenting* parenting)
{
     const Vec2i xy = position->xy();
     auto parent = parenting->parent();
     if (parent)
     {
         // If the entity has a parent, return the sum of the
         // parent position and the entity's local position.
         return xy + abs_position(dynamic_cast<IPosition*>(parent),
                                  dynamic_cast<IParenting*>(parent));
     }
     return xy;
}

... as well as quite ugly/dangerous, given that we're leaking the responsibility to do error-prone casting to the client code using these interfaces and/or passing the same object as an argument multiple times to multiple parameters of the same function. So we end up often wanting to design a more diluted interface which consolidates the concerns of IParenting and IPosition in one place, like IGuiElement or something like that which then becomes susceptible to overlapping with the concerns of orthogonal interfaces which will likewise be tempted to have more member functions for the same "self-sufficiency" reason.

Mixing Responsibilities vs. Casting

When designing interfaces with a totally distilled, ultra-singular responsibility, the temptation will often be to either accept some downcasting or consolidate interfaces to fulfill multiple responsibilities (and therefore tread on both ISP and SRP).

By using a COM-style approach (just the QueryInterface part), we play to the downcasting approach but consolidate the casting to one central place in the codebase, and can do something more like this:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should implement `IPosition` and optionally `IParenting`.
Vec2i abs_position(Object* obj)
{
     // `Object::query_interface` returns nullptr if the interface is
     // not provided by the entity. `Object` is an abstract base class
     // inherited by all entities using this interface query system.
     IPosition* position = obj->query_interface<IPosition>();
     assert(position && "obj does not implement IPosition!");
     const Vec2i xy = position->xy();

     IParenting* parenting = obj->query_interface<IParenting>();
     if (parenting && parenting->parent()->query_interface<IPosition>())
     {
         // If the entity implements IParenting and has a parent, 
         // return the sum of the parent position and the entity's 
         // local position.
         return xy + abs_position(parenting->parent());
     }
     return xy;
}

... of course hopefully with type-safe wrappers and all that you can build centrally to get something safer than raw pointers.

With this, the temptation to design overlapping interfaces is often mitigated to the absolute minimum. It allows you to design interfaces with very singular responsibilities (sometimes just one member function inside) that you can mix and match all you like without worrying about ISP, and getting the flexibility of pseudo-duck typing at runtime in C++ (though of course with the trade-off of runtime penalties to query objects to see if they support a particular interface). The runtime part can be important in, say, a setting with a software development kit where the functions will not have the compile-time information of plugins in advance that implement these interfaces.

Templates

If templates are a possibility (we have the necessary compile-time info in advance which is not lost by the time we get hold of an object, i.e.), then we can simply do this:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should have `position` and `parent` methods.
template <class Entity>
Vec2i abs_position(Entity& obj)
{
     const Vec2i xy = obj.xy();
     if (obj.parent())
     {
         // If the entity has a parent, return the sum of the parent 
         // position and the entity's local position.
         return xy + abs_position(obj.parent());
     }
     return xy;
}

... of course in such a case, the parent method would have to return the same Entity type, in which case we probably want to avoid interfaces outright (since they'll often want to lose type information in favor of working with base pointers).

Entity-Component System

If you start pursuing the COM-style approach further from a flexibility or performance standpoint, you'll often end up with an entity-component system similar to what game engines apply in the industry. At that point you'll be going completely perpendicular to a lot of object-oriented approaches, but ECS might be applicable to GUI design (one place I've contemplated using ECS outside of a scene-oriented focus, but considered it too late after settling on a COM-style approach to try there).

Note that this COM-style solution is completely out there as far as GUI toolkit designs go, and ECS would be even more, so it's not something which will be backed by a lot of resources. Yet it'll definitely allow you to mitigate the temptations to design interfaces which have overlapping responsibilities to an absolute minimum, often making it a non-concern.

Pragmatic Approach

The alternative, of course, is relax your guard a bit, or design interfaces at a granular level and then start inheriting them to create coarser interfaces that you use, like IPositionPlusParenting which derives from both IPosition and IParenting (hopefully with a better name than that). With pure interfaces, it shouldn't violate ISP quite as much as those monolithic deep-hierarchical approaches commonly applied (Qt, MFC, etc, where the documentation often feels the need to hide irrelevant members given the excessive level of violating ISP with those kinds of designs), so a pragmatic approach might simply accept some overlap here and there. Yet this kind of COM-style approach avoids the need to create consolidated interfaces for every combination you'll ever use. The "self-sufficiency" concern is completely eliminated in such cases, and that will often eliminate the ultimate source of temptation to design interfaces which have overlapping responsibilities which want to fight with both SRP and ISP.

11

This is a judgement call that you have to make, on a case-by-case basis.

First of all, remember that SOLID principles are just that ... principles. They're not rules. They're not a silver bullet. They're just principles. That's not to take away from their importance, you should always lean towards following them. But the second they introduce a level of pain, you should ditch them until you need them.

With that in mind, think about why you're separating out your interfaces in the first place. The idea of an interface is to say "If this consuming code requires a set of methods to be implemented on the class being consumed, I need to set a contract on the implementation: If you provide me an object with this interface, I can work with it."

The purpose of ISP is to say "If the contract I require is only a subset of an existing interface, I shouldn't enforce the existing interface on any future classes that may be passed to my method."

Consider the following code:

public interface A
{
    void X();
    void Y();
}

public class Foo
{
     public void ConsumeX(A a)
     {
         a.X();
     }
}

Now we have a situation where, if we want to pass a new object to ConsumeX, it has to implement X() and Y() to fit the contract.

So should we change the code, right now, to look like the next example?

public interface A
{
    void X();
    void Y();
}

public interface B
{
    void X();
}

public class Foo
{
     public void ConsumeX(B b)
     {
         b.X();
     }
}

ISP suggests we should, so we should lean towards that decision. But, without context, it's hard to be sure. Is it likely that we'll extend A and B? Is it likely that they'll extend independently? Is it likely that B will ever implement methods that A doesn't require? (If not, we can make A derive from B.)

This is the judgement call you have to make. And, if you really don't have enough information to make that call, you should probably take the simplest option, which might well be the first code.

Why? Because it's easy to change your mind later. When you need that new class, simply create a new interface and implement both in your old class.

  • 1
    "First of all, remember that SOLID principles are just that ... principles. They're not rules. They're not a silver bullet. They're just principles. That's not to take away from their importance, you should always lean towards following them. But the second they introduce a level of pain, you should ditch them until you need them.". This should be on the first page of every design patterns / principles book. An it should appear also every 50 pages as a reminder. – Christian Rodriguez Jan 12 '16 at 12:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.