Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active July 27, 2022 01:04
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/aff94311d4d586616cf3c586a641d9d3 to your computer and use it in GitHub Desktop.
Save atrick/aff94311d4d586616cf3c586a641d9d3 to your computer and use it in GitHub Desktop.
BorrowedValue pre-pitch

BorrowedValue pitch

Introduction

Adding control over value ownership to Swift requires the coordination of multiple language features. This proposal focuses on the specific problem of composing aggregates from "shared" values. A "shared borrow" provides read-only access to a value within a designated scope. This allows APIs to view the contents of a value without taking ownership of it.

Let's define "shared borrowing" as initializing a non-owned variable with the value from another variable without making a copy. To be concise, we'll just call this "borrowing" from here on. Consider passing an argument to a non-consuming parameter (this is the default ownership convention):

func nonConsuming<T>(t: T)

let x: AnyObject = ...
nonConsuming(t: x)

Passing x to nonConsuming uses pass-by-value semantics, as if the callee receives a copy. The implementation may, however, borrow x rather than copying it after proving that x is not modified during the call. Borrowing a variable requires read-level exclusivity to avoid copying its value. In other words, nothing can modify the variable during the call, but it can be read multiple ways.

Currently, borrowing is implemented only as an optimization. But once move-only values are introduced, it becomes a correctness requirement. Move-only values will be proposed separately. More background can be found in these documents:

This proposal assumes, as a straw-man, that a variable can be given a @moveOnly type annotation. This removes the copyable requirement from the variable's type, which effectively prevents the variable from being copied, either explicitly or implicitly. Consider a function with move-only parameter type:

func borrowValue<T>(t: @moveOnly T)

The function borrowValue(t:) always borrows its argument because the default ownership convention is "non-consuming" and its type is not necessarily copyable. (For clarity we might allow non-consuming parameters to be spelled @borrow instead of @moveOnly). This means a call to borrowValue(t:) performs an exclusivity check on the value passed to t. This exclusivity enforcement changes the observed programming model. @moveOnly arguments have pass-by-immutable-reference semantics rather than pass-by-copy semantics.

Unlike with consuming uses, the same move-only value may be borrowed multiple times:

let x: @moveonly AnyObject = ...
borrowValue(t: x)
borrowValue(t: x)

The same move-only value can even be passed into multiple arguments of the same function call. In fact, x could be borrowed twice and passed as a tuple without breaking ownership rules:

borrowValue(t: (x, x))

Whether the language implementation actually supports borrowing into aggregates is a different question.

Motivation

@moveOnly arguments may always be borrowed in-place. Exclusivity is enforced in the caller, and the callee's argument has the same layout; therefore, the callee can simply reuse the caller's storage. Composition of borrowed values, however, cannot be done in-place. Consider passing a move-only value into an Optional API:

func borrowOptional<T>(t: @moveOnly T?)

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

The optional-wrapped value inhabits a different storage location, requiring a copy. The same problem occurs when composing a tuple, or any other borrowed aggregate:

borrowValue(t: (x, x))  // error: copying into a tuple

Nearly all types in Swift are bitwise-movable, which also implies that they are bitwise-borrowable. A single instance may inhabit multiple addressable locations as long as the variable is not modified while multiple physical copies are being accessed. Unfortunately, generic types don't automatically come with a bitwise-movable constraint, so the compiler assumes the worst case.

Of course, generic APIs should be compatible with move-only types. That means their parameters need to be annotated as @moveOnly. This presents a problem. Either non-bitwise-movable types could not participate in these APIs, or the API implementations could not compose any values passed in as arguments. Generic APIs could not, for example, convert an argument to an optional-wrapped value, as shown in the example above. In essence, the practical usability of move-only values would be severely restricted by the potential presence of an Objective-C weak reference in any of the values passed to generic APIs.

Proposal

To allow the same generic APIs to be compatible with both move-only types and non-bitwise-movable types, we propose introducing a BorrowedValue type. BorrowedValue<T> represents any type as a pointer to its borrowed value. Regardless of the wrapped type, the value witnesses are all trivial operations on the pointer value. This is analogous to a C++ const & type, but with safety guaranteed by exclusivity enforcement and scoped lifetimes.

A borrowed value can be wrapped in BorrowedValue<T> whenever composition forces the value into a separate storage location, which would otherwise require copying the underlying value of type T:

    func borrowOptional<T>(t: @moveOnly T?)
   
    func borrowValue<T>(t: @moveOnly T) {
      borrowOptional(BorrowedValue(t))
    }

Generic specialization could eliminate the BorrowedValue wrapper.

BorrowedValue's definition may look something like this:

public struct BorrowedValue<T> {
  let _rawPointer: Builtin.RawPointer

  @_nonescaping(t) // new intrinsic
  init(t: @_moveOnly __shared T) {
    _rawPointer = Builtin.addressof(&t)
  }

  var _value: T {
    @_transparent unsafeAddress {
      return UnsafePointer<T>(_rawPointer)
    }
  }

  public var value: @_moveOnly T {
    _read {
      yield _value
    }
  }
}

A new @_nonescaping(t) intrinsic is needed. The @_nonescaping(t) annotation on the initializer tells the compiler that the initialized value cannot escape the caller's scope and that its lifetime depends on the lifetime of argument t.

We also need to expand Builtin.addressof to handle borrowed value. Currently, the compiler assumes the value passed to addressof is inout, but this can be extended to non-inout values as long as the value is still addressable. Addressability holds in the code above because the generic type T is not statically bitwise-borrowable and therefore must be passed by address.

The compiler features needed to implement of BorrowedValue are nearly identical to the features needed for BufferView. Another type that has been informally discussed and that has several important use cases. See this informal BufferView proposal.

Alternatives considered

A transparent BorrowedValue wrapper type

The compiler could automatically generate a BorrowedValue wrapper whenever needed. Generic values could be borrowed, composed, and passed to Optional APIs without the programmer being burdened by support for non-bitwise-movable types. This seems possible. Accessing a witness of the wrapped value would trivially cast the wrapped pointer to the address of the borrowed value. If the wrapped value has a concrete type, then the compiler could automatically unwrap it when accessing methods and properties. Making BorrowedValue a transparent wrapper, however, requires runtime support for type checking, type casting, conformance checking, and reflection.

Eager borrowed wrappers: all borrowed values are bitwise-borrowable.

If BorrowedValue is transparent, it would be possible to generate the wrapper eagerly without affecting the programming model. For example, a value can be wrapped in BorrowedValue whenever it is converted from an owned value to a borrowed value. In other words, the initial act of borrowing always introduces a wrapper:

func borrowValue<T>(t: @moveOnly T) // T = BorrowedValue<AnyObject>

let x: NonBitwiseMovableType = ...
borrowValue(x) // implicit creation of BorrowedValue<AnyObject>

The compiler can now statically assume that all borrowed values have a bitwise-borrowable constraint. This is a nice conceptual property and could in theory lead to better optimization.

Bypassing BorrowedValue at runtime: only wrap non-bitwise-borrowable runtime types

Whenever a transparent BorrowedValue is statically required, the runtime could perform a check on the metadata of the wrapped type. Types that already conform to bitwise-movable never require BorrowedValue. In practice, BorrowedValue would only be required for types containing Objective-C weak references.

Possible argument passing syntax

Borrowed values have different argument passing semantics. They are not pass-by-value--a fact that will be evident whenever programmers encounter exclusivity violations. To communicate these semantics, we could require the '&' sigil, similar to inout:

func borrowValue<T>(t: @moveOnly T)

let x: AnyObject = ...
borrowValue(&x)

If eager borrow wrappers are used, then this "borrow" sigil conveniently indicate points at which a BorrowedValue wrapper may be introduced.

Generalized first-class references

There may be other reasons to add first-class references to Swift. Namely, improved C++ interoperability. Such a feature might subsume transparent BorrowedValue wrappers. Introducing a BorrowedValue type now, however, in no way prevents future language support.

TODO

Do we want to piggyback BitwiseCopyable and BitwiseMovable requirements on top of this proposal? Adding a BitwiseMovable requirement is an alternative to BorrowedValue in limited situations. I think this proposal should be limited to introducing the standard library type.

@eeckstein
Copy link

@atrick I really like this proposal!

IMO, it would make sense to define BorrowedValue as a property wrapper. Then there is no need to manually unwrap by using var value. On the other hand I don't think that property wrappers can be put into tuples or optionals. But maybe we can make this work.

I'm not sure about @_nonescaping(t): I believe this should be an attribute on the argument. E.g.

  init(t: @_moveOnly @escapingToSelf __shared T) {
    _rawPointer = Builtin.addressof(&t)
  }

Or maybe combined into

  init(t: @_moveOnlyToSelf __shared T) {
    _rawPointer = Builtin.addressof(&t)
  }

@atrick
Copy link
Author

atrick commented Jul 27, 2022

@eeckstein somehow we need to indicate that the BorrowedValue instance is non-escaping when returned from the initializer. When BorrowedValue is instantiated, the caller needs to enforce the scope of its lifetime.

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