Skip to content

Commit

Permalink
Allow reach capabilities from within a nested closure
Browse files Browse the repository at this point in the history
  • Loading branch information
odersky committed Oct 31, 2024
1 parent f54d0fa commit 1a781b2
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 10 deletions.
32 changes: 22 additions & 10 deletions compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ object CheckCaptures:
owner: Symbol,
kind: EnvKind,
captured: CaptureSet,
outer0: Env | Null):
outer0: Env | Null,
nestedClosure: Symbol = NoSymbol):

def outer = outer0.nn

Expand Down Expand Up @@ -395,16 +396,18 @@ class CheckCaptures extends Recheck, SymTransformer:
else
!sym.isContainedIn(env.owner)

def checkUseDeclared(c: CaptureRef, env: Env) =
c.pathRoot match
def checkUseDeclared(c: CaptureRef, env: Env, lastEnv: Env | Null) =
if lastEnv != null && env.nestedClosure.exists && env.nestedClosure == lastEnv.owner then
() // access is from a nested closure, so it's OK
else c.pathRoot match
case ref: NamedType if !ref.symbol.hasAnnotation(defn.UseAnnot) =>
val what = if ref.isType then "Capture set parameter" else "Local reach capability"
report.error(
em"""$what $c leaks into capture scope of ${env.ownerString}.
|To allow this, the ${ref.symbol} should be declared with a @use annotation""", pos)
case _ =>

def recur(cs: CaptureSet, env: Env)(using Context): Unit =
def recur(cs: CaptureSet, env: Env, lastEnv: Env | Null)(using Context): Unit =
if env.isOpen && !env.owner.isStaticOwner && !cs.isAlwaysEmpty then
// Only captured references that are visible from the environment
// should be included.
Expand All @@ -418,7 +421,7 @@ class CheckCaptures extends Recheck, SymTransformer:
c match
case ReachCapability(c1) =>
if c1.isParamPath then
checkUseDeclared(c, env)
checkUseDeclared(c, env, lastEnv)
else
// When a reach capabilty x* where `x` is not a parameter goes out
// of scope, we need to continue with `x`'s underlying deep capture set.
Expand All @@ -433,16 +436,16 @@ class CheckCaptures extends Recheck, SymTransformer:
capt.println(i"Widen reach $c to $underlying in ${env.owner}")
underlying.disallowRootCapability: () =>
report.error(em"Local reach capability $c leaks into capture scope of ${env.ownerString}", pos)
recur(underlying, env)
recur(underlying, env, lastEnv)
case c: TypeRef if c.isParamPath =>
checkUseDeclared(c, env)
checkUseDeclared(c, env, lastEnv)
case _ =>
isVisible
checkSubset(included, env.captured, pos, provenance(env))
capt.println(i"Include call or box capture $included from $cs in ${env.owner} --> ${env.captured}")
if !isOfNestedMethod(env) then
recur(included, nextEnvToCharge(env, !_.owner.isStaticOwner))
recur(cs, curEnv)
recur(included, nextEnvToCharge(env, !_.owner.isStaticOwner), env)
recur(cs, curEnv, null)
end markFree

/** Include references captured by the called method in the current environment stack */
Expand Down Expand Up @@ -838,10 +841,19 @@ class CheckCaptures extends Recheck, SymTransformer:
override def recheckDefDef(tree: DefDef, sym: Symbol)(using Context): Type =
if Synthetics.isExcluded(sym) then sym.info
else
// If rhs ends in a closure or anonymous class, the corresponding symbol
def nestedClosure(rhs: Tree)(using Context): Symbol = rhs match
case Closure(_, meth, _) => meth.symbol
case Apply(fn, _) if fn.symbol.isConstructor && fn.symbol.owner.isAnonymousClass => fn.symbol.owner
case Block(_, expr) => nestedClosure(expr)
case Inlined(_, _, expansion) => nestedClosure(expansion)
case Typed(expr, _) => nestedClosure(expr)
case _ => NoSymbol

