Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active November 2, 2022 21:41
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/55c8999d54a9e83590c4c40b4d9b5b9c to your computer and use it in GitHub Desktop.
Save atrick/55c8999d54a9e83590c4c40b4d9b5b9c to your computer and use it in GitHub Desktop.
Ownership Types Survey (Pre-Pitch)

A survey of proposed ownership types

This document identifies several potential type constraints related to value ownership and lifetime in Swift and explains those constraints relate to each other. They have not yet been formally proposed. This is only a straw-man. The goal of giving these constraints a name and gathering them all in one place is at least twofold. First, we gain a clear understanding of how to structure the internals of the compiler. Second, as we propose language features, we can refer to dependent features and understand how the individual proposal will fit into cohesive programming model. The goal is not to surface each constraint identified here as separate knob in the language. We expressly want to avoid making Swift a language of annotations. But the only way we can minimize the impact on the programming model is by first seeing the landscape of features that we want to cover. Naturally, any new annotation will be justified before formally proposing an addition to the language.

Table of Contents

Variations on type features

Type constraints and capabilities

A type constraint places requirements on the type, usually via marker protocol. While a typical protocol conformance requires the implementation of methods or properties, a type constraint limits the structure or behavior of the type. For example, an AnyObject constraint requires the type to be a single class reference. Similarly, a Sendable capability prohibits references to shared objects. Type constraints and capabilities are transitive top-down. If an aggregate has a constraint, then so must its members, unless explicitly exempted.

Below, I use type-name convention (TypeName) for generic type constraints and capabilities. All other forms of type qualifiers are named as annotations (@typeQualifier).

Variable constraints

A variable constraint places requirements on a variable's usage, usually by qualifying the variable's type. Constraining a variable means that it accepts more types. For example, a hypothetical "nonescaping" parameter would accept both escapable and nonescapable types.

public func constrainParameter<T>(argument: @nonescaping T)

Contrast this with adding a requirement to a generic type parameter, which restricts the API such that it accespts fewer types, but places no constraint on the usage of the argument:

public func constrainType<T : Escapable>(argument: T)

The type requirement is expressed as a capability (Escapable), while the variable's type qualifier is expressed as a limitation (@nonescaping).

Generic type constraints for negative capabilities

Removing a default capability from a type requires new syntax for generic type constraints. We need to express that a generic type can be bound to either an Escapable or NonEscapable type. Strawman:

public func constrainType<T : ?Escapable>(argument: T)

Concrete type constraints

We may want to enforce a type constraint without full-fledged type system support for a negative capability. Let's use this syntax to indicate that View does not have the (default) Escapable capability:

@nonescaping
struct MyView {...}

When MyView is assigned to a variable, or bound to a parameter with a concrete type, @nonescaping will be automatically inferred as a variable constraint:

public func cannotEscape(view: MyView) // '@nonescaping view' is automatically inferred

This strategy could be extended to concrete protocol constraints:

@nonescaping
protocol View {...}

Passing a View to a generic API then requires an explicit variable constraint:

public func constrainParameter<T>(argument: @nonescaping T)

A View can never be type erased such that it loses its nonescaping constraint. It cannot be passed to an unqualified generic API and cannot be assigned to an existential without the @nonescaping constraint. (This severely limits usability of types with conrete constraints).

Concrete type behaviors

A type annotation may also be used to statically place constraints on values of that type without imposing any type constraints. This lets a programmer selectively bypass some default constraints for improved optimization. Type erasure conservatively drops the behavior, unless it can be inferred from a generic or protocol requirement. Specialization reintroduces the behavior.

protocol CanImplicitCopy {}

@eagerMove
struct PreferMove : CanImplicitCopy {...}

let preferMove: PreferMove = ...
let canImplicitCopy: CanImplicitCopy = preferMove
foo(canImplicitCopy) // could conceivably pass-by-copy

Proposed constraints, behaviors, and wrapper types related to "move-only" types

BitwiseMovable type constraint

  • move and borrow may bypass the value witness

  • implies "bitwise-borrowable": a single instance may inhabit multiple addressable locations as long as exclusivity is enforced

  • constrains the type to be address-independent

BitwiseCopyable type constraint (aka Trivial)

  • transitively constrains all stored properties to be non-reference types

  • copy, move, and borrow may bypass the value witness (they all reduce to memcpy)

  • deinit may bypass the value witness (it reduces to a nop)

  • implies BitwiseMovable

NonCopyable type constraint (aka move-only)

Removes the copy capability from a type, grants it deinit capability, and grants it eager-move behavior

  • copy is prohibited

  • deinit is supported

  • preserves uniqueness (but does not constrain uniqueness)

  • grants @eagerMove behavior

  • is not BitwiseCopyable even if all members are BitwiseCopyable (deinit is witnessed)

  • transitively inferred on a struct from any of its properties

