On this page:
3.1 Introduction
3.2 The problem and analysis
3.2.1 Assumptions
3.2.2 Designing the interface
3.2.2.1 How to design a method signature?
3.2.2.2 Adding two durations
3.2.2.3 Argument and return types
3.2.2.4 Methods for equality and comparison
3.2.2.5 The Duration interface
3.2.2.6 Documentation
3.3 Implementation
3.3.1 Constructor
3.3.2 Observer Methods
3.3.3 The plus method
3.3.4 Comparison and other methods
6.3

Lecture 3: How to Design Classes: A Primer

Related files:
  Duration.java     HmsDuration.java     HmsDurationTest.java  

3.1 Introduction

This lecture designs a simple example from scratch. We start from the description of a problem, convert it into an interface specification, complete one implementation and test it. In the next lecture we add another implementation, and show how abstraction can be used to minimize code replication.

3.2 The problem and analysis

We wish to create a library to compute with time durations. A few examples of durations are:

Some operations that we would like to do with durations are:

3.2.1 Assumptions

The above description is general and vague. We probably cannot represent all conceivable durations. What is the expected precision of durations? Which units of time do we allow? Are there upper and lower bounds to durations? Is a duration of 7868 seconds “equal” to a duration of 2 hours, 11 minutes and 8 seconds?

Although these are not conceptually difficult questions, we need to make assumptions to actually represent them. Therefore we make the following assumptions about the durations that our library will support:

3.2.2 Designing the interface

An object is only as good as the operations it offers. Keeping this in mind, we start our analysis by first concentrating on the operations that durations should support. From the list above, we distill method signatures that ought to be offered by any object that represents a duration. This results in a Duration interface, shown in the section below. In the subsequent sections we elaborate on how it is designed.

3.2.2.1 How to design a method signature?

When we design method signatures in an interface, it is a good idea to imagine using the method assuming it already existed. In other words consider some examples.

Two of the methods are fairly simple: creating a formatted string from a duration and getting the duration in seconds. We can envision using them as follows:

Duration d = ...
String formattedDuration = d.asHms();
long secondsValue = d.inSeconds();

We keep the seconds value long to support the maximum integral duration possible. The above examples show that these two methods should be as follows:

String asHms();
long inSeconds();
3.2.2.2 Adding two durations

Consider a method to add two durations. The purpose of this method is to add two durations to produce a sum. One way to translate this purpose is to have a method that takes two Duration objects, adds them and returns the result as a Duration object. This results in a method signature as follows:

Summary: When designing methods in an interface, first have a clear idea about how you would like this method to be used and then engineer the appropriate method signature from its intended usage. This results in methods that are appropriately usable, not just how you think the method should be used by others.

//add two durations and return the result Duration plus(Duration one,Duration two);

How would one use this method?

Duration one = ...;
Duration two = ...;
 
Duration result = one.plus(one,two); //or two.plus(one,two)

Since the plus method is part of the Duration interface, we would need a Duration object to call it. The above call looks redundant because one of the objects appears twice (as the calling object and as one of the arguments). A better way would be

Duration one = ...;
Duration two = ...;
 
Duration result = one.plus(two); //or two.plus(one)

What should the method signature look like so that we can call it this way? It should look as it is specified in the Duration interface.

Summary: When designing the signature of a method for an interface, use as high-level and general types as possible. Use interface names instead of concrete classes as much as possible. This is enshrined as one of the SOLID principles: Dependency Inversion. This principle says that an abstraction (i.e. interface) should not depend upon details (implementing classes).

3.2.2.3 Argument and return types

Notice the type of the argument and the return value of the plus method: it is Duration, an interface. Using the name of the interface makes this interface much more general purpose. The plus method can be called using any Duration object, can take an argument of any Duration object and is capable of returning any Duration object! As an extreme example, the calling object, the parameter and the returned object can be of three different classes that all implement the Duration interface. It should not matter which ones they are, because (1) these objects are used to call only those methods that are in the Duration interface and (2) conceptually one should be able to add any two valid durations, irrespective of how they are represented.

