Is it possible to let kotlin property support different types for getter and setter

This sounds like a very unreasonable request. but I can give a very reasonable usage scenario.

I’m creating a framework, its first sub module “kimmer” is nearly finished.
I bring immer(GitHub - immerjs/immer: Create the next immutable state by mutating the current one) for kotlin/jvm, it’s called kimmer.

You can see the usage in this unit test.

graphql-provider/KimmerTest.kt at main · babyfish-ct/graphql-provider · GitHub

“Node”, “BookStore”, “Book” and “Author” are defined by the user and are read-only data model types.

“NodeDraft”, “BookStoreDraft”, “BookDraft” and “AuthorDraft” are automatically generated by the pre-compiler(google/ksp) of this framework, so their source code can only be seen after compilation, they represent a mutable imperative data model.

In this unit test, we see the same effect as immer, create new immutable data model by modifying the mutable data model directly. The deeper the data model structure, the greater the value.

Let’s see an immutable interface

interface Book: Node {

   // Readonly property, readonly list and readonly element
   val authors: List<Author>
}

Let’ see the mutable interface generated by the ksp

inteface BookDraft<T: Book>: Book, NodeDraft<T> {

    // Mutable property, mutable list and mutable element
    override var authors: MutableList<AuthorDraft<T>> 

    // Get or create, remove the "unloaded" flag
    fun authors(): MutableList<AuthorDraft<T>> 
}
  1. Immutable interface “Book” uses “val” to declare readonly property, mutable interface “BookDraft” override it and use “var” to declare writable property.
  2. “Book.authors” uses readonly collection “kotlin.List”, “BookDraft” uses mutable collection “kotlin.MutableList”
  3. Element of “Book.authors” is readonly interface “Author”, element type of “BookDraft.authors” is mutable interface “AuthorDraft”

Now, let’s create/modify immutable object tree by modify mutable tree directly

val book = new(BookDraft.Sync::class) { // The type of the 'book' is Book, we get immutable data tree
    name = "The book"
    authors() += new(AuthorDraft.Sync::class) {
       name = "Jim"
    }
    authors() += new(AuthorDraft.Sync::class) {
       name = "Kate"
    }
}

Or, we do it by another style

val book = new(BookDraft.Sync::class) { // The type of the 'book' is Book, we get immutable data tree
    name = "The book"
    authors = mutableListOf( // must be "mutableListOf", cannot be "listOf"
       new(AuthorDraft.Sync::class) {
           name = "Jim"
       },
       new(AuthorDraft.Sync::class) {
           name = "Kate"
       }
  )
}

Here, developer must uses “mutableListOf”, “listOf” will cause compilation error.

Because the type of the getter of “BookDraft.authors” is “MutableList”, so its setter must be “MutableList” too.

However, for this scenario, readonly list can work fine (even if you use “Collections.unmodifableList” for runtime defense) and it makes more sense to the user.

What about:

val book = new(BookDraft.Sync::class) { // The type of the 'book' is Book, we get immutable data tree
    name = "The book"
    authors = listOf( // pretend "listOf" is ok
       new(AuthorDraft.Sync::class) {
           name = "Jim"
       },
       new(AuthorDraft.Sync::class) {
           name = "Kate"
       }
    )
    // Uh-Oh, authors was set to immutable list
    // How are we mutating an immutable list?
    authors() += new(AuthorDraft.Sync::class) {
         name = "Sam"
      }
}

When you mix the two examples it shows how listOf doesn’t really make sense.

Good question

// Uh-Oh, authors was set to immutable list
// How are we mutating an immutable list?

For draft object.

  1. Getter is mutable list, so it always returns a mutable list PROXY.
  2. Setter accept everything, no matter readonly list or mutable list.
  3. mutable list can be created base on readonly list, but it can be upgraded to mutable when it’s modified at the first time.

You can veiw: ListProxy.kt

internal open class ListProxy<E>(
    private base: List<E> //Proxy can be created by readonly list
): MutableList<E> { // Proxy is mutable

    // If not null, it hides "this.base"
    private val modified: MutableList<E>? = null

   // All the reading behaviors of proxy depends on this field
   private inline val list: List<E>
     get() = modified ?: base

   // All the writing behaviors of proxy depends on this field
   private inline val mutableList: MutableList<E>
        get() = modified ?: base.toMutableList().also {
            modified = it
        }

    // An example about reading behavior
    override fun isEmpty(): Boolean =
        list.isEmpty()

    // An example about writing behavior
    override fun clear() {
        mutableList.clear().also {
            modCount++
        }
    }
}

We can see proxy is mutable list, but it can be created base on readonly list.

  1. If user only create this proxy but don’t change it, it will always be an empty wrapper for a read-only list.
  2. If user create this proxy and change it, when it is first modified, it will be upgraded to a real writable list.

This is copy/write strategy. Similarly, for objects, writable object proxy can be created based on read-only objects. It will also be upgraded to a real writable object the first time it is modified.

After the whole modification is terminated(The end of outside lambda). All the proxies will be used to created new immutable object tree.

  1. If proxy is not upgraded, its old readonly data will be used to create new immutable data tree(Reuse sub-tree)
  2. If proxy has been upgraded, its new data will be used to create new immutable data tree(Replace sub-tree)

The deeper the data model structure, the greater the value. So immer is very powerful, it’s not an exaggeration to describe it as great.

That’s why I port this library for kotlin/jvm

It’s good question, and this is beautiful magic of immer.

I reply it twice, but system hide my reply twice…

OK, system hide my relay twice… I try a shorter relay.

For draft object

  1. Getter returns proxy, this proxy is mutable list so that developer can modify deeper data
  2. Setter accept everything, not matter it’s readonly list or mutable list.

The magic is in the List

internal open class ListProxy<E>(
    private val base: List<E> // Create proxy base on readonly list
) {
    // become non-null when proxy is modified first time
    private val modified?: MutableList<E>? = null

    // for reading
    private val list: List<E>
      get() = modified ?: base

   // for writing
   private val mutableList: MutableList<E>
      get() = modified ?: base.toMutableList().also {
           modified = it
      }

   fun isEmpty(): Boolean = list.isEmpty()

   fun clear() { mutableList.clear() }
}

Copy/write strategy, proxy base on readonly list will be upgraded to real writable-list when it’s modified first time.

That doesn’t sound desirable, IMO. It is unexpected and confusing.

This value, which is hard to see when the object is created, is very obvious when the object is modified.

With a read-only tree, it’s very deep, and it’s not easy to modify parts.

First, immer can create a draft proxy for the root of the tree. If the object has some properties of other objects or collections, it can create more leaf sub-proxy as needed according to the user’s access method.

No matter object or a collection, we can get proxies, and although the API exposed by these proxies is writable, these proxies are initialized by readonly data without expensive cost. If the user modifies them, they will be upgraded to real writable objects by “copy/write” strategy.

Finally, when the whole process is over, all proxies are used to create new read-only objects

  1. If proxies are not upgraded, directly reuse the read-only data immutable objects they wrap(reuse subtree)
  2. If proxies have been upgraded, create immutable objects by the new data they hold(replace subtree)

This value, which is hard to see when the object is created, is very obvious when the object is modified.

val book2 = new(BookDraft.Sync::class, book) { // Change book -> book2
    store().area().parent().name += "*"
    println(authors.size) // For authors, only read, not write
}

In the above example, I only modified the many-to-one association “store”, don’t modified the one-to-many association “authors”.

In the new book2, store are replaced with new object(replace sub-tree), but authors are still the old list(reuse-subtree).

If the tree is shallow, you won’t see any difference between this approach and the copy function of the kotlin data class. But if the tree is deep, you will find that it is a gift from God.

This is immer, building a read-only object tree by modifying the mutable object tree. That’s why immer got the “Most impactful contribution JavaScript open source award in 2019” and why I ported it to kotlin/jvm.