Skip to content

Commit e8699e3

Browse files
committed
Update scoped-caps
1 parent 4d812df commit e8699e3

File tree

1 file changed

+149
-64
lines changed

1 file changed

+149
-64
lines changed

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

Lines changed: 149 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,63 @@ nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/capture-che
66

77
## Scoped Universal Capabilities
88

9-
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 a universal capability `cap` is 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.
9+
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.
10+
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. We will further expand on this idea later when discussing [separation checking](separation-checking.md).
12+
13+
### Existential Binding
1014

1115
Special rules apply to `cap`s in method and function parameters and results. For example, take this method:
1216

1317
```scala
14-
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
18+
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
1519
```
16-
This creates a `Logger` that captures `fs`.
17-
We could have been more specific in specifying `Logger^{fs}` as the return type of makeLogger, but the current definition is also valid, and might be preferable if we want to hide details what the returned logger captures. If we write it as above then certainly the implied `cap` in the return type of `Logger` should be able subsume the capability `fs`. This means that this `cap` has to be defined in a scope in which
18-
`fs` is visible.
20+
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.
1922

2023
In logic, the usual way to achieve this scoping is with an existential binder. We can express the type of `makeLogger` like this:
2124
```scala
2225
makeLogger: (fs: cap₁.FileSystem^{cap₁}): cap₂. Logger^{cap₂}
2326
```
24-
In words: `makeLogger` takes a parameter `fs` of type `Filesystem` capturing _some_ universal capability `cap` and returns a `Logger` capturing some other (possibly different) universal `cap`.
27+
In words: `makeLogger` takes a parameter `fs` of type `Filesystem` capturing _some_ universal capability `cap` and returns a `Logger` capturing some other (possibly different) universal `cap`.
2528

26-
We can also turn the existential in the function parameter to a universal "forall"
27-
in the function itself. In that alternative notation, the type of makeLogger would read like this:
29+
We can also turn the existential in the function parameter to a universal "forall" in the function itself. In that alternative notation, the type of makeLogger would read like this:
2830
```scala
2931
makeLogger: cap₁.(fs: FileSystem^{cap₁}): cap₂. Logger^{cap₂}
3032
```
31-
There's a connection with [capture polymorphism](polymorphism.md) here. `cap`s in function parameters behave like additional
32-
capture parameters that can be instantiated at the call site to arbitrary capabilities.
33+
There's a connection with [capture polymorphism](polymorphism.md) here. `cap`s in function parameters behave like additional capture parameters that can be instantiated at the call site to arbitrary capabilities.
3334

34-
The conventions for method types carry over to function types. A function type
35+
### Function Types
36+
37+
The conventions for method types carry over to function types. A dependent function type
3538
```scala
36-
(x: T) -> U^
39+
(x: T) -> U^
3740
```
38-
is interpreted as having an existentially bound `cap` in the result, like this
41+
is interpreted as having an existentially bound `cap` in the result, like this:
3942
```scala
40-
(x: T) -> cap.U^{cap}
43+
(x: T) -> cap.U^{cap}
4144
```
42-
The same rules hold for the other kinds of function arrows, `=>`, `?->`, and `?=>`. So `cap` can in this case
43-
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

45-
However, the expansion of `cap` into an existentially bound variable only applies to functions that use
46-
the dependent function style syntax, with explicitly named parameters. Parametric functions such as
47-
`A => B^` or `(A₍, ..., Aₖ) -> B^` don't bind their result cap in an existential quantifier.
48-
For instance, the function
47+
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
48+
`(A₁, ..., Aₖ) -> B^` don't bind the `cap` in their return types in an existential quantifier. For instance, the function
4949
```scala
50-
(x: A) -> B -> C^
50+
(x: A) -> B -> C^
5151
```
5252
is interpreted as
5353
```scala
54-
(x: A) -> cap.B -> C^{cap}
54+
(x: A) -> cap.B -> C^{cap}
5555
```
5656
In other words, existential quantifiers are only inserted in results of function arrows that follow an explicitly named parameter list.
5757

58+
**Examples:**
59+
60+
- `A => B` is an alias type that expands to `A ->{cap} B`.
61+
- Therefore
62+
`(x: T) -> A => B` expands to `(x: T) -> ∃c.(A ->{c} B)`.
63+
64+
- `(x: T) -> Iterator[A => B]` expands to `(x: T) -> ∃c.Iterator[A ->{c} B]`.
65+
5866
To summarize:
5967

