[Pitch] Non-Escapable Types and Lifetime Dependency

This proposal contains quite interesting ideas, but it leaves me a little concerned about the complexity that it brings to Swift in general. Further, I think that some of Swift features offer unique opportunities to simplify interactions with lifetime and those seem to be missing.

To give a little context, I am one of the designers of Hylo, a programming language that shamelessly steals from Swift but emphasizes even more on mutable value semantics. @dabrahams talked about it a little while ago (the language was called Val back then) and I'm not about to hijack this post to advertise my language but I'll just introduce some key relevant features.

All types are considered immovable and non-copyable by default in Hylo. So you can consider that any generic parameter has a ~Escapable bound by default. You can also consider that all function parameters have a borrowing modifier by default. We also have inout, which has similar semantics as Swift.

When you can express non-copyable types, you typically end up with a linear (or at least affine) type system, like Rust. When you can express immovable types, you typically need some additional mechanism to communicate the result of an abstraction. In Rust we use references. But since Hylo is all about value semantics, what do we do?

Hylo has subscripts, like Swift:

subscript min<T: Comparable>(_ x: T, _ y: T): T {
  if y > x { yield y } else { yield x }
}

This declaration should look like the _read accessor of a subscript in Swift. We must write min that way in Hylo because the instance of an unconstrained T can't be moved or copied (T would have both ~Copyable and ~Escapable bounds in Swift). So there is no way to "return" the minimum of two values. But we can "project" one!

Hylo also lets us bind the result of a subscript. That is in contrast to Swift where subscripts can only be used as sub-expressions. So we can write the following:

public fun foo() -> Int {
  let a = 4
  let b = 2
  let c = min[a, b]
  print(c)
  return c // error: `c`'s lifetime is bound to `a` and `b`
}

The program doesn't compile because it is trying to escape a value whose lifetime is bound to local variables. So as we can see, we used subscripts to define a simple lifetime dependency: the result of a subscript depends on the lifetime of the subscript's arguments.

This approach lets us implement first-order dependencies without the proposed dependsOn annotation. We only need to declare subscripts instead of functions. The latter return independent values and the former project lifetime-dependent ones.

The proposed dependsOn annotation offers a finer control over the arguments whose lifetimes are being tracked. But we can achieve a similar result in Hylo using different passing conventions. For example:

subscript min(_ x: T, _ y: T, by precedes: sink [](T, T) -> Bool): T {
  if precedes(y, x) { yield y } else { yield x }
}

Here, we said that precedes is an independent value because it is passed with the sink convention. That means the lifetime of the argument won't be part of the lifetime of the projected value. So you can think of this system having different defaults than the one proposed above: projected values are lifetime-dependent on all arguments unless explicitly stated otherwise.

Note that we can also project mutable values, which also fits very well into Swift's subscript design:

subscript min(_ x: yielded T, _ y: yielded T, by precedes: sink [](T, T) -> Bool): T {
  let { if precedes(y, x) { yield y } else { yield x } }
  inout { if precedes(y, x) { yield &y } else { yield &x } }
}

public fun main() {
  var a = 4
  var b = 2
  inout c = &min[&a, &b, by: (x, y) => x < y]
  print(c) // Prints "2"
  &c += 1
  print(b) // Prints "3"
}

Here, yielded means that the argument will be taken with the same capability as the one requested on the projection, which is inout in this example. So not only can we express a value dependent on the lifetime of an abstraction's arguments, we can also express a value dependent on the mutability of an abstraction's arguments.

Hylo suffers similar limitations as the one described in @Joe_Groff's post w.r.t. to aggregates of lifetime-dependent things. We can express a pair of two remote parts but we can't track individual lifetimes. FWIW, I personally believe the complexity of any system capable of doing that gets more expensive than the benefits it brings. I would be okay if people had to write safe APIs abstracting over unsafe features to address this issue. That is why I am concerned about Swift embracing such a complexity.

Nonetheless, I find the idea of idea of expressing lifetimes as dependent members interesting. I can buy that it would help library evolution but I remain skeptical about scalability.

I'd also like to note that an annotation of the form dependsOn(self.elements) involves a path-dependent type, which is a feature that has been thoroughly explored in Scala. In fact, named lifetimes as presented in the proposal are very reminiscent of Scala's capture checking, which is an ongoing project that aims at generalizing Rust lifetimes. It might be worth a look.

Note that the team developing capture checking is currently contemplating quantification over capture sets (i.e., quantification over the "arguments" that would go in dependsOn), which sadly comes back full circle to Rust's lifetime parameters. That is why I'm concerned about scalability.

15 Likes