On this page:
4.1 Introduction
4.2 Interface Polymorphism
4.3 The Compact  Duration class
4.4 Abstraction
4.4.1 An abstract class
4.4.2 First Cut
4.4.3 Second Cut
4.4.4 Abstracting the plus method
4.4.5 Factory Method Design Pattern
4.4.6 Testing Compact  Duration
4.5 When and how much to abstract?
4.6 Refactoring:   a primer
6.3

Lecture 4: How to Design Classes: Encore

4.1 Introduction

In Lecture 3: How to Design Classes: A Primer we designed a Duration interface and wrote an implementation in the form of a HmsDuration class that represented a duration in hours, minutes and seconds (HMS). This is certainly not the only way to represent durations (if you read the lecture notes, our decision to choose hours, minutes and seconds seems "convenient"). For example, another way to represent durations is only in terms of seconds (we call this a compact representation). Looking back, we find that many of the reasons we choose the HMS representation can apply to this representation as well.

We wish to implement the Duration interface again. We also wish to minimize any changes to the code that uses our earlier implementation (i.e. client code, our tests). This situation occurs quite often: how do we design so that components can be easily replaced by better versions of themselves?

We observe how we can achieve this by taking advantage of some of our design choices:

Since we cannot categorically claim the superiority of one of the representations over another (HMS vs. compact), we will pursue the latter strategy of having both implementations at our disposal.

4.2 Interface Polymorphism

We will implement the Duration interface a second time, as a CompactDuration class. This creates the following design:

Consider two ways of using this design:

Use interfaces as the types of fields and method parameters as much as possible. This makes it easier to change code when switching one implementation for another. This is referred to as "programming by interface." It is simply a consequence of "design by interface": objects are used by calling only those methods that come from an interface, without worrying about details of a specific implementation.

In reality, often a client will not able to decouple itself completely from a specific implementation. This is because objects of the implementation would need to be created somewhere, mandating the mention of the class name in constructors (look at the setup method in our tests from Lecture 1). In this case, we would need to make a limited number of changes to the nearly happy client to update the constructors. If we can isolate the places where objects are created, we will have lesser and more focused changes in code when we change from one implementation to another. We will see later a way to design a client that avoids even calling a particular class’s constructor, making the client completely decoupled from the class.

4.3 The CompactDuration class

We can now implement the CompactDuration class as follows:

package duration;
 
/** * Durations represented compactly, with a range of 0 to 2<sup>63</sup>-1 * seconds. */
public final class CompactDuration implements Duration {
private final long inSeconds;
 
/** * 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 CompactDuration(long inSeconds) {
if (inSeconds < 0) {
throw new IllegalArgumentException("must be non-negative");
}
 
this.inSeconds = inSeconds;
}
 
/** * 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 CompactDuration(int hours, int minutes, int seconds)
throws IllegalArgumentException {
if ((hours < 0) || (minutes < 0) || (seconds < 0)) {
throw new IllegalArgumentException("Negative durations are not supported");
}
this.inSeconds = 3600 * hours + 60 * minutes + seconds;
}
}

Look at the second constructor: we have seen this code somewhere. The inSeconds() implementation in the HmsDuration class used this exact arithmetic.

Other methods can be implemented as follows:

@Override
public long inSeconds() {
return this.inSeconds;
}
 
@Override
public Duration plus(Duration other) {
long thisSeconds = this.inSeconds();
long otherSeconds = other.inSeconds();
long total = thisSeconds + otherSeconds;
return new CompactDuration(total);
}

These methods are different from their implementations in the HmsDuration class. But consider the asHms method:

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

The arithmetic to convert a duration into hours, minutes and seconds is the same as that in the one-argument constructor of the HmsDuration class. The last line above is identical to that in the asHms implementation of the HmsDuration class.

This is not altogether surprising, but as classes get bigger even more code replication is likely. Code replication is not desirable for a variety of reasons:

4.4 Abstraction

If two or more methods within the same class have replicated code, we can write a (private) helper method and call it from the other methods. When we want to factor out redundancy between classes, we write a helper class and refer to it from the other classes. One way to use the helper class from the classes that share it is to have them extend it. Extension, also known as inheritance, derives a new class from an older one by adding fields, adding methods, and replacing methods. Furthermore, we can design an abstract class with extension in mind, leaving some of its methods missing for subclasses to define.

4.4.1 An abstract class

An abstract class is a class with at least one method definition missing (the method signature is there, but no body). Such an incomplete method is an abstract method. An abstract class may have fields and other complete methods like a regular, concrete class. If we gradually strip an abstract class of all its method bodies and fields and non-public method signatures, we are left with only a bunch of method signatures. This is nothing but an interface. Many programming languages do not even support an explicit "interface" construct: abstract classes are good enough.

We begin by creating a new, empty abstract class AbstractDuration and insert it between the Duration interface and its implementations:

We do this by defining the new class so that it implements Duration and modifying the two concrete classes to extend the new AbstractDuration class. Both "implements" and "extends" are examples of inheritance, but UML distinguishes them using dotted and solid arrows respectively.

 
abstract class AbstractDuration implements Duration {
// nothing yet }
 
public final class HmsDuration extends AbstractDuration { ... }
 
public final class CompactDuration extends AbstractDuration { ... }

Specifying nothing as the access modifier defaults to package-private access in Java. This means other classes and methods within the same package can access it. Its not the best syntax design, because not writing anything may be deliberate, or simply oversight.

Notice that AbstractDuration is not a public class, unlike the others. This means that this class cannot be accessed from another package. Effectively it keeps the AbstractDuration as a package-internal implementation detail. Indeed a client does not have to even know it exists. It does not provide any new functionality relevant to the client, only reduces redundancy and organizes our code better.

Adding AbstractDuration by itself accomplishes nothing. However, now we have a place to park all the code that is common to HmsDuration and CompactDuration: instead of writing it twice in these classes, we write it once in the abstract class and have both classes inherit it.

4.4.2 First Cut

We begin by identifying methods that are identical in both implementations. They are compareTo(Duration), equals(Object) and hashCode(). Therefore our design changes to:

4.4.3 Second Cut

There are still some redundancies in the implementations:

We create one helper method to convert from HMS to seconds, and three helper methods to convert seconds into hours, minutes and seconds respectively. We put them in AbstractDuration so that both implementations can share them.

/** * Returns the number of whole hours in the given number of seconds. * * @param inSeconds the total number of seconds * @return the number of hours * @throws ArithmeticException if the correct result cannot fit in an * {@code int}. */
protected static int hoursOf(long inSeconds) {
if (inSeconds / 3600 > Integer.MAX_VALUE) {
throw new ArithmeticException("result cannot fit in type");
}
 
return (int) (inSeconds / 3600);
}
 
/** * Returns the number of whole minutes in the given number of seconds, less * the number of whole hours. * * @param inSeconds the total number of seconds * @return the number of remaining minutes */
protected static int minutesOf(long inSeconds) {
return (int) (inSeconds / 60 % 60);
}
 
/** * Returns the number of seconds remaining after all full minutes are removed * from the given number of seconds. * * @param inSeconds the total number of seconds * @return the number of remaining seconds */
protected static int secondsOf(long inSeconds) {
return (int) (inSeconds % 60);
}
 
/** * Converts an unpacked hours-minutes-seconds duration to its length in * seconds. * * @param hours the number of hours * @param minutes the number of minutes * @param seconds the number of seconds * @return the duration in seconds */
protected static long inSeconds(int hours, int minutes, int seconds) {
return 3600 * hours + 60 * minutes + seconds;
}

Summary: there are four access modifiers in Java: private, nothing(package-private), protected and public. They are ordered from most to least restrictive. protected allows access to any class within the same package, and also classes in other packages that extend this class.

Since they should be accessible only to classes that inherit from AbstractDuration we make them protected. Also all these methods use only data passed to it as parameters, and not any fields. Therefore we make them static.

We can now clean up the constructors, and even move asHms to AbstractDuration.

 
//In HmsDuration 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");
}
 
long totalSeconds = inSeconds(hours, minutes, seconds);
 
this.hours = hoursOf(totalSeconds);
this.minutes = minutesOf(totalSeconds);
this.seconds = secondsOf(totalSeconds);
}
 
@Override
public long inSeconds() {
return inSeconds(hours, minutes, seconds);
}
 
 
//In CompactDuration  
public CompactDuration(int hours, int minutes, int seconds)
throws IllegalArgumentException {
if ((hours < 0) || (minutes < 0) || (seconds < 0)) {
throw new IllegalArgumentException("Negative durations are not supported");
}
this.inSeconds = inSeconds(hours, minutes, seconds);
}
 
//moved to AbstractDuration @Override
public String asHms() {
return String.format("%d:%02d:%02d", hoursOf(this.inSeconds()),
minutesOf(this.inSeconds()), secondsOf(this.inSeconds()));
}

In UML protected is denoted by #, and static is denoted by underlining.

4.4.4 Abstracting the plus method

The plus method implementations are similar.

//in HmsDuration  
@Override
public Duration plus(Duration other) {
long thisSeconds = this.inSeconds();
long otherSeconds = other.inSeconds();
long total = thisSeconds + otherSeconds;
return new HmsDuration(total);
}
 
//in CompactDuration  
@Override
public Duration plus(Duration other) {
long thisSeconds = this.inSeconds();
long otherSeconds = other.inSeconds();
long total = thisSeconds + otherSeconds;
return new CompactDuration(total);
}

One option could be to choose either HmsDuration or CompactDuration and use it in both implementations. However this would be bad design, because it would couple one specific implementation with another. This makes one implementation dependent on another, so they aren’t really equal choices anymore.

Only the last line is unique: creating an object from the duration specified in seconds. This line is different in each implementation because it creates an object of its own class. Specifically its uniqueness is because it invokes the constructor, which is mandated by Java to have the same name as its class. How can this be abstracted?

We can write another method with the same purpose: create (and return) a Duration object given the value in seconds.

Duration fromSeconds(long inSeconds) {...}

If such a method existed, we could rewrite the plus method as:

@Override
public Duration plus(Duration other) {
long thisSeconds = this.inSeconds();
long otherSeconds = other.inSeconds();
long total = thisSeconds + otherSeconds;
return fromSeconds(total);
}

This implementation would be identical in both HmsDuration and CompactDuration and hence, can be moved to AbstractDuration. However it calls the fromSeconds method, so such a method must exist in the AbstractDuration class. However it cannot be implemented there (which object would it create?), so we make it an abstract method.

protected abstract Duration fromSeconds(long inSeconds);

We make it protected because only subclasses need to know about it (if there is a use-case for this method to be called from outside these classes, we can make it public, but such a case does not currently exist).

Being an abstract method it must be implemented by subclasses for them to be concrete classes (i.e. classes you can instantiate).

//in HmsDuration  
@Override
public Duration fromSeconds(long inSeconds) {
return new HmsDuration(total);
}
 
//in CompactDuration  
@Override
public Duration fromSeconds(long inSeconds) {
return new CompactDuration(total);
}

In summary, we abstracted the common parts of the original plus method, and kept the unique parts in the two implementations. Our final design is as follows:

4.4.5 Factory Method Design Pattern

Consider the signature of the fromSeconds method:

Duration fromSeconds()

This method can be thought of as being able to create and return objects of different (but related) kinds: HmsDuration and CompactDuration. Remarkably, the type of the object it returns is determined at runtime, through dynamic dispatch (see Dynamic dispatch). Specifically, it will return a HmsDuration object when the plus method is called using a HmsDuration object, and similarly for the CompactDuration class. This is an example of a factory method.

A factory method is a creational design pattern, i.e., it is related to creating (instantiating) objects. It can be used when objects of different (related) types must be created at one place, with the actual type of object determined at runtime. There are several variations of a factory method, related to how it determines which object can be returned. The above implementation shows one option: the factory method is embedded in an abstract class, such that the object used to call it determines the type of object it creates and returns. Another option is to write the factory method in a completely different "creator" class. It can accept parameters (e.g. a string, or an enumerated type) that determine which type of object it should return. Thus this type can be controlled by the value of the parameter when the method is called. In this form, the factory method may be static so that the creator class which contains it need not be instantiated (yet another variation is to make the creator class a singleton: another design pattern).

4.4.6 Testing CompactDuration

Observe the tests for the HmsDuration class in Lecture 3: How to Design Classes: A Primer. All of them apply to CompactDuration, and in fact to any implementation of the Duration interface. We can apply the same strategy to avoid duplication of these tests. We factor out the only difference in applying them to various implementations: instantiation of the Duration-type object.

4.5 When and how much to abstract?

The above exercise gives a complete example of analysis, interface design, concrete implementations and abstraction. The result is one interface, one abstract class, two concrete classes and several helper methods inside them. We may not have thought about all of them when we started to design a solution to this problem. Should we have, and if so, how do we know we’ve done enough? And how do we scale our approach to more, bigger classes?

In general it is important to keep design decisions goal-oriented. We should be able to justify every design aspect based on a requirement that we know, or we have good reason to anticipate in future. The above exercise in abstraction was a mix of ongoing and retrospective design. If we compare the code from Lecture 3 and this lecture, we see that many of the possible abstractions in the HmsDuration class happened only when we considered the CompactDuration implementation. A more experienced designer may very well anticipate these abstractions and proactively create abstract classes. However that approach may lead to excessive abstraction, turning simple, readable code into unnecessarily complicated, sliced-and-diced code.

It does not matter whether one proactively designs for abstraction, or modifies design iteratively to abstract it. One must strive for design and code that minimizes code replication and is understandable and well-documented.

4.6 Refactoring: a primer

This lecture was partly an exercise in code refactoring. We started from an implementation from Lecture 3 and changed the design to remove code replication and simplify the design (the new HmsDuration class is about 40% shorter). Most importantly we made all our designs without changing the "outward face" of this class (i.e. the list of public method signatures that a client uses). Changing the internals of a component without changing how its clients use it is called code refactoring, and is a critical part of software development.

Code refactoring has many flavors and processes, but it has two objectives: make code simpler to understand, evolve its design. One may term arbitrary copy-pasting of code as refactoring, but such an approach would be inefficient and error-prone. A critical tool for good, efficient code refactoring is tests. Having a suite of automated tests makes it easy to try code factorizations and test whether the code still fulfills all its contracts. As we moved methods from HmsDuration to AbstractDuration and created helper methods, we could quickly test correctness by re-running our tests. Thus writing good tests pays dividends several times!