Open julien-truffaut opened 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
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
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
.
@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]
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 anInt
(A) into aString
(B), resulting in anOption[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:
map
ortraverse
.