Bikeshed / functionality proposal for implicits

I would be absolutely delighted to use a system that worked like this. I think it overcomes, or could be made to overcome, basically all of the most common objections to the current system of givens. It is slightly less compact in certain cases, but this is on purpose in order to increase regularity.

This could be tweaked, however.

The tl;dr is–use assume to provide implicits, use given on parameters. assume works like val or def. Encode auto-filling-in with a Given trait, so it works fine on functions too. Use standard renaming notation and/or override to control visibility.


Anonymous classes

Creating class instances without a new statement raises an ambiguity when trying to create an anonymous class: is a following block an argument or the declaration of an anonymous class?

To indicate the anonymous class case, we use with to indicate that we are extending the original class:

abstract class Foo(val foo: String) { def bar: Bar }

val foo = Foo("foo") with {
  def bar = Bar("bar")
}

Type inference will assume that the type is that of the extended class (Foo in this case). If you wish for the full interface to be part of the type, use the soft keyword public after with:

vall foo = Foo("foo") with public {
  val baz = Baz("baz")
  def bar = Bar("bar")
}

Context-dependent parameters

Repetitive context-dependent parameters can be automatically supplied by using assume statements to specify a canonical value to fill in based on the argument type and what is currently in scope.

Because this process happens implicitly, it has the potential to be difficult for a programmer to track. Therefore, parameter values are filled in contextually only when three conditions are met.

  1. A unique assume statement can deliver a value an appropriate type (the type of the argument or a supertype)
  2. The parameter is marked as given, indicating that the value must be given by the compiler
  3. The parameter is not supplied explicitly.

assume context-dependent values

A static value is assumed using the same syntax as val or var:

assume theUsualFoo: Foo = Foo("foo")

Because the value is typically not referrred to by name, it can be left out by using a wildcard instead of the name:

assume _: Foo = Foo("foo")

and type inference works normally:

assume _ = Foo("foo")

A computed value is assumed using the same syntax as def:

trait Ord[A]{ def compare(l: A, r: A): Int }

assume listOrd[A](ord: Ord[A]): List[Ord[A]] = List[Ord[A]] with {
  def compare(l: List[A], r: List[A]): A = l match {
    case Nil => if r.isEmpty then 0 else -1
    case l0 :: lmore => r match {
      case Nil => 1
      case r0 :: rmore => ord.compare(l0, r0) match {
        case 0 => compare(lmore, rmore)
        case x => x
      }
    }
  }
}

However, note that all parameters must themselves be assumed in the context where this assumption should apply. If you wish to denote this explicitly to remind yourself, it is okay (see given statements below).

assume listOrd[A](given ord: Ord[A]): List[Ord[A]] = List[Ord[A]] with { ... }

Type inference works as normal:

assume listOrd[A](ord: Ord[A]) = List[Ord[A]] with { ... }

The assumption can be unnamed by using a wildcard:

assume _[A](ord: Ord[A]) = List[Ord[A]] with { ... }

The parameter also can be unnamed, and can be fetched within the code using summon[Type]:

assume _[A](_: Ord[A]) = List[Ord[A]] with {
  ...
      case r0 :: more => summon[Ord[A]].compare(l0, r0) match { ... }
  ...
}

(Aside: the implementation of summon is just inline def summon[A](given a: A): A = a, so it is zero-overhead to use this form.)

Given parameters

A method may assume the value of one or more of its parameters. In order to mark that a parameter’s value may be assumed, mark it with the given keyword. This parameter can then be used as normal inside the method definition:

def foo(name: String)(given bar: Bar): Foo =
 bar.makeFooWithName(name)

Given parameters may be placed in the same block as regular parameters, but they must appear at the end to reduce confusion about which argument is which when specifying the normal arguments. If a parameter block consists entirely of given parameters, it may be omitted when the method is called. For a similar reason, blocks that can be omitted entirely must appear after all other parameter blocks.

def baz(name: String, given bar: Bar): Baz = ???

// def quux(given bar: Bar, name: String): Quux 
// Compile-time error "given parameter appears before regular parameter"

// def bippy(given bar: Bar)(name: String): Bippy
// Compile-time error "elidable parameter block appears before mandatory parameter block"

Given parameters are automatically assumed inside the definition block of the method. Therefore they need not be named; use _ to indicate this.)

def foo(name: String)(given _: Bar): Foo =
  summon[Bar].makeFooWithName(name)

Given parameters are filled in from what is available from assume in the context of the call site. Static values will be used directly; if it is possible to synthesize a value based on the transformations provided with assume, it will be synthesized and then used.

assume theBar = Bar("tuna")

val myFoo = foo("salmon")
val myBaz = baz("herring")

Alternatively, given parameters can be supplied explicitly by using the keyword given in the parameter list. It is a compile-time error to specify a given parameter without the keyword. This will override any automatic source for the parameter.

val myFoo = foo("salmon")(given Bar("not your ususual Bar!"))

// val myBaz = baz("herring", Bar("use me!"))
// Compile-time error "regular parameter used in given parameter position"

Given parameters can have defaults, just like regular parameters. In this case, if no value of appropriate type can be assumed, the default will be used. They can also be specified by name at call-site, just like regular parameters. However, they still need the keyword given before the parameter name.