3.2.2.4 Methods for equality and comparison

One of our intended operations is to be able to compare two objects. There are two ways to do this: is a duration equal to another? and is a duration less than, equal to or greater than another?.

Let us design the method signature to check for equality: how would we use it?

Duration one = ...;
Duration two = ...;
 
if (one.isEqual(two)) { //is two equal to one? ...
}
else {
...
}

To allow this usage, its method signature should be:

boolean isEqual(Duration other);

The "magical" existence of an equals method for every Java object is due to the fact that every Java class (whether you write it or it already exists) implicitly extends the Object class. This is the "father of all classes." Because of this, methods defined in the Object class are available to all Java objects (methods like equals, toString, hashCode, etc.). However these default implementations may not work correctly for each class, so those methods may need to be overridden.

We could add this signature to our Duration interface. It turns out that there is an “inbuilt” method in every Java class that is used for the same purpose: checking equality between two objects. Its signature is

boolean equals(Object obj);

Even if we add the first signature to our interface, any class that implements this interface would still offer a method of the second signature. To avoid redundancy and inconsistency (the first one would work correctly, but the default implementation of the second may not) we use the second signature. Note that there is no way to force a class that implements the Duration interface to provide an implementation of the equals method (because it always has a default one). One must document it explicitly in the interface documentation, and hope that any implementor reads and follows it!

Consider the second method of comparison. A possible usage could be:

Duration one = ...;
Duration two = ...;
 
if (one.compare(two)==...) {
...
}
else {
...
}

Unlike the equals method that returns one of only two answers, such a method would need to return one of three different values (lesser than, equal to, greater than). How can we design such a method? Some possibilities of the return type include:

Summary: Whenever possible, use existing methods and method signatures to avoid redundancy and inconsistency. It allows us to potentially use other classes that work with these existing methods. It reduces redundancy and confusion (a knowledgeable Java programmer will know about the equals method and the Comparable interface and will expect your classes to support comparison using them, if comparison makes sense.)

We choose integers, but eliminate the invalid values problem. Instead of matching three numbers to the three possibilities of comparison, we match three number ranges. We interpret any negative number as "first object is lesser than second object," any positive number as "first object is greater than second object" and zero as "the two objects are equal to each other."

As it turns out, this choice of ranges is not arbitrary. There is a "standard" method in Java to compare two objects that uses these numerical ranges. This method is part of the Comparable interface. So once again we choose to reuse an existing method to avoid redundancy. We do this by having the Duration interface extend the Comparable<Duration> interface. This means the Duration interface contains all method signatures specified in it, plus any signatures from the Comparable<Duration> interface. The Comparable interface is generic, i.e. it takes in a type as a parameter (Duration in this case). It includes the following method signature as a result:

The assertEquals method in JUnit tests uses the equals method on the objects passed to it. Many existing data structure implementations in the Collection classes are able to work with the Comparable interface.

int compareTo(Duration other);

The generic type (Duration) becomes the type of the only argument to the compareTo method.

There is benefit to "aligning" our Duration interface with an existing method of comparison (equals and compareTo methods). There are many existing classes within the Java SDK that use these methods, so we set up our Duration-type objects to potentially work with them. This promotes reuse, another highly desirable feature of good design.

3.2.2.5 The Duration interface

The final interface looks like this:

public interface Duration extends Comparable<Duration> {
/** * Gets the total duration in seconds. * * @return the number of seconds (non-negative) */
long inSeconds();
 
/** * Formats this duration in the form {@code H:MM:SS} where the hours and * minutes are both zero-padded to two digits, but the hours are not. * The duration should be in canonical form, meaning that both the minutes * and the seconds are less than 60. * * @return this duration formatted in hours, minutes, and seconds */
String asHms();
 
/** * Returns the sum of two durations. * * @param other the duration to add to {@code this} * @return the sum of the durations */
Duration plus(Duration other);
}
3.2.2.6 Documentation

