You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
10
12
11
13
Special rules apply to `cap`s in method and function parameters and results. For example, take this method:
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.
19
20
20
21
In logic, the usual way to achieve this scoping is with an existential binder. We can express the type of `makeLogger` like this:
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₂`.
25
26
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:
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
33
34
34
35
The conventions for method types carry over to function types. A function type
35
36
```scala
36
-
(x: T) ->U^
37
+
(x: T) ->U^
37
38
```
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:
39
40
```scala
40
-
(x: T) ->∃cap.U^{cap}
41
+
(x: T) ->∃cap.U^{cap}
41
42
```
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.
44
44
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
49
46
```scala
50
-
(x: A) ->B->C^
47
+
(x: A) ->B->C^
51
48
```
52
49
is interpreted as
53
50
```scala
54
-
(x: A) ->∃cap.B->C^{cap}
51
+
(x: A) ->∃cap.B->C^{cap}
55
52
```
56
53
In other words, existential quantifiers are only inserted in results of function arrows that follow an explicitly named parameter list.
57
54
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)`.
- If a function result type follows a named parameter list and contains covariant occurrences of `cap`,
@@ -65,58 +69,145 @@ To summarize:
65
69
- Occurrences of `cap` elsewhere are not translated. They can be seen as representing an existential in the
66
70
scope of the definition in which they appear.
67
71
68
-
**Examples:**
72
+
### Fresh Capabilities vs Result Capabilities
69
73
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
+
valf: (x: FileSystem^) ->Logger^=???
84
+
valg: (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
+
valleaky:Logger^=???
93
+
valf: (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)
- 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:
78
109
79
-
**Typing Rules:**
110
+
```scala
111
+
defouter(c1: Cap^) =// level: outer
112
+
valx=1// level: outer (vals don't create levels)
113
+
valref=Ref[() =>Unit](() => ())
114
+
115
+
definner(c2: Cap^) =// level: inner
116
+
valy=2// level: inner
117
+
valf= () => c2.use()
118
+
ref.set(f) // Error: cap2 would escape its level
119
+
120
+
classLocal:// level: Local
121
+
defmethod(c3: Cap^) =// level: method
122
+
valz= 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
+
fnbad<'a>() ->&'ai32 {
146
+
letx=42;
147
+
&x
148
+
}
149
+
```
150
+
151
+
```scala
152
+
// Scala CC: rejected because cap would escape its level
153
+
defbad(): () ->Unit=
154
+
valcap=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
80
162
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
-**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.
86
167
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.
89
171
90
-
Say you have a method `f` that takes an impure function argument which gets stored in a `var`:
91
172
```scala
92
-
def f(op: A => B)
93
-
var x: A ->{op} B = op
94
-
...
173
+
defouter(cap1: FileSystem^):Unit=
174
+
definner(): () ->{cap1} Unit=
175
+
() => cap1.read() // cap1 is used here
176
+
inner()
95
177
```
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`.
100
178
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
+
defprocess(fs: FileSystem^):Unit=
188
+
valf: () ->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
+
103
197
```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
+
deftest(fs: FileSystem^):Logger^=
199
+
vallocalLogger=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
+
115
207
```scala
116
-
def f(ops: List[A => B])
117
-
var xs = ops
118
-
var x: A ->{ops*} B = xs.head
119
-
...
208
+
deftest(fs: FileSystem^): () ->Unit=
209
+
vallocalLogger=Logger(fs)
210
+
() => localLogger.log("hello") // Error: cannot widen to empty set
120
211
```
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