[Pitch] Non-Escapable Types and Lifetime Dependency

EDIT: The answer is right above me :upside_down_face:

Would it be possible to use the internal name of the parameters instead of their external label? To borrow an example from the proposal, but adding a missing internal parameter name:

func mutatingBufferReference(to array: inout Array) -> mutate(to) MutatingBufferReference<Element>

I think this would read much more naturally as:

func mutatingBufferReference(to array: inout Array) -> mutate(array) MutatingBufferReference<Element>

Now, my initial thought was that the external labels were chosen because they define the API while the internal names don't. However, internal names are still surfaced publicly; they're visible in documentation and parameters in doc comments are labeled with their internal name, not their external label. So there's precedent for defining things based on them.

This would also solve the awkwardness around repeated labels or unlabeled arguments: internal names must be unique and must be present, even when labels aren't. I think this would let you drop the integer-based scheme.

5 Likes

It's valid to have no internal paramter label for an unused parameter.

1 Like

Today, TaskGroup.addTask takes an escaping closure, which means it cannot capture non-escaping values. This is necessary because TaskGroup itself can escape. (It's not supposed to be escaped, but there's no compiler enforcement of this today.)

After talking with some of the Concurrency experts, it seems there is interest in using ~Escapable to close this hole and improve other aspects of the Concurrency API.

I've updated the proposal Future Directions to call this out as something that we'd like to do someday. It's not something I want to try to implement in this proposal. Since adding ~Escapable to an existing type is not ABI stable, it will require some careful thought about how to best evolve the Concurrency APIs to avoid breaking existing code.

1 Like

How does this work for a protocol requirement?

protocol P {
  func doThing(with x: borrowing) -> borrow(x) ThingTemp
}

Is the following a valid witness?

struct S: P {
  func doThing(with y: borrowing) -> borrow(y) ThingTemp
}
1 Like

As I mentioned just above Tony's post :wink: the internal name is in fact what we've implemented, and I've updated the proposal to match this.

It's certainly valid to have no internal parameter label, but it's also not a problem for someone to add one specifically for this purpose. In addition, the proposal includes the option of using the argument index instead of label.

2 Likes

+1 to the question by @rauhul .
This doesn't feel quite right. From the interface point of view local names are irrelevant, and the lifetime lifetime modifiers are part of the function's interface.

EDIT: Thinking about this once again the implemented behavior is better. Local names are irrelevant at the call site, but the names in parentheses after modifiers (borrow(whateverName)) are irrelevant there as well. The only thing matters is consistency at the declaration site.

1 Like

Ah, true. But in that case, I don't think it would be possible for the function's implementation to perform a lifetime transferring operation with that value.

This thread is already moving too fast for me :sweat_smile: That sounds great!

1 Like

As @allevato said, that’s not entirely true. In any event, the ABI can encode the argument’s position rather than its internal name.

2 Likes

That was my own initial thought as well, which is why I wrote the proposal that way at first.

But I've since realized that the one key requirement here is that whatever we choose must be able to be parsed in source code unambiguously. Numeric argument indices and local parameter names both provide this.

How it appears in documentation is certainly also a consideration, although it's less of a concern, as documentation tools do have the ability to transform their input and mark things up in various ways (e.g., they might match text colors or draw arrows to reflect dependencies). As Tony pointed out, the local parameter names are currently carried through into docc output, so that should already be fine.

How this gets represented internally to the compiler and/or runtime is not relevant here, as the compiler can transform the source form into whatever makes most sense for its requirements.

I believe this also answers @rauhul's question: yes, those would match.

4 Likes

@tbkka It was initially proposed as @nonescaping modifier. However, it eventually became ~Escapable. Could you please expand on why? I feel like the initial notion is far superior and would allow much better composition, but I'm sure you had a good reason to express it as a property of type instead of a property of a function argument.

Also, are non-escaping closures ~Escaping (and subject to lifetime modifiers)? Will it be possible to write this:

func f(_ g: borrowing () -> Void) -> copy(g) () -> Void { // not sure about `copy`
  g
}

PS:

  • A copy(self) lifetime dependency with a borrowing or inout mutation-modifier

I believe it should be "borrowing or mutating"

1 Like

Those are actually independent proposals. A few of us are focusing on the ~Escapable type property right now specifically because of the StorageView type, which requires something along these lines in order to provide strong safety guarantees.

The argument modifier is still interesting and I hope we'll have time to work on that sometime soon (if no-one else beats us to it :wink:).

Good question! That seems like a reasonable thing to me, but @Andrew_Trick knows the technical details better than I do. (It may not be something we'll be able to implement in the initial release of this feature.)

Thank you for pointing that out! Fixed.

1 Like

I think that's reasonable and desirable. We could can say that nonescaping function types are Copyable, ~Escapable.

Nonescaping functions in stored properties are even more interesting because they could capture mutable state in a closure without giving up all the normal strategies for type composition:

struct Visitor: ~Escapable {
  var f: @nonescaping ()->()

  func visit() {
    f()
  }
}

struct Container {
  mutating func update() {  }
}

func visit(container: inout Container) {
  let visitor = Visitor { container.update() }
  visitor.visit()
}

In this proposal we don't yet loosen the existing restrictions on nonescaping functions. So you still can't return them, store them in properties, or abstract over them with generics. We should probably stabilize the design and implementation of the new ~Escapable types feature before changing the semantics of such a fundamental part of the language. But hopefully we can remove those restriction on nonescaping functions soon in a source compatible way.

5 Likes

Indeed, Swift has a very good syntax known to all users for unambiguously referring to arguments by index: $0, $1, etc.

It occurs to me that we might want to change the default for function type properties in ~Escapable types to assume @nonescaping and allow @escaping if needed. If so, then we would want to address it in this proposal, and at the very least force people to write @escaping for now.

4 Likes

This is another aspect of the design I don't fully understand. All the proposed lifetime modifiers affect the object that is being returned. In your example it's the opposite: Visitor can't outlive f, not vice versa. It seems like we are missing annotations to modify lifetime of the existing object. Namely f setter should redefine lifetime constraint for self as lifetime_of_self = borrow(newValue).
Also, I don't really understand what is the lifetime of the result of f getter. I don't think copy(self) would work. Consider a struct with 2 properties:

struct S: ~Escapable {
  var f: @nonescaping () -> ()
  var g: @nonescaping () -> ()
}

let s = S(f: source_of_f, g: source_of_g)

Its lifetime will be borrow(source_of_f) borrow(source_of_g), but the lifetimes of f and g are just copy(source_of_f) and copy(source_of_g) respectively. Am I wrong?

I meant to use a 'let' here. The initializer can carry lifetime dependence from an initializer argument onto the initialized value.

The current proposal does not handle setters because we need support for checking that one parameter's lifetime depends on another. For a setter, self would depend on newValue. That will ensure correctness, but it is very restrictive, and not a key use case for the current round of APIs that we need to build.

I'm not sure if we actually want to allow default initializers to carry lifetime dependence. The current proposal does not allow it. It would make the most sense to write this

struct S: ~Escapable {
  var f: @nonescaping () -> ()
  var g: @nonescaping () -> ()

  init(f: borrowing @nonescaping () -> (), g: borrowing @nonescaping () -> ()) -> copy(f, g) Self {
    self.f = f
    self.g = g
    return self
  }
}

Yes, the resulting S will copy the lifetime dependence from both its arguments.

Yeah, but what about f and g? How will this work?

func h(_ f: borrowing @nonescaping () -> ()) {
  let fCopy: @nonescaping () -> ()
  do {
    let gTemporary: @nonescaping () -> Void = ...
    let s = S(f: f, g: gTemporary) // copy(f) copy(gTemporary) S
    fCopy = s.f // ???
  }
  use(fCopy)
}

I don't think it would be correct if s.f to inherit the lifetime of s. It should inherit the argument f lifetime.

@tbkka Sorry for what may be a slightly stupid question, but why do we need the consume lifetime modifier? Both copy and consume mean the result will inherit the lifetime of the original. And we already know from the function signature that the argument will be consumed. Why is it important for the caller who exactly consumes the argument?

1 Like

There’s a major but subtle difference between a value being scoped to the duration of a specific access to storage and it being scoped to the same scope as an existing non-escaping value. To explain why, I’m going to talk about buffer views, which are linked earlier in the thread.

When you construct a view of the elements of an Array, you are accessing the variable that contains that Array. As soon as you stop accessing the variable, your view of the elements has to become unusable; otherwise, the feature breaks Swift’s exclusivity model. Similarly, if you have a mutable view, and you want to temporarily break that down into narrower views (i.e. slices), those slices must be scoped to an exclusive access to the parent view. The slices have to go away before that access ends because otherwise you’d be able to use the parent view and the slices at the same time, which, again, violates exclusivity.

When you copy an existing view of the elements of an Array, you’re just propagating the scope restriction of the existing view. It’s fine for you to keep using your copy after the view you copied goes away, as long as that doesn’t escape the original scope that you made the view within. Similarly, if you wanted to permanently split a mutable view into slices, without needing to get the original view back, you’d need to consume the original view so that you can’t use it again, but then those slices can just inherit the scope restriction of the original view.

Edit: I think I misread your question, but hopefully this is useful to someone. The a spellings in the proposal do seem up for debate.

8 Likes

Yeah, the question was why do we need the consume modifier at all. The proposal defines:

  • borrow lifetime-type is only permitted with a borrowing parameter-convention
  • mutate lifetime-type is only permitted with an inout parameter-convention
  • consume lifetime-type is only permitted with a consuming parameter-convention
  • copy lifetime-type is only permitted with borrowing or inout parameter-convention

So we have

  • Access extending modifiers: borrow(x) and mutate(x)
  • Lifetime inheriting modifiers: consume(x) and copy(x)

But as consume(x) is the only allowed modifier for consuming parameters, and essentially it does the same thing as copy(x), we can just always use copy.
From the caller POV these two functions produce results with the same lifetime constraint

func f(_ x: consuming X) -> copy(x) Y
func g(_ x: consuming X) -> consume(x) Y

Oh, btw. Couldn't we allow borrow(x) for inout parameters as well? It would mean that we take an exclusive mutating access to x for the call to the function, but the result will downgrade this access to borrowing.

2 Likes

In some sense, they are redundant. You are right that both mean that the return value is a new value with the same lifetime constraint as the tied argument.

The distinction is really to more clearly document how they're used: copy for cases where the return value is conceptually independent of the argument, consume for cases where the return value is conceptually replacing the argument.

It's certainly possible that the final review will collapse these. We'll have toolchain builds that will allow people to try these features out very soon, and I'd be really interested to hear people's experience trying to use them. That will help us better understand whether distinctions like this are helpful or just confusing.

Update: I've edited the proposal to clarify that combining copy and consume would be another reasonable possibility.

1 Like