Open Credo

January 30, 2015

Traits with Java 8 Default Methods

When I first started programming in Scala a few years ago, Traits was the language feature I was most excited about. Indeed, Traits give you the ability to compose and share behaviour in a clean and reusable way. In Java, we tend deal with the same concerns by grouping crosscutting behaviour in abstract base classes that are subsequently extended every time we need to access shared behaviour in part or in total.

WRITTEN BY

Tareq Abedrabbo

Tareq Abedrabbo

Traits with Java 8 Default Methods

I generally felt that this way of doing things in Java is a necessary evil; it is only required to accommodate Java’s inheritance model. The main issue with this model is that it eventually results in base classes that violate separation of concerns and modularity by conflating disparate pieces of behaviour into one incoherent and unmaintainable ensemble – but what other options did we have?

Luckily for us all, Java 8 default methods can provide a good solution for composable and reusable behaviour, in similar – but not identical – way to Scala’s Traits. While the initial motivation behind this new language feature has to do with ensuring binary compatibility when library interfaces are extended, the underlying concept is much more generic and can be used to implement a number of interesting patterns.

It is probably best if we look at some concrete examples to see in context how this technique can enhance our code in different ways.

Examples

1. Abstract Framework Classes

Framework code is typically built for the purpose of providing a common basis of shared behaviour to application code. This is obviously one of the first places where the issues described previously can manifest themselves. The ‘Hello World’ of framework code is probably defining a common logging capability in a base class that is then made available to extension classes through inheritance.

It is often the case that more crosscutting concerns and shared functionality emerge with time, such as recording metrics or capturing common configuration options. All these useful but substantially different features would typically end up being co-located in one or more abstract classes, and eventually code readability and flexibility take a beating.

Alternatively, we could use default methods to introduce a Loggable interface that captures the logging concern and provides a default implementation.

Any class from any hierarchy can now implement Loggable. Admittedly, Loggable only captures one simple concern, but this technique can be easily generalised to cover other pieces of common and shared functionality.

It is immediately clear from the declaration of this class what it can do; there is no need to dig into complex class hierarchies to figure it out. Additionally, all classes that share the same behaviour will automatically adhere to the same simple contract. A default and working implementation is also provided but any class can override it if need be.

2. Abstract Test Classes

A very similar situation arises in test classes, especially if we are testing complex systems where a number of setup steps are required to initialise our backend components such as databases and message brokers. If not by design, all these code fragments end up being piled up in increasingly complex abstract classes, forcing concrete test classes to rely on invisible and intermingled pieces of functionality, ultimately making tests harder to understand and modify.

This is another opportunity to break complex behaviour into a number of composable interfaces. The next example shows a simple RabbitMQConnector trait:

This trait can be used in a test class, for example a Lambda Behave specification:

You’ve probably already noticed that dealing with state (keeping track of the RabbitMQ channel object in the previous example) is something that a trait interface cannot do – it is the responsibility of the concrete test class to keep track of any internal objects it might require.

Also, we can imagine that certain initialisation steps combining a number of method calls from different traits need to happen pretty much in the same order, potentially across a number of similar test classes. In this case each class needs to explicitly invoke the required methods in the right order (e.g. initialise RabbitMQ, initialise Redis). Even better, we can create an abstract class that captures the desired initialisation pattern while keeping the implementation detail of each step in its own interface.

3. Shared Functions

In some other cases, we could have some library-type functionality, typically a collection of related stateless functions, that are required in a number of places across the code base. On one recent project, we had a number of utility functions that read attributes by name from input tuples and returned their corresponding value, cast to the expected type. This in theory is very simple to solve; we could easily implement these functions as static methods in a utility class, or as methods on a helper object that can be either short-lived or long-lived. While both options have their advantages, static methods are not very flexible, and creating an object as a placeholder for stateless functions does feel a little bit clunky to me.

This is another area where default methods can improve readability, modularity and flexibility. The following example is inspired by the Apache Storm class model but the point is that the principle is generic enough to be applied elsewhere.

Anywhere we need the ability to interpret data passed in as a tuple, we could simply implemented TupleReader

The code in this example should be more readable because it doesn’t require additional declaration of helper classes or objects to do its work. We also know from its declaration what MyBolt is capable of. Additionally, if we need to customise any method from TupleReader, we can do so locally by overriding the relevant method.

Summary

Default interface methods (or extension methods) can be really valuable to write flexible, modular and readable code; their use extends beyond maintaining backward compatibility in Java’s standard libraries.
Using default methods to modularise behaviour has a number of advatnages:

  • Clarity: it is immediately clear from the declaration of a class, what behaviour it supports
  • Coherence: all classes sharing the same behaviour adhere to the same contract
  • Flexibility: default behaviour can be redefined where needed

The main limitation of default methods is managing state; any internal state will need to be tracked directly by the implementing class. Default methods can be combined with abstract classes to provide clean separation between compassable behaviour and state management.

RETURN TO BLOG

SHARE

Twitter LinkedIn Facebook Email

SIMILAR POSTS

Blog