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

Givens without as #10538

Merged
merged 10 commits into from Dec 1, 2020
Merged

Givens without as #10538

merged 10 commits into from Dec 1, 2020

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Nov 28, 2020

Changes in a nutshell:

given intOrd: Ordering[Int] with
  ...
given listOrd[T: Ordering]: Ordering[List[T]] with
  ...

given Ordering[Int] with
  ...
given [T: Ordering]: Ordering[List[T]] with
  ...

given global: ExecutionContext = ForkJoinContext()
given Context = ctx

instead of the current syntax:

given intOrd as Ordering[Int]:
  ...
given listOrd[T: Ordering] as Ordering[List[T]]:
  ...

given Ordering[Int]:
  ...
given [T: Ordering] as Ordering[List[T]]:
  ...

given global as ExecutionContext = ForkJoinContext()
given Context = ctx

Syntax

Here is the syntax for given instances:

TmplDef             ::=  ...
                     |   ‘given’ GivenDef
GivenDef            ::=  [GivenSig] StructuralInstance
                     |   [GivenSig] Type ‘=’ Expr
                     |   [GivenSig] Type
GivenSig            ::=  [id] [DefTypeParamClause] {UsingParamClause} ‘:’
StructuralInstance  ::=  ConstrApp {‘with’ ConstrApp} ‘with’ TemplateBody

A given instance starts with the reserved word given and an optional signature. The signature
defines a name and/or parameters for the instance. It is followed by :. There are three kinds
of given instances:

  • A structural instance contains one or more types or constructor applications, followed by with and a template body
    that contains member definitions of the instance.
  • An alias instance contains a type, followed by = and a right hand side expression.
  • An abstract instance contains just the type, which is not followed by anything.

How did we get here?

The previous syntax for givens provided alias givens and structural givens like this.

given x as C = e
given x as C {}

It's the only instance in the grammar where a construct can be followed by either an = and an alias or a template in braces. Everywhere else we distinguish between the two. The main reason for this is what happens if there is neither a = nor a {.
I.e.

  • val x: T is an abstract val, whereas
  • object x extends T is an empty object

That's why I felt that we need a keyword after the name, to emphasize that we get a concrete given with an empty body if followed by nothing.

But what if we turn around that decision? I.e. what if we take an empty continuation as an abstract given? Several people have presented use cases for abstract givens. While it's true that they can be encoded, the encodings do lead to some duplication, so at first sight it seems like a welcome generalization to support abstract givens. If we do switch that convention, then we do not need a keyword as connective anymore. Arguably : is the right choice then.

But if we do that there should be something heavier than just {} to distinguish between an abstract
given x: T and a concrete given given x: T{}, in particular since T{} can be read as a refinement type. Everywhere else
the choice between abstract and concrete is made clear by the absence or presence of =. We can't use = since we expect a block after an =, not a template body containing definitions. So we need something else.

In this PR, the connective starting a template body is with.

Ramifications and Speculations

This has another interesting consequence. Taken by itself, can we give a meaning to T with { defs }? In fact, this does make sense as an alternative syntax for an anonymous class. If we go down that path, we can truly get rid of all vestiges of new in the syntax (over time, not for 3.0).

Taking this further, we can now see the following (approximate) equivalence:

given x: C with { ... }     ~~~      given x: C = C with { ... }   ~~~    given x: C = new C {...}

So, : C with {...} is approximately : C = C with {...}, and it avoids the repetition. In fact : C with {} is more useful than : C = C with {...} since it keeps any refinements in {...} in the type. So it's more like a RHS = new {...} and an inferred result type. In fact it's even better than that, since an anonymous class new { ... } is subject to avoidance, but : C with { .. } creates the class alongside the given instead of in its right hand side. This means that members freshly introduced
in {...} are visible in the result of the given. To see the difference, consider this code:

class C
given c: C with
  def foo = 1

given d: C = new C { def foo = 1 }

def test =
  c.foo  // OK
  d.foo  // error: `foo` is not a member of `d`.

Now, one intriguing step further is whether we want to allow the same convention for normal defs. I.e

def foo(x: Int): T with {...}

as a slightly more powerful alternative to

def foo(x: Int) = T with {...}

or, using new

def foo(x: Int) = new T {...}

with the same expansion as for the given. This would give us parameterized objects, or functors in the SML sense,
without having to define a class. It also gives a nice way to avoid duplication between return type and RHS while stile having an explicitly defined return type.

Summary

The new design leads to a strong similarity between givens and other definitions. Essentially

  • given expands to lazy val or def (we leave it to the system to decide), and the name is optional
  • the rest is exactly as for vals and defs

This makes the language more regular. Ity also makes it very easy to switch from explicit definitions to givens and back.

Other benefits:

  • Anonymous classes can be written without new
  • Abstract givens are supported
  • A powerful way to get parameterized objects without having to go through classes

Timeline

The change to the given syntax, including support for abstract givens, would have to be done before 3.0 release. The rest could come later.

@smarter
Copy link
Member

smarter commented Nov 28, 2020

def foo(x: Int): T with {...}

Setting aside whether this is a good idea for now, it's worth thinking about how this could be encoded, I assume it would be something like:

class some_generated_name extends { ... }
def foo(x: Int): some_generated_name = new some_generated_name

The problem with this is: what should some_generated_name be exactly? For anonymous classes we use something like T$anon$1 but we can't do that for public types or simply reordering definitions would break the APIs. For givens we use the type of the given, but that can lead to clashes which users can only solve by giving names to those givens. If we combine the method name and its signature we can maybe end up with something that isn't likely to cause clashes, for example:

class result_foo_Int_extends_T { ... }
def foo(x: Int): result_foo_Int_extends_T = new result_foo_Int_extends_T

But then, what happens if I write:

val z = foo(1)

Is the type of z result_foo_Int_extends_T ? That wouldn't be very nice. Or do we perform avoidance at that point to get T? That could be confusing since it would mean that foo(1).member could work but val z = foo(1); z.member wouldn't.

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2020

@smarter Ah yes, overloading throws a spammer in the works, as always. If we take the given encoding, the name of the class would be foo. This means that only one overloaded variant of the method can be defined with with. Still it might be worth it even with this restriction.

@Ichoran
Copy link

Ichoran commented Nov 28, 2020

This is great! I have felt uncomfortable with aspects the given syntax the entire time prior to this proposal, but this finally feels comfortable. The equivalence with existing constructs makes it feel much more familiar. I hope this becomes the actual syntax!

@jdegoes
Copy link

jdegoes commented Nov 29, 2020

@odersky

When developing teaching material for existing anonymous given, I felt I cannot explain the syntax:

given [A] as Show[List[A]]

The word as does not make sense here, as no label is being attached to the term.

On the other hand, the following syntax can be more easily explained:

given [A]: Show[List[A]]

By pointing out the similarity to polymorphic def methods, which introduce their type parameters before type ascription.

Then T with { ... } can be taught by saying that in a fully refined type, all abstract details have been fully refined into specific concrete implementations, so there is no need for a separate implementation anymore--i.e. it can be taught as a logical consequence of existing type refinements.

@lihaoyi
Copy link
Contributor

lihaoyi commented Nov 30, 2020

Not sure if this kind of bikeshedding is welcome here, but how about:

given intOrd: Ordering[Int] = new
  ...
given listOrd[T: Ordering]: Ordering[List[T]] = new
  ...

given Ordering[Int] = v
  ...
given [T: Ordering]: Ordering[List[T]] = new
  ...

given global: ExecutionContext = ForkJoinContext()
given Context = ctx

If we make new open up an indentation-based block, everything else follows trivially:

  • Scala3's inferred-new-types to automatically make the new instantiate and object of the relevant type
  • The RHS of intOrd is an instance of Ordering[T] with certain methods defined/overriden
  • and the RHS complies with the annotated type of intOrd, and is thus assigned

Alternatively, we could ask for : after the new:

given intOrd: Ordering[Int] = new:
  ...
given listOrd[T: Ordering]: Ordering[List[T]] = new:
  ...

given Ordering[Int] = v
  ...
given [T: Ordering]: Ordering[List[T]] = new:
  ...

given global: ExecutionContext = ForkJoinContext()
given Context = ctx

Honestly to me the : doesn't make a difference, just a tradeoff between how many special cases you want v.s. how much syntax you want people to write.

Having new or new Foo (or new: or new Foo:) open up indentation blocks could be useful in a number of other scenarios as well

@odersky
Copy link
Contributor Author

odersky commented Nov 30, 2020

: <type> = new does not work in general since the thing following the : in a structural instance is not always type, it can also be a constructor application.

@LPTK
Copy link
Contributor

LPTK commented Nov 30, 2020

@odersky if it's possible to write new when there is an expected type and no constructor arguments (as currently implemented), it ought to also be possible to write new (args) to pass the corresponding arguments.
So val foo: T = new (args) { ... } would be equivalent to val foo: T = new T(args) { ... }.

Add tests for them. Also, improve error message if a given instance still has abstract members.
Structural instances (i.e. use `with` instead of `new` for anonymous classes)
are not part of this PR.
@odersky odersky changed the title Trial: Givens without as Givens without as Nov 30, 2020
@odersky
Copy link
Contributor Author

odersky commented Nov 30, 2020

The community build still needs to be updated. But I propose to do this in a separate PR after the review, because of the churn. It's basically impossible to do any large scale changes to the CB and then wait for the PR to be reviewed. Things will break very quickly and it takes a lot of effort to keep them up-to-date.

@lihaoyi
Copy link
Contributor

lihaoyi commented Dec 1, 2020 via email

@odersky
Copy link
Contributor Author

odersky commented Dec 1, 2020

The currently implemented status is this:

trait T { def x: Int }
def f(x: T): T = new { def x = 1 }

works, but this one does not:

trait T(x: Int)
def f(x: T): T = new(1)

But nothing is specified or documented yet. We need to go over it and decide what to do with it. I believe extending it to full target typed constructor applications a la C# would need more discussion and would have to come after 3.0. One issue is it runs somewhat counter to the idea of creator applications. Now there would be two ways to simplify new C(e) with expected type C, either drop the new or drop the C. We should settle on just one way, and arguably C(e) is more informative than new(e) (in particular since the expected type might not be immediately obvious).

@odersky
Copy link
Contributor Author

odersky commented Dec 1, 2020

But there's another reason why any form of new cannot be a replacement for a structural given. It's in the PR message. A structural given carries more precise type information than any form of new on the rhs. This is exploited in existing code. If we do not do this, common existing implicit libraries cannot be ported. So, I really think with is the right choice here.

Copy link
Contributor

@liufengyun liufengyun left a comment

Choose a reason for hiding this comment

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

LGTM

This is a big improvement and makes the language more regular 👍

The only reservation I have is the introduction of structural givens. On one hand, its succinctness makes it elegant for usages like the following:

given Conversion[Int, String] with
  def apply(x: Int) = ""
end

On the other hand, it does not look natural with some usage:

given ops1: IntOps with {}  // brings safeMod into scope

The code above is more readable if rewritten as follows (and I guess most users would not mind the duplicate):

given ops1: IntOps = new IntOps

More importantly, it seems to break the regularity of the Scala syntax: all concrete inlinable/reducible definitions are defined with "=". This might complicate learning and reasoning about Scala programs.

Meanwhile, in most usage of structural givens, programmers can still write given T = new T(...) { ... } for readability. The might be a concern as it introduces two ways of doing the same thing.

infix type +[X <: Int | String, Y <: Int | String] = (X, Y) match {
import scala.annotation.infix

type +[X <: Int | String, Y <: Int | String] = (X, Y) match {
Copy link
Contributor

Choose a reason for hiding this comment

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

infix is accidentally removed?

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's not needed for symbolic identifiers.

Copy link
Contributor

Choose a reason for hiding this comment

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

Then we need to remove import scala.annotation.infix.

/** with Template, with EOL <indent> interpreted */
def withTemplate(constr: DefDef, parents: List[Tree]): Template =
if in.token != WITHEOL then accept(WITH)
possibleTemplateStart() // consumes a WITHEOL token
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we miss else here?

Suggested change
possibleTemplateStart() // consumes a WITHEOL token
else possibleTemplateStart() // consumes a WITHEOL token

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. possibleTemplateStart expects a { or a WITHEOL.

@@ -131,7 +131,7 @@ than other classes. Here is an example:
trait Vehicle extends reflect.Selectable {
val wheels: Int
}
val i3 = new Vehicle { // i3: Vehicle { val range: Int }
val i3 = Vehicle with { // i3: Vehicle { val range: Int }
Copy link
Contributor

Choose a reason for hiding this comment

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

This change needs to be reverted.

@LPTK
Copy link
Contributor

LPTK commented Dec 1, 2020

In any case, if with is going to be used for starting indented blocks of definitions, for consistency it would be good to also use with it after class and object instead of :, as was once implemented.

@odersky
Copy link
Contributor Author

odersky commented Dec 1, 2020

In any case, if with is going to be used for starting indented blocks of definitions, for consistency it would be good to also use with it after class and object instead of :, as was once implemented.

Once implemented, tested at length, and then found inferior to :. So, we will not go back to it.

@Jasper-M
Copy link
Member

Jasper-M commented Dec 1, 2020

given ops1: IntOps with {}  // brings safeMod into scope

What's wrong with this?

given ops1: IntOps

How were abstract givens handled with the previous syntax?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
release-notes Should be mentioned in the release notes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

10 participants