BinaryCompatibleDefaultMethods
From APIDesign
(→Why now? Not then?) |
|||
Line 91: | Line 91: | ||
Moral of the story? When trying to add [[DefaultMethods]] into existing interfaces in a 100% compatible way, also add a new type. That it has to be [[BackwardCompatible]]! | Moral of the story? When trying to add [[DefaultMethods]] into existing interfaces in a 100% compatible way, also add a new type. That it has to be [[BackwardCompatible]]! | ||
+ | |||
+ | ==== Apply the Knowledge ==== | ||
+ | |||
+ | Now, when we know how 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''': | ||
+ | <source lang="java"> | ||
+ | interface CharSequence { | ||
+ | record Status(boolean empty); | ||
+ | |||
+ | default Status status() { | ||
+ | return Status(length() == 0); | ||
+ | } | ||
+ | } | ||
+ | </source> | ||
+ | |||
+ | That'd be completely compatible evolution. People would have to write ''seq.status().empty()`` instead of simple ''seq.isEmpty()''. But does that matter? Not from a performance perspective - any good [[JIT]] compiler eliminates the overhead. Then it is just about the will to be 100% compatible and the lack of it. | ||
+ | |||
+ | Moreover this kind of evaluation "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 status() { | ||
+ | return Status(length() == 0, length() > 255, codePoints().allMatch((ch) -> ch <= 127); | ||
+ | } | ||
+ | } | ||
+ | </source> | ||
+ | |||
+ | That'd be a compatible 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! |
Revision as of 08:21, 28 September 2020
DefaultMethods are useful when one desperately needs to add a method into an existing interface. However, they decrease clarity of a ProviderAPI (no, you can't disagree!). As such, don't overuse. Morever it has been recently demonstrated that adding DefaultMethods can even compromise BinaryCompatibility. Recently Emilian Bold asked me to participate in a tweeting about binary incompatibility caused by adding CharSequence.isEmpty in JDK15. An interesting case. Following code compiles 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); } }
While the code compiles and runs fine with JDK14 and older, it can be no longer compiled on JDK15. It results in following error:
$ /jdk-14/bin/javac ArrayLike.java $ /jdk-14/bin/java CharArrayLike 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
Why? Since JDK15 there is CharSequence.isEmpty() 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 that is not that big deal. Keeping SourceCompatibility in Java is hard. SourceCompatibility can be compromised by almost any addition. However the problem is that the above change also compromises BinaryCompatibility (I'd say the javac error is more a warning of the future runtime failure than real problem for javac). Compile against JDK14 APIs, you'll get runtime error on JDK15:
$ /jdk-15/bin/javac --release 14 ArrayLike.java $ /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)
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:
final class CharArrayLike implements CharSequence, ArrayLike { @Override public boolean isEmpty() { return ArrayLike.super.isEmpty(); } }
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 separate API from SPI and don't mix the two concepts (especially in types that are frequently implemented by users of your API).
Why now? 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 a new DefaultMethods. In JDK8 the interface got two new methods:
public default IntStream chars(); public default IntStream codePoints();
No problem happened back then. How comes? Was the JDK team just lucky that no clash happened?
No, the JDK8' addition of chars() and codePoints() was BinaryCompatible! Because adding new methods into existing types cannot clash, if one of the arguments, or a return type is new. As IntStream was also added in JDK8 the methods couldn't clash with any code compiled on JDK7. A method reference in a bytecode encodes not only name of the method, but also types of all parameters and return type. As such it was impossible on JDK7 to compile method with the same signature. Quite different to JDK15 adding ```boolean isEmpty()``` identified by boolean a core type available since JDK1.0.
Moral of the story? When trying to add DefaultMethods into existing interfaces in a 100% compatible way, also add a new type. That it has to be BackwardCompatible!
Apply the Knowledge
Now, when we know how 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:
interface CharSequence { record Status(boolean empty); default Status status() { return Status(length() == 0); } }
That'd be completely compatible evolution. People would have to write seq.status().empty()`` instead of simple seq.isEmpty(). But does that matter? Not from a performance perspective - any good JIT compiler eliminates the overhead. Then it is just about the will to be 100% compatible and the lack of it.
Moreover this kind of evaluation "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 status() { return Status(length() == 0, length() > 255, codePoints().allMatch((ch) -> ch <= 127); } }
That'd be a compatible 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!