Low-overhead wrappers using inline classes

Previously, we discussed how Kotlin's type aliases could make our code more readable. However, if you remember, since type aliases don't introduce new types, they don't provide any type-safety.

This week we will be exploring Kotlin's experimental inline classes which have benefits that type aliases provide but go further than that.

A little about inline classes

Kotlin introduced inline classes in their 1.3 release, advertised as light-weight wrapper classes. What separates inline classes from traditional wrappers is that inline classes don't add any overhead due to no additional heap allocations.

Now that's a mouthful. Let's see what that means. Consider this function:

fun renderLocation(latitude: Double, longitude: Double) {
    map.render(latitude, longitude)
}

Since both latitude and longitude are Double values, it's easy to pass them in the wrong order making our program render an incorrect map.

We can prevent this error by introducing two wrapper classes called Latitude and Longitude like this, thereby, making our function type-safe:

class Latitude(val value: Double)
class Longitude(val value: Double)
    
fun renderLocation(latitude: Latitude, longitude: Longitude) {
    map.render(latitude, longitude)
}

Although we made our function type-safe, we added a little overhead here. Every time we pass new values to our renderLocation function here, we need to initialise two new objects. This approach, as you might have guessed, results in additional expensive heap allocations.

The previous version of our function didn't have this problem because primitive values are optimised by the compiler.

All hope's not lost, yet:

We can convert our plain old wrapper classes into inline classes to prevent any extra initialisation.

How?

Well, the Kotlin compiler treats inline classes as a drop-in replacement for their underlying value. Therefore, if we do this:

inline class Latitude(val value: Double)
inline class Longitude(val value: Double)
    
fun renderLocation(latitude: Latitude, longitude: Longitude) {
    map.render(latitude, longitude)
}

When we run our program, Kotlin will try to replace every usage of Latitude or Longitude with a Double value.

In cases where the type has to be retained, like for a type check using the as keyword, Kotlin will box the Double value into the appropriate wrapper class.

Keep in mind that type erasure happens only during runtime or when your Kotlin code gets compiled to byte code. In our source code, we get full type support that we don't get while using a type alias.

A decompiled Java version of our renderLocation will be this:

public static final void renderLocation_vKZqJUM/* $FF was: renderLocation-vKZqJUM*/(double latitude, double longitude) {
    map.render-vKZqJUM(latitude, longitude);
}

Notice how the function parameters are plain old primitive double values.

What happened to our function name?

We named our function as renderLocation , but our Java code shows a weird name renderLocation-vKZqJUM , what's the deal here?

It turns out, inline classes only work when we are writing Kotlin code. The hot-swapping of an inline class to its underlying type doesn't happen in Java.

As a result, if we have multiple functions like this:

fun renderLocation(latitude: Double, longitude: Double) {}
    
fun renderLocation(latitude: Latitude, longitude: Longitude) {
    map.render(latitude, longitude)
}

A straightforward conversion to Java code would look like:

public static final void renderLocation(double latitude, double longitude) {}
    
public static final void renderLocation(double latitude, double longitude) {
    map.render(latitude, longitude);
}

The above code is not valid because we can't have two methods with the same signature.

To deal with this collision problem, Kotlin adds a "-hash code" to all methods which have an inline class parameter in their Kotlin counterpart. This technique is called mangling .

Although this trick solves the collision issue, it creates another problem. We can't refer to any of our Kotlin functions which accepts an inline class parameter from our Java code.

Why?

Because, in Java, "-" is considered an illegal symbol. Therefore, it is impossible to call methods which has a "-" in their names.

This is where Kotlin-Java interoperability breaks for us.

Don't let this be a blocker though

Casting aside the interoperability problem, Kotlin's inline classes are quite handy in a variety of cases.

As we saw in our renderLocation function, using inline classes makes our program bug free by enforcing compile-time type-safety. We can't wrongly pass a Latitude value as Longitude to our function here. The compiler won't allow this misplacement.

Jake Wharton pointed out another extensive use case for inline classes in one of his blog post – type-safe database IDs .

Similar to our example here, database IDs are easy to misplace due to being of the same type. We can slip a payment ID as a customer ID and not notice until we get an incorrect result during runtime. Inline classes prevent these errors during compilation.

Comparing with type aliases

Type aliases don't introduce a new type. They mask an existing type to a different name.

Inline classes, however, introduce new types which are available for us to harness while writing our programs.

That doesn't mean type aliases are useless; they are useful for a different reason as we discussed in anearlier article.

A few caveats to keep in mind

Owing to the nature of their design, inline classes, right now:

init

They can, however, have simple computed properties like this:

inline class Longitude(val value: Double) {
    val formattedValue: String
        get() = "$value°"
}

Enforcing contracts with interfaces

Inline classes can implement interfaces. This feature gives us the ability to enforce some contracts for our wrappers.

Take out Latitude and Longitude wrappers as an example. We can define a contract for all geolocation values to have a formatting method, like this:

interface GeoUnit {
    val formattedValue: String
}

Modifying our inline classes to implement this interface will ensure all implement classes have at least a formattedValue property which returns a String value. Here, we can make use of this property to pretty print our location value:

inline class Latitude(val value: Double) : GeoUnit {
    override val formattedValue: String
        get() = "$value°"
}

We can now have consistent functionalities across wrappers of similar type.

Refactoring already?

Inline classes can be tempting to use. However, if you have an existing codebase, it's better to take a step back and think whether using inline classes would be suitable or not.

Firstly, as of Kotlin 1.3.6, inline classes are still at an experimental stage. Refactoring a large project with experimental API can be detrimental.

Also, if your codebase is mostly in legacy Java code, using inline classes means losing access to a bunch of methods from your Java classes.

Before plunging into a full refactor mode, weigh in pros and cons and then make a decision.

Here's a sketch note on the topic

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章