Zero-cost* abstractions in Kotlin

Kotlin Vocabulary: inline classes

Florina Muntenescu
Android Developers
Published in
7 min readJan 22, 2020

--

*Terms and Conditions apply

⚠️ [May 2021 Update] The inline modifier for inline classes is now deprecated!⚠️

Warning: this blog post covers a Kotlin experimental feature, subject to change. This article was written using Kotlin 1.3.50.

Type safety prevents us from making errors or having to debug them later. For Android resource types, like String, Font or Animation resources, we can use androidx.annotations like @StringRes, @FontRes and Lint enforces that we pass a parameter of the correct type:

fun myStringResUsage(@StringRes string: Int){ }// Error: expected resource of type String
myStringResUsage(1)

If our id is not an Android resource, but rather an id for a domain object like a Doggo or a Cat, then differentiating between these two Int ids can’t be easily done. To achieve type-safety which encodes that the id of a dog is not the same as a cat’s, you’d have to wrap your id in a class. The downside of this is that you pay a performance cost, as a new object needs to be instantiated when actually, all you need is a primitive.

Kotlin inline classes allow you to create these wrapper types without the performance cost. This is an experimental feature added in Kotlin 1.3. Inline classes must have exactly one property. At compile time, inline classes instances are replaced with their underlying property (are unboxed) where possible, reducing the performance cost of a regular wrapper class. This matters even more for cases where the wrapped object is a primitive type as the compiler already optimizes them. So wrapping a primitive type in an inline class leads, where possible, to the value represented as primitive values at runtime.

inline class DoggoId(val id: Long)
data class Doggo(val id: DoggoId, … )
// usage
val goodDoggo = Doggo(DoggoId(doggoId), …)
fun pet(id: DoggoId) { … }

Get inline

The sole role of an inline class is to be a wrapper around a type so Kotlin enforces a number of restrictions:

  • No more than one parameter (no limitation on the type)
  • No backing fields
  • No init blocks
  • No extending classes

But, inline classes can:

  • Inherit from interfaces
  • Have properties and functions
interface Id
inline class DoggoId(val id: Long) : Id {

val stringId
get() = id.toString()
fun isValid()= id > 0L
}

⚠️ Warning: Typealias might seem similar to inline classes but, type aliases just provide an alternate name for existing types, while inline classes create a new type.

Representation — wrapped or not?

Since the biggest advantage of inline classes over manual wrapper classes is the memory allocation impact, it’s important to remember that this heavily depends on where and how you’re using inline classes. The general rule is that the parameter will be wrapped (boxed) if the inline class is used as another type.

A parameter is boxed when used as another type

For example, if you’re using it when an Object or Any is expected such as in collections, arrays or as nullable objects. Depending on how you’re checking two inline classes for structural equality, one or none of them will be boxed:

val doggo1 = DoggoId(1L)val doggo2 = DoggoId(2L)

doggo1 == doggo2 — neither doggo1 and doggo2 are boxed

doggo1.equals(doggo2) — doggo1 is used as a primitive but doggo2 is boxed

Under the hood

Let’s take a simple inline class:

interface Id
inline class DoggoId(val id: Long) : Id

Let’s see what the decompiled Java programming language code looks like step by step and what implications they have for using inline classes. You can find the full decompiled code here.

Under the hood — constructors

The DoggoId has two constructors:

  • A private synthetic constructor DoggoId(long id)
  • A public constructor-impl

When creating a new instance of the object the public constructor is used:

val myDoggoId = DoggoId(1L)// decompiled
static final long myDoggoId = DoggoId.constructor-impl(1L);

If we try to create the doggo id in Java, we’ll get an error:

DoggoId u = new DoggoId(1L);
// Error: DoggoId() in DoggoId cannot be applied to (long)

You can’t instantiate an inline class from Java

The parameterized constructor is a private and the second constructor contains a - in the name — an invalid character in Java. This means that inline classes can’t be instantiated from Java.

Under the hood — parameter usage

The id is exposed in two ways:

  • As a primitive, via a getId
  • As an object via a box_impl method that creates a new instance of the DoggoId

When the inline class is used where a primitive could have been used, then the Kotlin compiler will know this and will directly use the primitive:

fun walkDog(doggoId: DoggoId) {}// decompiled Java code
public final void walkDog_Mu_n4VY(long doggoId) { }

When an object is expected, then the Kotlin compiler will use the boxed version of our primitive, leading to a new object creation every time.

When an object is expected, then the Kotlin compiler will use the boxed version of our primitive, leading to a new object creation every time. For example:

Nullable objects

fun pet(doggoId: DoggoId?) {}// decompiled Java code
public static final void pet_5ZN6hPs/* $FF was: pet-5ZN6hPs*/(@Nullable InlineDoggoId doggo) {}

Because only objects can be nullable, then the boxed implementation is used.

Collections

