今天看啥  ›  专栏  ›  NeXT

An Introduction to Inline Classes in Kotlin - Dave Leeds on Kotlin

NeXT  · 掘金  ·  · 2018-08-30 03:10

An Introduction to Inline Classes in Kotlin

Whether you’re writing massive data-chomping processes that execute in the cloud, or apps running on low-powered cell phones, most developers want their code to run fast. And now, Kotlin’s new, experimental inline classes feature allows us to create the data types that we want without giving up the performance that we need!

In this new series of articles, we’re going to take a look at inline classes from top to bottom!

In this article, we’re going to explore what they are, how they work, and the trade-offs involved when choosing to use them. Then, in the upcoming articles, we’re going to look under the hood of inline classes to see exactly how they’re implemented, and explore how they interoperate with Java.

Keep in mind - this is an experimental feature, and it’s very actively being developed and refined. This article is currently based on inline classes as implemented in Kotlin 1.3-M1.

If you’d like to try them out, I also wrote a companion article that shows you how to enable them in your IDE so that you can start playing with inline classes and other Kotlin 1.3 features today!

Ready? Let’s dive in!

Strong Types and Simple Values: A Case for Inline Classes

It’s 8 AM on Monday morning, and after pouring yourself a fresh, steaming cup o’ joe, you pull a new ticket in your project management system. It reads:

Send new users a welcome email - four days after they sign up.

Since the mailing system has already been written, you pull up the interface for mail scheduler, and this is what you see:

interface MailScheduler {
    fun sendEmail(email: Email, delay: Int)
}

Looking at this function, you know you need to call it… but what arguments would you send to it in order to delay the email by 4 days?

The delay parameter has a type of Int. So, we know that it’s an integer, but we don’t know what unit it represents - should you pass 4 for 4 days? Or maybe it represents hours, in which case you should pass 96. Or maybe it’s in minutes, seconds or milliseconds

How could we improve this code?

Sure, we can change the name of the parameter to include the time unit, like delayInMinutes. That would certainly be an improvement. But even this is only a hint - it still requires that the developer pays attention to the name, and sends an integer that represents the unit of time that the function expects. It’s not hard to imagine errors in calculations:

fun send(email: Email) {
    mailScheduler.sendEmail(email, WELCOME_EMAIL_DELAY_IN_DAYS * 24 * 60 * 60)
}

Oops! We accidentally converted it to seconds instead of minutes!

What would make this even better?

If the compiler could enforce that the right unit of time is sent. For example, instead of accepting an Int, let’s update our interface so that it accepts a strong type - Minutes:

interface MailScheduler {
    fun sendEmail(email: Email, delay: Minutes)
}

Now we’ve got the type system working for us! We can’t possibly send a Seconds to this function because it only accepts an argument of type Minutes! Consider how the following code can go a long way toward reducing bugs compared to the previous version:

val defaultDelay = Days(2)

fun send(email: Email) {
    mailScheduler.sendEmail(email, defaultDelay.toMinutes())
}

We get a lot more assurance when we can take full advantage of the type system.

But developers often choose not to write these kinds of simple value wrapper classes, opting instead to pass integers, floats, and booleans around the code base.

Why is that?

Often, we’re averse to creating simple types like these because of performance reasons. As you might recall, memory on a JVM looks a little something like this:

The stack and heap on JVM.

When we create a local variable (that is, function parameters and variables defined within a function) of a primitive type - like integers, floating point numbers, and booleans - those values are stored on a part of the JVM memory called the stack. There’s not very much overhead involved in storing the bits for these primitive values on the stack.

On the other hand, whenever we instantiate an object, that object is stored on the heap1. We take a performance hit when storing and using objects - heap allocations and memory fetches are expensive. Per object, the cost is small. But when accumulated, it can have an important impact on how fast your code runs.

Wouldn’t it be great if we could get all the benefits of the type system without taking the performance hit?

In fact, Kotlin’s new inline class feature is designed to do just that!

Let’s take a look!

Introducing Inline Classes

Inline classes are very simple to create - just use the keyword inline when defining your class:

