Migrating to Kotlin—what to look out for
Since Google announced the support for Kotlin as a first-class language at I/O in 2017, here at Auto Trader we’ve been gradually moving our Android app codebase over to Kotlin from Java. There are some tools to help developers achieve this (such as the automatic Java to Kotlin conversion tool), but there are a number of pitfalls to be aware of when it comes to the converter and the interoperability between the languages. In this post we share some insight from our experience.
Nullable types
One of the great benefits of Kotlin over Java is the ability to declare both nullable and non-nullable types; these can be checked at compile time with the goal of avoiding the dreaded NullPointerException
at runtime:
fun acceptsANullString(canBeNullString: String?) {
// Kotlin will force the developer to deal with a null parameter
}
fun doesNotAcceptANullString(nonNullString: String) {
// use the string without worry of dereferencing a null pointer
}
Since the above is both safer and nicer to deal with as a developer, it could be tempting when writing new Kotlin code to use the automatic conversion tool to translate all of your code from Java to Kotlin. This way you’ll never have to worry about a NullPointerException
again, right?
Let’s take an example:
public class StringUtils {
public static String getFirstCharacter(String parameter) {
if (parameter != null && !parameter.isEmpty()) {
return parameter.substring(0, 1);
}
return "";
}
}
public class NameProcessor {
public String getFirstLetterCapitalised(String name) {
String firstLetter = StringUtils.getFirstCharacter(name);
return firstLetter.toUpperCase();
}
}
In this Java code, there is a class NameProcessor
, which we can pass a name, and ask for the capitalised first letter. As this is Java, it is perfectly acceptable to pass name
as null
, and the application does not crash because the StringUtils
class used to obtain the first character checks for a null parameter and handles it accordingly (by returning an empty String).
Now, let’s say in our new Kotlin code we call getFirstLetterCapitalised()
, so we decide to use the automatic code converter to convert NameProcessor
while we are doing this other work. What we end up with is this:
class NameProcessor {
fun getFirstLetterCapitalised(name: String): String {
val firstLetter = StringUtils.getFirstCharacter(name)
return firstLetter.toUpperCase()
}
}
This looks reasonable, so what is the problem? Well the eagle-eyed Kotlin developers will immediately notice that we’ve gone from a nullable name
parameter to a non-nullable one; this has unexpected consequences for us:
- Calling
getFirstLetterCapitalised()
from Kotlin code no longer does the same thing—before, passing it a null value was permitted, and we’d get an empty String as a result. Now, Kotlin will not allow us to call the function with null. - Critically, calling
getFirstLetterCapitalised()
from Java code can generate an exception at runtime where it did not previously:
Exception in thread "main" java.lang.IllegalArgumentException: Parameter specified as non-null is null: method blog.NameProcessor.getFirstLetterCapitalised, parameter name
So, we’ve gone from having null-safe code in Java to producing what is essentially aNullPointerException
again!
The reason for this is that unless there are annotations on the Java code, or evidence that a parameter can be null (such as a name != null
check in the source file), then the converter will assume that you want to deal with a non-nullable type.
The important thing to remember here is that the Kotlin code generated by the converter may not output the same bytecode as the original Java. You should be very cautious when using the converter and review the resulting code. It could be that you don’t mind some behavioural changes, but make sure you’ve thought about the usage and have made an informed decision rather than get caught out unexpectedly.
Exceptions
Over the years, Java developers have argued over the usefulness of checked exceptions. In Kotlin, all exceptions are unchecked, and we must remember this when dealing with codebases that consist of code written in both Java and Kotlin.
Once again, let’s look at an example:
public class FileProcessor {
public void process() throws IOException {
// Process some files - can throw an IOException!
}
}
public class DailyJob {
public void run() {
try {
new FileProcessor().process();
// Do some other work
} catch (IOException e) {
// Log an error
}
}
}
The FileProcessor.process()
method throws a checked exception (an IOException
) if there is an error when the code tries to access files for processing. In the Java class DailyJob
, the calling code must handle that exception with either a try/catch clause or by declaring that it can also throw an IOException
.
Since in Kotlin there are no checked exceptions, it is perfectly legal to do something like:
class DailyJob {
fun run() {
FileProcessor().process()
}
}
The result of this is that we’ve lost the intentional safety of the FileProcessor
class, and we can get exceptions at runtime for scenarios that the author of FileProcessor
expected us to handle.
Similarly, we can go ahead and convert FileProcessor
to Kotlin, and we wouldn’t need to declare that the process()
function throws an exception:
class FileProcessor {
fun process() {
// Process some files - can throw an IOException!
}
}
This is quite dangerous, because now even in calling Java code, the developer will not be forced to handle the IOException
. The correct approach is to annotate the Kotlin code as follows:
class FileProcessor {
@Throws(IOException::class)
fun process() {
// Process some files - can throw an IOException!
}
}
With the above code, the Java code will not compile until the caller has handled the checked exception, and you should look to take this approach when converting any of your code over to Kotlin.
Summary
There are a number of things to be wary of when working with Kotlin and Java in the same project—particularly when converting code. We’ve looked at a couple of issues that can critically affect your application at runtime and introduce defects that were not present before the process of migrating the code. We hope these tips will help others avoid introducing issues into their live software.
Enjoyed that? Read some other posts.