Skip to content

Commit b07e36c

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

File tree

1 file changed

+155
-64
lines changed

1 file changed

+155
-64
lines changed

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

Lines changed: 155 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,59 @@ 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+
### Existential Binding
1012

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

1315
```scala
14-
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
16+
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
1517
```
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.
18+
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.
1920

2021
In logic, the usual way to achieve this scoping is with an existential binder. We can express the type of `makeLogger` like this:
2122
```scala
2223
makeLogger: (fs: cap₁.FileSystem^{cap₁}): cap₂. Logger^{cap₂}
2324
```
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`.
25+
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`.
2526

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:
27+
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:
2828
```scala
2929
makeLogger: cap₁.(fs: FileSystem^{cap₁}): cap₂. Logger^{cap₂}
3030
```
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.
31+
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.
32+
33+
### Function Types
3334

3435
The conventions for method types carry over to function types. A function type
3536
```scala
36-
(x: T) -> U^
37+
(x: T) -> U^
3738
```
38-
is interpreted as having an existentially bound `cap` in the result, like this
39+
is interpreted as having an existentially bound `cap` in the result, like this:
3940
```scala
40-
(x: T) -> cap.U^{cap}
41+
(x: T) -> cap.U^{cap}
4142
```
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.
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.
4444

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
45+
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
4946
```scala
50-
(x: A) -> B -> C^
47+
(x: A) -> B -> C^
5148
```
5249
is interpreted as
5350
```scala
54-
(x: A) -> cap.B -> C^{cap}
51+
(x: A) -> cap.B -> C^{cap}
5552
```
5653
In other words, existential quantifiers are only inserted in results of function arrows that follow an explicitly named parameter list.
5754

55+
**Examples:**
56+
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+
60+
- `(x: T) -> Iterator[A => B]` expands to `(x: T) -> ∃cap.Iterator[A ->{cap} B]`
61+
5862
To summarize:
5963

6064
- If a function result type follows a named parameter list and contains covariant occurrences of `cap`,
@@ -65,58 +69,145 @@ To summarize:
6569
- Occurrences of `cap` elsewhere are not translated. They can be seen as representing an existential in the
6670
scope of the definition in which they appear.
6771

68-
**Examples:**
72+
### Fresh Capabilities vs Result Capabilities
6973

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)`.
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+
98+
## Levels and Escape Prevention
99+
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.
101+
102+
### How Levels Are Computed
103+
104+
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:
105+
- **Classes** (but not inner non-static module classes)
106+
- **Methods** (but not accessors or constructors)
72107

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.
108+
Consider this example:
78109

79-
**Typing Rules:**
110+
```scala
111+
def outer(c1: Cap^) = // level: outer
112+
val x = 1 // level: outer (vals don't create levels)
113+
val ref = Ref[() => Unit](() => ())
114+
115+
def inner(c2: Cap^) = // level: inner
116+
val y = 2 // level: inner
117+
val f = () => c2.use()
118+
ref.set(f) // Error: cap2 would escape its level
119+
120+
class Local: // level: Local
121+
def method(c3: Cap^) = // level: method
122+
val z = c3 // level: method
123+
```
124+
125+
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:
126+
- `c1` and `ref` are both at `outer`'s level
127+
- `c2` and `f` are both at `inner`'s level
128+
- `c3` and `z` are both at `method`'s level
129+
130+
### The Level Check
131+
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`
136+
137+
This ensures capabilities can only flow "inward" to more nested scopes, never "outward" to enclosing ones.
138+
139+
### Comparison with Rust Lifetimes
140+
141+
Readers familiar with Rust may notice similarities to lifetime checking. Both systems prevent references from escaping their valid scope:
142+
143+
```rust
144+
// Rust: rejected because x doesn't live long enough
145+
fn bad<'a>() -> &'a i32 {
146+
let x = 42;
147+
&x
148+
}
149+
```
150+
151+
```scala
152+
// Scala CC: rejected because cap would escape its level
153+
def bad(): () -> Unit =
154+
val cap = CC()
155+
() => cap.use()
156+
```
157+
158+
The key analogies are:
159+
- **Levels ≈ Lifetimes**: Both represent "how long something is valid"
160+
- **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)
161+
- **Escape prevention**: Both reject code where a reference/capability would outlive its scope
80162

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-
-->
163+
The key differences are:
164+
- **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.
86167

87-
<!--
88-
## Reach Capabilities
168+
## Charging Captures to Enclosing Scopes
169+
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.
89171

90-
Say you have a method `f` that takes an impure function argument which gets stored in a `var`:
91172
```scala
92-
def f(op: A => B)
93-
var x: A ->{op} B = op
94-
...
173+
def outer(cap1: FileSystem^): Unit =
174+
def inner(): () ->{cap1} Unit =
175+
() => cap1.read() // cap1 is used here
176+
inner()
95177
```
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`.
100178

101-
A more complicated situation is if we want to store successive values
102-
held in a list. Example:
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)
183+
184+
At each level, the checker verifies that the used capabilities are a _subcapture_ of what the scope declares:
185+
186+
```scala
187+
def process(fs: FileSystem^): Unit =
188+
val f: () -> Unit = () => fs.read() // Error: {fs} is not a subset of {}
189+
```
190+
191+
The closure is declared pure (`() -> Unit`), but uses `fs`. Since `{fs}` is not a subset of the empty capture set, capture checking fails.
192+
193+
## Visibility and Widening
194+
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:
196+
103197
```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-
...
111-
```
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*`.
198+
def test(fs: FileSystem^): Logger^ =
199+
val localLogger = Logger(fs)
200+
localLogger // Type is widened from Logger^{localLogger} to Logger^{fs}
201+
```
202+
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`.
204+
205+
However, widening cannot always succeed:
206+
115207
```scala
116-
def f(ops: List[A => B])
117-
var xs = ops
118-
var x: A ->{ops*} B = xs.head
119-
...
208+
def test(fs: FileSystem^): () -> Unit =
209+
val localLogger = Logger(fs)
210+
() => localLogger.log("hello") // Error: cannot widen to empty set
120211
```
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-
-->
212+
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.

0 commit comments

Comments
 (0)