Immovable type constraint

Removes both the copy and move capability

  • implies NonCopyable

  • cannot take ownership of values

  • transitively inferred on a struct from any of its properties

  • requires class property or global storage

SharedValue type constraint

  • implies NonCopyable

  • is not BitwiseMovable even of all its members are BitwiseMovable

That's it. There are no other special semantics. A SharedValue can be moved. But typically they would only be borrowed.

This is useful for address-dependent types. It guarantees a stable address across sequential inout calls and across multiple simultaneous borrows. A native atomic implementation would allow mutation via those simultaneous borrows.

SharedValue types can only passed as arguments by borrowing in-place. They can only be composed into borrowed values (e.g. passed to an Optional API) by wrapping them in a BorrowedValue type. This all natural falls out of the fact that they are not BitwiseMovable.

Alternatively, this can be expressed as a @pinned variable declaration. SharedValue would be inferred for any struct containing a @pinned property.

BorrowedValue wrapper type

  • An intrinsic wrapper around a pointer to a value with guaranteed ownership

  • conforms to Copyable

  • conforms to BitwiseMovable

  • allows address-dependent types to be bitwise-borrowed or even copied

This is analogous to a C++ const & type, but with safety guaranteed by exclusivity enforcement and scoped lifetimes.

@unique variable

  • guarantees that any reference contained in this value is unique

  • assignment from a non-unique value requires dynamic enforcement

@eagermove type behavior

A type behavior is used to control eager-move semantics. These types have relaxed lifetime semantics. They are moved or destroyed as soon as possible, making their lifetimes optimization dependent. Their deinitializers do not execute predictably relative to other side effects. (This is not the default behavior for lifetimes because, in the presence of weak references and custom deinitializers, it defies common programmer intuition and makes behavior difficult to reproduce and debug.)

Eager-move will automatically be implied for all copy-on-write (CoW) types, but can also be granted by certain type annotations.

See also Optimization rules governing the lifetime of Swift variables.

@noImplicitCopy variable

  • copy is prohibited, except via an explicit copy intrinsic

  • in the absence of a copy intrinsic, preserves but does not constrain uniqueness

  • grants eager-move behavior

@noImplicitCopy type behavior

  • implies @noImplicitCopy for all variables of this type

This not a type constraint. Copies may "escape" through type erasure. Then subsequent copies of the type-erased value no longer need not be explicit.

@dependsOn parameter

  • indicates a lifetime dependence from the functions return value to a parameter (or self).

  • if the parameter is borrowed (on the caller side) or passed inout, then the dependence is on the exclusive access scope.

For BitwiseCopyable (trivial) return values, this serves as an "interior pointer" annotation.

@nonescaping variable

  • values held by this variable cannot escape the variable scope. This includes explicit copies of the variable

  • escaping the scope by returning a value requires a @dependsOn parameter

  • enforcement may be bypassed using withoutActuallyEscaping

  • A borrowed NonCopyable variable implies @nonescaping

NonEscaping (or @nonescaping) type constraint

  • As a concrete type constraint, this effectively implies @nonescaping on all variable declarations, including self and the initializer's return value

  • As a generic type constraint, removes the type's Escapable capability. This is no safer than the concrete constraint but allows type erasure where the variable is not already either borrowed NonCopyable or explicitly qualified as @nonescaping.

@nonescaping types are required to build APIs based on a BufferView. See the BufferView proposal.

@nonescaping property

  • requires read/modify accessors

  • implies @depensOnSelf for the accessor

  • implies @nonescaping yielded value dependent on the access scope

  • can't fullfill a protocol requirement unless the requirement is also @nonescaping

[TBD] Refer to the borrowed view proposal.

@nonescaping enforcement

@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

Borrowed views

[TBD]

See also:

C++ Interoperability Requirements

Types without copy constructors requires:

  • NonCopyable type constraint

Usability of non-copyable types requires:

  • Generic type constraints for negative capabilities
  • A NonCopyable constraint on all standard library types
  • Copyable extensions for all standard library types

Safe sequence conformance for C++ container types requires the implementation of "safe iterators". Safe iterators requires "borrowed view" support:

  • @dependsOn parameter annotations
  • @nonescaping concrete type constraints
  • @nonescaping properties (read/modify accessors)

Safe iterators requires have the same usability requirements

Address-dependent types and native atomics (SharedValue)

In rare cases, variables need to have a stable address. This will support native Swift atomics, potentially using special syntax. This example uses a @pinned annotation to infer a "shared value" type:

struct Atomic<T> {
  @pinned
  var value: T
}

var x: Atomic<Int>

// Return an address for demonstration purposes--don't actually do this
let address = withUnafeBytes(of: &x) { return $0 }

withUnafeBytes(of: &x) { assert(address == $0) }

A shared value's representation can only be copied by reading it's internal state. Exclusivity allows simultaneous borrowing because both accesses appear to be reads. The atomic API allows mutation via a borrowed value. Atomic APIs would prevent the compiler from reusing or reordering the atomic value across this potential mutation.

// borrow 'x' twice in the same scope
updateTwoAtomics(x, x)

func updateTwoAtomics(a: Atomic<Int>, b: Atomic<Int>) {
  let value = a.load(ordering: .acquire)
  b.store(value + 1, ordering: .release)
  // will load 'value + 1'
  a.load()
}

Borrowing a shared value either needs to be in-place or it needs to be wrapped in a copyable "BorrowedValue" type that refers to the shared address.

Bitwise borrowing requires a BitwiseMovable constraint

Noncopyable (move-only) values have seemingly arbitrary usability limitations unless they can be assumed to be bitwise borrowable. The bitwise borrowable capability releases those limitations and is naturally implied by the BitwiseMovable constraint.

Borrowing arguments can be handled in the compiler by reusing the caller's storage whenever an argument is implicitly borrowed. This requires emitting an exclusivity check, but has no ABI impact. This does not, however, solve the other usability problems. Consider passing a borrowed value to an optional API:

func borrowOptional<T: ?Copyable>(t: T?)

func borrowValue<T: ?Copyable>(t: T) {
  borrowOptional(t) // error: copying into an optional
}

If move-only values can be assumed BitwiseMovable, then all of these usability problems related to physical storage, such as building aggregates, vanish.

Unfortunately, ?Copyable only removes a capability. We can't reasonably require the BitwiseMovable capability. So enforcing any assumption about BitwiseMovable requires adding type constraints to generic APIs:

func genericAPI<T: ?Copyable & BitwiseMovable>(canBeBorrowed: T)

Relying on bitwise borrowing would mean that the BitwiseMovable constrains needs to be plumbed through all generics API. It also means that values with Objective-C weak references can only be borrowed by boxing them inside a "by-reference" type.

Instead, we will likely need to speculatively box generic borrowed values just in case they might contain an Objective-C weak reference. We can do this either by adding a programmer-visible BorrowedValue type wrapper or by transparently introducing the wrapper as an implementation detail. The value witnesseses of BorrowedValue would be trivial pointer copies. By granting BitwiseMovable to a borrowed value, the BorrowedValue wrapper would allow that borrowed value to be passed to Optional APIs and generally composed with other borrowed values.

Constraints that associate types with side effects

These constraints aren't needed for ownership control. But they are relevant for understanding how restrictions on types converge toward a programming model based on pure value semantics. These unlock advanced optimization capabilities.

PureDeinit type

  • constrains all user-defined deinits for all types transitively referenced by this type including their subclasses or conformances.

  • the user-defined deinits cannot produce side effects. Notably, it cannot access other objects unless they are uniquely referenced by the deinitialized object.

  • inferred when all types transitively referenced are final and have no user-defined deinit, within the usual boundaries of library evolution

@noglobal function

  • prohibits the implementation from accessing global variables, transitively

@pure function

  • repeated invocation cannot affect semantics

PureValue type

  • implies BitwiseMovable

  • implies Sendable

  • implies PureDeinit

  • grants @eagerMove behavior

  • transitively requires all properties to be PureValue

  • constrains all references to be transitively unique (or CoW)

  • inferred for BitwiseCopyable and copy-on-write types whose elements are PureValues

  • implies @noglobal for all methods

  • implies @pure for any @noglobal function for which all arguments are PureValues

PureValue signals a broad intention that "mutation" cannot be observed by other values. This intention is not fully enforceable at the type level. The real usefulness of this constraint is twofold:

  1. as a placeholder for related constraints that are important for safety and optimization (BitwiseMovable, PureDeinit, Sendable, eager-move).

  2. to infer function purity without widespread function annotation

PureValue could mostly be subsumed by Sendable if we're willing to place most of the same constraints on Sendable.

Minimizing API annotations

Separately constraining types and variables presents a challenge for minimizing API annotations.

Imagine if, to achieve full optimization and flexibility, API declarations need to look something like this:

func properAPI<T: BitwiseMovable, PureDeinit, Sendable>(
  argument: @moveonly @unique T
)

This argues for a single constraint name that captures all the defaults we want in a value-semantics programming model. We might even consider inferring generic type constraints from a parameter's variable constraints.

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