Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active October 25, 2024 20:59
Show Gist options
  • Save atrick/288ccc064bcaf1e7ab7225a843460ccc to your computer and use it in GitHub Desktop.
Save atrick/288ccc064bcaf1e7ab7225a843460ccc to your computer and use it in GitHub Desktop.

Preview of Lifetime Definitions

Preface

Lifetime definitions are an indisputably complicated feature. At least three aspects of the Swift language make the semantics challenging to express syntactically:

  1. Swift lacks first-class references. This leads to a dichotomy between concrete vs. generic lifetimes. Concrete lifetimes imply a required lifetime dependence. Generic lifetimes represent potential lifetime dependence.

  2. Unconditionally nonescapable types have fundamentally different lifetime constraints than conditionally escapable types, but the difference between these two flavors of lifetime dependent types is not syntactically apparent.

  3. Swift stored property definitions are not, by default, part of a type's public interface, forcing some decoupling between a type's properties and the lifetime constraints imposed by those properties.

As a result, much of the rules described in the sections below depend on context. Although the semantics aren't readily apparent from syntax, the rules are actually self-explanatory and reasonably obvious once put into practice.

Introduction

Nonescapable standard library types, such as Span, are currently usable in Swift without adding lifetime definitions to the language. Lifetime definitions will allow Swift programmers to define their own nonescapable types.

Lifetime definitions are public interface

Each ~Esacapable type has at least one lifetime definition. Here we'll often refer them simply as lifetimes. For simple aggregates, lifetimes could be inferred from a type's stored properties. Whether inferred or not, the lifetime definitions need to be part of a type's public interface. This proposal avoids inferring public interface features from internal definitions, instead relying discrepancies between them to be diagnosed.

Concrete lifetime definitions

A lifetime definition is associated with a type in the generic context of its parent type. A concrete lifetime is associated with an unconditionally nonescapable type, typically Self. Every unconditionally nonescapable type defines at least one concrete lifetime. Here, RawSpan declares a storage lifetime and associates it with Self:

@lifetime(storage: Self) // concrete lifetime
struct RawSpan: ~Escapable {
  let _pointer: UnsafeRawPointer
  //...
}

Conceptually, storage is associated with RawSpan's _pointer, but the explicit lifetime declaration provides a more meaningful name, and associating the lifetime with Self allows useful dependency inferrence.

Initialization requirements

An initializer of an unconditionally nonescapable type must satisfy a lifetime dependency for each concrete lifetime. The dependency can be satisfied by inferred or explicit lifetime annotation on the initializer:

@lifetime(storage: Self) // concrete lifetime
struct RawSpan: ~Escapable {
    // @lifetime(rawSpan) - inferred from 'Self'
    init(_ rawSpan: RawSpan) { ... }

    @lifetime(unsafePointer) // explicit borrow scope
    init(unsafePointer: UnsafeRawPointer, count: Int) { ... } 
}

Concrete lifetimes are unconditional

A concrete lifetime cannot be associated with a conditionally escapable type. The following definition is illegal:

@lifetime(self: Self)
struct A<T: ~Escapable>: ~Escapable {}

// πŸ›‘ `A` cannot be conditionally escapable because it's `self` lifetime
// is associated with concrete type: `Self`.
extension A: Escapable where T: Escapable {}

This means that unconditionally nonescapable public types can never be extended to be conditionally escapable.

Explicit vs. default lifetime

A single concrete lifetime on Self could be inferred as the default lifetime for an unconditionally nonescapable type:

// @lifetime(_: Self) - unnamed, concrete lifetime inferred
struct RawSpan: ~Escapable {
  let _pointer: UnsafeRawPointer
  //...
}

Instead, we choose to require explicit lifetime annotation on all unconditionally nonescapabe types:

  1. A lifetime annotation clearly distinguishes unconditionally nonescapable types from conditionally escapable types: an important distinction that is otherwise absent from the type definition.

  2. Forcing a lifetime annotation makes the irreversible library evolution requirement explicit. The author of SpanPair, for example, should deliberately decide whether each stored property should have a separate concrete lifetime before shipping the interface:

@lifetime(first: Span)
@lifetime(second: Span)
public struct SpanPair: ~Escapable {
  let first: Span
  let second: Span
}
  1. Each concrete lifetime creates a requirement that must be satisfied by all the type's intializers. An explicit annotation communicates that requirement to maintainers of the type.

