Skip to content
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

SIP-64: Improve the Syntax of Context Bounds and Givens #81

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Mar 11, 2024

No description provided.

That's generally considered too painful to write and read, hence people usually adopt one of two alternatives. Either, eschew context bounds and switch to using clauses:
```scala
def reduce[A](xs: List[A])(using m: Monoid[A]): A =
xs.foldLeft(m)(_ `combine` _)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.unit

Since we don't have a name for the `Monoid` instance of `A`, we need to resort to `summon` in the body of `reduce`:
```scala
def reduce[A : Monoid](xs: List[A]): A =
xs.foldLeft(summon Monoid[A])(_ `combine` _)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summon[Monoid[A]].unit

Copy link

@johnynek johnynek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, these are two separate changes that can be considered completely independently:

  1. Context bound changes
  2. Given syntax changes

I think it would be valuable to discuss, implement and approve these two independently.

@JD557
Copy link

JD557 commented Mar 11, 2024

I think something is off in this branch. The Named Tuples commits (from fd964a9 to d649f6e) are included here as well.

I imagine this was intended to only start at 267b8ff?

trait:
def showMax[X : {Ordering, Show}](x: X, y: X): String
class B extends A:
def showMax[X : {Ordering as ordering, Show as show}](x: X, y: X): String =

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add a discussion (apologies if I missed it) of the fact that some using/givens don't have a single parameter to dispatch on. For such cases we would still employ the using variant. To me I'd like to see a case that we could completely kill the using variant. To do that, it would be good to have a catalog of examples that don't fit. For instance, does Builder/CanBuildFrom work with this approach? From cats, MonadError won't work with this, I don't think. In any case taking a few such examples like that would strengthen this.

@odersky
Copy link
Contributor Author

odersky commented Mar 11, 2024

@JD557 yes, indeed. I force pushed as an independent separate branch, incorporating fixes to the two typos pointed out by
@johnynek.

The proposal could be split further into two or three independent areas, but there are also connections between the parts:

  • Both context bounds and new givens use as to introduce an optional name.
  • Implementing context bounds for type members relies on deferred givens.

So one logical progression could be (1) deferred givens replacing abstract givens (2) new given syntax (3) context bound changes. But the motivation why the new given syntax is harmonious comes in part from the fact that it is in agreement with names for context bounds.

think we should add a discussion (apologies if I missed it) of the fact that some using/givens don't have a single parameter to dispatch on. For such cases we would still employ the using variant. To me I'd like to see a case that we could completely kill the using variant.

I don't think that's possible or desirable. In my world view type classes are a kind of types for types. That means they can only refer to a single type. Multi-parameter type classes are really constraints passed as context. So "multi-parameter type class" is already a misnomer. The name was invented in Haskell because Haskell does not have a general context passing mechanism, all has to be force-fitted into the typeclass paradigm. And of course there are also bits of context that don't constrain any type parameters. So it seems natural to keep using clauses for these cases, and reserve context bounds for true (that is, single-parameter) type classes.

are time sensitive since they affect existing syntax that was introduced in 3.0, so it's better to make the change at a time when not that much code using the new syntax is written yet.


1. Named tuples are a convenient lightweight way to return multiple results from a function. But the absence of names obscures their meaning, and makes decomposition with _1, _2 ugly and hard to read. The existing alternative is to define a class instead. This does name fields, but is more heavy-weight, both in terms of notation and generated bytecode. Named tuples give the same convenience of definition as regular tuples at far better readability.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this from this SIP?
It's unclear how named tuples are connected with context bounds and givens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, no that was an oversight. Deleted.

@anatoliykmetyuk anatoliykmetyuk changed the title New SIP: Improve the Syntax of Context Bounds and Givens SIP-64: Improve the Syntax of Context Bounds and Givens Mar 12, 2024

- An optional _precondition_, which introduces type parameters and/or using clauses and which ends in `=>`,
- the implemented _type_,
- an optional name binding using `as`,
Copy link

@lihaoyi lihaoyi Mar 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the meta-rule here is:

  1. <prefix> as <name> with the name on the right is used where the name is optional and usually elided (import renames, pattern-match name bindings, given names)

  2. <keyword> <name> <suffix> style with the name on the left is used where the name is mandatory and important (val, def, object, class, etc.)

@Kordyjan
Copy link
Contributor

Kordyjan commented Apr 19, 2024

Some random thoughts about the parts of the proposal:

  1. Named Context Bounds:
    The necessity to type summon[...] never bothered me, so I don't see a clear motivation for this change apart from the possibility of achieving the same effect with fewer keystrokes. Also, I really dislike the usage of the as (soft) keyword. I know it is entirely new for Scala, but we cannot ignore that it is used in multiple languages (Rust, Kotlin, C#, and TypeScript come to my mind just now), and in all of them, it means some kind of type-coercion or unchecked cast. What's more, in all of those languages, it has the same general form: <term> as <type>. We want to use it backward as <type> as <term>. This will be highly confusing to any newcomers just discovering Scala. Last, but not least, it breaks the convention that the names of terms always come before their types. I don't like this inconsistency, but I realize it would require advanced syntactic acrobatics to avoid it.

  2. Aggregate Context Bounds:
    That is a very good change; let's approve it.

  3. Expansion of Context Bounds:
    We must check if the change doesn't introduce any weird and non-intuitive type inference. Apart from that, it is an excellent idea.

  4. Context Bounds for Type Members, Deferred GIvens
    Another very good proposal. I was going to side with the opinion that deferred should be a modifier, but the interpretation of it as a compiler-provided macro convinced me.

  5. New Given Syntax
    While I agree that the current given ... : ... with ... syntax is far from perfect, alternatives (except for the trivial cases) are extremely confusing. If I stumbled on given [A : Ord] => Ord[List[A]]: before reading the proposal, I wouldn't even have an idea how to parse it. Named alternatives also suffer from problems with users' expectations about the meaning of the as keyword, as mentioned in point 1.
    I wouldn't change anything as I think there is a smooth transition from:

def lexicographicOrd[T](using Ord[T]): Ord[List[T]] = ???

through

given lexicographicOrd[T](using Ord[T]): Ord[List[T]] = ???

to

 given lexicographicOrd[T](using Ord[T]): Ord[List[T]] with

That makes the last syntax intuitive, even if it is awkward. The proposal doesn't mention alias givens at all, leaving a question about whether it intends to introduce a huge inconsistency between ordinary given instances definitions and alias given definitions.

  1. There is no point 6

  2. Abolish abstract givens
    Very good change, let's approve it.

Copy link
Member

@sjrd sjrd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall 1, 2, 3 and 5 look good to me.

I have serious concerns about 4.

I am sympathetic to 7 (6?) but it's not clear that a library could migrate off of abstract givens without breaking its own binary API.

Comment on lines +194 to +195
**Alternative:** It was suggested that we use a modifier for a deferred given instead of a `= deferred`. Something like `deferred given C[T]`. But a modifier does not suggest the concept that a deferred given will be implemented automatically in subclasses unless an explicit definition is written. In a sense, we can see `= deferred` as the invocation of a magic macro that is provided by the compiler. So from a user's point of view a given with `deferred` right hand side is not abstract.
It is a concrete definition where the compiler will provide the correct implementation.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this can be viewed as a magic macro. Magic or not, a macro is expanded at the call site of the macro. Not somewhere else.

No, this is definitely not a concrete member with a magical body. It is an abstract member that happens to receive some concrete implementation automatically in subclasses.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A non-magic macro is expanded at the use site. A magic macro can avoid that restriction ;-)

The point is, for a deferred given you know it will be (attempted to be) implemented without requiring special provisions for implementers. That's why it is not abstract.

- Simplification of the language since a feature is dropped
- Eliminate non-obvious and misleading syntax.

The only downside is that deferred givens are restricted to be used in traits, whereas abstract givens are also allowed in abstract classes. But I would be surprised if actual code relied on that difference, and such code could in any case be easily rewritten to accommodate the restriction.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please explain how such code can be "easily rewritten"? I don't think it is easy, or even perhaps possible, to rewrite such code in a way that preserves the binary API of an open class/trait that contains an abstract given.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it would break the binary API. The thing is, I am not sure there is any use case in the wild where abstract givens are used in abstract classes. People usually reach for traits anyway.


- A `given` clause consists of the following elements:

- An optional _precondition_, which introduces type parameters and/or using clauses and which ends in `=>`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you show how using clauses would look like in this context? What about a combination of type parameters and using clauses? All the examples use type parameters only.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given [T](using Ord[T]) => Ord[List[T]]:
  ...

```scala
given T = deferred
```
`deferred` is a new method in the `scala.compiletime` package, which can appear only as the right hand side of a given defined in a trait. Any class implementing that trait will provide an implementation of this given. If a definition is not provided explicitly, it will be synthesized by searching for a given of type `T` in the scope of the inheriting class. Specifically, the scope in which this given will be searched is the environment of that class augmented by its parameters but not containing its members (since that would lead to recursive resolutions). If an implementation _is_ provided explicitly, it counts as an override of a concrete definition and needs an `override` modifier.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it awkward that we introduce behavior that is very different between traits and classes. There is no precedent for anything like this in the language. In fact, Scala 3 brought classes and traits closer to each other by allowing constructor parameters in traits.

What if I already have the concrete interpretation in trait? What if I don't have the concrete interpretation in an abstract class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it awkward that we introduce behavior that is very different between traits and classes. There is no precedent for anything like this in the language.

There is precedent. Trait parameters are resolved in the next enclosing class.

```scala
trait Sorted:
type Element
given Ord[Element] = compiletime.deferred
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is my main concern with this proposal. I think this expansion is dangerous and will create compatibility issues. Not backward incompatibility at the language, but intrinsic, synchronous incompatibility between libraries.

The core issue is that this desugaring introduces "nameless" members whose name actually matters a lot. There is no precedent for that in the language.

Consider for example

trait Babar[T]
given Babar[Int] with {}
given Babar[String] with {}

trait BaseA[A] {
  given Babar[A] = deferred
}

trait BaseB[A] {
  given Babar[A] = deferred
}

class Child extends BaseA[Int] with BaseB[String]

Now you need to implement two given_Babar_A that have nothing in common but that happen to share a generated name.

You could argue that this is already problematic with given definitions today, but at least they have an explicit definition. Once we automatically generate given Ord[Element] from type Element : Ord, and then given_Ord_Element from that, there is a multi-step, non-obvious relationship between an innocuous type definition and auto-generated names that clash.

Another example would be

type Foo : {package1.Bar, package2.Bar}

which would immediately result in clashing members. Generalize it to type Foo : package1.Bar in one super trait and type Foo : package2.Bar in another supertrait, and things becomes even more obscure.

There are so many ways that such a scheme is going to go wrong. At the level of the compilation scheme, we're approaching the amount of danger of value classes. Value classes also expose issues that arise in similar situations: where a generic in a superclass cannot be instantiated to some value classes because their bridges double-clash.


Since the core issue is that a generated name acquires a strong semantic meaning, I think we can fix this by disallowing anonymous things here. We could demand that deferred givens have a name, and that includes demanding them for context bounds on type members.

Even then, some issues remain: what is the visibility of the generated member? Does it match the visibility of the type member? What if the type member is package[something] but the implementing class is outside something?

Overall, this proposal introduces for the first time adding invisible members to open traits that actually matter in subtraits and subclasses. We have no precedent for that. There are a zillion issues that could arise from such a new situation, and they need to be explored much more carefully than what the current SIP text suggests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now you need to implement two given_Babar_A that have nothing in common but that happen to share a generated name.

The attempt to provide synthesized implementations should fail with a double definition error in this case. The user can always solve the problem by defining a single given Babar[A] in Child that implements both inherited deferred givens. So there's nothing very new or problematic about it, as far as I can see.

Migration to the new syntax is straightforward, and can be supported by automatic rewrites. For a transition period we can support both the old and the new syntax. It would be a good idea to backport the new given syntax to the LTS version of Scala so that code written in this version can already use it. The current LTS would then support old and new-style givens indefinitely, whereas new Scala 3.x versions would phase out the old syntax over time.


### 7. Abolish Abstract Givens
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be 6?

given Ord[String]:
def compare(x: String, y: String) = ...

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the arrow mean here?

given Ord[String] as stringOrd:
def compare(x: String, y: String) = ...

given [A : Ord] => Ord[List[A]] as listOrd:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the arrow mean here and how does the as bind to it?

Copy link
Contributor Author

@odersky odersky Apr 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as binds to the whole given clause. The => means conditional. If there is an [A: Ord] then here is an Ord[List[A]]. It's role is similar to the arrow in pattern matching. You could also see it as a given that defines a function [A : Ord] to Ord[List[A]]. The two interpretations are the same, so it means that all the usual interpretations of => are applicable and they coincide.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the arrow can be used and then it means something different:

Welcome to Scala 3.4.1 (17.0.6, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> given [T]:(T => String) = x => x.toString
def given_T_to_String[T]: T => String

The first time I saw the new arrow syntax in this thread, I thought it was a given for FunctionN, so there could perhaps be some confusion. How should we relate the new syntax to givens for functions? Have you considered alternatives?

@odersky
Copy link
Contributor Author

odersky commented Apr 19, 2024

@Kordyjan Given clauses are usually written without a name, and that's mostly where the old syntax is weird.

I know it is entirely new for Scala

as is not entirely new, it is already used for import renamings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
9 participants