Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active November 18, 2022 03:24
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/fe873e9dc855d417fd41337b44ed0818 to your computer and use it in GitHub Desktop.
Save atrick/fe873e9dc855d417fd41337b44ed0818 to your computer and use it in GitHub Desktop.

Non-escaping view proposal

Introduction

A data type often needs to provide limited access to its underlying data without copying the data. That is typically done either with a "slice" that retains a reference to the original storage, or with an unsafe pointer into the storage.

Consider slicing: return array[range]

This approach is limited because slices only work safely with reference counted storage. Slices may arbitrarily extend the original lifetime, which is sometimes unacceptable. And, finally, by retaining a reference, they prevent mutation of copy-on-write containers. So slices can't be used to mutate collection types that have value semantics.

Consider unsafe pointers: array.withUnsafeBufferPointer { buffer in ... }

Pointers are extremely unsafe because it is easy for programmers to cause the pointer to escape the original lifetime. This leads to use-after-free bugs and undefined behavior caused by access to pointer after the closure returns.

Motivation

A "view" type should safely abstract over a region of storage independent from the container that owns the storage. A view does not copy the underlying data. Safety relies on a guarantee that the original container cannot be modified or destroyed during the lifetime of the view. A view may be mutable or immutable, following Swift's regular rules for exclusive access to storage. A view is considered "non-escaping" if its lifetime is limited to a scope outlined by the container's API. A non-escaping view does not extend the lifetime of the original container.

Non-escaping views will allow existing unsafe APIs to be replaced with safe and efficient APIs. They will allow the development of new, more efficient storage techniques, such as stack allocation. And they allow generalized, safe, efficient communication across module boundaries.

An important use case for non-escaping views are "BufferViews". This a least-common demonator abstraction over contiguous memory, independent of how that memory is managed. This will enable a new class of system-level API for accessing memory directly in a way that is lifetime-safe and type-safe. For details, see the BufferView Propsal.

Solution

We define non-escaping views in the context of a "Container" type that provides a property of type "View. Their design depends on three language features that each build on each other. Non-escaping results expose compiler support for enforcing lifetime dependence. Non-escaping initialization forces the initializers of a non-escaping type to produce only non-escaping results. Non-escaping properties tie the lifetime of a non-escaping value to a container's exclusive access scope.

Non-escaping results

Lifetime safety of the View type requires its initializer to produce a value that is immediately dependent on its container. To do this, the initializer must take the container as an argument, even if it is unused, and express a lifetime dependency on that argument. This can be done with a "non-escaping result" feature. A non-escaping result declares a lifetime dependence on a value that was passed into the function. We introduce a @resultDependsOn parameter annotation for this purpose:

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

let parent = ...
let child = getNonescapingResult(parent)
use(child)
// parent is kept alive until after child's lifetime ends

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

func getExclusiveMember(@resultDependsOn a: 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

Non-escaping initialization

Initializing a non-escaping type requires returning a lifetime-dependent self. Here, we introduce a non-escaping View type whose initial value depends on some opaque container passed to the initializer.

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

This "language feature" is nothing more than a pattern

Non-escaping properties

A container type that vends a non-escaping property generally needs both a lifetime dependence guarantee and an exclusive access guarantee.

Let's define a "non-escaping accessor" as a computed property that provides both guarantees:

@nonescaping
var view {
  _read {
    yield SomeView(internalState)
  }
}

A read accessor creates an exclusivity scope protecting access to self. The @nonescaping implies a lifetime dependence. But this is not a simple lifetime dependence on self. Rather, it constrains the lifetime of the yielded view to the exclusive access scope that protects self.

Note that this changes the caller-side semantics of the accessor.

let container = ...
let view = container.view
use(view)
// exclusive access to container covers the lexical scope of `view`

Normally, a _read accessor enters exclusive access of self just long enough to evaluate the expression that refers to the property. So let view = container.view would create a copy the view, relinquish exclusive access to the container, then bind that copy to the view variable. A nonescaping property, however, needs to create a "borrow scope" that forces exclusive access across the lexical scope of the view variable.

This "borrow scope" can be made explicit by using a ref binding:

let container = ...
ref view = container.view
use(view)
// exclusive access to container covers the lexical scope of `view`

Enforcing non-escaping values

Non-escaping results rely on compiler support for non-escaping values. A non-escaping value is any value that dependends on the lifetime of another value and meets the following usage criteria:

@nonescaping variables 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. They can however, be borrowed as NonCopyable types. Thus, @nonescaping variables can be passed to move-only compatible APIs. The cannot, however, be passing to a __consuming parameter (including self) since the argument is not borrowed.

Specifically, when an API refers to a polymorphic type, the compiler can enforce @nonescaping arguments at the API boundary in any of these ways:

A @nonescaping parameter annotation

A borrow parameter annotation combined with a NonCopyable requirement

An @_effects(nonescaping) annotation, which is unsafe

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

Future directions

Non-escaping variables

We could introduce a @nonescaping attribute on variable declarations. This simply extends compiler suport for non-escaping values to any variable. This way, programmers could express their intention to limit the lifetime of values and rely on the compiler's enforcement.

Non-escaping variables could only be assigned from a non-escaping result. Usage would meet the same requirements outlined in enforcing non-escaping values.

Non-escaping type attributes

We could introduce a @nonescaping attribute on type declarations to force non-escaping initialization of the type. This simply forces the types initializers to follow the pattern described in Non-escaping initialization.

@nonescaping
struct View {
   // invalid initializer
   init(internals: Internals) {...}

   // valid initializer
   init(internals: Internals, @resultDependsOn opaqueContainer: Any) {...}
}

The non-escaping type attribute does not otherwise affect the enforcement of non-escaping properties.

Non-escaping type

We could introduce a non-escaping type constraint

Enforcement of such types would be stricter than for non-escaping values. withoutActuallyEscaping would not bypass enforcement for non-escaping types.

Generic code compatible with borrowed values that have non-copyable types would also be compatible with values that have non-escaping types. In short, a "borrowed value" is strictly non-escaping.

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