What is the current status of 'lexical lifetimes'?

in this 'roadmap' evolution document from a few years ago it describes how local variables in Swift would eventually have their lifetimes 'anchored' to their lexical scope. mainly out of curiosity, i'm wondering: is this behavior fully implemented today? if so, when exactly did that change occur? i found this documentation in the repo – is this the best/most-up-to-date resource on this subject?

2 Likes

Hi Jamie, thanks for the question.

This behavior was implemented in Swift 5.7.

The current docs look accurate to me based on a quick reread. It's worth including a direct link to the section on Deinit Barriers, which is more relevant to this audience. You can jump straight there after reading only the first paragraph on Variable Lifetimes. I'll summarize these rules below so people don't need to dissect the docs.

This document is too formal to serve as a programmer's guide; the target audience is compiler implementers. It also refers to several internal features that allow precise control over the behavior for the purpose of evaluating and fine-tuning the implementation. Features like @_eagerMove and @_noImplicitCopy will likely be exposed to the language as ownership modifiers: borrowing and consuming--that's work-in-progress.

These precise rules do help ensure that all the layers of the language implementation agree and give implementers a shared understanding of what is and is not a compiler bug. They came about to balance the optimizations that we have come to rely on with the practical need to support some common uses of weak references an unsafe pointers that aren't formally supported by the language. The implementation rules aren't necessarily guidelines that we want to adopt at the language level, or broadly promote.

Source code that follows recommended use of withExtendedLifetime will always behave as expected--programmers don't think about the rules in this document. Below, I'll summarize the "Variable Lifetime" rules. But first, as a programming guideline I advise the following:

Swift class deinit's are unordered. This includes Swift structs that contain class references, including String and Data. A deinit can run immediately after the last program side effect that requires access to the class object.

Swift's ~Copyable struct deinits are different. They are strictly ordered. They run when the variable is consumed or goes out of scope. If you want to rely on the order of deinitialization, use ~Copyable.

Our general advice is to avoid using class deinits for ordered side effects. This is good advice regardless of optimization potential. It is generally problematic to rely on side effects that happen implicitly after the last reference to an object dies.

Nonetheless, sometimes it is convenient to rely on class deinitialization side effects. In these exceptional cases, use:
withExtendedLifetime(object) { ... }
to explicitly mark the scope that needs to be ordered with respect to deinitialization. For example, if you need a class deinit of object to run after some unsafe operation (like calling C), then put the unsafe operation inside
withExtendedLifetime(object) { /* unsafe code */ }.

To avoid nested closures, this works well:
defer { withExtendedLifetime(object) {} }

The empty curly braces above are an unfortunate artifact (hopefully we'll eliminate that in an upcoming proposal pitched here.


Here's a brief summary of the Variable Lifetimes rules that the implementation follows. Again, I don't advise that programmers rely on these subtleties...

Given a local variable that holds a "regular" class reference and a side-effect that occurs within the variable scope, the class deinitialzer can be forced to run after that side-effect using withExtendedLifetime:

{
  var x: AnyClass = ...
  withExtendedLifetime(x) {
    use(x)
    <side effect>
  }
}

If <side-effect> above is any of the following, the compiler will automatically extend the lifetime, without the help of withExtendedLifetime:

  • a use of a weak or unowned reference to an object kept alive by x
  • a use of an unsafe pointer into an object kept alive by x
  • an "externally visible" side effect, such as I/O, closing a file handle, or thread synchronization.

These three cases could be written as follows without changing behavior:

{
  var x = ...
  use(x)
  <side effect>
}

Calling any function outside of the current Swift module, for which the compiler has no information, is conservatively assumed to access weak or unowned references, access unsafe pointers, and execute external side effects.

If, however, the local variable is a "copy-on-write value type", then all side-effects are ignored after the variables last use:

{
  let arrayOfRefs = [Ref()]
  weak var weakRef = arrayOfRefs[0]
  use(arrayOfRefs) // destroyed at last use
  print(weakRef!)  // runtime trap
}

This behavior does still trip people up sometimes, but copy-on-write types heavily depend on lifetime optimization for predictable performance.

Copy-on-write value types are: Array, ContiguousArray, Set, Dictionary, and String. In the future, Swift could provide a "copy-on-write" storage declaration that would allow non-standard-library types to opt into this lifetime optimization, as well as other optimizations.

11 Likes