SE-0446: Nonescapable Types

We did have a discussion with the proposal authors and the Language Steering Group about what interactions this subset might have with a full lifetime dependency model before beginning this review, and what behaviors it locks in as the "default":

  • When a function receives an nonescaping value as a parameter, that value has a lifetime dependency that at least covers the lifetime of the function's execution. That feels like a safe default behavior, and one could argue it's "obvious" and the only way things could possibly work, though it's imaginable that a flexible enough model could tie the lifetime of a value to the scope of running another function parameter received as a parameter, as a way to say "you can't use this value, except to pass it over here".
  • There's a subtler 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? The former would be akin to a Rust declaration
     fn foo<'a>(body: impl Fn(Param<'a>) -> ())
    
    and this could 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, but on the other hand, the model would rule out a function that transiently produced spans and only kept each span alive for the duration of a single body invocation. That would be better modeled by the latter case, which would correspond to a Rust declaration like:
    fn foo(body: impl for<'a> Fn(Param<'a>) -> ())
    
    Since this is the more conservative API guarantee, it feels like the right default, and having this behavior now doesn't foreclose on a more general lifetime feature allowing the same-lifetime-for-each-invocation model to be expressed in the future.

If we never planned to support lifetime dependencies at all, there's technically still a way within the bounds of these strictly-downward-scoped nonescaping types to make a complete feature out of it, since initializers could primitively produce nonescaping values by passing the initialized value down and then unconditionally failing:

struct Span: ~Escapable {
  var start: UnsafeRawPointer, count: Int

  private init(_unsafeStart: UnsafeRawPointer, count: Int, body: (Span) -> Void) throws {
    self.start = _unsafeStart
    self.count = count
    body(self)
    throw Sike()
  }
}

That's awful, of course, and we would never actually leave the feature at a point where you have to do that since we know we do want to support proper lifetime dependencies (and if we didn't, there would hopefully at least be some accommodation to have these downward-passing initializers exist as more than just a fluke of existing behavior), but I think it serves as an example that it is possible to consider only strictly-downward-scoped nonescaping types as a feature on its own.

4 Likes