Skip to content

Latest commit

 

History

History
212 lines (160 loc) · 8.82 KB

switching-to-kotlin.md

File metadata and controls

212 lines (160 loc) · 8.82 KB

Opinion: Switching to Kotlin makes less sense as Java evolves

  • A list of arguments for switching to Kotlin that I've seen that I don't believe really hold up well.
    • Lots of features Kotlin and other languages pioneered are now in core Java

What this is not:

  • An exhaustive list for every possible defense of the argument of sticking with Java
  • An assertion that you should switch back to Java if you're already using it.
  • An assertion that Java is "better" than Kotlin (Even if it is)

Argument: Kotlin has var

Java 10, JEP-286 does too, but in most cases I would advise against using it. I find that clearly defined typing makes code much easier to digest for new developers looking at your code... and for yourself after a months long break. There's less potential confusion about how a type is used if you know its intended type. The case to use var in my opinion is when using a really poorly written generic type that you cannot cleanup yourself. For example Map<EntityType, EntityFactory<InstanceSupplier<Entity>>>, is just painfully verbose. One solution would be to create a wrapper type like EntityFactoryMap extends ... then use that as the declared type. You could even use a forwarding map as the parent type to keep it flexible and reduce boilerplate.

Argument: Kotlin has data classes

Java 16, JEP-395 brings Records to the language. They are also much more memory effecient due to the backend changes of Valhalla.

record Point(int x, int y) {}

With argument validation:

record Range(int lo, int hi) {
    Range {
        if (lo > hi)  // referring here to the implicit constructor parameters
            throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
    }
}

With parameter normalization:

record Rational(int num, int denom) {
    Rational {
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
    }
}

Argument: Kotlin has null safety

Kotlins compiler has this extra step which tracks what values can be null and warn about this.

In Java we have tools like NullAway and FindBugs that can do exactly the same. FindBugs is more lenient with the default configuration than Kotlin, but its nothing that can't be a one-and-done configuration.

And more recently IDE's have added null safety checks. Here's an example of IntelliJ warning about a possible NPE:

NPE on latch

Argument: Kotlin has smart casting

So does Java as of Java 14, JEP 305.

if (obj instanceof String s && s.length() > 5) { 
	// can use s here
}

But kotlin is shorter! obj is now treated as a string!

While it is true that this is shorter, it is also more brittle. Consider the following: public class MyHandler implements HandlerOne, HandlerTwo.

HandlerOne x = getHandler();
if (x instanceof HandlerTwo) {
    // If we assume 'x' is now 'HandlerTwo' we lose the ability to refer
    // to it as 'HandlerOne'. Not every 'HandlerOne' has to be a 'HandlerTwo'. 
    // Calling a method of 'HandlerOne' now requires casting.
}
if (x instanceof HandlerTwo y) {
    // Using a separate variable we have access to both:
    //  - 'x' as 'HandlerOne'
    //  - 'y' as 'HandlerTwo'
}

Another "brittle" problem that could arise would be unintented breakage of existing logic. Assuming there is a method foo(HandlerOne) and foo(HandlerTwo), when you wrap calls to one with an x instanceof <the-other-handler> then it is your responsibility to update those calls by including a cast to the orignal type of x.

Last point on being "brittle", data isn't always constant. The approach of assuming x is now a different type does not work for other expression types that are not local variables. For instance, direct references to fields. Within the scope of a field cast such as if (field instanceof HandlerTwo) the field value could be re-assigned. The approach of "copying" that reference to a second variable mitigates this issue. And for method call expressions

Argument: Kotlin has coroutines

So does Java, JEP-425. For an elaborate comparison and deep dive into the implementation details of both check "Kotlin Coroutines vs Java Virtual Threads — A good story, but just that…". The TLDR is they're comparable, one is not inheriently better than the other.

Argument: Kotlin has sealed classes

So does Java, JEP-409.

Argument: Kotlin has lambdas

I've not found an instance of Kotlin-specific lambda usage that results in code that is easier to digest than an inline Java functional interface. I'm of the mindset that consistency and clear design are goals to strive towards to ensure future maintainability. Kotlin allows you to declare, invoke, and pass lambdas in a variety of ways. This is a "convivence" that I find only serves to bring inconsistency into the language design.

It is also worth mentioning Kotlin's handling of lambda's can lead to some unexpected behaviors if you are not explicit with your type usage. See: https://youtu.be/Ta5wBJsC39s?t=1836

From the video, this Kotlin source does not function as expected. The listener is never removed:

val w = Widget()

val listener = { widget: Widget -> println("Listened to $widget") }

w.addListener(listener)
println(w.listeners.size())

w.removeListener(listener)
println(w.listeners.size())

Why does this simple add/remove not work? Well, if you take a look at the bytecode/decompiled logic it becomes obvious:

Function1 listener = ...

Object var10001 = listener;
if (listener != null) 
    var10001 = new LambdasKt$sam$Widget_Listener$0(listener);
w.addListener((Listener) var10001);

var10001 = listener;
if (listener != null) 
    var10001 = new LambdasKt$sam$Widget_Listener$0(listener);
w.removeListener((Listener) var10001);

See how listener gets wrapped in the compiler-generated type and the calls to add/remove take in the generated value stored in var10001 instead of directly using the listener variable?

Argument: Kotlin has multi-line strings

So does Java, JEP-378, and it does so more efficiently with under-the-hood invoke-dynamic usage.

// Kotlin
println("""
 Hey $name!
   Hey $name!
    Hey $name!
""".trimIndent())
// Java
System.out.println("""
 Hey %s!
   Hey %s!
    Hey %s!
""".formatted(name, name, name));

The latter is more performant.

Argument: Kotlin has pattern matching

Switches in Java 12, JEP-325. The Java version can be more concise with some specific when cases but for the most part they're comparable.

Advanced when example :

// Kotlin
val download: Download = //...
val result = when (download) {
  is App -> {
    val (name, developer) = download
    when (developer) {
      is Person -> 
        if (developer.name == "Alice") {
          "Alice's app ${name}"
        } else {
          "Not by Alice"
        }
      else -> "Not by Alice"
    }
  is Movie ->
    val (title, directory) = download
    if (director.name = "Alice") {
      "Alice's movie ${title}"
    } else {
      "Not by Alice"
    }
// Java
Download download = //...
var result = switch(download) {
  case App(String name, Person("Alice", _)) -> "Alice's app " + name
  case Movie(String title, Person("Alice", _)) -> "Alice's movie " + title
  case App(_), Movie(_) -> "Not by Alice"
};

Community Opinions


Counter: Things Kotlin has that Java does not have:

  • Local methods.
    • A branch for this was created last year in Project Amber though.
  • Lambdas that can take non-final values
  • Extension methods
    • I'd argue these are massive code-smells though
  • Default argument constructors
  • Multi-property setting with apply()
  • Elvis operator