val doggos = listOf(myDoggoId)// decompiled Java code
doggos = CollectionsKt.listOf(DoggoId.box-impl(myDoggoId));

The signature of CollectionsKt.listOf is:

fun <T> listOf(element: T): List<T>

Because this method expects an object, then the Kotlin compiler boxes our primitive, making sure that an object is used.

Base classes

fun handleId(id: Id) {}fun myInterfaceUsage() {
handleId(myDoggoId)
}
// decompiled Java code
public static final void myInterfaceUsage() {
handleId(DoggoId.box-impl(myDoggoId));
}

Because here we’re expecting a super type, then the boxed implementation is used.

Under the hood — Equality checks

The Kotlin compiler tries to use the unboxed parameter wherever possible. To do this, inline classes have 3 different implementations for equality: an override of equals and 2 generated methods:

doggo1.equals(doggo2)

The equals method calls a generated method: equals_impl(long, Object). Since equals expects an object, the doggo2 value will be boxed, but doggo1 will be used as a primitive:

DoggoId.equals-impl(doggo1, DoggoId.box-impl(doggo2))

doggo1 == doggo2

Using == generates:

DoggoId.equals-impl0(doggo1, doggo2)

So in the == case, the primitive will be used for both doggo1 and doggo2.

doggo1 == 1L

If Kotlin is able to determine that doggo1 is actually a long, then you’d expect that this equality check to work. But, because we are using inline classes for their type safety, then, the first thing the compiler will do is to check whether the type of the two objects we’re trying to compare is the same. And since it’s not, we’ll get a compiler error: Operator == can’t be applied to long and DoggoId. In the end, for the compiler, this is just as if we’d said cat1 == doggo1, which is definitely not true.

doggo1.equals(1L)

This equality check does compile because the Kotlin compiler uses the equals implementation that expects a long and an Object. But, because the first thing this method does is to check the type of the Object, this equality check will be false, as the Object is not a DoggoId.

Overriding functions with primitive and inline class parameters

The Kotlin compiler allows defining functions with both primitive and non-nullable inline class as parameters:

fun pet(doggoId: Long) {}
fun pet(doggoId: DoggoId) {}
// decompiled Java code
public static final void pet(long id) { }
public final void pet_Mu_n4VY(long doggoId) { }

In the decompiled code, we can see that for both functions, the primitive is used.

To allow this functionality, the Kotlin compiler will mangle the name of the function taking the inline class as a parameter.

Using inline classes in Java

We already saw that we can’t instantiate an inline class in Java. How about using them?

✅ Passing inline classes to Java functions

We can pass them as parameters to be used as objects and we can get the property they wrap.

void myJavaMethod(DoggoId doggoId){
long id = doggoId.getId();
}

✅ Using inline classes instances in Java functions

If we have inline classes instances defined as top level objects, we can get a reference to them in Java as primitives, as

// Kotlin declaration
val doggo1 = DoggoId(1L)
// Java usage
long myDoggoId = GoodDoggosKt.getU1();

✅ & ❌Calling Kotlin functions that have inline classes as parameters

If we have a Java function that receives an inline class parameter and we want to call a Kotlin function that accepts an inline class, we get a compile error:

fun pet(doggoId: DoggoId) {}// Java
void petInJava(doggoId: DoggoId){
pet(doggoId)
// compile error: pet(long) cannot be applied to pet(DoggoId)
}

For Java, DoggoId is a new type, but the compiler generates pet(long) and pet(DoggoId) doesn’t exist.

But, we are able to pass the underlying type:

fun pet(doggoId: DoggoId) {}// Java
void petInJava(doggoId: DoggoId){
pet(doggoId.getId)
}

If in the same class we overrode a function with the inline class and the underlying type, when we call the function from Java we get an error, as the compiler can’t tell which function we actually wanted to call:

fun pet(doggoId: Long) {}fun pet(doggoId: DoggoId) {}// Java
TestInlineKt.pet(1L);
Error: Ambiguous method call. Both pet(long) and pet(long) match

To inline or not to inline

Type safety helps us to write more robust code but historically could have performance implications. Inline classes offer the best of both worlds, type safety without the costs–should you always use them?

Inline classes bring a series of restrictions that ensure that the object you create play a single role: to be a wrapper. This means that in the future, developers unfamiliar with the code won’t be able to wrongly increase the complexity of the class, by adding other parameters to the constructor, as you could with a data class.

Performance wise we’ve seen that the Kotlin compiler tries its best to use the underlying type whenever possible, but still ends up creating new objects in many cases.

Usage from Java comes with several caveats so, if you haven’t migrated fully to Kotlin yet, you might end up in cases where you can’t use inline classes.

Finally, this is an experimental feature still, bringing uncertainty on whether its implementation will stay the same once it’s stable and whether it will actually graduate to stable.

So now that you understand inline classes’ benefits and restrictions, you can make an informed decision about if and when to use them.

--

--