I'm super excited by this direction. Lifetimes in the type system have motivated an incredible type system in Rust, including generic associated types, which I am missing dearly in Swift. I also find this model much easier to reason about than the value-based one, because I can reuse the reasoning about the type system that I have already built up over the years.
I also like scope as a keyword in generic signatures. There is precedent for distinguishing different kinds of generic parameters with keywords like each for parameter packs and let for value generics. And while I can see @scoped working as a spelling for applying a concrete scope restriction, the document is much more tentative about @unscoped, and notes that unbound types really have a whole expected scope signature rather than a boolean property. That made me wonder whether this model would be more naturally expressed by fully embracing existing generic syntax. It would leave the door open to generalizing some of this syntax to regular type parameters in the future, and I have the feeling it would also offer better progressive disclosure than introducing new attributes that nevertheless participate deeply in the type system.
One idea for this would be to introduce a separate implicit generic argument section:
struct Span<Element; scope storage>
It would always come last and be introduced using a semicolon (for example). The key is that this section could be omitted in ordinary source, preserving the existing spelling:
Span<Int>
// understood as:
Span<Int; scope _> // where the scope is inferred
The semicolon is just one possible way to separate the implicit scope section from the type's existing visible generic arguments. I mainly like that it preserves the idea of Span<Element> at the declaration instead of making its scope look like an ordinary generic argument.
This implicit scope section would not exist on every type. It would belong to types that are unconditionally ~Escapable because their values retain some scoped capability, such as Span, Ref, or MutableRef. A generic wrapper that becomes nonescapable only because one of its arguments is nonescapable (like Optional) can propagate the scopes already bound into that argument through ordinary type substitution.
Ordinary escapable types such as Int also have no scope argument of their own. However, every borrow or exclusive access has a scope parameter attached to the ownership convention:
borrowing T
// conceptually:
borrowing<scope _> T
inout T
// conceptually:
inout<scope _> T
And now that Swift has Ref and MutableRef, there's a type level analogy:
borrowing<scope s> T
// conceptually:
Ref<T; scope s>
inout<scope s> T
// conceptually:
MutableRef<T; scope s>
borrowing and inout describe the exclusivity of the access, while the scope parameter describes how long that access remains valid.
More complex examples from the document could then look like this:
struct SMR<T; scope r, s> {
let ref: MutableRef<Span<T; scope s>; scope r>
}
struct SpanPair<T; scope left, right>: ~Escapable {
let left: Span<T; scope left>
let right: Span<T; scope right>
}
func returnEither<scope a, b>(
spanOne: Span<Int; scope a>,
spanTwo: Span<Int; scope b>
) -> Span<Int; scope a & b>
Scopes do not necessarily need to be projected back out of values. As with ordinary generic arguments, a function that needs to express a relationship between scopes can introduce names for them in its generic signature:
func returnFirst<scope first>(
spanOne: Span<Int; scope first>,
spanTwo: Span<Int>
) -> Span<Int; scope first>
The explicit scope parameters are only needed when inference is insufficient or the programmer wants to document a particular relationship. This is one more way in which this syntax sticks to patterns familiar to existing Swift users (versus the decltype-like syntax @scoped(spanOne) from the document).
Borrowing and inout accesses also introduce scopes, but these scopes do not come from generic arguments in the parameter's nominal type. They come from the ownership convention itself. I see two ways of spelling this, I'm not entirely sure which one is better long term.
Because Swift's self parameter is implicit and its ownership convention is written before the declaration keyword, one option would be to let that convention introduce a name for its access scope:
borrowing<scope access> func visit(
visitor: (borrowing<scope access> Element) -> Void
)
Here access names the borrow of self performed for the call, and the annotation on Element says that its borrow remains valid for the same scope.
This syntax is somewhat special because borrowing<scope access> both introduces the scope parameter and binds it to the implicit self access. Another option would be to make the implicit parameter explicit:
func visit<scope access>(
self: borrowing<scope access> Self,
visitor: (borrowing<scope access> Element) -> Void
)
In this form, access is introduced in the ordinary generic signature and then applied to the ownership convention of self, just like any other generic argument.
The first spelling is probably more swifty today. But the explicit form may ultimately be more principled as ownership, isolation, and lifetime properties of the hidden self parameter become increasingly important (and complex).
Also: if Self is itself nonescapable, its concrete scope arguments remain part of the Self type and are separate from access, which names this particular call's temporary borrow.
Generic associated types
The document introduces the distinction between bound and unbound types. This seems like the strongest motivation for using generic syntax. Consider:
typealias ISpan = Span<UInt32>
This could be explained as implicitly abstracting over the omitted scope:
typealias ISpan<scope s> = Span<UInt32; scope s>
When ISpan is used as the type of a value, the scope argument is inferred and bound at that use.
The same reasoning applies to an unbound associated type. An attribute such as:
@unscoped associatedtype BorrowingIterator
looks like it switches on one special property. But as the document points out, an unbound type actually has an expected scope signature.
Embracing GATs, an adaptation of Iterable could look something like this:
protocol Iterable: ~Copyable, ~Escapable {
associatedtype Element: ~Copyable
associatedtype Failure: Error = Never
associatedtype BorrowingIterator<scope access>:
BorrowingIteratorProtocol<Element, Failure>
& ~Copyable
& ~Escapable
where Self: access
borrowing<scope access> func makeBorrowingIterator()
-> BorrowingIterator<scope access>
}
(The exact syntax of where Self: access is again illustrative.)
The associated type declaration says that BorrowingIterator is a family of types indexed by an access scope. The where clause says that the family only needs to be defined for access scopes contained within the scope in which Self is valid.
I think this is pleasantly intuitive if you already understand generic syntax. Instead of one associated type, the conformance provides a family of associated types indexed by a scope parameter.
More importantly, it gives us ordinary generic operations for composing these families. An implementation could write:
typealias BorrowingIterator<scope access> =
PrefixIterator<Base.BorrowingIterator<scope access>>
The scope-indexed associated type is explicitly instantiated before being composed in an ordinary generic wrapper. With only a @unscoped attribute, the language would still eventually need some way to instantiate/project/equate/compose such unbound associated types. At that point it seems like we would be reconstructing a parallel GAT system with special purpose rules.
To be clear, I do not want to predicate lifetime features on the existence of fully fledged GATs or other type system features. We could start with a very limited form that permits only a single scope parameter on an otherwise non generic associated type.
I also don't want to seriously suggest any particular concrete syntax here. My argument is more about the direction:
- By basing scope abstraction on existing generic syntax we can reuse a mental model Swift programmers already have and offer better progressive disclosure than either
@scoped or the current @lifetime.
- Treating "unbound" as an implicit generic signature leaves the door open to more powerful scope abstractions and potentially broader improvements to Swift's generics system in the future.
Even if none of that broader generalization happens in the short term, I think it would be valuable for the lifetime model to be built on concepts that can naturally grow in that direction. That could also help justify the scale of this refactor, as the machinery would not only improve Swift's ability to abstract over scopes, but could eventually provide the foundation for stronger abstraction capabilities over ordinary types as well.