Interfaces and subtyping

The word “interface” has more than one meaning. In the context of computer science generally, it refers to a description or specification of the way that program modules interact with one another, or the way that client code interacts with a program module. In the context of Java, “interface” refers to a language feature that allows programmers to create interfaces for classes, which are a module mechanism. An interface describes a set of public methods that must be provided by the class; when using the class via the interface; only these public methods can be used. The interface includes the name and signature of the methods; it's also a good place to write specifications for those methods.

Example

For example, suppose that we want to implement the 2048 game, a popular game for cell phones consisting of movable numbered tiles in a 4×4 grid. We want to keep track of the state of the game at any given point and allow the user to make moves in the game.

Let's define an interface to the game. We'll call it Board:

Board.java

Notice that this interface says nothing about how the various methods are implemented or about how the game information is represented inside objects. The interface doesn't say that the methods are public, because interface methods are always public.

The method move() uses a feature we haven't seen yet: enum (enumeration) types. The type Direction is declared as follows:

Direction.java

This type has exactly the four specified values UP, DOWN, LEFT, and RIGHT. Of course, we could encode the four directions using another type such as numbers or characters, but when used appropriately, using an enum makes code easier to read and less error-prone.

Smoe implementations

The Board interface can be implemented by defining a class that is declared to implement it using the implements keyword. Now we must make some implementation choices, such as how to represent the tiles of the game. One obvious representation is as a two-dimensional array of integers, with 0 representing blank tiles:

ABoard.java

This code has a few notable aspects. First, notice that each method declared in the interface must be implemented as a public method in the class. The class has some other components as well. For example, the instance variables tiles and score are declared private to hide them from clients. A class can also add new methods not declared in the interface, such as the ABoard() constructor.

We can define an interface (in the general sense) to a class either by declaring which of its methods are public, or by declaring a Java interface as above. One advantage of using the interface mechanism is that it allows multiple implementations of the interface.

The code also shows how to use a switch statement to execute different code based on the value of an expression. These statements are particularly useful in combination with enum types, because they make it easy to check that all possible values have been covered by some case. Notice that each case should be terminated by a break statement; Java unfortunately inherits the C semantics of switch, in which each case falls through into the code of the next case unless some statement, such as a break or return, stops the execution of the switch.

For example, here is a sketch of a second implementation of the Board in which the Board keeps track of the positions of the tiles rather than what tile is located at each position.

TBoard.java

The method tile() becomes more complicated because the code has to search for a title at the specified location. When it finds such a tile, the method returns immediately. If no matching tile is found, it returns zero.

And here is a third implementation which the tiles are represented as characters in a string, aiming for efficient use of the computer's memory:

SBoard.java

As we can see, even a simple abstraction like Board has a variety of implementations, with different tradeoffs in simplicity of code, running time, and space used. If the rest of the code accessing the board through the interface Board, the code has loose coupling. We can easily change to another implementation of Board if we gain better understanding of these tradeoffs. For example, a search algorithm to suggest the next move might need to allocate a large number of board objects; in that case, we would likely prefer the space-efficient implementation SBoard.

Using objects at type Board

We now have three implementations of Board. The important new capability that is added is that client code can be written that does not care which implementation is being used. For example, suppose we want to write a method that displays a game board. That code can be written so it works on objects from either implementation:

display.java

The client code can use display() on either kind of object, because both ABoard and SBoard are implementations of Board.

As long as ABoard and SBoard are implemented correctly, the code of display() cannot tell which implementation is being used. That is why display() can be used on objects of either kind. The abstraction barrier imposed by the Board interface allows the programmer to start with one implementation and later to replace it with a different implementation, with confidence that it won't break the program.

When compiling the method call p.tile(r, c) in the method display(Board b), the compiler does not know which method code it is going to run. The variable p can refer to either an ABoard object or an SBoard object (or maybe some other implementation of Board altogether). In general, the compiler cannot figure out ahead of time which one it will be. The call to p.tile(r, c) must find the correct method implementation at run time. It does this by checking the dynamic type (aka run-time type) of the object p. The dynamic or run-time type of an object is the class that was named when the object was created via new. This is called dynamic dispatch.

Classes vs. types

In Java programs, each object has a dynamic or run-time type. The dynamic type is the name of the class that was specified in the new statement when the object was created. The dynamic type is associated with the object at birth and stays with it unchanged throughout its lifetime.

On the other hand, expressions in the Java language have static types that they obtain by declaration of variables. The static type of a variable is the type given to it in its declaration. (Note that this usage of the term static is not related to the Java keyword static used to declared static fields and methods.)

