Updated Proposal: Revisiting Implicits

It’s not quite that simple. Even if there’s only one implicit for that type, it’s helpful to debug situations where the type might be wrong at the callsite (misplaced parens, typos, etc) by manually passing the value you’d expect the compiler to grab. Generally the extra hint will produce more useful compilation errors.

It’s even more helpful when there isn’t a compilation error. I’ve been playing with Dotty, and this was exceptionally useful figuring out why it was hanging at runtime - turned out there was a missing given clause upstream.

Debugging this involved caching them locally and passing them explicitly to the method. The kicker was that using summon[A] would still hang, so I had to replace them with named references to get things working again. Then I could bisect the list of givens, narrowing down which would fail when converted to summon[A].

I’m not quite sure how I would have approached this if I couldn’t refer to them by name, but whatever it was would have been considerably more convoluted.

2 Likes

imply / implied is intriguing, but I would look at using these as direct replacements for given in the currently proposed syntax.

imply intOrd: Ord[Int] { ... }
imply ExecutionContext = ...
def max[T](x: T, y: T)(implied Ord[T]) = ...
max(x, y)(implied intOrd)

or maybe:

max(x, y)(imply intOrd)

What do others think?

8 Likes

verb/adjective combos have been discussed on github before, I personally find it the most intuitive of all the options I’ve seen here, pretty much regardless of the actual word (imply/provide/assume/give/etc) used.

Also the questionable

given (given Ctx)

example would look much more legible:

imply (implied Ctx)
provide (provided Ctx)

etc.

I wouldn’t even mind mixing them up such as

provide (given Ctx)
imply (given Ctx)

so long as the verb / adjective form is retained. Actually kinda like these two.

7 Likes

I don’t think the tenses work properly. The declaration site is static, old, past-tense, implied-like. In fact, it’s more like a noun: impliable. The argument-site seems current, fresh, present-tense: imply. The call site is definitely present-tense: imply.

Given works from mathematics, but a pair that makes the providers clearly distinct from the use sites (e.g. give/given) is, I think, substantially better than relying upon a single term.

2 Likes

I don’t like this one though, passive works fine/better here I think.

1 Like

I think it’s an improvement – specifically, I think that having separate terms for declaration vs consumption is a big win in readability. And while I think imply / implied is about equal to give / given in the abstract, I think there is a win in keeping some terminology relationship to implicit.

I agree with Rex that the call site should be imply, but prefer Martin’s suggestion otherwise. IMO, the declaration site implies that something is available (hence imply); the argument site consumes an implied value. That pretty closely matches the way I’ve always taught implicit.

5 Likes

I still think imply (“active” verb) should be on the instance and the implied (adjective / passive voice) should be on the parameter.

The reasoning behind the parameter being passive is the same as with many other modifiers – private, final, lazy, etc. It’s a description of a certain behavior, without any action taken.

On the other hand, making an instance able to be used implicitly is more of an action rather than a description. It’s actually quite similar to import, as both are “merely” shortcuts to help organize the code better, and they do not really change the characteristics of an object in a meaningful way.

This is also why I believe that instances (actively) implied in a scope shouldn’t be automatically implied in the importing scope. The “importer” only intends to refer to other definitions, but doesn’t necessarily intend to make the action of implying them. It’s a confusing side-effect, and one has to read the source of the imported scope to detect it.

I want to know exactly how I am importing definitions into my scope. Even more so, I’d like to control that myself, and there should be a distinct way for doing that:

object Ordering {
  object Int extends Ordering[Int] {...}
}

object MyApp {
  imply import Ordering._
  Seq(2,1,3).sorted
}

Which is really just a short for:

object MyApp {
  import Ordering
  imply Ordering._
  Seq(2,1,3).sorted
}

Going forward with this, I don’t understand why there is a need for an entire new construct just to “imply” an object:

imply intOrd: Ord[Int] { ... }

Why not stay with this?

imply val intOrd = new Ord[Int] { ... }

Which again - is really just a short for:

val intOrd = new Ord[Int] { ... }
imply intOrd

I noticed a problem with the verb/adjective syntax. It does not work at all for anonymous aliases.

imply ExecutionContext = ForkJoinContext()

is wrong, since it equates a type with a term. With an adjective, it’s acceptable:

given ExcecutionContext = ForkJoinContext()

This can be read as "the given ExecutionContext is a ForkJoinContext". The adjective form “given T” or “implied T” brings the type T to the term level, so the equality makes sense.

Now, before we start to unravel everything again, here are some corner-stones that I feel we have pretty much settled on.

  • The basic given syntax, without val, def, or object.
  • The optional x: labels, which can be left out to make instances and parameters anonymous

I am not convinced that the distinction between verbs for instances and adjectives for parameters is needed for clarity. I believe the fact that parameters are enclosed in parens is enough of a clue. Compare it with the following hypthetical syntax for by name-parameters:

def f(def x: T): U = ...

