Updated Proposal: Revisiting Implicits

Agree, this doesn’t automatically make code more safe. But it makes it more regular, easier to read, and less daunting (IMO). I would rather tackle the problem of thread safety in the new setup, than reverting to the old implicit system.

What is the common case? Is it defining a named object and using it implicitly (i.e applying it indirectly to a function) in the same scope it was defined? I would argue that this is not the common case, and if it is then it’s a bad practice.

If one desires to define objects that can be used implicitly by others / in a different scope, one should name them but shouldn’t define them as implicit (the “importer” should make them implicit in his/her scope). Alternatively, if one desires to define objects to be used implicitly in the same scope, one should define them anonymously and make them implicit.

given intOrd: Ord[Int] { defs }

This is confusing. This introduces a new type of definition into the language. This “new type” is indistinguishable from an object that has been been made implicit. More definitions, steeper learning curve.

What common cases require more than this concise syntax?

def foo(a: Int)(given b: Int): Int = ???
foo(1)(give expr)
give expr
import give Foo
import Foo.{give A, B}
summon Type

(obviously conversions and extension methods would require a different syntax)

In natural language we would probably say “let t be a given Table”. I think even given t as Table is confusing, because I would read it as if it were given Table = t, so an alias. I think what this shows is that using just the word given is still not solving the whole riddle which didn’t exist with implicit because we have implicit object t extends Table and while that’s very wordy, there is no ambiguity what it means. I don’t know what the solution is, only that the problem is not solved yet.

4 Likes

I also think (raised before by AMatveev, soronpo, sciss, mdedetrich) that the non-explicit evaluation semantic is problematic.

Currently [edited after Martin’s clarification]

  • direct instance, monomorphic: object / lazy val
  • direct instance, generic: def
  • alias (monomorphic or generic): def
  • monomorphic: object / lazy val
  • generic: def
My old example that was based on wrong assumptions

For example, I could write

given Ordering[Int] { def compare(x: Int, y: Int) = x - y }

Later I realize that I can use a function literal, so I refactor it to

given Ordering[Int] = (x, y) => x - y

which creates a new instance for every use.

Or the other way around: I might start with an alias like the above, but then have to move to a direct instance because the type is no longer a SAM. The enclosing instance suddenly gains a field.

1 Like

I’m trying to partially delineate what for me constitutes the problem. Perhaps one can find a solution iteratively.

Martin’s “matrix of eight” is a good starting point; I think if this matrix is “solved”, the entire construction is solved. Looking at it, I think one problem is that it works well / is intuitive for the alias cases using the equals sign. So these four

given TC = expr
given x: TC = expr
given [T]: TC = expr
given x[T]: TC = expr

For a seasoned Scala programmer, you can just say “like a type alias, but instead it’s an implicit term alias”. Note now the first element, it’s irregular, it doesn’t have a colon and no term definition on the left side. How about fixing this as follows:

given: TC = expr

Examples:

given: Ordering[File] = Ordering.by(_.lastModified)

given: Ordering[File] {
  def compare(a: File, b: File): Int = 
    math.signum(a.lastModified - b.lastModified).toInt
}
3 Likes

Another idea (sorry, I hope it’s not too much traffic)

// aliases
given[TC]      = expr
given[TC] x    = expr
given[TC[T]]   = expr
given[TC[T]] x = expr

// definitions
given[TC]      { defs }
given[TC] x    { defs }
given[TC] x
given[TC[T]]   { defs }
given[TC[T]] x { defs }

Examples:

given[Ordering[File]] = Ordering.by(_.lastModified)

given[Ordering[File]] {
  def compare(a: File, b: File): Int = 
    math.signum(a.lastModified - b.lastModified).toInt
}

On the downside, given now looks like it is a method invocation.

alias (monomorphic or generic): def

No, if it’s a monomorphic alias instance, it translates to a lazy val(*).

Essentially, if the given has parameters it’s a def, otherwise it’s a lazy val. I think that’s quite clear. It’s exactly what Haskell does for normal definitions.

(*) Maybe the confusion arose from the clause that said that an alias instance of a simple, pure reference is optimized to a def since in that case the two implementations have the same observable behavior and the additional field would be wasteful.

What do you do for parameters? Right now it’s

(given TC)
(given x: TC)

for parameters and

given TC = expr
given x: TC = expr

for instances. I think it’s very good that the two scenarios are consistent.

3 Likes

Ah, thanks for the clarification. I looked at the output of -Xprint:front where it’s a def, but then it gets changed into a lazy val in a later phase.

In any case, I think making it obvious how instances are encoded would be valuable.

