Kotlin and Java interoperability: Traps and gotchas
In Kotlin, every function declares a return type, so we return Unit
in Kotlin instead of the Java void
keyword.
Of course, practically speaking it would be inefficient to always return Unit
even when it’s not needed; so, functions with the Unit
result type are compiled to functions with no result. When their result type is needed, it is injected on the use side.
// Compiled to the equivalent of the following Java code
public static final void a() {
}
public static final void main() {
a();
Unit var0 = Unit.INSTANCE;
System.out.println(var0);
}
This is a performance optimization. The same process is used for Java functions without a result type, therefore they can be treated like they return Unit
in Kotlin.
Function types and function interfaces
Using function types from Java can be problematic. Consider the following setListItemListener
function, which expects a function type as an argument. Thanks to named arguments, Kotlin will provide proper suggestions and the usage will be convenient.
Using Kotlin function types from Java is more problematic. Not only are named parameters lost, but also it is expected that the Unit
instance is returned.
The solution to this problem is functional interfaces, i.e., interfaces with a single abstract method and the fun
modifier. After using functional interfaces instead of function types, the usage of setListItemListener
in Kotlin remains the same, but in Java it is more convenient as named parameters are understood and there is no need to return Unit
.
Tricky names
Keywords like when
or object
are reserved in Kotlin, so they cannot be used as names for functions or variables. The problem is that some of these keywords are not reserved in Java, so they might be used by Java libraries. A good example is Mockito, which is a popular mocking library, but one of its most important functions is named "when". To use this function in Kotlin, we need to surround its name with backticks (`).
Backticks can also be used to define functions or variable names that would otherwise be illegal in Kotlin. They are most often used to define unit test names with spaces in order to improve their readability in execution reports. Such function names are legal only in Kotlin/JVM, and only if they are not going to be used for code that runs on Android (unit tests are executed locally, so they can be named this way).
Throws
In Java, there are two types of exceptions:
- Checked exceptions, which need to be explicitly stated and handled in code. Checked exceptions in Java must be specified after the
throws
keyword in the declarations of functions that can throw them. When we call such functions from Java, we either need the current function to state that it might throw a specific exception type as well, or this function might catch expected exceptions. In Java, except forRuntimeException
andError
, classes that directly inheritThrowable
are checked exceptions. - Unchecked exceptions, which can be thrown "at any time" and don’t need to be stated in any way, therefore methods don't have to catch or throw unchecked exceptions explicitly. Classes that inherit
Error
orRuntimeException
are unchecked exceptions.
public class JavaClass {
// IOException are checked exceptions,
// and they must be declared with throws
String readFirstLine(String fileName) throws IOException {
FileInputStream fis = new FileInputStream(fileName);
InputStreamReader reader = new InputStreamReader(fis);
BufferedReader bufferedReader = new BufferedReader(reader);
return bufferedReader.readLine();
}
void checkFirstLine() {
String line;
try {
line = readFirstLine("number.txt");
// We must catch checked exceptions,
// or declare them with throws
} catch (IOException e) {
throw new RuntimeException(e);
}
// parseInt throws NumberFormatException,
// which is an unchecked exception
int number = Integer.parseInt(line);
// Dividing two numbers might throw
// ArithmeticException of number is 0,
// which is an unchecked exception
System.out.println(10 / number);
}
}
In Kotlin, all exceptions are considered unchecked. This leads to a problem when we use Java to call Kotlin methods that throw exceptions that are considered checked exceptions in Java. In Java, such methods must have exceptions specified after the throws
keyword. Kotlin does not generate these, therefore Java is confused. If you try to catch such an exception, Java will prohibit it, explaining that such an exception is not expected.
To solve this issue, in all Kotlin functions that are intended to be used from Java, we should use the Throws
annotation to specify all exceptions that are considered checked in Java.
When this annotation is used, the Kotlin Compiler will specify these exceptions in the throws
block in the generated JVM functions, therefore they are expected in Java.
Using the Throws
annotation is not only useful for Kotlin and Java interoperability; it is also often used as a form of documentation that specifies which exceptions should be expected.
JvmRecord
Java 16 introduced records as immutable data carriers. In simple words, these are alternatives to Kotlin data classes. Java records can be used in Kotlin just like any other kind of class. To declare a record in Kotlin, we define a data class and use the JvmRecord
annotation.
Records have more restrictive requirements than data classes. Here are the requirements for the JvmRecord
annotation to be used for a class:
- The class must be in a module that targets JVM 16 bytecode (or 15 if the
-Xjvm-enable-preview
compiler option is enabled). - The class cannot explicitly inherit any other class (including
Any
) because all JVM records implicitly inheritjava.lang.Record
. However, the class can implement interfaces. - The class cannot declare any properties that have backing fields, except these initialized from the corresponding primary constructor parameters.
- The class cannot declare any mutable properties that have backing fields.
- The class cannot be local.
- The class's primary constructor must be as visible as the class itself.
Summary
Kotlin and Java are two different languages, designed in two different centuries1, which sometimes makes it challenging when we interoperate between them. Most problems relate to important Kotlin features, like eliminating the concept of checked exceptions, or distinguishing between nullable and non-nullable types, between interfaces for read-only and mutable collections, or between shared types for primitives and wrapped primitives. Kotlin does all it can to make interoperability with Java as convenient as possible, but there are some inevitable trade-offs. For example, both the List
and MutableList
Kotlin types relate to the Java List
interface. Also, Kotlin relies on Java nullability annotations and uses platform types when they are missing. We also need to know and use some annotations that determine how our code will behave when used from Java or another JVM language. These are challenges for developers, but they’re definitely worth all the amazing features that Kotlin offers.
Java's first stable release was in 1996, while Kotlin's first stable release was around 20 years later in 2016.