Not all code breakage is bad
Russ feels that a slavish devotion to interfaces is over-engineering. Darren weighs in on the side of interfaces, but seems to mostly be advocating the naming convention. Cedric advocates the consistent use of factories when programming with interfaces.
I like interfaces a lot. Raised in the cold, winter-y days of structured programming, I am like a person who lived through the Depression who still picks up every penny on the street. I think Cedric mischaracterizes interfaces with these words: "...interfaces break easily. If you add a method to an interface, a lot of your code (and maybe that of others') is going to break. The Microsoft way of approaching this problem...."
Not all code breakage is bad and when an interface changes, I would argue that what happens is not a problem at all. There are times when code should not compile, and this is one of them: when you change an interface, you are changing the contract of each and every object that implements that interface. The concept of Design by Contract is subtle, but this is one of the more obvious parts of the metaphor: if you are a general contractor and negotiate a change with the homeowner about the way things are done, of course you must work with all your subcontractors to ensure that they are okay with the changes before going forward. This may be problematic (someone doesn't want to do things the new way), but the fact that it comes to light and you have to address it is not a problem.
This, to me, is one of the chief benefits of strong typing. You can get around typing with factories and various tricks up to and including reflection but... if you don't generally agree that protection against misuse is worthwhile, why use a typed language at all?
And, to raise the rant factor and introduce my own pet peeve about how interfaces are used (I'm sure that Russ, Darrin, and Cedric aren't guilty of what I'm talking about but some people -- you know who you are -- are), if you need to extend an interface and that change is not logically valid for each and every type that implements that interface, it's a very good indication that your interface is under-abstracted; that is, that you've placed into what is supposed to be the essential abstraction some characteristics that are not abstract. For instance, in an abstraction of an Employee you may have placed an OvertimeRate( ) method, a method inappropriate to Manager types.
If you discover such a conflict, you should not conclude "Dang these brittle interfaces!" but rather that you need another level of abstraction. For instance, perhaps you should have an Employee interface from which Exempt and Nonexempt subtypes descend. Place the OvertimeRate( ) in the Nonexempt subtype. While one should avoid deep hierarchies, generally when you discover a mismatch such as this, you're discovering the first mismatch, not the only mismatch.
This is why I hate the NotImplementedException and all its evil siblings -- it provides a way to "unbreak" your code temporarily, but you do so at the cost of increasing confusion. System.IO.Stream is an abstract class that has an example of such confusion: it defines a Seek( ) operation, which sets the current position according to its parameters. But seeking is not appropriate to the Stream abstraction! Network streams cannot seek, cryptographic streams cannot seek: the word "Stream" is a great class name because it properly implies that you may be "standing on the shore" watching something flow by you.
If you have code such as this: MyClass{ Stream str; MyClass(Context ctxt){ str = StreamFactory.Create(ctxt); }
void UseStream(){ str.Seek(100, SeekOrigin.Begin); } }
You cannot reliably detect via unit testing that if the Context ctxt results in StreamFactory.Create( ) generates a NetworkStream, then UseStream( ) will break. Only exhaustive regression testing, which is a combinatorial impossibility for non-small programs, will detect the problem (I believe that technology such as Parasoft's jTest, which uses algorithmic evaluation on code paths, might also be able to detect this type of problem, but I'm not sure and, for the moment, jTest is a JVM-only solution).
So if Stream should not have a Seek( ) method, or more generally, an interface needs to be extended, how should it be done? Here's the worst way, which unfortunately is not unknown (you know who you are): interface IEmployee{ void Hire(); void Fire(); } interface IEmployee2 : IEmployee{ Rate OvertimeRate(); }
This is just poor object-oriented design: the obvious problem being that if the interface needs to be extended again (IEmployee3, 4, 5, 6, 7, 8...), you're back to square one. Then, rather than face up to the problem, you see the introduction of methods such as GetCapabilities( ), one of the more profoundly non-object-oriented idioms one sees.
To extend an interface with a capability that is not universal, one should exploit the fact that a .NET object can inherit from only one base class, but it can implement many interfaces. Thus, you could create an interface such as: interface Seekable{ long Seek(long offset, SeekOrigin origin); }
and then a FileStream, for which seeking is appropriate, could be declared as: class FileStream : Stream, Seekable{ ... etc ... }
or you can combine them into a single interface: //Just combine the two i'faces, no new methods interface SeekableStream : Stream, Seekable { } class FileStream : SeekableStream { ... etc ... }
The BCL's System.Collections namespace demonstrates this type of good design: the Array class, for instance, is declared as: abstract class Array : ICloneable, IList, ICollection, IEnumerable{ ... etc ... }
And, oh yeah, interfaces allow you to implement the "Mock Object" pattern, in which you stub out the (complex and perhaps buggy) implementation with code that (simply and directly) fulfills the interface contract, thus allowing your tests to be validated on both sides. Quis custodiet ipsos custodes? and all that.
12:36:45 PM
|