Both interface names like Board and class names like ABoard and SBoard may be used as static types in Java programs, but only class names may be used as dynamic types. The dynamic type of an object can never be Board; it is always the class of the object that was provided to the new operator when the object was created.

For example, consider the following code. The first line creates an object with dynamic type ABoard and assigns it to a variable of static type ABoard. The second line assigns the same object to a variable of static type Board. This is allowed because ABoard is an implementation of Board. The third line tries to use the interface Board with the operator new. This is illegal, because Board is not a class; the compiler doesn't know what implementation to use.

ABoard a = new ABoard(); // ok
Board b = a; // ok
Board p = new Board();  // illegal

This shows that both class and interface names may be used as static types in Java, but only class names may be used as dynamic types.

Subtyping and subtype polymorphism

Because ABoard implements Board, an expression of static type ABoard can always be used where a Board is expected. This is an example of a subtype relationship between two types. We write ABoard <: Board to mean that the type ABoard is a subtype of the type Board. (Sometimes you will see this written as ABoardBoard.) Since an SBoard can also be used wherever a Board is expected, the subtype relationship SBoard <: Board also holds.

The subtyping relationships among the various types form a type hierarchy (or subtype hierarchy), an example of which is shown in this figure:

In addition to the subtype relationships we've discussed so far, this diagram shows a few more, such as Board <: Object. The subtyping relation is reflexive (\(T<:T\) for all types T) and is also transitive (if \(T<:U\) and \(U<:V\), then \(T<:V\)).) Thus by transitivity, we also have ABoard <: Object. We can also notice that array types like int[] are subtypes of Object. Standing alone in the diagram are the primitive types (int, boolean, char, ...). These types are not subtypes of any other type.

In the diagram above, every type has at most one parent, making this diagram a collection of trees (a forest). However, a class is allowed to implement more than one interface, as in the following definition:

class TBoard implements Board, Collection<Integer> {
    ...
}

so in general the subtype diagram is a graph in which some nodes have more than one ancestor.

If static type checking is doing its job, then the following invariant should always hold throughout execution is:

The dynamic type of the value of an expression is always a subtype of the static type of the expression.

In particular, whenever a variable references an object, the dynamic type of the object is a subtype of the declared type of the variable.

However, Java has some unsafe features that can break this invariant. These features should be avoided when possible!

Subtyping vs. coercion

Java lets us write the following declaration, which might make us think incorrectly that int <: Object:

Object x = 2;

Although this looks like subtyping, actually Java is automatically inserting a coercion (a conversion) to make the types work. The variable x is not being assigned the value appearing on the right-hand side, but rather an object of type Integer that is created automatically. The declaration is syntactic sugar for this one: Object x = Integer.valueOf(2). This mechanism is known as autoboxing.

Casting

With subtyping, a given value can be treated as though it has more than one type. (When a value can have more than one type, it is called polymorphism, and the kind of polymorphism we get with subtyping is called subtype polymorphism.)

Java's cast operator can be used to control the compile-time type at which a value is used. A cast does not affect the run-time type of the value, only the type which the compiler will treat the value as belonging to. As an example, suppose we have a variable a of type ABoard. To force it to be treated as a Board, we write a cast expression: (Board) a. Since ABoard is a subtype of Board, this cast will always succeed at run time: it is type-safe. We refer to this kind of cast as an upcast because it shifts the type upward in the type hierarchy. In fact, it is not normally necessary to write a cast operation, since the language will automatically treat a subtype value as belonging to the supertype when necessary. Occasionally it is useful to write an explicit upcast when debugging.

It is also possible to cast downward in the subtype hierarchy. This gives us a downcast, an unsafe cast that can fail at run time. For example, consider this code:

ABoard a = new ABoard();
Board p = a;
ABoard a2 = (ABoard) p;

Here, all three variables refer to the same underlying ABoard object. The downcast to a2 succeeds at run time because the dynamic type of the object is a subtype of the type it is being cast to. On the other hand, if we had reassigned the variable p to refer to an SBoard object, the downcast would fail with a ClassCastException.

A downcast should ordinarily be used only when it is guaranteed to succeed, so a failed downcast generally means that the programmer has made a mistake. It is possible to test in advance whether a downcast will succeed by using the instanceof operator to test the run-time type of the object.

Board p = ...;
if (p instanceof ABoard) {
    ABoard a2 = (ABoard) p;
    ...
}

In recent versions of Java, it is possible to avoid writing an explicit cast by introducing a variable in the instanceof expression itself:

Board p = ...;
if (p instanceof ABoard a2) {
    ...
}

