Convention, Configuration, and Customization
The Scenario, and its Hairy Implications
Imagine you’re writing a class for some framework. You expect a healthy amount of people to use it; it’s not disposable. Designing even a simple class for this case can be painful, usually because your design motivations conflict with one another. Let’s approach these from the viewpoints of various people:
You. You, being the developer, want to write a useful class. You’re hoping to write it fairly quickly, and you don’t want to get stuck correcting mistakes and additions to it
The Casual User. He needs the functionality your class provides, but he doesn’t want to waste alot of time trying to use it. The functionality this provides is tangential to what he’s working on. As a developer, this user is highly predictable in how he’s using your class, and you want to minimize time and code spent for this individual.
The One-Off Guy. He wants the functionality of your class, but will need to do some configuration to get it to work like he wants to. He’s not stretching the definition of your class very much, so ideally the effort required should be trivial and obvious.
The Academic. He sees potential in your class to perform something that you’re not likely to anticipate. He’ll probably want to subclass your class, or plug in a subclassed component that your class uses, to get what he wants.
These three individuals all stress different parts of the system. The casual user loves convention; he is, by definition, conventional. Catering to him will make your class seem monolithic. Favoring the one-off guy instead will stress the configuration of your class. His interests will involve indirect access of some small piece of your system.
These two individuals don’t usually clash. Adding configuration to help the one-off guy usually means providing a default for the casual. In many ways, they’re the same person: Slight variance in the design and defaults of the class will make casual users have to be one-off guys and vice versa. If you can be aware of this balance, you can tune your defaults to favor the majority.
Dealing with Academics
Academics are fringe-cases. They may be interested in pieces of your system that aren’t typically touched by other users. For example, they may not be interested in the conventional use of your system, but the workflow that your class provides. A classic example would be a socket stream: he may want to use the interface that the stream provides, but write it to use an entirely different medium, such as accessing a file locally.
The academic may seem like a trivial concern, since they’re heavily outnumbered by the first two. However, academics stress the design of your class directly. Adding file I/O directly to the stream class makes socket-specific functionality senseless. In the face of this, you have a few options:
- Do nothing. You may choose to rely on the intelligence of your users to ignore socket-specific functionality if they’re dealing with files. While easiest for the developer, it’s rude to make users have to guess how your class should work, especially when your interface isn’t consistent.
- Retrofit the Conventional Case. You may be able to get away with fitting the new functionality into existing methods. In this case, a file stream is “connecting” to a file like a socket stream would connect to a wayward socket. This makes the interface seem consistent, but it really isn’t. You’re still relying on the fact that users will use your class conventionally, and you’ve actually done them a disservice by hiding the true intentions of your class.
- Subclass! What the academic seems to want is a new class that uses the functionality of the parent while extending it to his own needs. Subclassing clearly indicates there is a specialization of functionality present, and both options are clear on their own. This seems like the safest solution, but it has hidden dangers in how the breakdown is performed. You can’t just subclass your socket stream, since doing so leaves you with the same consequences as the first two options (Which one you’re left with depends on details of your implementation). While you have a separate class that works with files, you’re still left with a ambiguous interface that people are stuck using.
Now, this is solvable too. If you remove socket-specific stuff and place it in its own subclass, and make the original stream abstract, it’s pretty obvious what you’re doing.
The Problems of Subclassing
The problem I see with this is proliferation. A FileStream and a SocketStream class may themselves be extended to provide specialized functionality. If they are, then the subclasser must decide which type of stream he wishes to specialize. If he tries to do the Right Thing and be as flexible as possible, he must subclass every permutation of the Stream class. So now you have Stream, FileStream, SocketStream, SpecializedFileStream, SpecializedSocketStream. You’re also forced to implement the full interface of Stream (Even if that interface just throws exceptions) since you’re masquerading as a stream.
Wrapping the streams through composition cleans up the hierarchy. You’d have instead your base Stream classes, and your specializer that takes a stream as an argument to its constructor. Composition is a clean alternative to subclassing that doesn’t muck up the class hierarchy. As a rule, flat class hierarchies are much better than deep ones. The problem here is that it’s difficult to justify not subclassing. After all, your Specializer class is essentially a stream. The only difference is that it’s not a stream like the original stream is.
Refactor? The problem isn’t solved by composition or inheritance because the original class is ill-defined and should be refactored. We organized the functionality of the stream into one class because it makes semantic sense to do so: We connect to the socket, send and receive data, then close it. File I/O involves opening a file, reading and writing data, then closing it. Our problems arise because we have two responsibilities, both potentially flexible, in the same class. When this happens, you’ll have this problem of class proliferation. Composition masks the problem, but inhibits the benefits of the class hierarchy.
What we should do is separate the stream class into its two natural components: The connection, and the encoding. We’ll steal a UNIX tradition and treat files and sockets as fundamentally the same thing. This probably isn’t that big a jump for anyone. The strength here is separating the management of the connection from the encoding of data. Instead of making a Stream class for every type of connection, we separate them both entirely into Encoders and Locations. A stream is a composition of these two. A file and a socket are both subclasses of Location, and a specializer and our original Stream class are both encoders. A stream is, for the most part, a convenience class that is given these two components and manages the events of both.
Why Good Decomposition Is Better
One could probably argue that this solution is overly complex. After all the work, we’ll have the Encoder, Location interfaces, and the Stream class. Two classes implement Location, File and Socket. On top of that, we’ll have our DefaultEncoder and SpecializedEncoder to solve the original problem. That’s five classes and 2 interfaces. That’s more than any other solution. However, hear me out: Classes that are decomposed well are better than fewer classes that are decomposed poorly. We found and separated the two responsibilities that were causing us problems earlier, and now we’re left with two hierarchys that are both very specific in their purpose. As a consequence, I’ll imagine they’re very easy to test. Problems with connection management are isolated inside the Location class, and problems during encoding are isolated within the other. Both classes are also extensible to add new methods of encoding or destinations to which to send.
Even further, there is no coupling between the two class hierarchies. While we’ve constructed them to use them together, there’s nothing keeping us from using these two hierarchies elsewhere. The fact that they are separate encourages this possbility. Sensible factory methods (presumably in Stream) will allow the conventional user to use our Stream uninhibited by the newfound flexibilty that we’ve provided him. The logical decomposition of these responsibilities allows the Academic to extend this class with ease. A set of preconstructed locations and encodings will allow the one-off user easy configuration of the stream.
Why OOP Matters
I should stress one final point: The goal of OOP is not code reuse. We benefit from code reuse when we do good OOP, but reusing code does not mean we are practicing good design. The real goal of OOP is to allow decomposition of responsibility in a fashion that closely resembles how we work and think. This contrasts with functional programming, where decomposition is done along the preference of the machine. The original design of the stream class resembles this – we hardcoded the semantics of the conventional use in the design of the class, instead of hardcoding the responsibility of each concept into the classes.
This probably isn’t very clear – a functional approach to decomposition would yield classes that mirror data structures. The Stream class is defined by how we’d use it, and its functions expose that conventional use. This works as long as we use the class conventionally. However, when we move beyond that, our class becomes a barrier to progress. It’s difficult to extend a class that exposes this kind of interface. A good responsibility-driven approach to decomposition will yield classes that allow extension and facilitate easy maintenance and testing.
Leave a Comment