1 Like

Another question regarding ambiguity. What if a trait defines an abstract implicit / given. Scala 2:

trait Foo[A] {
  implicit def ord: Ordering[A]

  def bar(a: A, b: A): A = List(a, b).max
}

How would that look in Scala 3:

trait Foo[A] {
  given ord: Ordering[A]

  def bar(a: A, b: A): A = List(a, b).max
}

Is this not in general ambiguous with form (2) in the 8-matrix (the confusing given t: Table case)?

1 Like

I think it works in parameter position too:

def foo(given: TC): R
def foo(given x: TC): R

The same as for instances:

given: TC = expr
given x: TC = expr

So it’s just as regular as your proposal within the given proposal, but I also find it much more regular within the language as a whole, since it avoids having type names in what looks like term positions.

3 Likes

The general idea with given is that it does much less than previous implicits (and that’s a good thing!). Whenever you need to achieve something like abstract over a definition, do it on normal definitions and then inject it into the given space. The Foo example would look like this in Scala 3:

trait Foo[A] {
  def ord: Ordering[A]
  given Ordering[A] = ord

  def bar(a: A, b: A): A = List(a, b).max
}

Bonus points: The given can be private to Foo, i.e.

  private given Ordering[A] = ord
1 Like

I still prefer the original syntax, for two reasons: First, it’s a symbol less to type and read (which matters a lot for parameters). Second, the alias form looks wrong to me.

given ExecutionContext = ForkJoinContext()

reads to me: The “given ExecutionContext is a ForkJoinContext”, whereas

given: ExecutionContext = ForkJoinContext()

reads “Given that ExecutionContext is ForkJoinContext” which again has the flaw that it equates a type with a term.

I agree that one has to get used to see sometimes a term name and sometimes a type after a given. To avoid confusion there should be the style guide rule that if a given is explicitly named the name is always in lower case. That makes it’s easier to distinguish the two forms at a glance.

2 Likes

I also think it will be better for IDEs to offer proper completions this way.

2 Likes

Would something along these lines be more readable/understandable?
Idea is that:

  • the definitions are summonable
  • parameters are (already) summoned (resolved, known)
  • summon can be used too summon the summonables… :smiley:
summonable global: ExecutionContext = new ForkJoinPool()

summonable (summoned outer: Context): Context = 
  outer.withOwner(currentOwner)

summonable [T](summoned Ord[T]): Ord[List[T]] = 
  { ... }

Not saying it is more readable, could be something different from “summon”.
But IMHO it’s easier to understand and parse in your head, has better semantics.
Problem with given is kinda same like with implicit, it’s hard to see what it is doing…

I also agree that using _ : Something for anonymous declarations would be fine.

2 Likes

Personally, when I see:

given ExecutionContext = ForkJoinContext()

I read:

Given ExecutionContext is ForkJoinContext

as in:

Given the fact that ExecutionContext is ForkJoinContext

I understand it’s the wrong way of reading it. But it seems problematic when a construct reads ambiguously like that.

1 Like

To avoid confusion, why not make the chosen keyword behave mostly like existing keywords rather than creating a hybrid of multiple syntax possibilities:

  1. given can be used as a modifier for parameters.
  2. given can be used in import definitions.
  3. given can define a new term / name / identifier (like val, def and object).
  4. given can define an anonymous instance, with the type on the left side, making it the only keyword (not completely sure about this) able to have : to the right of it (should probably be replaced with given _: Type = ???).
  5. given can define an anonymous generic function, which is not currently possible by any other means.

No matter how much styling guides people will follow. The fact that this keyword has so many distinct and unique semantics beyond its core functionality, where’s no other keyword has that many, will continue to cause confusion.

I understand it’s the wrong way of reading it. But it seems problematic when a construct reads ambiguously like that.

Agree it’s not ideal but it looks more like a problem initially that can be overcome when people are more used to the new construct. The difference for me is that

given ExecutionContext = ForkJoinContext()

can be read both ways whereas

given: ExecutionContext = ForkJoinContext()

really can be read only in one way, and that’s the wrong one.

Do we really need the compiler to manage the val/def semantics of givens?

It seems like it’s causing way more confusion than it’s helping.

7 Likes

It’s nice because you get a free lazy val for all givens with type parameters that have no value parameters, e.g:

given[A] Monoid[List[A]] { ... }

In Scala 2 this is really tricky to achieve:

implicit def monoidList[A]: Monoid[List[A]] = monoidList0.asInstanceOf[Monoid[List[A]]
private[this] lazy val monoidList0 = new Monoid[List[Any]] { ... }