Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active May 28, 2024 18:18
Show Gist options
  • Save atrick/9ed708431e9a18059c22d37d18759c6d to your computer and use it in GitHub Desktop.
Save atrick/9ed708431e9a18059c22d37d18759c6d to your computer and use it in GitHub Desktop.
[Update] Compile-time Lifetime Dependency Annotations

Update to Compile-time Lifetime Dependency Annotations

The following additional sections have been added to the original pitch.

Additions to Proposed solutions

Dependent parameters

Normally, lifetime dependence is required when a nonescapable function result depends on an argument to that function. In some rare cases, however, a nonescapable function parameter may depend on another argument to that function. Consider a function with an inout parameter. The function body may reassign that parameter to a value that depends on another parameter. This is similar in principle to a result dependence.

func mayReassign(span: dependsOn(a) inout [Int], to a: [Int]) {
  span = a.span()
}

A selfDependsOn keyword is required to indicate that a method's implicit self depends on another parameter.

extension Span {
  mutating selfDependsOn(other) func reassign(other: Span<T>) {
    self = other // βœ… OK: 'self' depends on 'other'
  }
}

We've discussed how a nonescapable result must be destroyed before the source of its lifetime dependence. Similarly, a dependent argument must be destroyed before an argument that it depends on. The difference is that the dependent argument may already have a lifetime dependence when it enters the function. The new function argument dependence is additive, because the call does not guarantee reassignment. Instead, passing the 'inout' argument is like a conditional reassignment. After the function call, the dependent argument carries both lifetime dependencies.

  let a1: Array<Int> = ...
  var span = a1.span()
  let a2: Array<Int> = ...
  mayReassign(span: &span, to: a2)
  // 'span' now depends on both 'a1' and 'a2'.

Dependent properties

Structural composition is an important use case for nonescapable types. Getting or setting a nonescapable property requires lifetime dependence, just like a function result or an 'inout' parameter. There's no need for explicit annotation in these cases, because only one dependence is possible. A getter returns a value that depends on self. A setter replaces the current dependence from self with a dependence on newValue.

struct Container<Element>: ~Escapable {
  var element: Element {
    /* dependsOn(self) */ get { ... }
    /* selfDependsOn(newValue) */ set { ... }
  }

  init(element: Element) /* -> dependsOn(element) Self */ {...}
}

Conditional dependencies

Conditionally nonescapable types can contain nonescapable elements:

    struct Container<Element>: ~Escapable {
      var element: /* dependsOn(self) */ Element

      init(element: Element) -> dependsOn(element) Self {...}

      func getElement() -> dependsOn(self) Element { element }
    }

    extension Container<E> { // OK: conforms to Escapable.
      // Escapable context...
    }

Here, Container becomes nonescapable only when its element type is nonescapable. When Container is nonescapable, it inherits the lifetime of its single element value from the initializer and propagates that lifetime to all uses of its element property or the getElement() function.

In some contexts, however, Container and Element both conform to Escapable. In those contexts, any dependsOn in Container's interface is ignored, whether explicitly annotated or implied. So, when Container's element conforms to Escapable, the -> dependsOn(element) Self annotation in its initializer is ignored, and the -> dependsOn(self) Element in getElement() is ignored.

Immortal lifetimes

In some cases, a nonescapable value must be constructed without any object that can stand in as the source of a dependence. Consider extending the standard library Optional or Result types to be conditionally escapable:

enum Optional<Wrapped: ~Escapable>: ~Escapable {
  case none, some(Wrapped)
}

extension Optional: Escapable where Wrapped: Escapable {}

enum Result<Success: ~Escapable, Failure: Error>: ~Escapable {
  case failure(Failure), success(Success)
}

extension Result: Escapable where Success: Escapable {}

When constructing an Optional<NotEscapable>.none or Result<NotEscapable>.failure(error) case, there's no lifetime to assign to the constructed value in isolation, and it wouldn't necessarily need one for safety purposes, because the given instance of the value doesn't store any state with a lifetime dependency. Instead, the initializer for cases like this can be annotated with dependsOn(immortal):

extension Optional {
  init(nilLiteral: ()) dependsOn(immortal) {
    self = .none
  }
}

Once the escapable instance is constructed, it is limited in scope to the caller's function body since the caller only sees the static nonescapable type. If a dynamically escapable value needs to be returned further up the stack, that can be done by chaining multiple dependsOn(immortal) functions.

Depending on immutable global variables

Another place where immortal lifetimes might come up is with dependencies on global variables. When a value has a scoped dependency on a global let constant, that constant lives for the duration of the process and is effectively perpetually borrowed, so one could say that values dependent on such a constant have an effectively infinite lifetime as well. This will allow returning a value that depends on a global by declaring the function's return type with dependsOn(immortal):

let staticBuffer = ...

func getStaticallyAllocated() -> dependsOn(immortal) BufferReference {
  staticBuffer.bufferReference()
}

Depending on an escapable BitwiseCopyable value

The source of a lifetime depenence may be an escapable BitwiseCopyable value. This is useful in the implementation of data types that internally use UnsafePointer:

struct Span<T>: ~Escapable {
  ...
  // The caller must ensure that `unsafeBaseAddress` is valid over all uses of the result.
  init(unsafeBaseAddress: UnsafePointer<T>, count: Int) dependsOn(unsafeBaseAddress) { ... }
  ...
}

By convention, when the source of a dependence is escapable and BitwiseCopyable, it should have an "unsafe" label, such as unsafeBaseAddress above. This communicates to anyone who calls the function, that they are reponsibile for ensuring that the value that the result depends on is valid over all uses of the result. The compiler can't guarantee safety because BitwiseCopyable types do not have a formal point at which the value is destroyed. Specifically, for UnsafePointer, the compiler does not know which object owns the pointed-to storage.

var span: Span<T>?
let buffer: UnsafeBufferPointer<T>
do {
  let storage = Storage(...)
  buffer = storage.buffer
  span = Span(unsafeBaseAddress: buffer.baseAddress!, count: buffer.count)
  // πŸ”₯ 'storage' may be destroyed
}
decode(span!) // πŸ‘Ώ Undefined behavior: dangling pointer

Normally, UnsafePointer lifetime guarantees naturally fall out of closure-taking APIs that use withExtendedLifetime:

extension Storage {
  public func withUnsafeBufferPointer<R>(
    _ body: (UnsafeBufferPointer<Element>) throws -> R
  ) rethrows -> R {
    withExtendedLifetime (self) { ... }
  }
}

let storage = Storage(...)
storage.withUnsafeBufferPointer { buffer in
  let span = Span(unsafeBaseAddress: buffer.baseAddress!, count: buffer.count)
  decode(span!) // βœ… Safe: 'buffer' is always valid within the closure.
}

Standard library extensions

Conditionally nonescapable types

The following standard library types will become conditionally nonescapable: Optional, ExpressibleByNilLiteral, and Result.

MemoryLayout will suppress the escapable constraint on its generic parameter.

unsafeLifetime helper functions

The following two helper functions will be added for implementing low-level data types:

/// Replace the current lifetime dependency of `dependent` with a new copied lifetime dependency on `source`.
///
/// Precondition: `dependent` has an independent copy of the dependent state captured by `source`.
func unsafeLifetime<T: ~Copyable & ~Escapable, U: ~Copyable & ~Escapable>(
  dependent: consuming T, dependsOn source: borrowing U)
  -> dependsOn(source) T { ... }

/// Replace the current lifetime dependency of `dependent` with a new scoped lifetime dependency on `source`.
///
/// Precondition: `dependent` depends on state that remains valid until either:
/// (a) `source` is either destroyed if it is immutable,
/// or (b) exclusive to `source` access ends if it is a mutable variable.
func unsafeLifetime<T: ~Copyable & ~Escapable, U: ~Copyable & ~Escapable>(
  dependent: consuming T, scoped source: borrowing U)
  -> dependsOn(scoped source) T {...}

