3

Effective Kotlin Item 49: Consider using inline value classes

 2 years ago
source link: https://kt.academy/article/ek-value-classes
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

item49.jpg

Effective Kotlin Item 49: Consider using inline value classes

This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.

Not only functions can be inlined, but also objects holding a single value can be replaced with this value. For that, we need to define a class with a single primary constructor read-only property, modifier value and annotation JvmInline.

xxxxxxxxxx
@JvmInline
value class Name(private val value: String) {
    // ...
}

Value classes were introduced in Kotlin 1.5 as an introduction to much bigger feature than just class inlining. Before that (but since Kotlin 1.3) we could use inline modifier to achieve a similar result.

xxxxxxxxxx
inline class Name(private val value: String) {
    // ...
}

Such a class will be replaced with the value it holds whenever possible:

xxxxxxxxxx
// Code
val name: Name = Name("Marcin")
// During compilation replaced with code similar to:
val name: String = "Marcin"

Methods from such a class will be evaluated as static methods:

xxxxxxxxxx
@JvmInline
value class Name(private val value: String) {
    // ...
    fun greet() {
        print("Hello, I am $value")
    }
}
// Code
val name: Name = Name("Marcin")
name.greet()
// During compilation replaced with code similar to:
val name: String = "Marcin"
Name.`greet-impl`(name)

We can use inline value classes to make a wrapper around some type (like String in the above example) with no performance overhead (Item 47: Avoid unnecessary object creation). Two especially popular uses of inline value classes are:

  • To indicate a unit of measure

  • To use types to protect user from value misuse

Let's discuss them separately.

Indicate unit of measure

Imagine that you need to use a method to set up timer:

xxxxxxxxxx
interface Timer {
    fun callAfter(time: Int, callback: () -> Unit)
}

What is this time? Might be time in milliseconds, seconds, minutes… it is not clear at this point, and it is easy to make a mistake. A serious mistake. One famous example of such a mistake is Mars Climate Orbiter that crashed into Mars atmosphere. The reason behind that was that the software used to control it was developed by an external company, and it produced outputs in different units of measure than the ones expected by NASA. It produced results in pound-force seconds (lbf·s), while NASA expected newton-seconds (N·s). The total cost of the mission was 327.6 million USD, and it was a complete failure. As you can see, confusion of measurement units can be really expensive.

One common way for developers to suggest a unit of measure is by including it in the parameter name:

xxxxxxxxxx
interface Timer {
    fun callAfter(timeMillis: Int, callback: () -> Unit)
}

It is better but still leaves some space for mistakes. The property name is often not visible when a function is used. Another problem is that indicating the type this way is harder when the type is returned. In the example below, time is returned from decideAboutTime and its unit of measure is not indicated at all. It might return time in minutes and time setting would not work correctly then.

xxxxxxxxxx
interface User {
    fun decideAboutTime(): Int
    fun wakeUp()
}
interface Timer {
    fun callAfter(timeMillis: Int, callback: () -> Unit)
}
fun setUpUserWakeUpUser(user: User, timer: Timer) {
    val time: Int = user.decideAboutTime()
    timer.callAfter(time) {
        user.wakeUp()
    }
}

We might introduce the unit of measure of the returned value in the function name, for instance by naming it decideAboutTimeMillis. Such a solution is not considered very good, as it makes this function state a low-level information even when we don’t need to know about it. Not to mention, it does not necesserly solve the problem - a developer still needs to control that the units of measure match.

A better way to solve this problem is to introduce stricter types that will protect us from misusing types, and to make them efficient we can use inline value classes:

xxxxxxxxxx
@JvmInline
value class Minutes(val minutes: Int) {
    fun toMillis(): Millis = Millis(minutes * 60 * 1000)
    // ...
}
@JvmInline
value class Millis(val milliseconds: Int) {
    // ...
}
interface User {
    fun decideAboutTime(): Minutes
    fun wakeUp()
}
interface Timer {
    fun callAfter(timeMillis: Millis, callback: () -> Unit)
}
fun setUpUserWakeUpUser(user: User, timer: Timer) {
    val time: Minutes = user.decideAboutTime()
    timer.callAfter(time) { // ERROR: Type mismatch
        user.wakeUp()
    }
}

This would force us to use the correct type:

xxxxxxxxxx
fun setUpUserWakeUpUser(user: User, timer: Timer) {
    val time = user.decideAboutTime()
    timer.callAfter(time.toMillis()) {
        user.wakeUp()
    }
}

It is especially useful for metric units. For instance in frontend, we often use a variety of units like pixels, millimeters, dp, etc. To support object creation, we can define DSL-like extension properties (you can make them inline as well):

xxxxxxxxxx
inline val Int.min 
    get() = Minutes(this)
