|
1 | 1 | ---
|
2 |
| -title: SIP-54 - Multi-Source Extension Overloads. |
3 |
| -status: vote-requested |
4 |
| -pull-request-number: 60 |
5 |
| -stage: design |
6 |
| -recommendation: accept |
| 2 | +layout: sip |
| 3 | +permalink: /sips/:title.html |
| 4 | +stage: implementation |
| 5 | +status: under-review |
| 6 | +title: SIP-54 - Multi-Source Extension Overloads |
| 7 | +--- |
| 8 | + |
| 9 | +**By: Sébastien Doeraene and Martin Odersky** |
| 10 | + |
| 11 | +## History |
| 12 | + |
| 13 | +| Date | Version | |
| 14 | +|---------------|--------------------| |
| 15 | +| Mar 10th 2023 | Initial Draft | |
| 16 | + |
| 17 | +## Summary |
| 18 | + |
| 19 | +We propose to allow overload resolution of `extension` methods with the same name but imported from several sources. |
| 20 | +For example, given the following definitions: |
| 21 | + |
| 22 | +```scala |
| 23 | +class Foo |
| 24 | +class Bar |
| 25 | + |
| 26 | +object A: |
| 27 | + extension (foo: Foo) def meth(): Foo = foo |
| 28 | + def normalMeth(foo: Foo): Foo = foo |
| 29 | + |
| 30 | +object B: |
| 31 | + extension (bar: Bar) def meth(): Bar = bar |
| 32 | + def normalMeth(bar: Bar): Bar = bar |
| 33 | +``` |
| 34 | + |
| 35 | +and the following use site: |
| 36 | + |
| 37 | +```scala |
| 38 | +import A.* |
| 39 | +import B.* |
| 40 | + |
| 41 | +val foo: Foo = ??? |
| 42 | +foo.meth() // works with this SIP; "ambiguous import" without it |
| 43 | + |
| 44 | +// unchanged: |
| 45 | +meth(foo)() // always ambiguous, just like |
| 46 | +normalMeth(foo) // always ambiguous |
| 47 | +``` |
| 48 | + |
| 49 | +## Motivation |
| 50 | + |
| 51 | +Extension methods are a great, straightforward way to extend external classes with additional methods. |
| 52 | +One classical example is to add a `/` operation to `Path`: |
| 53 | + |
| 54 | +```scala |
| 55 | +import java.nio.file.* |
| 56 | + |
| 57 | +object PathExtensions: |
| 58 | + extension (path: Path) |
| 59 | + def /(child: String): Path = path.resolve(child).nn |
| 60 | + |
| 61 | +def app1(): Unit = |
| 62 | + import PathExtensions.* |
| 63 | + val projectDir = Paths.get(".") / "project" |
| 64 | +``` |
| 65 | + |
| 66 | +However, as currently specified, they do not compose, and effectively live in a single flat namespace. |
| 67 | +This is understandable from the spec--the *mechanism**, which says that they are just regular methods, but is problematic from an intuitive point of view--the *intent*. |
| 68 | + |
| 69 | +For example, if we also use another extension that provides `/` for `URI`s, we can use it in a separate scope as follows: |
| 70 | + |
| 71 | +```scala |
| 72 | +import java.net.URI |
| 73 | + |
| 74 | +object URIExtensions: |
| 75 | + extension (uri: URI) |
| 76 | + def /(child: String): URI = uri.resolve(child) |
| 77 | + |
| 78 | +def app2(): Unit = |
| 79 | + import URIExtensions.* |
| 80 | + val rootURI = new URI("https://www.example.com/") |
| 81 | + val projectURI = rootURI / "project/" |
| 82 | +``` |
| 83 | + |
| 84 | +The above does not work anymore if we need to use *both* extensions in the same scope. |
| 85 | +The code below does not compile: |
| 86 | + |
| 87 | +```scala |
| 88 | +def app(): Unit = |
| 89 | + import PathExtensions.* |
| 90 | + import URIExtensions.* |
| 91 | + |
| 92 | + val projectDir = Paths.get(".") / "project" |
| 93 | + val rootURI = new URI("https://www.example.com/") |
| 94 | + val projectURI = rootURI / "project/" |
| 95 | + println(s"$projectDir -> $projectURI") |
| 96 | +end app |
| 97 | +``` |
| 98 | + |
| 99 | +*Both* attempts to use `/` result in error messages of the form |
| 100 | + |
| 101 | +``` |
| 102 | +Reference to / is ambiguous, |
| 103 | +it is both imported by import PathExtensions._ |
| 104 | +and imported subsequently by import URIExtensions._ |
| 105 | +``` |
| 106 | + |
| 107 | +### Workarounds |
| 108 | + |
| 109 | +The only workarounds that exist are unsatisfactory. |
| 110 | + |
| 111 | +We can avoid using extensions with the same name in the same scope. |
| 112 | +In the above example, that would be annoying enough to defeat the purpose of the extensions in the first place. |
| 113 | + |
| 114 | +Another possibility is to *define* all extension methods of the same name in the same `object` (or as top-level definitions in the same file). |
| 115 | +This is possible, although cumbersome, if they all come from the same library. |
| 116 | +However, it is impossible to combine extension methods coming from separate libraries in this way. |
| 117 | + |
| 118 | +Finally, there exists a trick with `given`s of empty refinements: |
| 119 | + |
| 120 | +```scala |
| 121 | +object PathExtensions: |
| 122 | + given pathExtensions: {} with |
| 123 | + extension (path: Path) |
| 124 | + def /(child: String): Path = path.resolve(child).nn |
| 125 | + |
| 126 | +object URIExtensions: |
| 127 | + given uriExtensions: {} with |
| 128 | + extension (uri: URI) |
| 129 | + def /(child: String): URI = uri.resolve(child) |
| 130 | +``` |
| 131 | + |
| 132 | +The empty refinement `: {}` prevents those `given`s from polluting the actual implicit scope. |
| 133 | +`extension`s defined inside `given`s that are in scope can be used, so this trick allows to use `/` with the imports of `PathExtensions.*` and `URIExtensions.*`. |
| 134 | +The `given`s must still have different names for the trick to work. |
| 135 | +This workaround is however quite obscure. |
| 136 | +It hides intent behind a layer of magic (and an additional indirection at run-time). |
| 137 | + |
| 138 | +### Problem for migrating off of implicit classes |
| 139 | + |
| 140 | +Scala 2 implicit classes did not suffer from the above issues, because they were disambiguated by the name of the implicit class (not the name of the method). |
| 141 | +This means that there are libraries that cannot migrate off of implicit classes to use `extension` methods without significantly degrading their usability. |
| 142 | + |
| 143 | +## Proposed solution |
| 144 | + |
| 145 | +We propose to relax the resolution of extension methods, so that they can be resolved from multiple imported sources. |
| 146 | +Instead of rejecting the `/` call outright because of ambiguous imports, the compiler should try the resolution from all the imports, and keep the only one (if any) for which the receiver type matches. |
| 147 | + |
| 148 | +Practically speaking, this means that the above `app()` example would compile and behave as expected. |
| 149 | + |
| 150 | +### Non-goals |
| 151 | + |
| 152 | +It is *not* a goal of this proposal to allow resolution of arbitrary overloads of regular methods coming from multiple imports. |
| 153 | +Only `extension` method calls are concerned by this proposal. |
| 154 | +The complexity budget of relaxing *all* overloads in this way is deemed too high, whereas it is acceptable for `extension` method calls. |
| 155 | + |
| 156 | +For the same reason, we do not propose to change regular calls of methods that happen to be `extension` methods. |
| 157 | + |
| 158 | +### Specification |
| 159 | + |
| 160 | +We make two changes to the [specification of extension methods](https://docs.scala-lang.org/scala3/reference/contextual/extension-methods.html). |
| 161 | + |
| 162 | +In the section [Translation of Extension Methods](https://docs.scala-lang.org/scala3/reference/contextual/extension-methods.html#translation-of-extension-methods), we make it clearer that the "desugared" version of the call site may require an explicit qualifier. |
| 163 | +This is not strictly a novelty of this SIP, since it could already happen with `given`s and implicit scopes, but this SIP adds one more case where this can happen. |
| 164 | + |
| 165 | +Previously: |
| 166 | + |
| 167 | +> So, the definition of circumference above translates to the following method, and can also be invoked as such: |
| 168 | +> |
| 169 | +> `<extension> def circumference(c: Circle): Double = c.radius * math.Pi * 2` |
| 170 | +> |
| 171 | +> `assert(circle.circumference == circumference(circle))` |
| 172 | +
|
| 173 | +With this SIP: |
| 174 | + |
| 175 | +> So, the definition of circumference above translates to the following method, and can also be invoked as such: |
| 176 | +> |
| 177 | +> `<extension> def circumference(c: Circle): Double = c.radius * math.Pi * 2` |
| 178 | +> |
| 179 | +> `assert(circle.circumference == circumference(circle))` |
| 180 | +> |
| 181 | +> or |
| 182 | +> |
| 183 | +> `assert(circle.circumference == qualifierPath.circumference(circle))` |
| 184 | +> |
| 185 | +> for some `qualifierPath` in which `circumference` is actually declared. |
| 186 | +> Explicit qualifiers may be required when the extension method is resolved through `given` instances, implicit scopes, or disambiguated from several imports. |
7 | 187 |
|
8 | 188 | ---
|
| 189 | + |
| 190 | +In the section [Translation of Calls to Extension Methods](https://docs.scala-lang.org/scala3/reference/contextual/extension-methods.html#translation-of-calls-to-extension-methods), we amend step 1. of "The precise rules for resolving a selection to an extension method are as follows." |
| 191 | + |
| 192 | +Previously: |
| 193 | + |
| 194 | +> Assume a selection `e.m[Ts]` where `m` is not a member of `e`, where the type arguments `[Ts]` are optional, and where `T` is the expected type. |
| 195 | +> The following two rewritings are tried in order: |
| 196 | +> |
| 197 | +> 1. The selection is rewritten to `m[Ts](e)`. |
| 198 | +
|
| 199 | +With this SIP: |
| 200 | + |
| 201 | +> 1. The selection is rewritten to `m[Ts](e)` and typechecked, using the following slight modification of the name resolution rules: |
| 202 | +> |
| 203 | +> - If `m` is imported by several imports which are all on the same nesting level, try each import as an extension method instead of failing with an ambiguity. |
| 204 | +> If only one import leads to an expansion that typechecks without errors, pick that expansion. |
| 205 | +> If there are several such imports, but only one import which is not a wildcard import, pick the expansion from that import. |
| 206 | +> Otherwise, report an ambiguous reference error. |
| 207 | +
|
| 208 | +### Compatibility |
| 209 | + |
| 210 | +The proposal only alters situations where the previous specification would reject the program with an ambiguous import. |
| 211 | +Therefore, we expect it to be backward source compatible. |
| 212 | + |
| 213 | +The resolved calls could previously be spelled out by hand (with fully-qualified names), so binary compatibility and TASTy compatibility are not affected. |
| 214 | + |
| 215 | +### Other concerns |
| 216 | + |
| 217 | +With this SIP, some calls that would be reported as *ambiguous* in their "normal" form can actually be written without ambiguity if used as extensions. |
| 218 | +That may be confusing to some users. |
| 219 | +Although specific error messages are not specified and therefore outside the SIP scope, we encourage the compiler implementation to enhance the "ambiguous" error message to address this confusion. |
| 220 | +If some or all of the involved ambiguous targets are `extension` methods, the compiler should point out that the call might be resolved unambiguously if used as an extension. |
| 221 | + |
| 222 | +## Alternatives |
| 223 | + |
| 224 | +A number of alternatives were mentioned in [the Contributors thread](https://contributors.scala-lang.org/t/change-shadowing-mechanism-of-extension-methods-for-on-par-implicit-class-behavior/5831), but none that passed the bar of "we think this is actually implementable". |
| 225 | + |
| 226 | +## Related work |
| 227 | + |
| 228 | +- [Contributors thread acting as de facto Pre-SIP](https://contributors.scala-lang.org/t/change-shadowing-mechanism-of-extension-methods-for-on-par-implicit-class-behavior/5831) |
| 229 | +- [Pull Request in dotty](https://github.com/lampepfl/dotty/pull/17050) to support it under an experimental import |
| 230 | + |
| 231 | +## FAQ |
| 232 | + |
| 233 | +This section will probably initially be empty. As discussions on the proposal progress, it is likely that some questions will come repeatedly. They should be listed here, with appropriate answers. |
0 commit comments