These are useful for nonescapable data types that are internally represented using escapable types such as UnsafePointer. For example, some methods on Span will need to derive a new Span object that copies the lifetime dependence of self:

extension Span {
  consuming func dropFirst() -> Span<T> {
    let local = Span(base: self.base + 1, count: self.count - 1)
    // 'local' can persist after 'self' is destroyed.
    return unsafeLifetime(dependent: local, dependsOn: self)
  }
}

Since self.base is an escapable value, it does not propagate the lifetime dependence of its container. Without the call to unsafeLifetime, local would be limited to the local scope of the value retrieved from self.base, and could not be returned from the method. In this example, unsafeLifetime communicates that all of the dependent state from self has been copied into local, and, therefore, local can persist after self is destroyed.

Additions to Detailed design

Dependency semantics by example

This section illustrates the semantics of lifetime dependence one example at a time for each interesting variation. The following helper functions will be useful: Array.span() creates a scoped dependence to a nonescapable Span result, copySpan() creates a copied dependence to a Span result, and parse uses a Span.

extension Array {
  // The returned span depends on the scope of Self.
  borrowing func span() -> /* dependsOn(scoped self) */ Span<Element> { ... }
}

// The returned span copies dependencies from 'arg'.
func copySpan<T>(_ arg: Span<T>) -> /* dependsOn(arg) */ Span<T> { arg }

func parse(_ span: Span<Int>) { ... }

Scoped dependence on an immutable variable

let a: Array<Int> = ...
let span: Span<Int>
do {
  let a2 = a
  span = a2.span()
}
parse(span) // πŸ›‘ Error: 'span' escapes the scope of 'a2'

The call to span() creates a scoped dependence on a2. A scoped dependence is determined by the lifetime of the variable, not the lifetime of the value assigned to that variable. So the lifetime of span cannot extend into the larger lifetime of a.

Copied dependence on an immutable variable

Let's contrast scoped dependence shown above with copied dependence on a variable. In this case, the value may outlive the variable it is copied from, as long as it is destroyed before the root of its inherited dependence goes out of scope. A chain of copied dependencies is always rooted in a scoped dependence.

An assignment that copies or moves a nonescapable value from one variable into another copies any lifetime dependence from the source value to the destination value. Thus, variable assignment has the same lifetime copy semantics as passing an argument using a dependsOn() annotation without a scoped keyword. So, the statement let temp = span has identical semantics to let temp = copySpan(span).

let a: Array<Int> = arg
let final: Span<Int>
do {
  let span = a.span()
  let temp = span
  final = copySpan(temp)
}
parse(final) // βœ… Safe: still within lifetime of 'a'

Although the result of copySpan depends on temp, the result of the copy may be used outside of the temp's lexical scope. Following the source of each copied dependence, up through the call chain if needed, eventually leads to the scoped dependence root. Here, final is the end of a lifetime dependence chain rooted at a scoped dependence on a: a -> span -> temp -> {copySpan argument} -> final. final is therefore valid within the scope of a even if the intermediate copies have been destroyed.

Copied dependence on a mutable value

First, let's add a mutable method to Span:

extension Span {
  mutating func droppingPrefix(length: Int) -> /* dependsOn(self) */ Span<T> {
    let result = Span(base: base, count: length)
    self.base += length
    self.count -= length
    return result
  }
}

A dependence may be copied from a mutable ('inout') variable. In that case, the dependence is inherited from whatever value the mutable variable holds when it is accessed.

let a: Array<Int> = ...
var prefix: Span<Int>
do {
  var temp = a.span()
  prefix = temp.droppingPrefix(length: 1) // access 'temp' as 'inout'
  // 'prefix' depends on 'a', not 'temp'
}
parse(prefix) // βœ… Safe: still within lifetime of 'a'

Scoped dependence on 'inout' access

Now, let's return to scoped dependence, this time on a mutable variable. This is where exclusivity guarantees come into play. A scoped depenendence extends an access of the mutable variable across all uses of the dependent value. If the variable mutates again before the last use of the dependent, then it is an exclusivity violation.

