KEEP icon indicating copy to clipboard operation
KEEP copied to clipboard

Design Notes on Kotlin Value Classes

Open elizarov opened this issue 4 years ago • 82 comments

This issue is for discussing and gathering feedback for Design Notes on Kotlin Value Classes: https://github.com/Kotlin/KEEP/blob/master/notes/value-classes.md

elizarov avatar Feb 01 '21 16:02 elizarov

Yesterday we had a long discussion in kotlin telegram chat about that. There was a nice proposal there that should be at least discussed. The idea is to be able to isolate all value class mutations into a single mutating lambda function. It could be done in two ways: either allow mutations only in a mutating lambda/function with an appropriate receiver or allow those lambdas alongside simple mutations (one can also allow only lambdas at first and relax the rule later).

There are two practical benefits of such lambdas:

  1. If we are doing several mutations, we need to either create several copies of a value object with subsequently changing fields or we need a scope for atomic changes. Subsequent changes could be in theory detected and glued together, but it would require a lot of work from the compiler and in the end, won't be guaranteed to work properly in all cases. The lambda on the other hand could guarantee atomicity of changes.
  2. Introducing a lambda provides a small barrier in the usage of such operations. One must remember that copying value-classes is still an expensive operation and one should not use it mindlessly. The performance is maybe not a primary point but it seems a bit unexpected when two different assignment operations give such dramatic performance difference.

Also, I have an additional educational point in favor of isolation - it is a mental model. The mental model explained in the design notes is understandable from the point of view of an experienced programmer, but there are a lot of concepts there. All those mutating modifieds, var declarations, etc add a lot of things to understand and remember. Whereas mutating via lambda keeps things simple - it is just in-place mutation that does not affect previous usages of the variable.

At the end of the design notes document, there is a short discussion about non-constructors for data classes (the idea is really nice, but I think it should be discussed more). Those non-constructors are in fact follow the same idea of mutating lambda, so having them both would probably also simplify the mental model.

altavir avatar Feb 02 '21 06:02 altavir

Update on the var keyword reuse

See section on Mutable properties of immutable types

This forum post quotes an important use-case https://discuss.kotlinlang.org/t/inline-classes-var-to-be-deprecated/20762 where inline value class is used as a "handle” to some mutable data storage. It nicely shows how a regular var can be used on immutable value classes for a good reason.

We’d love to rescue and keep this use-case. We don’t have an alternative plan that will make it possible yet, but we are looking for it. There are two potential approaches.

Note: both approaches also open a road to implement regular (non-value) interface with regular var properties by immutable value classes, which is not directly possible if var is hardcoded to have a different meaning in value and non-value contexts.

A new keyword to denote properties with mutating setter (wither)

In addition to two kinds of properties that we already have in Kotlin (val for a read-only property with a getter and var for a writable property with a getter and a setter) we can add a separate keyword for the 3rd kind of a property – a mutating property with a getter and a mutating setter (wither).

How to name it? That is the question!

Introducing the 3rd type of property will make the resulting scheme harder to learn and might require the new hard keyword in the language. However, we can alleviate the former concern by nudging the learners by IDE inspections. For example, value class Foo(var prop: Type) could have an automated suggestion to change var with a backing field (that will not be allowed on a value class) to this new property type.

A new modifier to denote a regularly mutatable property on an immutable value

Alternatively, we can add some new modifier that, being added before a var property on a value class (or interface) turns into a regular mutable property with a setter that is supposed to mutate some mutable data structure and does not affect the value it is being called on.

Basically, this will be a modifier that is opposite to mutating (in the same sense that open is opposite to final). So, that, by default all var properties on a value class/interface will be considered to implicitly have a mutating modifier, while all properties on a regular non-value class/interface will be considered to implicitly have this new "opposite to mutating" modifier.

How to name it? That is the question!

elizarov avatar Feb 08 '21 09:02 elizarov

Please sorry for my trivial question, but I missed something important.

If a value class is an immutable data type, why is it important to mark a property like a var or a val?

All other properties are really similar to regular properties without backing field.

If so, the Grid example can be written as:

value class Grid(ptr: Long) {
    var width: Int
        get() = GridNative.getWidth(ptr)
        set(v) { GridNative.setWidth(ptr, v) }
}

or

value class Grid(ptr: Long)

var Grid.width: Int
        get() = GridNative.getWidth(ptr)
        set(v) { GridNative.setWidth(ptr, v) }

It looks really similar to a regular class

class Grid(val ptr: Long)

var Grid.width: Int
        get() = GridNative.getWidth(ptr)
        set(v) { GridNative.setWidth(ptr, v) }

We really need a new modifier?

fvasco avatar Feb 08 '21 13:02 fvasco

@fvasco

If a value class is an immutable data type, why is it important to mark a property like a var or a val?

Consider, for example, an immutable type for complex numbers with properties re: Double (for its real part), im: Double (for its immaginary part), absoluteValue: Double, etc. For our discussion, it is completely unimportant how these properties are actually stored, whether they have backing fields or not. All of them can be stored in backing fields, or just a few can be stored. Typically re and im will be stored, while others will be computed, but that is not important in larger business data structures, as the "storage approach" for some big enterprise entities might even evolve and change over time.

Regardless of "backed by a field or not", from all these properties, re and im properties are special. You can always reconstruct your complex number with different values of re and im. They are "mutatable properties of an immutable class". They are the ones we'll want to mark as var or in some other way (see the comment above). But, for example, an abosoluteValue property is truly read-only (val property). You can only query it, but you cannot, in general, update an abosluteValue on an arbitrary complex number and expect a meaningful result in all the cases.

For this specific example of a complex number you could define mutation for abosoluteValue if you want, but you'll have to figure out what to do with zero. For larger business entities, there could be lots of readonly (val) properties that cannot be meaningfully mutated at all.

Now, having read this, you can say that Kotlin already has a feature to distinguish these two kinds of properties on immutable classes. In Kotlin, we can define an immutable Complex class like this:

data class Complex(val re: Double, val im: Double) { // constructor properties
    val absoluteValue: Double // other properties
}

That is, we can distinguish different kinds of properties by whether they are constructor properties or body properties. Constructor val properties are actually mutable, since they can be mutated using a copy() function to get a new value. But this constructor/non-constructor is a wrong dichotomy:

  • Constructor properties must be backed by a field. While this is a good default, there is no intrinsic relation between mutation ability and being in a stored field (e.g. multiple mutable properties can be packed into a single backing field). We could lift this restriction on the constructor properties (with some effort) but see the other point.
  • Constructor properties are a class-only concept. What if you need to define an interface for you class, how do you distinguish those two kinds of properties in an interface? What if you need to define an extension property? How do you distinguish between a truly read-only property and a property that has a mutating setter ("wither") (an example is given in this section of the KEEP).

elizarov avatar Feb 09 '21 10:02 elizarov

Constructor properties are a class-only concept. What if you need to define an interface for you class, how do you distinguish those two kinds of properties in an interface? What if you need to define an extension property? How do you distinguish between a truly read-only property and a property that has a mutating setter ("wither") (an example is given in this section of the KEEP).

I guess the value classes should just be sort of literals (because they really are not allocating memory on the heap, like another wrapper classes) and hence fully immutable. (Correct me if I'm wrong)

Also written in KEEP:

Values classes are immutable classes that disavow the concept of identity for their instances.

operator fun Complex.add(other: Complex) should return another complex instead of mutating it. Just like operations on primitive doesn't change themselves but returns a new value.

complex.re += 5 doesn't really make much sense but rather complex += Complex(5, 0) do more.

And to extend/shrink modulus of a complex number there can be another function to take the components, form a new complex and return that.

Edit: Also wanna add, that there could be a annotation which can generate a compile-time copy() method (Similar to the kotlinx.serialization) in case there's a need to change only a few fields 👀.

Animeshz avatar Feb 09 '21 10:02 Animeshz

Hi, @elizarov, I noticed that I already answer with a really similar example here.

My question is: how many modifier are allowed for re and im properties?

Function's parameters are val by default, and there isn't any other possible modifier.

Sincerely, I didn't see the point to add some kind of wither modifier.

I made a trivial example, just to understand better the issue.

value class IntPointer(val+wither pointer: Long) {

  var value : Int get() ... set() ...
}

How this declaration differs from:

value class IntPointer(val pointer: Long) { ... }
value class IntPointer(pointer: Long) { ... }

If any value class is identified by its state, I don't need to update an unmodifiable type, I have to create a new value with arguments, and it is always possible to create a new value with different values (please correct me if I wrong).

I really see a value class like a primitive type.

var real = 5.0
var imaginary = 2.0 + 3.i

How differs these two variable? I don't considering primitive/object question (there is no primitive type concept in Kotlin), nor stack/heap allocation (Kotlin does not guarantee allocation type for both type).

fvasco avatar Feb 09 '21 10:02 fvasco

@fvasco The difference is syntactic. The goal is to enable working with immutable classes just like with mutable types. Indeed, every time you "modify" an immutable class you create a new value. We want the syntax for this operation to be as concise as for mutable classes so that people who choose to model their domain with immutable classes don't suffer from the added boilerplate. Now, assume I want to modify a Complex number (that is, to create a new complex value) by adding 5 to its real part (taking example from @Animeshz). I don't want to write this, as this is verbose and does not actually express what I wanted to do:

complex += Complex(5, 0)

I want to write this, just like I do it for mutable classes:

complex.re += 5

The example is not that great for complex numbers, though. The actual design notes have a more striking example where the syntactic difference is quite apparent in the section on deep mutations.

elizarov avatar Feb 09 '21 16:02 elizarov

I agree with you, @elizarov. But I don't understand how your examples requires a wither. Maybe we have different view on the same problem and I not exposed my view very well.

I view a strict analogy from base classes (Int, Char, ...) and value classes: both miss of identity and both are immutable.

Considering your example above, in my view it is possible to update a variable if it is declared as a var, regardless it is a base or a value class.

So:

value class Complex(re: Double, im: Double)

var mutableComplex = 1.0 + 2.0.i
mutableComplex += 5.0 // works

val constantComplex = 1.0 + 2.0.i
constantComplex += 5.0 // fail

Regarding the deep mutation, assuming that all types are value classes, the statement order.delivery.status.message = updatedMessage works if order is declared as var order. Instead it is not possible to update val order (like val c: Char).

fvasco avatar Feb 09 '21 17:02 fvasco

Some general feedback: value classes for the purpose of optimizations look great. Often it's necessary to model domain objects like Email(value: String) and Username(value: String), and having to box/unbox them all the time is unnecessarily expensive.

On the other hand, the whole "immutable way of working with mutability" (i.e. creating new instances when assigning new values for var/wither properties of value classes) is very implicit. When I see point.x += 10 it's not possible to know if this is an assignment happening to a regular class or a value class without inspecting point. What if the code base is heavily multi-threaded and Point starts as a value class and is later changed to a regular class? With the current design, it seems this would compile with no errors or warnings, however all sorts of bugs would appear.

Using copy makes it explicit, so I know what's going on without having to look at the class definition. Sure, the IDE could give some hints, but we also spend a lot of time reviewing code on platforms such as GitHub, where there's no intelligent code navigation for Kotlin.

I understand copy is much more verbose whenever it's necessary to update deeply nested values, but I'd like to see some real use cases for this. The example given on the Deep mutations section looks ad-hoc and I think it's important to also have real use-cases to see if we're not trying to solve locally what should be solved through better code design.

Another point is how compiler plugins could be used to solve some of the mentioned issues. For example, the following:

order = order.copy(
    delivery = order.delivery.copy(
        status = order.delivery.status.copy(
            message = updatedMessage
        )
    )
)

could be

val updatedOrder = order.deepCopy {
  delivery.status.message.mutate(newValue = updatedMessage) 
}

where deepCopy and mutate could be generated for every value class. This would be more explicit and wouldn't (probably) require language changes.

edrd-f avatar Feb 09 '21 18:02 edrd-f

@fvasco Continuing from the other thread:

On potentially making val/var superfluous and removing them altogether, there would be a few things to consider:

  1. What if, conceptually, we’d like to restrict which combination of things can belong to a value class? In that case, val/var would have some utility, where a library could emit “instances” and the user could “change” only a subset of fields. This means the library author can better protect against misuse.
  2. Removing the keyword altogether may introduce parse issues, depending on how it’s implemented. I don’t think there’s a precedent for the syntax anywhere else in Kotlin.
  3. What if, on Kotlin/Native for instance, they do want to eventually allow mutating fields directly? In that case, they would definitely want to keep var/val in use.

I’m sure there’s a lot more, but it is still interesting to consider.

More generally, I notice the comparison of value types to primitives occurring frequently, but I have to wonder how useful that analogy really is. In a theoretical sense it may be nice, but the “law of leaky abstractions” probably applies here as well. We are adding a lot of functionality to what was previously just the set of primitives after all, and the list of supported features can only grow.

I also have to wonder if Valhalla won’t be petitioned to add normal field setters at some point in the future - assuming it’s even possible. The desire to focus on immutability rather than just emulate C# structs always struck me as odd, though I’m sure they have good reason for it. Carving out space for this in Kotlin might not be the worst idea, especially for Kotlin/Native.

Finally, on the case of non-mutating custom setters: I’m not sure what the “mutating” keyword would be, but my vote is for option 2 suggested by @elizarov, where the keyword is only needed on the non-mutating case. However, what if we moved the keyword to the setter itself?:

var x: Float
    get() = ...
    const set(v) { ... } // or whichever keyword

By doing so, const is available as it cannot clash with the existing const val. Not sure if there's another consideration.

philipguin avatar Feb 09 '21 20:02 philipguin

@philipguin

What if, conceptually, we’d like to restrict which combination of things can belong to a value class?

This issue should be addressed in the init block. How some kind of var can forbid something like `Complex(NAN, INFINITY)?

Removing the keyword altogether may introduce parse issues. I don’t think there’s a precedent for the syntax anywhere else in Kotlin.

Nothing new.

class Sum(a: Int, b: Int) {
  val n = a + b
}

Yes, updating the parser is an issue, but we are considering changing the language.

What if, on Kotlin/Native for instance, they do want to eventually allow mutating fields directly?

I will vote no for this proposal. There are enough mutating types in Kotlin, I think we can define an immutable one, at least.

fvasco avatar Feb 10 '21 07:02 fvasco

@edrd-f

What if the code base is heavily multi-threaded and Point starts as a value class and is later changed to a regular class?

With my proposal, switching from

value class Point(x: Int, y: Int)

to

data class Point(val x: Int, val y: Int)

results in a compilation error.

Instead, switching from immutable to mutable leads to an errors' party.

fvasco avatar Feb 10 '21 08:02 fvasco

@edrd-f

Another point is how compiler plugins could be used to solve some of the mentioned issues.

Everything can be solved by a plugin. The whole idea of having better support for immutability is to have this plugin built-in into the language itself.

For example, the following /skipped/ could be

val updatedOrder = order.deepCopy {
  delivery.status.message.mutate(newValue = updatedMessage) 
}

This is similar to the concerns that @altavir shared above, so let me answer both here. The argument is that these "implicit copies" are spooky, hard to see without IDE, etc, so let's have some more verbose, more explicit, scoped syntax for them. We could invent lots of different approaches to this "more explicit syntax" (the quote above shows just one). These concerns are valid, yet, regardless of how this "more explicit syntax" will look like, they gloss over the key fact:

Mutations (creating new copies) of immutable types are safe (they cannot have side-effects on unrelated parts of the system), while mutations of mutable types are dangerous (they can accidentally affect unrelated code that kept a reference to the same instance).

It is wrong to design a language in such a way that performing a safe and mostly harmless operation (like creating a new immutable value) requires more boilerplate than performing a more dangerous operation (like updating a field in a mutable instance) that requires a lot of attention and forethought from a programmer.

Indeed, if those two kinds of mutations look syntactically the same, then changing a (safe) immutable value class to a (dangerous) mutable class will cause the old code to still compile, yet it will start producing weird bugs. Let's see how this problem can be solved. Both @altavir and @edrd-f propose to distinguish (syntactically or contextually) these two kinds of mutations. However, taking into account the key fact, it means that we have to make (dangerous) mutations of mutable classes more explicit, not vice versa. The safer operation should not be more verbose than a more dangerous one.

There is a simpler solution to this problem. First of all, note that the problem is not novel. It is happening right now all the time. People make mistakes of using mutable classes where they should be using immutable ones. As you write, it compiles "with no errors or warnings, however, all sorts of bugs would appear."

However, as we improve support for immutable classes in the language (make it less burdensome to use immutable values) we can start adding mechanisms to require immutability in various contexts (like asynchronous data pipelines) or at least warn on attempts to use mutable data there. Now if you accidentally use a mutable class where you should have been using an immutable one then you'll get, at least, warned.

elizarov avatar Feb 10 '21 09:02 elizarov

@fvasco But I don't understand how your examples require a wither.

Wither is not needed for mutableComplex += 5.0 (it works in Kotlin now!), wither is needed for order.delivery.status.message = updatedMessage:

  • It needs to create a new copy of status with an updated message field.
  • Then it needs to create a new copy of delivery with an updated status field.
  • Then it needs to create a new copy of order with an updated delivery field.
  • Only then it assigns this new copy of order to the order property/local (which must be var or it will not compile).

elizarov avatar Feb 10 '21 11:02 elizarov

Would custom setters be called for each nested “wither” in the example? That would explain the motivation for disallowing them. However, the case I’m interested in is the one lacking a backing field, which I suppose wouldn’t be touched being just functions, essentially.

philipguin avatar Feb 10 '21 11:02 philipguin

Yes @elizarov, I agree.

But I suppose to argue about new modifiers (https://github.com/Kotlin/KEEP/issues/237#issuecomment-774986997), not about implementation details.

fvasco avatar Feb 10 '21 12:02 fvasco

I think it's fair to mention here the partially related issue of KT-44530. The quick TL;DR is that currently there's a missing optimisation whenever you return a lambda or store it in a local variable that only gets passed to inline functions because the compiler doesn't realise that the lambda doesn't have to be boxed. If that optimisation is implemented, one could use a @JvmInline value class with a backing lambda with a few tricks to simulate a multi-field value class that doesn't have to be boxed if it doesn't need to. Or even better yet, the kotlin language can include some sort of @AutoInline or @LambdaBacked or an annotation along those lines that automatically writes all that boilerplate for you, or it can even use the same underlying mechanism as the lambda optimisation (which should be not incredibly hard to implement) to avoid boxing a multi-field value class when it doesn't need to box it. My point here is that even if the Kotlin team isn't interested in the idea of @AutoInline value classes right now or just doesn't have the time to implement them, they can still be implemented manually by the users if that lambda optimisation is implemented. Again that optimisation is possibly trivial and, from a certain prespective, it is kind of something that a beginner who just learned about inilne functions probably would expect from from the compiler to do automatically; check the linked issue for more detailed info and examples as to what this brings onto the table in terms of possibilities. Hopefully I'm not going super off-topic because I do think that this is probably relevant to this discussion as a future possibility at least. Feel free, however, to delete this comment if you think that it's harmful to this current discussion.

kyay10 avatar Feb 10 '21 12:02 kyay10

One question/clarification about value interfaces: how do value interfaces and non-value interfaces interact inheritance-wise? It doesn't make sense for a non-value interface to extend a value interface, but the opposite (i.e. ImmutableList extending List) does. I would think value interfaces would be allowed to extend non-value interfaces, but I didn't see it specified anywhere.

Two questions about the scope functions section:

If I understand your mutating inline fun <T, R> T.run(block: mutating T.() -> R): R example right, it wouldn't be callable on non-var value objects?

If I understand the apply example right, there is a difference between:

var state: State = ...
state.tags += "tag"
state.updateNow()

and

var state: State = ...
state.apply{
    tags += "tag"
    updateNow()
}

where the proper replacement of the first snippet is

var state: State = ...
state = state.apply{
    tags += "tag"
    updateNow()
}

This is different than how apply is used currently (the docs say "The return value is the object itself", and while the identity is irrelevant, this wouldn't do it with respect to equality either) and I expect it would trip people up. Especially in cases where you are using apply on deep mutable or immutable var variables, i.e. call.symbol.owner.apply{ } differs in whether it updates owner depending on if owner is mutable or an immutable var value.

Given that we essentially want the lambda to match the call site's mutability, one easy way to handle this would be allowing overloads on mutating. Something type system based seems more ideal though (like the tagged projections that were mentioned).

Another thing that was hinted at a bit in the "Read-only collections vs immutable collections" section: MutableList will have essentially the same methods as a var ImmutableList. It would be nice if those could be combined, like if there was a way for a value class/interface to implement interfaces only when mutable (using it's mutating methods). It would prevent having to create duplicates of any mutating functions for MutableList and mutating ImmutableList, etc. This probably falls under the tagged projection proposal as well though.

rnett avatar Feb 10 '21 20:02 rnett

@elizarov, first of all, thanks for taking the time to reply to our feedback.

About these statements:

It is wrong to design a language in such a way that performing a safe and mostly harmless operation (like creating a new immutable value) requires more boilerplate than performing a more dangerous operation (like updating a field in a mutable instance) that requires a lot of attention and forethought from a programmer.

Both @altavir and @edrd-f propose to distinguish (syntactically or contextually) these two kinds of mutations. However, taking into account the key fact, it means that we have to make (dangerous) mutations of mutable classes more explicit, not vice versa. The safer operation should not be more verbose than a more dangerous one.

I agree when looking through an idealistic perspective, however, from a realistic perspective, I don't think it will be practical to have more warnings/boilerplate for mutability while switching the default to immutabilty considering Kotlin has to interoperate with Java and JavaScript, and these use mutability heavily. It's the same story as flexible types: ideally, everything would be nullable when dealing with platform types, however it would lead to an insane amount of null-handling code and noisy type declarations, so there's a pragmatic compromise of safety for interop conciseness. So this:

[...] as we improve support for immutable classes in the language (make it less burdensome to use immutable values) we can start adding mechanisms to require immutability in various contexts (like asynchronous data pipelines) or at least warn on attempts to use mutable data there. Now if you accidentally use a mutable class where you should have been using an immutable one then you'll get, at least, warned.

Has two potential issues: worse interoperability and more IDE dependency to know what's going on (which is bad for code reviews). The alternative is to drop the warnings and keep the idea of same syntax for mutable and immutable assignments, however we're then forced to look at the class declarations to know what's safe to mutate and what's not.

The alternative @altavir and I proposed solves the ambiguity problem and I'm sure there are ways of designing something pretty concise so that the additional syntax shouldn't be an issue.

Lastly, I'd like to reinforce what @altavir said about an easier mental model. Kotlin already has properties instead of getters/setters, which some complain "hide behaviors". It has delegated properties. It has dispatch and extension receivers. It's now going to have multiple receivers... These are some examples of features that have implicit behaviors and require some experience to know exactly what's going on, but the steeper learning curve is justified by how useful they are in almost every domain. Now, given the issues I described, I'm not sure the benefits of the proposed immutability facilities are so widely applicable to justify the extra implicity they create, and that's the reason why I suggested they're provided as a compiler plugin.

edrd-f avatar Feb 11 '21 00:02 edrd-f

@elizarov @edrd-f Let me reiterate since I've skipped some details that were present in the chat discussion. The initial proposal by Roman it to do the following:

value class B(var c: Int, var d: Int)
value class A(var b: B)

var a = A(..)

a.b.c += 1 //returns Unit, but the state of a is changed
a.b.d -= 1 //second rewrite

My thought (not originally mine, I am translating the result of the discussion) is to do

a.mutate{ //or mutate(a){ which is probably even better
  b.c += 1
  b.d -= 1
} //both changes are done atomically here and the value

As you see the syntax is exactly the same but is allowed only in a specific colored (see @ilmirus PR) scope. This syntax could actually co-exist with the first one to allow atomic changes and could be used for a restrict-first-relax-later introduction (only scoped changes at first, but also non-scoped later).

The scoped change also has the benefit of always explicitly knowing, which variable is actually a root of a change. In a simple lens assignment, you can't know if the variable is the one that is being changed or an intermediate step.

altavir avatar Feb 11 '21 07:02 altavir

Did you mean to write value class B(var c: Int, var d: Int)?

quickstep24 avatar Feb 11 '21 08:02 quickstep24

Yes. It won't compile otherwise

altavir avatar Feb 11 '21 10:02 altavir

In the section Abstracting mutation into functions

A type has to be mentioned twice: as a receiver type and as a return type.

It is not clear what is the return type of the copy method., why state.copy(...) should not return a State?

The intent of writing a function that returns an updated receiver is not immediately clear

Yes, I agree. But few lines above

Sometimes a convention of naming such functions as withXxx may be used, but we don’t follow it here.

Why need to cover with a language feature a deliberate, wrong programmer's choice?

Finally it isn't not specified in that section how should work the code:

val state = State(...)
state.updateNow()

I don't find enough motivations for a new feature, moreover we should inspect Java interoperability better.

final var state = State(...)
state.updateNow() // or StateKt.updateNow(state)

This is a valid and misleading Java code.

fvasco avatar Feb 12 '21 12:02 fvasco

To help the developer to use immutable objects (see also my post above), we can introduce a new operator to "invoke and assign", really similar to "plus and assign" (with similar pro and cons). I propose you an example, I call it .=, for example.

Here we play with a not mutable list.

var list = listOf(1, 2, 3)
list += 4
list .= filter { it % 2 == 0 }

The example in the previous post became:

var state = State(...)
state .= updateNow()

This operator is more explicit because it has been read on the caller site, there is no magic under the hood, and it is more flexible because it is possible to use with already existent types.

var t = n.seconds
t .= absoluteValue
t /= 42

or

myDataClass .= copy( value = "abc" )

fvasco avatar Feb 17 '21 11:02 fvasco

Regarding the section Var this as a ref (inout) parameter in disguise, I try here to enhance delegation to achieve the same goal.

Sincerely, I am not really a fan of this proposal, I don't perceive this feature as undoubtedly useful.

My first idea is to use & to access the delegated instance, i.e.:

val value by lazy { ... }
....
if( &value.isInitialized() ) { ... }

My second idea is to allow delegation in function's arguments, i.e.:

fun <T> f( value by Lazy<T>) {
if ( ... ) println(value)
}

Using these building blocks we can write code as:

// use a `Closeable` and close if it has been initialized
fun <T : Closeable, R> Lazy<T>.useLazy(block: (Lazy<T>) -> R): R =
    try {
        block(this)
    } finally {
        if (isInitialized()) value.close()
    }


    val largeBuffer by lazy { allocateBuffer(10_000) }
    &largeBuffer.useLazy { buf ->
        if (debug) dump(buf)
    }

What does it has to do with inout?

My first idea is to use & to access the delegated instance, i.e.:

val value by lazy { ... }
....
if( &value.isInitialized() ) { ... }

My second idea is to allow delegation in function's arguments, i.e.:

fun <T> f( value by Lazy<T>) {
if ( ... ) println(value)
}

Using these building blocks we can write code as:

// use a `Closeable` and close if it has been initialized
fun <T : Closeable, R> Lazy<T>.useLazy(block: (Lazy<T>) -> R): R =
    try {
        block(this)
    } finally {
        if (isInitialized()) value.close()
    }


    val largeBuffer by lazy { allocateBuffer(10_000) }
    &largeBuffer.useLazy { buf ->
        if (debug) dump(buf)
    }

What does it have to do with inout? Now it is possible to provide ref variable as a library.

interface Ref<T> { var obj: T }
operator fun <T> Ref<T>.getValue(thisRef: Any?, property: KProperty<*>) = this.obj
operator fun <T> Ref<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) { obj = value }

fun <T> ref(value: T): Ref<T> = object : Ref<T> { override var obj: T = value }

fun main() {
    var a by ref(1)
    var b by ref(2)
    swap(&a, &b)
    println("$a, $b") // 2, 1
}

fun <T> swap(a by Ref<T>, b by Ref<T>) {
    a = b.also { b = a }
}

This implementation is a little more verbose (explicit) than the native one. Finally, this implementation is more flexible, developers can implement Ref using a @Volatile var or a StateFlow.

fvasco avatar Feb 17 '21 14:02 fvasco

@fvasco To help the developer to use immutable objects (see also my post above), we can introduce a new operator to "invoke and assign", really similar to "plus and assign" (with similar pro and cons).

This would be better discussed under https://youtrack.jetbrains.com/issue/KT-44585 (it is listed there as one of the possible syntactic options).

elizarov avatar Feb 18 '21 14:02 elizarov

Speaking of my app & library development experiences with Swift, the ambiguity as discussed is perhaps an unfortunate projection of the underlying implementation details of this language feature. Maybe it is caused by the close analogy to data class and its copy().

In my opinion, forcing mutations in a block scope indeed makes the intent to mutate stand out. But it is never going to tackle the "ambiguity" at its root, because the "ambiguity" is started before any mutation can happen — value classes effectively play the rules of value semantics.

Conceptually, var obj2 = obj1 is where the ambiguity semantically begins. If we use the analogy of a multiverse, every assignment of a value class instance to a new variable signals the need to branch off a new alternative universe to the original instance, and the spin-offs must be independent from the original timeline from that point of branching off.

If we explain it in terms of value semantics, every rvalue is copy-assigned to the lvalue, notwithstanding the compiler potentially optimizing it away.

This is why I find it slightly unfortunate that the document does not build on concepts like reference semantics v.s. value semantics, specifically for explaining changes in the language spec for the language users. Instead, it seems to be more geared towards implementors, with a wealth of details on implementation approaches.

More specifically, while the document seems to indicate the initial implementation uses a copy-on-write approach, IMO it can still work well with value semantics + copy-assign being the conceptual model for value classes in the language. As far as I can understand, the procedural outcome should be the same — copy-on-write can be treated as an optimization to copy-assign, provided that there is no intervening optimisation.

(like rewriting copy-updates into in-place mutations)

I do understand that there might be a desire to set it apart from C/C++/Swift structs, but adopting these established and distinctive concepts as a tool to better contextualize the new feature need not imply optimization constraints.

For example, the document argues that:

A number of languages, like C++, C#, Rust, Go, Swift, and others have a concept of a struct. [...] The conceptual model behind value classes is different. A developer’s intent of writing a value class is to explicitly disavow its identity and to declare, upfront, its immutability. This declaration enables compiler optimization such as passing those values directly, but it does not guarantee them.

When we look at Swift, its language specification indeed does establish that:

  1. value type that is passed-by-copy and copy-on-assignment;
  2. inout references have copy-in, copy-out semantic; and
  3. It also has ABI stability with strong guarantees around flattened memory layout.

While all these might seem to be agreeing with the presented counter argument, the Swift compiler does defy it — many compiler optimizations deviating from these semantics have been implemented and shipped anyways, as long as they did not change the procedural outcome or break the invariants. A couple of examples:

  1. automatically passing immutable small values to function calls in registers;
  2. automatically passing immutable large values to function calls on stack by reference; and
  3. inout reference being a pointer to the backing field memory, as long as the compiler knowns it is safe to go direct (e.g. no getter/setter vtable dispatch needed).

So... hmm, what I want to get to is, that this ambiguity feels more a teaching/explaining issue, rather than a trap warranting an in-language solution. I do think bringing in ref vs value semantics — as the new basis to explain normal classes vs value classes — could be helpful in this regard, rather than the current somewhat “a special kind of class” narrative.

After all, AFAIK, no existing declarations except inline classes will be subsumed/auto-converted to value classes. Using a new type of declaration implies the need to learn new semantics. Sounds pretty fair to me.

andersio avatar Mar 09 '21 15:03 andersio

UPDATE: Taking into account use-cases where a value class is used as an immutable handle into a mutable data structure and the need to gradually teach developers new features where properties of immutable classes are somehow updated, we've decided to give an explicit name to the corresponding concept -- a "copyable property", and introduce a corresponding copy modifier both for "copyable properties" (copy var) and for copying functions (copy fun).

The text was updated to consistently use the corresponding terminology and the copy modifier. Motivation for the change was added to the text, too.

The restriction on the use of var properties for value classes is removed.

See https://github.com/Kotlin/KEEP/pull/247

elizarov avatar Apr 09 '21 17:04 elizarov

@elizarov

UPDATE: Taking into account use-cases where a value class is used as an immutable handle into a mutable data structure and the need to gradually teach developers new features where properties of immutable classes are somehow updated, we've decided to give an explicit name to the corresponding concept -- a "copyable property", and introduce a corresponding copy modifier both for "copyable properties" (copy var) and for copying functions (copy fun).

The text was updated to consistently use the corresponding terminology and the copy modifier. Motivation for the change was added to the text, too.

The restriction on the use of var properties for value classes is removed.

See #247

According to the last commit date, there was nothing changed near the date of your message image

zhelenskiy avatar Apr 11 '21 21:04 zhelenskiy

@zhelenskiy

See #247

According to the last commit date, there was nothing changed near the date of your message image

It is in a separate PR #247, to be merged after review.

elizarov avatar Apr 12 '21 09:04 elizarov