SE-0390: @noncopyable structs and enums

Yeah, I think this is a really important point. deinit is meant for basic resource management, and it's okay for us to state that as a policy. with functions are the right way of doing things where it's semantically critical what code in inside the with, because deinit is inherently implicit, subtle, and imprecise to naive readers of code even if the language gives rigid semantics to when it'll actually be called.

9 Likes

I was on the right track for a sec...

Then in haste, I started to argue against my own design...

[Correction and clarification]:

let NonCopyable should have "maximal lifetimes" (which we proactively implemented a while back in copy propagation):

In Michael's example, this means that x is deinitialized at #1 and #4:

let x = NonCopyable()

if condition {
  _ = consume x
  // (1)
} else {
  // (2)
  callUnknownFunction()
  // (3)
  doSomethingElseThatCanBeProvenToNotHaveSideEffects()
  // (4)
}
// (5)

There are three key reasons for this:

  1. With NonCopyable, let is an alias for consuming. Special let semantics only make sense when the compiler can insert implicit copies. Noncopyable let should, therefore, have the same lifetime rules as Copyable consuming.

  2. Copyable consuming variables are designed to have "maximal" lifetimes at -Onone. Their lifetime only ends on a path in which it was consumed. This is important for the debugging experience.

  3. NonCopyable lifetimes should be the same at -Onone and -O. This way programmers can use a debugger to understand the behavior of a struct deinit.


The discussion above went off the rails with using "pointer escapes" as a reason for extending lifetimes. That's really what I wanted to correct in my first post. Attempt #2:

An implicit consume does not protect weak references or pointer escapes

Any model that relies on this would lead to confusing action-at-a-distance bugs. The example below is dangerous, and we want to add compiler diagnostics to make it an error:

class Storage {}

@noncopyable
struct Container {
    var storage: Storage

    deinit { ... }
}

func shouldNotAllow(container: consuming Container, condition: Bool) {
    weak var storage = container.storage
    if condition {
        _ = consume container
        expectsNull(storage)
    } else {
        _ = storage!
    }
}

It should be rewritten using either a (lexically scoped) borrow:

func useBorrow(container: consuming Container, condition: Bool) {
    weak var storage = container.storage
    if condition {
        _ = consume container
        expectsNull(storage)
    } else {
        borrowing _ = container
        _ = storage!
    }
}

Or using an explicit consume on the "escaping" path:

func test(container: consuming Container, condition: Bool) {
    weak var storage = container.storage
    if condition {
        _ = consume container
        expectsNull(storage)
    } else {
        _ = storage!
        _ = consume container
    }
}

[EDIT] We probably should not promote this last workaround because it can't be used consistently across both Copyable and NonCopyable types. We might even consider a rule that storage must be force-unwrapped with a borrow scope in all cases:

borrow container = container
weak var storage = container.storage
_ = storage!

For most APIs, these borrow scopes would be hidden by _read accessors.

If this example used a class instead of a noncopyable struct, wouldn’t we have the same problem and tell ourselves that withExtendedLifetime is the solution for keeping the strong reference alive?

2 Likes

Right. withExtendedLifetime works in all cases. But if you need it in "normal" situations, I consider that a language or API bug. We should try to replace it with ownership controls, namely borrow scopes, which can be implicit when using with _read accessors.

With Copyable types, let conservatively handles cases like this as a concession to "legacy" code that doesn't explicitly guard weak refs and unsafe pointers. But that stands in the way of optimizing copies. consuming is the way for programmers to opt back into those optimizations.

Above, I mentioned that NonCopyable lifetimes can be controlled with an explicit consume:

    } else {
        _ = storage!
        _ = consume container
    }

But that does not work for Copyable types, whose lifetime can be optimized. Removing dead consumes in order to eliminate copies is a routine optimization. We could give an empty consume stronger semantics than a regular consume, but that would only lead to more confusion.

It's probably better to just have one simple rule for dependent values:

Any use of an escaped pointer or force-unwrapped weak reference must be within a borrow scope.

The language workgroup has decided to accept the proposal in principle, while kicking off a focused re-review on some of the details that have been expanded on and clarified by the proposal authors. There is a new review thread, please continue discussion there.

2 Likes