You Will Regret Applying Overloading with Lambdas!
Writing good APIs is hard. Extremely hard. You have to think of an incredible amount of things if you want your users to love your API. You have to find the right balance between:
- Usefulness
- Usability
- Backward compatibility
- Forward compatibility
We’ve blogged about this topic before, in our article: How to Design a Good, Regular API. Today, we’re going to look into how…
Java 8 changes the rules
Yes!
Overloading is a nice tool to provide covenience in two dimensions:
- By providing argument type alternatives
- By providing argument default values
Examples for the above from the JDK include:
public class Arrays { // Argument type alternatives public static void sort(int[] a) { ... } public static void sort(long[] a) { ... } // Argument default values public static IntStream stream(int[] array) { ... } public static IntStream stream(int[] array, int startInclusive, int endExclusive) { ... } }
The jOOQ API is obviously full of such convenience. As jOOQ is a DSL for SQL, we might even abuse a little bit:
public interface DSLContext { <T1> SelectSelectStep<Record1<T1>> select(SelectField<T1> field1); <T1, T2> SelectSelectStep<Record2<T1, T2>> select(SelectField<T1> field1, SelectField<T2> field2); <T1, T2, T3> SelectSelectStep<Record3<T1, T2, T3>> s select(SelectField<T1> field1, SelectField<T2> field2, SelectField<T3> field3); <T1, T2, T3, T4> SelectSelectStep<Record4<T1, T2, T3, T4>> select(SelectField<T1> field1, SelectField<T2> field2, SelectField<T3> field3, SelectField<T4> field4); // and so on... }
Languages like Ceylon take this idea of convenience one step further by claiming that the above is the only reasonable reason why overloading is be used in Java. And thus, the creators of Ceylon have completely removed overloading from their language, replacing the above by union types and actual default values for arguments. E.g.
// Union types void sort(int[]|long[] a) { ... } // Default argument values IntStream stream(int[] array, int startInclusive = 0, int endInclusive = array.length) { ... }
Read “Top 10 Ceylon Language Features I Wish We Had In Java” for more information about Ceylon.
In Java, unfortunately, we cannot use union types or argument default values. So we have to use overloading to provide our API consumers with convenience methods.
If your method argument is a functional interface, however, things changed drastically between Java 7 and Java 8, with respect to method overloading. An example is given here from JavaFX.
JavaFX’s “unfriendly” ObservableList
JavaFX enhances the JDK collection types by making them “observable”. Not to be confused with Observable
, a dinosaur type from the JDK 1.0 and from pre-Swing days.
JavaFX’s own Observable
essentially looks like this:
public interface Observable { void addListener(InvalidationListener listener); void removeListener(InvalidationListener listener); }
And luckily, this InvalidationListener
is a functional interface:
@FunctionalInterface public interface InvalidationListener { void invalidated(Observable observable); }
This is great, because we can do things like:
Observable awesome = FXCollections.observableArrayList(); awesome.addListener(fantastic -> splendid.cheer());
(notice how I’ve replaced foo/bar/baz with more cheerful terms. We should all do that. Foo and bar are so 1970)
Unfortunately, things get more hairy when we do what we would probably do, instead. I.e. instead of declaring an Observable
, we’d like that to be a much more useful ObservableList
:
ObservableList<String> awesome = FXCollections.observableArrayList(); awesome.addListener(fantastic -> splendid.cheer());
But now, we get a compilation error on the second line:
awesome.addListener(fantastic -> splendid.cheer()); // ^^^^^^^^^^^ // The method addListener(ListChangeListener<? super String>) // is ambiguous for the type ObservableList<String>
Because, essentially…
public interface ObservableList<E> extends List<E>, Observable { void addListener(ListChangeListener<? super E> listener); }
and…
@FunctionalInterface public interface ListChangeListener<E> { void onChanged(Change<? extends E> c); }
Now again, before Java 8, the two listener types were completely unambiguously distinguishable, and they still are. You can easily call them by passing a named type. Our original code would still work if we wrote:
ObservableList<String> awesome = FXCollections.observableArrayList(); InvalidationListener hearYe = fantastic -> splendid.cheer(); awesome.addListener(hearYe);
Or…
ObservableList<String> awesome = FXCollections.observableArrayList(); awesome.addListener((InvalidationListener) fantastic -> splendid.cheer());
Or even…
ObservableList<String> awesome = FXCollections.observableArrayList(); awesome.addListener((Observable fantastic) -> splendid.cheer());
All of these measures will remove ambiguity. But frankly, lambdas are only half as cool if you have to explicitly type the lambda, or the argument types. We have modern IDEs that can perform autocompletion and help infer types just as much as the compiler itself.
Imagine if we really wanted to call the other addListener()
method, the one that takes a ListChangeListener. We’d have to write any of
ObservableList<String> awesome = FXCollections.observableArrayList(); // Agh. Remember that we have to repeat "String" here ListChangeListener<String> hearYe = fantastic -> splendid.cheer(); awesome.addListener(hearYe);
Or…
ObservableList<String> awesome = FXCollections.observableArrayList(); // Agh. Remember that we have to repeat "String" here awesome.addListener((ListChangeListener<String>) fantastic -> splendid.cheer());
Or even…
ObservableList<String> awesome = FXCollections.observableArrayList(); // WTF... "extends" String?? But that's what this thing needs... awesome.addListener((Change<? extends String> fantastic) -> splendid.cheer());
Overload you shan’t. Be wary you must.
API design is hard. It was hard before, it has gotten harder now. With Java 8, if any of your API methods’ arguments are a functional interface, think twice about overloading that API method. And once you’ve concluded to proceed with overloading, think again, a third time whether this is really a good idea.
Not convinced? Have a close look at the JDK. For instance the java.util.stream.Stream
type. How many overloaded methods do you see that have the same number of functional interface arguments, which again take the same number of method arguments (as in our previous addListener()
example)?
Zero.
There are overloads where overload argument numbers differ. For instance:
<R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner); <R, A> R collect(Collector<? super T, A, R> collector);
You will never have any ambiguity when calling collect()
.
But when the argument numbers do not differ, and neither do the arguments’ own method argument numbers, the method names are different. For instance:
<R> Stream<R> map(Function<? super T, ? extends R> mapper); IntStream mapToInt(ToIntFunction<? super T> mapper); LongStream mapToLong(ToLongFunction<? super T> mapper); DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);
Now, this is super annoying at the call site, because you have to think in advance what method you have to use based on a variety of involved types.
But it’s really the only solution to this dilemma. So, remember: You Will Regret Applying Overloading with Lambdas!
Reference: | You Will Regret Applying Overloading with Lambdas! from our JCG partner Lukas Eder at the JAVA, SQL, AND JOOQ blog. |