Skip to content

Commit da87716

Browse files
committed
Pass over scoped-caps
1 parent b07e36c commit da87716

File tree

1 file changed

+43
-62
lines changed

1 file changed

+43
-62
lines changed

docs/_docs/reference/experimental/capture-checking/scoped-caps.md

Lines changed: 43 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/capture-che
88

99
When discussing escape checking, we referred to a scoping discipline. That is, capture sets can contain only capabilities that are visible at the point where the set is defined. But that raises the question: where is a universal capability `cap` defined? In fact, what is written as the top type `cap` can mean different capabilities, depending on scope. Usually a `cap` refers to a universal capability defined in the scope where the `cap` appears.
1010

11+
A useful mental model is to think of `cap` as a "container" that can _absorb_ concrete capabilities. When you write `T^` (shorthand for `T^{cap}`), you're saying "this value may capture some capabilities that will flow into this `cap`." Different `cap` instances in different scopes are different containers: a capability that flows into one doesn't automatically flow into another.
12+
1113
### Existential Binding
1214

1315
Special rules apply to `cap`s in method and function parameters and results. For example, take this method:
@@ -16,7 +18,7 @@ Special rules apply to `cap`s in method and function parameters and results. For
1618
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
1719
```
1820

19-
This creates a `Logger` that captures `fs`. We could have been more specific in specifying `Logger^{fs}` as the return type, but the current definition is also valid, and might be preferable if we want to hide details of what the returned logger captures. If we write it as above then certainly the implied `cap` in the return type should be able to subsume the capability `fs`. This means that this `cap` has to be defined in a scope in which `fs` is visible.
21+
This creates a `Logger` that captures `fs`. We could have been more specific in specifying `Logger^{fs}` as the return type, but the current definition is also valid, and might be preferable if we want to hide details of what the returned logger captures. If we write it as above then certainly the implied `cap` in the return type should be able to absorb the capability `fs`. This means that this `cap` has to be defined in a scope in which `fs` is visible.
2022

2123
In logic, the usual way to achieve this scoping is with an existential binder. We can express the type of `makeLogger` like this:
2224
```scala
@@ -32,15 +34,15 @@ There's a connection with [capture polymorphism](polymorphism.md) here. `cap`s i
3234

3335
### Function Types
3436

35-
The conventions for method types carry over to function types. A function type
37+
The conventions for method types carry over to function types. A dependent function type
3638
```scala
3739
(x: T) -> U^
3840
```
3941
is interpreted as having an existentially bound `cap` in the result, like this:
4042
```scala
4143
(x: T) -> cap.U^{cap}
4244
```
43-
The same rules hold for the other kinds of function arrows, `=>`, `?->`, and `?=>`. So `cap` can in this case subsume the function parameter `x` since it is locally bound in the function result.
45+
The same rules hold for the other kinds of function arrows, `=>`, `?->`, and `?=>`. So `cap` can in this case absorb the function parameter `x` since `x` is locally bound in the function result.
4446

