optics-dev / Monocle

Optics library for Scala
https://www.optics.dev/Monocle/
MIT License
1.66k stars 206 forks source link

Remove polymorphic optics #770

Open julien-truffaut opened 4 years ago

julien-truffaut commented 4 years ago

Standard optics work between two types A and B. There is a generalisation of optics called polymorphic optics that takes four type parameters, generally called S, T, A, and B. This generalisation let us change the type of the value targetted by an optic. For example, if you have an Option[Int] (S), you may want to transform an Int(A) into a String (B), resulting in an Option[String] (T). Polymorphic optics can express standard optics (aka monomorphic optics) as an alias, type Lens[A, B] = PolyLens[A, A, B, B].

Polymorphic optics have several issues:

julien-truffaut commented 4 years ago

@smarter here is a more detailed explanation of type inference issues with polymorphic optics. You may have some ideas how we could make it work in dotty/scala 3

Odomontois commented 4 years ago

I have at least two places where I moved from monocle to my own tofu-optics because I had to write Functor[...].compose[...].compose[...].compose[...].map Instead of some nice applied optics I've designed some scary approach here https://github.com/TinkoffCreditSystems/tofu/blob/master/optics/core/src/main/scala/tofu/optics/Applied.scala#L12 for this application that breaks polymorphic Applied composition to two stages: first stage is search for the "tag" during which we calculating next optic type And then we may pass the argument (like map key or list index) and get back simple Applied optic

Tag works like typeclasses from the monocle.function._ package example can be found here https://github.com/TinkoffCreditSystems/tofu/blob/master/optics/core/src/main/scala/tofu/optics/tags/index.scala

nigredo-tori commented 3 years ago

Just my two cents here. There are valid use cases that are not covered by monomorphic optics. For example, I have a hierarchy of data types like this (simplified):

final case class Foo[A](bar: Bar[A])
final case class Bar[A](as: List[A])

where A represents some entity. I'd like to store a (partially) normalized variant in the database (Foo[QuxId] in one table, Qux in another one), and have an API that builds a Foo[Qux] out of that. Something like def _barAsEach[A, B]: PTraversal[Foo[A], Foo[B], A, B], while cumbersome, is invaluable for this.

I agree that full-blown polymorphic optics (with four independent type parameters) are inconvenient to use in Scala - but, perhaps, there's some middle ground here? For example, we can have an abstraction like this (pseudocode to show intent):

trait ParamTraversal[F[_], G[_]] {
  def apply[A, B]: PTraversal[F[A], F[B], G[A], G[B]]
}

This should be far easier to use, and it covers my use case quite nicely:

val _bar: ParamTraversal[Foo, Bar] = ???
val _as: ParamTraversal[Bar, List] = ???
val listEach: ParamTraversal[List, Id] = ???

val _barAsEach: ParamTraversal[Foo, Id] = _bar.compose(_as).compose(listEach)

Monomorphic optics can be seen as special cases:

type Const0[A, B] = A
type Traversal[S, A] = ParamTraversal[Const0[S, *], Const0[A, *]]

The tradeoff here is that building a ParamTraversal will be noisier than building a PTraversal. However, this can be somewhat mitigated using macros - see cats.arrow.FunctionK.lift.

julien-truffaut commented 3 years ago

@nigredo-tori you are right, this could also be an option.

Actually, after playing with Dotty type inference, I believe we can make the full blown polymorphic optics work quite nicely in Scala 3, see this example https://github.com/optics-dev/Monocly/blob/master/src/main/scala/Main.scala#L21-L31

An advantage to keep polymorphic optics is that we can add variance annotations on each type parameters which also help type inference when working with enumeration (e.g. Some / Nil instead of Option / List). For example:

trait Optional[+E, -S, +T, +A, -B]
type Lens[-S, +T, +A, -B] = Optional[Nothing, S, T, A, B]