[Accepted with modifications] SE-0446: Nonescapable Types

The review for SE-0446: Nonescapable Types ran from September 17 through October 1, 2024. The Language Steering Group has decided to accept this proposal with revisions. The Language Steering Group acknowledges that this proposal in isolation is not sufficient to make ~Escapable types fully functional, since there is no way yet to specify initializers that construct new values or methods that return values of ~Escapable type. This proposal is one of a series of proposals to introduce lifetime dependencies and supporting types into the language and standard library. A follow-up proposal will introduce the ability to express lifetime dependencies on ~Escapable values that allow for more than strictly downward-passed values.

We would like to see the proposal revised to address the following design point:

Declaring conditional Copyable and Escapable capabilities

When we accepted SE-0427, which introduced the Copyable protocol, which types conform to by default unless specified otherwise, along with the ~Copyable syntax for suppressing the implicit Copyable requirement. Since Copyable was the only protocol with this behavior at the time, we did not fully commit to a general rule for how an extension declaring conditional Copyable or Escapable conformance should be specified when multiple default capabilities are suppressed. Now that SE-0446 introduces Escapable, the proposal needs to specify this rule.

The proposal provides an example in which a type that is ~Copyable & ~Escapable in the general case expresses conditional Copyable and Escapable capabilities by explicitly reiterating which suppressed requirements on its type parameters become required or remain suppressed to satisfy each capability:

struct Wrapper<T: ~Copyable & ~Escapable>: ~Copyable, ~Escapable { ... }
extension Wrapper: Copyable where T: Copyable, T: ~Escapable {}
extension Wrapper: Escapable where T: Escapable, T: ~Copyable {}

The Language Steering Group believes requiring the suppressed requirements to be reiterated explicitly is the correct starting point for this functionality, and the proposal should be amended to clarify that this is required when specifying conditional Copyable or Escapable extensions on a type whose generic parameters suppress multiple protocols.

To maintain source compatibility if Swift ever adds additional suppressible default capabilities in the future, the rule should be that whatever suppressed requirements were specified in the original declaration must be reiterated in each extension that specifies a conditional conformance to a suppressed requirement. This should allow existing code to compile as is when that happens, since it would only need updates once it explicitly adopts the new language feature.

struct Foo1<T: ~Copyable>: ~Copyable {}
// OK
extension Foo1: Copyable where T: Copyable {}

struct Foo2<T: ~Copyable & ~Escapable>: ~Copyable, ~Escapable {}
// ERROR: conditional Copyable requirement must specify whether it allows T: ~Escapable or requires T: Escapable
extension Foo2: Copyable where T: Copyable {}
// OK
extension Foo2: Copyable where T: Copyable, T: ~Escapable {}

// in future Swift that introduces ~Runcible…
struct Foo3<T: ~Copyable & ~Escapable & ~Runcible>: ~Copyable, ~Escapable, ~Runcible {}
// ERROR: conditional Copyable requirement must specify whether it allows T: ~Runcible or requires T: Runcible
extension Foo3: Copyable where T: Copyable, T: ~Escapable {}
// OK
extension Foo3: Copyable where T: Copyable, T: ~Escapable, T: ~Runcible {}

By starting with a conservative rule, we can consider potential syntax improvements as we get more developer experience with these features in their initial form.


The Language Steering Group also reviewed community feedback on the following topics:

Constraints on further evolution of lifetime dependencies

Several reviewers raised concerns that they felt they were unable to review this proposal without a bigger picture of how lifetime dependencies will eventually look as a whole, and were concerned that this proposal may be prematurely establishing decisions we may regret as the feature develops. For instance, this proposal establishes that an Escapable protocol exists, and that types conforming to Escapable indicate that values never carry lifetime dependencies. Reviewers pointed out that Rust for example does not need a corresponding trait, since its system of lifetime generic parameters makes it structurally apparent when concrete types carry lifetime dependencies: a concrete type in Rust with no lifetime parameters has no lifetime dependencies.

