KEEP icon indicating copy to clipboard operation
KEEP copied to clipboard

Inline classes

Open zarechenskiy opened this issue 7 years ago • 192 comments

Discussion of the proposal: https://github.com/Kotlin/KEEP/blob/master/proposals/inline-classes.md

zarechenskiy avatar Apr 04 '18 08:04 zarechenskiy

This is an awesome idea and I can definitely see myself using this feature in different scenarios.

I just have a small question. From an implementation perspective, inline classes feel more like interfaces than actual classes (mainly due to their stateless nature).

The only reason I can think of that justifies the use of classes instead of interfaces is instantiation: instances can be "created" by calling the constructor. But I feel like there could be another mechanism to accomplish this.

What do you think?

gabrielhuff avatar Apr 05 '18 06:04 gabrielhuff

There was a discussion about allowing inline sealed class hierarchies in https://github.com/Kotlin/KEEP/pull/103#discussion_r179326199.

Since inline classes support autoboxing just like primitives, this could be possible to use inline sealed classes and do is checks in when or boolean expressions. However, wouldn't it result in autoboxing most of the time (as is check is not possible on inlined value), defeating the purpose of making them inline?

LouisCAD avatar Apr 05 '18 12:04 LouisCAD

@gabrielhuff Do you mean that inline classes are similar to something like "newtype" or "strict typealiases"? And they can be used like this:

inline type UInt = Int

fun test() {
    var x: UInt = 0
    x = 1 // type mismatch!
}

?

It's interesting, but we believe that current form to declare members/types is suited for us better

zarechenskiy avatar Apr 05 '18 16:04 zarechenskiy

@LouisCAD Yes, we can imagine inline sealed classes like this:

inline sealed class Base
inline class Foo(val s: String) : Base()
inline class Bar(val i: Int) : Base()

All subclasses must have different runtime underlying representation.

And you're right that in current design autoboxing will occur even on the simplest cases:

fun box(b: Base) {}

fun test() {
  box(Foo("")) // boxing
}

Basically, yes, currently this is a major reason to prohibit inline sealed classes for now

zarechenskiy avatar Apr 05 '18 16:04 zarechenskiy

I wonder if @InlineOnly inline classes were discussed? That is, classes which are always inlined, and an attempt to box an instance of which would cause a compilation error. In theory, their metadata could be stored more compactly than in the .class file, and the whole .class file is not needed for them at all.

udalov avatar Apr 05 '18 17:04 udalov

@udalov Interesting! In other case, for @InlineOnly underlying representation could always be used:

@InlineOnly
inline class Name(val s: String)

