docs/_docs/reference/experimental/capture-checking/scoped-capabilities.md
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 any defined?
The key principle is that each occurrence of any represents a different capability. Each any
can subsume capabilities of the same or outer level, including other anys. This property is
central to separation checking, where each any gets its own hidden set
of capabilities it subsumes.
Besides any, there is a related capability fresh (available via import caps.fresh) that can
appear in function type results. While any in a function result refers to the enclosing scope's
local any, fresh introduces a new existentially bound capability, guaranteeing that each call
produces a distinct, isolated result. We cover fresh in detail
below.
any AppearsWe distinguish four positions where any (or fresh) can appear:
Local anys: Every class, method body, and block has its own local any. It abstracts over the
capabilities used inside that scope, representing them by a single name to the outside world. Local
anys form a subcapturing hierarchy based on lexical nesting.
Parameter anys: When any appears in a function parameter type (e.g., def foo(x: T^)), it
gets its own any scoped to that parameter. At call sites, parameter anys are instantiated to the
actual capabilities passed in.
Result anys: When any appears in a function result type (e.g., A^ -> B^, i.e.,
A^{any} -> B^{any}), it refers to the local any of the enclosing scope. Two calls to such a
function produce results with the same capture-set bound.
Result freshs: Instead of any, one can write fresh in the result type of a function type
(e.g., A^ -> B^{fresh}). This indicates that each call to the function yields a result capturing
a fresh, distinct capability, hence the name. Unlike a result any, a result fresh is isolated:
it cannot be merged with capabilities from the enclosing scope. Note that fresh applies only to
function types, not to method return types (which use any).
So, when writing T^ (shorthand for T^{any}), any is a way of saying "captures something"
without naming what it is precisely, and depending on the context, the capture checker imposes
restrictions on which capabilities are allowed to flow into them by means of subcapturing. Writing
T^{fresh} instead says "captures something new and isolated"; see the comparison
below for the practical difference.
Another analogy for the different anys is that they are some form of implicitly named existential
or abstract self-capture set attached to elements of the program structure, e.g., scopes,
parameters, or return values.
anysLocal anys form a subcapturing hierarchy based on lexical nesting: a nested scope's local any
subsumes its enclosing scope's local any. This makes sense because the inner scope can use any
capability available in the outer scope as well as locally defined ones. At the top level, there is
a true universal any — the local any of the global scope — which all other local anys
ultimately subsume:
// top level: the global `any`
class Outer: // has local any₁
val f1: File^ = File("f1") // File^{any₁}
def method() = // has local any₂
val f2: File^ = File("f2") // File^{any₂}
var ref: () => Unit = null // () ->{any₂} Unit, can accept what can flow into any₂
val closure = () => // has local any₃
val f3: File^ = File("f3") // File^{any₃}
val f4: File^ = f2 // ok, because {any₂} <: {any₃}
val f5: File^ = f1 // ok, because {any₁} <: {any₃}
ref = () => f3.read() // error, f3 is at the level of any₃ and cannot flow into any₂
...
Each capability has a level corresponding to the local any of its defining scope. The level
determines where a capability can flow: it can flow into anys 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). The compiler computes a capability's level by walking up the ownership chain
until reaching a symbol that represents a level boundary. Level boundaries are:
Local values like f1, f2, ref, etc., don't define their own levels. They inherit the level of
their enclosing method or class. For example, this means:
f1 is at Outer's level, i.e., f1 subcaptures local any₁.f2 and ref are both at method's level, i.e., both subcapture local any₂.{any₂} <: {any₃} holds, but it does not hold that {any₃} <: {any₂}. Hence,
we cannot assign the closure to ref, because {f3} is subcapture-bounded by {any₃}.When a capability is used, it must be checked for compatibility with the capture-set constraints of all enclosing scopes. This process is called charging the capability to the environment.
def outer(fs: FileSystem^): Unit =
def inner: () ->{fs} Unit =
() => fs.read() // fs is used here
inner()
When the capture checker sees fs.read(), it verifies that fs can flow into each enclosing scope:
() => fs.read() must permit fs in its capture set ✓inner must account for fs (it does, via its capture set) ✓outer must account for fs (it does, via its parameter) ✓If any scope refuses to absorb the capability, capture checking fails:
def process(fs: FileSystem^): Unit =
val f: () -> Unit = () => fs.read() // Error: fs cannot flow into {}
The closure is declared pure (() -> Unit), meaning its local any is the empty set. The
capability fs cannot flow into an empty set, so the checker rejects this.
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:
def test(fs: FileSystem^/*{any₁}*/): Logger^{fs} =
val localLogger = Logger(fs)
localLogger // Type widens from Logger^{localLogger} to Logger^{fs}
Here, localLogger cannot appear in the result type because it's a local variable. The capture set
{localLogger} widens to {fs} (since localLogger captures fs), which is visible
outside of test.
Local anys are one of the mechanisms that enable escape checking for
the try-with-resources pattern. They prevent escaping of scoped capabilities through (direct or
indirect) assignment to mutable variables:
def withFile[T](block: File^ => T): T
var esc: File^/*{any₁}*/ = null
withFile: f /* : File^{any₂} */ =>
esc = f // error, since any₂ cannot flow into any₁
The other mechanism is careful treatment of anys in function results
(cf. below) which prevents returning closures holding onto f.
Later sections on capability classifiers will add a controlled mechanism that permits capabilities to escape their level for situations where this would be desirable.
anys of ClassesA class receives its own local any for the scope of its body. This any serves as a template for
a new any that will be attached to each instance of the class. Inside the class body, references
to the class's any are implicitly prefixed by the path this:
class Logger(fs: FileSystem^): // local any₁
// Logger has its own local any₁, accessed as this.any₁
val file: File^ = fs.open("log.txt") // File^{this.any₁}
def log(msg: String): Unit = file.write(msg)
When a class inherits from other classes or traits, the anys of all supertypes in the extends
clause are essentially unified with the any of the current class. This unification happens because
all inherited members are accessed through this, and hence the local anys will conform through
subtyping with each other:
trait Super: // local any₁
val doSomething: () => Unit // () ->{any₁} Unit
class Logger(fs: FileSystem^) extends Super: // local any₂
val file: File^ = fs.open("log.txt") // File^{any₂}
def log(msg: String): Unit = file.write(msg)
val doSomething = () => log("hello") // ok, since {file} <: {this.any₂} =:= {this.any₁}
As explained in Capture Checking of Classes, the capture checker infers and verifies
constraints on the contents of a class's any through its self-type, reporting any inconsistencies.
When creating an instance, the class's template any is substituted with a new any specific to
the new object:
def test(fs: FileSystem^) = /* local any₁ */
val logger1 = Logger(fs) // New logger1.any for this instance, capturing fs
val logger2 = Logger(fs) // New logger2.any, distinct from logger1.any
Note that the anys attached to logger1 and logger2 subsume the local any of method test in accordance
to the rules outlined earlier.
Conceptually, a class' local any behaves like an implicit capture-set member
present in the class and all its supertypes:
class Logger(fs: FileSystem^) extends Super:
type Cap^
val file: File^{Cap} = ...
// ...
anys in Function TypesSo far we've discussed local anys that follow the lexical nesting hierarchy. But any can also appear in function parameter and result types, where special binding rules apply.
Consider this method:
def makeLogger(fs: FileSystem^): Logger^ = new Logger(fs)
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 any in the return type should be able to absorb the capability fs. This
means that this any has to be defined in a scope in which fs is visible.
In logic, the usual way to achieve this scoping is with an existential binder. We can express the
type of makeLogger like this:
makeLogger: (fs: ∃any₁.FileSystem^{any₁}): ∃fresh. Logger^{fresh}
In words: makeLogger takes a parameter fs of type Filesystem capturing some universal
capability any₁ and returns a Logger capturing some (possibly different) fresh capability.
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:
makeLogger: ∀any₁.(fs: FileSystem^{any₁}): ∃fresh. Logger^{fresh}
There's a connection with capture polymorphism here. anys in function
parameters behave like additional capture parameters that can be instantiated at the call site to
arbitrary capabilities.
The conventions for method types carry over to function types. A function type with fresh in the
result, such as
(x: T) -> U^{fresh}
is interpreted as having an existentially bound fresh:
(x: T) -> ∃fresh.U^{fresh}
The same rules hold for all kinds of function arrows: ->, =>, ?->, and ?=>. So fresh can in
this case absorb the function parameter x since x is locally bound in the function result.
Only fresh expands to existentially bound capabilities, and it does so regardless of how the
function is written. For instance, both of these types have existentially bound result capabilities:
A => B^{fresh} // ∃fresh. A ->{any} B^{fresh}
(x: A) -> B -> C^{fresh} // (x: A) -> ∃fresh. B -> C^{fresh}
In effect, we have some form of "second-class" existential capture types which are carefully
restricted and implied by the type structure and fresh occurrences. Unlike first-class existential
types, this approach does not require programmers to explicitly pack and unpack: the system
determines the binding structure automatically from where fresh appears in the type.
fresh in a function result type are replaced by a new existential variable
bound by a quantifier scoping over the result type.any, we replace these
occurrences with a new existential variable scoping over the parameter type.any in function result types are not translated to existential variables.
They refer to the local any of the enclosing scope. This includes bare T^ in function type
results, which is shorthand for T^{any}.any vs fresh in Function Type ResultsThe rules above establish a key practical distinction when writing function types. Consider:
import caps.fresh
class A
class B
def test(): Unit =
val f: (x: A^) -> B^{fresh} = ??? // B^{fresh}: existentially bound
val g: A^ -> B^ = ??? // B^{any}: enclosing scope's local any
val _: A^ -> B^ = f // error: fresh is not in {any}
val _: A^ -> B^{fresh} = f // ok
val _: (x: A^) -> B^{fresh} = g // error: g* is not in {fresh}
val _: A^ -> B^ = g // ok
With B^{any} in the result, the returned value's captures are tied to the enclosing scope's local
any. Two calls to g produce values with the same capture-set bound. The checker treats them
identically. With B^{fresh}, each call to f yields a value with a distinct existential
capability, so the checker can prove that results from different calls do not alias.
The two types are not interchangeable: a result fresh cannot flow into a local any (since the
existential is not visible from the enclosing scope), and a local any cannot flow into a result
fresh (since fresh can only subsume other existentials or
shared capabilities).
freshBy default, fresh in a function result type is bound by the immediately enclosing function. But
sometimes we want the fresh to be bound by an outer function instead. This can be achieved by
using type aliases or capture-set parameters to "tunnel" the fresh through an inner function type.
Consider these type definitions:
class A
type F[X] = (t: String) -> X
type G[C^] = (t: String) -> A^{C}
With these aliases, we can write:
val x: (s: String) -> F[A^{fresh}] = ???
val y: (s: String) -> G[{fresh}] = ???
In both cases, the fresh is bound by the outer function (s: String) -> ..., not by the inner
function (t: String) -> .... This works because fresh appears outside the inner function type
definition—it is passed as a type argument or capture-set argument to the alias. The expanded types
are:
x: ∃fresh. (s: String) -> (t: String) -> A^{fresh}
y: ∃fresh. (s: String) -> (t: String) -> A^{fresh}
This technique is useful when a capability needs to span nested function calls while remaining existentially bound at an outer scope.
anys and Local anysInside the function body, parameter anys are at the same level as the function's local any.
This means the function's local any can subsume capabilities from parameters:
def process(x: File^/* parameter {any₁} */): Unit = /* local any₂ */
val y: File^/*{any₂}*/ = x // OK: x's any is at process's level, same as process's local any
val f: () =>/*{any₂}*/ Unit = () => x.read() // OK: closure's local any subsumes x
The parameter x has a capability at process's level. The local any of process (and any
nested closures) can subsume it because they're at the same level or more deeply nested.
freshs are IsolatedResult freshs (i.e., those we assign an existential capture set in function-result types) cannot
absorb capabilities that would allow scoped resources to escape. Consider trying to leak a file by
directly returning a closure that captures it:
withFile[() => File^]("test.txt"): f =>
// ^^^^^^^^^^^ T = () => File^, i.e., () ->{any} File^{any} for some outer any
() => f // We want to return this as () => File^
The lambda (f: File^) => () => f has inferred type:
(f: File^) -> () ->{f} File^{f}
The inner closure explicitly captures f. To fit the expected type File^ => () => File^, we'd
need to widen through these steps:
(f: File^) -> () ->{f} File^{f} // inferred: captures f explicitly
(f: File^) -> () ->{any} File^{any} // widen to any
(f: File^) -> ∃fresh. () ->{fresh} File^{fresh} // apply existential rule for result `fresh`
The final step produces a result fresh that is existentially bound to the outer function type. But
the expected type () => File^ has an any bound to the enclosing scope of withFile outside the
function entirely. The capture checker prevents the existentially bound fresh from flowing
into this outer any, so the assignment fails.
Otherwise, allowing widening ∃fresh. () ->{fresh} File^{fresh} to () => File^ would let the scoped file escape:
val escaped: () => File^ = withFile[() => File^]("test.txt")(f => () => f)
// ^^^^^^^^^^^ any here is in the outer scope
escaped().read() // Use-after-close!
By keeping result freshs isolated, the capture checker ensures that an existential cannot hide f and
smuggle it out of its scope.
Beyond preventing escaping capabilities, the principle of isolating result freshs is important for
tracking mutation and allocation effects and
separation checking, e.g., for a function returning a new mutable
reference cell on each call
def freshCell(init: Int): Cell^ = new Cell(s)
val c1 = freshCell(0).set(42) // Cell^{fresh₁}
val c2 = freshCell(11) // Cell^{fresh₂}, fresh₁ and fresh₂ are incomparable
a natural type would be (init: Int) -> Cell^, i.e., (init: Int) -> ∃fresh.Cell[Int]^{fresh}
by the rules above, reflecting that each
invocation returns a distinct new Cell instance.
Readers familiar with Rust may notice similarities to lifetime checking. Both systems prevent
references from escaping their valid scope. In Rust, a reference type &'a T carries an explicit
lifetime parameter 'a. In Scala's capture checking, the lifetime is folded into the capability
name itself: T^{x} says "a T capturing x," and x's level implicitly determines how long this
reference is valid. A capture set of a reference then acts as an upper bound of the reference
itself: it only lives as long as all the capabilities it contains are visible.
Here is how we could express the familiar withFile pattern in Rust:
struct File;
impl File { fn open(_path: &str) -> Option<File> { Some(File) } }
// Rust: the closure receives a reference bounded by 'a
fn with_file<R>(path: &str, f: impl for<'a> FnOnce(&'a File) -> R) -> R {
let file = File::open(path).unwrap();
f(&file)
}
fn main() {
let f = File;
let mut escaped: &File = &f;
with_file("test.txt", |file| {
escaped = file; // Error: borrowed value does not live long enough
});
}
In both Rust and Scala, 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).
The key analogies are:
&'a T, Scala writes T^{x}. The
capability x carries its lifetime implicitly via its level.{x, y} bounds the lifetime of a value to be no
longer than the shortest-lived capability it contains.'a: 'b (a outlives b) corresponds to Scala's level
check (outer scopes can flow into inner ones).The key differences are:
&'a T). Scala levels are
computed automatically from program structure: you name the capability, not the lifetime.