Good software engineering is about dividing code into modules that separate concerns and localize them within modules. These modules then interact via interfaces that provide abstraction barriers supporting local reasoning. Let's look more closely at the problem of designing good interfaces.
Interfaces exist at the boundaries of the modular decomposition. An interface will be most effective when it has the following three properties:
It provides a strong abstraction barrier between modules.
The interface should be as narrow as possible while providing the functionality needed by clients.
The interface should be clearly specified.
We've already discussed abstraction earlier; our goal here is to examine the second two attributes of a good interface.
By a narrow interface, we mean an interface that exposes few operations or other potential dependencies between modules. The opposite of a narrow interface is a wide interface, one that exposes many operations or potential dependencies between modules.
The choice between a narrow interface and a wide interface is not always obvious, because there are benefits to each approach. We can compare and contrast the philosophies:
narrow | wide |
---|---|
few operations, limited functionality for clients to use | many operations, much functionality available for clients |
easy to extend, maintain, reimplement | hard to extend, maintain, reimplement |
loose coupling: clients less likely to be disrupted by changes | tight coupling: clients more likely to be disrupted by changes |
In principle, it's possible to make the interface so narrow that it interferes with clients getting their job done in an efficient and straightforward way. But this is not the usual mistake of software designers; more typically, they make interfaces too wide, leading to software that is hard to maintain, extend, and reimplement without breaking client code.
The rule of thumb, then, is that interfaces should be made only as wide as necessary for efficient client code to be written in a straighforward way.
Often when a narrow interface feels awkward to use, it is possible to address this problem by writing convenience functions that are implemented outside the module, using only the narrow interface that the module provides. Clients can then use the convenience functions to avoid code duplication, but without widening the interface and thereby introducing new dependencies between modules.
When a module's interface is wide and there doesn't seem to be a way to avoid this by writing convenience functions or by separating the module into multiple modules, it is often a sign that you haven't managed to separate program concerns into different modules. When concerns are not sufficiently separated, there are inherently too many interactions between the different parts of the program to define a narrow interface between the components.
Once we've decided what operations and other functionality belongs in an interface, what documentation should be added? An important principle can guide us here: documentation is code, code for humans to run. The documentation is a human-readable abstraction of the code that (depending on which documentation we're talking about) supports programmers writing client code or maintaining the implementation.
The most important function of documentation is to provide specifications of what code does. Specifications are particularly useful for supporting client code, but also help implementers.
According to the principle that documentation is code, the best place for documentation is with the code itself, in the form of program comments. When this is not practical, code documentation should be linked from code so it can be easily accessed. Javadoc documentation is a good example of this principle in action: the documentation is extracted from the code, so it cannot be separated from it.
Documenting code separately in separate documents may be appealing, but the more separate documentation is from the code it describes, the more it tends to diverge from the code. The more it diverges from the code, the less useful it becomes and the less programmers rely on (or look at) the documentation. Both documentation and code require programmers' attentation to stay fresh!
Too often, programmers write their documentation at the end of the design and implmentation process, as a kind of afterthought. The workflow of design, coding, debugging, and documenting tends to look like the figure on the left. A lot of time is spent debugging because the design is not worked out carefully enough. In general, spending a lot of time debugging is a sign you haven't worked hard enough on the design.
Documenting the design early, as shown in the figure on the right, helps you work bugs out of your design and to understand your design better. Typically, this makes both coding and debugging faster. Sometimes your code just works on the first try!
The moral is that documentation is not some kind of esthetic decoration for your code. It is a tool that can improve your designs and save you time in the long run.
Know your audience: Tell your reader the things they need to know in a way they can understand. But your reader's attention is precious. Don't waste space on obvious, boring things. Filler distracts from what's important. Avoid “filler” comments that don't add any value and distract from what's important, such as:
x = x + 1 // add one to x
Be coherent: avoid interruptions. Better to write one clear explanation than to intersperse explanatory comments throughout the code.
Respect the abstraction barrier: write specifications in terms the reader/client can understand without knowing the implementation.
/** A polynomial over a single variable x * Example: 2 + 3x - 5.3x3 */ interface Poly { ... }
Well-designed methods usually fall into one of three categories: creators (factory methods), observers (also known as queries), and mutators (also known as commands).
Abstractions that do not have any mutators that can change their state, such as
String
and Integer
, are immutable
abstractions. Abstractions with mutators are mutable. Both kinds of abstractions
have their uses. The advantage of immutable abstractions is that their objects can be
shared freely by different code modules.
The useful principle of command-query separation can guide how we design methods. The principle says that a given operation should fall into one of these three categories, rather than multiple categories. This makes the interface easier to use. For example, you don't want to be forced to have side effects in order to check the state of an object.
Considering each of the categories in turn, we might come up with operations like the following:
zero
: create the zero polynomialmonomial
: create a polynomial with form axbfromArray
: create a polynomial with coefficients defined by an array of double
s.derivative
: create a polynomial that is the derivative of the given polynomial (also an observer).plus
: create a polynomial that is the sum of two polynomials.minus
: create a polynomial that is the difference of two polynomials.degree
: report the maximum exponent with non-zero degree.coefficient
: report one coefficient of the polynomialevaluate
: evaluate the polynomial at a given value for xtoString
: generate a string representation of the polynomialequals
: report equality of a polynomial with another object.clear
: set this to the zero polynomial.add
: add another polynomial to this.Notice that we have not discussed how we are going to implement this polynomial abstraction. That is a good thing. We want to expose the operations that clients are going to need. We might have to make sacrifices because some operations are hard or expensive to implement, but that should be done only after thinking about the ideal interface.
We want to avoid adding operations that we can implement efficiently using
existing operations. For example, we might be tempted to have an operation that
finds zeros of the polynomial. However, such an operation can probably be
implemented efficiently using either factoring (for low-degree polynomials) or
numerically via Newton's method, using evaluate
and
derivative
.
Standard operations. Some operations are so useful that it is worth thinking about whether you will want them for every data abstraction you define:
equals. Testing whether two values are equal is fundamental to mathematics and to programming.
toString. It is very useful for debugging to be able to
print a string representation of an object. Ideally, two objects should have
equal answers for toString()
if and only if they are equal
according to equals
.
hashcode. If you want to use an object as key in a hash table,
it needs to have a hashcode()
method. Two objects that are equal
according to equals()
must have the same hashcode.
Two application-generated objects that are not equal according to
equals()
should have different hash codes with high probability.
a copy constructor. For mutable abstractions (that is, abstractions that have mutators), it is often useful to make a copy of an existing mutable object. Until mutators are used on either the copy or the original, the two should be indistinguishable. There should be no way to affect the original by mutating the copy, or vice versa. Among other uses, copies are handy for building test cases.
The problem with getters and setters. Some people reflexively add getters and
setters to classes they write. Getters are observer methods that merely
report the
contents of fields, and setters are mutators that simply
change the values of fields
to an arbitrary value. Both getters and setters can undermine abstraction.
Setters are especially pernicious because they allow any client to change the
contents of the object in an arbitrary way. You might as well make the field
public. Often it does makes sense to have
operations that are implemented by reporting the contents of a field, when
the field contains information that makes sense to the client. For example,
we might implement polynomials with a field that keeps track of the degree
of the polynomial. The degree
method might just report the
contents of that field, e.g.
int degree() { return deg; }
However, this should not be thought of as a getter. As far as the client knows
we might instead have kept track of all the coefficients in an array and
implemented degree
by reporting the array's length:
int degree() { return coefficients.length - 1; }
A second problem with getters is that they may lead to representation exposure, in which mutable state from inside the abstraction is made available in an unconstrained way to clients. For example, suppose we stored the coefficients of the polynomial as an array in the instance variable coefficients, and provided a getter:
double[] coefficients; double[] getCoefficients() { return coefficients; }
Now a client can get access to the internal array of coefficients and change the polynomial in an unconstrained way, possibly breaking its class invariant.
So think before adding getters and think twice before adding setters to an interface.
Another operation that is overrated is the default constructor. It is the job of constructors to create a properly initialized object. Unfortunately, one often sees programmers using a default constructor to create the object, then initializing the fields using setters. This style of programming is a strong sign that the abstraction is poorly designed. A default constructor does make sense for some abstractions, typically mutable ones whose state can be changed using additional calls to mutators.
We have a rough idea of the operations we want to support. But before we start implementing, we should write clear, precise specifications so we know when we've implemented the operations correctly.
For each method, we need to define a signature that gives the types of the parameters and the return value, and the possible exceptions. And we need to define a specification (spec) that describes what the client needs to and is allowed to know about the behavior of the method.
For example, we might write a spec for the degree method as follows:
/** Returns: the degree of the polynomial, the largest exponent with a non-zero * coefficient. */ int degree();
To help us construct a good spec, it is useful to think of the spec as being composed of various clauses. These cover different things the client needs to know about:
The key to writing good specs is to think of the spec as a contract between the client and the implementer. Like a legal contract, its main goal is to help everyone figure out who to blame when things go wrong. This is very important for successful software engineering, especially in a large team.
Javadoc doesn't completely support the clauses we have been describing thus far, though there are efforts to make it do so. If you want to use Javadoc to generate HTML documentation, you will need to adapt this documentation strategy accordingly. The key is not that you need to have explicit clauses, but that you should know for each thing you write in the comment which clause it belongs to.
Now we're ready to implement our specifications. And we'll want to write some documentation of that implementation...
Our goal for documenting the implementation is to support implementation and maintenance by describing implementation methods and even abstracting them. Therefore, private methods have the same clauses as public ones.
We also want to write specifications for fields where it is not obvious. Two kinds of information are essential:
Example of field specifications including rep invariants:
class LinkedList{ /** First node. May be null when list is empty. */ Node first; /** The number of nodes in the list. size ≥ 0. */ int size; /** Last node. last.next = null. May be null when list is empty. */ Node last; ...
When we write methods like LinkedList.add
, these invariants may be temporarily
broken:
/** Append x to the end of the list. */ void add(T x) { // Algorithm: Create a new node. Make it the new head of the list // if the list is empty. Otherwise attach it to "last". if (first == null) { first = last = new Node(x, null); // size invariant broken here } else { last = last.next = new Node(x, null); // size invariant broken } size++; // invariant restored here }
The specializer uses the code as a superclass, with the goal of producing a subclass that reuses superclass functionality. The specializer may override the behavior of existing methods that have public or protected visibility. When we write a specification for a method that can be overridden, there are really two separate goals:
For example, consider an implementation of an extensible chess game.
We might define a class Piece
that gives an interface for
manipulating pieces, with subclasses such as King
that specialize
it:
/** A chess piece */ class Piece { /** Spec: Iterates over all the legal moves for this piece. * Overridable: uses legalDests() to construct * the legal moves. */ public Iterator<Move> legalMoves() { ... } /** Iterates over all destinations this piece can move to in an * ordinary move, including captures. */ abstract protected Iterator<Location> legalDests(); }
Given this specification for Piece
, we can implement a piece such as
a king with extra castling moves that are not computed from the legal
destinations as with other pieces:
class King extends Piece { public Iterator<Move> legalMoves() { Collection<Move> moves = ...; for (Move m : super.legalMoves()) { // rely on superclass overridable behavior moves.add(m); } m.add(new CastleMove(...)); return moves.iterator(); } /** Overridable behavior: iterate over the squares adjacent * to the current location. */ public Iterator<Location> legalDestination() { ... } }
Note that King.legalMoves
obeys the specification of
Piece.legalMoves
, but overrides its behavior. Because the
implementer of Piece
defined overridable behavior of the method, the
implementer of King
can rely on this behavior in implementing their
own method, without needing to read the details of the implementation
of Piece.legalMoves
.
Writing classes that can be inherited and reused effectively requires keeping these two different kinds of specification separate.