val saved = curEnv
val localSet = capturedVars(sym)
if !localSet.isAlwaysEmpty then
curEnv = Env(sym, EnvKind.Regular, localSet, curEnv)
curEnv = Env(sym, EnvKind.Regular, localSet, curEnv, nestedClosure(tree.rhs))

// ctx with AssumedContains entries for each Contains parameter
val bodyCtx =
Expand Down
71 changes: 71 additions & 0 deletions docs/_docs/internals/cc/use-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@

Possible design:

1. Have @use annotation on type parameters and value parameters of regular methods
(not anonymous functions).
2. In markFree, keep track whether a capture set variable or reach capability
is used directly in the method where it is defined, or in a nested context
(either unbound nested closure or unbound anonymous class).
3. Disallow charging a reach capability `xs*` to the environment of the method where
`xs` is a parameter unless `xs` is declared `@use`.
4. Analogously, disallow charging a capture set variable `C^` to the environment of the method where `C^` is a parameter unless `C^` is declared `@use`.
5. When passing an argument to a `@use`d term parameter, charge the `dcs` of the argument type to the environments via markFree.
6. When instantiating a `@use`d type parameter, charge the capture set of the argument
to the environments via markFree.

It follows that we cannot refer to methods with @use term parameters as values. Indeed,
their eta expansion would produce an anonymous function that includes a reach capability of
its parameter in its use set, violating (3).

Example:

```scala
def runOps(@use ops: List[() => Unit]): Unit = ops.foreach(_())
```
Then `runOps` expands to
```scala
(xs: List[() => Unit]) => runOps(xs)
```
Note that `xs` does not carry a `@use` since this is disallowed by (1) for anonymous functions. By (5), we charge the deep capture set of `xs`, which is `xs*` to the environment. By (3), this is actually disallowed.

Now, if we express this with explicit capture set parameters we get:
```scala
def runOpsPoly[@use C^](ops: List[() ->{C^} Unit]): Unit = ops.foreach[C^](_())
```
Then `runOpsPoly` expands to `runOpsPoly[cs]` for some inferred capture set `cs`. And this expands to:
```scala
(xs: List[() ->{cs} Unit]) => runOpsPoly[cs](xs)
```
Since `cs` is passed to the `@use` parameter of `runOpsPoly` it is charged
to the environment of the function body, so the type of the previous expression is
```scala
List[() ->{cs} Unit]) ->{cs} Unit
```

We can also use explicit capture set parameters to eta expand the first `runOps` manually:

```scala
[C^] => (xs: List[() ->{C^} Unit]) => runOps(xs)
: [C^] -> List[() ->{C^} Unit] ->[C^] Unit
```
Except that this currently runs afoul of the implementation restriction that polymorphic functions cannot wrap capturing functions. But that's a restriction we need to lift anyway.

## `@use` inference

- `@use` is implied for a term parameter `x` of a method if `x`'s type contains a boxed cap and `x` or `x*` is not referred to in the result type of the method.

- `@use` is implied for a capture set parameter `C` of a method if `C` is not referred to in the result type of the method.

If `@use` is implied, one can override to no use by giving an explicit use annotation
`@use(false)` instead. Example:
```scala
def f(@use(false) xs: List[() => Unit]): Int = xs.length
```

This works since `@use` is defined like this:
```scala
class use(cond: Boolean = true) extends StaticAnnotation
```



30 changes: 30 additions & 0 deletions tests/neg-custom-args/captures/method-uses.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
def test(xs: List[() => Unit]) =
xs.head // error

def foo =
xs.head // ok
def bar() =
xs.head // ok

class Foo:
println(xs.head) // error, but could be OK

foo // error
bar() // error
Foo() // OK, but could be error

def test2(xs: List[() => Unit]) =
def foo = xs.head // ok
()

def test3(xs: List[() => Unit]): () ->{xs*} Unit = () =>
println(xs.head) // ok

def test4(xs: List[() => Unit]) = () => xs.head // ok

def test5(xs: List[() => Unit]) = new:
println(xs.head) // ok

def test6(xs: List[() => Unit]) =
val x= new { println(xs.head) } // error
x

0 comments on commit 1a781b2

Please sign in to comment.