Downcasting and instanceof are sometimes necessary, but excessive use of these operations is a danger sign that your code is not well designed. If you find yourself using them a lot, it is worth thinking about whether there is a way to redesign your code to avoid them.

Upcasting to a supertype can sometimes be used as a form of information hiding. Upcasting to a supertype hides operations not present in the supertype, although not as completely as using private visibility. The corresponding downcast can be used to restore access to these operations if desired, so the abstraction barrier is porous. In general, it is good form to work with the narrowest interface that still allows the desired operations to be performed. Often this means accessing objects only through the interfaces they implement rather than through their classes.

Interface subtyping

An interface can extend another interface, creating a subtyping relationship. For example, we might want a board interface that adds a method for automatically solving the board. If we declare the interface to extend Board, it will be a subtype:

interface SolvableBoard extends Board {
    Move[] solve();
}

The definition of SolvableBoard makes it a subtype of Board: that is, SolvableBoard <: Board, and SolvableBoard has all of the methods of Board in addition to the new solve() method that it adds.

The ability to extend interfaces gives even more control over how much of a class is exposed to client code. Clients that do not need the solve() method can be given the Board view of the board, avoiding unnecessarily coupling of the implementing class and its clients. However, the possibility of downcasting means that the abstraction barrier is more permeable when interfaces are in this way: we get a form of information hiding but not full encapsulation.

It is worth noting that subtype relationships need to be explicitly declared in Java. If we declared another separate interface Q that had all the same methods as Board, plus some additional ones, but did not declare Q to extend Board, it might appear harmless to allow Q <: Board. Unlike some languages, Java does not allow this structural subtype relationships, but only nominal subtyping, in which subtypes are explicitly declared. There are two reasons for this design: first, structural subtyping is hard to implement efficiently, and second, just because two methods have the same name and signature does not mean that they actually mean the same thing.

Conformance

For one type to be a subtype of another, the methods of the first type must have signatures that conform to the signatures of those in the supertype. The conformance requirement has implications for the types and visibility of methods.

For conformance, Java requires that the types of method formal parameters in the subtype be exactly the same as the types of the corresponding formal parameters in the supertype. However, the return type of a method in the subtype may be a subtype of the corresponding return type in the supertype.

In addition, the visibility of instance methods in the subclass is not allowed to be any less than the visibility in the superclass. Visibility modifiers can be ordered as follows:

private < (package) < protected < public

To see why, suppose that a method were declared public in the supertype but was private in the subtype. If we had a reference to an object of the subtype, the method would be inaccessible, but would become accessible after an upcast to the supertype. That private visibility modifier would be misleading and useless, so it is disallowed in Java. On the other hand, it makes sense and is legal to have a method that becomes more accessible in the subtype.

Behavioral subtyping

Conformance between subtypes and supertypes extends beyond merely the signatures of methods. Subtyping should govern the behavior of the subtype as well. If it is safe to use a subtype \(S\) of type \(T\) wherever \(T\) is expected, then the specification of the subtype operations must also conform to the specification of the \(T\) operations; otherwise, client code expecting the behavior specified by \(T\) will be unpleasantly surprised by the behavior of \(S\).

In other words, subtyping used correctly is behavioral subtyping; it ensures not only that types are compatible but also that behavior is. The principle that objects of a subtype can always be safely substituted for supertypes without surprising clients of those supertypes is called the Liskov substitution principle, after Barbara Liskov.

With respect to the components of the specification, the implication of this conformance is that the postcondition of an \(S\) operations must be at least as strong as (that is, imply) the postcondition of the corresponding \(T\) operation. Conversely, the caller using the \(S\) object as if it is a \(T\) would call the operations of the object while only satisfying the preconditions specified by \(T\). Therefore, the preconditions of \(S\) must be implied by the preconditions of \(T\).

Factory methods

The use of interfaces allows us to write code that is independent of the choice of implementation—except when the objects are actually created. At some point an actual implementing class must be provided to new. This might seem to break the principle of separating interfaces from implementations.

When this stronger separation is desired, a common solution is to use factory methods to build objects. A factory method is one that creates objects, typically with an interface type. For example, we could (in some class) declare a method that creates Board objects:

static Board createBoard();

This method can be used to create Board objects without committing to using any particular implementation of the Board interface. A factory method need not be static; it may also be a method of some object that determines which implementation to create.

Summary

Java interfaces are a useful mechanism for writing code in which module clients and module implementations are strongly separated. In addition, Java's support for subtyping means that it is possible to write multiple implementations of the same interface. These different implementations can even coexist and operate within the same program. This is a valuable feature for building large software systems.