SOLID Principles – Handling Significant Overlap in Interfaces with Interface Segregation Principle

cdesigninterfacesobject-orientedsolid

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?

Best Answer

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.