Summary: Write documentation for each interface, class and method. Write documentation as you develop them, not as an afterthought. The target audience for documentation should be other developers who want to understand and use your code, but were not involved in its development. Finally, use Javadoc-styling.

Read the documentation included in the Duration interface. Without knowing anything about how these methods are implemented, are you able to understand how to use objects that implement this interface? Is the correct and intended use of every method clear? That is the threshold we must meet with good documentation.

The documentation should include the purpose of the interface, the purpose of each method and information about its parameters and return values (i.e., its contract). Documentation is meant for the user of these classes, not the programmer who wrote them. Accordingly it should not assume that the reader is familiar with its implementation details.

3.3 Implementation

Now that the interface is designed we can think of ways to implement it. How can we represent durations? Humans usually break down durations into hours, minutes and seconds. Looking at the interface, we need to implement a method that formats a duration in that way. Therefore we choose to represent our durations in hours, minutes and seconds.

/** * Durations represented as hours, minutes, and seconds. */
public final class HmsDuration implements Duration {
 
private final int hours;
private final int minutes;
private final int seconds;
}

Some salient features:
  • Although we stated that we do not assume an upper bound on the durations, using int implicitly places an upper bound on this duration. This would be true using most data types, because all data representations have an upper limit. This may not be a problem by itself, but we must keep this in mind as we implement operations.

  • All fields are made private. This means they can only be accessed from within this class (i.e. only inside this class’s methods). This drastically reduces the number of places in code that can change these fields, reducing the possibility of errors (see private, public and final: What are they?). This is information hiding.

  • Making the fields final prevents their mutation after being initialized in the constructor (see private, public and final: What are they?). Since all fields are final this makes an HmsDuration object immutable (i.e. once created, it cannot be changed). Immutability avoids many potential errors due to unintended modifications of fields in methods (called side effects).

  • Making the class final prevents another class extending it. Thus one cannot write another class that overrides methods in this class to possibly break some of the promises made by this class. If you do not anticipate extending a class, make it final.

3.3.1 Constructor

The constructor is used to create an instance of a class (i.e. create an object). How would we want to instantiate a HmsDuration object?

Duration d1 = new HmsDuration(0, 0, 90);
Duration d2 = new HmsDuration(0, 1, 30);
Duration d3 = new HmsDuration(0, 1, -30);

To support the above usage, we should write a constructor that takes three arguments for the hours, minutes and seconds in that order. However consider the third instantiation. Since we do not support negative duration, the third instantiation should not work. Since we are using integers, it is not possible to prevent this invocation. There is no good way of correcting this error automatically (we could take the absolute, but does this make sense? Would the programmer who typed the above code expect that?). Therefore we throw an exception. Thus, our constructor would be as follows:

/** * Constructs a duration in terms of its length in hours, minutes, and * seconds. * * @param hours the number of hours * @param minutes the number of minutes * @param seconds the number of seconds * @throws IllegalArgumentException if any argument is negative */
public HmsDuration(int hours, int minutes, int seconds)
throws IllegalArgumentException {
}

Consider the first instantiation: 90 seconds. This is clearly a valid duration. However when we represent durations in hours, minutes and seconds, we expect the minutes and seconds to be numbers between 0 and 59. This is the canonical representation. This means that we may not be able to directly assign the passed arguments to the fields. We must first convert them into a canonical form (e.g. convert 0 minutes and 90 seconds to 1 minute and 30 seconds). We can do it as follows:

public HmsDuration(int hours, int minutes, int seconds)
throws IllegalArgumentException {
if ((hours<0) || (minutes<0) || (seconds<0)) {
throw new IllegalArgumentException("Negative durations are not supported");
}
int h,m,s;
h = hours;
m = minutes;
s = seconds;
m = m + s / 60;
s = s % 60;
h = h + m / 60;
m = m % 60;
 
this.hours = h;
this.minutes = m;
this.seconds = s;
}
3.3.2 Observer Methods

The two methods that return durations in certain formats (seconds and strings) are fairly straightforward:

@Override
public long inSeconds() {
return 3600 * hours + 60 * minutes + seconds;
}
 
@Override
public String asHms() {
return String.format("%d:%02d:%02d", hours, minutes, seconds);
}

Some salient features:

3.3.3 The plus method

The plus method is slightly complicated. Consider its signature:

Duration plus(Duration other);

Summary: When writing an implementation, strive for generality. The more general (i.e. not dependent on specific types), the more abstract. Abstraction promotes code reuse and helps to maintain code over time, which are desirable features of good design.

The two durations to be added are this (the object used to call this method) and other. Since we are implementing this method in the HmsDuration class, we know that this is an HmsDuration object. However we cannot make this assumption about other, because the interface states that this can be any Duration-type object. In other words we cannot make any assumptions about how other is implemented, except that it offers all the methods specified in the Duration interface.

We can add two durations by first converting them to seconds and then adding them. Fortunately the Duration interface has an inSeconds() method, so we can get any duration in seconds. Finally, we wish to convert the resulting seconds into a Duration-type object to return it. The only type we have is HmsDuration, so we must find a way to instantiate a HmsDuration object from seconds.

We can use the existing constructor this way (we can just pass 0 for hours and minutes), but it makes sense to offer an explicit way to create a HmsDuration object from just one input: seconds. Therefore we write another constructor:

/** * Constructs a duration in terms of its length in seconds. * * @param inSeconds the number of seconds (non-negative) * @throws IllegalArgumentException {@code inSeconds} is negative */
public HmsDuration(long inSeconds) {
if (inSeconds < 0) {
throw new IllegalArgumentException("must be non-negative");
}
 
seconds = (int)(inSeconds % 60);
minutes = (int)(inSeconds / 60 % 60);
hours = (int)(inSeconds / 3600);
}

Now we can implement the plus method.

@Override
public Duration plus(Duration other) {
long thisSeconds = this.inSeconds();
long otherSeconds = other.inSeconds();
long total = thisSeconds + otherSeconds;
return new HmsDuration(total);
}
3.3.4 Comparison and other methods

The compareTo(...) is simple: we compare two durations by comparing their values in seconds. We can do so directly, or use a helper method in the Long class that returns a result in the same ranges that we want.

@Override
public int compareTo(Duration that) {
return Long.compare(this.inSeconds(), that.inSeconds());
}

Overriding equals is a bit trickier, because the argument type is Object. This opens the possibility of calling this method using a Duration-type object and passing any object! Obviously if the other object is not a Duration-type object it is not equal to the calling object, but we must explicitly write code for this.

Note again how the equals method does not assume that we are comparing one HmsDuration object with another, but rather one Duration object to another. This is another example of abstraction. Its advantage is that the same code is applicable to check any two Duration-type objects for equality.

Since HmsDuration (implicitly) extends Object, it must already have a default implementation of the equals method. What does it do? It compares actual references (is this object literally the same as that?). An object is (obviously) equal to itself, so we must support this possibility as well.

@Override
public boolean equals(Object o) {
// Fast path for pointer equality: if (this == o) { //backward compatibility with default equals return true;
}
 
// If o isn't the right class then it can't be equal: if (! (o instanceof Duration)) {
return false;
}
 
// The successful instanceof check means our cast will succeed: Duration that = (Duration) o;
 
return this.inSeconds() == that.inSeconds();
}

It helps to understand the above code by reading its three sections "backwards":

For reasons that will be clarified later, one must override the hashCode() method when one overrides equals. The rationale is that if two objects are equal to each other (as deemed by the equals method) then their hashCode methods should return the same value. We ensure this by making equals and hashCode both use the seconds value of the duration. ‘
@Override
public int hashCode() {
return Long.hashCode(this.inSeconds());
}

Since we have precisely defined how two Duration-type objects can be compared to each other, it makes sense to document this explicitly in the Duration interface. Programmers looking to implement the Duration interface in another class will benefit from these details in the documentation.