SE-0390: @noncopyable structs and enums

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.

1 Like

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?

1 Like

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.

2 Likes

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.

2 Likes

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()
    }
}

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.

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 to close(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.

1 Like

I think a major issue with forget self and _ = consume self is that they read as synonyms.

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?
      }
    }
}
2 Likes

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.

1 Like

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.

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.

5 Likes

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 privately, 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.

3 Likes

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).

1 Like

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.

3 Likes

Can you speak to why Rust chose that behavior? Easier reasoning?

1 Like

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.

1 Like

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).

8 Likes

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.

11 Likes