[Pitch] Non-Escapable Types and Lifetime Dependency

We would like to propose adding a new type constraint ~Escapable for types that can be locally copied but cannot be assigned or transferred outside of the immediate context. This complements the ~Copyable types added with SE-0390 by introducing another set of compile-time-enforced lifetime controls that can be used for safe, highly-performant APIs.

In addition, these types will support lifetime-dependency constraints that allow them to safely hold pointers referring to data stored in other objects.

Motivation

The design was motivated by ongoing work to provide a safe version of Swift's current UnsafeBufferPointer. This would take the form of a new type that we are currently referring to as StorageView. This new type would store a pointer and size to contiguously stored data, providing the same high performance and universal applicability as UnsafeBufferPointer. For example, it could be used to write concrete implementations of parsing and other services that work well regardless of whether the underlying data was being stored in a Foundation Data value, a Swift Standard Library Array<T>, or some other data structure.

However, unlike UnsafeBufferPointer, this new type would be entirely safe to use. This requires a number of properties:

  • It should allow copying. For example, a common scenario might involve shrinking the view as data is consumed, and making copies at key points in order to easily revert to an earlier state. In particular, this new "non-escapable" concept is fundamentally different from the "non-copyable" concept that was added to Swift last year (and which is still being expanded and developed).
  • It should not escape the local context. We want to forbid storing this in a global or static variable, for instance. This restriction also helps improve performance: "escape analysis" can be done entirely at compile time, avoiding any need for runtime checks.
  • It should be possible to link the lifetime of this "view object" to the lifetime of the container that provided it.

We believe this ~Escapable (non-escapable) concept will prove to be useful in many other contexts, so we're proposing it as a general addition to the core Swift language.

Our Proposal

The full details are in the two linked proposals, so I'll just provide a couple of quick examples here.

The recently-proposed StorageView<T> type will provide a non-escapable reference to contiguously-stored typed data. In simplified form, it looks like this:

// A non-escapable reference to contiguously-stored typed data
struct StorageView<T>: ~Escapable {
  private base: UnsafePointer<T>
  private count: Int
}

We can then extend the Array type (among others) to provide a StorageView of its contents. The borrow(self) notation here indicates that the return value has logically "borrowed" the contents of the array. The StorageView value will maintain read access to those contents until it is destroyed. As a result, the compiler will ensure that the StorageView value cannot outlive the Array, and that the Array is not mutated during that period of time:

extension Array {
  borrowing func storageView() -> borrow(self) StorageView<Element> {
    ... construct a StorageView ...
  }
}

Since any data type that stores contiguous data can provide this view, it forms a universal "currency type" for working with contiguous data. In particular, this enables the following idiom, which uses a very thin inlineable generic adapter to provide a flexible API while maintaining an efficient concrete ABI to the core logic.

@inlineable
func parse<T: StorageViewable>(_ store: T) {
  return parse(storageView: store.storageView())
}

// A fully concrete implementation of the core parsing logic
func parse(storageView: StorageView<UInt8>) {
   ... fast concrete implementation ...
}

We've split the non-escapable types proposal into two parts:

Non-Escapable Types provides the basic definition of the proposed ~Escapable type constraint.

Lifetime Dependency Annotations for Non-escapable Types describes a set of lifetime annotations that can be used to link the lifetime of a particular ~Escapable value to some other value.

In addition, the StorageView pitch describes how this can be used to provide safe, performant access to a wide variety of containers.

Related Discussions

Edited: Changed BufferView to StorageView to match the recently-posted pitch.

54 Likes

This is pretty exciting! It's great to see Swift getting as expressive as Rust (or even Objective-C, cf NS_RETURNS_INNER_POINTER :smile:) with lifetimes, without compromising on progressive disclosure (in that copying/escaping/etc are still the default.) My one bit of feedback is about usability. I'm a big fan of Rust's diagnostics โ€” it feels like a language designed for humans, which makes working with lifetimes so much less frustrating given I'm not an expert at Rust.

Anecdotally, it feels like so many SE proposals (such as those related to result builders and Sendability) have created incredibly powerful features, but at the cost of rather opaque errors when they don't work. I really hope that if/when this pitch turns into a full-fledged proposal and ultimately gets implemented, it comes with some great fix-its and easily understandable diagnostics. So long as that's the case, it's a +1 from me.

18 Likes

Really excited to see this being pitched! Overall the proposal looks great. I personally would like to see the future direction around TaskGroups to be considered as part of this proposal since those are the primary tools of structured concurrency and async lets are a used less frequently. Especially since this pitch is proposing to make async lets work out of the box. If we only implement async lets to work this will create an unfortunate situation where only the simplistic use cases can use ~Escapable types with structured concurrency.

