Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Scala incompatible nested types created in implicit class

The code fragment provided is a made-up minimalistic example just to demonstrate the issue, not related to actual business logic types.

In the code below we have a nested Entry type inside Registry type.

class Registry[T](name: String) {
  case class Entry(id: Long, value: T)
}

That makes sense cause Entries of different Registries are kind of different, incomparable types.

Then we may have an implicit Ops class, for example, used in tests, which binds our registries to some test storage implementation, a simple mutable map

object testOps {

  import scala.collection.mutable

  type TestStorage[T] = mutable.Map[Long, T]

  implicit class RegistryOps[T](val self: Registry[T])(
    implicit storage: TestStorage[T]
  ) {

    def getById(id: Long): Option[self.Entry] = 
      storage.get(id).map(self.Entry(id, _))

    def remove(entry: self.Entry): Unit       = storage - entry.id
  }
}

The problem is: the Entry consructed inside Ops wrapper is treated as an incomparable type to the original Registry object

object problem {

  case class Item(name: String)

  val items = new Registry[Item]("elems")

  import testOps._

  implicit val storage: TestStorage[Item] = 
    scala.collection.mutable.Map[Long, Item](
      1L -> Item("whatever")
    )
  /** Compilation error: 
   found   : _1.self.Entry where val _1: testOps.RegistryOps[problem.Item]
   required: eta$0$1.self.Entry
  */
  items.getById(1).foreach(items.remove)
}

The question is: Is there a way to declare Ops signatures to make compiler understand that we're working with same inner type? ( I've also tried self.type#Entry in RegistryOps with no luck) If I miss some understanding and they are actually different types, I would appreciate any explanations and examples why considering them as same may break type system. Thanks!

like image 940
Ivan Klass Avatar asked Feb 04 '19 09:02

Ivan Klass


1 Answers

To start off, it's worth noting that the implicitness here isn't really the issue—if you wrote out something like the following, it would fail in exactly the same way:

new RegistryOps(items).getById(1).foreach(e => new RegistryOps(items).remove(e))

There are ways to do the kind of thing you want to do, but they aren't really pleasant. One would be to desugar the implicit class so that you can have it capture a more specific type for the registry value:

class Registry[T](name: String) {
  case class Entry(id: Long, value: T)
}

object testOps {
  import scala.collection.mutable

  type TestStorage[T] = mutable.Map[Long, T]

  class RegistryOps[T, R <: Registry[T]](val self: R)(
    implicit storage: TestStorage[T]
  ) {
    def getById(id: Long): Option[R#Entry] = 
      storage.get(id).map(self.Entry(id, _))

    def remove(entry: R#Entry): Unit = storage - entry.id
  }

  implicit def toRegistryOps[T](s: Registry[T])(
    implicit storage: TestStorage[T]
  ): RegistryOps[T, s.type] = new RegistryOps[T, s.type](s)
}

This works just fine, either in the form you're using it, or slightly more explicitly:

scala> import testOps._
import testOps._

scala> case class Item(name: String)
defined class Item

scala> val items = new Registry[Item]("elems")
items: Registry[Item] = Registry@69c1ea07

scala> implicit val storage: TestStorage[Item] = 
     |     scala.collection.mutable.Map[Long, Item](
     |       1L -> Item("whatever")
     |     )
storage: testOps.TestStorage[Item] = Map(1 -> Item(whatever))

scala> val resultFor1 = items.getById(1)
resultFor1: Option[items.Entry] = Some(Entry(1,Item(whatever)))

scala> resultFor1.foreach(items.remove)

Note that the inferred static type of resultFor1 is exactly what you'd expect and want. Unlike the Registry[T]#Entry solution proposed in a comment above, this approach will prohibit you from taking an entry from one registry and removing it from another with the same T. Presumably you made Entry an inner case class specifically because you wanted to avoid that kind of thing. If you don't care you really should just promote Entry to its own top-level case class with its own T.

As a side note, you might think that it would work just to write the following:

implicit class RegistryOps[T, R <: Registry[T]](val self: R)(
  implicit storage: TestStorage[T]
) {
  def getById(id: Long): Option[R#Entry] = 
    storage.get(id).map(self.Entry(id, _))

  def remove(entry: R#Entry): Unit = storage - entry.id
}

But you'd be wrong, because the synthetic implicit conversion method the compiler will produce when it desugars the implicit class will use a wider R than you need, and you'll be back in the same situation you had without the R. So you have to write your own toRegistryOps and specify s.type.

(As a footnote, I have to say that having some mutable state that you're passing around implicitly sounds like an absolute nightmare, and I'd strongly recommend not doing anything remotely like what you're doing here.)

like image 171
Travis Brown Avatar answered Oct 09 '22 05:10

Travis Brown