[Pitch 2] Allowing for partial mutation and consumption inside of non-`Copyable` type `deinit`

Hi everyone. I'd like to restart discussion about allowing for deinits on non-Copyable structs and enums to mutate and consume the value while it's being destroyed. Based on our previous discussions, and on feedback from developers who have been using non-Copyable types, I've implemented the proposal, with the restriction that self in a deinit cannot be mutated or consumed as an entire value, but its fields can be, which avoids the potential for "resurrection" behavior when passing self into other methods. I now have that implementation under -experimental-feature-flag MutateOrConsumeInDeinit here:

I've revised the proposal to correspond to this implementation:

Let me know what you all think. Is this an acceptable initial limitation for the feature? Are there other concerns you all have with the design?

21 Likes
struct Resource: ~Copyable {
  var resourceID: Int
  
  private static func release(resourceID: Int) throws {...}
  consuming func release() throws {
    try Self.release(resourceID: self.resourceID)
    discard self
  }
  
  deinit {
    do {
      try Self.release(resourceID: self.resourceID)
    } catch {
    }
  }
}

Just making sure I understand: this doesn't let me make a linearly-typed fnOnce (eg UnsafeContinuation.resume) that must be called prior to release, but... it does let me provide a default value (in this case resourceId) to be used to enforce making the call when self is released?

This proposal won't change anything as far as enabling strict linear types. The intent of the example is to show how a static method can be used as a way of sharing common logic between an explicit consuming method and the implicit deinit under the restriction that deinit cannot consume self as an entire value. You can use that as a way to provide an explicit consume operation with additional parameters, results, or errors, while having the implicit deinit provide default behavior for cleanup.

3 Likes

I mostly like this! I don't think I have any deep insights, but it seems generally more expressive / ergonomic than what we have for drop (finalizers / deinit) in Rust, in ways that I think are more useful for many applications. In rust you receive &mut Self for drop which would allow passing self to mutating (though not consuming) functions. We can't do that via this pitch without boilerplate (could a macro generate it? not as efficient without optimizations as resurrect self I guess). But you can't consume members via &mut Self in rust, since the type needs to remain valid. You can call special methods on some containers that remove contents and leave a valid empty value (so you can invoke a consuming thing on the contents), but that type needs to have been designed with that in mind. Consuming arbitrary members with no ceremony feels more useful to me than abstracting deinit... It seems to me like the "deinit deferal" pattern could even be done automatically with a macro on the decl, which is nice.

For the future directions, it also seems like we can add resurrect self later if we discover the need? Or we could allow calling locally defined methods on self in the future as well (this rule feels weird to teach, maybe its ok).

So this reads like an incremental improvement to deinit that lets us express more code without limiting our options (assuming we never want to express finalizers as mutable references) under a type based lifetimes model. Except, right now we have "value based" lifetimes, where dependency is rooted in some specific value. This commits us to 'bag of independent members' understanding of values during deinit, so (I think?) we need to be able to reject code where one member depends on another member, and the dependent member is used after its dependency is consumed. Afaik we don't let you express a type like that at all today (type with a member that borrows from another member), so it's not a huge deal, but we are making some kind of commitment with this change around that, that I don't claim to fully understand... We should make sure this doesn't let us express any lifetimes code we'd need to break if we transition to a more type based model.

I think Rust's design for drop makes sense given that they (a) didn't want to introduce a special form for destructor declarations, (b) didn't want to introduce a "type minus destructor" special case just to allow for consumption in drop without an implicit infinite loop, and (c) their &mut borrows don't allow for casual consumption and reinitialization of a value in-place like ours do, so resurrection by whole-value mutation is less likely. Rust does still have std::mem::swap and std::mem::replace to replace a value, and using them from drop seems like it would have the same effect we're trying to avoid of resurrecting the current "self" and sacrificing the value left behind in its stead. My hunch is that this isn't that big of a deal in practice, and I don't recall anyone ever bringing this up as a "gotcha" in Rust land, but for this proposal it seems prudent to start restrictive and only ease up if the demand is there.

I don't foresee this being a problem, or at least not a new problem, since we already allow for elementwise consumption of structs when they don't have a deinit and their layout is visible, and as you noted, there is no way yet to specify dependencies between fields within the same aggregation. Intra-field dependencies would come with some additional complications, and AIUI Rust is also grappling with that problem.

1 Like