Skip to content

Commit 5109a15

Browse files
authored
Prevent opaque types leaking from transparent inline methods (#23792)
Fixes #13461 Before this PR, every transparent inline call returning an opaque type would actually be typed with an intersection type DECLARED & ACTUAL, where DECLARED was the declared return type of the transparent method, and ACTUAL was the type actually returned in the expansion, with every opaque type alias then dealiased. There was no way to guard against this dealiasing. With the changes in this PR, users are now able to manually ensure that they receive the types they want, although they might have to manually annotate the returned type inside of the transparent inline method body (as described in the added documentation section). The previous dealiasing was caused by the proxy mechanism in inlining, which would effectively deals every opaque type, that is transparent from the perspective of the original method declaration. Now, we try to map the results of the transparent inline back to the original (opaque) types. However all of this is only true for the outermost transparent inline method calls. Nested calls will not be affected by this change. This is because the type checker in the original context of the method will see the opaque type as transparent (so it will type the rest of the method according to that), and that typing must still hold after inlining the method e.g.: ```scala object Time: opaque type Time = String transparent inline makeTime(): Time = "1h" transparent inline listTime(): List[Time] = List[String](makeTime()) // mapping the results of makeTime() back into opaque types outside // of the scope of Time will cause an inlining compilation error // (which we are generally trying to avoid, and which would be // considered a bug in the compiler). ``` This might cause the aliased type to still leak in a manner that may feel unexpected. In the above example, even if the List does not have an explicit type parameter, the type inference will still decide on `String`, causing any call to listTime to leak that type. This is also touched upon in the added docs. This PR might cause some source/library incompatibilities connected to the changed returned types (but I doubt it’s many, considering the additional required effort of ignoring type inference if we want the outputted type to be different).
2 parents 20c62f8 + e611110 commit 5109a15

File tree

11 files changed

+277
-7
lines changed

11 files changed

+277
-7
lines changed

compiler/src/dotty/tools/dotc/inlines/Inliner.scala

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ class Inliner(val call: tpd.Tree)(using Context):
394394
case (from, to) if from.symbol == ref.symbol && from =:= ref => to
395395
}
396396

397+
private def mapRefBack(ref: TermRef): Option[TermRef] =
398+
opaqueProxies.collectFirst {
399+
case (from, to) if to.symbol == ref.symbol && to =:= ref => from
400+
}
401+
397402
/** If `tp` contains TermRefs that refer to objects with opaque
398403
* type aliases, add proxy definitions to `opaqueProxies` that expose these aliases.
399404
*/
@@ -438,6 +443,22 @@ class Inliner(val call: tpd.Tree)(using Context):
438443
}
439444
)
440445

446+
/** Map back all TermRefs that match the right element in `opaqueProxies` to the
447+
* corresponding left element.
448+
* E.g. for a previously created
449+
* `val proxy$1 = Time {type OpaqueInt = Int}` as part of the ongoing inlining
450+
* a `List[proxy$1.OpaqueInt]` will be mapped back into a `List[Time.OpaqueInt]`.
451+
*/
452+
protected val mapBackToOpaques = TreeTypeMap(
453+
typeMap = new TypeMap:
454+
override def stopAt = StopAt.Package
455+
def apply(t: Type) = mapOver {
456+
t match
457+
case ref: TermRef => mapRefBack(ref).getOrElse(ref)
458+
case _ => t
459+
}
460+
)
461+
441462
/** If `binding` contains TermRefs that refer to objects with opaque
442463
* type aliases, add proxy definitions that expose these aliases
443464
* and substitute such TermRefs with theproxies. Example from pos/opaque-inline1.scala:
@@ -487,6 +508,48 @@ class Inliner(val call: tpd.Tree)(using Context):
487508

488509
private def adaptToPrefix(tp: Type) = tp.asSeenFrom(inlineCallPrefix.tpe, inlinedMethod.owner)
489510

511+
def thisTypeProxyExists = !thisProxy.isEmpty
512+
513+
/** Maps a type that includes a thisProxy (e.g. `TermRef(NoPrefix,val Foo$_this)`)
514+
* by reading the defTree belonging to that thisProxy (`val Foo$_this: Foo.type = AnotherProxy`)
515+
* back into its original reference (`AnotherProxy`, which is directly or indirectly a refinement on `Foo`)
516+
*
517+
* Usually when we end up with another proxy like this, we will be able to further unwrap it back
518+
* into `Foo` with mapBackToOpaques, but, for nested transparent inline calls, `AnotherProxy` will be
519+
* a proxy created by inlining the outer calls, that we might not be able to further unwrap this way
520+
* (as those proxies will not be a part of opaqueProxies created during this inlining).
521+
* We leave that as it is and treat this behavior as intended (see documentation with an example in
522+
* `Opaque Types in Transparent Inline Methods` section in `opaques-details.md`),
523+
* as we might need those opaques to have visible right hand sides for successful
524+
* typechecking of the outer inline call.
525+
*/
526+
val thisTypeUnpacker =
527+
TreeTypeMap(
528+
typeMap = new TypeMap:
529+
override def stopAt = StopAt.Package
530+
def apply(t: Type) = mapOver {
531+
t match
532+
case a: TermRef if thisProxy.values.exists(_ == a) =>
533+
a.termSymbol.defTree match
534+
case untpd.ValDef(a, tpt, _) => tpt.tpe
535+
case _ => t
536+
}
537+
)
538+
539+
/** Returns the result type of the Inlined code block after removing thisProxy and opaqueProxy TermRefs.
540+
* E.g. for an Inlined tree returning Block of type `Option[Foo$_this.OpaqueInt]`,
541+
* and for proxies:
542+
* ```
543+
* val $proxy1: Foo.type{type OpaqueInt = Int} = = ...
544+
* val Foo$_this: ($proxy1 : Foo.type{type OpaqueInt = Int}) = ...
545+
* ```
546+
* the method will return: `Foo.OpaqueInt`
547+
*/
548+
def unpackProxiesFromResultType(inlined: Inlined): Type =
549+
if thisTypeProxyExists then mapBackToOpaques.typeMap(thisTypeUnpacker.typeMap(inlined.expansion.tpe))
550+
else inlined.tpe
551+
552+
490553
/** Populate `thisProxy` and `paramProxy` as follows:
491554
*
492555
* 1a. If given type refers to a static this, thisProxy binds it to corresponding global reference,

compiler/src/dotty/tools/dotc/inlines/Inlines.scala

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -573,12 +573,47 @@ object Inlines:
573573
// different for bindings from arguments and bindings from body.
574574
val inlined = tpd.Inlined(call, bindings, expansion)
575575

576-
if !hasOpaqueProxies then inlined
576+
val hasOpaquesInResultFromCallWithTransparentContext =
577+
val owners = call.symbol.ownersIterator.toSet
578+
call.tpe.widenTermRefExpr.existsPart(
579+
part => part.typeSymbol.is(Opaque) && owners.contains(part.typeSymbol.owner)
580+
)
581+
582+
/** Remap ThisType nodes that are incorrect in the inlined context.
583+
* Incorrect ThisType nodes can cause unwanted opaque type dealiasing later.
584+
* E.g. if inlined in a `<root>.Foo` package (but outside of <root>.Foo.Bar object) we will map
585+
* `TermRef(ThisType(TypeRef(ThisType(TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class <root>)),object Foo),Bar$)),MyOpaque$)),one)`
586+
* into
587+
* `TermRef(TermRef(TermRef(TermRef(ThisType(TypeRef(NoPrefix,module class <root>)),object Foo),object Bar),object MyOpaque),val one)`
588+
* See test i13461-d
589+
*/
590+
def fixThisTypeModuleClassReferences(tpe: Type): Type =
591+
val owners = ctx.owner.ownersIterator.toSet
592+
TreeTypeMap(
593+
typeMap = new TypeMap:
594+
override def stopAt = StopAt.Package
595+
def apply(t: Type) = mapOver {
596+
t match
597+
case ThisType(tref @ TypeRef(prefix, _)) if tref.symbol.flags.is(Module) && !owners.contains(tref.symbol) =>
598+
TermRef(apply(prefix), tref.symbol.companionModule)
599+
case _ => mapOver(t)
600+
}
601+
).typeMap(tpe)
602+
603+
if !hasOpaqueProxies && !hasOpaquesInResultFromCallWithTransparentContext then inlined
577604
else
578-
val target =
579-
if inlinedMethod.is(Transparent) then call.tpe & inlined.tpe
580-
else call.tpe
581-
inlined.ensureConforms(target)
605+
val (target, forceCast) =
606+
if inlinedMethod.is(Transparent) then
607+
val unpacked = unpackProxiesFromResultType(inlined)
608+
val withAdjustedThisTypes = if call.symbol.is(Macro) then fixThisTypeModuleClassReferences(unpacked) else unpacked
609+
(call.tpe & withAdjustedThisTypes, withAdjustedThisTypes != unpacked)
610+
else (call.tpe, false)
611+
if forceCast then
612+
// we need to force the cast for issues with ThisTypes, as ensureConforms will just
613+
// check subtyping and then choose not to cast, leaving the previous, incorrect type
614+
inlined.cast(target)
615+
else
616+
inlined.ensureConforms(target)
582617
// Make sure that the sealing with the declared type
583618
// is type correct. Without it we might get problems since the
584619
// expression's type is the opaque alias but the call's type is

compiler/src/dotty/tools/dotc/typer/Typer.scala

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ object Typer {
109109
*/
110110
private[typer] val HiddenSearchFailure = new Property.Key[List[SearchFailure]]
111111

112+
113+
/** An attachment on a Typed node. Indicates that the Typed node was synthetically
114+
* inserted by the Typer phase. We might want to remove it for the purpose of inlining,
115+
* but only if it was not manually inserted by the user.
116+
*/
117+
private[typer] val InsertedTyped = new Property.Key[Unit]
118+
112119
/** Is tree a compiler-generated `.apply` node that refers to the
113120
* apply of a function class?
114121
*/
@@ -3029,7 +3036,10 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
30293036
val rhs1 = excludeDeferredGiven(ddef.rhs, sym): rhs =>
30303037
PrepareInlineable.dropInlineIfError(sym,
30313038
if sym.isScala2Macro then typedScala2MacroBody(rhs)(using rhsCtx)
3032-
else typedExpr(rhs, tpt1.tpe.widenExpr)(using rhsCtx))
3039+
else
3040+
typedExpr(rhs, tpt1.tpe.widenExpr)(using rhsCtx)) match
3041+
case typed @ Typed(outer, _) if typed.hasAttachment(InsertedTyped) => outer
3042+
case other => other
30333043

30343044
if sym.isInlineMethod then
30353045
if StagingLevel.level > 0 then
@@ -4694,7 +4704,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
46944704
insertGadtCast(tree, wtp, pt)
46954705
case CompareResult.OKwithOpaquesUsed if !tree.tpe.frozen_<:<(pt)(using ctx.withOwner(defn.RootClass)) =>
46964706
// guard to avoid extra Typed trees, eg. from testSubType(O.T, O.T) which returns OKwithOpaquesUsed
4697-
Typed(tree, TypeTree(pt))
4707+
Typed(tree, TypeTree(pt)).withAttachment(InsertedTyped, ())
46984708
case _ =>
46994709
//typr.println(i"OK ${tree.tpe}\n${TypeComparer.explained(_.isSubType(tree.tpe, pt))}") // uncomment for unexpected successes
47004710
tree

