BinaryCompatibleDefaultMethods

From APIDesign

(Difference between revisions)
Jump to: navigation, search
Current revision (05:59, 15 October 2020) (edit) (undo)
(Future Benefits)
 
(55 intermediate revisions not shown.)
Line 1: Line 1:
-
[[DefaultMethods]] are useful when one desperately needs to add a method into an existing '''interface'''. However, they decrease [[clarity]] of a [[ProviderAPI]] (no, you [[DefaultMethods#Can_you_disagree.3F|can't disagree!]]). As such, don't overuse. Morever it has been recently demonstrated that adding [[DefaultMethods]] can even compromise [[BinaryCompatibility]]. Recently [https://twitter.com/emilianbold/status/1308677540408709125?s=20 Emilian Bold asked me] to participate in a tweeting about [[BinaryCompatible|binary incompatibility]] caused by adding '''CharSequence.isEmpty''' in JDK15. An interesting case. Following code compiles on JDK8 to JDK14:
+
[[DefaultMethods]] are useful when one desperately needs to add a method into an existing '''interface'''. There are reasons to [[DefaultMethods#Can_you_disagree.3F|strive to avoid doing that]], however sometimes there is just no way around [[DefaultMethods|doing so]]. Then it comes handy that [[DefaultMethods]] are available since '''JDK8'''. However, use with care. It has recently been demonstrated that adding [[DefaultMethods]] can compromise [[BinaryCompatibility]].
 +
 
 +
=== Binary Incompatibility in CharSequence ===
 +
 
 +
Recently [https://twitter.com/emilianbold/status/1308677540408709125?s=20 Emilian Bold asked me] to participate in a tweeting about [[BinaryCompatible|binary incompatibility]] caused by adding new {{JDK|java/lang|CharSequence}}'''.isEmpty''' default method in '''JDK15'''. An interesting case. To summarize: Following code compiles and runs on '''JDK8''' to '''JDK14''':
<source lang="java">
<source lang="java">
Line 39: Line 43:
</source>
</source>
-
While the code compiles and runs fine with '''JDK14''' and older, it can be no longer compiled on '''JDK15'''. It results in following error:
+
However trying to run the code on '''JDK15''', gives a linkage error:
<source lang="bash">
<source lang="bash">
Line 45: Line 49:
$ /jdk-14/bin/java CharArrayLike
$ /jdk-14/bin/java CharArrayLike
not empty: false
not empty: false
-
$ /jdk-15/bin/javac ArrayLike.java
 
-
ArrayLike.java:9: error: types CharSequence and ArrayLike are incompatible;
 
-
final class CharArrayLike implements CharSequence, ArrayLike {
 
-
^
 
-
class CharArrayLike inherits unrelated defaults for isEmpty() from types CharSequence and ArrayLike
 
-
1 error
 
-
</source>
 
-
 
-
Why? Since '''JDK15''' there is '''CharSequence.isEmpty()''' [[DefaultMethods|default method]]. As such, when '''javac''' processes the '''CharArrayLike''' class it doesn't know whether to select the '''ArrayLike.isEmpty()''' for delegation or the newly added method. [[SourceCompatibility]] is compromised!
 
-
 
-
However keeping [[SourceCompatibility]] in [[Java]] is hard. It can be compromised by almost any addition. However the problem is that the above change also compromises [[BinaryCompatibility]]. Compile against, '''JDK14''' [[API]]s, you'll get runtime error on '''JDK15''':
 
-
 
-
<source lang="bash">
 
-
$ /jdk-15/bin/javac --release 14 ArrayLike.java
 
$ /jdk-15/bin/java CharArrayLike
$ /jdk-15/bin/java CharArrayLike
Exception in thread "main" java.lang.IncompatibleClassChangeError: Conflicting default methods: java/lang/CharSequence.isEmpty ArrayLike.isEmpty
Exception in thread "main" java.lang.IncompatibleClassChangeError: Conflicting default methods: java/lang/CharSequence.isEmpty ArrayLike.isEmpty
Line 65: Line 55:
</source>
</source>
-
Well, that's bad. A code that used to work on previous version of [[Java]] no longer works on new one. Why? Because adding methods (even [[DefaultMethods]]) to classes that are be implemented by 3rd parties is never 100% [[BinaryCompatible]]. The fix is simple. Just add:
+
Why it fails? Since '''JDK15''' there is the new '''CharSequence.isEmpty()''' [[DefaultMethods|default method]]. As such, when the [[JVM]] processes the '''CharArrayLike''' class it doesn't know whether to dispatch to the '''ArrayLike.isEmpty()''' method or to the newly added '''CharSequence.isEmpty()''' method.
 +
 
 +
=== Dispatch Directly ===
 +
 
 +
It is unfortunate that a code that used to run on older [[JDK]]s, doesn't run and leads to a linkage error on '''JDK15'''. The fix is however ''simple'' - just override the method and dispatch manually:
<source lang="java">
<source lang="java">
Line 74: Line 68:
}
}
}
}
-
</code>
+
</source>
 +
 
 +
Such fix has to be made across all affected places. Then all applications using affected libraries need to update to their latest versions. Only then such applications can run on '''JDK15'''. While the fix is ''simple'', the organizational effort no longer sounds as ''simple'', right? Clearly [[DefaultMethods]] aren't a heaven sent solution, they have some cost! If you are starting to write a new [[API]] and you want to avoid your customers paying that cost, then [[APIvsSPI|separate API from SPI]] and don't mix the [[APIvsSPI|two concepts]] (especially in types that are frequently subclassed/implemented by users of your [[API]]).
 +
 
 +
=== Why now? Why not then? ===
 +
 
 +
It would be possible to end the story here, but let's go on. The '''CharSequence.isEmpty()''' case is not the first time a {{JDK|java/lang|CharSequence}} interface was enhanced with additional [[DefaultMethods]]. In '''JDK8''' the interface got two new methods:
 +
 
 +
<source lang="java">
 +
public default IntStream chars();
 +
public default IntStream codePoints();
 +
</source>
 +
 
 +
No problem happened back then. No clash appeared. How come? Was it just a pure luck?
 +
 
 +
No! It wasn't a luck. The '''JDK8''' addition of '''chars()''' and '''codePoints()''' was [[BinaryCompatible]]! Because adding new methods into an existing type cannot create a clash, if ''one of the arguments, or the return type is a newly introduced type''!
 +
 
 +
The {{JDK|java/util/stream|IntStream}} return type of both methods was also added in '''JDK8''' - as such the methods couldn't clash with any code compiled on '''JDK7''' (a method invocation encodes name of methods and types of parameters as well as return type in the [[bytecode]]). As such it was impossible on '''JDK7''' to compile method with exactly the same signature as '''chars()''' or '''codePoints()'''.
 +
 
 +
Moral of the story? When trying to add [[DefaultMethods]] into existing interfaces in a 100% [[BinaryCompatible]] way, also add a new type! Adding simple signatures like '''boolean isEmpty()''' may clash. Adding complex signature like '''IntStream codePoints()''' cannot!
 +
 
 +
=== Apply the Knowledge ===
 +
 
 +
Now, when we have seen a way to enhance interfaces with [[DefaultMethods]] compatibly, let's continue the mental experiment and apply the know-how to the '''CharSequence.isEmpty()''' case. Let's be super modern and let's use '''record''' feature (not really necessary, any '''final class''' will do as well):
 +
<source lang="java">
 +
interface CharSequence {
 +
/** @since 15 */
 +
record Status(boolean empty);
 +
 
 +
/** @since 15 */
 +
default Status is() {
 +
return Status(length() == 0);
 +
}
 +
}
 +
</source>
 +
 
 +
That'd be completely [[BinaryCompatible]] evolution. People would have to write ''seq.is().empty()'' instead of simpler ''seq.isEmpty()''. But does that matter? Not from a [[performance]] perspective - every [[good]] [[JIT]] compiler eliminates any overhead. Then it is just about the will to be 100% [[BinaryCompatible]] or the lack of it.
 +
 
 +
=== Future Benefits ===
 +
 
 +
Moreover this kind of [[evolution]] "mounts" an open space of [[ClientAPI]] on the {{JDK|java/lang|CharSequence}} interface. E.g. in future versions it will be easy to expand the '''Status''' class with new attributes. For example in JDK23 one could add:
 +
 
 +
<source lang="java">
 +
interface CharSequence {
 +
record Status(boolean empty, boolean large, boolean asciiOnly);
 +
 +
default Status is() {
 +
return Status(length() == 0, length() > 255, codePoints().allMatch((ch) -> ch <= 127);
 +
}
 +
}
 +
</source>
 +
 
 +
That'd be a [[BinaryCompatible]] change (if one follows [[RecordEvolution]] rules properly). And so on, so on: New attributes could be added to the '''Status''' over time. Adding [[DefaultMethods]] to interfaces in a 100% [[BinaryCompatible]] way is possible at the end!
 +
 
 +
 
 +
[[Category:APIDesignPatterns]]
 +
[[Category:APIDesignPatterns:Evolution]]
 +
 
-
but such fix has to be made across all libraries. All applications using such libraries need to update to their latest versions and only then they can run on '''JDK15'''. Clearly [[DefaultMethods]] aren't a heaven sent solution, they have some cost! If you are writing an [[API]] and want to avoid your customers paying that cost, then [[APIvsSPI|separate API from SPI]] and don't mix it (especially in types that are frequently implemented by users of your [[API]]).
+
Reactions to some input obtained after publishing this essay are [[ScienceOfAPIDesign|available here]]...

Current revision

DefaultMethods are useful when one desperately needs to add a method into an existing interface. There are reasons to strive to avoid doing that, however sometimes there is just no way around doing so. Then it comes handy that DefaultMethods are available since JDK8. However, use with care. It has recently been demonstrated that adding DefaultMethods can compromise BinaryCompatibility.

Contents

Binary Incompatibility in CharSequence

Recently Emilian Bold asked me to participate in a tweeting about binary incompatibility caused by adding new CharSequence.isEmpty default method in JDK15. An interesting case. To summarize: Following code compiles and runs on JDK8 to JDK14:

public interface ArrayLike {
    int length();
 
    default boolean isEmpty() {
        return length() == 0;
    }
}
 
final class CharArrayLike implements CharSequence, ArrayLike {
    private final char[] chars;
 
    CharArrayLike(char... chars) {
        this.chars = chars;
    }
 
    @Override
    public int length() {
        return chars.length;
    }
 
    @Override
    public char charAt(int index) {
        return chars[index];
    }
 
    @Override
    public CharSequence subSequence(int start, int end) {
        return new String(chars, start, end);
    }
 
    public static void main(String... args) {
        boolean empty = new CharArrayLike('E', 'r', 'r', 'o', 'r', '!').isEmpty();
        System.err.println("not empty: " + empty);
    }    
}

However trying to run the code on JDK15, gives a linkage error:

$ /jdk-14/bin/javac ArrayLike.java
$ /jdk-14/bin/java CharArrayLike 
not empty: false
$ /jdk-15/bin/java CharArrayLike 
Exception in thread "main" java.lang.IncompatibleClassChangeError: Conflicting default methods: java/lang/CharSequence.isEmpty ArrayLike.isEmpty
        at CharArrayLike.isEmpty(ArrayLike.java)
        at CharArrayLike.main(ArrayLike.java:32)

Why it fails? Since JDK15 there is the new CharSequence.isEmpty() default method. As such, when the JVM processes the CharArrayLike class it doesn't know whether to dispatch to the ArrayLike.isEmpty() method or to the newly added CharSequence.isEmpty() method.

Dispatch Directly

It is unfortunate that a code that used to run on older JDKs, doesn't run and leads to a linkage error on JDK15. The fix is however simple - just override the method and dispatch manually:

final class CharArrayLike implements CharSequence, ArrayLike {
    @Override
    public boolean isEmpty() {
        return ArrayLike.super.isEmpty();
    }
}

Such fix has to be made across all affected places. Then all applications using affected libraries need to update to their latest versions. Only then such applications can run on JDK15. While the fix is simple, the organizational effort no longer sounds as simple, right? Clearly DefaultMethods aren't a heaven sent solution, they have some cost! If you are starting to write a new API and you want to avoid your customers paying that cost, then separate API from SPI and don't mix the two concepts (especially in types that are frequently subclassed/implemented by users of your API).

Why now? Why not then?

It would be possible to end the story here, but let's go on. The CharSequence.isEmpty() case is not the first time a CharSequence interface was enhanced with additional DefaultMethods. In JDK8 the interface got two new methods:

public default IntStream chars();
public default IntStream codePoints();

No problem happened back then. No clash appeared. How come? Was it just a pure luck?

No! It wasn't a luck. The JDK8 addition of chars() and codePoints() was BinaryCompatible! Because adding new methods into an existing type cannot create a clash, if one of the arguments, or the return type is a newly introduced type!

The IntStream return type of both methods was also added in JDK8 - as such the methods couldn't clash with any code compiled on JDK7 (a method invocation encodes name of methods and types of parameters as well as return type in the bytecode). As such it was impossible on JDK7 to compile method with exactly the same signature as chars() or codePoints().

Moral of the story? When trying to add DefaultMethods into existing interfaces in a 100% BinaryCompatible way, also add a new type! Adding simple signatures like boolean isEmpty() may clash. Adding complex signature like IntStream codePoints() cannot!

Apply the Knowledge

Now, when we have seen a way to enhance interfaces with DefaultMethods compatibly, let's continue the mental experiment and apply the know-how to the CharSequence.isEmpty() case. Let's be super modern and let's use record feature (not really necessary, any final class will do as well):

interface CharSequence {
  /** @since 15 */
  record Status(boolean empty);
 
  /** @since 15 */
  default Status is() {
     return Status(length() == 0);
  }
}

That'd be completely BinaryCompatible evolution. People would have to write seq.is().empty() instead of simpler seq.isEmpty(). But does that matter? Not from a performance perspective - every good JIT compiler eliminates any overhead. Then it is just about the will to be 100% BinaryCompatible or the lack of it.

Future Benefits

Moreover this kind of evolution "mounts" an open space of ClientAPI on the CharSequence interface. E.g. in future versions it will be easy to expand the Status class with new attributes. For example in JDK23 one could add:

interface CharSequence {
  record Status(boolean empty, boolean large, boolean asciiOnly);
 
  default Status is() {
     return Status(length() == 0, length() > 255, codePoints().allMatch((ch) -> ch <= 127);
  }
}

That'd be a BinaryCompatible change (if one follows RecordEvolution rules properly). And so on, so on: New attributes could be added to the Status over time. Adding DefaultMethods to interfaces in a 100% BinaryCompatible way is possible at the end!


Reactions to some input obtained after publishing this essay are available here...

Personal tools
buy