inline class Hours(val value: Int) {
    fun toMinutes() = Minutes(value * 60)
}

That’s it! This class will now serve as a strong type for your value, and in many cases, it won’t have nearly the same performance costs that you’d experience with regular, non-inlined classes.

You can instantiate and use the inline class just like any other class. You’ll probably eventually need to reference the underlying value at some point in the code - usually at the boundary with another library or system. At that point, of course, you’d access value like you normally would with any other class.

Key Terms You Should Know

Inline classes wrap an underlying value. That value has a type, which we call the underlying type.

Anatomy of an inline class - underlying values and underlying types

Why Inline Classes Can Perform Better

So, what is it about inline classes that can cause them to perform better than regular classes? 2

When you instantiate an inline class like this:

val period = Hours(24)

… the class isn’t actually instantiated in the compiled code! In fact, as far as the JVM is concerned, you’ve simply written this …

int period = 24;

As you can see, there’s no notion of Hours here in this compiled version of the code - it’s just assigning the underlying value to an int variable!

Similarly, when you use an inline class as the type of a function parameter:

fun wait(period: Hours) { /* ... */ }

… it can effectively compile down to this …

void wait(int period) { /* ... */ }

So, the underlying type and underlying value were inlined in our code. In other words, the compiled code just used an integer type, so we avoided the cost of creating and accessing an object on the heap.

But wait a minute!

Remember how the Hours class had a function called toMinutes() on it? Since the compiled code is using an int instead of an Hours object, what happens when you call toMinutes()? After all, int doesn’t have a function called toMinutes(), so how does this work?

Easy - the compiled code implements inline class functions as static functions! Here’s how we defined our Hours class above:

inline class Hours(val value: Int) {
    fun toMinutes() = Minutes(value * 60)
}

The compiled code for Hours.toMinutes() looks like this:

public static final int toMinutes(int $this) {
	return $this * 60;
}

And if we were to call Hours(24).toMinutes() in Kotlin, it can effectively compile down to just toMinutes(24).

Okay, so that that takes care of functions, but what about fields? What if we want Hours to include some other data on it besides the main underlying value?

Everything has its trade-offs, and this is one of them - an inline class can’t have any other fields other than the underlying value. Let’s explore the rest of them.

Trade-offs and Limitations

Now that we know that inline classes can be represented by their underlying value in the compiled code, we’re ready to understand the trade-offs and limitations that are involved when we use them.

First, an inline class must include an underlying value. This means that it needs a primary constructor that accepts the underlying value as a read-only property. You can name the underlying value whatever you want.

inline class Seconds()              // nope - needs to accept a value!
inline class Minutes(value: Int)    // nope - value needs to be a property
inline class Hours(var value: Int)  // nope - property needs to be read-only
inline class Days(val value: Int)   // yes!
inline class Months(val count: Int) // yes! - name it what you want

You can make that property private if you want, but the constructor has to be public.

inline class Years private constructor(val value: Int) // nope - constructor must be public
inline class Decades(private val value: Int)           // yes!

They cannot include initialization blocks. This part will make more sense once we explore how inline classes interoperate with Java in an upcoming article.

inline class Centuries(val value: Int) {
	// nope - "Inline class cannot have an initializer block"
    init { 
        require(value >= 0)
    }
}

As we discovered above, our inline classes can’t have any fields on them other than the underlying value.

// nope - "Inline class must have exactly one primary constructor parameter"
inline class Years(val count: Int, val startYear: Int)

However, they can have properties, as long as they are calculated based only the underlying value, or from some value or object that can be statically resolved - from singletons, top-level objects, constants, and so on.

object Conversions {
    const val MINUTES_PER_HOUR = 60    
}

inline class Hours(val value: Int) {
    val valueAsMinutes get() = value * Conversions.MINUTES_PER_HOUR
}

Class inheritance is not allowed - inline classes cannot extend another class, and they can’t be extended by another class.

(Kotlin 1.3-M1 technically does allow inline classes to extend another class, but this is corrected in upcoming versions)

