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.
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:
-
With NonCopyable,
let
is an alias forconsuming
. Speciallet
semantics only make sense when the compiler can insert implicit copies. Noncopyablelet
should, therefore, have the same lifetime rules as Copyableconsuming
. -
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. -
NonCopyable lifetimes should be the same at
-Onone
and-O
. This way programmers can use a debugger to understand the behavior of astruct 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?
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.