Control escapability of closure parameters?

I was working on a problem which could have been solved with finer-grained control of the execution context of a parameter to a closure, and I thought surely this already existed, but I can't seem to find anything about it. The only discussion I was able to find about this is from Karl's post here:

I think Karl was onto something, specifically one can consider the withUnsafeX apis:

    let ptr = withUnsafePointer(to: x) { ptr in
        return ptr // Why couldn't this be a compile-time error?
    }

All of the unsafe pointer apis are explicitly documented as "The pointer argument is valid only for the duration of the function’s execution.", but it seems that, if there was a way to mark a closure parameter as non-escaping, this could actually then be enforced at compile-time right?

This would also solve a similar issue, where the desire is to disallow passing the closure parameter to an async dispatch block:

    withUnsafePointer(to: x) { ptr in
        DispatchQueue.main.async {
            print(ptr) // error, ptr cannot escape its context.
        }
    }

I feel like there must be a good reason that this is the way it is, but on the off-chance there isn't, I'd be in favor of what Karl proposed all those years ago :)

3 Likes

The trivial cases can be validated at compile time, but the issue is that this misses more cases than it catches. For example, consider an alternative example:

    let object: OpaquePointer = withUnsafePointer(to: x) { ptr in
        MyObject_new(ptr)  // Function coming from some shared object
    }

This function comes from C code in a .dylib. SwiftPM cannot see what this function actually does. Does this pointer escape? Ultimately swiftc simply cannot tell. The pointer might escape, but it might not. Opaque function call sites such as this are impossible to analyse. This limitation is judged acceptable because you wrote the word unsafe already, and that means "you're on your own, you must ensure that you follow the rules".

I think this is the reason that there has been limited interest in doing this work: it's not really possible to produce a 100% safety net, and the code is already unsafe.

6 Likes

Ah I see, thanks!

I still think this can be useful, that is, a compiler error for apis that choose to adopt this feature in the event that the closure parameter might escape. Clearly that is too strict for the unsafe pointer apis (which as you say already announced their unsafeness), but other apis may wish to maintain safety and control the execution context of the parameter.

As an example, consider the following:

func foo(_ body: (SomeObject) -> Void) {
   let object = SomeObject()
   object.prepare() // Assume this prepares the object to be passed into the closure for clients to modify it in some way.
   body(object) // object is prepared, and passed to the closure.
   object.finish() // Assume the modifications to the object in the closure have finished executing, and now the object needs to do some "finished" work.
}

Because there is no control on the escapability of the object from the closure, this is dangerous:

foo { object in
    
    // object will happily escape into the async block
    DispatchQueue.main.async {
          object.modify()
    }
}

It is likely that the actual order of execution will be:

object.prepare()
object.finish()
object.modify()

One solution of course is to tell the caller that they need to call prepare() and finish() themselves, but that also opens the door for the caller to not do that...

If there was a way to prevent the object from escaping the context of that closure, then a Dispatch async there would not be possible, and prepare() and finish() could be private methods. It wouldn't be possible for the caller to "do it wrong".

So, in the years since, I've thought about this a bit more and learned a bit more about how other languages (most notably, Rust) approach it.

The problem is that escaping/non-escaping isn't enough to express what we want here. Currently, our use of "escaping" is quite primitive - it kind of means that you need to use the value directly, and our analysis breaks down if you ever store the value or wrap it in a struct. Even for closures, it's a poor substitute for what we actually mean:

func doFilter(predicate: (Int)->Bool) {
  let array = [1, 2, 3]
  // Error: Passing non-escaping parameter 'predicate' to function expecting an @escaping closure
  let lazyFiltered = array.lazy.filter(predicate) as LazyFilterSequence
}

The thing we really want to express are dependent lifetimes - i.e. that predicate has a lifetime, and that the LazyFilterSequence called lazyFiltered which stores it is also bound to that same lifetime. In general, that any object can have a limited lifetime, that all objects which store it are bound by that lifetime, and some of the objects it returns through instance members may also be bound by that lifetime.

That's what Rust does. They represent lifetimes as something like generic parameters, so you get an object with a lifetime, wrap it in a structure, and the result has the same lifetime. There is no need for closure scopes, or the pyramid-of-doom effect, and it's all checked by the compiler ahead-of-time to be memory-safe. You can even mutate through the wrapper structures.

My understanding is that Swift deliberately chose a different approach: we essentially consider all lifetimes to be unbounded. Everything should be able to be copied, to escape, and to live as long as it wants. The idea is that ARC will make it safe, and (hopefully) the optimizer will do a good-enough job that it doesn't perform too badly and doesn't bloat code-size too badly. The only reason to use pointers should be for C interop, and as mentioned above we can't verify that anyway.

The thing is that there are use-cases which benefit from a better expression of lifetimes, even in pure Swift. The LazyFilterSequence example above is one example which could benefit - we have withoutActuallyEscaping, but that doesn't really feel satisfactory and prevents you from, say, returning the LazyFilterSequence to the caller (with an annotation that enforces the lifetime constraint). Eventually, we may also have support for non-copyable and move-only types, and similarly we'd want to say that wrappers over these types also become non-copyable or move-only. This still isn't exactly what we want to express - we'd have both extremes, but nothing in the middle. It would be an improvement, though.

We also have features like _modify accessors which are totally awesome. I hope they become an official feature soon. They give us a window where we can yield an object's internal storage with a guaranteed callback before anybody else is able to read from the source object, which allow you to do some things that would otherwise require more advanced lifetime features. WebURL uses them to provide rust-like mutation through a wrapper view (by moving the URL's storage in to a wrapper, yielding the wrapper, and copying it back out again, the wrapper is able to hold a unique reference to its storage while it mutates), and swift-system's FilePath does something similar, and this all "just works" with COW. In Rust, my understanding is that this would require a "mutable borrow", but Swift builds it in to the language in an elegant way that doesn't burden users with too many details.

There are also use-cases even in pure Swift where it's very convenient to use pointer types. For instance, it can really help to limit code-size for generic functions if you can collapse all contiguous collections to a single specialisation. I think it would be really valuable to introduce safe alternatives for these situations - a (pointer, owner) pair, or a "deconstructed COW"(:boom::cow:).

6 Likes

Oh, I should note: even with _modify, I don't think we can truly provide Rust-style mutations through a wrapper. We can do the basic functionality where you write x.wrapper.mutate() and it applies to x, but functions in Rust can return mutable borrows, so you can hold a mutable reference and chain mutating operations. For example, in rust-url, you can do this:

let mut url = Url::parse("https://github.com/servo/rust-url/")?;
url.path_segments_mut().map_err(|_| "cannot be base")?
     .clear().push("logout");
//   ^       ^ - chained mutations
assert_eq!(url.as_str(), "https://github.com/logout");

I don't think it's possible to express such an API in Swift. At least, I can't think of a way.

This sounds a bit like what we already have with inout for letting mutations not escape. Although you can still make a copy of that value and let the copy escape.
I think the compiler can guarantee this relatively easily because all functions which you call also need to mark its arguments as inout if they want to mutated the value.

1 Like

I agree with Karl, I think having more control over value lifetimes would be excellent.

Just to focus on a pure Swift and Apple API example, consider the postProcess render callback api in RealityKit:

/// Closure invoked after RealityKit's post process pass. Enables encoding of Metal commands for additional
/// custom post processing effects.
///
/// Note This closure may execute on a different thread than the application main thread
///
/// - Parameters:
/// - PostProcessContext: Provides properties to assist in custom post processing pass
public var postProcess: ((ARView.PostProcessContext) -> Void)?

I think there are a couple things wrong here:

  1. It is almost definitely an error to keep a reference to that context outside of that closure. Doing so could probably end up starving the renderer of drawable textures, and this is a situation that could be avoided if RealityKit had the api tools to enforce this.

  2. It is almost definitely going to cause problems if you use the post processing context in an async dispatch block, as RealityKit is probably committing the command buffer as soon as the closure finishes executing, so your work will either not happen or potentially will cause a crash.

There are only two solutions here, the first is that the api author be aware of these problems and add proper documentation (and the api consumer be aware of these problems and read the documentation), the second is that the api author be aware of these problems and add certain annotations to control the lifetime of the value they are providing in their closure, which would greatly reduce the ways that an api consumer could misuse the api.

2 Likes

I think the ownership manifesto calls for inout variables, which would essentially be mutable borrows. That plus uncopyable/immovable types would take care of most of the existing edge cases, I think.

1 Like

For UnsafePointer parameters, we've been referring to them as "nonephemeral" when they escape the function. We have some diagnostics built around it. It would be nice to have a mandatory SIL pass that infers nonephemeral for UnsafePointer arguments that escape. That might improve the diagnostics around withUnsafePointer without adding a requirement to the closure's argument type. As others pointed out, a requirement would break a lot of source.

Your foo(body:) example is a nice demonstration of how a "noescape" qualifier would be generally useful though. As @dnadoba says, support for non-copyable (move-only) values would give us a strict form of that requirement, but as @karl says, it may be too strict for some interesting use cases.

1 Like