Generic lifetime definitions

A generic lifetime is associated with an a conditionally escapable type. Here, Optional associates an unnamed lifetime with Wrapped:

// @lifetime(_: Wrapped) - implicit generic lifetime
public enum Optional<Wrapped: ~Copyable & ~Escapable>: ~Copyable & ~Escapable {
  case none
  case some(Wrapped)
}

Generic lifetimes do not require an explicit lifetime declaration; its name serves no purpose, and a separate generic lifetime can be inferred for each generic parameter.

// @lifetime(_: T) - implicit generic lifetime
// @lifetime(_: U) - implicit generic lifetime
public struct A<T: ~Escapable, U: ~Escapable>: ~Escapable {
  let t: T
}

Here, the generic lifetime associated with U is unnecessary, but it places no additional restriction on the usage of its parent type, A. Unlike concrete lifetimes, generic lifetimes are not required for type initializtion; they are only relevant when their associated type is used in a method signature.

Multiple lifetime definitions

Span is unconditionally nonescapable, and, therefore declares a concrete lifetime. Its Element type, however, is conditionally nonescapable; it will be associated with a separate, generic lifetime.

@lifetime(storage: Self) // concrete lifetime
// @lifetime(_: Element) // generic lifetime
struct Span<Element: ~Copyable & ~Escapable>: ~Escapable {
  let _pointer: UnsafeRawPointer
  //...
}

Nested concrete lifetimes

Nested concrete lifetimes can be exposed simply by associating the outer lifetime with the inner type:

@lifetime(storage: RawSpan) // concrete nested lifetime
struct TrivialSpan<Element: BitwiseCopyable> {
  let rawSpan: RawSpan<Element>
}

The inner type may not be in the public interface, preventing the outer lifetime from being associated with the inner lifetime:

@lifetime(storage: Self)
public struct TrivialSpan<Element: BitwiseCopyable> {
  let rawSpan: InternalRawSpan<Element>
}

As a convenience--to avoid specifying lifetime dependencies on all the internal interfaces--lifetimes can be aliased. Lifetime aliases can be but need not be in the public interface:

@lifetime(storage: Self)
public struct TrivialSpan<Element: BitwiseCopyable> {
  lifetimealias storage = InternalRawSpan.storage

  let rawSpan: InternalRawSpan<Element>
}

Lifetime aliases may also be useful in associating a lifetime with multiple types.

Protocol requirements

A protocol may define both concrete and generic lifetimes. Each generic ~Escapable type in the protocol declaration, including Self will have a generic lifetime, just like other generic types:

// @lifetime(_: Self) - inferred
// @lifetime(_: T) - inferred
protocol P: ~Escapable {
  associatedtype T: ~Escapable
}

Additionally, a protocol can require concrete lifetimes via explicit lifetime annotation:

@lifetime(storage: Self)
// @lifetime(_: Element) - inferred
protocol NEContainer: ~Escapable {
  associatedtype Element: ~Escapable
}

Stored property constraints

Nonescapable stored properties impose constraints on their parent type. If the stored property is generic over a conditionally escapable type, then that lifetime naturally propagates to the parent's generic context:

// '@lifetime(_: T)' - generic lifetime inferred
public struct A<T: ~Escapable>: ~Escapable {
  var t: T
}

// '@lifetime(_: T)' - generic lifetime inferred
public struct B<T: ~Escapable>: ~Escapable {
  var t: A<T>
}

If the stored property has a concrete lifetime, or concrete lifetime requirement, then the parent must have a concrete lifetime:

@lifetime(self: Self) // concrete lifetime
public struct A: ~Escapable { ... }

@lifetime(self: Self)
public struct B: ~Escapable {
  var a: A // πŸ›‘ ERROR: lifetime A.self is not exposed in B's interface
}

@lifetime(a: A)
public struct C: ~Escapable {
  var a: A // βœ… OK: A.self is associated with the same type as `A`
}

@lifetime(self: Self)
public struct D: ~Escapable {
  lifetimealias self = A.self

  var a: A // βœ… OK: A.self is aliased with `self`
}

Question: this constraint is not required for correctness. It only adds Rust-like constraints. Do we really want it?

TODO

captures & non-recursive exclusivity

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