Skip to content

Commit

Permalink
SIP 61 - add documentation, and regression test for local defs
Browse files Browse the repository at this point in the history
  • Loading branch information
bishabosha committed Oct 5, 2024
1 parent fba2445 commit 4868397
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 1 deletion.
2 changes: 1 addition & 1 deletion compiler/src/dotty/tools/dotc/transform/PostTyper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
156 changes: 156 additions & 0 deletions docs/_docs/reference/experimental/unrolled-defs.md
Original file line number Diff line number Diff line change
@@ -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
`<invisible>` 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
`<invisible>` 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
`<invisible>` 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.
1 change: 1 addition & 0 deletions docs/sidebar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/neg/unroll-illegal2.check
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions tests/neg/unroll-illegal2.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions tests/neg/unroll-illegal3.check
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions tests/neg/unroll-illegal3.scala
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 4868397

Please sign in to comment.