4547
However, the expansion of `cap` into an existentially bound variable only applies to functions that use the dependent function style syntax, with explicitly named parameters. Parametric functions such as `A => B^` or `(A₁, ..., Aₖ) -> B^` don't bind their result cap in an existential quantifier. For instance, the function
4648
```scala
@@ -54,10 +56,11 @@ In other words, existential quantifiers are only inserted in results of function
5456

5557
**Examples:**
5658

57-
- `A => B` is an alias type that expands to `A ->{cap} B`, therefore
58-
`(x: T) -> A => B` expands to `(x: T) -> ∃cap.(A ->{cap} B)`.
59+
- `A => B` is an alias type that expands to `A ->{cap} B`.
60+
- Therefore
61+
`(x: T) -> A => B` expands to `(x: T) -> ∃c.(A ->{c} B)`.
5962

60-
- `(x: T) -> Iterator[A => B]` expands to `(x: T) -> ∃cap.Iterator[A ->{cap} B]`
63+
- `(x: T) -> Iterator[A => B]` expands to `(x: T) -> ∃c.Iterator[A ->{c} B]`
6164

6265
To summarize:
6366

@@ -69,35 +72,10 @@ To summarize:
6972
- Occurrences of `cap` elsewhere are not translated. They can be seen as representing an existential in the
7073
scope of the definition in which they appear.
7174

72-
### Fresh Capabilities vs Result Capabilities
73-
74-
Internally, the compiler represents scoped `cap` instances using two different mechanisms:
75-
76-
- **Fresh capabilities** are used for most `cap` instances. They track a _hidden set_ of concrete capabilities they subsume. When you pass a `FileSystem^` to a function expecting `T^`, the fresh capability in the parameter learns that it subsumes your specific `FileSystem`. Fresh capabilities participate in subcapturing: if `{fs} <: {cap}`, the fresh capability records `fs` in its hidden set.
77-
78-
- **Result capabilities** are used for `cap` in dependent function results. They are _rigid_—they don't accumulate a hidden set. Instead, two result capabilities can only be related through _unification_, which makes them equivalent. This prevents the result's capture set from being "polluted" by unrelated capabilities.
79-
80-
The distinction matters when checking function subtyping:
81-
82-
```scala
83-
val f: (x: FileSystem^) -> Logger^ = ???
84-
val g: (x: FileSystem^) -> Logger^{x} = f // OK
85-
```
86-
87-
Here, the result `cap` in `f`'s type is a result capability. When checking if `f` can be assigned to `g`, the checker unifies `f`'s result capability with `{x}`. This works because unification is symmetric—we're just saying "these represent the same capability."
88-
89-
In contrast:
90-
91-
```scala
92-
val leaky: Logger^ = ???
93-
val f: (x: FileSystem^) -> Logger^ = x => leaky // Error
94-
```
95-
96-
This fails because `leaky`'s capture set cannot flow into the result capability—result capabilities don't accept arbitrary capabilities through subsumption, only through unification with other result capabilities tied to the same function.
97-
9875
## Levels and Escape Prevention
9976

100-
Each capability has a _level_ corresponding to where it was defined. A capability can only be captured by scopes at the same level or nested more deeply.
77+
Each capability has a _level_ corresponding to where it was defined. The level determines where a capability can flow: it can flow into `cap`s at the same level or more deeply nested, but not outward to enclosing scopes. Later sections on [capability classifiers](classifiers.md) will add a controlled
78+
escape mechanism.
10179

10280
### How Levels Are Computed
10381

@@ -115,7 +93,7 @@ def outer(c1: Cap^) = // level: outer
11593
def inner(c2: Cap^) = // level: inner
11694
val y = 2 // level: inner
11795
val f = () => c2.use()
118-
ref.set(f) // Error: cap2 would escape its level
96+
ref.set(f) // Error: c2 would escape its level
11997

12098
class Local: // level: Local
12199
def method(c3: Cap^) = // level: method
@@ -129,12 +107,12 @@ Local values like `x`, `y`, and `z` don't define their own levels. They inherit
129107

130108
### The Level Check
131109

132-
A capability can flow into a capture set only if the capture set's scope is _contained in_ the capability's level owner. In the example above, `ref.set(f)` fails because:
133-
- `ref`'s type parameter was instantiated at `outer`'s level
134-
- `f` captures `cap2`, which is at `inner`'s level
135-
- `outer` is not contained in `inner`, so `cap2` cannot flow into `ref`
110+
A capability can flow into a `cap` only if that `cap`'s scope is _contained in_ the capability's level owner. In the example above, `ref.set(f)` fails because:
111+
- `ref`'s type parameter has a `cap` that was instantiated at `outer`'s level
112+
- `f` captures `c2`, which is at `inner`'s level
113+
- `outer` is not contained in `inner`, so `c2` cannot flow into `ref`'s `cap`
136114

137-
This ensures capabilities can only flow "inward" to more nested scopes, never "outward" to enclosing ones.
115+
This ensures capabilities flow "inward" to more nested scopes, never "outward" to enclosing ones.
138116

139117
### Comparison with Rust Lifetimes
140118

@@ -149,10 +127,13 @@ fn bad<'a>() -> &'a i32 {
149127
```
150128

151129
```scala
152-
// Scala CC: rejected because cap would escape its level
153-
def bad(): () -> Unit =
154-
val cap = CC()
155-
() => cap.use()
130+
// Scala CC: rejected because c escapes inner's level to outer's level
131+
def outer() =
132+
var escape: () => Unit = () => ()
133+
def inner(c: Cap^) =
134+
escape = () => c.use() // Error: c at inner's level cannot escape to outer
135+
inner(Cap())
136+
escape
156137
```
157138

158139
The key analogies are:
@@ -162,52 +143,52 @@ The key analogies are:
162143

163144
The key differences are:
164145
- **What's tracked**: Rust tracks memory validity (preventing dangling pointers). Scala CC tracks capability usage (preventing unauthorized effects).
165-
- **Explicit vs. implicit**: Rust lifetimes are often written explicitly (`&'a T`). Scala CC levels are computed automatically from the program structure.
166-
- **Granularity**: Rust lifetimes can distinguish different fields of a struct. Scala CC levels are coarser, tied to method and class boundaries.
146+
- **Explicit vs. implicit**: Rust lifetimes are often written explicitly (`&'a T`). Scala capture checking levels are computed automatically from the program structure.
167147

168148
## Charging Captures to Enclosing Scopes
169149

170-
When a capability is used, the capture checker must verify that all enclosing scopes properly account for it. This process is called _charging_ the capability to the environment.
150+
When a capability is used, it must flow into the `cap`s of all enclosing scopes. This process is
151+
called _charging_ the capability to the environment.
171152

172153
```scala
173-
def outer(cap1: FileSystem^): Unit =
174-
def inner(): () ->{cap1} Unit =
175-
() => cap1.read() // cap1 is used here
154+
def outer(fs: FileSystem^): Unit =
155+
def inner(): () ->{fs} Unit =
156+
() => fs.read() // cap1 is used here
176157
inner()
177158
```
178159

179-
When the capture checker sees `cap1.read()`, it verifies that:
180-
1. The immediately enclosing closure `() => cap1.read()` declares `cap1` in its capture set
181-
2. The enclosing method `inner` accounts for `cap1` (it does, via its result type)
182-
3. The enclosing method `outer` accounts for `cap1` (it does, via its parameter)
160+
When the capture checker sees `fs.read()`, it verifies that `fs` can flow into each enclosing scope:
161+
1. The immediately enclosing closure `() => fs.read()` must have `fs` in its capture set
162+
2. The enclosing method `inner` must account for `fs` (it does, via its result type)
163+
3. The enclosing method `outer` must account for `fs` (it does, via its parameter)
183164

184-
At each level, the checker verifies that the used capabilities are a _subcapture_ of what the scope declares:
165+
If any scope refuses to absorb the capability, capture checking fails:
185166

186167
```scala
187168
def process(fs: FileSystem^): Unit =
188-
val f: () -> Unit = () => fs.read() // Error: {fs} is not a subset of {}
169+
val f: () -> Unit = () => fs.read() // Error: fs cannot flow into {}
189170
```
190171

191-
The closure is declared pure (`() -> Unit`), but uses `fs`. Since `{fs}` is not a subset of the empty capture set, capture checking fails.
172+
The closure is declared pure (`() -> Unit`), meaning its `cap` is the empty set. The capability `fs` cannot flow into an empty set, so this is rejected.
192173

193174
## Visibility and Widening
194175

195-
As capabilities are charged to outer scopes, they are _filtered_ to include only those visible at each level. When a local capability cannot appear in a type (because it's not visible), the capture set is _widened_ to the smallest visible superset:
176+
When capabilities flow outward to enclosing scopes, they must remain visible. A local capability cannot appear in a type outside its defining scope. In such cases, the capture set is _widened_ to the smallest visible superset:
196177

197178
```scala
198-
def test(fs: FileSystem^): Logger^ =
179+
def test(fs: FileSystem^): Logger^{cap} =
199180
val localLogger = Logger(fs)
200-
localLogger // Type is widened from Logger^{localLogger} to Logger^{fs}
181+
localLogger // Type widens from Logger^{localLogger} to Logger^{fs}
201182
```
202183

203-
Here, `localLogger` cannot appear in the result type because it's a local variable. The capture set `{localLogger}` is widened to `{fs}`, which covers it (since `localLogger` captures `fs`) and is visible outside `test`.
184+
Here, `localLogger` cannot appear in the result type because it's a local variable. The capture set `{localLogger}` widens to `{fs}`, which covers it (since `localLogger` captures `fs`) and is visible outside `test`. In effect, `fs` flows into the result's `cap` instead of `localLogger`.
204185

205-
However, widening cannot always succeed:
186+
However, widening cannot always find a valid target:
206187

207188
```scala
208189
def test(fs: FileSystem^): () -> Unit =
209190
val localLogger = Logger(fs)
210191
() => localLogger.log("hello") // Error: cannot widen to empty set
211192
```
212193

213-
The closure's capture set `{localLogger}` would need to be widened to fit the return type `() -> Unit`, but there's no visible capability that covers `localLogger` and fits in the empty set. This is an error.
194+
The closure captures `localLogger`, but the return type `() -> Unit` has an empty capture set. There's no visible capability that covers `localLogger` and can flow into an empty set, so the checker rejects this code.

0 commit comments

Comments
 (0)