Note: This is the fourth of five posts I’m writing on the SOLID principles of object-oriented programming. Part 1: S, Part 2: O, Part 3: L
The Interface Segregation Principle is probably the easiest of the five SOLID principles for most programmers to grasp, if for no other reason than they’ve been exposed to it constantly if they’ve been working with an object-oriented language. The ISP says that small, single-purpose interfaces are to be preferred to large, omnibus interfaces.
Finding examples is easy. Here are a few lines pulled from the Cocoa Foundation headers:
@interface NSArray : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>
@interface NSDictionary : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>
@interface NSSet : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>
@interface NSString : NSObject <NSCopying, NSMutableCopying, NSSecureCoding>
@interface NSNumber : NSValue
@interface NSValue : NSObject <NSCopying, NSSecureCoding>
@protocol NSSecureCoding <NSCoding>
For those of you who aren’t fluent in Objective-C: In each of those lines, the identifier immediately before the colon is the name of a class or protocol being declared (as distinct from being defined), an identifier immediately after the colon but outside the angle brackets is the parent class of the class being defined, and identifiers inside the angle brackets are protocols to which the class or protocol being defined will conform.
If you’re a native Java speaker,
@protocol is very similar to
interface; if C++ is your thing,
@protocol is akin to a pure abstract base class. In all three cases, it’s all contract and no implementation.
What contracts are these interfaces expressing?
NSCopying exists “for providing functional copies of an object.”
NSMutableCopying is “for providing mutable copies of an object.”
NSSecureCoding offers all the
NSCoding methods for archiving an object, and additionally allows an object to assert that it unarchives securely.
NSFastEnumeration is “implemented by objects wishing to make use of a fast and safe enumeration style.”
…and of course, each class has the methods that make it special: an NSArray has the the operations you’d expect for an ordered, randomly-accessible collection of objects; NSString allows you to search for substrings, and so on.
Each interface defines a very specific capability – you could almost call them atoms of functionality (or promised functionality).
So why do we break up our object declarations into these separate interfaces?
First, it offers you a certain amount of protection.
NSCoder (non-Cocoa heads: it archives objects complying with the
NSCoding protocol) only needs to know about those methods relating to object serialization. Someone writing an
NSCoder subclass doesn’t know and doesn’t need to know about copying or enumeration or any of the other things Foundation objects commonly do, and therefore can’t do anything surprising to an object that is passed into that subclass (like mutate it unexpectedly via a method having nothing to do with archiving). It allows you to expose only those methods a particular caller should care about, and in that way avoid surprises.
Second, it allows you more freedom in how you express the capabilities of a class. Imagine modeling a bird in Objective-C:
- (void)poopOnWindshield:(Car*) targetCar;
// ...more properties and methods
This looks straightforward, but what about subclasses that don’t need all of those capabilities? Should
Penguin throw an exception when you call
-fly? Should it be a no-op? What is it reasonable for calling code to expect? You could make
Bird a protocol instead of a base class, and make flying-related operations optional:
- (void)poopOnWindshield:(Car*) targetCar;
…but then what do you do when it comes time to model a
Bat? Flying is a very similar operation, but all the code you wrote that needs
-fly is expecting a
Bird. You don’t want to duplicate the same code for a
Bat, and you certainly don’t want to start checking types and casting, because you’re eventually going to have to implement
FlyingFish, and who knows what else, and that code will turn into an error-prone hairball. If the
-fly operation is used in the same way on each class, the calling code shouldn’t care about the specific type, only whether
-fly is implemented.
With interface segregation, we can declare all of these things very flexibly:
@interface Pigeon : Bird <Flying>
@interface Penguin : Bird
@interface Bat : Mammal <Flying>
Behavior that is shared across class hierarchies is broken out into a special-purpose interface. A method to check the altitude of a flying animal doesn’t need to know whether it’s a flying bird or a bat; the method signature
- (NSFloat)checkAltitude:(id<Flying>)flyingAnimal; makes it clear that this code cares
only about flying animals. You can’t even pass a
Penguin to this method. (Another note for the non-ObjC-ers:
id<Flying> means any object that conforms to the
Going back to the Foundation classes I referenced at the beginning of this post: It might be tempting to say that most of the classes need most of the same functionality, so why not put all the copying, archiving, and enumeration methods on
NSObject, or make a subclass or protocol called
NSFoundationObject that offers all the relevant methods?
That would work fine for the collection classes, all of which implement all the interfaces. Then we get to
NSString… What does it mean to enumerate a string? Our first naïve thought might be to treat the string as a collection of characters, but nothing in
NSCopying says anything about a character encoding, so that’s not going to work. Someday, someone is going to try to enumerate a string, and it will… crash? Throw an exception? Behave like an empty collection? Doing nothing isn’t even an option, because the lone method on
NSFastEnumeration has a return value. (And the answer is not to have
NSCopying‘s method take a character encoding enum, and have classes that don’t need it ignore it – that’s making the problem worse, not better.)
It gets even sillier with
NSNumber. What does it mean to enumerate over an atomic value type? What does it mean to have a mutable copy of it? It would be senseless for
NSNumber to claim that it offers these capabilities.
So, it doesn’t. Every type advertises only those capabilities that are meaningful to it, with interfaces that describe those capabilities minimally and generically.
And for those of you diving into Swift, this mode of thinking is a precursor of the new hotness, Protocol-Oriented Programming.
Come back tomorrow for the thrilling conclusion of the SOLID series: the Dependency Inversion Principle.