Control escapability of closure parameters?

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