Effective Java with Groovy - Favour Composition to Inheritance
One of the key lessons from the Gang-of-Four Design Patterns is to favour composition over inheritance to achieve better maintainability in our Object-Oriented design. However, most of the codebases written in Java seem to favour inheritance over composition. The main force behind this is the ease of implementing inheritance with Java. However, this decision turns out to be an adversary to maintainability and, hence, the cost of ownership of the application in the long run. Maybe the developers don't think their applications will survive the test of time!
Suppose we want to create a collection that holds phone numbers. Let us call this collection type as PhoneNumbers
. I also want the collection to have a method indianPhoneNumbers()
, which will output all the phone numbers that belong to India from the original collection of phone numbers. I want some of the methods available on java.util.List
to be available on PhoneNumbers
type, such as size()
, isEmpty()
etc. Note that I am not interested in the methods such as subList()
. Hence, PhoneNumbers
is not a substitute for ArrayList
.
Java
Since we know it is better to use composition than inheritance here because PhoneNumbers
is not a substitute for ArrayList
. Let us start with the composition.
public class PhoneNumbers implements Iterable<String> {
private final List<String> storage = new ArrayList<>();
public void addPhoneNumber(String phoneNumber) {
storage.add(phoneNumber);
}
@Override
public Iterator<String> iterator() {
return storage.iterator();
}
public List<String> indianPhoneNumbers() {
return storage
.stream()
.filter(number -> number.startsWith("+91"))
.toList();
}
// Implement size(), isEmpty() ...
public int size() {
return storage.size();
}
}
Our PhoneNumbers
type uses an ArrayList
to store the phone numbers. We have added the method indianPhoneNumbers
. However, we still need to ensure that other List methods are available, such as size()
, isEmpty()
etc. There are many methods in java.util.List
. If we need them here, adding them will be a huge effort.
At this point, many developers would repent for embracing composition and returning to inheritance. The resultant design would look as follows.
public class PhoneNumbers extends ArrayList<String> {
public void addPhoneNumber(String phoneNumber) {
super.add(phoneNumber);
}
public List<String> indianPhoneNumbers() {
return super
.stream()
.filter(number -> number.startsWith("+91"))
.toList();
}
public static void main(String[] args) {
PhoneNumbers phoneNumbers = new PhoneNumbers();
phoneNumbers.addPhoneNumber("+9112312312");
phoneNumbers.addPhoneNumber("+4112312312");
phoneNumbers.addPhoneNumber("+9112312313");
for (String phoneNumber : phoneNumbers) {
System.out.println(phoneNumber);
}
System.out.println(phoneNumbers.indianPhoneNumbers());
System.out.println(phoneNumbers.size()); // inherited method
}
}
For convenience, I have added the main
method inside PhoneNumbers
. This design choice saves you from hundreds of lines of code to implement the methods of ArrayList
.
Groovy
Let us see how Groovy helps here.
Using Delegates
Groovy provides a feature to delegate the method invocations on the current instance to another instance contained in the current instance. This feature is available through the AST transformer groovy.lang.Delegate
.
class PhoneNumbers {
private @Delegate List<String> phoneNumbers
PhoneNumbers(List<String> numbers) {
this.phoneNumbers = numbers
}
def indianNumbers() {
phoneNumbers.findAll { it.startsWith("+91") }
}
static void main(String[] args) {
def phoneNumbers = ['+9112312312', '+4112312312', '+9112312313'] as PhoneNumbers
println phoneNumbers
println phoneNumbers.indianNumbers()
println phoneNumbers.size()
}
}
In the above example, we have marked instance variable phoneNumbers
, which is an instance of java.util.List
with @Delegate
. When we try to invoke the method size()
on an instance of PhoneNumbers
, that class doesn't contain a method named size
. Hence, an attempt is made to invoke that method on the phoneNumbers
, which indeed contains the size()
method.
When we invoke the method indianNumbers()
on phoneNumbers
, the instance already has the method with that name, and hence it will be executed as expected.
There is not much magic under the hood here. If you had to implement composition in Java, whatever code you would have written, the Groovy compiler generates the same code here and is available in the class file/byte code. Hence, there isn't any runtime overhead. Also, if the target type gets additional methods in the future, all you have to do is recompile your code. The compiler will generate the additional methods with delegating logic.
Also, it is possible to specify methods we want to delegate to the target type. This way, you do not need to delegate every method of List
.
Using Traits
There are times when we want to add multiple capabilities to the objects of a class. Suppose we have two capabilities, say CanSing
and CanDance
. We have two classes, Person
and Pet
. Let us assume a person can sing and dance while a pet can only dance. In a typical Java implementation, these capabilities would be interfaces. If you are not careful, you might end up with duplicate implementations across Person
and Dog
. The solution to avoid duplicate implementations of the capabilities is to extract them into separate classes. But then we would end up with the same problem we encountered while implementing composition in the phone number example in Java above.
trait CanSing {
def sing() {
println 'Singing ...'
}
}
trait CanDance {
def dance() {
println 'Dancing ...'
}
}
class Person implements CanSing, CanDance {}
class Pet implements CanDance {}
def person = new Person()
person.sing()
person.dance()
def pet = new Pet()
pet.dance()
It might seem like using the default methods available in Java. However, Groovy will generate separate interface and implementation classes for each trait. The classes implement the generated interfaces, and in the implementation code for the interface methods within the classes, the call will be delegated to the actual implementation class.
Note that we are not writing boilerplate code when using either the @Delegate
or traits. Thus, Groovy helps its users to be productive. Also, the code is easy to read as it contains only the essence.
More details on the trait are for another post!
To learn more about Effective Java with Groovy, refer to the collection of posts here.