[Pitch] Non-Escapable Types and Lifetime Dependency

I discussed that as a Future Direction in the proposal. Note that for all the cases we're supporting initially, the access during the function execution (read vs. read/write) is the same as the access after the function executes (borrow vs. mutate).

The case you mention would require more work to accurately track the change in access level. It can be added later once we have a little more experience.

1 Like

Semanticaly we only have two ways to transfer lifetime: "access-scoped" vs. "inherited". You can only inherit a lifetime from another ~Escapable type.

The ownership keywords map those semantics to the type of lifetime transfer that you get when you use one of the four possible ownership operators. If I write a function whose ownership semantics are that of a copy, then I also want to copy the lifetime. If I write a "move" function with consume ownership semantics, then I consume the lifetime. Those both happen to have the same lifetime semantics.

If we use ownership keywords, then we need a way to spell lifetime inheritance for noncopyable types. It would be nonsense to use the copy operator on a noncopyable type.

Relating lifetime semantics to ownership operations is helpful for seeing the coherency of the model, but probably unhelpful to anyone trying to find meaning in an API declaration if they aren't already thinking about ownership.

The lifetime of fCopy needs to be defined in relation to the lifetime of the value s, because the type system does not yet allow us to relate the lifetimes of components within a type. In other words, your code above is illegal for, now. To do better, I we need a richer syntax for dependence, like -> copy(argf: .f, argg: .g) S.

We might be able to make an exception for structural types (tuples) locally and separately track each component's lifetime. But we'll still run into the syntax limitations at function boundaries.

The lifetimes of components won't make it into this proposal, but it is worth discussing potential syntax as a future direction.

What's more pressing to this proposal is whether property access requires a new lifetime scope, or whether it can inherit the parent value's lifetime.

The fact that S is ~Escapable here means accessing s.f could inherit the lifetime of s whenever we can see that it is a stored property, making this legal:

let s = S(f: f, g: gTemporary) // copy(f) copy(gTemporary) S
fCopy = s.f // ???
consume s
use(fCopy)

We should, however, probably force an access-scoped lifetime whenever s.f is a computed property, which means we also need access scope for any public non-frozen property. If that's too restrictive, then we'll need to have a discussion about whether "consuming accessors" should be a thing.

2 Likes

Classes cannot be declared ~Escapable .

// Example: Conditionally Escapable generic type
// By default, Box is itself non-escapable
struct Box<T: ~Escapable>: ~Escapable {
  var t: T
}

// Box gains the ability to escape whenever its
// generic argument is Escapable
extension Box: Escapable when T: Escapable { }

What about functions? Functions cannot have protocol conformances. However, it's been multiple times where I run into an accidental issues escaping functions via a generic type like Optional.

func ne(_ closure: () -> Void) // closure is non-escapable
func e(_ closure: @escaping () -> Void) // now escaping

func optionalE(_ closure: Optional<() -> Void>) // ESCAPING!!
func genericE<T>(_ value: T) // Functions can still escape here
func anyE(_ value: Any) // Type erasure still can escape functions

It seems that we still have a hole when it comes to capturing functions. Generic structures like Optional can still escape functions by default and do not require an explicit @escaping attribute as the escaping was already performed at different stage. This can be a source of unintentional bugs.

Could we possibly get a @nonEscaping attribute to explicitly override the Escapable-ness of a type to -Escapable in the current scope? Or some other way of marking that intention.

3 Likes

What is the syntax to declare closures that work with non-escapable types? Swift rejects argument labels in closure parameter lists.

You can write (_ a: Int, _ b: Int) -> Int as a type with internal parameter names but no outward argument labels. Normally this is just sugar over (Int, Int) -> Int but with lifetime annotations would provide a more readable way to describe the dependencies as well.

Can you provide a more complete example of how this causes unintentional bugs?

It sounds like a reasonable future extension to this work, but I'd like to make sure I understand the issue first.

Sure, here's a quick example snippet where you create retain cycles through escaping the closure.

class B {
  var closure: (() -> Void)! = nil

  func escapeThroughGeneric<T>(_ t: T) {
    closure = t as! () -> Void
  }

  func escapeThroughOptional(_ opt: Optional<() -> Void>) {
    closure = opt! // optional generic init already escaped the function
  }

  func escapeThroughTypeErasure(_ erased: Any) {
    closure = erased as! () -> Void
  }

  func cannotEscape(_ closure: () -> Void) {
    // expected error: error: converting non-escaping value to 'Any' may allow it to escape
    // self.closure = closure
  }

  func allowedEscape(_ closure: @escaping () -> Void) {
    self.closure = closure
  }
}
class A {
  let b = B()

  func function() {}

  func test() {
    b.cannotEscape(function) // nothing to worry about, `function` cannot escape

    b.allowedEscape(function) // expected in this case

    // if we don't know the implementation of these functions as the API user, these could
    // create issues such as retain cycles in this show case example
    b.escapeThroughGeneric(function)
    b.escapeThroughOptional(function)
    b.escapeThroughTypeErasure(function)
  }
}

Are functions non-escaping by default? If so, then these examples showcase how this can be circumvented. Especially having optional functions is extremely easy to be trapped into.

If they are not escaping by default and I'm mistaking here something, can we make them non-escaping by default in Swift 6?

2 Likes

functions are escaping!
Do you propose to have an ability to opt out of that? Like so?

@nonescaping func foo() { ... }

I was referring to capturing closures. The default was flipped in the past of swift evolution. However, the language still has some holes which remain implicit. The example showcased some of these. Probably the most common one is by using the optional closure, because generics kinda workaround the need for @escaping when it comes to closures.

If you consider this modified version:

func test(_ closure: () -> Void) {
    b.cannotEscape(closure)
    b.allowedEscape(closure) // πŸ›‘ Passing non-escaping parameter 'closure' to function expecting an @escaping closure
    b.escapeThroughGeneric(closure) // πŸ›‘ Converting non-escaping parameter 'closure' to generic parameter 'T' may allow it to escape
    b.escapeThroughOptional(closure) // πŸ›‘ Passing non-escaping parameter 'closure' to function expecting an @escaping closure
    b.escapeThroughTypeErasure(closure) // πŸ›‘ Converting non-escaping value to 'Any' may allow it to escape
}

you'd see that all attempts to escape a non-escaping closure fail. This doesn't happen when you pass a function, thus functions are escaping. To flip this default is too late at this point. But regardless of that, what exactly do you propose and why? To somehow (whether that's an opt-out or an opt-in) have both flavours of functions: escaping and non escaping?

What does it mean for a global to "escape"? If the func is at the top level, there's nothing for it to escape from (it's always in-scope); and if it isn't, you can just replace the local function with a local variable that is a closure.

I believe @DevAndArtist is talking about instance methods only.

Huh, now I'm confused. Is there a difference between functions/methods and closures? I thought they were all treated the same way with the exception of some being bound to some instance. Why does the compiler flag the closures exactly the way I would expect it to, but it's not the case for methods as shown in the example?

Saying "closure" is confusing here. In tera's example, test has a parameter of function type. That parameter is an arbitrary non-escaping function value, and so Swift has no choice but to restrict it strictly according to its type: it cannot be converted to an escaping function type, and it can only be captured by a non-escaping function.

A reference to a specific named function is not an arbitrary function value. There's only a handful of expressions that originate a function value like this; Swift knows how to check in each case that it's not forming an escaping closure that captures non-escaping state.

  • If you refer to a named function in a context that needs an escaping function value, Swift checks that the function doesn't capture anything non-escaping.
  • Similarly, if an anonymous function expression or autoclosure is resolved to have escaping function type, Swift checks that it doesn't capture anything non-escaping.
  • For a "partial application" like obj.methodName (or function in Adrian's example, which implicitly resolves as self.function), Swift checks whether the self value is non-escaping. For now, it never is, but non-escapable types will change that.

That's pretty much it.

10 Likes

As review manager for these proposals, I've been reading through the discussion and review revision history to understand the discussion that has happened so far. I wanted to raise a few points for community discussion while we get this ready for review:

Syntax revision

It appears that most of the discussion in this thread discussed a syntax model for lifetime dependencies using borrow(x), mutate(x), copy(y), consume(y), with the former two indicating a scoped dependency on an access of x, and the latter two indicating a dependency copied from an existing nonescaping value y. The current proposal has been revised to use a simpler-looking model where one syntax dependsOn(x) "does what you mean", forming a scoped dependency on an access of x when x is escapable or a borrowing/inout parameter, or copying the dependency from x if it's a nonescaping consuming or copyable value. dependsOn(scoped x) can be used to explicitly form a scoped dependency. Speaking personally, this appears to be an improvement, but it doesn't appear that this revision was discussed yet in the pitch thread, so I wanted to prompt discussion about it.

Values of ~Escapable type with unlimited, indefinite, and/or unknown lifetime

The proposal only tangentially discusses values of ~Escapable type that don't have a lifetime dependency, mentioning in passing an @_unsafeNonescapableResult attribute without going into depth. I think this capability deserves a little more consideration, since from talking to some early adopters of this feature, it sounds like the need (or at least desire) for it comes up in a number of common scenarios. For one example, an enum may have some cases which are ~Escapable but also some cases that have Escapable-payload or no-payload case. If we extend the standard library Optional and Result types to allow for ~Escapable values, they would likely look like:

enum Optional<Wrapped: ~Copyable & ~Escapable>: ~Copyable, ~Escapable {
  case none, some(T)
}

enum Result<Success: ~Copyable & ~Escapable, Failure: Error>: ~Copyable, ~Escapable {
  case failure(Failure), success(Success)
}

When constructing an Optional<NotEscapable>.none or Result<NotEscapable>.failure(error) case, there's no lifetime to assign to the constructed value in isolation, and it wouldn't necessarily need one for safety purposes, because the given instance of the value doesn't store any state with a lifetime dependency.

Another place where indefinite lifetimes might come up arose in the pitch discussion thus far, where the question of what happens with dependencies on global variables arose. When a value has a scoped dependency on a global let constant, that constant lives for the duration of the process and is effectively perpetually borrowed, so one could say that values dependent on such a constant have an effectively infinite lifetime as well. This would be analogous to the 'static lifetime in Rust.

Anecdotally, a few developers I've spoken to who've tried to early-adopt ~Escapable types have also wanted to be able to construct a value without a lifetime constraint as a layering strategy, where a primitive private unsafe initializer takes raw unsafe pointers as a building block for the safe public API, like:

public struct Ref<T: ~Copyable>: ~Escapable {
  private var address: UnsafePointer<T>

  private init(unsafePointer: UnsafePointer<T>) -> /*what lifetime?*/ Ref<T>

  public init(borrowing value: borrowing T) -> dependsOn(value) Ref<T> {
    self.init(unsafePointer: _someWayToGetALimitedScopePointer(to: value))
  }
}

This use case is perhaps less essential, since an arguably safer way to layer this is to have it so that even the primitive initializer takes a phantom parameter to specify the lifetime dependency, so that it's harder to create leaky unconstrained unsafe values:

public struct Ref<T: ~Copyable>: ~Escapable {
  private var address: UnsafePointer<T>

  private init<X>(unsafePointer: UnsafePointer<T>, dependingOn dummy: borrowing X) -> dependsOn(dummy) Ref<T> { ... }

  public init(borrowing value: borrowing T) -> dependsOn(value) Ref<T> {
    self.init(unsafePointer: _someWayToGetALimitedScopePointer(to: value), dependingOn: value)
  }
}

The proposal might encourage this approach more explicitly as a safer way of building up nonescaping reference types from primitives. However, the cases of enum payloads and global dependencies are perfectly safe and not uncommon situations where indefinite lifetimes would be useful.

Thank you to everyone who has contributed to the discussion thus far!

9 Likes

With the updated language rules, dependsOn results in scoped dependence only for Escapable types, we default to copy dependence in all other cases.

3 Likes

I might have missed it from the pitch: are initializers allowed to have a return type now because dependsOn goes on side of the return type?

1 Like

Yes, we now allow you to write out a return type for an initializer, for precisely this reason.

However, the return type of an initializer must be written -> Self or -> Self? for a failable initializer. (Exactly those tokens.)

So a typical initializer for a ~Escapable type might look like this:

... type declaration ... {
   init(foo: Foo) -> dependsOn(foo) Self {
     ...
   }
}
1 Like

It seems rather unfortunate to introduce the notion of a return type on initializers. Unlike Objective-C, Swift initializers can only modify the instance in-place; you can’t pull any fancy tricks like turning an empty placeholder object into an instance. Could this also be potentially confusing when used in protocols?

2 Likes

To be clear: We are not changing the semantics of initializers. Initializers in Swift have always been defined as returning the constructed value (even though you don't use an explicit return except for return nil in failable initializers).

We are only adding a syntactic option to provide a return type clause in an initializer definition -- this clause is only present in order to provide a consistent way to express lifetime dependencies, and has no other effect.

2 Likes