Immutability, builders and type inference


#1

I've been playing around with a design for immutable objects and builders and I've run into a couple of problems. I'm aiming for this:

val foo1 = Foo(x = 1, y = 2) // Foo is immutable val foo2 = foo1.copy { y = 3 } // creates a new instance with x = 1, y = 3

The copy function:

  • creates a builder object from foo1
  • executes the closure (an extension function on the builder) to update the builder’s state
  • builds a new instance of Foo from the builder.

I’ve got something that almost works. The code is below. But I’ve run into a couple of problems:

  • I have to specify the type parameters for the copy function at the call site which messes up the syntax. Will the compiler be able to infer these at some point?
  • I have to specify the bounds of the builder type in 2 places, the declaration of Copyable and the declaration of copy. Should the compiler be able to infer the bounds in the declaration of copy?
  • If I change the order of the supertypes in the declaration of Bar or BarBuilder it doesn’t compile. The compiler thinks the class doesn’t extend the trait. I assume that’s a bug.

I’m using build 0.1.3299 of the plugin.

// T is the immutable type trait Builder<out T> {   fun build(): T }

// T is the immutable type, U is the builder
trait Copyable<out T, out U : Builder<T>> {
  fun builder(): U
}

open class Foo(val x: Int, val y: Int) : Copyable<Foo, Foo.FooBuilder> {
  override fun builder(): FooBuilder = FooBuilder(x, y)
  open fun toString(): String = “Foo[x=$x, y=$y]”

  open class FooBuilder(var x: Int, var y: Int): Builder<Foo> {
  override fun build(): Foo = Foo(x, y)
  }
}

class Bar(x: Int, y: Int, val z: Int) : Copyable<Bar, Bar.BarBuilder>, Foo(x, y) {
  override fun builder(): BarBuilder = BarBuilder(x, y, z)
  override fun toString(): String = “Bar[x=$x, y=$y, z=$z]”

  class BarBuilder(x: Int, y: Int, var z: Int) : Builder<Bar>, Foo.FooBuilder(x, y) {
  override fun build(): Bar = Bar(x, y, z)
  }
}

fun <T : Copyable<T, U>, U : Builder<T>> T.copy(fn: U.() -> Unit): T {
  val builder = this.builder()
  builder.fn()
  return builder.build()
}

fun main(args : Array<String>) {
  val foo1 = Foo(x = 1, y = 2)
  //val foo2 = foo1.copy { y = 3 } // this doesn’t work
  val foo2 = foo1.copy<Foo, Foo.FooBuilder> { y = 3 } // this is ugly

  val bar1 = Bar(x = 1, y = 2, z = 3)
  //val bar2 = bar1.copy { z = 23; y = 21 } // this doesn’t work
  val bar2 = bar1.copy<Bar, Bar.BarBuilder> { z = 23; y = 21 } // this is ugly

  println(foo1) // Foo[x=1, y=2]
  println(foo2) // Foo[x=1, y=3]
  println(bar1) // Bar[x=1, y=2, z=3]
  println(bar2) // Bar[x=1, y=21, z=23]
}


#2

* I have to specify the type parameters for the copy function at the call site which messes up the syntax. Will the compiler be able to infer these at some point?

Looks like we could do it, most likely. To make sure, I created an issue: http://youtrack.jetbrains.com/issue/KT-2754

* I have to specify the bounds of the builder type in 2 places, the declaration of Copyable and the declaration of copy. Should the compiler be able to infer the bounds in the declaration of copy?

This is possible (at least for some cases), but a questionable feature.

* If I change the order of the supertypes in the declaration of Bar or BarBuilder it doesn't compile. The compiler thinks the class doesn't extend the trait. I assume that's a bug.

Yes, looks like a bug, http://youtrack.jetbrains.com/issue/KT-2756

#3

Great, #1 is the most important for me because it affects the call site. I don't think #2 is a big deal because it only affects the declaration.


#4

BTW, one can think of an alternative approach to this particular problem:

``

class Foo(val x: Int, val y: String)

fun Foo.copy(x: Int? = null, y: String? = null) {
  return Foo(x ?: this.x, y ?: this.y)
}

fun test() {
  val f = Foo(1, “a”)
  val f1 = f.copy(y = “b”)
}

This solution is rather specific, but still works.


#5

Yep, that works and is definitely simpler than my idea. It's obvious now I see it ;)

Having a mutable builder might be useful in some cases (GUI binding maybe?) but your approach is cleaner.