let a: Array<Int> = ...
a[i] = ...
let span = a1.span()
parse(span) // βœ… Safe: still within 'span's access on 'a'
a[i] = ...
parse(span) // πŸ›‘ Error: simultaneous access of 'a'

Here, a1.span() initiates a 'read' access on a1. The first call to parse(span) safely extends that read access. The read cannot extend to the second call because a mutation of a1 occurs before it.

Dependence reassignment

We've described how a mutable variable can be the source of a lifetime dependence. Now let's look at nonescapable mutable variables. Being nonescapable means they depend on another lifetime. Being mutable means that dependence may change during reassignment. Reassigning a nonescapable 'inout' sets its lifetime dependence from that point on, up to either the end of the variable's lifetime or its next subsequent reassignment.

func reassign(_ span: inout Span<Int>) {
  let a: Array<Int> = ...
  span = a.span() // πŸ›‘ Error: 'span' escapes the scope of 'a'
}

Reassignment with argument dependence

If a function takes a nonescapable 'inout' argument, it may only reassign that argument if it is marked dependent on another function argument that provies the source of the dependence.

func reassignWithArgDependence(_ span: dependsOn(arg) inout [Int], _ arg: [Int]) {
  span = arg.span() //  βœ… OK: 'span' already depends on 'arg' in the caller's scope.
}

Conditional reassignment creates conjoined dependence

'inout' argument dependence behaves like a conditional reassignment. After the call, the variable passed to the 'inout' argument has both its original dependence along with a new dependence on the argument that is the source of the argument dependence.

let a1: Array<Int> = arg
do {
  let a2: Array<Int> = arg
  var span = a1.span()
  testReassignArgDependence(&span, a2) // creates a conjoined dependence
  parse(span) // βœ… OK: within the lifetime of 'a1' & 'a2'
}
parse(span) // πŸ›‘ Error: 'span' escapes the scope of 'a2'

Additions to Grammar

TODO

  • dependent parameters

  • 'immortal' keyword

Additions to Future extensions

Value component lifetime

In the current design, aggregating multiple values merges their scopes.

struct Container<Element>: ~Escapable {
  var a: /*dependsOn(self)*/ Element
  var b: /*dependsOn(self)*/ Element

  init(a: Element, b: Element) -> dependsOn(a, b) Self {...}
}

This can have the effect of narrowing the lifetime scope of some components:

var a = ...
{
  let b = ...
  let c = Container<Element>(a: a, b: b)
  a = c.a // πŸ›‘ Error: `a` outlives `c.a`, which is constrained by the lifetime of `b`
}

In the future, the lifetimes of multiple values can be represented independently by attaching a @lifetime attribute to a stored property and referring to that property's name inside dependsOn annotations:

struct Container<Element>: ~Escapable {
  @lifetime
  var a: /*dependsOn(self.a)*/ Element
  @lifetime
  var b: /*dependsOn(self.b)*/ Element

  init(a: Element, b: Element) -> dependsOn(a -> .a, b -> .b) Self {...}
}

The nesting level of a component is the inverse of the nesting level of its lifetime. a and b are nested components of Container, but the lifetime of a Container instance is nested within both lifetimes of a and b.

Abstract lifetime components

Lifetime dependence is not always neatly tied to stored properties. Say that our Container now holds multiple elements within its own storage. We can use a top-level @lifetime annotation to name an abstract lifetime for all the elements:

@lifetime(elements)
struct Container<Element>: ~Escapable {
  var storage: UnsafeMutablePointer<Element>

  init(element: Element) -> dependsOn(element -> .elements) Self {...}

  subscript(position: Int) -> dependsOn(self.elements) Element
}

Note that a subscript setter reverses the dependence: dependsOn(newValue -> .elements).

As before, when Container held a single element, it can temporarily take ownership of an element without narrowing its lifetime:

var c1: Container<Element>
{
  let c2 = Container<Element>(element: c1[i])
  c1[i] = c2[i] // OK: c2[i] can outlive c2
}

Now let's consider a View type, similar to Span, that provides access to a borrowed container's elements. The lifetime of the view depends on the container's storage. Therefore, the view depends on a borrow of the container. The container's elements, however, no longer depend on the container's storage once they have been copied. This can be expressed by giving the view an abstract lifetime for its elements, separate from the view's own lifetime:

@lifetime(elements)
struct View<Element>: ~Escapable {
  var storage: UnsafePointer<Element>

  init(container: Container)
    -> dependsOn(container.elements -> .elements) // Copy the lifetime associated with container.elements
    Self {...}

  subscript(position: Int) -> dependsOn(self.elements) Element
}

@lifetime(elements)
struct MutableView<Element>: ~Escapable, ~Copyable {
  var storage: UnsafeMutablePointer<Element>
  //...
}

extension Container {
  // Require a borrow scope in the caller that borrows the container
  var view: dependsOn(borrow self) View<Element> { get {...} }

  var mutableView: dependsOn(borrow self) MutableView<Element> { mutating get {...} }
}

Now an element can be copied out of a view v2 and assigned to another view v1 whose lifetime exceeds the borrow scope that constrains the lifetime of v2.

var c1: Container<Element>
let v1 = c1.mutableView
{
  let v2 = c1.view // borrow scope for `v2`
  v1[i] = v2[i] // OK: v2[i] can outlive v2
}

To see this more abstractly, rather than directly assigning, v1[i] = v2[i], we can use a generic interface:

func transfer(from: Element, to: dependsOn(from) inout Element) {
  to = from
}

var c1: Container<Element>
let v1 = c1.mutableView
{
  let v2 = c1.view // borrow scope for `v2`
  transfer(from: v2[i], to: &v1[i]) // OK: v2[i] can outlive v2
}

Protocol lifetime requirements

Value lifetimes are limited because they provide no way to refer to a lifetime without refering to a concrete type that the lifetime is associated with. To support generic interfaces, protocols need to refer to any lifetime requirements that can appear in interface.

Imagine that we want to access view through a protocol. To support returning elements that outlive the view, we need to require an elements lifetime requirement:

@lifetime(elements)
protocol ViewProtocol {
  subscript(position: Int) -> dependsOn(self.elements) Element
}

Let's return to View's initializer;

@lifetime(elements)
struct View<Element>: ~Escapable {
  init(container: borrowing Container) ->
    // Copy the lifetime assoicate with container.elements
    dependsOn(container.elements -> .elements)
    Self {...}
}

This is not a useful initializer, because View should not be specific to a concrete Container type. Instead, we want View to be generic over any container that provides elements that can be copied out of the container's storage:

@lifetime(elements)
protocol ElementStorage: ~Escapable {}

@lifetime(elements)
struct View<Element>: ~Escapable {
  init(storage: ElementStorage) ->
    // Copy the lifetime assoicate with storage.elements
    dependsOn(storage.elements -> .elements)
    Self {...}
}

Structural lifetime dependencies

A scoped dependence normally cannot escape the lexical scope of its source variable. It may, however, be convenient to escape the source of that dependence along with any values that dependent on its lifetime. This could be done by moving the ownership of the source into a structure that preserves any dependence relationships. A function that returns a nonescapable type cannot currently depend on the scope of a consuming parameter. But we could lift that restriction provided that the consumed argument is moved into the return value, and that the return type preserves any dependence on that value:

struct OwnedSpan<T>: ~Copyable & ~Escapable{
  let owner: any ~Copyable
  let span: dependsOn(scope owner) Span<T>

  init(owner: consuming any ~Copyable, span: dependsOn(scope owner) Span<T>) -> dependsOn(scoped owner) Self {
    self.owner = owner
    self.span = span
  }
}

func arrayToOwnedSpan<T>(a: consuming [T]) -> OwnedSpan<T> {
  OwnedSpan(owner: a, span: a.span())
}

arrayToOwnedSpan creates a span with a scoped dependence on an array, then moves both the array and the span into an OwnedSpan, which can be returned from the function. This converts the original lexically scoped dependence into a structural dependence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment