Kotlin Default Interface Methods and Binary Compatibility

TL;DR - adding default interface functions in Kotlin constitutes a source-compatible (but not necessarily a binary-compatible) change.

Whenever I used to run across issues mentioning binary compatibility, I used to always think, “this is super interesting, but that’s for library developers, I don’t have to worry about this while working on apps.” Little did I know how wrong I was.

This is a short story of my first encounter with an AbstractMethodError. But first, allow me to give a brief explanation of the landscape that made this possible.

Libraries and Modularization

As companies have to build and support multiple apps, it starts to make sense to share code or features across the apps. Larger companies sometimes achieve this sharing by referencing code via monorepos.

For smaller companies, pushing internal libraries and features to a private maven repository is a good way to achieve this. This is where my story begins.

Setting the Stage

Let’s say I have a common styles library that I share between my apps. Imagine that version 1.0 of this library, net.helw.common.ui:style:1.0, has one interface that looks like this:

interface ColorProvider {
  fun colorPrimary(): Int
  fun colorSecondary(): Int
}

Let’s also say that I share a common login feature between my apps - and suppose that version 1.0 of my login feature, net.helw.app.feature:login:1.0, has this implementation:

class LoginColorProvider: ColorProvider {
  override fun colorPrimary() = 42
  override fun colorSecondary() = 42
}

My Android app (which depends on net.helw.app.feature:login:1.0 and on net.helw.common.ui:style:1.0) has a class that looks like this:

object ColorUtil {
  fun getContrastingColor(colorProvider: ColorProvider): Int {
    // logic here
  }
}

So far everything is great!

A Well-Intentioned Change

The designer asks me to add a new colorPrimaryVariant, and says that “in some cases it can be the same as colorPrimary, but generally it will be different.”

Ok, so I’ll just update ColorProvider to support colorPrimaryVariant and publish a new version. I don’t want to break compatibility, so I will use a default method, thinking I’ll be safe:

interface ColorProvider {
  fun colorPrimary(): Int
  fun colorSecondary(): Int
  fun colorPrimaryVariant(): Int = colorPrimary()
}

I update the net.helw.common:ui:style library to 1.1 and push it to maven.

I also update my app to use this 1.1 version of the library and update getContrastingColor call to use this colorPrimaryVariant method and use it in my computation somehow. My app still depends on versions 1.0 of login.

I compile my app and run it. I expected everything to work, but instead, I was greeted with an error that looked like this:

Exception in thread "main" java.lang.AbstractMethodError: ColorUtil.colorPrimaryVariant()

Digging Deeper

If we look at the decompiled Java bytecode for ColorProvider after adding the method, it looks something like this:

public interface ColorProvider {
  int colorPrimary();
  int colorSecondary();
  int colorPrimaryVariant();

  public static final class DefaultImpls {
    public static int colorPrimaryVariant(ColorProvider $this) {
      return $this.colorPrimary();
    }
  }
}

The decompiled Java bytecode for my LoginColorProvider implementation looks something like this:

public final class LoginColorProvider implements ColorProvider {
  public int colorPrimary() {
   return 42;
  }

  public int colorSecondary() {
    return 42;
  }

  public int colorPrimaryVariant() {
    return DefaultImpls.colorPrimaryVariant(this);
  }
}

So we can see that at compile-time, Kotlin added an implementation of colorPrimaryVariant to my interface, asking it to call the static method it added.

But if this is the case, why did it break?

Classes in the AAR

An aar typically contains (among resources and other things) a file named classes.zip. This file contains .class files of all the code in the library (not source files). This means that my login aar file contains class files as they were compiled against the 1.0 version of my styles library.

In other words, the decompiled Java code of my LoginColorProvider really looks like this:

public final class LoginColorProvider implements ColorProvider {
  public int colorPrimary() {
    return 42;
  }

  public int colorSecondary() {
    return 42;
  }

  // ← No colorPrimaryVariant()!
}

If I were to recompile the login module against the 1.1 version of styles, the new method implementation would be there (calling the static method), but since I didn’t, it is nowhere to be seen.

Putting it all Together

My login feature was compiled against styles 1.0 (without the new method). But because my actual app used styles 1.1 to call the new method, it requested Gradle to use styles 1.1. Gradle saw the conflict and upgraded everything to use 1.1. Everything compiles fine - but at runtime, we get an AbstractMethodError because the new method can’t be found.

This is an example of binary compatibility breaking.

Solution

The easiest solution here is to publish an updated versions of login that depends on the 1.1 version of the style library.

Alternatively, instead of adding a default implementation, we can have sub-interfaces and the code can check against them as is appropriate. For example:

interface VariantColorProvider : ColorProvider {
  fun colorPrimaryVariant(): Int = colorPrimary()
}

This way, getContrastingColor could look like this:

object ColorUtil {
  fun getContrastingColor(colorProvider: ColorProvider): Int {
    return when (colorProvider) {
      is VariantColorProvider -> colorProvider.colorPrimaryVariant()
      else -> colorProvider.colorPrimary()
    }
  }
}

There doesn’t seem to be any static analysis solutions for catching this type of issue, but the japicmp tool can be used to detect when a binary incompatible change is made between two library versions.

There is, however, one more solution.

What about Java 8 / @JvmDefault?

I was curious, “are the default interface methods in Java 8 binary compatible?” - turns out that the answer to this is “mostly.” The JLS says:

Adding a default method, or changing a method from abstract to default, does not break compatibility with pre-existing binaries, but may cause an IncompatibleClassChangeError if a pre-existing binary attempts to invoke the method.

(Note that the JLS link also shows an interesting case where you’d get an IncompatibleClassChangeError that’s worth looking at. Because of this, japicmp, the open source API comparison tool, intentionally marks new methods in interfaces as errors).

In our case, if we were asking Kotlin to output Java 8 code with the -Xjvm-default=enable flag (and with the @JvmDefault annotation), everything would actually work. (Note: Zac Sweers has an in-depth article about @JvmDefault that’s worth reading).

On Android, if we annotate the default method as @JvmDefault and set the -Xjvm-default=enable flag, D8 will actually add a synthetic method to call through to the static method, even if the class was compiled against the old version of the library!

Surprisingly, @JvmDefault is currently experimental.

~

Special thanks to Zac Sweers for reviewing this post.

comments powered by Disqus