[Pitch] Noncopyable (or "move-only") structs and enums

[EDIT] This is still a strawman. If you have any counter-examples please share!

Summary

consuming values are eager-drop.

borrowing values are associated with a lexical scope.

Noncopyable values have strict lifetimes.

Copyable values have optimized lifetimes.

Strict Lifetimes

Strict eager-drop values give us a last-source-level-use rule.

Strict borrowing values give us a lexical lifetime rule.

Optimized Lifetimes

With optimized lifetimes, deinitialization is unordered.

An optimized consuming lifetime is "eager-drop", but is not a "last-source-level-use" rule. The optimizer must be able to freely substitute and delete copyable values. consuming value does not keep a value alive if it is copyable.

Optimized borrowing lifetimes follow our current default rules for let variables. An optimized borrowing lifetime is not a "lexical lifetime". The optimizer does track each variable's scope, but that scope is only restricted by deinitialization barriers.

Motivation

Generic lifetimes must be at least as strict as their specialized counterpart.

Copyable generic types cannot have strict lifetimes. ARC would not be optimizable if releases all had the semantics of a virtual C++ destructor defined in another module.

Noncopyable types don't require ARC optimization because retains and releases are only a materialization of copies.

Non-goal: An explicit borrowing or consuming modifier should dictate the ownership and lifetime rules independent of the type. While this is highly desirable, it directly conflicts with other goals.

consuming should eager-drop

We need a lightweight way to tell the compiler it can aggressively drop variables. Otherwise, the compiler is burdened by supporting certain patterns of weak references, unsafe pointers, and deinit side effects. That's a substantial burden for optimizing ownership because it's often impossible to prove that those patterns aren't present. These specific caveats aren't relevant to this thread. What's relevant is that sprinkling another layer of annotations around the code to control lifetimes, in addition to consuming and borrowing is a bad model.

This is all been shown by experiment.

Example:

struct Container {
  func append(other: consuming Container) {
    push(other) // Do not copy 'other' to keep it alive across 'push'
  }
}

borrowing should not eager-drop

First, here's why we can't eager-drop:

@noncopyable
struct FileWrapper {
  let handle: Handle

  [borrowing] func access() -> Data {
    handle.read() // self *cannot* be destroyed after evaluating 'handle', but before calling 'read'
  }
  consuming func close() {
    handle.close()
    forget self
  }
  deinit {
    close()
  }
}

Borrows need some relationship with their lexical scope.

We still have three viable options:
Option 1: optimized borrow lifetimes (just like `let)
Option 2: strict borrow lifetimes
Option 3: optimized copyable and strict noncopyable borrow lifetimes

TLDR; Only option #3 meets our goals.

The FileWrapper example above is currently safe if borrowing variables inherit optimized let lifetimes. The reason is that external calls to read or close a file handle are considered synchronization points. This does not mean that borrows need strict lexical lifetimes. We still have a choice. The question is whether we want to optimize copies or make struct-deinitialization immune to optimization.

The conundrum is this:

  • For copyable types, we need to optimize copies.
  • For noncopyable types, we want predictable deinitialization points
  • If borrowing is explicit, then lifetime behavior should (ideally) not depend on copyability

One of these needs to give.

let lifetime semantics allow optimization of copies, but do not specify "well-defined" deinitialization points. By today's rules, we will optimize the extra copy in this example:

struct Value {
  static var globalCount = 0

  borrowing func borrowMe() -> Value {
    let value = copy self
    globalCount += 1
    return value
  }

  deinit {
    globalCount = 0 // strange I know
  }
}

let value = Value().borrowMe()
//... 'Value.i' might be 0 or 1

Option 1: optimized borrow lifetimes

Ask users to use either call a consuming method, or use withFixedLifetime or deinitBarrier if they expect deinitialization to occur at a specific point.

To fix the unusual case of Value.globalCount above, the programmer would need to "synchronize" their deinitializer by adding a barrier to the method that accesses the Value:

struct Value {
  static var i = 0

  borrowing func borrowMe() -> Value {
    let value = copy self
    i += 1
    deinitBarrier() // withExtendedLifetime() also works
    return value
  }

  deinit {
    i = 0
  }
}

let value = Value().borrowMe()
//... 'Value.i' might be 0 or 1

Deinit barriers are required for class deinits regardless of how borrowing behaves. Any access to an external function or variable acts as a barrier. As does any concurrency primitive like await. We can trivially provide a deinitBarrier API in the standard library if it's useful. Or we can provide a "keep alive" keyword which behaves like consuming but doesn't actually consume.

This programming burden is, however, worse for struct deinits. Unlike with automatically managed objects, the programmer naturally anticipates the point at which struct deinitialization occurs. It's also an unnecessary burden because structs with deinits are noncopyable types. Optimizing their lifetime does not eliminate copies.

Option 2: strict borrow lifetimes

With this option, borrow becomes a "keep alive" keyword for any variable.

This option will significantly harm optimization as we migrate toward borrowing as standard practice. This is especially unwelcome because programmers are using borrowing precisely to optimize copies.

There will be quality of implementation issues. For example, we'll need to prevent the optimizer from deleting dead values and substituting equivalent values with different lifetime constraints. I suspect the compiler will never completely get this right.

Option 3: optimized copyable and strict noncopyable borrow lifetimes

With this option, copyable borrows can be optimized just like let variables today. Migrating to borrowing won't prevent ARC optimization.

With noncopyable borrows, there's no serious performance concern. Objects may be freed later then otherwise, but that matches expectations.

This requires optimizer support, but it mostly falls out naturally from our representation of noncopyable values. The problematic optimizations will largely be disabled for noncopyable values.

It might confuse programmers that borrowing is optimized more aggressively for copyable types. But it's natural that struct-deinits have somewhat special lifetime rules. And the optimization impact is only noticeable when unwanted copies are present.

2 Likes