8 ms·
Rust's behavior of moving without leaving a moved-out shell behind also simplifies the implementation of the type itself, because its dtor doesn't have to handl
by Arnavion 1y ago
Rust's behavior of moving without leaving a moved-out shell behind also simplifies the implementation of the type itself, because its dtor doesn't have to handle the special case of a moved-out shell, and the type doesn't even need to be able to represent a moved-out shell.
For example, a moved-out-from tree in C++ could represent this by having its inner root pointer be nullptr, and then its dtor would have to check for the root being nullptr, and all its member fns would have the danger of UB (nullptr dereference) if the caller called them on a moved-out shell. But the Rust version could use a non-nullable pointer type (Box), and its dtor and member fns would be guaranteed to act on a valid pointer.
- spacechild1 1y agoIn practice, move operations typically just leave an empty object behind. The destructor already has to deal with that. And of course you can't call certain methods on an empty object. So in practice you don't need special logic except for the move operations themselves.
- Dylan16807 1y ago> The destructor already has to deal with that. That's partly true, partly circular. Because moves work this way, it's harder to make a class that doesn't have empty states, so I don't design my class to avoid empty states, so the destructor has to handle them.
- spacechild1 1y agoPlease give me an example for a class that needs to handle empty state in the destructor only because of move operations. These exist, but IME they are very rare. As soon as you have a default constructor, the destructor needs to handle the case of empty state.
- tialaramex 1y agoThis means C++ is riddled with types that have unrelated "I'm empty" state inside them rather than this being relegated to a separate wrapper type. It's Tony's Billion Dollar Mistake but smeared across an entire ecosystem. The smart pointer std::unique_ptr<T> is an example of this, sometimes people will say it's basically a boxed T, so analogous to Rust's Box<T> but it isn't quite, it's actually equivalent to Option<Box<T>>. And if we don't want to allow None? Too bad, you can't express that in C++ But you're right that C++ people soldier on, there aren't many C++ types where this nonsense unavoidably gets in your face. std::variant's magic valueless_by_exception is such an example and it's not at all uncommon for C++ people to just pretend it can't happen rather than take it square on.
- steveklabnik 1y ago(And that difference leads to an ABI difference that makes it not a zero overhead abstraction in the way that Box is…)
- spacechild1 1y agoGreat point! Chandler Carruth explained this in one of this cppcon talks: https://youtu.be/rHIkrotSwcc?t=1047 https://youtu.be/rHIkrotSwcc?t=1047
- spacechild1 1y ago> This means C++ is riddled with types that have unrelated "I'm empty" state Again, these cases are still rare. Most classes either don't require user-defined move operations, or they have some notion of emptiness or default state. > And if we don't want to allow None? Too bad, you can't express that in C++ That's actually a good example! Nitpick: you can express it in C++, just not without additional logic and some overhead :)
- 7jjjjjjj 1y ago>you can express it in C++, just not without additional logic and some overhead :) How?
- sgsjchs 1y agoA socket.
- spacechild1 1y agoHow so? Doesn't your socket class have a default constructor and a notion of open and closed?
- sgsjchs 1y agoIf the moves were destructive, I'd design it to have the default constructor call `::socket` and destructor call `::close`. And there wouldn't be any kind of "closed" state. Why would I want it?
- spacechild1 1y agoYour socket class would have no default constructor? And you would never want to close the socket before the object's lifetime ends? Really?
- 7jjjjjjj 1y agoWith destructive moves, you can end an object's lifetime whenever you want.
- spacechild1 1y agoHow would I use such a socket class as a member variable? How do I reopen the socket?
- sgsjchs 1y agoReopen by constructing and assigning a new socket.
- spacechild1 1y ago
- ninkendo 1y agoIt’s not just the destructor you have to worry about, it’s all of the state accessible to callers. If you have any type that represents validated data, say a string wrapper which conveys (say) a valid customer address, how do you empty it out? You could turn it into an empty string, but now that .street() method has to return an optional value, which defeats the purpose of your type representing validated data in the first place. The moved-from value has to be valid after move (all of its invariants need to hold), which means you can’t express invariants unless they can survive a move. It is much better for the language to simply zap the moved-from value out of existence so that you don’t have to deal with any of that.
- spacechild1 1y agoFirst, one shouldn't use a moved-from object in the first place (except for, maybe, reassigning it). Second, why can't the .street() method simply return an empty string in this case? > The moved-from value has to be valid after move (all of its invariants need to hold) The full quote from the C++ standard is: "Unless otherwise specified, such moved-from objects shall be placed in a valid but unspecified state" AFAIK, it only makes such requirements for standard library types, but not for user defined types. Please correct me if I'm wrong.
- ninkendo 1y ago> First, one shouldn't use a moved-from object in the first place (except for, maybe, reassigning it). It still requires you to come up with somethkng to do to the old value in the move constructor. What would you do in the ValidatedAddress case? Set a flag in the struct called “moved_from” and use that to throw an exception if it’s ever used? Wouldn’t it be nice if you just didn’t need to worry about it? > Second, why can't the .street() method simply return an empty string in this case? In this example I’m referring to a type that represents a “validated” address, so, one that has already passed checks to make sure the street isn’t empty, etc. (it’s the whole “parse, don’t validate” idea, although I’ve never understood why the word “parse” is used when I would’ve just called it “validate just once”.) It is an extremely useful concept for your type system to represents invariants in your data like this. Having to make every type contain an “empty” case, just to make the language’s move semantics work, pokes an enormous hole through this idea. > AFAIK, it only makes such requirements for standard library types, but not for user defined types It makes the requirement because the compiler is not going to stop anyone from using the moved-from value, so you have to think of something to do in the move constructor. You can pinky-swear to never use the moved-from value in your own code (and linters can help here) but the possibility still exists, so it must be solved for.
- mort96 1y agoIt's not like it's the only part of the language that mandates a default constructor though. There are plenty of situations where default-constructible types are desirable. Even simple things like having a non-default-constructible type in a map is awkward.
- masklinn 1y ago> It's not like it's the only part of the language that mandates a default constructor though It’s… not a part of the langage which mandates a default ctor in the first place.
- mort96 1y agoIt doesn't, but it does mandate that the object has some "empty state". If you have an empty state you might as well have a default constructor which initializes the object to that empty state.
- masklinn 1y agomoved-from objects are not in an empty state but in an unspecified state, they are only required to be destructible, every other operation can be disallowed. That is not a useful state for default construction. Thus being movable does not imply defaulting is any sort of good idea. The other way around makes more sense, but even then it is not systematic, if default construction is costly (allocation, syscall, …) then you don’t want to do that for a moved-from object which will just be destroyed, which is the fate of most.
- mort96 1y ago> moved-from objects are not in an empty state but in an unspecified state, they are only required to be destructible, every other operation can be disallowed. That is not a useful state for default construction. Thus being movable does not imply defaulting is any sort of good idea. This is only true for standard library objects. The C++ standard specifies that e.g std::unordered_map will be in a "valid but unspecified state" when moved from. You can define your own classes to behave however you want, including defining a moved-from object to be identical to a default-constructed object. Regardless, you're missing the point. Even if the standard specified that every moved-from object can only be destructed and every other use was UB, you still need the moved-from object to be in some empty state. Your move constructor and move operator= needs to put the moved-from object into some state where it doesn't own any resources but can be safely destructed. There's typically little reason to not make this "doesn't own any resources" state available through a default constructor. > The other way around makes more sense, but even then it is not systematic, if default construction is costly (allocation, syscall, …) then you don’t want to do that for a moved-from object which will just be destroyed, which is the fate of most. If your class can be in some "empty" state that doesn't own any resources and doesn't require syscalls to construct, you want this to be both the default constructed state and the moved-from state. Default-constructible objects end up getting default-constructed then overwritten all the time in C++, such as by the common pattern 'my_unordered_map["foo"] = MyClass(...)' which will first default-construct a value and then call its move operator=.
- motorest 1y ago> That's partly true, partly circular. I don't think there is anything "partly" about it being true. A moved-from object is expected to remain valid and preserve class invariants. If you wrote a class whose objects fails to remain valid after being moved,you wrote bugs into your code. > Because moves work this way, it's harder to make a class that doesn't have empty states, so I don't design my class to avoid empty states, so the destructor has to handle them. You are not required to implement an empty state. You are only required to write your classes so that after moving an object it remains valid. You are free to specify what this means to your classes, and can be anything from leaving the object as if it was default initialized or have literally a member variable such as bool moved. It's up to you. In C++'s perspective as long as your moved-from object can be safely destroyed them it's all good. Anything else is the behavior you chose to have, and bugs you introduced.
- Someone 1y ago> For example, a moved-out-from tree in C++ could represent this by having its inner root pointer be nullptr, and then its dtor would have to check for the root being nullptr, delete null is fine in C++ [1], so, assuming root either is a C++ object or a C type without members that point to data that also must be freed, its destructor can do delete root. And those assumptions would hold in ‘normal’ C++ code. [1] https://en.cppreference.com/w/cpp/language/delete.html https://en.cppreference.com/w/cpp/language/delete.html: “If ptr is a null pointer value, no destructors are called, and the deallocation function may or may not be called (it's unspecified), but the default deallocation functions are guaranteed to do nothing when passed a null pointer.”
- nixpulvis 1y agoThis was one of the most unsatisfying things about learning C++ move semantics. They only kinda move the thing, leaving this shell behind is a nightmare.
- tialaramex 1y agoWhen I looked into the history of the C++ move (which after all didn't even exist in C++ 98 when the language was first standardized) I discovered that in fact they knew nobody wants this semantic. The proposal paper doesn't even try to hide that what programmers want is the destructive move (the thing Rust has) but it argues that was too hard to do with the existing C++ design so... The more unfortunate, perhaps disingenuous part is that the proposal paper tries to pretend you can make the destructive move later if you need it once you've got their C++ move. But actually what they're proposing is that "move + create" + "destroy" = "move". So, that's extra work it's not the same thing at all and sure enough in the real world this means extra work, from compilers, from programmers and sometimes (if it isn't removed by the optimiser) from the runtime program.
- reactordev 1y agoC++ is riddled with “good enough” without completeness. Resulting in more bandaids to the language to fix stuff they half implemented in the first place.
- aw1621107 1y ago> When I looked into the history of the C++ move (which after all didn't even exist in C++ 98 when the language was first standardized) I discovered that in fact they knew nobody wants this semantic. The proposal paper doesn't even try to hide that what programmers want is the destructive move (the thing Rust has) but it argues that was too hard to do with the existing C++ design so... > The more unfortunate, perhaps disingenuous part is that the proposal paper tries to pretend you can make the destructive move later if you need it once you've got their C++ move. For reference, I think N1377 is the original move proposal [0]. Quoting from that: > Alternative move designs > Destructive move semantics > There is significant desire among C++ programmers for what we call destructive move semantics. This is similar to that outlined above, but the source object is left destructed instead of in a valid constructed state. The biggest advantage of a destructive move constructor is that one can program such an operation for a class that does not have a valid resourceless state. For example, the simple string class that always holds at least a one character buffer could have a destructive move constructor. One simply transfers the pointer to the data buffer to the new object and declares the source destructed. This has an initial appeal both in simplicity and efficiency. The simplicity appeal is short lived however. > When dealing with class hierarchies, destructive move semantics becomes problematic. If you move the base first, then the source has a constructed derived part and a destructed base part. If you move the derived part first then the target has a constructed derived part and a not-yet-constructed base part. Neither option seems viable. Several solutions to this dilemma have been explored. <snip> > In the end, we simply gave up on this as too much pain for not enough gain. However the current proposal does not prohibit destructive move semantics in the future. It could be done in addition to the non-destructive move semantics outlined in this proposal should someone wish to carry that torch. [0]: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n13...