6068
- If a function result type follows a named parameter list and contains covariant occurrences of `cap`,
@@ -65,58 +73,135 @@ To summarize:
6573
- Occurrences of `cap` elsewhere are not translated. They can be seen as representing an existential in the
6674
scope of the definition in which they appear.
6775

68-
**Examples:**
76+
## Levels and Escape Prevention
77+
78+
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 (which would mean a capability lives longer than its lexical lifetime). Later sections on [capability classifiers](classifiers.md) will add a controlled mechanism that permits escaping/flowing outward for situations
79+
where this would be desirable.
80+
81+
### How Levels Are Computed
82+
83+
A capability's level is determined by its _level owner_, which the compiler computes by walking up the ownership chain until reaching a symbol that represents a level boundary. Level boundaries are:
84+
- **Classes** (but not inner non-static module classes)
85+
- **Methods** (but not accessors or constructors)
86+
87+
Consider this example:
88+
89+
```scala
90+
def outer(c1: Cap^) = // level: outer
91+
val x = 1 // level: outer (vals don't create levels)
92+
var ref: () => Unit = () => ()
93+
94+
def inner(c2: Cap^) = // level: inner
95+
val y = 2 // level: inner
96+
val f = () => c2.use()
97+
ref = f // Error: c2 would escape its level
98+
99+
class Local: // level: Local
100+
def method(c3: Cap^) = // level: method
101+
val z = c3 // level: method
102+
```
103+
104+
Local values like `x`, `y`, and `z` don't define their own levels. They inherit the level of their enclosing method or class. This means:
105+
- `c1` and `ref` are both at `outer`'s level
106+
- `c2` and `f` are both at `inner`'s level
107+
- `c3` and `z` are both at `method`'s level
108+
109+
### The Level Check
110+
111+
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:
112+
- `ref`'s type parameter has a `cap` that was instantiated at `outer`'s level
113+
- `f` captures `c2`, which is at `inner`'s level
114+
- `outer` is not contained in `inner`, so `c2` cannot flow into `ref`'s `cap`
69115

70-
- `A => B` is an alias type that expands to `A ->{cap} B`, therefore
71-
`(x: T) -> A => B` expands to `(x: T) -> ∃cap.(A ->{cap} B)`.
116+
This ensures capabilities flow "inward" to more nested scopes, never "outward" to enclosing ones.
72117

73-
- `(x: T) -> Iterator[A => B]` expands to `() -> ∃cap.Iterator[A ->{cap} B]`
74-
<!--
75-
- If we define `type Fun[T] = (y: B) -> T`, then `(x: A) -> Fun[C^]` expands to
76-
`(y: B) -> ∃cap. Fun[C^{cap}]`, which dealiases to `(x: A) -> ∃cap.(y: B) -> C^{cap}`.
77-
This demonstrates how aliases can be used to force existential binders to be in some specific outer scope.
118+
### Comparison with Rust Lifetimes
78119

79-
**Typing Rules:**
120+
Readers familiar with Rust may notice similarities to lifetime checking. Both systems prevent
121+
references from escaping their valid scope. In Rust, a reference type `&'a T` carries an explicit
122+
lifetime parameter `'a`. In Scala's capture checking, the lifetime is folded into the capability
123+
name itself: `T^{x}` says "a `T` capturing `x`," and `x`'s level implicitly determines how long this
124+
reference is valid. A capture set then acts as an upper bound on the lifetimes of all the
125+
capabilities it contains.
80126

81-
- When we typecheck the body of a method, any covariant occurrences of `cap` in the result type are bound with a fresh existential.
82-
- Conversely, when we typecheck the application of a function or method,
83-
with an existential result type `Exists ex.T`, the result of the application is `T` where every occurrence of the existentially bound
84-
variable `ex` is replaced by `cap`.
85-
-->
127+
Consider a `withFile` pattern that ensures a file handle doesn't escape:
86128

