Skip to content

Commit 631a6bb

Browse files
committed
SIP 61 - add documentation, and regression test for local defs
1 parent fba2445 commit 631a6bb

File tree

6 files changed

+183
-0
lines changed

6 files changed

+183
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
---
2+
layout: doc-page
3+
title: "Automatic Parameter Unrolling"
4+
nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/unrolled-defs.html
5+
---
6+
7+
Parameter unrolling enables new parameters to be added to methods and classes,
8+
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.
9+
10+
## Example
11+
```scala
12+
// V1
13+
final def foo(
14+
s: String,
15+
i: Int
16+
): String = s + i
17+
```
18+
19+
In the example above, assume version `V1` of a library defines the method `foo` with two parameters: `s` and `i`.
20+
Assume a client library or application `C1` compiles against `V1` of `foo`.
21+
22+
```scala
23+
// V2
24+
final def foo(
25+
s: String,
26+
i: Int,
27+
@unroll b: Boolean = true,
28+
l: Long = 0L
29+
): String = s + i + b + l
30+
31+
// Generated automatically
32+
`<invisible>` final def foo(
33+
s: String,
34+
i: Int
35+
) = foo(s, i, true, 0L)
36+
```
37+
38+
In version `V2`, the library adds the `b` and `l` parameters to `foo`, along with default values.
39+
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`.
40+
41+
A client `C2` compiling against `V2` will only see `foo` with four parameters in the public API.
42+
The generated forwarder is hidden from those clients.
43+
However, `C1` remains compatible with `V2` of the library, and does not need to be recompiled.
44+
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.
45+
46+
## Specification
47+
48+
### `@unroll` annotation
49+
50+
The `scala.annotation.unroll` annotation can be applied to any term parameter of an effectively-final method:
51+
- `def` in an `object` (i.e. `final` may be omitted)
52+
- `final def` in a `class` or `trait`
53+
- `class` parameters (i.e. primary constructors)
54+
- `def this` in a `class` (i.e. secondary constructors)
55+
56+
### Restrictions
57+
58+
It is illegal for `@unroll` to be applied to any other definition (including `trait` parameters and local methods), or to annotate a type.
59+
60+
`@unroll` may be applied to more than one parameter per method, but all occurrences must appear in the same parameter clause.
61+
62+
The annotated parameter, and any parameters to the right in the same parameter clause, must have a default value.
63+
64+
It is a compile-time error if any generated forwarder matches the signature of another declaration in the same class.
65+
66+
## Code generation
67+
68+
Expansion of `@unroll` parameters is performed before TASTy generation, so generated code will appear in TASTy.
69+
70+
Below specifies the transformations that occur:
71+
72+
For each method `m` of a template, there is a target method `t` which is checked for `@unroll`:
73+
- for `fromProduct`, `copy`, and `apply` of the companion of case class `C`, then `t` is the primary constructor of `C`.
74+
- otherwise `m` is `t`.
75+
76+
if `t` has a single parameter list with `@unroll` annotations, then `m` is subject to code generation. There are two
77+
possible transformations:
78+
1. Forwarder generation
79+
2. Reimplementation: for `fromProduct` of a case class companion
80+
81+
### (1) Forwarder generation
82+
83+
In a method `foo` with unrolled parameters in parameter list `i`:
84+
each parameter `p` with an `@unroll` annotation causes the generation of exactly one forwarder method `f_p`.
85+
86+
for a given method with generic signature
87+
88+
```scala
89+
final def foo[T](ps0...)(psX..., @unroll p, psY...)(psN...): T =
90+
...
91+
```
92+
then `f_p` will take the form
93+
94+
```scala
95+
`<invisible>` final def foo[T](ps0...)(psX...)(psN...): T =
96+
foo(ps0...)(psX..., p_D, psY_D...)(psN...)
97+
```
98+
99+
i.e. result type is preserved, parameter lists before and after `i` are unchanged, and within `i`:
100+
- the parameters `psX...` to the left of `p` are preserved,
101+
- the parameters `p` and `psY...` are dropped.
102+
103+
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...`).
104+
105+
Forwarders are generated after type checking, before pickling, and with the `Invisible` flag.
106+
This means that while present in TASTy, they can not be resolved from other top-level classes.
107+
108+
Forwarder method parameters do not have default values, and are never annotated with `@unroll`.
109+
110+
### (2) Method reimplementation
111+
112+
To preserve semantic compatibility of `fromProduct`, its body is replaced with a pattern match over the `productArity` of the parameter.
113+
For each forwarder generated for the case class primary constructor, an equivalent case is generated in the pattern match.
114+
115+
e.g. for a forwarder
116+
```scala
117+
`<invisible>` def this(ps...) = this(ps..., ds...)
118+
```
119+
then the following case is generated:
120+
```scala
121+
case n => new C(...p.productElement(n - 1), ds...)
122+
```
123+
where `n` is an integer matching the number of parameters in `ps`.
124+
125+
The pattern match will have a default wildcard case, which has the same body as the original `fromProduct` method.
126+
127+
In all the complete transformation:
128+
129+
```scala
130+
case class C(ps0...) // ps0 has z parameters
131+
132+
object C:
133+
def fromProduct(p: Product): C =
134+
p.productArity match
135+
case ... => ...
136+
case n => new C(...p.productElement(n - 1), ds...)
137+
case _ => new C(...p.productElement(z - 1))
138+
```
139+
140+
141+
## Background Motivation
142+
143+
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).
144+
145+
Adding a parameter to a method or constructor is a binary backwards incompatible change:
146+
clients compiled against the previous version will expect the old signature to exist, and cause a `LinkageError` to be thrown at runtime.
147+
The correct solution to this problem, to preserve compatibility, is to duplicate the method before adding the new parameter.
148+
149+
In practice, Scala users developed various techniques and disciplines for mitigating this problem when evolving APIs.
150+
Either by forbidding certain features, such as case classes, or various code generation frameworks. Here are some well-known examples:
151+
152+
1. [data-class](https://index.scala-lang.org/alexarchambault/data-class)
153+
2. [SBT Contraband](https://www.scala-sbt.org/contraband/)
154+
3. [Structural Data Structures](https://github.com/scala/docs.scala-lang/pull/2662)
155+
156+
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.

docs/sidebar.yml

+1
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ subsection:
158158
- page: reference/experimental/modularity.md
159159
- page: reference/experimental/typeclasses.md
160160
- page: reference/experimental/runtimeChecked.md
161+
- page: reference/experimental/unrolled-defs.md
161162
- page: reference/syntax.md
162163
- title: Language Versions
163164
index: reference/language-versions/language-versions.md

tests/neg/unroll-illegal2.check

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- [E200] Syntax Error: tests/neg/unroll-illegal2.scala:7:10 -----------------------------------------------------------
2+
7 | final def foo(s: String, @unroll y: Boolean) = s + y // error
3+
| ^^^
4+
| The final modifier is not allowed on local definitions

tests/neg/unroll-illegal2.scala

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//> using options -experimental
2+
3+
import scala.annotation.unroll
4+
5+
class wrap {
6+
locally {
7+
final def foo(s: String, @unroll y: Boolean) = s + y // error
8+
}
9+
}

tests/neg/unroll-illegal3.check

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Error: tests/neg/unroll-illegal3.scala:7:8 --------------------------------------------------------------------------
2+
7 | def foo(s: String, @unroll y: Boolean) = s + y // error
3+
| ^
4+
| Unrolled method method foo must be final

tests/neg/unroll-illegal3.scala

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//> using options -experimental
2+
3+
import scala.annotation.unroll
4+
5+
object wrap {
6+
locally {
7+
def foo(s: String, @unroll y: Boolean) = s + y // error
8+
}
9+
}

0 commit comments

Comments
 (0)