Skip to content

Commit 05b0bdc

Browse files
committed
add spec for abstract methods
1 parent 1f2cedc commit 05b0bdc

File tree

1 file changed

+215
-98
lines changed

1 file changed

+215
-98
lines changed

content/unroll-default-arguments.md

Lines changed: 215 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,36 @@ spot it!
166166
Sebastien Doraene's talk [Designing Libraries for Source and Binary Compatibility](https://www.youtube.com/watch?v=2wkEX6MCxJs)
167167
explores some of the challenges, and discusses the workarounds.
168168

169+
170+
## Requirements
171+
172+
### Backwards Compatibility
173+
174+
Given:
175+
176+
* Two libraries, **Upstream** and **Downstream**, where **Downstream** depends on **Upstream**
177+
178+
* If we use a _newer_ version of **Upstream** which contains an added
179+
default parameter together with an _older_ version of **Downstream** compiled
180+
against an _older_ version of **Upstream** before that default parameter was added
181+
182+
* The behavior should be binary compatible and semantically indistinguishable from using
183+
a verion of **Downstream** compiled against the _newer_ version of **Upstream**
184+
185+
**Note:** we do not aim for _Forwards_ compatibility. Using an _older_
186+
version of **Upstream** with a _newer_ version of **Downstream** compiled against a
187+
_newer_ version of **Upstream** is not a use case we want to support. The vast majority
188+
of OSS software does not promise forwards compatibility, including software such as
189+
the JVM, so we should just follow suite
190+
191+
### All Overrides Are Equivalent
192+
193+
All versions of an `@unroll`ed method `def foo` should have the same semantics when called
194+
with the same parameters.
195+
196+
**Note:** this includes forwarder methods that may never get called in the scenarios
197+
required by our [Backwards Compatibility](#backwards-compatibility) requirement.
198+
169199
## Proposed solution
170200

171201

@@ -427,59 +457,187 @@ take into account field default values, and this change is necessary to make it
427457
use them when the given `p: Product` has a smaller `productArity` than the current
428458
`CaseClass` implementation
429459

460+
### Abstract Methods
461+
462+
Apart from `final` methods, `@unroll` also supports purely abstract methods. Consider
463+
the following example with a trait `Unrolled` and an implementation `UnrolledObj`:
464+
465+
```scala
466+
trait Unrolled{ // version 3
467+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0): String
468+
}
469+
```
470+
```scala
471+
object UnrolledObj extends Unrolled{ // version 3
472+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0) = s + n + b
473+
}
474+
```
475+
476+
This unrolls to:
477+
```scala
478+
trait Unrolled{ // version 3
479+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0): String = foo(s, n, b)
480+
def foo(s: String, n: Int, b: Boolean): String = foo(s, n)
481+
def foo(s: String, n: Int): String
482+
}
483+
```
484+
```scala
485+
object UnrolledObj extends Unrolled{ // version 3
486+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0) = s + n + b + l
487+
def foo(s: String, n: Int, b: Boolean) = foo(s, n, b, 0)
488+
def foo(s: String, n: Int) = foo(s, n, true)
489+
}
490+
```
491+
492+
Note that both the abstract methods from `trait Unrolled` and the concrete methods
493+
from `object UnrolledObj` generate forwarders when `@unroll`ed, but the forwarders
494+
are generated _in opposite directions_! Unrolled concrete methods forward from longer
495+
parameter lists to shorter parameter lists, while unrolled abstract methods forward
496+
from shorter parameter lists to longer parameter lists. For example, we may have a
497+
version of `object UnrolledObj` that was compiled against an earlier version of `trait Unrolled`:
498+
499+
500+
```scala
501+
object UnrolledObj extends Unrolled{ // version 2
502+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true) = s + n + b
503+
def foo(s: String, n: Int) = foo(s, n, true)
504+
}
505+
```
506+
507+
But further downstream code calling `.foo` on `UnrolledObj` may expect any of the following signatures,
508+
depending on what version of `Unrolled` and `UnrolledObj` it was compiled against:
509+
510+
```scala
511+
UnrolledObj.foo(String, Int)
512+
UnrolledObj.foo(String, Int, Boolean)
513+
UnrolledObj.foo(String, Int, Boolean, Long)
514+
```
515+
516+
Because such downstream code cannot know which version of `Unrolled` that `UnrolledObj`
517+
was compiled against, we need to ensure all such calls find their way to the correct
518+
implementation of `def foo`, which may be at any of the above signatures. This "double
519+
forwarding" strategy ensures that regardless of _which_ version of `.foo` gets called,
520+
it ends up eventually forwarding to the actual implementation of `foo`, with
521+
the correct combination of passed arguments and default arguments
522+
523+
```scala
524+
UnrolledObj.foo(String, Int) // forwards to UnrolledObj.foo(String, Int, Boolean)
525+
UnrolledObj.foo(String, Int, Boolean) // actual implementation
526+
UnrolledObj.foo(String, Int, Boolean, Long) // forwards to UnrolledObj.foo(String, Int, Boolean)
527+
```
528+
529+
As is the case for `@unroll`ed methods on `trait`s and `class`es, `@unroll`ed
530+
implementations of an abtract method must be final.
531+
532+
#### Are Reverse Forwarders Really Necessary?
533+
534+
This "double forwarding" strategy is not strictly necessary to support
535+
[Backwards Compatibility](#backwards-compatibility): the "reverse" forwarders
536+
generated for abstract methods are only necessary when a downstream callsite
537+
of `UnrolledObj.foo` is compiled against a newer version of the original
538+
`trait Unrolled` than the `object UnrolledObj` was, as shown below:
539+
540+
```scala
541+
trait Unrolled{ // version 3
542+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0): String = foo(s, n, b)
543+
// generated
544+
def foo(s: String, n: Int, b: Boolean): String = foo(s, n)
545+
def foo(s: String, n: Int): String
546+
}
547+
```
548+
```scala
549+
object UnrolledObj extends Unrolled{ // version 2
550+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true) = s + n + b
551+
// generated
552+
def foo(s: String, n: Int) = foo(s, n, true)
553+
}
554+
```
555+
```scala
556+
// version 3
557+
UnrolledObj.foo("hello", 123, true, 456L)
558+
```
559+
560+
If we did not have the reverse forwarder from `foo(String, Int, Boolean, Long)` to
561+
`foo(String, Int, Boolean)`, this call would fail at runtime with an `AbstractMethodError`.
562+
It also will get caught by MiMa as a `ReversedMissingMethodProblem`.
563+
564+
This configuration of version is not allowed given our definition of backwards compatibility:
565+
that definition assumes that `Unrolled` must be of a greater version than `UnrolledObj`,
566+
which itself must be of a greater version than the final call to `UnrolledObj.foo`. However,
567+
the reverse forwarders are to fulfill our requirement
568+
[All Overrides Are Equivalent](#all-overrides-are-equivalent):
569+
looking at `trait Unrolled // version 3` and `object UnrolledObj // version 2` in isolation,
570+
we find that without the reverse forwarders the signature `foo(String, Int, Boolean, Long)`
571+
is defined but not implemented. Such an un-implemented abstract method is something
572+
we want to avoid, even if our artifact version constraints mean it should technically
573+
never get called.
430574

431575
## Limitations
432576

433-
1. Only the one parameter list of multi-parameter list methods (i.e. curried or taking
434-
implicits) can be `@unroll`ed. Unrolling multiple parameter lists would generate a number
435-
of forwarder methods exponential with regard to the number of parameter lists unrolled,
436-
and the generated forwarders may begin to conflict with each other. We can choose to spec
437-
this out and implement it later if necessary, but for 99% of use cases `@unroll`ing one
438-
parameter list should be enough. Typically, only one parameter list in a method has default
439-
arguments, with other parameter lists being `implicit`s or a single callback/blocks, neither
440-
of which usually has default values.
441-
442-
2. As unrolling generates synthetic forwarder methods for binary compatibility, it is
443-
possible for them to collide if your unrolled method has manually-defined overloads
444-
445-
3. As mentioned earlier, `@unroll`ed case classes are only fully binary compatible in Scala 3,
446-
though they are _almost_ binary compatible in Scala 2. Direct calls to `unapply` are binary
447-
incompatible, but most common pattern matching of `case class`es goes through a different
448-
code path that _is_ binary compatible. In practice this should be sufficient for 99% of use
449-
cases, but it does mean that it is possible for code written as below to fail in Scala 2
450-
if a new unrolled parameter is added to the case class and `.unapply` is called directly.
451-
452-
4. While `@unroll`ed `case class`es are fully binary compatible, they are *not* fully
453-
_source_ compatible, due to the fact that pattern matching requires all arguments to
454-
be specified. This proposal does not change that. Future improvements related to
455-
[Pattern Matching on Named Fields](https://github.com/scala/improvement-proposals/pull/44)
456-
may bring improvements here. But as we discussed earlier, binary compatibility is generally
457-
more important than source compatibility, and so we do not need to wait for any source
458-
compatibility improvements to land before proceeding with these binary compatibility
459-
improvements.
460-
461-
5. This proposal does not address how macros will derive typeclasses for `case class`es, and
462-
whether or not those will be binary/source/semantically compatible. That is up to the
463-
individual macro implementations to decide. e.g., [uPickle](https://github.com/com-lihaoyi/upickle)
464-
has a very similar rule about adding `case class` fields, except that field ordering
465-
does not matter. Trying to standardize this across all possible macros and all possible
466-
typeclasses is out of scope
467-
468-
6. `@unroll` generates a quadratic amount of generated bytecode as more default parameters
469-
are added: each forwarder has `O(num-params)` size, and there are `O(num-default-params)`
470-
forwarders. We do not expect this to be a problem in practice, as the small size of the
471-
generated forwarder methods means the constant factor is small, but one could imagine
472-
the `O(n^2)` asymptotic complexity becoming a problem if a method accumulates hundreds of
473-
default parameters over time. In such extreme scenarios, some kind of builder pattern
474-
(such as those listed in [Major Alternatives](#major-alternatives)) may be preferable.
475-
476-
7. `@unroll` only supports `final` methods. `object` methods and constructors are naturally
477-
final, but `class` or `trait` methods that are `@unroll`ed need to be explicitly marked `final`.
478-
It has proved difficult to implement the semantics of `@unroll` in the presence of downstream
479-
overrides, `super`, etc. where the downstream overrides can be compiled against by different
480-
versions of the upstream code. If we can come up with some implementation that works, we can
481-
lift this restriction later, but for now I have not managed to do so and so this restriction
482-
stays.
577+
### Only the one parameter list of multi-parameter list methods can be `@unroll`ed.
578+
579+
Unrolling multiple parameter lists would generate a number
580+
of forwarder methods exponential with regard to the number of parameter lists unrolled,
581+
and the generated forwarders may begin to conflict with each other. We can choose to spec
582+
this out and implement it later if necessary, but for 99% of use cases `@unroll`ing one
583+
parameter list should be enough. Typically, only one parameter list in a method has default
584+
arguments, with other parameter lists being `implicit`s or a single callback/blocks, neither
585+
of which usually has default values.
586+
587+
### Unrolled forwarder methods can collide with manually-defined overrides
588+
589+
This is similar to any other generated methods. We can raise an error to help users
590+
debug such scenarios, but such name collisions are inevitably possible given how binary
591+
compatibility on the JVM works.
592+
593+
### `@unroll`ed case classes are only fully binary compatible in Scala 3
594+
595+
596+
They are _almost_ binary compatible in Scala 2. Direct calls to `unapply` are binary
597+
incompatible, but most common pattern matching of `case class`es goes through a different
598+
code path that _is_ binary compatible. There are also the `AbstractFunctionN` traits, from
599+
which the companion object inherits `.curried` and `.tupled` members. Luckily, `unapply`
600+
was made binary compatible in Scala 3, and `AbstractFunctionN`, `.curried`, and `.tupled`
601+
were removed
602+
603+
### While `@unroll`ed `case class`es are *not* fully _source_ compatible
604+
605+
This is due to the fact that pattern matching requires all arguments to
606+
be specified. This proposal does not change that. Future improvements related to
607+
[Pattern Matching on Named Fields](https://github.com/scala/improvement-proposals/pull/44)
608+
may bring improvements here. But as we discussed earlier, binary compatibility is generally
609+
more important than source compatibility, and so we do not need to wait for any source
610+
compatibility improvements to land before proceeding with these binary compatibility
611+
improvements.
612+
613+
### Binary and semantic compatibility for macro-derived derive typeclasses is out of scope
614+
615+
616+
This propsosal does not have any opinion on whether or not macro-derivation is be binary/source/semantically
617+
compatible. That is up to the
618+
individual macro implementations to decide. e.g., [uPickle](https://github.com/com-lihaoyi/upickle)
619+
has a very similar rule about adding `case class` fields, except that field ordering
620+
does not matter. Trying to standardize this across all possible macros and all possible
621+
typeclasses is out of scope
622+
623+
### `@unroll` generates a quadratic amount of generated bytecode as more default parameters are added
624+
625+
Each forwarder has `O(num-params)` size, and there are `O(num-default-params)`
626+
forwarders. We do not expect this to be a problem in practice, as the small size of the
627+
generated forwarder methods means the constant factor is small, but one could imagine
628+
the `O(n^2)` asymptotic complexity becoming a problem if a method accumulates hundreds of
629+
default parameters over time. In such extreme scenarios, some kind of builder pattern
630+
(such as those listed in [Major Alternatives](#major-alternatives)) may be preferable.
631+
632+
###`@unroll` only supports `final` methods.
633+
634+
`object` methods and constructors are naturally
635+
final, but `class` or `trait` methods that are `@unroll`ed need to be explicitly marked `final`.
636+
It has proved difficult to implement the semantics of `@unroll` in the presence of downstream
637+
overrides, `super`, etc. where the downstream overrides can be compiled against by different
638+
versions of the upstream code. If we can come up with some implementation that works, we can
639+
lift this restriction later, but for now I have not managed to do so and so this restriction
640+
stays.
483641

484642
### Challenges of Non-Final Methods and Overriding
485643

@@ -647,52 +805,6 @@ object Unrolled{
647805
```
648806

649807

650-
### Abstract Methods
651-
652-
In [Limitations](#limitations), I mentioned that `@unroll` only supports `final` methods.
653-
It is likely possible for abstract methods which are `@unrolled` to have concrete forwarder
654-
methods generated on their behalf.
655-
656-
```scala
657-
import scala.annotation.unroll
658-
659-
trait Unrolled{
660-
def foo(s: String, n: Int = 1, @unroll b: Boolean = true): String
661-
}
662-
663-
object Unrolled extends Unrolled{
664-
def foo(s: String, n: Int = 1, b: Boolean = true) = s + n + b
665-
}
666-
```
667-
668-
Unrolls to:
669-
670-
```scala
671-
trait Unrolled{
672-
def foo(s: String, n: Int = 1, @unroll b: Boolean = true): String = foo(s, n)
673-
def foo(s: String, n: Int = 1): String = foo(s, n, true)
674-
}
675-
676-
object Unrolled extends Unrolled{
677-
def foo(s: String, n: Int = 1, b: Boolean = true) = s + n + b
678-
}
679-
```
680-
681-
As the forwarders are concrete, the implementor of the abstract method does
682-
not need to `@unroll` the implementation: they only need to provide the implementation
683-
for the primary method `def` and not the forwarders.
684-
685-
One thing to note is that the `@unroll`ed abstract method needs to _itself_ become a
686-
forwarder method, despite originally being abstract! That is because downstream code
687-
compiled against an old version may define classes which `extends Unrolled` and
688-
define a concrete `def foo(s: String, n: Int = 1): String`, while other downstream code compiled
689-
against a newer version may define a concrete
690-
`def foo(s: String, n: Int = 1, b: Boolean = true): String`. Thus, we need both overloads
691-
of `foo` to be forwarders, so that downstream code can override either version and still work.
692-
693-
This handling for abstract methods is not fully fleshed out or implemented, so I'm
694-
not sure if it can truly be made to work.
695-
696808
### Should the Generated Methods be Deprecated or Invisible?
697809

698810
It is not clear to me if we should discourage usage of the generated forwarders
@@ -743,7 +855,7 @@ This is not currently implemented in `@unroll`, but would be a straightforward a
743855
Given this:
744856

745857
```scala
746-
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, l: Long = 0) = s + n + b + l
858+
def foo(s: String, n: Int = 1, @unroll b: Boolean = true, @unroll l: Long = 0) = s + n + b + l
747859
```
748860

749861
There are two ways to do the forwarders. First option, which I used in above, is
@@ -765,7 +877,12 @@ def foo(s: String, n: Int) = foo(s, n, true)
765877
The first option results in shorter stack traces, while the second option results in
766878
roughly half as much generated bytecode in the method bodies (though it's still `O(n^2)`).
767879

768-
For now I chose the first option.
880+
In order to allow `@unroll`ing of [Abstract Methods](#abstract-methods), we had to go with
881+
the second option. This is because when an abstract method is overriden, it is not necessarily
882+
true that the longest override that contains the implementation. Thus we need to forward
883+
between the different `def foo` overrides one at a time until the override containing the
884+
implementation is found.
885+
769886

770887

771888
## Implementation & Testing

0 commit comments

Comments
 (0)