87-
<!--
88-
## Reach Capabilities
129+
```rust
130+
struct File;
131+
impl File { fn open(_path: &str) -> Option<File> { Some(File) } }
132+
133+
// Rust: the closure receives a reference bounded by 'a
134+
fn with_file<R>(path: &str, f: impl for<'a> FnOnce(&'a File) -> R) -> R {
135+
let file = File::open(path).unwrap();
136+
f(&file)
137+
}
138+
139+
fn main() {
140+
let f = File;
141+
let mut escaped: &File = &f;
142+
with_file("test.txt", |file| {
143+
escaped = file; // Error: borrowed value does not live long enough
144+
});
145+
}
146+
```
147+
148+
```scala
149+
// Scala CC: the closure receives a capability whose level prevents escape
150+
def withFile[R](path: String)(f: File^ => R): R =
151+
val file = File.open(path)
152+
f(file)
153+
154+
def main() =
155+
var escaped: File^
156+
withFile("test.txt"): file =>
157+
escaped = file // Error: file's level cannot escape to main's level
158+
```
159+
160+
In both cases, the type system prevents the handle from escaping the callback. Rust achieves this by requiring `'a` to be contained within the closure's scope. Scala achieves it by checking that `file`'s level (tied to `withFile`) cannot flow into `escaped`'s level (at `main`).
161+
162+
The key analogies are:
163+
- **Capability name ≈ Lifetime parameter**: Where Rust writes `&'a T`, Scala writes `T^{x}`. The capability `x` carries its lifetime implicitly via its level.
164+
- **Capture set ≈ Lifetime bound**: A capture set `{x, y}` bounds the lifetime of a value to be no longer than the shortest-lived capability it contains.
165+
- **Level containment ≈ Outlives**: Rust's `'a: 'b` (a outlives b) corresponds to Scala's level check (inner scopes are contained in outer ones).
166+
167+
The key differences are:
168+
- **What's tracked**: Rust tracks memory validity (preventing dangling pointers). Scala CC tracks capability usage (preventing unauthorized effects).
169+
- **Explicit vs. implicit**: Rust lifetimes are explicit parameters (`&'a T`). Scala levels are computed automatically from program structure: you name the capability, not the lifetime.
170+
171+
## Charging Captures to Enclosing Scopes
172+
173+
When a capability is used, it must be checked for compatibility with the capture-set constraints of
174+
all enclosing scopes. This process is called _charging_ the capability to the environment.
89175

90-
Say you have a method `f` that takes an impure function argument which gets stored in a `var`:
91176
```scala
92-
def f(op: A => B)
93-
var x: A ->{op} B = op
94-
...
177+
def outer(fs: FileSystem^): Unit =
178+
def inner(): () ->{fs} Unit =
179+
() => fs.read() // fs is used here
180+
inner()
95181
```
96-
This is legal even though `var`s cannot have types with `cap` or existential capabilities. The trick is that the type of the variable `x`
97-
is not `A => B` (this would be rejected), but is the "narrowed" type
98-
`A ->{op} B`. In other words, all capabilities retained by values of `x`
99-
are all also referred to by `op`, which justifies the replacement of `cap` by `op`.
100182

101-
A more complicated situation is if we want to store successive values
102-
held in a list. Example:
183+
When the capture checker sees `fs.read()`, it verifies that `fs` can flow into each enclosing scope:
184+
1. The immediately enclosing closure `() => fs.read()` must permit `fs` in its capture set ✓
185+
2. The enclosing method `inner` must account for `fs` (it does, via its result type) ✓
186+
3. The enclosing method `outer` must account for `fs` (it does, via its parameter) ✓
187+
188+
If any scope refuses to absorb the capability, capture checking fails:
189+
103190
```scala
104-
def f(ops: List[A => B])
105-
var xs = ops
106-
var x: ??? = xs.head
107-
while xs.nonEmpty do
108-
xs = xs.tail
109-
x = xs.head
110-
...
191+
def process(fs: FileSystem^): Unit =
192+
val f: () -> Unit = () => fs.read() // Error: fs cannot flow into {}
111193
```
112-
Here, `x` cannot be given a type with an `ops` capability. In fact, `ops` is pure, i.e. it's capture set is empty, so it cannot be used as the name of a capability. What we would like to express is that `x` refers to
113-
any operation "reachable" through `ops`. This can be expressed using a
114-
_reach capability_ `ops*`.
194+
195+
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.
196+
197+
### Visibility and Widening
198+
199+
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 super capture set:
200+
115201
```scala
116-
def f(ops: List[A => B])
117-
var xs = ops
118-
var x: A ->{ops*} B = xs.head
119-
...
202+
def test(fs: FileSystem^): Logger^ =
203+
val localLogger = Logger(fs)
204+
localLogger // Type widens from Logger^{localLogger} to Logger^{fs}
120205
```
121-
Reach capabilities take the form `x*` where `x` is syntactically a regular capability. If `x: T` then `x*` stands for any capability that appears covariantly in `T` and that is accessed through `x`. The least supertype of this capability is the set of all capabilities appearing covariantly in `T`.
122-
-->
206+
207+
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`.

0 commit comments

Comments
 (0)