For want of a better alternative, mostly. I didn't like the idea of a declaration-level attribute, since that leads to a fairly subtle behavior change that seems easy to miss at the actual point
of forgetfulness, and an attribute wouldn't completely eliminate the need for a special case syntax, since you'd instead need a way to say "actually I do want to run the deinit in this case" in conditional situations. I'm welcome to other alternatives.
Hmm. I think it would eliminate the need for a special-case syntax — an attributed function would be unconditionally “destructuring”, and if that wasn’t good enough, you could just make one that’s unconditional and call it conditionally. That also encourages a code pattern where you extract out an unconditional destructuring operation that returns the components that clients need to do something with, which seems much easier for programmers to reason about than some kind of complex intertwining where we conditionally suppress deinit and then separately use some subset of the properties.
But if you just want an alternate spelling, I’d just go long and loud about it. Nothing about this operation makes me think that suppressDeinit(self)
would be a hardship. It’s probably not going to be used more than, what, twice in a type definition at the worst?
Well, there are the destructuring cases, but then there are also cases where you're implementing an alternative teardown for the type, and particularly if the teardown sequence can fail with an error, you still might want to leave the default deinit armed in case you have to give up and throw during your attempted alternate teardown. If there is complex intertwining, I think there are benefits to keeping the complexity localized instead of making people factor their code in a way that might not be natural.
Recent reviews (particularly thinking of SE-0366 and the consume operator here) have had the community lead us away from using function syntax for non-function-like operators, so it seems appropriate to continue the precedent there, but I'd be fine with a longer name.
As Joe said, not all consuming methods are going to be destructuring. A database handle, for example, may want what are in essence just alternate deinits: One that flushes pending operations before closing and one that closes without flushing.
(On the other hand, there are good reasons to discourage consuming close
operations and maybe that's something we should analyze more closely.)
I'm not suggesting that all consuming
methods should be destructuring, just that allowing them to be annotated as destructuring might be a better way of achieving the language goals. Your forget
operator is perfectly isomorphic to defining a destructuring forget()
method. The difference is just that the latter encourages better code patterns: to me, if you have a complex consuming operation that sometimes destructures the value and sometimes forwards it, it is much better to write that in terms of smaller, unambiguous consumptions, splitting the destructuring paths out into tight "critical sections", than to have the destructuring be flow-sensitive and expect readers to keep track of it. And if anything that is more true in cases like init
where programmers may already be somewhat confused about when exactly the value is fully assembled.
And if anything that is more true in cases like
init
where programmers may already be somewhat confused about when exactly the value is fully assembled.
Perhaps there should be a rule that a noncopyable struct
(I refuse to type the preceding @
) with a deinit
must include an explicit deinit self
on all codepaths that follow complete initialization. Then there’s no ambiguity about when deinit
occurs:
noncopyable struct DatabaseHandle {
var dbConn: DatabaseConnection
init(hostname: String, port: Int, username: String, password: String) throws {
guard let dbConn = DatabaseConnection.open(to: hostname, port: port)
else {
throw ConnectionError()
}
// self is fully initialized at this point
guard dbConn.authenticate(as: username, password: password)
else {
deinit self
throw AuthenticationError()
}
}
deinit {
dbConn.close()
}
}
Perhaps there should be a rule that a
noncopyable struct
(I refuse to type the preceding@
) with adeinit
must include an explicitdeinit self
on all codepaths that follow complete initialization. Then there’s no ambiguity about whendeinit
occurs:
This is the rule that the proposal already imposes if you use forget self
anywhere in a consuming method, that deinit invocations must also become explicit (although it uses _ = consume self
, since that already effectively means "run the deinit"). It might be reasonable to require it to be explicit in initializers as well.
The difference is just that the latter encourages better code patterns: to me, if you have a complex consuming operation that sometimes destructures the value and sometimes forwards it, it is much better to write that in terms of smaller, unambiguous consumptions, splitting the destructuring paths out into tight "critical sections", than to have the destructuring be flow-sensitive and expect readers to keep track of it.
I'm having trouble thinking of how one would factor the "attempt an alternative destruction, but use the default destructor if that fails" pattern under this rule. Under the proposal as written, this would be written as:
consuming func attemptAlternateDeinit() throws {
do {
try library_attempt_alternate_deinit(self.handle)
// We no longer need to deinit if it succeeds
forget self
} catch {
// Explicitly consume self using the default deinit if it fails
_ = consume self
throw error
}
}
If the rule is that a method's otherwise non-consumed paths must either all end in deinit, or all end without deinit, then it seems like the best you can do is
@disableDefaultDeinit
consuming func attemptAlternateDeinit() throws {
do {
try library_attempt_alternate_deinit(self.handle)
// OK to drop the value at this point, as indicated by the attribute
} catch {
// Explicitly consume self by calling another method that
// defaults to doing so
self.runTheDefaultDeinit()
throw error
}
}
consuming func runTheDefaultDeinit() {}
which doesn't strike me as an improvement:
-
you have to write a method that has no purpose other than to get back into "deinit runs by default again" mode (though, to be fair, we could introduce a special
deinit self
syntax for that); -
the fact that the deinit doesn't run isn't locally evident at the point where it matters, after the successful call to
library_attempt_alternate_deinit
; -
the developer might not remember to deal with the error-thrown case, since the easiest thing to write is:
@disableDefaultDeinit consuming func attemptAlternateDeinit() throws { try library_attempt_alternate_deinit(self.handle) }
which lets the value leak on the error case. The proposal tries to avoid this by requiring whether the value is forgotten or not to be explicit on all code paths. Since even defaulting back to
deinit
might not be appropriate if you're doing something that requires suppressing it—you might want to throw ownership back to the caller to let them try something else again, or you're trying toclose(2)
on an unknown system and leaking the fd is really the only thing you can do—requiring the author to make an explicit choice struck us as appropriate.
Again, you can get your operator back by writing @disableDefaultDeinit consuming func forget() {}
, so if you want to write this function using implicit deinit
on some paths, you can do it:
consuming func attemptAlternateDeinit() throws {
try library_attempt_alternate_deinit(self.handle)
// If we got here, the call succeeded and we should suppress deinit.
self.forget()
}
But I think it's better to not mix implicit and explicit deinit and just drill directly through the abstraction, which presumably looks like this:
@suppressDefaultDeinit consuming func attemptAlternateDeinit() throws {
do {
try library_attempt_alternate_deinit(self.handle)
} catch {
library_normal_deinit(self.handle)
throw error
}
}
You can of course leak the value, but I don't think the attribute is any more prone to that than the flow-senstive operator; you can certainly use the operator carelessly by just putting forget self
at the top of the method.
I think a major issue with forget self
and _ = consume self
is that they read as synonyms.
consuming func attemptAlternateDeinit() throws { do { try library_attempt_alternate_deinit(self.handle) // We no longer need to deinit if it succeeds forget self } catch { // Explicitly consume self using the default deinit if it fails _ = consume self throw error } }
What does the declaration of self.handle
look like? Does this dilemma exist if the developer is explicit about the states their type may inhabit?
struct MyStruct {
var handle: Handle!
consuming func attemptAlternateDeinit() throws {
guard let handle = consumeAndReplace(&handle, with: nil) else { preconditionFailure("handle already destroyed") }
try library_attempt_alternate_deinit(handle)
_ = consume self
}
deinit () {
if let handle = handle.moveOut(replacingWith: nil) {
// Try to destroy the handle a different way?
}
}
}
What does the declaration of
self.handle
look like? Does this dilemma exist if the developer is explicit about the states their type may inhabit?struct MyStruct { var handle: Handle! consuming func attemptAlternateDeinit() throws { guard let handle = consumeAndReplace(&handle, with: nil) else { preconditionFailure("handle already destroyed") } try library_attempt_alternate_deinit(handle) _ = consume self } deinit () { if let handle = handle.moveOut(replacingWith: nil) { // Try to destroy the handle a different way? } } }
This creates a different dilemma, where handle
being nil is now a potential state that the value can be in at any time, which introduces runtime overhead to check for nil
and conceptual overhead for people working on the type who have to be mindful of that invalid state. That approach would indeed avoid the need for forget
, but I think it's worth a bit more conceptual complexity to be able to reach the "make invalid states unrepresentable" goal we generally strive for in Swift.
This creates a different dilemma, where
handle
being nil is now a potential state that the value can be in at any time, which introduces runtime overhead to check fornil
and conceptual overhead for people working on the type who have to be mindful of that invalid state. That approach would indeed avoid the need forforget
, but I think it's worth a bit more conceptual complexity to be able to reach the "make invalid states unrepresentable" goal we generally strive for in Swift.
This sounds like a different design goal that doesn't need to be conflated with the design of noncopyable
types. Everything about deinitialization of partially-constructed noncopyable structs applies equally to classes.
In the meantime, developers can adopt noncopyable
and model partial construction as complete construction, perhaps isolating partial construction to nested types such that their outer type is always either fully constructed or destructed.
This sounds like a different design goal that doesn't need to be conflated with the design of
noncopyable
types. Everything about deinitialization of partially-constructed noncopyable structs applies equally to classes.
The fact that classes have shared reference-counted ownership means it's not even an option to attempt static decomposition of them (outside of the deinit
itself). And I would say that "zero-runtime-overhead abstractions" are indeed a general goal of this feature, and that arguably includes the overhead of checking avoidable invalid states.
I wanted to pitch in favour of something like that because I feel it avoids growing the “language surface”. Forget as an operator duplicates access control rules and it comes with slightly different consequences for consuming than every other consuming operations. I find that making it a method is easier to hold in my head.
One pattern I’d probably use a lot, at least private
ly, would be to have a forgetting method that returns the held resource and consumes self, like unique_ptr’s release(). I could do that with either the operator or an attribute that makes the method forgetting. However, in the operator case, this pattern would not get any of forget’s benefits. It’s just one data point, but being able to find realistic cases where there isn’t much of a difference says to me that a new operator might not pull its mental overhead.
Other than this: completely in favor of non-copyable types, I think it's the right direction for Swift, etc. Only minor remarks are that given a prefix keyword/attribute to mark non-copyable types, I don't know what syntax we'll use for generic types that are copyable when their type arguments are. If the end result is a prefix keyword, I agree with other commenters that it's an important enough concept that it deserves to be outside of the @
syntactic namespace (which, until the introduction of result builders and property wrappers, I considered to be the namespace for keywords that had minor influence).
Although the review period is winding down, one topic I'd like to hear the community's feedback on is the lifetime behavior for noncopyable variables that don't get consumed. For copyable types, we've been imprecise about how long values live for, since we want to reserve optimization opportunities for ourselves with ARC, and the nature of shared ownership already makes the precise end of objects' lifetimes hard to predict, since you never definitely know who the last owner of an object is. However, we don't have that issue with single-ownership, noncopyable values, so we can choose to be more precise. The proposal currently states that a local value gets destroyed after its last use if it isn't otherwise consumed, so in this example:
func borrow(_: borrowing Foo)
func use(x: consuming Foo) {
print("a")
borrow(x)
// x's lifetime ends after `borrow` returns
print("b")
}
the deinit
if any for x
would run before print("b")
. Another option that might be more in line with developers' expectations might be to guarantee that x
's lifetime covers its lexical scope (up to the point it's consumed), which would be in line with C++ and Rust.
Can you speak to why Rust chose that behavior? Easier reasoning?
I seem to remember there being a trial of shortening lifetimes to last-use rather than end-of-scope, but that it was decided against after testing revealed it broke idiomatic Cocoa code. I recognize there’s no existing framework to break in the same way, but would the same potential problems arise here?
I believe it was an issue because it broke existing code in subtle ways. Since this is a new language feature it seems like there should be no impact at all and we're free to choose the "best" behavior.
Another option that might be more in line with developers' expectations might be to guarantee that
x
's lifetime covers its lexical scope (up to the point it's consumed), which would be in line with C++ and Rust.
I would advocate for prioritizing alignment with developer expectations here.
One can always explicitly consume to shorten the lifetime—indeed that seems like it should be a very idiomatic usage of consume
(if I eat the cake then I won’t have it anymore after that).
The opposite scenario, of explicitly consuming to extend lifetime, seems less intuitive (if I eat the cake later then I must have the cake until then—true but an odd way of thinking about it).
I think that the two interpretations are “the value is consumed after its last use” (ie, last use is implicitly consuming—which might have interesting implications if you can overload based on borrowing/consuming?) or “the value is consumed at the point it is syntactically no longer possible to reference it”.
I don’t feel strongly about it. I feel slightly towards “last use is implicitly consuming” so that it aligns with how references work, as consistency is always easier to explain. I’m also slightly concernced that “end of scope is consuming” will encourage people to bring C++ habits in Swift code when there are better patterns available, for instance relying on a lock guard protecting the rest of a scope when that’s not really how locks should be designed in Swift.