JAVA / ABSTRACT CLASSES AND INTERFACES
Default methods and evolving an interface safely
Add methods to an interface that is already in use, using default bodies that keep existing implementors compiling, and judge when a default is actually safe.
What you will learn
- Add a method to a published interface with default so old implementors still compile
- Write default bodies using only the interface's own methods; interfaces hold no state
- Predict resolution: a class or superclass method always beats an interface default
- Re-declare a method abstract in a sub-interface to force implementors to supply it
Understanding Default methods and evolving an interface safely
An interface that other people have already implemented is frozen in a way a class is not: adding one more abstract method turns every existing implementor into a compile error, and a class file compiled against the old version can fail with AbstractMethodError when the new method is called. A default method solves that by shipping a body along with the declaration, so an implementor that has never heard of the method inherits a working version, and neither its source nor its already-compiled class file has to change. That is how Java 8 could add forEach to Iterable and removeIf to Collection without invalidating a decade of existing collection classes.
The mental model is a fallback, not shared code with privileges: a default method runs on this, but an interface has no fields and no constructor, so the body can only call the interface's own abstract methods, its other defaults, and static helpers. The test before adding one is therefore whether the new operation can be expressed using nothing but the contract you already published; boolean isEmpty() { return size() == 0; } passes, while anything that needs a cache field or a lock does not. When the answer is no, the honest options are a new interface, a new abstract method with a version bump, or a default that throws UnsupportedOperationException to mark the operation as optional.
Resolution is what makes the change safe to compile: a method declared in the class wins over any interface default, and a concrete method inherited from a superclass wins too, so a default only fills a hole nobody else filled. The cost is that this safety is structural, not semantic. A default that is merely plausible will run silently on implementations where it is wrong, and an implementor that happened to declare a same-signature method years earlier quietly becomes the override of a contract it never read, so document exactly what an override must guarantee.
import java.util.List;
import java.util.Optional;
interface Query { // v1 shipped with run() only
List<String> run();
// v2 additions: the bodies mean old implementors need no edits
default int count() {
return run().size();
}
default Optional<String> first() {
List<String> rows = run();
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
}
class AllUsers implements Query { // written against v1, never touched
public List<String> run() {
return List.of("ana", "bo", "cy");
}
}
class NoUsers implements Query {
public List<String> run() {
return List.of();
}
@Override
public int count() { // this implementor knows a cheaper answer
return 0;
}
}
public class Main {
public static void main(String[] args) {
Query all = new AllUsers();
System.out.println(all.count() + " " + all.first().orElse("none"));
Query none = new NoUsers();
System.out.println(none.count() + " " + none.first().orElse("none"));
}
}A default method lets an interface grow by shipping a fallback body that existing implementors inherit for free, which removes the compile-time break but moves the risk to semantics.
Worked examples
A pre-existing method silently becomes the override
Shows that class-declared methods beat interface defaults, and how that can capture a new default by accident.
interface Report {
String body();
// added in v2
default int size() {
return body().length();
}
}
class Memo implements Report {
public String body() { return "abc"; }
}
class LegacyReport implements Report {
public String body() { return "abcdefgh"; }
// written long before v2; here "size" meant page count
public int size() { return 2; }
}
public class Main {
public static void main(String[] args) {
System.out.println(new Memo().size());
System.out.println(new LegacyReport().size());
Report r = new LegacyReport();
System.out.println(r.size());
}
}Example explained
Line 1Memo declares no size(), so the call resolves to the interface default and returns body().length(), which is 3.
Line 2LegacyReport already declared size(), and a class-declared method always outranks an interface default, so 2 is returned.
Line 3Through the Report reference the answer is still 2: dispatch follows the object's class, not the declared type.
Line 4LegacyReport is now an override of a contract it never agreed to, and no @Override annotation or warning marks it.
A default that refuses: adding an optional operation
Uses a throwing default to add a method that only some implementors can honour.
import java.util.HashMap;
import java.util.Map;
interface Cache {
String get(String key);
// v2: optional operation, overridden by implementors that can do it
default void evict(String key) {
throw new UnsupportedOperationException(
"evict is not supported by " + getClass().getSimpleName());
}
}
class ReadOnlyCache implements Cache {
public String get(String key) { return "v:" + key; }
}
class MapCache implements Cache {
private final Map<String, String> data = new HashMap<>();
MapCache() { data.put("a", "1"); }
public String get(String key) { return data.get(key); }
@Override
public void evict(String key) { data.remove(key); }
}
public class Main {
public static void main(String[] args) {
Cache ro = new ReadOnlyCache();
System.out.println(ro.get("a"));
try {
ro.evict("a");
} catch (UnsupportedOperationException e) {
System.out.println(e.getMessage());
}
Cache m = new MapCache();
System.out.println(m.get("a"));
m.evict("a");
System.out.println(m.get("a"));
}
}Example explained
Line 1The default throws instead of inventing behaviour, so ReadOnlyCache compiles unchanged while evict still exists on the type.
Line 2getClass() works inside the default because a default method has a this reference, even though the interface stores no state.
Line 3MapCache overrides evict, so a caller holding only a Cache gets real removal and get returns null afterwards.
Line 4Callers must now handle the exception, and the compiler cannot warn them, which is why this pattern is a last resort.
Re-abstracting a default in a sub-interface
Shows how a narrower interface can withdraw an inherited default and force implementors to answer for themselves.
interface Encoder {
String encode(String s);
default String name() { return "anonymous"; }
}
interface RegisteredEncoder extends Encoder {
@Override
String name(); // no body: the inherited default is removed again
}
class Identity implements Encoder {
public String encode(String s) { return s; }
}
class Upper implements RegisteredEncoder {
public String encode(String s) { return s.toUpperCase(); }
public String name() { return "upper"; }
}
public class Main {
public static void main(String[] args) {
Encoder a = new Identity();
Encoder b = new Upper();
System.out.println(a.name() + " -> " + a.encode("abc"));
System.out.println(b.name() + " -> " + b.encode("abc"));
}
}Example explained
Line 1Identity implements only Encoder, so it inherits the default and reports anonymous with no code of its own.
Line 2RegisteredEncoder redeclares name() without a body, which erases the inherited default for anything implementing it.
Line 3Upper must therefore declare name(); deleting that method is a compile error rather than a silent fallback.
Line 4This tightens the contract for new implementors while leaving the original default in place for existing ones.
Important notes
Adding a default can still break an implementor that already inherits the same signature from a second interface; that class is then forced to disambiguate before it compiles.
Inside an override you can reuse the inherited body with `Encoder.super.name()`; a plain `super.name()` refers to the superclass, not the interface.
Common mistakes
Trying to keep state in the default: an interface has no fields or constructor, so `this.counter` will not compile, and reaching for a static field instead gives one value shared by every instance.
Writing `default String toString()` or a default equals/hashCode: the compiler rejects it outright, because a method inherited from Object would always outrank an interface default anyway.
Treating "it still compiles" as "it is safe": a default like `count() { return run().size(); }` can execute an entire query on a lazy implementor, and the bad behaviour appears at run time in someone else's class.
Try it yourself
Change, predict, then run
Start from `interface Playlist { List<String> tracks(); }` and add `default int total()` plus `default boolean isEmpty()` written only in terms of tracks(). Then write one implementor that keeps both defaults and one that overrides isEmpty() to answer without building the list, and print both results.
Open the Java workspaceCheck your understanding
You add `default boolean isEmpty() { return size() == 0; }` to a published interface Bag. An existing class SackBag already declared `public boolean isEmpty()` that returns true only when a "sealed" flag is set. What happens when code holding a Bag reference calls isEmpty() on a SackBag?
- SackBag compiles unchanged and its own isEmpty() runs, so the default never applies to that class
- SackBag no longer compiles, because two implementations of isEmpty() are visible to it
- The default runs, because the caller's variable is declared as the interface type Bag
- SackBag must add @Override to its isEmpty(), otherwise the interface default is used
Show answer
A method declared in the class always outranks an interface default, so nothing breaks at compile time and SackBag's flag-based logic runs, quietly becoming the override of a contract it was never written against. Option 3 is tempting because the caller only sees the Bag type, but the declared type selects the signature while the object's class selects the implementation; @Override in option 4 is only documentation and changes no resolution.