def yarth(given foo: Foo, given bar: Bar = Bar("minnow")) = ???

assume theFoo = new Foo("sturgeon")

val y1 = yarth
val y2 = yarth(given Foo("bass"), given Bar("pike"))
val y3 = yarth(given Foo("bass"))
val y4 = yarth(given bar = Bar("pike"))
val y5 = {
  assume theBar = Bar("perch")
  yarth    // Given parameters are Foo("bass") and Bar("perch")
}

Functions can also assume their parameters. Parameters that are given have type Given[A]. When creating a closure, you can specify the parameter using given notation, however.

val fooFn: (String, Given[Bar]) => Foo = foo _

val myFoo = fooFn("marlin")   // Works if an assumed Bar is in scope
val myFoo2 = fooFn("sunfish", given Bar("perch"))  // Always works

val bazFn = (s: String, given bar: Bar) => baz(s.capitalize)
// Equivalent:
// val bazFn (s: String, bar: Given[Bar]) => baz(s.capitalize)

val myBaz = bazFn("tuna")

Controlling assumptions

*Note: maybe the keyword here should be given instead? Not sure. It seems more logical to me to have it be assume since the assume statements are what is being controlled.

Assumptions are imported along with wildcard imports from a package. If you wish to leave some out, they can be excluded by name or by type by using the assume keyword and renaming them to wildcard:

import foo.{ assume myBar => _, assume Foo => _ }

Types with generic type parameters can be excluded either via wildcards or by parameterizing the type after assume:

import foo.{ assume Context[_] => _ }
import foo.{ assume[A <: Foo] Context[A] => _ }

If you want to import only the assumes and nothing else, you can also use the assume keyword to pick out individual types, names, or everything. Note that you cannot import assumptions by name without the assume keyword.

import foo.{ assume myBar }
import foo.{ assume _ }

Within a block, assumptions can be controlled the same way. Note that controls must appear within braces:

def foo(name: String)(given bar: Bar): Foo = bar.fooWithName(name)

assume theBar = Bar("herring")

val myFoo =
  if args.isEmpty then
    assume {bar => _}
    assume _(baz: Baz): Bar = baz.myBar
    foo("salmon")
  else
    foo("salmon")

To remove all assumptions, use assume { => _ }.

If multiple assumptions are valid at the same time, you’ll get a compile-time error:

  if args.isEmpty then
    assume _(baz: Baz): Bar = baz.myBar
    foo("salmon")
// Compile time error: can assume more than one value for given parameter `bar` in `foo`:
//    theBar: Bar on line N of Something.scala
//    rule Baz => Bar on line M of This.scala

Alternatively, you can use override to assert that the local assumption takes priority:

  if args.isEmpty
    override assume _(baz: Baz): Bar = baz.myBar
    foo("salmon")   // Works fine, uses baz.myBar
2 Likes

@odersky - I know it’s extremely late in the process, but I hope there’s still time to consider some aspects of this manner of doing things. I think it solves all of the clarity issues and most of the regularity issues; it is, unfortunately, at the expense of taking assume as a keyword, which is high-impact. However, I think that the overall clarity gained from clear distinction between the process of providing and consuming implicits is worth it. (Automatic tools to backtick-escape existing uses of assume should be pretty easy/foolproof.)

1 Like

I think that’s a clever idea.
Indeed, in old implicit def definitions, it was possible to have non-implicit parameters because of implicit conversions, but if implicit conversions are removed, we can change this rule and just assume that all the parameters of an implicit def definition are also implicit/given/assumed. This seems to simply fix the problem of “conditional givens” without requiring a new syntax!

4 Likes

inb4 this thread is filled with countless other bikeshed suggestions that drown out the OP :unamused:

I think the requirements that fell out of the previous bikeshedding sessions were:

  1. At the definition site, the keyword must be a noun. (this disallows assume)
  2. The keyword for implicit arguments must be the same as the keyword at the definition site (so that too must be a noun, or if it’s an adjective it must be an adjective that’s also a noun)
  3. There should be a keyword used at the callsite when explicitly passing (the artist formerly known as) implicit arguments, and this must also be the same as the keyword at the definition site (here you probably want a past-tense verb, so now you need a word that’s all three of past-tense verb, adjective, and noun)

I think “given” is the only word that meets all three of those criteria and also makes any amount of sense. I don’t really agree with the criteria, but they’ve been handed down from what I can tell.

Similarly handed down are the syntax for nameless instances, backflips for importing instances, and so forth.

Honestly I appreciate the thoughtfulness (and focus on regularity) of the suggestion here but I’d encourage moving on. The implicit rebranding ship has sailed, I think. Seeing as how it’s turned into essentially substituting a new word for “implicit” and separating out implicit conversions (plus a couple of new niceties), I think it won’t be too bad to get used to. As a shapeless user and contributor, I’ve probably come to enjoy (ab)using implicits more than most, and my strategy for Scala 3 is to just get on the train and trust them to end up at something that’s basically a syntactic facelift (plus a nice new macro system). In that case, I don’t really care that much what the incantations are, be it given and/or assume and/or implied and/or simsaladimbabaasaladosaladim. In the end, I’ll get used to it, just like I got used to implicit which was quite confusing in the beginning. Hopefully whatever is landed upon will be a little clearer; if not, it can’t be much less clear.

