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 an earlier article.
A few caveats to keep in mind
Owing to the nature of their design, inline classes, right now:
- cannot have
init
blocks in them - can neither inherit from another class nor can be open for extension
- cannot have any property with a backing field
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.