6 Likes

Glad to see this. Hopefully we'd be able to get rid of ugly and unsafe:

var data = Data()
var value: Int = 0
func foo() {
    data.withUnsafeBytes { a in
        withUnsafeBytes(of: &value) { b in
            bar(a, b.assumingMemoryBound(to: Int.self).baseAddress!)
        }
    }
}
func bar(_ a: UnsafePointer<UInt8>, _ b: UnsafePointer<Int>) {
    ....
    global = a  // Ho-ho-ho
    global2 = b // Ho-ho-ho
}

and replace it with something like this which is both safe and concise:

func foo() {
    bar(data.bufferView(), value.bufferView())
}
func bar(_ a: BufferView<UInt8>, _ b: BufferView<Int>) {
    ....
    global = a  // ๐Ÿ›‘ can't do this
    global2 = b // ๐Ÿ›‘ can't do this
}
2 Likes

Hopefully, that's only a problem initially. We work hard to improve error messages over time, as I hope you've noticed with the steady improvement in Concurrency diagnostics over the last few years.

Please file bug reports whenever you see a confusing and/or misleading error message. We really do take those very seriously.

9 Likes

I like the spelling of the lifetime constraints. It fits in well with other recently added features.

Similarly, ~Escapable works well given the existence of ~Copyable and feels pretty natural. It's also nice that newbies can ignore Escapable until they need it.

Will this hold?

func foo(normalClosure: () -> Void, escapingClosure: @escaping () -> Void) {
    print(normalClosure is ~Escaping)   // true
    print(escapingClosure is ~Escaping)  // false
}
2 Likes

Our first goal is to make these work well for very performance-critical code. So I don't think we'll get support for dynamic type-checks (is, as?, as!) right away.

Great pitch! A couple notes on the details:

  1. In Lifetime Dependency Annotations there's a syntax:

    Functions: A function with a lifetime dependency annotation generally takes this form:

    func f(arg: <parameter-convention> ArgType) -> <lifetime-type>(arg) ResultType
    

    As arguments can be positional and/or named the proposed syntax doesn't cover all possible cases, e.g. when two arguments have the same label. I guess there should be an explicit ban when such ambiguity takes place.

  2. I think this might be too complex, but I have two examples in mind that's not covered with the proposed syntax:

    1. Return type is a tuple. One element is lifetime dependent from an arg, the other ones aren't. func f(a: consume A, b: B) -> (consume(a) C, B)
    2. Return type is a container. Let's say we want to process a big buffer in parallel, so we split it to chunks, and we don't know how many of them will be at compile time.
      borrowing func chunks(n: Int) -> borrow(self) SomeList<borrow(self) BufferView<UInt8>>
      
      Effectively SomeList's lifetime depends on its elements. And the elements depend on self. And all elements' lifetimes have the same dependency.
  3. It would be nice to have a notion of non-escapeness of an escapable type. Let's say we have a class and a function:

    final class Foo {
      var bar: String { ... }
    }
    
    func inspect(foo: Foo) { 
      print(foo.bar)
    }
    
    func test() {
      let foo = Foo()
      inspect(foo: foo)
    }
    

    foo doesn't escape from inspect. In some circumstances the compiler can take advantage from the fact and allocate Foo on stack. Probably we can express this non-escapeness explicitly, so such optimization could work across module boundaries.

4 Likes

Thanks for the thoughtful feedback! As it happens, we had already thought of most of these but I somehow failed to mention them in the proposal. Here's where I think we are today:

The draft proposal already supports <lifetime-type>(number) as a way to refer to an argument by index rather than name for precisely this reason.

We definitely want to support this. It might not be in the initial implementation, but I suspect we'll add it fairly soon. I'll add this idea as a Future Direction so it doesn't get lost.

This is a more complex case that the team working on this is still debating. We have some ideas, but they will take a bit longer to work out.

Marking individual arguments or values as non-escaping is certainly desirable, but it's unrelated to this particular proposal. I'll add that as a Future Direction also.

7 Likes

I may have missed it, but do any of the proposals explain why not to use rust-style lifetime parameters? I dont necessarily think rust's model is a perfect fit, but it certainly has a ton of literature that makes the model easier to learn and diverging from that model comes with an additional learning burden.

9 Likes

This question seems particularly relevant since named lifetimes have already been effectively pitched in the form of #isolation default arguments.

What does the consume lifetime modifier do? Is it instructing the compiler that the return value steals the lifetime constraints of the consumed value, rather than putting constraints on the lifetime of the consumed value (which ended at the call site)?

How do lifetimes interact with packs? Can I write something like repeat mutate(each Foo)?

Is an expected result of this proposal that with*Pointer methods, and several other methods/functions that currently take a closure to enforce a lifetime, should be overhauled to provide mutating/borrowing versions that don't use a closure?

When a lifetime constraint is provided with a plain old copyable return type (like UnsafePointer), what happens when the value is copied?

(Edit)

Do temporaries have lifetimes? For instance, can I write [1, 2, 3].bufferReference()?

Can global variables have constraints put on them? Is the answer different for let and var globals? Is the answer different for copyable globals?

(End edit)

Bikeshedding: I know that consume is already a keyword, but I haven't followed enough to remember if borrow and mutate are. If that's not the case, to avoid creating new keywords, would we consider consuming(param), mutating(param) and borrowing(param) as the function modifiers?

Bikeshedding: is ~Escapable the right spelling for this feature? We've held in the past that the protocol conformance syntax is best when there's an analogous useful protocol requirement, but the proposal doesn't show ~Escapable as a protocol requirement.

3 Likes

+1 on that, in fact, I donโ€™t believe we have a single use case of async let in our code base, but quite a few task groups - but I guess it depends on the domain one works in.

1 Like

Yes, precisely. The copy modifier is similar. The key use case here is a function that takes one BufferView and returns another one, for example, a dropFirst method on BufferView might work this way. That way, the new view object would continue to be dependent on the lifetime of the backing container.

Good question! I'll have to check on that one.

One of our goals here is to provide a more ergonomic way to design such APIs. As for whether any particular existing use "should" be overhauled or not -- I'll leave that to the folks who own those respective libraries. ;-)

UnsafePointer is Escapable, so you can't have a lifetime constraint on it. (At least, not in this proposal.) But if you had some other type that was copyable and non-escapable, copies would inherit the same lifetime constraint. So if you had a non-escapable and copyable iterator, you could make local copies of it to record a point in the iteration but could not store the original iterator or any of its copies in a global var.

I believe so. @Andrew_Trick can verify that.

Not in this proposal.

The protocol requirement is Escapable and ~Escapable is the syntax for expressing that this requirement is not present. (All current Swift types are implicitly Escapable.)

To understand why it's modeled this way, consider what happens to Any. An Any existential container is naturally both Copyable and Escapable: You can make as many copies as you want, store them in global variables, etc., without restriction. We can't change that. Having a protocol that expressed non-escapability would mean that an escapable container (Any) could carry a non-escapable payload, which is clearly a problem.

An early prototype instead used a @nonescapable attribute on a type, but that doesn't allow generic types to cleanly support both escapable and non-escapable objects. We really do want to expand the standard library to support things like arrays of non-escapable elements someday, and generic support is crucial for that.

I realize there's uncertainty about temporaries here, but if they can have lifetimes, it's ambiguous to me whether array.bufferReference() would capture array itself or a copy of it, and whether this is an issue that we have any way of diagnosing. I think we landed on borrowing/consuming needing a keyword for arguments, so it's less a problem with those.

Yes, the compiler tracks the lifetime of all values of non-BitwiseCopyable type. This works:

foo([1, 2, 3].bufferReference())

Naรฏve diagnostics will error on this:

let ref = [1, 2, 3].bufferReference()
foo(ref)

But the diagnostics can be improved to lengthen the lifetime of the temporary array.

We will almost certainly have a borrow operator to match consume. The lifetime modifiers are written as if those operators are being applied to the parameter to intentionally distinguish them from the borrowing|consuming parameter modifiers. We also want to support borrowing return values. We don't want to use the same keyword to mean very different things that are easy to confuse:

foo(borrowing arg) -> borrow(arg) borrowing Result

This means that the caller will automatically apply the borrow operator to arg to create a scoped access for the duration of Result. The callee will receive a borrowed instance of arg, which it does not need to destroy. And the callee will return a borrowed instance of Result, which the caller does not need to destroy. Three different but related kinds of borrowing.

Another way to avoid confusion is the alternate spelling shown in the proposal
foo(borrowing arg) -> @dependsOn(borrow arg) borrowing Result

1 Like

@dmt. Thank you for those examples. I've added some of them to the Future Directions section of the proposal.

1 Like

@meg-gupta corrected me on this point: The actual behavior being implemented uses the local parameter name, not the external argument label. This avoids the issues you raised here.

I'll update the proposal.

2 Likes