Java Generics and API Extensions
Java's generics, combined with covariant return types, gives us a clean way to manage API extensions.
1. Problem
Let's say we have a very simple file system API, here in the form of a factory and an interface:
interface FileNode {
String getName ();
FileNode getParent ();
List<FileNode> getChildren ();
}
class FileNodeFactory {
FileNode getRoot ();
}
This is all well and good. But now we want to extend it with a network file system:
interface NetworkFileNode
extends FileNode {
String getName ();
String getHost ();
NetworkFileNode getParent ();
List<NetworkFileNode> getChildren ();
}
class NetworkFileNodeFactory {
NetworkFileNode getRoot ();
}
Since Java supports covariant return types, this is not a problem. Any client that starts with a NetworkFileNodeFactory
will obtain a NetworkFileNode
and can then remain in the Network*
set of classes.
The problem comes when we want to reuse functionality from an implementation of FileNode
or FileNodeFactory
. Let's say we have a FileNode
implementation that looks like this:
class FileNodeImpl
implements FileNode {
private final List<FileNode> children =
new ArrayList<FileNode> ();
public List<FileNode> getChildren () {
...
}
...
}
We wish to re-use the children
list and the getChildren()
method, but we can't do so with a straight subclassing:
class NetworkFileNodeImpl
extends FileNodeImpl
implements NetworkFileNode {
public String getHost () { ... }
...the rest is inherited...
...or is it?...
}
Since the inherited getChildren()
returns a list of FileNode
, it doesn't satisfy the NetworkFileNode
interface. Our first, very rough, solution might be to note that the children
list will only ever be populated by NetworkFileNode
children, and therefore we can do this:
List<NetworkFileNode> getChildren () {
return (List<NetworkFileNode>)
((Object) super.getChildren ());
}
We would then go on to do the same with all the other "inherited" methods. The end result works, but it is really ugly. For simple functionality such as this, when we really only pass on the calls to another class - the ArrayList
in this case - we end up having to write lots of ugly boilerplate in order to re-use clean boilerplate. This is actually making our code worse.
2. Solution
The problem comes from the fact that the FileNode
interface isn't complete. It doesn't encompass everything we want to do with it. In particular, it has no clean way for it to be extended; and this is one of the top things we want to do with it. Any methods returning or taking as a parameter a FileNode
should really refer to a type that is "the node type of this file system". So let's rewrite the interface a bit:
interface FileNode<NodeType> {
String getName ();
NodeType getParent ();
List<NodeType> getChildren ();
}
class FileNodeFactory<NodeType> {
NodeType getRoot ();
}
We're almost there. The last problem is that NodeType
by itself is devoid of meaning. One could declare a FileNode<Integer>
, and it would be valid. This is not what we want. Any extension of the FileNode
interface must at least be part of a tree of nodes that at least implement FileNode
. Therefore we add a constraint to the NodeType
, requiring it to extend FileNode
:
interface FileNode
<NodeType extends FileNode> {
String getName ();
NodeType getParent ();
List<NodeType> getChildren ();
}
class FileNodeFactory
<NodeType extends FileNode> {
NodeType getRoot ();
}
Now we are back in business. The NetworkFileNode
interface and its implementation becomes:
interface NetworkFileNode
<NodeType extends NetworkFileNode>
extends FileNode<NodeType> {
String getHost ();
}
class NetworkFileNodeImpl
<NodeType extends NetworkFileNode>
extends FileNodeImpl<NodeType>
implements NetworkFileNode<NodeType> {
public String getHost () { ... }
...the rest is inherited...
}
Two improvements: First, we don't have to redefine the methods in FileNode
, making the interface code more compact. Second, the inheritance works this time, ridding us of the ugliness of the previous attempt.
3. Next Problem
Before we pat ourselves on the back, there's one more wart we need to get rid of from the API client's point of view: The type parameter. Consider someone using our API:
FileNode fileNode = ...;
for (FileNode child :
fileNode.getChildren ()) {
...
}
That won't work. Since no type parameter is specified, the client gets the raw FileNode
. The raw FileNode
doesn't return a List<FileNode>
, it returns a List
. Our API client must write:
// An interface so nice
// you have to write it twice!
FileNode<FileNode> fileNode = ...;
for (FileNode child :
fileNode.getChildren ()) {
...
}
This isn't critical, but it sure is ugly. It also makes it difficult to understand just what the code means unless you know what the API looks like. For example, one could ask what a FileNode<NetworkFileNode>
is. Is it just another way of saying NetworkFileNode<NetworkFileNode>
? Is it a FileNode
that got lost and ended up in a NetworkFileNode
tree? Is that even possible? What combinations of node type and node type parameter do we allow in client programs?
4. Next Solution
The solution here is to have two sets of interfaces: One is for extension, and the other set are the "rock bottom" interfaces used by clients. Here we use FileSystemNode
to refer to a node in any file system, and FileNode
to refer to an interface for a file in a local file system:
/* Extend this one */
interface FileSystemNode
<NodeType extends FileSystemNode> {
String getName ();
NodeType getParent ();
List<NodeType> getChildren ();
}
/* Not for extension */
interface FileNode
extends FileSystemNode<FileNode> {
}
The naming can be a bit contrived, as this example shows, but the idea is straightforward: We have two hierarchies. One is a hierarchy of interfaces. Extension here means that you create a new API based on a previous one. The non-extensible leaves of this tree are the interfaces that a client will use, and those interfaces are final
- although Java doesn't support the concept of a final
interface, they are treated as if they were. The second hierarchy are of implementations. These can inherit as needed to any level.
5. Design Notes
The solution above leaves us with one big question: How do we design the interface hierarchy, and in particular which interfaces do we consider final?
Let's go back to the beginning: We started with a "final" interface - FileNode
. Then we ran into problems when we tried to inherit functionality in the implementation class hierarchy. Then we solved this by redesigning the interface hierarchy.
The answer is then easy: The "final" interfaces correspond to the interfaces in our original design.