fun foo(n: Any) {}
...
foo(Name("K") // no boxing

Here .class file also is not needed

zarechenskiy avatar Apr 09 '18 06:04 zarechenskiy

I think @InlineOnly semantics would be very helpful. you don't worry about an accident boxing and always has close to zero overhead.

gildor avatar Apr 09 '18 06:04 gildor

Current spec seems to not cover annotations on inline classes.

It seems that annotations are pretty much useless since class gets erased after compilation.

Moreover they might be even dangerous as they might trick user into thinking that they'll make difference:

inline class UserId(@SerializedName("user_id") private val id: String)

However I guess you could give compiler plugins a chance to introspect them (which might be very useful).

WDYT?

artem-zinnatullin avatar Apr 09 '18 18:04 artem-zinnatullin

Due to nature of inline class it should be passed by value. If so, this feature intersects with const data class feature and upcoming value types proposed by project Valhalla as part of Java 10.

sakno avatar Apr 11 '18 14:04 sakno

Since inline classes can't have init blocks, how can we do validation?

For example, suppose I create a Username class:

inline class Username(private val value: String)

And I want to throw an exception at run time if somebody tries to create a Username that contains spaces:

Username("Invalid Username") // error

Will this be possible?

RaisedByTheInternet avatar Apr 13 '18 16:04 RaisedByTheInternet

@RaisedByTheInternet This is also true for Data classes. Maybe private constructor + factory function can help with that because there is no such problem as with data class that also exposes copy method

gildor avatar Apr 16 '18 02:04 gildor

@artem-zinnatullin Yes, annotations on underlying value should be prohibited, thanks for pointing this out! Moreover, there might be inconsistent behaviour for boxed/unboxed representation.

It's hard to say about compiler plugins for now, could you please elaborate more about use cases?

zarechenskiy avatar Apr 16 '18 08:04 zarechenskiy

@sakno As I see, const data classes can use very restrictive set of types as an underlying value to ensure constant hashCode/toString. So, goals and motivation of inline classes are different.

Yes, we're closely watching the project Valhalla and probably will try to use their mechanism for value/inline classes in future

zarechenskiy avatar Apr 16 '18 08:04 zarechenskiy

@RaisedByTheInternet No, currently this is impossible. Unfortunately, the current restrictions are made in a such way to avoid any initialisation (validation) for inline classes. Seems that it's impossible to have consistent behaviour and Java interop if we'll allow to have init blocks or private primary constructors.

zarechenskiy avatar Apr 16 '18 09:04 zarechenskiy

inline sealed class is union type for Kotlin

In such case we should consider also inline object Baz : Base()

fvasco avatar Apr 16 '18 09:04 fvasco

@fvasco What's the point of Baz in your snippet? Do you have a use case?

LouisCAD avatar Apr 16 '18 09:04 LouisCAD

@LouisCAD https://github.com/Kotlin/kotlinx.coroutines/issues/330

fvasco avatar Apr 16 '18 09:04 fvasco

@zarechenskiy

Hmm, if we can't do validation then this feature seems very limited. For example, we have this UInt inline class:

inline class UInt(private val value: Int)

If anyone can simply say UInt(-1) then it almost seems pointless to use an inline class here. Apologies if I'm missing something.

RaisedByTheInternet avatar Apr 16 '18 14:04 RaisedByTheInternet

Hi @RaisedByTheInternet how you define UInt.MAX_VALUE?

The following statement is invalid

const val MAX_VALUE: UInt = UInt(0xFFFF_FFFF)

C has the same issue. Inline classes miss of encapsulation.

Have you some proposal for this feature?

fvasco avatar Apr 16 '18 15:04 fvasco

@RaisedByTheInternet You are right and yes, this is one of the most controversial restriction.

As @fvasco mentioned, UInt(-1) will probably represent max value, but of course it would be cool to avoid expressions like UInt(-42)

zarechenskiy avatar Apr 16 '18 15:04 zarechenskiy

@zarechenskiy

It sounds like I may have misunderstood, as I didn't realise that UInt(-1) could be represented as the maximum value.

But how will that work? Something like invoke() inside a companion object?

RaisedByTheInternet avatar Apr 16 '18 19:04 RaisedByTheInternet

I wish bring to your attention another common use case, I hope that considering a bit more complex problem can help us to understand the simpler one.

I want to define a Point type as (x: Int, y: Int).

I can use a single Long backing field to build my type, ie:

inline class Point private constructor(private val long: Long) {

  constructor(x: Int, y: Int): this(wrap(x, y))

  val x: Int get() = ...
  val y: Int get() = ...

  compantion object {

    fun wrap(x: Int, y: Int): Long = ...

  }
}

In such case, as @gildor said, we have "private constructor + factory function".

In UInt case

inline class UInt private constructor(private val int: Int) {

  constructor(value: Long): this(wrap(value))

  fun toLong() = ...

  compantion object {

    fun wrap(value: Long): Int = ...

  }
}

fvasco avatar Apr 17 '18 07:04 fvasco

@fvasco ~Your second snippet had a wrong class name~ The use case example is a good one regardless 👍

LouisCAD avatar Apr 17 '18 07:04 LouisCAD

@fvasco:

I see. Thanks for the examples.

I guess one problem with the UInt example is that we need to pass a Long, and so something like this won't work:

    val value: Int = getValueFromSomewhere()
    UInt(value) // error: a Long is expected and we tried to pass an Int

Passing value.toLong() works, of course, but this looks awkward.

RaisedByTheInternet avatar Apr 17 '18 17:04 RaisedByTheInternet

That isn't a problem with this proposal. All Kotlin behaves like that.

JakeWharton avatar Apr 17 '18 17:04 JakeWharton

Why should annotations be prohibited on properties? In the previous example

inline class UserId(@SerializedName("user_id") private val id: String)

The annotation could simply be inlined as well. This would allow for use with existing APIS/frameworks like JPA, and it seems to me like it is the expected behavior.

ricmf avatar Apr 17 '18 21:04 ricmf

@ricmf

The annotation could simply be inlined as well.

Inlined where?

Idea of inline class is that it's only present as a separate type in compilation time. Pretty much how Java generics work in many cases.

ie:

inline class UserId(@SerializedName("user_id") private val id: String)

fun test(userId: UserId) {

}

^ after compilation fun test() will be a function that accepts String, not UserId. There won't be UserId at runtime.

So… where should we put information from annotations? (I mean, you can create a meta registry of inline-classes and then annotate all functions that use them thus allowing runtime reflection, but I doubt it's useful)

artem-zinnatullin avatar Apr 17 '18 21:04 artem-zinnatullin

It could be inlined on the filed/parameter that resulted from inlining the class.

inline class UserId(@SerializedName("user_id") private val id: String)

fun test(userId: UserId) {

}
class Foo(val userId : UserId)

becomes

fun test(@SerializedName("user_id") id: String) {

}
class Foo(@SerializedName("user_id") val id : String)

ricmf avatar Apr 17 '18 22:04 ricmf

Well, yes but:

  • Lots of frameworks/libraries won't be looking at annotations on method parameters
  • Annotations might not be compatible with method parameters. @SerializedName is only allowed on fields and methods for instance. Though Kotlin compiler still can produce bytecode with annotated method parameter.
  • Inlining annotations to a field can result in unexpected behavior (see below)
inline class UserId(@SerializedName("user_id") private val id: String)

data class Request(
    private val userId: UserId,
    …
)

Inlining annotation to the field is very questionable:

data class Request(
    @SerializedName("user_id") // ???
    private val userId: UserId,
    …
)

There is a way however to keep some meta-information about inline classes, but I'm not sure we should invest time into that.

Given:

package test

inline class UserId(@SerializedName("user_id") private val id: String)

fun test(userId: UserId) {

}

and stdlib interface:

interface InlineClassMetadata {
    val fqn: String
    val fieldAnnotations: List<Annotation>
    …
}

Compiler can produce following code (pseudo):

package test

enum class InlineClassRegistry(
        override val fqn: String,
        override val fieldAnnotations: List<Annotation>) : InlineClassMetadata  {
    
    UserId(
            fqn = "test.UserId", 
            fieldAnnotations = listOf(object : SerializedName { override fun value() = "user_id" }))
}

@kotlin.Metadata(d1 = "p1.inline_class=test.InlineClassRegistry.UserId")
fun test(userId: String) {

}

artem-zinnatullin avatar Apr 17 '18 23:04 artem-zinnatullin

Lots of frameworks/libraries won't be looking at annotations on method parameters

That's true, but i don't see how it's an argument for forbidding annotations. If the frameworks don't search for annotations in parameters then nothing happens. If you expected them to, then that's a problem with your understanding of the framework.

Annotations might not be compatible with method parameters.

Then the compiler can ignore them since using them on parameters would be illegal and you should be aware of that when using it in an inline class.

Inlining annotation to the field is very questionable:

What would be the expected behavior in this situation in your opinion? That's exactly how i would use this feature / expect it to behave. It's what JPA's Embeddable is for, for example. Inlining the annotation would allow for zero-cost, arbitrary granularity in your model, even if the data in the database/xml/json is completely flat, and the framework being used wouldn't even have to know about it.

ricmf avatar Apr 17 '18 23:04 ricmf