However, generic parameters in Rust are implicitly treated as carrying some lifetime dependency, unless they are explicitly declared as having none with a T: 'static constraint (which uses the more general T: 'a lower bound lifetime constraint with the special 'static lifetime). 'static as a constraint serves an analogous role to Escapable as proposed, though the default assumption in Swift is opposite that of Rust. Since Escapable is like Copyable (and unlike normal protocols) in that it is a core capability of a type and does not require any additional runtime information, we could in theory transition it into an alias for a lifetime lower bound constraint in the future, should we bring that functionality to Swift.

There's also a question about the lifetime behavior of higher-order functions that pass nonescaping value(s) to their function parameters: if the function is invoked more than once, does each invocation run under a common scope, or does each invocation run independently? In Rust, a declaration

fn foo<'a>(body: impl Fn(Param<'a>) -> ())

would specify that every invocation of body receives a Param with some overlapping lifetime, which would allow for the body closure to pass nonescaping information between invocations of itself, since each invocation would be understood to occur in the same scope:

let mut carry_over: Option<Param> = None;

foo(|param| {
  use(carry_over, param);
  carry_over = Some(param);
});

This might be useful for something like a forEachSpan operation on an in-memory data structure, where the caller may want to track multiple spans during the computation. On the other hand, this rules out a function that transiently produces spans and only kept each span alive for the duration of a single body invocation. To allow for transient nonescaping values to be passed down to a closure that's invoked more than once, we want to model each invocation as occurring with an independent lifetime, which would correspond to a Rust declaration like:

fn foo(body: impl for<'a> Fn(Param<'a>) -> ())

This is the behavior proposed by SE-0446, and since this is the more conservative API guarantee, the Language Steering Group agrees this is the right default behavior. Choosing this default behavior does not foreclose on the ability for future proposals to offer a way to express a higher-order function that does provide a constant lifetime across invocations of a function parameter.

Escapability as a type rather than value property

Reviewers asked whether escapability should be modeled as a property of particular values, rather than a property of a type shared by all values of that type. In particular, being able to specify that a parameter of a normally heap-allocated and/or reference-counted type could potentially be promoted to the stack, if that property could be transitively proven through the callee's body and any functions it calls. Being able to guarantee this benefit for existing types would require that all of their APIs apply lifetime annotations. Unannotated functions would not be available on nonescaping values, effectively making them act as if they were of a different type. As precedent, Swift already treats nonescaping functions as a type distinction from escapable function types, and we think this is still the right place to keep the distinction for now.

Interactions with region isolation

The review raised the question of whether nonescapability has any interactions with sending parameters and region isolation. As proposed, a type that is ~Escapable by itself does not prevent a value of that type from containing components that are Escapable and providing APIs to allow those components to escape, so it is not by itself enough to provide any stronger isolation guarantees.

Future directions

Reviewers asked about several potential related features that the Language Steering Group is comfortable with exploring as future directions:

  • It may at some point make sense for class types to be declarable as ~Escapable.
  • This proposal does not yet try to make nonescaping function types into full-featured ~Escapable types or lift the restrictions on their use only as parameters to other functions. The proposal authors and Language Steering Group anticipate that doing this well will require deeper interactions with how lifetime dependencies work, and so it is premature to attempt to generalize them.
  • On a related note, many reviewers noted that it would be very useful for TaskGroup to be able to model the body function of addTask as nonescaping. This is a good example of the sorts of lifetime interactions that generalized nonescaping functions would have to be able to express, since the lifetime that an addTask closure is bounded to is not the immediate addTask invocation, but the lifetime of the enclosing TaskGroup.

Again, the Language Steering Group acknowledges that this is just the beginning of a full lifetime dependency feature, and we'd like to thank everyone in the community who participated in the review and helped get this started!

29 Likes