open class TimeUnit
inline class Seconds(val value: Int) : TimeUnit() // nope - cannot extend classes

open inline class Minutes(val value: Int) // nope - "Inline classes can only be final"

If you need your inline class to be a subtype, that’s fine - you can implement an interface rather than extending a subclass.

interface TimeUnit {
	val value: Int
}

inline class Hours(override val value: Int) : TimeUnit  // yes!

Inline classes must be declared at the top level. Nested/inner classes cannot be inlined.

class Outer {
	 // nope - "Inline classes are only allowed on top level"
    inline class Inner(val value: Int)
}

inline class TopLevelInline(val value: Int) // yes!

Currently, enum classes cannot be inlined.

// nope - "Modifier 'inline' is not applicable to 'enum class'"
inline enum class TimeUnits(val value: Int) {
    SECONDS_PER_MINUTE(60),
    MINUTES_PER_HOUR(60),
    HOURS_PER_DAY(24)
}

Type Aliases vs. Inline Classes

Because they both encompass an underlying type, inline classes can be confused with type aliases. But there are a few key differences that make them useful in different situations.

Type aliases provide an alternate name for the underlying type. For example, you can alias a common type like String, and give it a descriptive name that’s meaningful in a particular context, like Username. Variables of type Username are actually variables of type String in both the source code and the compiled code. For example, you can do this:

typealias Username = String

fun validate(name: Username) {
    if(name.length < 5) {
        println("Username $name is too short.")
    }
}

Notice how we can call .length on name. That’s because name is a String, even though the parameter type we declared is using the alias Username.

Inline classes, on the other hand, are wrappers of the underlying type, so when you need to use the underlying value, you have to unwrap it again. For example, let’s rewrite the code above using an inline class instead of a type alias:

inline class Username(val value: String)

fun validate(name: Username) {
    if (name.value.length < 5) {
        println("Username ${name.value} is too short.")
    }
}

Notice how we had to call name.value.length instead of name.length - we had to unwrap the value.

The most important difference, though, has to do with assignment compatibility. Inline classes give you a level of type safety that type aliases do not.

Type aliases are the same as their underlying type. For example, check this out:

typealias Username = String
typealias Password = String

fun authenticate(user: Username, pass: Password) { /* ... */ }

fun main(args: Array<String>) {
    val username: Username = "joe.user"
    val password: Password = "super-secret"
    authenticate(password, username)
}

In this case, Username and Password are just different names for String, so you can assign a Username to a Password and vice versa. In fact, that’s exactly what we did in the code above - we mixed up the username and password when we called authenticate()… and the compiler was just fine with this!

On the other hand, if you use inline classes for this same case, then we’d thankfully get a compiler error:

inline class Username(val value: String)
inline class Password(val value: String)

fun authenticate(user: Username, pass: Password) { /* ... */ }

fun main(args: Array<String>) {
    val username: Username = Username("joe.user")
    val password: Password = Password("super-secret")
    authenticate(password, username) // <--- Compiler error here! =)
}

This is quite powerful! The strong typing here tells us immediately that we wrote a bug. We didn’t have to wait for an automated test, a QA engineer, or a user to tell us. Nice!

Wrap-up

Are you ready to start trying out inline classes on your own? If so, go read up on how to enable inline classes in your IDE today!

Although we’ve covered the basics, there are some fascinating considerations to keep in mind when using them. In fact, if you aren’t aware of what’s happening inside, inline classes could produce code that runs more slowly than normal classes.

In the next article, we’ll pop open the hood and look inside to get an in-depth understanding of exactly how they work, so that you can get the best possible use out of them.

See you then!


  1. You might also be wondering about local variables that are objects, and objects that contain primitives. In the first case, the object is stored on the heap, and a reference to it is stored on the stack. In the second case, the primitive is stored within the object on the heap. [return]
  2. There are lots of conditions that can affect their performance. In this article, we’re focused on introducing the concept, so we’ll stick with the best inlining scenario. The next article will cover the edge cases. [return]



原文地址:访问原文地址
快照地址: 访问文章快照