inline val Int.ms 
    get() = Millis(this)
val timeMin: Minutes = 10.min

Protect us from value misuse

In SQL databases, we often identify elements by their IDs, which are all just numbers. For instance, let’s say that you have a student grade in a system. It will probably need to reference the id of a student, teacher, school, etc:

xxxxxxxxxx
@Entity(tableName = "grades")
class Grades(
   @ColumnInfo(name = "studentId") 
   val studentId: Int,
   @ColumnInfo(name = "teacherId") 
   val teacherId: Int,
   @ColumnInfo(name = "schoolId") 
   val schoolId: Int,
   // ...
)

The problem is that it is really easy to later misuse all those ids, and the typing system does not protect us because they are all of type Int. The solution is to wrap all those integers into separate inline value classes:

xxxxxxxxxx
@JvmInline
value class StudentId(val studentId: Int)
@JvmInline
value class TeacherId(val teacherId: Int)
@JvmInline
value class SchoolId(val studentId: Int)
@Entity(tableName = "grades")
class Grades(
   @ColumnInfo(name = "studentId") 
   val studentId: StudentId,
   @ColumnInfo(name = "teacherId") 
   val teacherId: TeacherId,
   @ColumnInfo(name = "schoolId") 
   val schoolId: SchoolId,
    // ...
)

Now those id uses will be safe, and at the same time, the database will be generated correctly because during compilation, all those types will be replaced with Int anyway. This way, inline value classes allow us to introduce types where they were not allowed before, and thanks to that, we have safer code with no performance overhead.

Inline value classes and interfaces

Inline value classes can implement interfaces. We could use that in the example presented before, to avoid casting from one class to another.

xxxxxxxxxx
interface TimeUnit {
   val millis: Long
}
@JvmInline
value class Minutes(val minutes: Long): TimeUnit {
   override val millis: Long get() = minutes * 60 * 1000
   // ...
}
@JvmInline
value class Millis(val milliseconds: Long): TimeUnit {
   override val millis: Long get() = milliseconds
}
// the type under the hood is TimeUnit
fun setUpTimer(time: TimeUnit) {
   val millis = time.millis
   //...
}
setUpTimer(Minutes(123))
setUpTimer(Millis(456789))

The catch is that when an object is used through an interface, it cannot be inlined. Therefore, in the above example there is no advantage to using inline value classes, since wrapped objects need to be created to let us present a type through this interface. When we present inline value classes through an interface, such classes are not inlined.

Another situation when a type will not be inlined is when it is nullable, and the value class holds primitive as a parameter. In the example below, when Millis is used as a parameter type, it will be replaced with Long. Although if Millis? is used, it cannot be replaced, because Long cannot be null. Although if Millis would hold non-primitive type, like String, then its type nullability wouldn't influence inlining.

xxxxxxxxxx
@JvmInline
value class Millis(val milliseconds: Long) {
    val millis: Long get() = milliseconds
}
// the type under the hood is @Nullable Millis
fun setUpTimer(time: Millis?) {
    val millis = time?.millis
    //...
}
// the type under the hood is long
fun setUpTimer(time: Millis) {
    val millis = time.millis
    //...
}
fun main() {
    setUpTimer(Millis(456789))
}

Typealias

Kotlin typealias lets us create another name for a type:

xxxxxxxxxx
typealias NewName = Int
val n: NewName = 10

Naming types is a useful capability used especially when we deal with long and repeatable types. For instance, it is a popular practice to name repeatable function types:

xxxxxxxxxx
typealias ClickListener =
            (view: View, event: Event) -> Unit
class View {
    fun addClickListener(listener: ClickListener) {}
    fun removeClickListener(listener: ClickListener) {}
    //...
}

What needs to be understood though is that typealiases do not protect us in any way from type misuse. They are just adding a new name for a type. If we named Int as both Millis and Seconds, we would make an illusion that the typing system protects us while it does not:

xxxxxxxxxx
typealias Seconds = Int
typealias Millis = Int
fun getTime(): Millis = 10
fun setUpTimer(time: Seconds) {}
fun main() {
   val seconds: Seconds = 10
   val millis: Millis = seconds // No compiler error
   setUpTimer(getTime())
}

In the above example it would be easier to find what is wrong without type aliases. This is why they should not be used this way. To indicate a unit of measure, use a parameter name or classes. A name is cheaper, but classes give better safety. When we use inline value classes, we take the best from both options - it is both cheap and safe.

Summary

Inline value classes let us wrap a type without performance overhead. Thanks to that, we improve safety by making our typing system protect us from value misuse. If you use a type with unclear meaning (like Int or String), especially a type that might have different units of measure, consider wrapping it with inline value classes.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK