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
base: main
Are you sure you want to change the base?
Conversation
content/typeclasses-syntax.md
Outdated
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` _) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
m.unit
content/typeclasses-syntax.md
Outdated
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` _) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
summon[Monoid[A]].unit
There was a problem hiding this 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:
- Context bound changes
- Given syntax changes
I think it would be valuable to discuss, implement and approve these two independently.
I think something is off in this branch. The Named Tuples commits (from I imagine this was intended to only start at |
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 = |
There was a problem hiding this comment.
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.
267b8ff
to
fe41123
Compare
@JD557 yes, indeed. I force pushed as an independent separate branch, incorporating fixes to the two typos pointed out by The proposal could be split further into two or three independent areas, but there are also connections between the parts:
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.
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. |
content/typeclasses-syntax.md
Outdated
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
||
- An optional _precondition_, which introduces type parameters and/or using clauses and which ends in `=>`, | ||
- the implemented _type_, | ||
- an optional name binding using `as`, |
There was a problem hiding this comment.
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:
-
<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) -
<keyword> <name> <suffix>
style with the name on the left is used where the name is mandatory and important (val
,def
,object
,class
, etc.)
Some random thoughts about the parts of the proposal:
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.
|
There was a problem hiding this 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.
**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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 `=>`, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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]]: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
@Kordyjan Given clauses are usually written without a name, and that's mostly where the old syntax is weird.
|
No description provided.