Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active February 29, 2024 00:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save atrick/e28d9dedda71aa6681b3b48623375f77 to your computer and use it in GitHub Desktop.
Save atrick/e28d9dedda71aa6681b3b48623375f77 to your computer and use it in GitHub Desktop.
Lifetime dependence with generic types

Lifetime dependence with generic types

Conditionally nonescapable types

The current design of lifetimes relies on "value lifetimes" and "conditionally non-escapable types". Values of Escapable type have no lifetime scope. Values of ~Escapable type have a single lifetime scope. A nonescapable value cannot live beyond the current scope unless the scope's function interface provides value-based lifetime propagation via @dependsOn annotations.

E ≡ some T
NE ≡ some T: ~Escapable

Without a lifetime annotation, functions cannot return nonescapable values:

func foo(a: NE) -> NE // ERROR: interface violates nonescapable type checking

Adding value dependence to the interface allows nonescapable values to propagate out of their initial scope:

func foo(a: NE) -> @dependsOn(a) NE // OK: interface passes nonescapable type checking

Conditionally nonescapable types can contain nonescapable elements:

struct Container<NE>: ~Escapable {
  var element: @dependsOn(self) NE
  
  init(element: NE) -> @dependsOn(element) Self {...}
}

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

Here, Container becomes nonescapable only when its element type is nonescapable. This nonescapable Container inherits the lifetime of its single element value from the initializer and propagates that lifetime to all uses of its element property.

When a type conforms to Escapable, any @dependsOn annotation that modifies that type is ignored. So, when Container's element conforms to Escapable, the @dependsOn(self) NE annotation in Container's interface is ignored. And when Container conforms to Escapable, the @dependsOn(element) Self in Container's interface is ignored.

Value lifetimes

Value lifetimes allow nonescapable values to pass through aggregates, retaining their original lifetime scope:

var c1: Container<NE>
{
  let c2 = Container<NE>(element: c1.element)
  c1.element = c2.element // OK: c2.element can outlive c2
}

Borrowing a value creates a new lifetime scope. The @dependsOn(borrow <name>) annotation forces the named variable to be borrowed over the lifetime of the dependent value. Values that depends on a borrow are constrained by that borrow scope independent from their original lifetime:

struct Container<NE>: ~Escapable {
  var storage: Storage
  
  init(element: NE) -> @dependsOn(element) Self {...}
  
  var view: @dependsOn(borrow self) View // New lifetime scope
}

struct View<NE>: ~Escapable, ~Copyable {
  var ne: @dependsOn(self) NE { get {...} }
}

c1,c2 : Container<NE>
v1,v2 : View<NE>

var v1 = c1.view
{
  let v2 = c2.view
  v1.ne = v2.ne // ERROR: lifetime violation
}

Here, each view instance depends on a borrow of its container. Retrieving an element from one of the view's inherits the container's borrow scope. Hence, assigning an element from one view into another is a lifetime violation unless the destination view has a narrower lifetime than the source view.

Future extensions

This design can be extended through a natural progression of three additional features:

  1. Value component lifetimes

  2. Abstract lifetime components

  3. Protocol lifetime requirements

Value component lifetime

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

struct Container<NE>: ~Escapable {
  var a: /*@dependsOn(self)*/ NE
  var b: /*@dependsOn(self)*/ NE
  
  init(a: NE, b: NE) -> @dependsOn(a, b) Self {...}
}

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

var a = ...
{
  let b = ...
  let c = Container<NE>(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<NE>: ~Escapable {
  @lifetime
  var a: /*@dependsOn(self.a)*/ NE
  @lifetime
  var b: /*@dependsOn(self.b)*/ NE
  
  init(a: NE, b: NE) -> @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<NE>: ~Escapable {
  var storage: UnsafeMutablePointer<NE>
  
  init(element: NE) -> @dependsOn(element -> \.elements) Self {...}
  
  subscript(position: Int) -> @dependsOn(self.elements) NE
}

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<NE>
{
  let c2 = Container<NE>(element: c1[i])
  c1[i] = c2[i] // OK: c2[i] can outlive c2
}

Let's return to the example in which a view 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<NE>: ~Escapable {
  var storage: UnsafePointer<NE>

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

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

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

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

  var mutableView: @dependsOn(borrow self) MutableView<NE> { 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<NE>
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: NE, to: @dependsOn(from) inout NE) {
  to = from
}

var c1: Container<NE>
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) NE
}

Let's return to View's initializer;

@lifetime(elements)
struct View<NE>: ~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<NE>: ~Escapable {
  init(storage: ElementStorage) ->
    // Copy the lifetime assoicate with storage.elements
    @dependsOn(storage.elements -> \.elements)
    Self {...}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment