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.

5 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:

1 Like

I updated the proposal and the implementation taking into account the discussion we had here. There are two major changes:

  • Revert to prefix “name:” syntax for optional names.
  • Adopt the idea that a given conceptually implements a type, and that a conditional given conceptually implements a function type.

Thank you for all the feedback received so far! This was a deciding factor in improving the proposal.

11 Likes

If I may, there’s one more topic I would like to open, before this SIP is sealed.

It’s about “context bounds”. Like

  • given [A: Ord] => Ord[List[A]]:
  • def reduce[A: Monoid](xs: List[A]): A =

I’m a bit concerned that now the meaning of : is too overloaded. It’s used for

  • type annotation
  • context bounds
  • new blocks (Scala 3 braceless syntax)

Isn’t it too misleading and confusing (especially for newcomers)? Maybe this SIP is a good opportunity to come up with something better for context bounds.

Perhaps using a keyword like is would helpful?

A

conservative approach

  • given [A is Ord] => Ord[List[A]]:
  • def reduce[A is Monoid](xs: List[A]): A =

B

ambitious approach, which also drops [/] if they’re not necessary

  • given A is Ord => List[A] is Ord:
  • def reduce[A is Monoid](xs: List[A]): A =

I think I may have raised this in one of the PRs, but not here, so the audience was much smaller.

Context bounds and type annotations are conceptually very similar: either way they specify what kind of behavior you can expect (albeit with different mechanisms).

(Also, technically you’d probably want to say has not is since the latter implies universality, which Scala doesn’t enforce.)

5 Likes

I think the overloading of : is fine. Python does the same thing (used for both blocks and types) and it doesnt seem to cause confusion in practice

My personal beef with context bounds is not so much that they look similar to types (which as @odersky mentioned can be justified), but rather that they look so different from using clauses. IMO trying to unify them (making context bounds more flexible, making using causes more concise) would be where the opportunity for user-facing simplification lives. But I don’t have anything concrete to propose here

2 Likes

Yes, that would be awesome.

But it’s been my understanding that doing that is essentially impossible because of how type inference works (the order in which types are inferred).

I would be so happy if somebody could figure out a way around the inference problem.

I also think : is fine, since the meaning of x:T and A:T is very close (and a future SIP might make it even closer).

[A is Ord] is intriguing. But it only works if the typeclass in question has a name that can be used as an adjective. [A is ClassTag] seems off.

Besides the changes in the new syntax only relate to given definitions, and the major change only to conditional given definitions. That’s already hard to pull off at this stage. Changing the syntax of context bounds would affect at least one or two orders of magnitude more code, in my estimation.

4 Likes

Now I’m intrigued :slight_smile: Which SIP/feature do you have in mind?

1 Like

I hope a future SIP outlaws a space character in front of the colon.

Such important matters should not be left to scalafmt and local custom.

If a space is otherwise required, backticks must be used, e.g.,

`**`: T

I see that the “preformatted text” widget on this forum doesn’t handle backticks in a single line. Scala’s mission to bring weird backticks to every niche of the Internet is not nearly complete.

2 Likes

I meant the part where we use A as the default name of the witness for a context bound [A: T]. Then A: T would mean you have a value A of type T in hand, just like if you declare it with a val.

We delayed this since there’s still some design space to explore when it comes to handling multiple context bounds [A: {S, T}]. The experimental.modularity language import implements one solution to this, but we need more discussion to decide whether we want to settle on it.

1 Like

Then please consider these two points:

  • the new high-level syntax does not support multi-parameter typeclasses:
trait Action[M, S]:
  def act(m: M, s: S): S

def foo[M: Action, S]
def foo[M, S: Action[M]]
...
  • the old low-level syntax does not convey the “set-like” nature of implicit parameters:
def foo[A](x: A, y: A)(using ev1: Ord[A], ev2: Ord[A])

foo(x, y)(using summon[Ord[A]], summon[Ord[A]].reverse)

The { } characters do convey that notion though, and it already works in imports:

import scala.collection.immutable.{ Set, Set }

does not compile.

Are you sold on the idea of keeping everything inside the square brackets? Because this looks rather more scalable:

def showMax[X]{Ordering[X], Show[X]}(x:X, y:X): String
def run[P]{Parser[P] as p}(x: p.Input): p.Result
def reduce[A]{Monoid[A]}(xs: List[A]): A
def reduceSort[A]{Monoid[A], Ordering[A]}(xs: List[A])
def foo[M, S]{Action[M, S]}(x: M, y: S): S
given listOrd[A]{Ord[A]}: Ord[List[A]] with
given [A]{Ord[A]}: Ord[List[A]] with

now this may be just me but variables/parameters stand out in lowercase and it’s easier on the eye (at least to a dyslexic):

def showMax[x]{Ordering[x], Show[x]}(x:x, y:x): String
def run[p]{Parser[p] as p}(x: p.Input): p.Result
def reduce[a]{Monoid[a]}(xs: List[a]): a
def reduceSort[a]{Monoid[a], Ordering[a]}(xs: List[a])
def foo[m, s]{Action[m, s]}(x: m, y: s): s
given listOrd[a]{Ord[a]}: Ord[List[a]] with
given [a]{Ord[a]}: Ord[List[a]] with
1 Like