Destructuring Droppable structs

Should a droppable-struct-destructure require matching all fields? IMO that would help avoid confusion around what happens with the remaining fields. Basically, the idea should be that let Dummy { foo, bar } = dummy; fully consumes dummy and returns its constituent fields. However, let Dummy { foo, .. } = dummy looks more like let foo = dummy.foo;, i.e. like a move of some fields, and that obviously [at least, according to my intuitive expectation] should not work on types that have drop. So requiring all fields to be matched could help drive home the point that this is indeed fully disassembling the dummy, and there is nothing left that a destructor could be called on.

Exactly, that's my thinking, too. (I wrote my comment above before I began reading this thread.)

It is also incoherent with the usual type theory approach. In the theory of linear type systems, we typically have two product types. I always mix up which is which so I hope I will get this right...

  • positive products can be accessed via let (x, y) = prod in ..., which destructs prod and binds both of its pieces.
  • negative products (also called "additive conjunction" and spelled &) can be accessed via prod.0 and prod.1. Since the type system is linear ("every variable [including prod] may be used only once"), only one of these can be used! After prod.0, prod has been consumed and prod.1 cannot be used any more. Rust does not have a type that corresponds to negative products.

Rust deviates from this by providing projections such as .0 and .1 together with fine-grained tracking of which fields have already been moved out. But adding a destructor brings Rust closer to linear types in the sense that the destructor must be called when the variable goes out of scope (unlike normal linear languages, the call is inserted implicitly when it is missing). Allowing the equivalent of let (x, y) = prod in ... is consistent with the linear types view of this, but allowing projections is not.

So, with my type theory lens, it makes perfect sense that we would allow destructing and not projections.

2 Likes

As a user, I would say no.

Having to write:

let Dummy { foo, bar, baz } = dummy;
drop(bar);
drop(baz);

...

is just plain boilerplate, for no good reason.

I would be fine with the restriction that the user should be able to write the full form (ie, should only be allowed to destructure a value if they can access all its fields), but syntactically requiring the user to enumerate all fields is just punishing them for no good reason.

In my ManuallyDrop-based RFC, fields that are not moved out of (including inaccessible fields) are leaked. This removes the risk of UB if the private fields had broken safety invariants, but at the cost of making it easier to leak accidentally.

I am not sure, would it behave identically spelled like this:

let Dummy { foo, bar: _, baz: _} = dummy;

To me, this reads as a way of asserting "I am allowed to name the bar and baz fields from within this module, but want to drop the values.

No*. Binding a place to _ does not move from the mentioned place, but leaves the value where it is, still accessible. Try writing let _ = ident; and see how that's different from let _ = { ident }; which does cause a move.

* Since it's not possible to deconstruct impl Drop structs today, it could be defined to move the whole value instead of fields individually, but that subtle of a difference in semantics seems quite ill advised. That _ bindings are a full no-op is already surprising (but very desirable e.g. when only Copy types are pattern matched out) without it sometimes not being that.

3 Likes

(NOT A CONTRIBUTION)

I really don't understand why all of this shouldn't just work, including the field access: if you take a struct apart, the fields you don't take will be dropped (running their destructors), but the struct has been taken apart, so of course its destructor will not run. Again, if you want to control what modules are allowed to do this, for safety or other reasons, the solution is privacy.

I don't see field accesses as any different from destructuring, it's just performed across multiple expressions.

2 Likes

To me it feels very different whether I am writing something like let Foo { field1, field2 } = foo, or even let Foo { field1, .. } = foo, which very explicit takes foo apart, in contrast to foo.field1 which is just a projection. In situations where "the moment a Foo object stops existing" is semantically relevant (because it decides whether drop is run or not!), it can be quite tricky to find all the foo.field1 projections that implicitly do this, and it is much easier to spot let Foo { ... } = foo doing this explicitly.

It gets even worse with foo.field1.method(...). Now if you want to determine whether the destructor of foo ever runs, you need to know the type of method! I really don't think we want to have such implicit magic. Opting-out of a destructor should be more explicit than this. It should be a very conscious decision.

Maybe I have been around linear types people too long, maybe this is Stroustrup's Rule, but I think "does this object have its destructor run" is something we want to be somewhat straight-forward to reason about.

8 Likes

(NOT A CONTRIBUTION)

I understand that mindset. The way I disagree is that I feel like Rust has already crossed the bridge into the world of magic: to me it seems like the barrier between "explicit" and "implicit" is when we started tracking which fields you've moved out of or not, and let method receivers implicitly inject reference and dereference operators, and implicitly reborrow mutable references, and so on. Allowing this for types with destructors is just being consistent with the decision the language has already made.

Like what is the difference between "determining if the destructor of foo runs" and "determining if you still have ownership of foo"? It's already the case that the latter depends on the type of method. I can see that it could be important for unsafe code, but I'd really rather not have implicit destructors at all in unsafe code because of the bugs they have caused me.

Ownership of foo is entirely a type system fiction, it doesn't appear in the operational semantics. It decides whether your program gets accepted but not what it does. I am okay with all sorts of magic on the type system level.

The destructor running is very operational, magic here can actually change what your program does. That is qualitatively different.

3 Likes

(NOT A CONTRIBUTION)

Thanks, your reasoning makes sense.

To me, there is a difference between the desired behavior of well designed safe Rust APIs - where I would want the compiler to just "do what I mean" and run the destructor if and only if I don't move out of the value, by whatever means - and the more cautious design I would want for writing unsafe Rust and building those kinds of abstractions; it's a challenge to thread the needle in a single language.

Extending this hypothetical a bit more, there's a hazard of temporarily opting out of the destructor without realizing it. For example, foo.field1 = foo.field1.method(): It looks like you're simply transforming the field1 value, leaving the struct fully complete (with destructor). But if method() panics, foo's destructor will not run because it is a partial Foo at that moment.

1 Like

I think this one is subtle-enough that I would make moving out a field of a type which implements Drop -- and thus disabling Drop without it being obvious -- a hard error.

Let the user destructure the type to access the fields they need.

(Just don't overdo it by requiring they name all fields, which is pure boilerplate)

2 Likes

Do you have a particular example of such a safe API in mind?

APIs with public fields are not super common to begin with, so I think I lack the imagination here. So in my head I was mostly considering code that lives inside the abstraction barrier, even in entirely safe code, where I think I'd like the compiler to help me track when I am "disabling" drop.

2 Likes

(NOT A CONTRIBUTION)

My own examples (not open source) involve abstractions on low-level IO interfaces similar to io-uring, in which the right way to represent them involve passing ownership of an object to the kernel and then later receiving it back. Note these are all module-internal accesses to private fields, not public fields; they're just examples where just allowing all forms of moving out of an object if you have visibility would be exactly the right behavior.

I have a wrapper object around some operating system resource handle (something like OwnedFd). This has a destructor, which does the clean up for this resource when its no longer in use. But there other APIs on the object that pass it to the kernel, and the program will eventually get the object back, so the clean up code should not run when you call these APIs (which take self by value, so they can pass ownership to the kernel).

I can do this by destructuring (and I can even get around the PITA of today's rules by using ManuallyDrop), but the cleanest code is usually just to do something like pass_to_kernel(self.inner).

In my experience, there is a tight connection between "I passed ownership somewhere else" and "the destructor should not run," so that I'm inclined to believe that simply making it always possible to move out of an object if you have visibility and then not running its destructor will do the right thing every time. My experience is especially influenced by the fact that I have done a lot of work dealing with host interfaces that are effectively "pass ownership to this concurrent process" so the object disappears, but only because it is being "moved" to a process outside the bounds of our program.

Do you have an example in which a bug might've been introduced but is prevented by the sort of restriction you propose? I can imagine that my experience is biased and there are other examples where this restriction is more useful. I'd be eager to read about concrete examples beyond just an abstract sense of unease about this code being tricky.

5 Likes

Until an unrelated refactor makes self.inner Copy, and suddenly pass_to_kernel(self.inner) is no longer a move, so self’s destructor now runs, resulting in a double‐free.

2 Likes

(NOT A CONTRIBUTION)

A fair enough point. I'm not so worried about a refactor on this, but the subtlety of having to know ahead of time that this doesn't count as a move if the field type is Copy is not ideal. I'm not sure what a better solution would be, but I acknowledge that this is a problem about Copy and destructors.

However I note that this unfortunate behavior is already the case: you can destructure a droppable type into its Copy values, and then its destructor runs. So imposing the restriction Ralf proposes doesn't seem to help here. cf:

struct Foo(i32);

impl Drop for Foo {
    fn drop(&mut self) {
        println!("ran");
    }
}

// prints 0 and ran
fn main() {
    let f = Foo(0);
    let Foo(x) = f;
    println!("{x}");
}
5 Likes

Unfortunately not, I do not have access to software developers so I only hear about examples like that when they show up in our issue tracker or Zulip and I happen to be included in the conversation.

For the usecase you mention, would it be acceptable to define a method like

impl OsResourceHandle {
  fn inner(self) -> HandleType {
    let OsResourceHandle { inner, .. } = self;
    inner
  }
}

and then call things as pass_to_kernel(self.inner())? In the code implementing the logic, this is just an extra pair of parentheses. This assumes that you don't want to access any of the other fields afterwards, which seems like a fair assumption to me -- but we clearly have quite different mental models for how to think about this situation.

Good catch! I did not realize such code would be accepted. I wonder if it would be reasonable to lint against that.

(NOT A CONTRIBUTION)

Yes if this were being done a lot that would be a reasonable option; more likely I would just destructure the type inline.

If only destructuring were permitted I would consider that an improvement over the status quo; I'm just saying that a distinction between destructuring and field accesses wouldn't prevent bugs in my experience and would just require slightly more code contortion to satisfy the compiler.

This use case sounds a lot like OwnedFd which is explicitly !Copy for this reason...I think generally these "resource ownership handles" are going to be written by people familiar with Rust and they should generally know how to force off the auto-Copy impl, right?

I'm a bit confused. Is partial-borrow related to partial borrow (e.g. ' self.inner lifetime)?