From 4868397aed2339bf3c36c673e2bf8782733f2fd5 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sat, 5 Oct 2024 03:23:35 +0200 Subject: [PATCH] SIP 61 - add documentation, and regression test for local defs --- .../tools/dotc/transform/PostTyper.scala | 2 +- .../reference/experimental/unrolled-defs.md | 156 ++++++++++++++++++ docs/sidebar.yml | 1 + tests/neg/unroll-illegal2.check | 4 + tests/neg/unroll-illegal2.scala | 9 + tests/neg/unroll-illegal3.check | 12 ++ tests/neg/unroll-illegal3.scala | 17 ++ 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 docs/_docs/reference/experimental/unrolled-defs.md create mode 100644 tests/neg/unroll-illegal2.check create mode 100644 tests/neg/unroll-illegal2.scala create mode 100644 tests/neg/unroll-illegal3.check create mode 100644 tests/neg/unroll-illegal3.scala diff --git a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala index d5bb6a52ce39..dd0ac3461b5a 100644 --- a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala +++ b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala @@ -146,7 +146,7 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => report.error("implementation restriction: Unrolled method cannot be a trait constructor", method.srcPos) res = false if !(isCtor || method.is(Final) || method.owner.is(ModuleClass)) then - report.error(s"Unrolled method ${method} must be final", method.srcPos) + report.error(s"Unrolled method ${method.name} must be final", method.srcPos) res = false res }) diff --git a/docs/_docs/reference/experimental/unrolled-defs.md b/docs/_docs/reference/experimental/unrolled-defs.md new file mode 100644 index 000000000000..f2e09e82bc18 --- /dev/null +++ b/docs/_docs/reference/experimental/unrolled-defs.md @@ -0,0 +1,156 @@ +--- +layout: doc-page +title: "Automatic Parameter Unrolling" +nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/unrolled-defs.html +--- + +Parameter unrolling enables new parameters to be added to methods and classes, +while still preserving backwards binary compatibility. An `@unroll` annotation, on a parameter with default value, will generate backwards compatible forwarders to a method or constructor. + +## Example +```scala +// V1 +final def foo( + s: String, + i: Int +): String = s + i +``` + +In the example above, assume version `V1` of a library defines the method `foo` with two parameters: `s` and `i`. +Assume a client library or application `C1` compiles against `V1` of `foo`. + +```scala +// V2 +final def foo( + s: String, + i: Int, + @unroll b: Boolean = true, + l: Long = 0L +): String = s + i + b + l + +// Generated automatically +`` final def foo( + s: String, + i: Int +) = foo(s, i, true, 0L) +``` + +In version `V2`, the library adds the `b` and `l` parameters to `foo`, along with default values. +To preserve compatibility with `V1`, `b` is annotated with `@unroll`, generating a forwarder with only the parameters that come before, i.e. it has the same signature as `foo` in `V1`. + +A client `C2` compiling against `V2` will only see `foo` with four parameters in the public API. +The generated forwarder is hidden from those clients. +However, `C1` remains compatible with `V2` of the library, and does not need to be recompiled. +At runtime, it will continue to link against the signature of the old `foo` method, and call the generated forwarder which is accessible in the binary API. + +## Specification + +### `@unroll` annotation + +The `scala.annotation.unroll` annotation can be applied to any term parameter of an effectively-final method: +- `def` in an `object` (i.e. `final` may be omitted) +- `final def` in a `class` or `trait` +- `class` parameters (i.e. primary constructors) +- `def this` in a `class` (i.e. secondary constructors) + +### Restrictions + +It is illegal for `@unroll` to be applied to any other definition (including `trait` parameters and local methods), or to annotate a type. + +`@unroll` may be applied to more than one parameter per method, but all occurrences must appear in the same parameter clause. + +The annotated parameter, and any parameters to the right in the same parameter clause, must have a default value. + +It is a compile-time error if any generated forwarder matches the signature of another declaration in the same class. + +## Code generation + +Expansion of `@unroll` parameters is performed before TASTy generation, so generated code will appear in TASTy. + +Below specifies the transformations that occur: + +For each method `m` of a template, there is a target method `t` which is checked for `@unroll`: +- for `fromProduct`, `copy`, and `apply` of the companion of case class `C`, then `t` is the primary constructor of `C`. +- otherwise `m` is `t`. + +if `t` has a single parameter list with `@unroll` annotations, then `m` is subject to code generation. There are two +possible transformations: +1. Forwarder generation +2. Reimplementation: for `fromProduct` of a case class companion + +### (1) Forwarder generation + +In a method `foo` with unrolled parameters in parameter list `i`: +each parameter `p` with an `@unroll` annotation causes the generation of exactly one forwarder method `f_p`. + +for a given method with generic signature + +```scala +final def foo[T](ps0...)(psX..., @unroll p, psY...)(psN...): T = + ... +``` +then `f_p` will take the form + +```scala +`` final def foo[T](ps0...)(psX...)(psN...): T = + foo(ps0...)(psX..., p_D, psY_D...)(psN...) +``` + +i.e. result type is preserved, parameter lists before and after `i` are unchanged, and within `i`: +- the parameters `psX...` to the left of `p` are preserved, +- the parameters `p` and `psY...` are dropped. + +In the body of `f_p`, parameters are passed positionally to the original `foo`, except for the dropped parameters, which are replaced by default arguments for those parameters (`p_D` for `p`, and `psY_D...` for `psY...`). + +Forwarders are generated after type checking, before pickling, and with the `Invisible` flag. +This means that while present in TASTy, they can not be resolved from other top-level classes. + +Forwarder method parameters do not have default values, and are never annotated with `@unroll`. + +### (2) Method reimplementation + +To preserve semantic compatibility of `fromProduct`, its body is replaced with a pattern match over the `productArity` of the parameter. +For each forwarder generated for the case class primary constructor, an equivalent case is generated in the pattern match. + +e.g. for a forwarder +```scala +`` def this(ps...) = this(ps..., ds...) +``` +then the following case is generated: +```scala +case n => new C(...p.productElement(n - 1), ds...) +``` +where `n` is an integer matching the number of parameters in `ps`. + +The pattern match will have a default wildcard case, which has the same body as the original `fromProduct` method. + +In all the complete transformation: + +```scala +case class C(ps0...) // ps0 has z parameters + +object C: + def fromProduct(p: Product): C = + p.productArity match + case ... => ... + case n => new C(...p.productElement(n - 1), ds...) + case _ => new C(...p.productElement(z - 1)) +``` + + +## Background Motivation + +The Scala language library ecosystem is based upon compatability of API's represented via both the TASTy format (TASTy compatibility), and the Java class file format (binary compatibility). + +Adding a parameter to a method or constructor is a binary backwards incompatible change: +clients compiled against the previous version will expect the old signature to exist, and cause a `LinkageError` to be thrown at runtime. +The correct solution to this problem, to preserve compatibility, is to duplicate the method before adding the new parameter. + +In practice, Scala users developed various techniques and disciplines for mitigating this problem when evolving APIs. +Either by forbidding certain features, such as case classes, or various code generation frameworks. Here are some well-known examples: + +1. [data-class](https://index.scala-lang.org/alexarchambault/data-class) +2. [SBT Contraband](https://www.scala-sbt.org/contraband/) +3. [Structural Data Structures](https://github.com/scala/docs.scala-lang/pull/2662) + +The `@unroll` annotation was proposed as an alternative to these disciplines that not not require learning a new meta-language on top of Scala. The standard data modelling techniques of `def`, `case class`, `enum`, `class` and `trait` are preserved, and the mistake-prone boilerplate is automated. diff --git a/docs/sidebar.yml b/docs/sidebar.yml index 5048669ef664..d9be05baa3d3 100644 --- a/docs/sidebar.yml +++ b/docs/sidebar.yml @@ -158,6 +158,7 @@ subsection: - page: reference/experimental/modularity.md - page: reference/experimental/typeclasses.md - page: reference/experimental/runtimeChecked.md + - page: reference/experimental/unrolled-defs.md - page: reference/syntax.md - title: Language Versions index: reference/language-versions/language-versions.md diff --git a/tests/neg/unroll-illegal2.check b/tests/neg/unroll-illegal2.check new file mode 100644 index 000000000000..e2ead2ee3fe7 --- /dev/null +++ b/tests/neg/unroll-illegal2.check @@ -0,0 +1,4 @@ +-- [E200] Syntax Error: tests/neg/unroll-illegal2.scala:7:10 ----------------------------------------------------------- +7 | final def foo(s: String, @unroll y: Boolean) = s + y // error + | ^^^ + | The final modifier is not allowed on local definitions diff --git a/tests/neg/unroll-illegal2.scala b/tests/neg/unroll-illegal2.scala new file mode 100644 index 000000000000..ad7284506bbf --- /dev/null +++ b/tests/neg/unroll-illegal2.scala @@ -0,0 +1,9 @@ +//> using options -experimental + +import scala.annotation.unroll + +class wrap { + locally { + final def foo(s: String, @unroll y: Boolean) = s + y // error + } +} diff --git a/tests/neg/unroll-illegal3.check b/tests/neg/unroll-illegal3.check new file mode 100644 index 000000000000..20917e857ee7 --- /dev/null +++ b/tests/neg/unroll-illegal3.check @@ -0,0 +1,12 @@ +-- Error: tests/neg/unroll-illegal3.scala:7:8 -------------------------------------------------------------------------- +7 | def foo(s: String, @unroll y: Boolean) = s + y // error + | ^ + | Unrolled method foo must be final +-- Error: tests/neg/unroll-illegal3.scala:12:6 ------------------------------------------------------------------------- +12 | def foo(s: String, @unroll y: Boolean) = s + y // error + | ^ + | Unrolled method foo must be final +-- Error: tests/neg/unroll-illegal3.scala:16:6 ------------------------------------------------------------------------- +16 | def foo(s: String, @unroll y: Boolean): String // error + | ^ + | Unrolled method must be final and concrete diff --git a/tests/neg/unroll-illegal3.scala b/tests/neg/unroll-illegal3.scala new file mode 100644 index 000000000000..22a53bd04de6 --- /dev/null +++ b/tests/neg/unroll-illegal3.scala @@ -0,0 +1,17 @@ +//> using options -experimental + +import scala.annotation.unroll + +object wrap { + locally { + def foo(s: String, @unroll y: Boolean) = s + y // error + } +} + +class UnrolledCls { + def foo(s: String, @unroll y: Boolean) = s + y // error +} + +trait UnrolledTrait { + def foo(s: String, @unroll y: Boolean): String // error +}