softwaremill / quicklens

Modify deeply nested case class fields
https://softwaremill.com/open-source/
Apache License 2.0
825 stars 53 forks source link

Support polymorphic classes and type change? #4

Open FranklinChen opened 9 years ago

FranklinChen commented 9 years ago

Any interest in supporting polymorphism and type change?

case class Street[+A](name: A)
case class Address[+A](street: Option[Street[A]])
case class Person[+A](addresses: List[Address[A]])

val p2 = modify(person)(_.addresses.each.street.each.name).using(_.length)
adamw commented 9 years ago

Not sure if that would be possible, maybe with a dedicated version of modify which looks up the type parameter (which would have to be propagated all the way up to the top-level entity).

Do you have any thoughts on how to implement such a feature?

iamorchid commented 9 years ago

Here I think the example given above is not possible for quicklens since it's trying to use "_.length" to replace string content of "name", which means changing its original type. Since quicklens is based on copy method of case class, it doesn't make sense for us to pass a parameter of a different type.

If quicklens wants to support type change, I believe the whole framework may need to be changed. What's more, why do we need to change the type? How many people would use such functionality ? And I believe the following syntax for generic types already supported:

modify(person)(_.addresses.each.street.each.name).using { _.toLowerCase }
FranklinChen commented 9 years ago

I'm afraid I'm not an expert at Scala macros, so I don't know how to generalize to case classes with type parameters, but regarding @iamorchid 's question:

Yes, I change the type all the time in my code when annotating a tree and transforming it into a tree of a different type. I was just reminded of this by someone's blog post describing this common FP pattern: http://typelevel.org/blog/2015/09/21/change-values.html

Without lenses, I have to write boilerplate with copy:

scala> val street = Street("here")
street: Street[String] = Street(here)
scala> val newStreet = street.copy(name = street.name.length)
newStreet: Street[Int] = Street(4)
stanch commented 8 years ago

I considered having a go at implementing def modifyPoly[T[_], U](obj: T[U])(path: T => U) = ??? to cover the basic use-case, but there are a few problems:

  1. A class can have more than one type parameter. In these situations one would have to use type lambdas or kind-projector, e.g.:

    case class NamedPair[A, B](name: String, left: A, right: B)
    
    val before: NamedPair[Int, String] = NamedPair("test", 1, "one")
    val after: NamedPair[Int, Int] = NamedPair("test", 1, 1)
    
    modifyPoly[NamedPair[Int, ?], String](before)(_.right).setTo(1) mustEqual after

    If you generally favor one of the type arguments, but not the other, then -Ypartial-unification might remove the need for the type annotations.

  2. In the original example Street, Address and Person are all parametrized with the same type, however that might not be the case. Again, type lambdas or kind-projector are required, e.g.:

    case class Wrapper[A](value: A)
    case class Enclosure[A](value: A)
    
    val before: Wrapper[Enclosure[Int]] = Wrapper(Enclosure(1))
    val before: Wrapper[Enclosure[String]] = Wrapper(Enclosure("one"))
    
    modifyPoly[({ type λ[A] = Wrapper[Enclosure[A]] })#λ, Int](before)(_.value.value).setTo(1) mustEqual after
  3. The type parameter might have bounds. The bounds need to be somehow propagated to the PathModifyPoly class to generate code like this:

    abstract class PathModifyPoly[T[_], U](obj: T[U]) {
    // the bound has to be here
    def using[V <: Bound](f: U => V): T[V] = // this will be implemented by the macro
    
    // and here
    def setTo[V <: Bound](v: V): T[V] = using(Function.const(v))
    }
  4. One field can use two type parameters, so the above will not work at all, e.g.:

    case class NamedPair[A, B](name: String, pair: (A, B)]
    
    modifyPoly[???, (Int, String)](NamedPair("test", 1 -> "one"))(_.pair).setTo("one" -> 1)

What do you think about these limitations? Are they excluding a big number of use-cases? Are there any solutions I’m missing? In my own code I would face at least limitation 3, but perhaps also 2 and 1.

stanch commented 8 years ago

(I am of course assuming that we stick to blackbox macros.)

stanch commented 8 years ago

Regarding the third limitation, PathModifyPoly can be declared with bounds in mind using the technique below, so that it can be properly inherited with or without bounds:

@ abstract class A[L, U] { def foo[V >: L <: U](a: Int, b: Int => V): V }
defined class A

@ new A[Nothing, AnyVal] { def foo[V >: Nothing <: AnyVal](a: Int, b: Int => V) = b(a) }
res1: A[Nothing, AnyVal] = cmd12$$anon$1@6e84c052

@ new A[Null, Any] { def foo[V >: Null <: Any](a: Int, b: Int => V) = b(a) }
res2: A[Null, Any] = cmd13$$anon$1@6f4bafc2
adamw commented 8 years ago

I don't see a way to jump over 4 as way without introducing e.g. PathModifyPoly2, but maybe let's start with single-type-param version. If you have something working, it's definitely better to cover some use-cases than none I suppose :) Your solution to 3. looks good as well.

As for 1., kind-projector vs type-lambdas is up to the user, luckily we wouldn't have to add any dependency to quicklens.