7mind / izumi

Productivity-oriented collection of lightweight fancy stuff for Scala toolchain
https://izumi.7mind.io
BSD 2-Clause "Simplified" License
615 stars 66 forks source link

Proposal: Automatically include implicits in ModuleDefs? #230

Closed neko-kai closed 6 years ago

neko-kai commented 6 years ago

We could leave out explicit bindings of type classes in DI context, so instead of this:

trait Module[F[_]: TagK: Monad] extends ModuleDef {
  make[Monad[F]].from(Monad[F])

  make[MyService[F]]
}

We could write this:

trait Module[F[_]: TagK: Monad] extends ModuleDef {
  make[MyService[F]]
}

For static (macro) provisioner the change is easy, since generated code has access to the scope and implicits of enclosing ModuleDef: see https://github.com/kaishh/izumi-r2/pull/1/files#diff-994a658fc1f58f4d7831c3113087b6d9 , so we just need to replace parameters with implicitly[X] and not request them from DI context.

trait Module[F[_]: TagK: Monad] extends StaticModuleDef {
  stat[MyService[F]]
}

But, there are problems with this approach:

What we can do instead, is capture implicit parameters and inject them into context with a macro, transforming a call such as this:

make[MyService[F]]

into something like this

make[MyService[F]].withAddedImplicits(implicitly[Monad[F]])

That way, implicits are also managed by DI and we can guarantee type class coherence (which is a desirable feature in global context and which scala itself currently does not guarantee) – if there are two different instances for the same type class, application will fail to start and report conflicting bindings

However, there is an issue with deciding instance equality, for implicit vals everything is fine, but for implicit defs every implicit summon will generate a new instance, these instances are not equivalent:

import cats._
import cats.data._
import cats.effect._

class A[T[_]: Monad](dummy: Boolean = false) {
  val m = Monad[T]
}

object T {
  type opt[A] = OptionT[IO, A]
}
import T._

val m1 = new A[opt]().m
// m1: cats.Monad[T.opt] = cats.data.OptionTInstances$$anon$2@67757fa4

val m2 = new A[opt](false).m

m1 == m2
// false

m1.getClass
// cats.data.OptionTInstances$$anon$2

m1.getClass.isAnonymousClass
// true

m1.getClass == m2.getClass
// true

That means we can't, in general, decide type class instance equivalence and guarantee coherence.

But, we can use some heuristics instead, for example:

  1. We can compare implicits without type parameters (such as ExecutionContext) using .equals, but always assume that implicits with type parameters (such as Monad[F]) are type classes and are the same if their type parameters are the same and compare them only by using SafeType.equals.
  2. We can asssume that all implicit instances that aren't AnonymousClasses are implicit vals and compare them using .equals, but when .getClass.isAnonymousClass is true, compare them only by using .getClass.equals – this heuristic is kinda faulty – while most library authors use traits to define type classses and put new X { ... } anonymous classes in implicit defs, sometimes they also create named classes for instances. Also, this is less portable – scala.js doesn't have .isAnonymousClass method on Class.
neko-kai commented 6 years ago

Added .addImplicit method which is sort of a half-measure:

val mod = new ModuleDef {
  addImplicit[Ordering[Int]]
// same as make[Ordering[Int]].from(implicitly[Ordering[Int]])
}

The reason for not going all the way is that it's actually not modular to require implicit implementations to be available in scope when binding classes depending on implicits. Requiring implicits to be explicitly bound to implementations seems like a more consistent position to me, since If we'd have wanted to directly depend on implementations, we may as well not have bothered with distage at all.