No need to hate Java default methods

#java Aug 5, 2021 6 min Mike Kowalski

While browsing Twitter a few days ago, I’ve been reminded about a quite controversial topic in the Java community, which (apparently) still is using interface default methods. To be honest, I’ve been a bit skeptical as well when I’ve first seen them coming as a part of Java 8. However, today I’ll write a few words in defense of default methods, as when used carefully, they can turn out to be extremely handy.

For the sake of compatibility

What’s really unfortunate, the official Java Tutorials site doesn’t provide an extensive explanation for why default methods have arrived in Java at all, focusing (at the time of writing) only on a single aspect - compatibility.

Default methods enable you to add new functionality to the interfaces of your libraries and ensure binary compatibility with code written for older versions of those interfaces.

Ensuring binary backward compatibility between the releases was always a huge selling point of Java. According to those guarantees, each Java version should support running the code compiled by the older JDK. Adding new methods to an interface like java.util.Collection would instantly violate this property and force tons of classes implementing it to be updated.

Preserving binary compatibility was a huge challenge in terms of adding new features to Java 8. Introducing Streams semantics (in its current shape) wouldn’t be possible without using default methods like stream(), which enabled adding new behavior without enforcing any modifications on the classes implementing java.util.Collection interface.

// java.util.Collection

/**
    Returns a sequential Stream with this collection as its source.
    (...)
*/
default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
}

This use case has convinced many members of the Java community, that default methods were intended only as a workaround for the binary compatibility guarantees, and should not be used for different purposes. But if so, how to justify their usage within the java.util.function.Predicate interface introduced in Java 8 as well?

Predicate case study

Let’s become a Java archeologist for a moment and take a look at the initial version of the java.util.function.Predicate interface (full source here):

// javadocs omitted

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t); // the only non-default method here!

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }
}

The test method was the only non-default there, as a consequence of the @FunctionalInterface annotation presence. Predicate interface did not exist before Java 8, so there was absolutely no need to use default methods for compatibility reasons. So, why did Java maintainers decided to implement it like that? The answer is straightforward: to make the API experience better. Just take a look at this short example:

// store predicates as objects
Predicate<Employee> isDev = emp -> emp.getPosition() == DEVELOPER;
Predicate<Employee> worksMoreThanYear = 
    emp -> emp.getYearsWorking() > 1;
Predicate<Employee> hasBonusAlreadyPaid = Employee::isBonusPaid;

// and then simply combine them!        
Predicate<Employee> shouldGetBonus = isDev.and(
    worksMoreThanYear.or(hasBonusAlreadyPaid.negate())
);

Let’s pretend for a moment, that default methods never happened. In such a case, we would probably end up with a utility class like Predicates exposing corresponding static methods, leading to the code like this:

Predicate<Employee> shouldGetBonus = Predicates.and(
    isDev, 
    Predicates.or(worksMoreThanYear, Predicates.negate(hasBonusAlreadyPaid))
);

Of course, static imports could remove some of the clutter around, but the version with default methods still feels more like written in natural language. As we can see, the default methods may be also used for improving the API usage experience.

The new abstract classes?

Another common accusation against using default methods is that they feel like an alternative to the abstract classes. Let’s try to address this point as well.

First of all, the way I like to think about the difference between interfaces and abstract classes (in general) is that interfaces define only behavior, while abstract classes may also define the state.

An interface with a default method still defines only behavior, but at the same time provides an implementation that should work out of the box for all classes implementing it. What’s interesting, those classes may not care at all about the new method, as there might be no need to override it or it might be intended for “external usage” only. In other words, default methods often extend the behavior without any visible impact on classes implementing their interface.

If we would take a look at the Predicate one more time, it will turn out that all its default methods are using only the behavior of the Predicate interface itself (apart from two methods from the Objects helper class that should be considered more like a built-in language construct). As the new behavior depends only on the Predicate contract, it feels perfectly fine to make it a part of it.

As stated clearly in Effective Java, Third Edition by Joshua Bloch:

When there is an obvious implementation of an interface method in terms of other interface methods, consider providing implementation assistance to programmers in the form of a default method.

Such an “obvious implementation” seems to be exactly the case for Predicate methods like negate().

Of course, with abstract classes, we can define such an “implemented behavior” too, by simply exposing a non-abstract method. However, the difference is all about the consequences of inheritance…

A tale of multi inheritance

Giving the voice back to Effective Java:

If you use abstract classes to define types, you leave the programmer who wants to add functionality with no alternative but inheritance.

Java permits single inheritance to avoid the horror of multi inheritance known from languages like C++. However, using abstract classes (or inheritance at all) still comes with the cost of limited flexibility, which is one of the reasons why Effective Java advices preferring composition over inheritance.

And that’s how we reached the final nail in the default methods’ coffin - they effectively bring multi inheritance back to Java! Have we ignored the painful lessons of the past?

Yes, default methods do introduce a notion of multi inheritance, but also cleverly avoid almost all the issues with such an approach. Unlike abstract classes, interfaces are not “infectious” in terms of inheritance. In other words, implementing an interface does not enforce any inheritance structure on such a class. That’s in my opinion a huge win in terms of flexibility.

@BrianGoetz
July 31, 2021
Yes, they are perfectly ok for multiple inheritance of behavior, and this was our intent! The quote you cite needs historical context.

The motivation for *adding default methods to Java* was that interface evolution was too hard. That doesn’t mean this is their only use.
July 31, 2021

The power of misusage

Similar to another controversial language feature - var syntax - many prejudices come from misusage. For example, people critiquing var often refer to the scenarios of limited readability, where explicit type declarations for variables are almost gone. The truth is: every feature or pattern can be misused, and this does not make it less useful.

The same applies to the default methods - they are probably not a generic solution for defining behavior, which should be our first choice. However, for functional interfaces or cases of the “obvious implementation” mentioned before, they can really shine.

Every new tool has its learning curve. While trying, we are most likely gonna misuse it few times, but that’s a completely normal thing. All best practices are a result of experience - including positive and negative one.

Coda

Like every significant addition to the Java language, default methods have been introduced for a reason. Allowing API evolution while preserving binary compatibility may be one of their main usages, but it’s definitely not the only possible one.

Java internals are an interesting source of examples, that could help to understand the purpose of each language feature. In terms of default methods, we can spot there attempts of improving the readability or simply making the API more pleasant to use thanks to them.

In my opinion, default methods can turn out to be very useful in some scenarios, so it’s worth to having them in our Java programming toolbox.

Mike Kowalski

Software engineer believing in craftsmanship and the power of fresh espresso. Writing in & about Java, distributed systems, and beyond. Mikes his own opinions and bytes.