Skip to content

Commit 937300f

Browse files
committed
Pass over scoped-caps
1 parent f0ed9b7 commit 937300f

File tree

1 file changed

+43
-34
lines changed

1 file changed

+43
-34
lines changed

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

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ is interpreted as having an existentially bound `cap` in the result, like this:
4444
```
4545
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.
4646

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 `(A₁, ..., Aₖ) -> B^` don't bind the `cap` in their return types in an existential quantifier. 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
4849
```scala
4950
(x: A) -> B -> C^
5051
```
@@ -60,7 +61,7 @@ In other words, existential quantifiers are only inserted in results of function
6061
- Therefore
6162
`(x: T) -> A => B` expands to `(x: T) -> ∃c.(A ->{c} B)`.
6263

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

6566
To summarize:
6667

@@ -116,39 +117,57 @@ This ensures capabilities flow "inward" to more nested scopes, never "outward" t
116117

117118
### Comparison with Rust Lifetimes
118119

119-
Readers familiar with Rust may notice similarities to lifetime checking. Both systems prevent references from escaping their valid scope:
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.
126+
127+
Consider a `withFile` pattern that ensures a file handle doesn't escape:
120128

121129
```rust
122-
// Rust: rejected because x doesn't live long enough
123-
fn bad<'a>() -> &'a i32 {
124-
let x = 42;
125-
&x
130+
// Rust: the closure receives a reference bounded by 'a
131+
fn with_file<'a, R>(path: &str, f: impl FnOnce(&'a File) -> R) -> R {
132+
let file = File::open(path).unwrap();
133+
f(&file)
134+
}
135+
136+
fn main() {
137+
let escaped: &File;
138+
with_file("test.txt", |file| {
139+
escaped = file; // Error: borrowed value does not live long enough
140+
});
126141
}
127142
```
128143

129144
```scala
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
145+
// Scala CC: the closure receives a capability whose level prevents escape
146+
def withFile[R](path: String)(f: File^ => R): R =
147+
val file = File.open(path)
148+
f(file)
149+
150+
def main() =
151+
var escaped: File^
152+
withFile("test.txt"): file =>
153+
escaped = file // Error: file's level cannot escape to main's level
137154
```
138155

156+
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`).
157+
139158
The key analogies are:
140-
- **Levels ≈ Lifetimes**: Both represent "how long something is valid"
141-
- **Containment ≈ Outlives**: Rust's `'a: 'b` (a outlives b) corresponds to Scala's level containment check (but inverted: inner scopes are contained in outer ones)
142-
- **Escape prevention**: Both reject code where a reference/capability would outlive its scope
159+
- **Capability name ≈ Lifetime parameter**: Where Rust writes `&'a T`, Scala writes `T^{x}`. The capability `x` carries its lifetime implicitly via its level.
160+
- **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.
161+
- **Level containment ≈ Outlives**: Rust's `'a: 'b` (a outlives b) corresponds to Scala's level check (inner scopes are contained in outer ones).
143162

144163
The key differences are:
145164
- **What's tracked**: Rust tracks memory validity (preventing dangling pointers). Scala CC tracks capability usage (preventing unauthorized effects).
146-
- **Explicit vs. implicit**: Rust lifetimes are often written explicitly (`&'a T`). Scala capture checking levels are computed automatically from the program structure.
165+
- **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.
147166

148167
## Charging Captures to Enclosing Scopes
149168

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.
169+
When a capability is used, it must be checked for compatibility with the capture-set constraints of
170+
all enclosing scopes. This process is called _charging_ the capability to the environment.
152171

153172
```scala
154173
def outer(fs: FileSystem^): Unit =
@@ -158,7 +177,7 @@ def outer(fs: FileSystem^): Unit =
158177
```
159178

160179
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 ✓
180+
1. The immediately enclosing closure `() => fs.read()` must permit `fs` in its capture set ✓
162181
2. The enclosing method `inner` must account for `fs` (it does, via its result type) ✓
163182
3. The enclosing method `outer` must account for `fs` (it does, via its parameter) ✓
164183

@@ -171,24 +190,14 @@ def process(fs: FileSystem^): Unit =
171190

172191
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.
173192

174-
## Visibility and Widening
193+
### Visibility and Widening
175194

176195
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:
177196

178197
```scala
179-
def test(fs: FileSystem^): Logger^{fs} =
198+
def test(fs: FileSystem^): Logger^ =
180199
val localLogger = Logger(fs)
181200
localLogger // Type widens from Logger^{localLogger} to Logger^{fs}
182201
```
183202

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`.
185-
186-
However, widening cannot always find a valid target:
187-
188-
```scala
189-
def test(fs: FileSystem^): () -> Unit =
190-
val localLogger = Logger(fs)
191-
() => localLogger.log("hello") // Error: cannot widen to empty set
192-
```
193-
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.
203+
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)