2 Likes

I really really like Rex’s proposal.

Regarding the objections mentioned:

  1. Given is not a noun. Besides, whatever the reason for noun was, it doesn’t outweigh it being easy to learn and use. Anyway I would argue that assume is much more intuitive. We’re instructing the compiler to assume something. I never understood how you could read a definition site with given.

  2. IMO it’s much better not to have the same keyword. I don’t mind if implicit parameters are automatically part of the method’s implicit scope – I agree it’s better that way. But having too many syntax rules hanging from one keyword is going to be confusing. (Implicit doesn’t have this problem because it introduces zero syntax rules.)

  3. Here I agree, it would be better to use assume.

1 Like

Thank you for laying down a worked out proposal! It’s good to have alternatives. Here are my reactions so far:

  • Anonymous classes without new: I think it would be interesting if we could try this. It did not get done since we basically ran out of time for the feature complete release and it was not deemed high-priority enough to delay it.

  • Context dependent parameters: I am strongly opposed to use _ for anonymous instances or parameters. We need a syntax that flows naturally without it. Also, you should never be able to infer their types, so assume _ = Foo("") is out.

  • assume or given or delegate: whatever. I don’t have very strong opinions about it. In my daily usage, given works fine, but I suspect so would some other keywords. given got by far the broadest support among all acceptable choices so far (which means the fewest people arguing against after it was seriously proposed and implemented, that’s when you usually get the downvotes). We did have an issue at some point that the same keyword would mean “provide” or “consume” depending on context, but that issue is now mostly resolved I feel. First, given as parameter is now always in (…) which makes the difference much clearer. Second, the new syntax for given instances avoids using given in both roles in one construct. Third, as I repeatedly wrote already, given parameters are really providers and consumers, so trying to separate the two roles completely is unworkable.

  • Writing

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

    instead of

    given [T]: Ord[T] => Ord[List[T]]
    

    Maybe. I have a slight preference for the second syntax, since it does not suggest that
    we define a regular parameter.

  • given parameters in the same block as normal ones: My intuition is rather against it since it would work awkwardly with eta expansion and implicit function types (also the underlying SI calculus cannot express it: you either apply a function to arguments or you don’t).

  • Allowing given parameters with defaults: yes; in fact that’s already the case.

  • Given[..] as a type: Normally I am all for expressing properties with types instead of syntax but I believe here this will not work. You need to know whether something is given or not before type inference even starts. If Given[..] is a type it can be the result of some type variable instantiation. That will seriously complicate things.

  • Automatically importing givens with wildcards: I much prefer our latest proposal for imports.

@jeremyrsmith - I feel completely content rejecting any previous requirements if they are inconsistent with the primary goals stated in the motivations section of the document on givens, and with the desire for regularity and natural English-language (-with-mathematical-training) readability. So I guess we’re just coming from different positions. I don’t want to move on yet because I’m pretty certain that what we have so far is going to be considerably worse than what we could have, and now is effectively the last time we get to change it.

@odersky - Thanks for the responses!

  • I don’t understand why you object to _ to mean that something is anonymous, as this usage is already widespread in other areas of the language. Presumably you have a reason that is more important than all the advantages of increased regularity (i.e. faster to learn, less to remember, can guess at correct syntax more often rather than having to look it up, etc.).

  • I also don’t understand what the problem with inferring types is. If you want to have a warning about opaque type inference, e.g. anything that isn’t val x = new TheType(...) or val x = TheType(...) gets a warning, that’s maybe good to avoid surprises. But again, the cost to regularity seems high, and what you get in return is not obvious to me.

  • I understand that given parameters are also providers, but in my scheme you can assume { ev => _ } to turn off the provider part, and anyway, the key difference is that they are necessarily consumers, which I think is so important that at least using a different keyword should be very seriously considered.

  • I agree that suggesting that you get a regular parameter is bad. The suggestion of a regular function as a parameter is also not great (since that isn’t what it actually is). My solution would be to require more regularity and require given def if that is a significant concern.

  • The problems with SI calculus probably sink the mixed-givens-in-block idea. Oh well. Reworking that is completely untenable, I imagine.

  • I admit I haven’t fully thought through the rules for how to get Given to work as a type. I have a hunch that a couple of heuristics will make it work just fine (whether it’s a type tag or a parameterized type, I’m not sure; Rust uses traits extensively like this, and it works brilliantly for most things…but then they don’t have HKTs, which could make all the difference). But I don’t actually have the heuristics in mind, so I could well be wrong.

Anyway, thanks for considering these points.

The biggest concern I have over the new syntax is how far it is from the following summary: “If you want the compiler to fill in a value for you, use (keyword); if you want it to use something to fill in, use (keyword2); everything else is details, but if you keep only that in mind, it pretty much works like you’d guess.” My proposal was not only, but mostly, designed to get as close to that as I could. I hope we can still get closer.

2 Likes