I don’t think people would have a problem distinguishing the function from the parameter, since the parameter is enclosed in parens. Likewise for

given listOrd[T](given x: Ord[T]): Ord[T]

If you leave out the names listOrd, and x, it becomes slightly more awkward:

given [T](given Ord[T]): Ord[T]

But I believe one will get used to this style quickly. If not, it has two possible remedies: Use a context bound, or define a name for the parameterized instance.

That leaves the choice of adjective(s). I really like given for instances. It’s short, easy to understand, with the right connotations. Using it for parameters does bring up the issue that @eyalroth noted: given T is a common form to talk about any parameter of a function. We did experiment with two different adjectives (I believe it was given for parameters and implied for instances), but early users found that confusing. They were not sure when to use what adjective since their meaning was too similar. So I would advise against that.

Using implied everywhere for given has advantages as well a disadvantages. It works best for formal parameters and implicit function types.

   def f[T](implied Ord[T]) = ...
   type Ctx[T] = (implied Context) => T

vs

   def f[T](given Ord[T]) = ...
   type Ctx[T] = (given Context) => T

The main advantage is that it’s less ambiguous to talk about an “implied” parameter than about a “given” parameter. So I would assume that even if we keep the “given” syntax, the parameters would still be called “implicit”. That’s what the current docs do.

On the other hand, I believe given works better for instances and for explicit arguments to implicit parameters. implied is too convoluted for my taste, and it’s also too close to implicit.

In the end I think given still has the edge. The awkwardness about parameters concerns not so much the code itself but the ways we talk and document the code, and that can be managed.

1 Like

This is only a problem because of this:

Again, drop the additional construct. imply should not be thought of as an instance definition – like val, var, def, object – but rather as an action, like import. Dropping the two corner-stones mentioned above will avoid all the other problems and make the language simpler, clearer and thus less confusing. After all, what is the reasoning behind introducing a whole new construct to the language? How does it make it simpler and easier to understand?

And this is pretty confusing for newcomers who are not yet fully acquainted with the language as a whole, as now they have to also remember that when people say “implied” they mean “given”, but only in the context of function parameters.

1 Like

I am not sure this is much of a problem. For comparison: Agda and (proposed) OCaml use {...} for implicit parameters. The syntax does not suggest it, but they are called implicit parameters nonetheless.

2 Likes

Perhaps, but I feel like this is abusing the term “given”, which is normally used as definition for a given situation, and is naturally how programmers (and others) reason about functions. Why are the “normal” parameters of a function are not given? Are they somehow not “given”?

By the way, thinking as imply as an action rather then a definition, one can still anonymously introduce other non-implied instances as implied:

imply ExecutionContext.global

Actually, why not this?

imply {
  ...
}

One could imply any expression. It’s an action, not a definition. It elevates the scope of a certain instance.

This is the original sin IMHO. It may look more acceptable with an adjective, but you still have an equal sign between a type in what looks like a term position, and a term. It just looks like a pretty big wart to me.

5 Likes

I played with the idea a bit more and came up with the following proposal (written in the form of language documentation). The gist of the proposal is that it:

  1. Uses minimal syntax.
  2. Introduces the concept of “implication”, which is strongly associated with “application” (of a function).
  3. Treats the concept as an active / control construct rather than a definition.

Implied Parameters

Implied parameters are function parameters that can be applied directly – like other regular parameters – or indirectly as so:


def foo(implied i: Int): Int = ???
def bar(a: Int)(implied b: Int): Int = ???

foo() // error
foo(imply 1) // ok
foo.imply(1) // ok
bar(1) // error
bar(2)(imply 3) // ok
bar(2).imply(3) // ok

imply 9
foo() // ok
bar(2) // ok
bar() // error

imply is a new keyboard which indirectly applies a value to all the implied parameters of the same type whenever a function is invoked in the same scope the implication has been made.

An implication can be made anonymously on any expression:

imply 1
imply {
  ...
}

An expression can be also be implied as a more specific type of the expression, using the as keyword:

imply new ForkJoinPool() as ExecutionContext
imply {
  ...
} as ExecutionContext

A function that has implied parameters is also indirectly implying these parameters in its scope, and so an implication can be propagated through function invocations:

def foo(implied f: Int): Int = ???
def bar(implied f: Int): Int = foo()

imply 1
bar()

Named expressions can also be made implied:

implied val a: Int = 1
implied def f(): Int = ???
implied object Foo extends Bar { ... }

Much like implied parameters, there is nothing unique about these expressions (in contrasts with non-implied expressions) other than that they will be indirectly applied to any implied parameters in the same scope.

The scope of an implication made with a named definition does not extend to the scope of importing scopes. In order to imply any expression in a scope – whether it is already implied in another scope or not – one has to explicitly imply it:


object A {
  implied val i: Int = 1
}

object B {
  def foo(implied bar: Int): Int = ???
  
  import A.i
  foo() // error
  
  import A.{imply i}
  foo() // ok
}