docs/_docs/reference/other-new-features/opaques-details.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,45 @@ object obj:
109109
```
110110
The opaque type alias `A` is transparent in its scope, which includes the definition of `x`, but not the definitions of `obj` and `y`.
111111

112+
## Opaque Types in Transparent Inline Methods
113+
114+
Additional care is required if an opaque type is returned from a transparent inline method, located inside a context where that opaque type is defined.
115+
Since the typechecking and type inference of the body of the method is done from the perspective of that context, the returned types might contain dealiased opaque types. Generally, this means that calls to those transparent methods will return a `DECLARED & ACTUAL`, where `DECLARED` is the return type defined in the method declaration, and `ACTUAL` is the type returned after the inlining, which might include dealiased opaque types.
116+
117+
API designers can ensure that the correct type is returned by explicitly annotating it inside of the method body with `: ExpectedType` or by explicitly passing type parameters to the method being returned. Explicitly annotating like this will help for the outermost transparent inline method calls, but will not affect the nested calls, as, from the perspective of the new context into which we are inlining, those might still have to be dealiased to avoid compilation errors:
118+
119+
```scala
120+
object Time:
121+
opaque type Time = String
122+
opaque type Seconds <: Time = String
123+
124+
// opaque type aliases have to be dealiased in nested calls,
125+
// otherwise the resulting program might not be typed correctly
126+
// in the below methods this will be typed as Seconds & String despite
127+
// the explicit type declaration
128+
transparent inline def sec(n: Double): Seconds =
129+
s"${n}s": Seconds
130+
131+
transparent inline def testInference(): List[Time] =
132+
List(sec(5)) // infers List[String] and returns List[Time] & List[String], not List[Seconds]
133+
transparent inline def testGuarded(): List[Time] =
134+
List(sec(5)): List[Seconds] // returns List[Seconds]
135+
transparent inline def testExplicitTime(): List[Time] =
136+
List[Seconds](sec(5)) // returns List[Seconds]
137+
transparent inline def testExplicitString(): List[Time] =
138+
List[String](sec(5)) // returns List[Time] & List[String]
139+
140+
end Time
141+
142+
@main def main() =
143+
val t1: List[String] = Time.testInference() // returns List[Time.Time] & List[String]
144+
val t2: List[Time.Seconds] = Time.testGuarded() // returns List[Time.Seconds]
145+
val t3: List[Time.Seconds] = Time.testExplicitTime() // returns List[Time.Seconds]
146+
val t4: List[String] = Time.testExplicitString() // returns List[Time.Time] & List[String]
147+
```
148+
149+
Be careful especially if what is being inlined depends on the type of those nested transparent calls.
150+
```
112151
113152
## Relationship to SIP 35
114153

tests/neg/i13461-a.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package i13461:
2+
3+
opaque type Opaque = Int
4+
transparent inline def op: Opaque = (123: Opaque)
5+
6+
object Main:
7+
def main(args: Array[String]): Unit =
8+
val o22: 123 = op // error
9+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import scala.quoted.*
2+
opaque type MyOpaque = Int
3+
object MyOpaque:
4+
val one: MyOpaque = 1
5+
transparent inline def apply(): MyOpaque = ${ applyMacro }
6+
private def applyMacro(using Quotes): Expr[MyOpaque] =
7+
import quotes.reflect.*
8+
'{ one }
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
trait Leak[T]:
2+
type Out
3+
given [T]: Leak[T] with
4+
type Out = T
5+
extension [T](t: T)(using l: Leak[T]) def leak: l.Out = ???
6+
7+
val x = MyOpaque().leak
8+
val shouldWork = summon[x.type <:< MyOpaque]

tests/pos/i13461-a.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package i13461:
2+
3+
opaque type Opaque = Int
4+
transparent inline def op: Opaque = 123
5+
transparent inline def oop: i13461.Opaque = 123
6+
7+
object Main:
8+
def main(args: Array[String]): Unit =
9+
val o2: Opaque = op
10+
val o3: Opaque = oop // needs to be unwrapped from Typed generated in adapt
11+
val o22: 123 = op
12+
val o23: 123 = oop

tests/pos/i13461-c.scala

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
object Time:
2+
opaque type Time = String
3+
opaque type Seconds <: Time = String
4+
5+
transparent inline def sec(n: Double): Seconds =
6+
s"${n}s": Seconds // opaque type aliases have to be dealiased in nested calls, otherwise the resulting program might not be typed correctly
7+
8+
transparent inline def testInference(): List[Time] =
9+
List(sec(5)) // infers List[String] and returns List[Time] & List[String], not List[Seconds]
10+
transparent inline def testGuarded(): List[Time] =
11+
List(sec(5)): List[Seconds] // returns List[Seconds]
12+
transparent inline def testExplicitTime(): List[Time] =
13+
List[Seconds](sec(5)) // returns List[Seconds]
14+
transparent inline def testExplicitString(): List[Time] =
15+
List[String](sec(5)) // returns List[Time] & List[String]
16+
17+
end Time
18+
19+
@main def main() =
20+
val t1: List[String] = Time.testInference() // returns List[Time.Time] & List[String]
21+
val t2: List[Time.Seconds] = Time.testGuarded() // returns List[Time.Seconds]
22+
val t3: List[Time.Seconds] = Time.testExplicitTime() // returns List[Time.Seconds]
23+
val t4: List[String] = Time.testExplicitString() // returns List[Time.Time] & List[String]

tests/run/i13461-b.check

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
35s
2+
35m
3+
15s, 20m, 15m, 20s
4+
15s, 15m, 15s, 20m

0 commit comments

Comments
 (0)