SE-0446: Nonescapable Types

Hi everyone. The review of SE-0446: Nonescapable Types begins now and runs through October 1, 2024.

In order to try this feature out, the proposal authors have provided toolchains:

This proposal covers the first stage of support for nonescapable types; it covers the basic semantics of nonescapable types which are confined to their current scope. However, the proposal on its own provides no facility for expressing lifetime dependencies beyond that, which means that in particular it provides no safe way to initialize or return values of ~Escapable type. In order to facilitate experimentation with this feature, the above toolchains also provide some support for the @lifetime attribute, which must be used to express the lifetime dependency of an ~Escapable value produced by an initializer or return value. The exact behavior of this attribute is however not covered by this review, and may be subject to change in the future until it is separately reviewed and accepted.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager via the forum messaging feature. When contacting the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at https://github.com/apple/swift-evolution/blob/main/process.md.

Thanks for contributing to Swift!

Joe Groff
Review Manager

29 Likes

I look forward to having non-escapable types in the language!

A bit of a niche question, but…

If I need to refer to "any metatype", I can no longer say Any.Type since the addition of move-only types, and must instead say any ~Copyable.Type. With the addition of ~Escapable, "any metatype" becomes any (~Copyable & ~Escapable).Type… I think? This is only going to get more complicated—should we take a moment to consider some improved syntax for "any metatype" (since a metatype in itself is not subject to move-only or no-escape rules)?

6 Likes

It's tricky to do that without recreating the same problem next time we introduce some suppressible capability. A hypothetical LiterallyAny.Type would have to either change meaning when we introduce a new ~Capability, which would be source breaking, or it would have to keep the meaning it had when it was introduced, meaning we need another AbsolutelyLiterallyAny.Type to include the new thing.

8 Likes

In SE-0427, the steering group concluded, regarding the handling of then-future potentially suppressible constraints (such as Escapable):

that the current implementation on main when enabling the experimental Escapable feature was potentially inconsistent. However, this will only become an issue once Escapable (or another defaulted-but-supressible constraint) is actually introduced into the language, so discussion of that behavior can be had during the proposal review of that feature in a later Swift version.

We will need to reckon with that behavior here. On a quick scan, this proposal text as put forward for review does not include explicit language on this point, but any design will have to adhere to the key principle that existing generic constraints which don't use any suppressible constraints must not change their meaning when this or another future suppressible constraint is introduced.

Additionally, we want to—if possible—avoid possibly surprising inferred combinations of suppression and non-suppression while also avoiding unnecessary incantations of an ever-longer list of ~Copyable & ~Escapable & ~SomeOtherThing, etc.

6 Likes

Two questions:

  1. Will @escaping closures conform to the Escapable protocol?

    This would match how @Sendable closures conform to the Sendable protocol:

    struct NeedsSendable<T: Sendable> {}
    
    typealias MySendableFuncTy    = @Sendable () -> Void
    typealias MyNonSendableFuncTy = () -> Void
    
    print(NeedsSendable<MySendableFuncTy>.self) 
    // OK
    
    print(NeedsSendable<MyNonSendableFuncTy>.self)
    //    |- error: type 'MyNonSendableFuncTy' (aka '() -> ()') does not conform to the 'Sendable' protocol
    //    `- note: a function type must be marked '@Sendable' to conform to 'Sendable'
    

    Similarly, would it make sense to allow @Escapable on function types, as an alias of @escaping which aligns with the new protocol name?

  2. Can we mark arbitrary function parameters as ~Escapable?

    Even if a closure is not required to be non-escaping, we have previously found that annotating and checking escapability of closures can lead to significant optimisations, to the extent that closures used as function parameters were made non-escaping by default, unlike any other values in Swift.

    Doesn't it stand to reason that similar optimisations might be possible for other types, and that functions may want to promise that they treat a parameter as ~Escapable even though its type doesn't require non-escaping?

    For example:

    func readArray(_ numbers: [Int] & ~Escapable) {
      // we've promised not to escape 'numbers',
      // so it is considered non-escaping here
      // despite the fact that [Int] is Escapable.
    }
    

    Is this kind of usage allowed? It doesn't work with ~Copyable:

    func foo(_: borrowing [Int] & ~Copyable) {}
    //                    `- error: non-protocol, non-class type '[Int]' cannot be used within a protocol-constrained type
    
6 Likes

Does it really make sense for a function to be generic over all suppressible constraints that now or may ever exist in the future? If it were, wouldn’t Any have been made to imply Any & ~Copyable in Swift 6?

In other words, should we consider the need to specify yet another ~Constraint to be a price that new constraint proposals have to be worth paying?

