Skip to content

Trial: Use "default" for given instances #7941

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Jan 9, 2020

This is another experiment to validate the current given design, now against the idea of
using default for given instances, which was originally proposed by @olhotak
#5458 (comment).

At the time, arguments against default were:

  • It would not work at all with extensions (but we have specialized syntax for that use case now).
  • it looked more like an adjective to me (but given is much more an adjective than default so that point is moot).
  • It might be too familiar. People might feel encouraged to write defaults for common types like Int and String which would be a bad idea. That point still stands.
  • There would be confusion with default parameters. That point also stands.

Maybe we can align implicits and default parameters more. @lihaoyi originally suggested this
#1260 (comment). That raises a host of "interesting" issues with eta expansion and others, so it won't be straightforward. I believe before we tackle this it would be good to already decide whether default for implicit instances is a promising path ahead.

I have duplicated the reference docs, replacing syntax and terminology.

To get started with browsing: https://github.com/dotty-staging/dotty/blob/try-default/docs/docs/reference/contextual-defaults/motivation.md

This is an attempt to validate the current given design against my very first proposal
from more than a year ago: witnesses. I have duplicated the docs, replacing syntax
and terminology,
@jducoeur
Copy link
Contributor

jducoeur commented Jan 9, 2020

Hmm. Haven't thought about it deeply yet, but my gut reaction is positive -- teaching this as "the default value for this type" seems reasonably natural and intuitive, and I think folks are likely to pick up the idea quickly. I like it...

@arturopala
Copy link
Contributor

Does it mean default + given instead of given for declaring and summoning an instance?
Like in default listOrd[T](given ord: Ord[T]) for Ord[List[T]]?

@Jasper-M
Copy link
Contributor

Jasper-M commented Jan 9, 2020

Does it mean default + given instead of given for declaring and summoning an instance?
Like in default listOrd[T](given ord: Ord[T]) for Ord[List[T]]?

I don't like default but whatever we end up with I hope we land on a syntax where we don't have to repeat any keyword for the parameters of the instance/given/default/witness e.g.

default listOrd[T](ord: Ord[T]) for Ord[List[T]]

An implicit definition with non-implicit parameters doesn't make any sense anymore, so just make them implicit by default.

@lihaoyi
Copy link
Contributor

lihaoyi commented Jan 9, 2020

Some examples where default could be a reasonably meaningful keyword in various use cases:

Use Case Old (Code) New (English)
Implicit contexts implicit ExecutionContext "The default ExecutionContext"
Implicit contexts implicit request => "The default request in scope"
Typeclass instances implicit upickle.Writer[Foo] "The default upickle.Writer for serializing Foos"
Typeclass instances implicit Read[Int] "The default scopt.Read used to deserialize CLI args to Ints"
Implicit Constructors implicit class StringReadable(s: String) extends Readable "The default constructor for Readable from String"
Implicit Constructors implicit def stringFrag(v: String): Frag "The default constructor for Frags from String"

I think all of these sound pretty good to me: they provide an english meaning, the meaning is reasonably specific, and the meaning is correct. More so than a keyword like given which is vague to the point of meaninglessness, delegate which has a very specific/existing/incorrect meaning from the GoF design patterns, or other proposals like instance which clash too much with existing OO programming terminology.

These three use cases generally encompass all the ways I use implicits personally, except for Extension Methods but those are getting their own syntax anyway (I only intentionally use extension methods in a single place - FastParse parser syntax - and the fact that Implicit Constructors can provide extension methods in other cases is an unwanted behavior for me)

@julienrf
Copy link
Contributor

I’m not an English native but something that sounds weird to me is that “default” means (to me) that it co-exists with other non-default values. But, in the case of implicits, this raises an ambiguity. So, we don’t really want “a default ExecutionContext”, or “a default Functor[List]”, but the only ExecutionContext and Functor[List].

@arturopala
Copy link
Contributor

@julienrf The best part of default keyword meaning is that it implies and put emphasis on uniqueness, you expect compiler to know/select at most one default value for type in the current scope, exactly what we have implicit/given for.

@jducoeur
Copy link
Contributor

So, we don’t really want “a default ExecutionContext”, or “a default Functor[List]”, but the only ExecutionContext and Functor[List].

But that isn't really true in Scala. You can have alternate definitions; moreover, you can supply a different one at the call site.

ExecutionContext is actually a great example -- ExecutionContext.global is exactly a default value: often correct, but frequently not, and you often want to supply a different value at the call site.

@julienrf
Copy link
Contributor

julienrf commented Jan 10, 2020

I argue that in most of the cases this is an abuse of implicits. I’ve seen several libraries using this: Akka HTTP with RejectionHandler, Play framework with Codec, also ExecutionContext in the scala-library, and in my experience, this is very error-prone. However, I think I’m going a bit off-topic so maybe we should continue this debate somewhere else.

@sjrd
Copy link
Member

sjrd commented Jan 10, 2020

I strongly disagree with that. There are very good use cases for implicit parameters with several valid values: a famous example is the Context in the dotty codebase. In Scala.js we also heavily use such implicits for the Position of trees, and in the linker we also use Context-like parameters.

@arturopala
Copy link
Contributor

@sjrd I think we have two basic cases for an implicit value: 1) when it expresses some context which we want to carry silently through the code, 2) when it represents some required but generic capability we depend on, a.k.a typeclasses. Both have the property that we can expect a unique value per type in each scope, but otherwise, the value might change between scopes and types.

@tellurion
Copy link

I prefer default over witness. As @lihaoyi said, it reads well. It is also easy to understand that if you do not provide an implicit arg, then the value marked as default will be used.
I think it makes the given keyword a bit harder to understand though as given and default don't convey the same meaning or really seem to go together.
I do have a suggestion along the idea of combining/unifying implicit and default params. Have the syntax for default and implicit params be the same, except for implicit params, use the keyword default for the default value. e.g.:
def max[T](x: T, y: T, ord: Ord[T] = default): T
The value of ord, if not explicitly provided, is the default value currently in scope.

@ritschwumm
Copy link

ritschwumm commented Jan 10, 2020

@sjrd from time to time i have to deal with some apps that use a implicit value to pass toggle settings - i guess this would fall into the same category of "very good usecases"? i suppose that worked well and made for some quite clean code in the beginning. in my experience, the fun starts a few years later, when e.g. tests have sprouted a dozen different implicit instances of toggle settings and you never know which of the possible instances is in scope at any given place.

i hope i am wrong, but my gut feeling says Context will be the scala compiler's next cake pattern...

@sjrd
Copy link
Member

sjrd commented Jan 10, 2020

Well, I've been more than happy with my implicit pos: Positions for 7 years, and my implicit scope: Scopes for several years as well. I never had to regret them or consider doing otherwise. The trick is to choose well the things you mark implicit.

@odersky
Copy link
Contributor Author

odersky commented Jan 12, 2020

I tried to push the idea further along the lines of #1260, by making implicits more like default parameters. In short, the idea was to label an implicit parameter of type T with T? and to be able to mix this kind of use-site default parameter with other parameters in one parameter list. But I failed to make it work.

The issue is partial application, precisely what I already brought up in #1260. If you have a function with two parameters, the second one implicit, like this:

def f(x: A = a, y: B?) = ...

then you need to write f() to pass the two default arguments to f. By contrast, if both parameters
are implicits, like this

def f(x: A? = a, y: B?) = ...

then you must not write f(), as both parameters get applied immediately when f is referenced. This is not a minor problem. Pairing parameters with applications is about as fundamental as it gets. Making implicits look like default parameters when they are not is therefore a fundamental discrepancy and as such very confusing.

I tried to work around that fact for about a day where I tried different ways to get around the problem but eventually gave up. I am now convinced there is no principled and practical way that could solve this. You can be principled by saying that default parameters always behave like current defaults, i.e. you must write f(). But then you cannot write summon[T], it has to be summon[T](). Same for a host of other situations. In our codebase I counted more than 1000 occurrences of definitions whose use sites would have to be rewritten with extraneous () parameters. This would be extremely ugly, never mind the migration nightmares this would cause.

So, it would have to remain default for instances and given (or equivalent syntax alternative) for parameters.

Here's my evaluation of this design:

  • default works very well for one half of the implicit universe: contextual parameters.
  • It is more problematic for the other half: typeclasses, since default implies that there are other possible
    choices as well, and for many typeclasses there would really be only one choice. Maybe one can get
    used to this but it does not feel very natural right now.
  • The false similarity with default parameters is also problematic, since the two behave differently in subtle but fundamental ways.

@jvican
Copy link
Member

jvican commented Jan 12, 2020

I like the idea of using defaults instead of implicits/given very much and I think this proposal is my favorite so far. I agree with @lihaoyi's comments above.

Making implicits look like default parameters when they are not is therefore a fundamental discrepancy and as such very confusing.

My user expectation is that if I define any default parameter in the parameter list of a method f, I need to invoke it with f() in the call-site. The syntax irregularity you mention matches my expectation of how I should call methods with default args, which it's ideal (no special cases). Why do you think this is a big deal?

That being said, could we not address this problem by forbidding mixing default parameters with defaults, as in def f(x: A = a, y: B?)? Users can write def f(x: A = a)(y: B?) instead, which from the user point of view makes it clearer that an extra parameter list is required in the call-site f().

If forbidding that definition is too excessive, there are two ways we can relax the previous rule:

  • Emit a warning instead of an error in the definition site.
  • Skip warning/error if import scala.language.mixDefaultParameters is in scope.

typeclasses, since default implies that there are other possible choices as well, and for many typeclasses there would really be only one choice

Even for typeclasses that only have one implementation for a given type, I think it makes sense to talk about that implementation as the "default". Users are always in control and the language still allows them to copy-paste that "default" implementation and declare a new instance of it because the compiler cannot guarantee the coherence of any typeclass instance anyway.

@lihaoyi
Copy link
Contributor

lihaoyi commented Jan 12, 2020

Pairing parameters with applications is about as fundamental as it gets. Making implicits look like default parameters when they are not is therefore a fundamental discrepancy and as such very confusing.

I agree that this can be confusing, but I disagree that it's a new issue: If you throw a bunch of default and implicit-laden functions at someone in Scala 2.13, ask them to guess the results of eta expansion with f _ or calling with f(), I bet most people will get it wrong a lot of the time:

@ def foo(implicit i: Int) = i
defined function foo

@ implicit val x: Int = 999
x: Int = 999

@ foo _ // why doesn't this work?
cmd2.sc:1: type mismatch;
 found   : Int
 required: ? => ?
val res2 = foo _
           ^
Compilation Failed

@ (j: Int) => foo(j) // manual eta expansion works tho
res3: Int => Int = ammonite.$sess.cmd3$$$Lambda$1403/0x0000000800820040@604b1e1d

@ res3(1)
res4: Int = 1

Consider also the status quo mixing implicits with defaults, where adding a () can change the value being passed in exactly the way you describe:

@ def foo(implicit i: Int = 0) = i
defined function foo

@ foo
res6: Int = 999

@ foo()
res7: Int = 0

Yes, mixing implicits and defaults and eta expansion is confusing, but that's already the status quo and I don't see this proposal making it any worse. It's an edge case that already exists. Furthermore, while this is somewhat inelegant, I can't remember the last time I bumped into something like this causing me grief.

If this edge case is something that has a negligible effect on people's quality of life, it's questionable how much value fixing this specific edge case provides (when traded off against other things that we could improve)

We also can tweak the semantics to make it more regular if we wish: this is a re-design after all.

What if we specced it such that that () could only be omitted if every parameter in the parameter list is implicit? Or what if we tweaked the behavior of eta-expansion of parameters with defaults to allow ()-omission as well? What if we used target-typing when eta expanding to determine how many defaults/implicits to use, such that it would always "do the right thing" when eta-expanding in the context of a higher-order-function argument (by far the most common use case for eta expansion and partial evaluation), which would also fix some existing usability issues where adding default arguments breaks source compatibility?

There's a pretty wide design space here, and I'm reasonable sure we can find a place that has a balance of elegant and compatibility.

It is more problematic for the other half: typeclasses, since default implies that there are other possible
choices as well, and for many typeclasses there would really be only one choice. Maybe one can get
used to this but it does not feel very natural right now.

I think this is a non-issue for Scala: this isn't Haskell, and we do not have global coherence. Tons of typeclasses have multiple implementations: Jsonable[MyCaseClass] can serialize to tuple-like JSON lists or JSON dicts, Monoid[Int] could be either addition or multiplication, Applicative[AsyncTask[T]] could do either sequential or parallel execution of the async tasks. In Scala, typeclasses can have more than one choice. Maybe some only have one, but this is something the compiler does not guarantee you: as far as the language is concerned, all typeclasses may have more than one value.

Despite having multiple possible implementations, each one only can have one implicit definition in scope. And speaking of "the default serializer for MyCaseClass in this scope" sounds entirely reasonable, something someone with a Java/Python background would have an intuition for even without any FP background at all, as is a property such as "every scope can only have one default definition for Monoid[Int]"

@Jasper-M
Copy link
Contributor

forbidding mixing default parameters with defaults

I'm already confused.

@tellurion
Copy link

forbidding mixing default parameters with defaults

I'm already confused.

@Jasper-M Default parameters are method params that have a default value specified in the method definition:
def foo(x: Int = 0)
So if foo is called without explicitly passing a param, the default of 0 is used.
"defaults" is referring to the new trial keyword for implicits. A scala 2 example:
def foo[T](implicit ord: Ord[T])
So if foo is called without explicitly passing a param, the compiler uses the value in the implicit scope.
In scala 2, implicit params have to be in a separate parameter list than normal params. In dotty they can be in the same parameter list. If this was allowed in scala 2 and the normal parameter had a default value, it would look like:
def foo[T](x: Int = 0, implicit ord: Ord[T])
This is essentially what @odersky is referring to, but he is using the term 'default' instead of 'implicit' (or 'given') since that is what this PR is about.

@odersky
Copy link
Contributor Author

odersky commented Jan 14, 2020

It's true that the problem existed already in principle, but

  • we have fixed that in Scala 3, and for good reason
  • if we allow defaults like this, the problem would show up a lot more often.

What if we specced it such that that () could only be omitted if every parameter in the parameter list is implicit?

I believe that would cause exactly the sort of gotchas we want to avoid: A subtle change has dramatic consequences. If these parameters behave in fundamentally different ways we need different syntax for them.

Or what if we tweaked the behavior of eta-expansion of parameters with defaults to allow ()-omission as well?

This looks promising at first, but it does not work out either. Compare:

def f(x: A, y: B, z: C): D;  f: (A, B, C) => D
def f(x: A, y: B, z: C = c): D;  f: (A, B) => D
def f(x: A, y: B = b, z: C = c): D;  f: (A) => D
def f(a: A = a, y: B = b, c: C = c): D;  f: () => D

The logical end of the sequence is () => D, not D. it would be different if all function types were curried.

What if we used target-typing when eta expanding to determine how many defaults/implicits to use, such that it would always "do the right thing" when eta-expanding in the context of a higher-order-function argument (by far the most common use case for eta expansion and partial evaluation), which would also fix some existing usability issues where adding default arguments breaks source compatibility?

We have a problem even if no eta expansion is intended or allowed. For all-implicit functions, you must write f, f() is not allowed. But for all-default functions you are not allowed to write f; it must be f().

There's a pretty wide design space here, and I'm reasonable sure we can find a place that has a balance of elegant and compatibility.

I tried really hard to come up with an acceptable design for a while. I thought this would be a promising solution. But I now see there is a fundamental discontinuity here that no design can eliminate. We can paper over it, or make the difference clear by the syntax.

Unifying default parameters and implicits has other problems as well: Implicit parameters are themselves implicit, but it would be a stretch to automatically make all default parameters with use-site defaults implicit themselves. And if we do, I fear we promote the kind of tangled implicit parameters that people complain about and that let them propose a restriction to typeclasses as the only safe form of implicits. I do not subscribe to that view and use context parameters a lot in my code. But I also think hard where I use them and make sure the usage is extremely uniform. Default parameters are in my view too tempting and at the same time too undisciplined to promote restraint.

We could offer use-site defaults as an addition to what we have. Something like

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

The x would be resolved with a given at use site unless it is explicitly passed. It would not itself be an implicit. That would work, and could be useful.

@arturopala
Copy link
Contributor

We could offer use-site defaults as an addition to what we have. Something like
def f(x: T = given) ...

So we would end up with two slightly different ways of writing the implicit use-site and two different ways of calling it?

def f1(x: T = given)  
f1()

def f2(given x: T)
f2

@odersky
Copy link
Contributor Author

odersky commented Jan 18, 2020

I decided to pursue #8017 instead.

@odersky odersky closed this Jan 18, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants