What I miss in Java, the perspective of a Kotlin developer

Nicolas Fränkel - Jun 13 '22 - - Dev Community

Java has been my bread and butter for almost two decades. Several years ago, I started to learn Kotlin; I never regretted it.

Though Kotlin compiles to JVM bytecode, I sometimes have to write Java again. Every time I do, I cannot stop pondering why my code doesn't look as nice as in Kotlin. I miss some features that would improve my code's readability, expressiveness, and maintainability.

This post is not meant to bash Java but to list some features I'd like to find in Java.

Immutable references

Java has immutable references since the beginning:

  • For attributes in classes
  • For parameters in methods
  • For local variables
class Foo {

    final Object bar = new Object();      // 1

    void baz(final Object qux) {          // 2
        final var corge = new Object();   // 3
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Cannot reassign bar
  2. Cannot reassign qux
  3. Cannot reassign corge

Immutable references are a great help in avoiding nasty bugs. Interestingly, using the final keyword is not widespread, even in widely used projects. For example, Spring's GenericBean uses immutable attributes, but neither immutable method parameters nor local variables; slf4j's DefaultLoggingEventBuilder uses none of the three.

While Java allows you to define immutable references, it's not mandatory. By default, references are mutable. Most Java code doesn't take advantage of immutable references.

Kotlin doesn't leave you a choice: every property and local variable needs to be defined as either a val or a var. Plus, one cannot reassign method parameters.

The var Java keyword is quite different. First, it's only available for local variables. More importantly, it doesn't offer its immutable counterpart, val. You still need to add the static keyword, which nearly nobody uses.

Null safety

In Java, there's no way to know whether a variable is null. To be explicit, Java 8 introduced the Optional type. From Java 8 onward, returning an Optional implies the underlying value can be null; returning another type implies it cannot.

However, the developers of Optional designed it for return values only. Nothing is available in the language syntax for methods parameters and return values. To cope with this, a bunch of libraries provides compile-time annotations:

Project Package Non-null annotation Nullable annotation
JSR 305 javax.annotation @Nonnull @Nullable
Spring org.springframework.lang @NonNull @Nullable
JetBrains org.jetbrains.annotations @NotNull @Nullable
Findbugs edu.umd.cs.findbugs.annotations @NonNull @Nullable
Eclipse org.eclipse.jdt.annotation @NonNull @Nullable
Checker framework org.checkerframework.checker.nullness.qual @NonNull @Nullable

Obviously, some libraries are focused on specific IDEs. Moreover, libraries are hardly compatible with one another. So many libraries are available that somebody on StackOverflow asked which one to use. The resulting activity is telling.

Finally, using a nullability library is opt-in. On the other side, Kotlin requires every type to be either nullable or non-nullable.

val nonNullable: String = computeNonNullableString()
val nullable: String? = computeNullableString()
Enter fullscreen mode Exit fullscreen mode

Extension functions

In Java, one extends a class by subclassing it:

class Foo {}
class Bar extends Bar {}
Enter fullscreen mode Exit fullscreen mode

Subclassing has two main issues. The first issue is that some classes don't allow it: they are marked with the final keyword. A couple of widely-used JDK classes are final, e.g., String. The second issue is that if a method outside our control returns a type, one is stuck with that type, whether it contains the wanted behavior or not.

To work around the above issues, Java developers have invented the concept of utility classes, usually named XYZUtils for type XYZ. A utility class is a bunch of static methods with a private constructor, so it cannot be instantiated. It's a glorified namespace because Java doesn't allow methods outside classes.

This way, if a method doesn't exist in a type, the utility class can provide a method that takes the type as a parameter and execute the required behavior.

class StringUtils {                                          // 1

    private StringUtils() {}                                 // 2

    static String capitalize(String string) {                // 3
        return string.substring(0, 1).toUpperCase()
            + string.substring(1);                           // 4
    }
}

String string = randomString();                              // 5
String capitalizedString = StringUtils.capitalize(string);   // 6
Enter fullscreen mode Exit fullscreen mode
  1. Utility class
  2. Prevent instantiation of new objects of this type
  3. static method
  4. Simple capitalization that doesn't account for corner cases
  5. The String type doesn't offer a capitalization functionality
  6. Use an utility class to factor this behavior

Note that earlier, developers created such classes inside the project. Nowadays, the ecosystem offers Open Source libraries such as Apache Commons Lang or Guava. Don't reinvent the wheel!

Kotlin provides extension functions to solve the same issue.

Kotlin provides the ability to extend a class or an interface with new functionality without having to inherit from the class or use design patterns such as Decorator. We can achieve it via special declarations called extensions.

For example, you can write new functions for a class or an interface from a third-party library that you can't modify. Such functions can be called in the usual way as if they were methods of the original class. This mechanism is called an extension function.

To declare an extension function, prefix its name with a receiver type, which refers to the type being extended.

With extension functions, one can rewrite the above code as:

fun String.capitalize2(): String {                           // 1-2
    return substring(0, 1).uppercase() + substring(1);
}

val string = randomString()
val capitalizedString = string.capitalize2()                 // 3
Enter fullscreen mode Exit fullscreen mode
  1. Free-floating function, no need for a class wrapper
  2. capitalize() already exists in Kotlin's stdlib
  3. Call the extension function as if it belonged to the String type

Note that extension functions are resolved "statically". They don't really attach new behavior to the existing type; they pretend to do so. The generated bytecode is very similar (if not the same) to one of Java static methods. However, the syntax is much clearer and allows for function chaining, which is impossible with Java's approach.

Reified generics

Version 5 of Java brought generics. However, the language designers were keen on preserving backward compatibility: Java 5 bytecode was required to interact flawlessly with pre-Java 5 bytecode. That's why generic types are not written in the generated bytecode: it's known as type erasure. The opposite is reified generics, where generic types would be written in the bytecode.

Generic types being only a compile-time concern creates a couple of issues. For example, the following method signatures produce the same bytecode, and thus, the code is not valid:

class Bag {
    int compute(List<Foo> persons) {}
    int compute(List<Bar> persons) {}
}
Enter fullscreen mode Exit fullscreen mode

Another issue is how to get a typed value out of a container of values.
Here's a sample from Spring:

public interface BeanFactory {
    <T> T getBean(Class<T> requiredType);
}
Enter fullscreen mode Exit fullscreen mode

Developers added a Class<T> parameter to be able to know the type in the method body. If Java had reified generics, it wouldn't be necessary:

public interface BeanFactory {
    <T> T getBean();
}
Enter fullscreen mode Exit fullscreen mode

Imagine if Kotlin had reified generics. We could change the above design:

interface BeanFactory {
    fun <T> getBean(): T
}
Enter fullscreen mode Exit fullscreen mode

And to call the function:

val factory = getBeanFactory()
val anyBean = getBean<Any>()               // 1
Enter fullscreen mode Exit fullscreen mode
  1. Reified generics!

Kotlin still needs to comply with the JVM specification and be compatible with bytecode generated by the Java compiler. It can work via a trick called inlining: the compiler replaces the inlined method calls by the function body.

Here's the Kotlin code to make it work:

inline fun <reified T : Any> BeanFactory.getBean(): T = getBean(T::class.java)
Enter fullscreen mode Exit fullscreen mode

Conclusion

I've described four Kotlin features that I miss in Java in this post: immutable references, null safety, extension functions, and reified generics. While Kotlin offers other great features, these four are enough to make the bulk of improvements over Java.

For example, with extension functions and reified generics plus a bit of syntactic sugar, one can easily design DSLs, such as the Kotlin Routes and Beans DSL:

beans {
  bean {
    router {
      GET("/hello") { ServerResponse.ok().body("Hello world!") }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Make no mistake: I understand that Java has much more inertia to improve as a language, while Kotlin is inherently more nimble. However, competition is good, and both can learn from each other.

In the meantime, I'll only write Java when I have to, as Kotlin has become my language of choice on the JVM.

To go further:

Originally published at A Java Geek on June 12th, 2022

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .