"API design is like sex: make one mistake and support it for the rest of your life" (Josh Bloch on twitter)
There are many design mistakes in the Java library. Stack extends Vector
(discussion), and we can't fix that without causing breakage. We can try to deprecate Integer.getInteger
(discussion), but it's probably going to stay around forever.
Nonetheless, certain kinds of retrofitting can be done without causing breakage.
Effective Java 2nd Edition, Item 18: Prefer interfaces to abstract classes: Existing classes can be easily retrofitted to implement a new interface".
Examples: String implements CharSequence
, Vector implements List
, etc.
Effective Java 2nd Edition, Item 42: Use varargs judiciously: You can retrofit an existing method that takes an array as its final parameter to take varags instead with no effect on existing clients.
An (in)famous example is Arrays.asList
, which caused confusions (discussion), but not breakage.
This question is about a different kind of retrofitting:
void
method to return something without breaking existing code?My initial hunch points to yes, because:
void
to return something is legal (but not the other way around!)Class.getMethod
doesn't distinguish on return typeHowever, I'd like to hear a more thorough analysis by others more experienced in Java/API design.
As suggested in the title, one motivation is to facilitate fluent interface style programming.
Consider this simple snippet that prints a shuffled list of names:
List<String> names = Arrays.asList("Eenie", "Meenie", "Miny", "Moe");
Collections.shuffle(names);
System.out.println(names);
// prints e.g. [Miny, Moe, Meenie, Eenie]
Had Collections.shuffle(List)
been declared to return the input list, we could have written:
System.out.println(
Collections.shuffle(Arrays.asList("Eenie", "Meenie", "Miny", "Moe"))
);
There are other methods in Collections
that would've been much more pleasant to use if they were to return the input list instead of void
, e.g. reverse(List)
, sort(List)
, etc. In fact, having Collections.sort
and Arrays.sort
return void
is especially unfortunate, because it deprives us from writing expressive code such as this:
// DOES NOT COMPILE!!!
// unless Arrays.sort is retrofitted to return the input array
static boolean isAnagram(String s1, String s2) {
return Arrays.equals(
Arrays.sort(s1.toCharArray()),
Arrays.sort(s2.toCharArray())
);
}
This void
return type preventing fluency isn't just restricted to these utility methods, of course. The java.util.BitSet
methods could've also been written to return this
(ala StringBuffer
and StringBuilder
) to facilitate fluency.
// we can write this:
StringBuilder sb = new StringBuilder();
sb.append("this");
sb.append("that");
sb.insert(4, " & ");
System.out.println(sb); // this & that
// but we also have the option to write this:
System.out.println(
new StringBuilder()
.append("this")
.append("that")
.insert(4, " & ")
); // this & that
// we can write this:
BitSet bs1 = new BitSet();
bs1.set(1);
bs1.set(3);
BitSet bs2 = new BitSet();
bs2.flip(5, 8);
bs1.or(bs2);
System.out.println(bs1); // {1, 3, 5, 6, 7}
// but we can't write like this!
// System.out.println(
// new BitSet().set(1).set(3).or(
// new BitSet().flip(5, 8)
// )
// );
Unfortunately, unlike StringBuilder
/StringBuffer
, ALL of BitSet
's mutators return void
.
Unfortunately, yes, changing a void
method to return something is a breaking change. This change will not affect source code compatibility (i.e. the same Java source code would still compile just like it did before, with absolutely no discernible effect) but it breaks binary compatibility (i.e. bytecodes that was previously compiled against the old API will no longer run).
Here are the relevant excerpts from the Java Language Specification 3rd Edition:
13.2 What Binary Compatibility Is and Is Not
Binary compatibility is not the same as source compatibility.
13.4 Evolution of Classes
This section describes the effects of changes to the declaration of a class and its members and constructors on pre-existing binaries.
13.4.15 Method Result Type
Changing the result type of a method, replacing a result type with
void
, or replacingvoid
with a result type has the combined effect of:
- deleting the old method, and
- adding a new method with the new result type or newly
void
result.13.4.12 Method and Constructor Declarations
Deleting a method or constructor from a class may break compatibility with any pre-existing binary that referenced this method or constructor; a
NoSuchMethodError
may be thrown when such a reference from a pre-existing binary is linked. Such an error will occur only if no method with a matching signature and return type is declared in a superclass.
That is, while the return type of a method is ignored by the Java compiler at compile-time during the method resolution process, this information is significant at run-time at the JVM bytecode level.
A method's signature does not include the return type, but its bytecode descriptor does.
8.4.2 Method Signature
Two methods have the same signature if they have the same name and argument types.
15.12 Method Invocation Expressions
15.12.2 Compile-Time Step 2: Determine Method Signature
The descriptor (signature plus return type) of the most specific method is one used at run time to perform the method dispatch.
15.12.2.12 Example: Compile-Time Resolution
The most applicable method is chosen at compile time; its descriptor determines what method is actually executed at run time.
If a new method is added to a class, then source code that was compiled with the old definition of the class might not use the new method, even if a recompilation would cause this method to be chosen.
Ideally, source code should be recompiled whenever code that it depends on is changed. However, in an environment where different classes are maintained by different organizations, this is not always feasible.
A little inspection of the bytecode will help clarify this. When javap -c
is run on the name shuffling snippet, we see instructions like this:
invokestatic java/util/Arrays.asList:([Ljava/lang/Object;)Ljava/util/List;
\______________/ \____/ \___________________/\______________/
type name method parameters return type
invokestatic java/util/Collections.shuffle:(Ljava/util/List;)V
\___________________/ \_____/ \________________/|
type name method parameters return type
[Ljava.lang.Object;
?Let's now address why retrofitting a new interface
or a vararg, as explained in Effective Java 2nd Edition, does not break binary compatibility.
13.4.4 Superclasses and Superinterfaces
Changing the direct superclass or the set of direct superinterfaces of a class type will not break compatibility with pre-existing binaries, provided that the total set of superclasses or superinterfaces, respectively, of the class type loses no members.
Retrofitting a new interface
does not cause the type to lose any member, hence this does not break binary compatibility. Similarly, due to the fact that varargs is implemented using arrays, this kind of retrofitting also does not break binary compatibility.
8.4.1 Formal Parameters
If the last formal parameter is a variable arity parameter of type
T
, it is considered to define a formal parameter of typeT[]
.
double…
and double[]
in formal parameter type declarationActually, yes, there is a way to retrofit a return value on previously void
methods. We can't have two methods with the same exact signatures at the Java source code level, but we CAN have that at the JVM level, provided that they have different descriptors (due to having different return types).
Thus we can provide a binary for e.g. java.util.BitSet
that has methods with both the void
and non-void
return types simultaneously. We only need to publish the non-void
version as the new API. In fact, that's the only thing we can publish at the API, since having two methods with the exact same signature is illegal in Java.
This solution is a terrible hack, requiring special (and spec-defying) processing to compile BitSet.java
to BitSet.class
, and thus it may not be worth the effort to do so.
If you can't retrofit, you still can wrap your class into a new one which uses the same methods but with correct returns (MyClassFluent).
Or you can add new methods but with different names, instead of Arrays.sort()
we could have Arrays.getSorted()
.
I think that the solution isn't to force things, just deal with them.
EDIT: I know I didn't answer to the "retrofitting of void methods" question, but your answer is already really clear.
If you have to deal with source compatability only, then just go ahead. Changing from void to a return type will not break.
But to address the thing you actually want to do: I consider the problem with fluent interfaces is that the lines tend to get rather long and - after formatting - somewhat unreadable. For builders it works fine, but I'd probably not use it for anything else.
Is this for playing with it, or because you've found that this is really great?
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With