Kotlin Pitfalls: Compatibility
In the previous post Kotlin Pitfalls: FunctionReference, I covered how to solve the compatibility issue caused by FunctionReference when upgrading Kotlin from 1.3 to 1.5. But that’s far from the only compatibility issue in Kotlin. How do we address Kotlin’s compatibility problems systematically?
What Are Compatibility Issues
Software compatibility issues can be broadly divided into two categories: API compatibility and ABI compatibility.
API (Application Programming Interface) Compatibility
In short, this is about interface-level compatibility, which itself falls into two subcategories:
API Deprecation
For example, Kotlin 1.5 deprecated the String.toUpperCase() API in favor of String.uppercase(). Although the API is deprecated, you can still use it – the compiler will issue a warning but won’t halt compilation.
API Removal
For example, JDK 11 removed the Thread.destroy() and Thread.stop(Throwable) APIs. If your project uses Thread.destroy(), upgrading to JDK 11 will break the build. You either find an alternative or rewrite the implementation.
ABI (Application Binary Interface) Compatibility
In short, this is about binary-level compatibility. For languages running on the JVM, binary compatibility mainly concerns bytecode compatibility, which also has two subcategories:
JVM Bytecode Version Compatibility
A typical example is the major version of class files.
Language Runtime Version Compatibility
Some Kotlin language features are implemented at the compiler level. Different versions of the Kotlin compiler may implement things differently. While Kotlin developers all call the Kotlin standard library, the compiler generates additional bytecode and even classes to implement the syntactic sugar that makes Kotlin look elegant – for instance, the ubiquitous Function types.
The Real Headache
Incompatible Bytecode
Remember the issue from Kotlin Pitfalls: FunctionReference?
1 | fun f(fn: (Any) -> Unit) {} |
If we compile the above code with org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31, we get the following bytecode:
1 | final synthetic class io/johnsonlee/kotlin/TestKt$ff$1 extends kotlin/jvm/internal/FunctionReferenceImpl implements kotlin/jvm/functions/Function1 { |
The bytecode generated by the Kotlin compiler contains content that doesn’t exist in older versions. As a result, other projects using Kotlin below 1.4 will encounter a NoSuchMethodError at runtime when they consume this bytecode.
Incompatible Metadata
Beyond class bytecode, Kotlin also generates other binary content:
- Metadata (
@Metadata) - Module mapping (
*.kotlin_module) - ……
All of these binary artifacts contain version information and version compatibility constraints.
Taking @Metadata as an example, the default compatibility strategy is that x.y is compatible with x.{y + 1}, unless versions have strict semantics.
So how are the version numbers for these binary artifacts determined?
Metadata Version
The @Metadata version is determined by the Kotlin Compiler version. For Gradle projects, this is effectively the kotlin-gradle-plugin version. Changing the kotlin-gradle-plugin version will affect the @Metadata version.
Module Mapping Version
The *.kotlin_module version is also determined by the Kotlin Compiler version and matches the @Metadata version. If there’s a version incompatibility, compilation will fail with:
1 | Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is a.b.c, expected version is x.y.z. |
Java’s Solution
Java has a systematic solution for compatibility issues. If you’ve used Gradle, you’ll remember that Java compile tasks can be configured with these two parameters:
sourceCompatibilitytargetCompatibility
For example:
1 | java { |
These correspond to the API and ABI levels mentioned earlier:
| # | Java Compiler Options | Gradle Compiler Task Options |
|---|---|---|
| API | -source |
sourceCompatibility |
| ABI | -target |
targetCompatibility |
Kotlin’s Solution
Kotlin also provides compiler options to specify versions:
| # | Kotlin Compiler Options | Gradle Compiler Task Options |
|---|---|---|
| API | -api-version |
apiVersion |
| ABI | -language-version |
languageVersion |
Usage:
1 | tasks.withType<KotlinCompile> { |
Important notes:
-api-versioncannot be greater than-language-version- Restricting
-language-versionimplicitly restricts-api-versionas well
The correspondence between Kotlin and Java compiler options:
| # | Kotlin Compiler Options | Java Compiler Options |
|---|---|---|
| API | -api-version |
-source |
| ABI | -language-version |
-target |
So Kotlin’s compatibility management is just as straightforward as Java’s. But how exactly should you use these two compiler options?
Best Practices
Unify the Kotlin Version
Your project’s Kotlin version should ideally use embeddedKotlinVersion (the version of Kotlin embedded in Gradle). For example:
1 | buildscript { |
Or:
1 | plugins { |
Specify -language-version or -api-version
Using the FunctionReference issue as an example, our goal is backward compatibility at the bytecode level – the ABI level. To ensure the generated bytecode doesn’t contain content from Kotlin 1.4 (i.e., backward compatible with Kotlin 1.3), you can specify either -language-version or -api-version:
1 | tasks.withType<KotlinCompile> { |
Or:
1 | tasks.withType<KotlinCompile> { |
With either option, the compiled bytecode becomes:
1 | io.johnsonlee.kotlin.TestKt$ff$1(); |
Notice that the FunctionReference bytecode representation has changed.
Since both options work, what’s the actual difference between -language-version and -api-version?
The difference is:
Bytecode compiled with
-language-versionwill have a@Metadataversion of1.1.18, while bytecode compiled with-api-versionwill still have a@Metadataversion of1.5.1.
This tells us that -api-version doesn’t achieve full ABI-level compatibility. -language-version has a broader impact – it not only restricts language features across versions but also constrains the versions of binary artifacts including metadata.
Although -language-version and -api-version affect the compiled bytecode content, they do not change the version of the Kotlin stdlib that your project depends on. Even with the 1.5 kotlin-gradle-plugin, if you set -language-version or -api-version to 1.3, the project’s dependencies won’t change. This is why Kotlin can achieve backward compatibility – even when some APIs are disallowed in higher versions (e.g., toLowerCase() is disallowed from 1.5 onward), the API hasn’t actually been removed. The compiler simply won’t let you use it:
1 |
If a class has already been compiled with -language-version="1.3", it will work perfectly fine with the 1.5 stdlib.
- Blog Link: https://johnsonlee.io/2022/12/07/do-you-really-know-kotlin-compatibility.en/
- Copyright Declaration: 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