In fact, the compiler will warn you whenever you imply an expression without it being applied (indirectly) in the scope:


object A {
  implied val a: Int = 1
  val b = a + 1
}
// warn: val 'a' has been implied but is never applied indirectly

def foo(implied a: Int): Int = a + 1
// warn: parameter 'a' has been implied but is never applied indirectly
1 Like
imply new ForkJoinPool() as ExecutionContext

Would you need as?

It would seem like we only need type ascription:

imply new ForkJoinPool(): ExecutionContext

Alternatively, reusing for:

imply new ForkJoinPool() for ExecutionContext
1 Like

You’re right, I thought of it too, but then I realized as is needed for anonymous blocks, and maybe imports too:

imply { ... } as ExecutionContext
import foo.{imply bar as Baz}

Or maybe just disallow “typed implication” in these scenarios; I’m not sure they’re necessary.

Your proposal makes me realize that from a grammatical point of view, the following rule seems most natural:

Use ‘implied’ when the thing is named; use ‘imply’ when it is not.

This would apply to parameters:

// named:
def foo(x: Int)(implied ctx: Ctx) = ... ctx ...
// unnamed:
def foo(x: Int)(imply: Ctx) = ... summon[Ctx] ...

Arguments:

// named:
foo(123)(implied ctx = myCtx)
// unnamed:
foo(x: Int)(imply myCtx)

Aliases:

// named:
implied val ec: ExecutionContext = new ForkJoinPool()
// unnamed:
imply: ExecutionContext = new ForkJoinPool()

Instances:

// named:
implied class ListOrd[A](imply: Ord[A]) extends Ord[List[A]] { ... }
// unnamed:
imply[A](imply: Ord[A]): Ord[List[A]] = new { ... }

Imports:

// named:
import A.{implied FooOrd}
// unnamed:
import A.{imply: Ord[Foo]} // or: Ord[?] as in the current doc

It’s a bit annoying to have the imply/imply repetition in the Ord[List[A]] instance declaration. But since such an instance should never take non-implied parameters anyways, why not just allow this?:

imply[A](Ord[A]): Ord[List[A]] = new { ... }
1 Like

Exactly, but I would argue against the necessity of some of the examples you brought up:

def foo(x: Int)(imply: Ctx) = ... summon[Ctx] ...

I honestly believe that not naming a parameter is a very bad practice. It’s also impossible to achieve with regular parameters.

implied class ListOrd[A](imply: Ord[A]) extends Ord[List[A]] { ... }

Did you mean object? otherwise I don’t understand what an implied class means. Also, did you mean Ord[A] to be implied and named? if not then again - I’m very much against unnamed parameters.

imply[A](imply: Ord[A]): Ord[List[A]] = new { ... }

Anonymous generic functions are not allowed in general; why allow them just in the case of imply?

import A.{implied FooOrd}
import A.{imply: Ord[Foo]}

I think the “named vs unnamed” rule breaks with imports. An import is always regarded as imply, since it is not a definition, and is already in an active voice (“import”). Also, as I said previously, perhaps “typed implication” should be disallowed for imports and code blocks.

I think this should be refined to: Use implied when the thing is being named; use imply when it is not.

This excludes imports, because these do not define anything new:

import imply Ordering.Int
import Ordering.{Double, imply Int} // double is not implied

There is however the ability to rename on import, but for simplicity I believe it shouldn’t be possible to to an “implied renaming”:

import imply Ordering.{Int => MyInt} // ok
import Ordering.{imply Int => MyInt} // ok
import Ordering.{Int => implied MyInt} // not ok

Sorry for the flurry of comments and edits. My thought process is greedy.

So, as a native English speaker, I don’t entirely agree. The above reads decently to me, although the usage is a bit atypical: “imply that the current ExecutionContext is a ForkJoinContext”.

The verb/adjective distinction is interesting, though. You seem very set on the adjectival form; eg, “given that the current Execution Context is a ForkJoinContext”. That makes sense if one thinks of a program as a set of equations to be solved. But I’d suggest that most programmers think of a program as a series of commands to be followed (even when doing FP, I still tend to have that mental model), and verbal forms feel more natural in that mindset.

Granted, in this case we more or less literally are solving equations. But I still usually find it easier for folks to learn the concept when I describe it in terms of one operation that is putting types into “the implicit cloud” and another fetching them from it, so the verbal form is appealing and natural: “imply that…” vs “find the implied…”. (Which is why I also prefer give at the declaration site, vs given at consumption.)

4 Likes

It’s probably not the best way of thinking about things, but the syntax that would be closest to my mental model would be assume and assuming.

This reads for me as, “assume ec can be used when we need an ExcecutionContext”:

assume ec for ExecutionContext

This reads for me as, “Here’s how to foo, assuming we have an ExecutionContext and ActorMaterializer”:

def foo[A](assuming ExecutionContext, am: ActorMaterializer): A = ???
1 Like