Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active April 3, 2023 21:52
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/3b2acb6f9a71d74e2167fc8ed04b02d3 to your computer and use it in GitHub Desktop.
Save atrick/3b2acb6f9a71d74e2167fc8ed04b02d3 to your computer and use it in GitHub Desktop.
Dependent properties

Lifetime-dependent properties

Introduction

A lifetime-dependent property (or "dependent property") is a struct or class property whose value can only be accessed within an enclosing scope, during which the parent object cannot be deinitialized. A dependent property can provide a lightweight "view" of its parent object's underlying storage through an alternate type. This proposal focuses on the problem of returning the view object as a property of its "container". The author of the view type still bears responsibility for other aspects of safety. The Future Directions sections cover additional features needed to support development of safe view types.

Motivation

A container data type often needs to provide limited access to its underlying storage to a more general purpose API that consumes the data but is otherwise independent of the data type. In Swift, this is typically done by having the container provide a "slice" that conforms to the Collection protocol. Since slices depend on their container's storage, they need to keep their container alive by retaining a hidden reference. This incurs reference counting traffic, makes it impossible to reason about the container's lifetime independent of its slices, and is fundamentally incompatible with unmanaged storage. Developers who want to efficiently abstract over arbitrary storage need to drop down to unsafe pointer types:

func decode(buffer: UnsafeRawBufferPointer) {
    // ... decode buffer
}
let array: [UInt8] = ...
array.withUnsafeBytes {
    decode(buffer: $0)
}
let tuple: (UInt8, UInt8) = ...
withUnsafeBytes(of: tuple) {
    decode(buffer: $0)
}

Views can be used instead to avoid multiple dangers inherent to unsafe pointers: use-after-free, out-of-bounds, type confusion, and simultaneous modification.

The [BufferView proposal (TBD)] provides specific examples of putting views into practice. BufferViews have a concrete representation that supports efficient access without requiring specialization on the parent container. This allows storage to be passed safely and efficiently across module boundaries.

In summary, views

  • allow existing unsafe APIs to be replaced with safe and efficient APIs
  • enable more efficient storage techniques, such as stack allocation
  • support generalized, safe, efficient communication across module boundaries

A new language feature is needed to ensure that when a container provides a view as a property, that view does escape the corresponding access to the container.

Proposed Solution

A container type can safely provide a view of its underlying storage through a dependent property. The dependent property can refer back to its parent's underlying storage. The view returned by a dependent property is limited to a lexical scope, so the affect on its container's lifetime is locally, statically predictable. The view may be copied, passed to another function, and stored in an aggregate, but none of the copies can extend the container's lifetime beyond it's original scope. Although views abstractly reference their container, there's no need for them to retain references. The parent need not even be a reference counted object. In fact, views imposes no particular form of memory management on their storage.

A dependent property provides a consistent "view" of its parent's storage by enforcing exclusive access over the scope of the view. A mutable view requires exclusive access, so the storage is only modified by the view itself or a copy of that view. An immutable view prevents exclusive access, so the storage cannot be modified in the view's scope. SE-0176: Enforce Exclusive Access to Memory explains exclusivity in detail.

Lifetime-dependent accessors

A dependent property requires introducing exclusivity and lifetime constraints on the value it returns. We propose adding a single @dependent attribute to property declarations that provides both constraints and ties them to the same "access scope". In "Future directions", we discuss inferring this attribute from the property's type. This attribute is only valid for properties implemented using read or modify accessor coroutines.

class Storage { ... }

struct Container {
    private var _storage: Storage

    var view: @dependent View {
        _read {
            yield View(_storage)
        }
    }
    var mutableView: @dependent MutableView {
        _modify {
            var view = MutableView(_storage)
            yield &view
        }
    }
}

Accessors already establish an access scope in the caller. This scope normally only covers the expression that computes the property's value. A copy of that value is then passed along without exclusivity:

let v = container.view
// exclusive access to container.view is released here, before 'v' goes out of scope
lookAt(view: v)

The @dependent attribute tells the compiler to extend the access scope across the lexical scope that immediately encloses the access:

let v = container.view
lookAt(view: v)
// exclusive access to container.view is released here, after 'v' is no longer used.

If a dependent property's value is assigned to a variable (such as 'v' above), then, from that point onward, that variable is only available within the lexical scope of the access:

let v: View?
if let container = container {
  v = container.view
  lookAt(view: v) // this use is ok
}
lookAt(view: v)   // this use raises a compiler error

This lexical scope rule is enforced by the "nonescaping values" feature described below. The compiler may optimize the variable's lifetime using the usual lifetime rules. The access scope, however, must always extend beyond the last use of the variable. If destroying the variable results in deinitialization, then the deinitializer runs within the access scope.

If the dependent accessor is used in a subexpression, its scope now covers the entire expression. Here, the exclusivity scope covers all transformations applied by map:

container.view.map { ... }

View types may provide their own properties that expose the same underlying storage. To avoid escaping that storage, the author of the view type must transitively declare all such properties @dependent. Here, the iterator's exclusivity scope is nested within the view's scope. Both extend past the last use of i:

let i = container.view.iterator

By extending the scope of the accessor, dependent properties naturally extend the lifetime of the parent object. This means that the container's storage can neither be modified nor destroyed while the view exists.

Nonescaping values

In addition to creating a lifetime dependence on the parent object, dependent properties also restrict the lifetime of the value returned by that property to the same scope. In other words, the property returns a "nonescaping value" associated with its access scope. A new compiler feature supports this, which we refer as "nonescaping values". In broad terms, the compiler ensures that a nonescaping value does not outlive its designated scope.

Every copy of a nonescaping value inherits its lifetime restriction. So when a dependent property is assigned to a variable, the variable is restricted to the property's access scope. Here, the view's access scope is limited to the 'if let' block:

let v: View?
if let container = container {
  v = container.view
  lookAt(view: v) // ok
}
lookAt(view: v) // nonescaping property 'view' is used outside its access scope

v is a copy of view's nonescaping value embedded in an Optional. It therefore inherits a nonescaping scope corresponding to views access scope. The first call to lookAt receives another copy as an argument, which cannot escape the function call. The second call to lookAt is a violation because it uses v outside of its nonescaping scope.

Within a function, the compiler enforces nonescaping values using a dataflow-driven diagnostic. Passing a nonescaping value across function calls raises interesting issues described in the "Nonescaping parameters" section and further explored in "Future directions".

Nonescaping parameters

We propose adding the @nonescaping attribute to parameter types to allow passing a nonescaping value across an API boundary:

func lookAt(view: @nonescaping View)

A nonescaping parameter is simply a nonescaping value whose nonescaping scope is the function body. Plainly put, the argument value cannot escape the function. In "Future directions", however, we describe a "lifetime-dependent results" feature that will allow nonescaping values to be both passed as an argument and returned by the same function such that the return value inherits the argument's nonescaping scope.

A method's 'self' parameter can be marked nonescaping by adding the attribute before the function declaration:

struct MyView {
    @nonescaping func lookAtSelf()
}

Computed properties can also delcare 'self' as nonescaping. Note that the attribute occurs here before the property declaration rather than in type position:

struct MyView {
    @nonescaping var value: V { get {...} }
}

For convenience, accessors that yield dependent values also imply nonescaping self. There is no need to add @nonescaping to a @dependent property:

struct MyView {
    /*@nonescaping*/ var value: @dependent V { _read {...} }
}

In the future, the nonescaping parameter attribute may sometimes be inferred from the parameter type. Implementing safe view types, however, merits a separate discussion; so "Concrete nonescaping types" and "Generic nonescaping types" are discussed in "Future directions".

Other techniques for using nonescaping values across API boundaries are discussed under Future Directions.

Nonescaping inout parameters

When a view is accessed in a mutating context, the modify accessor yields an inout parameter:

struct MyView {
    @nonescaping mutating func update()
}
container.mutableView.update()

As with immutable views, mutable views can only be passed to inout parameters that have a @nonescaping attribute:

func mutate(view: @nonescaping inout View) {
    view.update()
}
mutate(&container.mutableView)

Unlike other nonescaping values, nonescaping inout parameters are noncopyable because allowing copies would break the exclusivity requirement. The compiler enforces noncopyable values using the same mechanism as for @noncopyable struct or enums.

withoutActuallyEscaping

A nonescaping value supports all standard operations on values that an escapable value supports. Therefore, the unsafe withoutActuallyEscaping escape hatch can be extended to handle non-function types:

let c: Collection = container.view
withoutActuallyEscaping(c) { c in  // v is now allowed to escape inside the closure
    // ...
    let iter = c.makeIterator()
    // I promise not to escape 'iter'
    // ...
}

Source compatibility

New @dependent properties and new @nonescaping parameters are additive relative to existing Swift code.

Adding @nonescaping attributes to existing parameters is not source breaking as long as the implementation cannot escape its parameter:

container.view.map {...} // ok after adding '@nonescaping' to 'map'
container.view.sort()    // ok after adding '@nonescaping' to 'sort'

But protocols requirements will either be incompatible with views or will require potentially breaking existing conformances:

container.view.index(after: i) // ok
container.view.swapAt(i, j)    // ok
let v: Collection = container.view
v.index(after: i)         // error: 'Collection.index()' may escape 'self'
v.swapAt(i, j)            // error: 'Collection.swapAt()' may escape 'self'
v.nonEscapingSwapAt(i, j) // ok: additive '@nonescaping' Collection requirement

Effect on ABI stability

Adding or removing @dependent on an existing property would break ABI because it is the caller's responsibility to enforce the attribute.

The @nonescaping parameter attribute does not affect ABI.

Effect on API resilience

Introducing new properties with a @dependent attribute is additive. For an existing property, adding or removing @dependent breaks API.

Adding a @nonescaping parameter attribute is a resilient change. Removing a @nonescaping parameter is not resilient because caller's make correctness assumptions based on the attribute.

Alternatives considered

Rather than using an explicit attribute, the compiler can deduce that a property is dependent from its type. This approach will be preferable once nonescaping types are supported, which is covered in "Future directions". An attribute is simply a convenient way to introduce dependent properties.

Future directions

API compatibility with nonescaping values

A number of techniques can be used to allow nonescaping values to cross API boundaries:

  • A @nonescaping parameter attribute

  • Automatic compiler analysis of always-emit-into-client code

  • An unsafe @_effects(nonescaping) parameter annotation

Only the first technique, @nonescaping parameter attributes, is formally supported by this proposal.

Nonescaping variables

A @nonescaping attribute could be applied to local variable declarations. Once initialized, the variable could be used within its lexical scope. Copies of the variable cannot escape its scope. Compiler support for "nonescaping values" is sufficient to implement this.

A nonescaping variable is only lifetime constrained by a lexical scope. It does not require exclusive access to a different storage object and does create a lifetime dependence on the storage object:

let x
do {
    @nonescaping let y = object.property
    // exclusive access to object.property is released here
    // object itself may be destroyed here
    x = y
}
// any use of x after this point is illegal

As such, this feature has potential for misuse.

Lifetime-dependent results

Returning a lifetime-dependent value from a function requires lifetime-dependent results. This could be expressed with a @resultDependsOn parameter attribute and a @resultDependsOnSelf function attribute. A dependent result is a nonescaping value relative to the lexical scope that contains the call, and it creates a lifetime dependence on the the value that was passed into the annotated parameter. Here, the returned View object will depend on the self Container's lifetime, but exclusivity would need to be enforced manually:

struct Container {
    @resultDependsOnSelf
    func lockContainerAndGetView() -> View { ... }
}

If, however, the dependent parameter is borrowed (on the caller side) or passed inout, then the result's nonescaping scope is the argument's exclusive access scope:

func getExclusiveMember(a: @resultDependsOn inout A) -> B {
  return a.b
}

let parent = ...
// The exclusive modification of parent begins here...
let child = getExclusiveMember(&parent)
use(child)
// The exclusive modification of parent ends after child's lifetime ends

This allows the behavior of dependent properties to be emulated using normal function calls. We can then add a @dependent attribute to regular method declarations:

@dependent someMethod() -> T

This would imply @resultDependsOnSelf borrowing:

 @resultDependsOnSelf borrowing someMethod() -> T

(borrowing tells the callee to borrow self as explained in borrow and consume parameter ownership modifiers.) On the caller side, the @dependent function attribute would ensure that the lifetime of the return value is confined to the borrow scope of self.

Concrete nonescaping types (view types)

Without nonescaping types, passing a nonescaping value across an API boundary requires a parameter annotation:

func lookAt(view: @nonescaping View)

Using view types, however, should not require API annotation. Instead, the view type should carry the annotation itself. This can be done with a @nonescaping struct, enum, or class type attribute:

@nonescaping
struct View { ... }

A nonescaping type requires all instances to be nonescaping values. As a consequence, the initializer must produce a nonescaping result. This can be done using a lifetime-dependent result as described above. View initializers will, in practice, take the view's container as the argument that the result depends on, even if that argument is unused within the initializer. This creates an immediate lifetime dependency from the view to its container:

@nonescaping
struct View {
   init(internals: Internals, @resultDependsOn opaqueContainer: Any) {...}
}

Generic nonescaping types

Nonescaping values can never be type erased such that they lose their nonescaping constraint. They can neither be passed to an generic API nor assigned to an existential unless the destination has a nonescaping constraint. As explained in "Nonescaping parameters", nonescaping values can, however, be borrowed as noncopyable types. Thus, nonescaping values are compatible with any API designed for noncopyable types.

If @nonescaping annotations become too onerous, then generic nonescaping types can be introduced. This can be done using exactly the same technique as Generics support for noncopyable types. In addition to a default Copyable capability, types would now have a default Escapable capability, which generic parameter types can explicitly opt-out of.

Mutating variables

Because mutable views can't be copied, their usability is severely restricted relative to immutable views. mutating variables will eventually address this shortcoming...

Mutating references, a.k.a. inout reference bindings, will allow mutable views to be used locally with a lightweight variable declarations syntax:

mutating view = &container.mutableView
view.sort() // assuming 'sort' is annotated @nonescaping

mutating properties will allow mutable views to be wrapped in adapters for convenenience:

struct MyMutableAdapter : Adapter {
  mutating view: View
  func apply() {
    mutate(&view)
  }
}
MyAdapter(view: &container.mutableView)

References

Read and modify accessor coroutines

borrow and consume parameter ownership modifiers

borrow and inout reference bindings

Noncopyable structs and enums proposal

Generalized nonescaping arguments

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