Pre-SIP: Improve Syntax for Context Bounds and Givens

I agree we don’t need both function form and method form, that’s too complicated. But the method form has the huge disadvantage that it again needs an underscore to indicate absence of the name. Since the unnamed form should be the norm, that’s a no-go for me.

I think the function form is a better way to look at the issue. But we don’t need to make our lives life too complicated by repeating phantom parameters on the right of the =. Just interpret the => as contextual ?=> since all givens are applied implicitly anyway. Then you are basically back at the syntax proposed in SIP 64.

But I think we could make the analogy with functions even stronger than before. I am adding the following section to the SIP which describes a small variation to the function syntax.

Alternative: Reinforce Similarity with Function Types

A reservation against the new syntax that is sometimes brought up is that the => feels strange. I personally find the => quite natural since it means implication, which is exactly what we want to express when we write a conditional given. This also corresponds to the meaning of arrow in functions since by the Curry-Howard isomorphism function types correspond to implications in logic. Besides => is also used in other languages that support type classes (e.g.: Haskell).

As an example, the most natural reading of

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

is if A is Ord then List[A] is Ord, or, equivalently, A is Ord implies List[A] is Ord, hence the =>. Another way to see this is that the given clause establishes a context function of type [A: Ord] ?=> Ord[List]A]] that is automatically applied to evidence arguments of type Ord[A] and that yields instances of type Ord[List[A]]. Since givens are in any case applied automatically to all their arguments, we don’t need to specify that separately with ?=>, a simple => arrow is sufficiently clear and is easier to read.

Once one has internalized the analogy with implications and functions, one
could argue the opposite, namely that the => in a given clause is not sufficiently function-like. For instance, given [A] => F[A] looks like it implements a function type, but given[A](using B[A]) => F[A] looks like a mixture between a function type and a method signature.

A more radical and in some sense cleaner alternative is to decree that a given should always look like it implements a type. Conditional givens should look like they implement function types. Examples:

  // Typeclass with context bound, as before
  given [A: Ord] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Typeclass with context parameter, instead of using clause
  given [A] => Ord[A] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Alias with context bound, as before
  given [A: Ord] => Ord[List[A]] =
    ListOrd[A]

  // Alias with with context parameter, instead of using clause
  given [A] => Ord[A] => Ord[List[A]] =
    ListOrd[A]()

For completeness I also show two cases where the given clause uses names for
both arguments and the clause as a whole (in the prefix style)

  // Named typeclass with named context parameter
  given listOrd: [A] => (ord: Ord[A]) => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Named alias with named context parameter
  given listOrd: [A] => (ord: Ord[A]) => Ord[List[A]] =
    ListOrd[A]()

The new syntax fits exactly the approach of seeing conditional givens as implications: For instance,

[A] => Ord[A] => Ord[List[A]]

can be read as:

If A is a type, then if A is Ord, then List[A] is Ord.

I think this is overall the cleanest proposal. For completeness here is the delta
in the syntax description:

GivenDef          ::=  [id ':'] GivenSig
GivenSig          ::=  GivenType ([‘=’ Expr] | TemplateBody)
                   |   ConstrApps TemplateBody
                   |   GivenConditional '=>' GivenSig
GivenConditional  ::=  DefTypeParamClause | DefTermParamClause | '(' FunArgTypes ')'
GivenType         ::=  AnnotType {id [nl] AnnotType}

This would also give a more regular and familiar syntax to by-name givens:

var ctx = ...
given () => Context = ctx

Indeed, since we know => means ?=> in givens, this defines a value of type () ?=> Context, which is exactly the same as a by-name parameter type.

Possible ambiguities

  • If one wants to define a given for an a actual function type (which is probably not advisable in practice), one needs to enclose the function type in parentheses, i.e. given ([A] => F[A]). This is true in the currently implemented syntax and stays true for all discussed change proposals.

  • The double meaning of : with optional prefix names is resolved as usual. A : at the end of a line starts a nested definition block. If for some obscure reason one wants to define a named given on multiple lines, one has to format it as follows:

      given intOrd
        : Ord = ...
    
      given intOrd
        : Ord:
        def concat(x: Int, y: Int) = ...
    

Finally, for systematic comparison, here is the listing of all 9x2 cases discussed previously with the proposed alternative syntax. Only the 3rd, 6th, and 9th case are different from what was shown before.

Unnamed:

  // Simple typeclass
  given Ord[Int]:
    def compare(x: Int, y: Int) = ...

  // Parameterized typeclass with context bound
  given [A: Ord] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Parameterized typeclass with context parameter
  given [A] => Ord[A] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Simple alias
  given Ord[Int] = IntOrd()

  // Parameterized alias with context bound
  given [A: Ord] => Ord[List[A]] =
    ListOrd[A]()

  // Parameterized alias with context parameter
  given [A] => Ord[A] => Ord[List[A]] =
    ListOrd[A]()

  // Concrete class instance
  given Context()

  // Abstract or deferred given
  given Context = deferred

  // By-name given
  given () => Context = curCtx

Named:

  // Simple typeclass
  given intOrd: Ord[Int]:
    def compare(x: Int, y: Int) = ...

  // Parameterized typeclass with context bound
  given listOrd: [A: Ord] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Parameterized typeclass with context parameter
  given listOrd: [A] => Ord[A] => Ord[List[A]]:
    def compare(x: List[A], y: List[A]) = ...

  // Simple alias
  given intOrd: Ord[Int] = IntOrd()

  // Parameterized alias with context bound
  given listOrd: [A: Ord] => Ord[List[A]] =
    ListOrd[A]()

  // Parameterized alias with context parameter
  given listOrd: [A] => Ord[A] => Ord[List[A]] =
    ListOrd[A]()

  // Concrete class instance
  given context: Context()

  // Abstract or deferred given
  given context: Context = deferred

  // By-name given
  given context: () => Context = curCtx
8 Likes

This is definitely better than the mix between method and function syntax, but it feels much less intuitive than the current system

Even though they are equivalent in theory, I almost always think with the method syntax, which feels much more natural and fluid.

Therefore I’m not sure this is going in the right direction.
It has however the upside of being very self-consistent and different from what we have currently (makes it simpler to spot outdated docs)

2 Likes

I agree that this has improved the regularity of the original example, and provides an additional consistent simplification by just going directly to = (consistent because you can already do this with context functions)! Although I also find the arrow to not look strange, and find the parallel to implication pleasing, I want to think through the alternative because I think most people have a lot more familiarity with it.

Your latest proposal is basically to use the form of the mapping that is curried over type parameter lists and regular parameter lists.

So, as usual with curried functions, there’s an exact parallel between these two forms:

given [A] => Ord[A] => Ord[List[A]]
given [A](using Ord[A]): Ord[List[A]]

and we’re already required to learn the second one (sans : Ord[List[A]]) to use extension methods.

extension [A](a: A)(using Ord[A])

So we already have precedent for that; the curried form is just yet more to learn. The arrow form feels plenty natural to me (no real extra learning), I like it, and yet…I hesitate because it seems like it’s probably an increased burden for some people who are not me.

Are there any cases where the method form doesn’t work? Well, it’s the old with thing again:

given listOrd[A](using Ord[A]): Ord[List[A]]:
  def compare(x: List[A], y: List[A]) = ...

We have replaced with with : but the double-duty is exactly the same as before.

But hang on! We can’t do this:

val intListOrd: Ord[Int] ?=> new Ord[List[Int]]:
  def compare ...

Here we are placing a (context) function definition in a type declaration slot, and the compiler doesn’t like it. It’s the same problem that we already have with doing double-duty: you are using type ascription but placing not a type but a definition of the operation in that spot. It’s a little less obvious than with with, but it’s exactly the same issue.

So, in fact, you haven’t actually removed the issue by moving to functional form. It’s a little more obscure, and maybe obscuring it is enough, but the problem is still a problem.

So what would be regular?

given listOrd = [A] => Ord[A] => List[Ord[A]]:
  def compare(x: List[A], y: List[A]) = ...

Now we’re defining a specific function instance with known type, a particular instance is created and everything is fine save that you probably don’t want

def p[A: Ord]() = ListOrd()
given listOrd = [A] => Ord[A] => p()

because the type isn’t explicit enough. Why is List[Ord[A]]: explicit enough where p() isn’t? Because if you’re explicitly creating an anonymous class, you have to state the type of the superclass right there. There’s no “type inference” to speak of. What you get is exactly what it says on the tin: this contains one List[Ord[A]].

But if that allows us to rescue the functional form from insufficient explicitness, it also allows us to rescue the method form:

given listOrd[A](using Ord[A]) = Ord[List[A]]:
  def compare(x: List[A], y: List[A]) = ...

So if we embrace regularity, there really isn’t anything that the method form can’t do. It’s only because we’re allowing irregularity to creep into the function form, probably mostly due to lack of familiarity, that it seems like it handles cases that the method form doesn’t.

One new issue

In givens, the proposal is to not require the new that is required elsewhere. Why might you want new? Well,

scala> class C():
     |   def apply(f: Int => Int) = f(foo)
     |   def foo: Int = 0
     | 
// defined class C
                                                                                
scala> C():
     |   _ + 5
     | 
val res1: Int = 5

scala> new C():
     |   override def foo = 2
     | 
val res2: C = anon$1@77d58f3a

So the new is doing work here to disambiguate the two cases.

I don’t have a strong opinion on how to resolve this, but it’s worth pointing out, because the proposal is to change the behavior to not require new. You certainly should be able to use it if you want, though:

given listOrd = [A] => Ord[A] => new Ord[List[A]]:
  def compare(x: List[A], y: List[A]) = ...

given listOrd[A](using Ord[A]) = new Ord[List[A]]:
  def compare(x: List[a], y: List[A]) = ...
2 Likes

No it wouldn’t:

scala> Option(5) match
     |   case Some(X) => X
     |   case None => 0
     | 
-- [E006] Not Found Error: -----------------------------------------------------
2 |  case Some(X) => X
  |            ^
  |            Not found: X

and

scala> case object c {}
// defined case object c
                                                                                
scala> ("fish": AnyRef) match
     |   case c => "yes"
     |   case _ => "no"
     | 
1 warning found
val res0: String = yes
-- [E121] Pattern Match Warning: -----------------------------------------------
3 |  case _ => "no"
  |       ^
  |Unreachable case except for null (if this is intentional, consider writing case null => instead).

Market capitalization.

I think the new alternative with all-in function-like syntax is the best so far! Less colon clutter and more regular.

@odersky Maybe the idea that => is OK when ?=> is expected can be generalized to all places where that could be so?

And if so, perhaps also allowing the arguably more regular (but less nice to read)
given [A: Ord] ?=> Ord[List[A]] = ...
as the desugaring of given [A: Ord] => Ord[List[A]] = ...

I am a bit skeptical about giving syntactic choices like => or ?=> because it increases the effort needed to understand code somebody else has written. Same issue when giving a choice between “name: ...” and “... as name”. I think it’s better in the end to pick one syntax and stick to it.

4 Likes

Yes, too much choice and extra sugar come with a cost, that maybe is not worth it here.

Skipping as-style in the main alternative of the proposal and going all in with given function syntax and optional name before colon, seams like the most viable alternative to me, if we should change the current given syntax.

1 Like

Sorry you’re right of course, I even spoke about captures in my message and I didn’t spot this

I should have said that it would be the first place where capitalization changes the meaning of a definition

Additionally, this behavior of pattern matching, while extremely useful, is confusing to a lot of new users.
Case and point: It wasn’t considered correctly in the implementation of match types, and no one noticed for a very long time:

// This should succeed, and Test should behave like the identity
type Test[A] = A match
  case a => a // error: Not found: type a
1 Like

Totally agree with all of this message, in particular, I think that idea is really nice: