4 Techniques for Writing Better Java
Touching on topics from inheritance and overriding to final classes and methods, here is some advice on how to be a better Java coder.
Join the DZone community and get the full member experience.
Join For FreeDay-in and day-out, most of the Java we write uses a small fraction of the capability of the language's full suite of possibilities. Each Stream
we instantiate and each @Autowired
annotation we prefix to our instance variables suffice to accomplish most of our goals. There are times, however, when we must resort to those sparingly used portions of the language: The hidden parts of the language that serves a specific purpose.
This article explores four techniques that can be used when caught in a bind and be introduced into a code-base to improve both the ease of development and readability. Not all of these techniques will be applicable in every situation, or even most. For example, there may be only a few methods that will lend themselves to covariant return types or only a few generic classes that fit the pattern for using intersectional generic types, while others, such as final methods and classes and try-with-resources blocks, will improve the readability and clearness of intention of most code-bases. In either case, it is important to not only know that these techniques exist, but know when to judiciously apply them.
1. Covariant Return Types
Even the most introductory Java how-to book will include pages of material on inheritance, interfaces, abstract classes, and method overriding, but rarely do even advanced texts explore the more intricate possibilities when overriding a method. For example, the following snippet will not come as a surprise to even the most novice Java developer:
public interface Animal {
public String makeNoise();
}
public class Dog implements Animal {
@Override
public String makeNoise() {
return "Woof";
}
}
public class Cat implements Animal {
@Override
public String makeNoise() {
return "Meow";
}
}
This is the fundamental concept of polymorphism: A method on an object can be called according to its interface (Animal::makeNoise
), but the actual behavior of the method call depends on the implementation type (Dog::makeNoise
). For example, the output of the following method will change depending on if a Dog
object or a Cat
object is passed to the method:
public class Talker {
public static void talk(Animal animal) {
System.out.println(animal.makeNoise());
}
}
Talker.talk(new Dog()); // Output: Woof
Talker.talk(new Cat()); // Output: Meow
While this is a technique commonly used in many Java applications, there is a less well-known action that can be taken when overriding a method: Altering the return type. Although this may appear to be an open-ended way to override a method, there are some serious constraints on the return type of an overridden method. According to the Java 8 SE Language Specification (pg. 248):
If a method declaration d 1 with return type R 1 overrides or hides the declaration of another method d 2 with return type R 2, then d 1 must be return-type-substitutable for d 2, or a compile-time error occurs.
where a return-type-substitutable (Ibid., pg. 240) is defined as
- If R1 is void then R2 is void
- If R1 is a primitive type then R2 is identical to R1
- If R1is a reference type then one of the following is true:
- R1 adapted to the type parameters of d2 is a subtype of R2.
- R1 can be converted to a subtype of R2 by unchecked conversion
- d1 does not have the same signature as d2 and R1 = |R2|
Arguably the most interesting case is that of Rules 3.a. and 3.b.: When overriding a method, a subtype of the return type can be declared as the overridden return type. For example:
public interface CustomCloneable {
public Object customClone();
}
public class Vehicle implements CustomCloneable {
private final String model;
public Vehicle(String model) {
this.model = model;
}
@Override
public Vehicle customClone() {
return new Vehicle(this.model);
}
public String getModel() {
return this.model;
}
}
Vehicle originalVehicle = new Vehicle("Corvette");
Vehicle clonedVehicle = originalVehicle.customClone();
System.out.println(clonedVehicle.getModel());
Although the original return type of clone()
is Object
, we are able to call getModel()
on our cloned Vehicle
(without an explicit cast) because we have overridden the return type of Vehicle::clone
to be Vehicle
. This removes the need for messy casts, where we know that the return type we are looking for is a Vehicle
, even though it is declared to be an Object
(which amounts to a safe cast based on a priori information but is strictly speaking unsafe):
Vehicle clonedVehicle = (Vehicle) originalVehicle.customClone();
Note that we can still declare the type of the vehicle to be a Object
and the return type would revert to its original type of Object
:
Object clonedVehicle = originalVehicle.customClone();
System.out.println(clonedVehicle.getModel()); // ERROR: getModel not a method of Object
Note that the return type cannot be overloaded with respect to a generic parameter, but it can be with respect to a generic class. For example, if the base class or interface method returns a List<Animal>
, the return type of a subclass may be overridden to ArrayList<Animal>
, but it may not be overridden to List<Dog>
.
2. Intersectional Generic Types
Creating a generic class is an excellent way of creating a set of classes that interact with composed objects in a similar manner. For example, a List<T>
simply stores and retrieves objects of type T
without an understanding of the nature of the elements it contains. In some cases, we want to constrain our generic type parameter (T
) to have specific characteristics. For example, given the following interface
public interface Writer {
public void write();
}
We may want to create a specific collection of Writers
in following with the Composite Pattern:
public class WriterComposite<T extends Writer> implements Writer {
private final List<T> writers;
public WriterComposite(List<T> writers) {
this.writers = writers;
}
@Override
public void write() {
for (Writer writer: this.writers) {
writer.write();
}
}
}
We can now traverse a tree of Writers
, not knowing whether the specific Writer
we encounter is a standalone Writer
(a leaf) or a collection of Writers
(a composite). What if we also wanted our composite to act as a composite for readers as well as writers? For example, if we had the following interface
public interface Reader {
public void read();
}
How could we modify our WriterComposite
to be a ReaderWriterComposite
? One technique would be to create a new interface, ReaderWriter
, that fuses the Reader
and Writer
interface together:
public interface ReaderWriter extends Reader, Writer {}
Then we can modify our existing WriterComposite
to be the following:
public class ReaderWriterComposite<T extends ReaderWriter> implements ReaderWriter {
private final List<T> readerWriters;
public ReaderWriterComposite(List<T> readerWriters) {
this.readerWriters = readerWriters;
}
@Override
public void write() {
for (Writer writer: this.readerWriters) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.readerWriters) {
reader.read();
}
}
}
Although this does accomplish our goal, we have created bloat in our code: We created an interface with the sole purpose of merging two existing interfaces together. With more and more interfaces, we can start to see a combinatoric explosion of bloat. For example, if we create a new Modifier
interface, we would now need to create ReaderModifier
, WriterModifier
, and ReaderWriter
interfaces. Notice that these interfaces do not add any functionality: They simply merge existing interfaces.
To remove this bloat, we would need to be able to specify that our ReaderWriterComposite
accepts generic type parameters if and only if they are both Reader
and Writer
. Intersectional generic types allow us to do just that. In order to specify that the generic type parameter must implement both the Reader
and Writer
interfaces, we use the &
operator between the generic type constraints:
public class ReaderWriterComposite<T extends Reader & Writer> implements Reader, Writer {
private final List<T> readerWriters;
public WriterComposite(List<T> readerWriters) {
this.readerWriters = readerWriters;
}
@Override
public void write() {
for (Writer writer: this.readerWriters) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.readerWriters) {
reader.read();
}
}
}
Without bloating our inheritance tree, we are now able to constrain our generic type parameter to implement multiple interfaces. Note that the same constraint can be specified if one of the interfaces is an abstract class or concrete class. For example, if we changed our Writer
interface into an abstract class resembling the following
public abstract class Writer {
public abstract void write();
}
We can still constrain our generic type parameter to be both a Reader
and a Writer
, but the Writer
(since it is an abstract class and not an interface) must be specified first (also note that our ReaderWriterComposite
now extends
the Writer
abstract class and implements
the Reader
interface, rather than implementing both):
public class ReaderWriterComposite<T extends Writer & Reader> extends Writer implements Reader {
// Same class body as before
}
It is also important to note that this intersectional generic type can be used for more than two interfaces (or one abstract class and more than one interface). For example, if we wanted our composite to also include the Modifier
interface, we could write our class definition as follows:
public class ReaderWriterComposite<T extends Reader & Writer & Modifier> implements Reader, Writer, Modifier {
private final List<T> things;
public ReaderWriterComposite(List<T> things) {
this.things = things;
}
@Override
public void write() {
for (Writer writer: this.things) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.things) {
reader.read();
}
}
@Override
public void modify() {
for (Modifier modifier: this.things) {
modifier.modify();
}
}
}
Although it is legal to perform the above, this may be a sign of a code smell (an object that is a Reader
, a Writer
, and a Modifier
is likely to be something much more specific, such as a File
).
For more information on intersectional generic types, see the Java 8 language specification.
3. Auto-Closeable Classes
Creating a resource class is a common practice, but maintaining the integrity of that resource can be a challenging prospect, especially when exception handling is involved. For example, suppose we create a resource class, Resource
, and want to perform an action on that resource that may throw an exception (the instantiation process may also throw an exception):
public class Resource {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
}
public void close() {
System.out.println("Closed resource");
}
}
In either case (if the exception is thrown or not thrown), we want to close our resource to ensure there are no resource leaks. The normal process is to enclose our close()
method in a finally
block, ensuring that no matter what happens, our resource is closed before the enclosed scope of execution is completed:
Resource resource = null;
try {
resource = new Resource();
resource.someAction();
}
catch (Exception e) {
System.out.println("Exception caught");
}
finally {
resource.close();
}
By simple inspection, there is a lot of boilerplate code that detracts from the readability of the execution of someAction()
on our Resource
object. To remedy this situation, Java 7 introduced the try-with-resources statement, whereby a resource can be created in the try
statement and is automatically closed before the try
execution scope is left. For a class to be able to use the try-with-resources, it must implement the AutoCloseable
interface:
public class Resource implements AutoCloseable {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
}
@Override
public void close() {
System.out.println("Closed resource");
}
}
With our Resource
class now implementing the AutoCloseable
interface, we can clean up our code to ensure our resource is closed prior to leaving the try execution scope:
try (Resource resource = new Resource()) {
resource.someAction();
}
catch (Exception e) {
System.out.println("Exception caught");
}
Compared to the non-try-with-resources technique, this process is much less cluttered and maintains the same safety (the resource is always closed upon completion of the try
execution scope). If the above try-with-resources statement is executed, we obtain the following output:
Created resource
Performed some action
Closed resource
In order to demonstrate the safety of this try-with-resources technique, we can change our someAction()
method to throw an Exception
:
public class Resource implements AutoCloseable {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
throw new Exception();
}
@Override
public void close() {
System.out.println("Closed resource");
}
}
If we rerun the try-with-resources statement again, we obtain the following output:
Created resource
Performed some action
Closed resource
Exception caught
Notice that even though an Exception
was thrown while executing the someAction()
method, our resource was closed and then the Exception
was caught. This ensures that prior to leaving the try
execution scope, our resource is guaranteed to be closed. It is also important to note that a resource can implement the Closeable
interface and still use a try-with-resources statement. The difference between implementing the AutoCloseable
interface and the Closeable
interface is a matter of the type of the exception thrown from the close()
method signature: Exception
and IOException
, respectively. In our case, we have simply changed the signature of the close()
method to not throw an exception.
4. Final Classes and Methods
In nearly all cases, the classes we create can be extended by another developer and customized to fit the needs of that developer (we can extend our own classes), even it was not our intent for our classes to be extended. While this suffices for most cases, there may be times when we do not want a method to be overridden, or more generally, have one of our classes extended. For example, if we create a File
class that encapsulates the reading and writing of a file on the file system, we may not want any subclasses to override our read(int bytes)
and write(String data)
methods (if the logic in these methods is changed, it may cause the file system to become corrupted). In this case, we mark our non-extendable methods as final
:
public class File {
public final String read(int bytes) {
// Execute the read on the file system
return "Some read data";
}
public final void write(String data) {
// Execute the write to the file system
}
}
Now, if another class wishes to override either the read or the write methods, a compilation error is thrown: Cannot override the final method from File
. Not only have we documented that our methods should not be overridden, but the compiler has also ensured that this intention is enforced at compile time.
Expanding this idea to an entire class, there may be times when we do not want a class we create to be extended. Not only does this make every method of our class non-extendable, but it also ensures that no subtype of our class can ever be created. For example, if we are creating a security framework that consumes a key generator, we may not want any outside developer to extend our key generator and override the generation algorithm (the custom functionality may be cryptographically inferior and compromise the system):
public final class KeyGenerator {
private final String seed;
public KeyGenerator(String seed) {
this.seed = seed;
}
public CryptographicKey generate() {
// ...Do some cryptographic work to generate the key...
}
}
By making our KeyGenerator
class final
, the compiler will ensure that no class can extend our class and pass itself to our framework as a valid cryptographic key generator. While it may appear to be sufficient to simply mark the generate()
method as final
, this does not stop a developer from creating a custom key generator and passing it off as a valid generator. Being that our system is security-oriented, it is a good idea to be as distrustful of the outside world as possible (a clever developer might be able to change the generation algorithm by changing the functionality of other methods in the KeyGenerator
class if those methods we present).
Although this appears to be a blatant disregard for the Open/Closed Principle (and it is), there is a good reason for doing so. As can be seen in our security example above, there are many times where we do not have the luxury of allowing the outside world to do what it wants with our application and we must be very deliberate in our decision making about inheritance. Writers such as Josh Bolch even go so far as to say that a class should either be deliberately designed to be extended or else it should be explicitly closed for extension (Effective Java). Although he purposely overstated this idea (see Documenting for Inheritance or Disallowing It), he makes a great point: We should be very deliberate about which of our classes should be extended, and which of our methods are open for overriding.
Conclusion
While most of the code we write utilizes only a fraction of the capabilities of Java, it suffices to solve most of the problems that we encounter. There are times though that we need to dig a little deeper into the language and dust off those forgotten or unknown parts of the language to solve a specific problem. Some of these techniques, such as covariant return types and intersectional generic types may be used in one-off situations, while others, such as auto-closeable resources and final methods and classes can and should be used to more often to produce more readable and more precise code. Combining these techniques with daily programming practices aids in not only a better understanding of our intentions but also better, more well-written Java.
The source code for this article can be found on GitHub.
Opinions expressed by DZone contributors are their own.
Comments