+1 on the overall proposal. I am looking forward to how we can leverage this to build really high performance parsers and asynchronous message streams.

The closures used in Task.init , Task.detached , or TaskGroup.addTask are escaping closures and therefore cannot capture nonescapable values.

I understand the constraints for Task.init and Task.detached but it is unfortunate that the same applies to TaskGroup.addTask. Is that really escaping? Similarly, what about async lets?

I can see quite a few use-cases where one might get a Span and wants to process independent chunks of that span in child tasks.

3 Likes

Great to see that this proposal doesn’t introduce lifetime dependencies altogether and leave it to a separate proposal.

+1 on the change, I like this direction of control Swift gives to the (especially, value-) types system.

Hello, I'd be interested in hearing thoughts about the interaction of non-Escapable Types and Region-Based Isolation. I known this escapes the focus of the proposal, but, well, I ask anyway.

According to SE-0414, Rules for Merging Isolation Regions:

Given a function f with arguments ai and result that is assigned to
variable y:

y = f(a0, ..., an)
  1. All regions of non-Sendable arguments ai are merged into one larger
    region after f executes.
  2. If any of ai are non-Sendable and y is non-Sendable, then y is in
    the same merged region as ai. If all of the ai are Sendable,
    then y is within a new disconnected region that consists only of y.
  3. [...]

Should the rule 2 still hold if an argument is non-Sendable, and non-Escapable?

The reason why I ask is that it is currently impossible to write the following method when Resource is not Sendable:

func accessResource<T>(
  _ work: sending @escaping (Resource) -> sending T
) async throw -> sending T

The reason why it is not possible is that at use site, all values grabbed from the "resource" are in its Region, and can not be sent out:

accessResource { rez in
    let value = makeValue(resource: rez)
    return value // warning or error
}

The only way this API must be written today if full of Sendable constraints:

func accessResource<T: Sendable>(
  _ work: @escaping @Sendable (Resource) -> T
) async throw -> T

We know "sending" was invented in order to help dealing with non-Sendable types, so this version is strictly inferior to the previous one that uses sending (but can't be made to work when Resource is not Sendable).

If the Resource type was made non-Escapable as well, would it be a sufficient guarantee that it can't "pollute" the value according to Region-Based Isolation, and could thus be "sent" without compiler warning or error?

2 Likes

Trivially, yes, because as with Copyable all existing types now magically conform as soon as the new protocol is introduced.

With today’s compiler architecture, every new suppressible protocol introduces non-trivial compile-time overhead, because every type parameter that does not suppress conformance gains extra requirements. So perhaps we should draw a line at two suppressible protocols, or at least require a very high bar to be met before we consider introducing more.

3 Likes

Classes cannot be declared ~Escapable .

Why does this proposal exclude classes? I don't see any discussion about that in the proposal beyond a passing mention.

1 Like

Classes cannot be ~Copyable yet either. Noncopyable and nonescaping classes are a straightforward generalization at the type checker level, but the representation raises some interesting design questions that I don’t think have been worked out yet. For example, a noncopyable class instance doesn’t need a reference count, and noncopyable class metadata doesn’t need a virtual destructor. For a nonescaping class instance you might be able to get away with not instantiating any metadata at all, etc. Once we enable these features (and indirect cases in noncopyable enums) we’ll need to commit to an ABI, and ideally it should not impose any fixed costs due to unnecessary indirection.

10 Likes

While we shouldn't keep adding new suppressible protocols forever with abandon, I think it's still a good idea to avoid baking in assumptions (again) that we will never make more basic type functionality optional in the future.

1 Like

Do we have an allowance here to have nonescaping function properties within ~Escapable types? It would allow a lot of fun, performant control flow experiments.

Apologies if it's in the proposal, if so I blame my fried brain for skipping it.

2 Likes

This is 3. BitwiseCopyable.

The proposal includes an upcoming feature flag NonescapableTypes.

The Source compatibility section of the proposal seems to indicate that adopting the proposal would introduce no source breakage for existing code.

What behavior or syntax is gated by the NonescapableTypes upcoming feature flag?

Or was this supposed to be an experimental feature flag?

Specifically, we will add this declaration to the standard library:

// An Escapable type may or may not be Copyable
protocol Escapable: ~Copyable {}

This is probably a silly question, but how do I reconcile the comment with the decl? :confused:

The ~Copyable constraint on Escapable doesn't mean that the protocol requires non-copyability. It merely says that types that conform to it are not required to be Copyable.

6 Likes

That uses the ~ syntax in a different way, to disable the default synthesized conformance or something IIRC. But we’re not generating a T: BitwiseCopyable requirement for each generic parameter T by default.

5 Likes