4 ms·
>You also have to be careful not to mess with a moved-from object, as it's in an unspecified state. This is incorrect, somewhat. It's "unspecified" in the sens
by fluoridation 3y ago
>You also have to be careful not to mess with a moved-from object, as it's in an unspecified state.
This is incorrect, somewhat. It's "unspecified" in the sense that the standard doesn't mandate that user-defined move constructors and assignment operators leave source objects in any particular state. All standard library classes are left in well-defined states when moved (if they can be moved), and you can choose to define your classes to do the same. The usual rule of thumb is that a moved object should be in the same state as if it had just been default-constructed. This is what all the standard classes do.
Move semantics in C++ and in Rust are more or less equivalent. The major differences are that C++ copies by default and rust moves by default, and that Rust doesn't allow using a moved object while C++ does.
>In Rust, all you have to remember is that types are by default moveable (and moves are a memcpy), deep copies are implemented through the Clone trait, and if your type can't be memcpy'd (e.g., a self-referential struct) it needs to only be accessible through Pin pointers, which ensure that it isn't memcpy'd in safe code.
If defining value semantics was so simple you wouldn't have had to bring up Pin pointers (which I assume are not just pointers, but something special that needs to be kept in mind), or the fact that Rust understands that there are two different kinds of code, which C++ doesn't distinguish. It's suddenly so obvious that one is simpler than the other.
>Another thing: you can cause a use-after-free error by combining coroutines with lambdas [1]. An error like this only happens because C++'s rules around coroutines and lambdas are complicated enough that the committee didn't forsee this happening. This seems indicative of higher complexity in C++ than Rust, to me.
It's trivially easy to cause use-after-free errors with lambdas. Return an std::function<void()> that captures and reads a local std::unique_ptr by reference and then call operator()() on the object. This is not a complex interplay between features; lambdas necessarily introduce dynamic lifetimes into a language that was originally not designed to support them, so using them requires care.
- trealira 3y ago>If defining value semantics was so simple you wouldn't have had to bring up Pin pointers (which I assume are not just pointers, but something special that needs to be kept in mind) [...] Pin<P> is basically a wrapper for any pointer type P, whether it's Box<T>, &mut T, &T, etc. Its sole purpose is to keep the object that's pointed at from being moved, byte-copied, or byte-swapped. For self-referential structs, that's all you can do; there's no equivalent to move constructors. See this link at the bottom [1]. > This is incorrect, somewhat. [...] You're right, but I really just meant that you have to keep track of more. If you use a moved-from unique_ptr, for example, you'll dereference a null pointer. Sometimes you do want to use a moved-from object, though, so it's not like this can be disallowed. It's something you have to keep track of. I just think this is more complex than Rust's unconditional rule that moves are bytewise copies, and moved-from objects aren't allowed to be used. I've heard C++ programmers complain about Rust being less powerful than Rust in this respect, but it is simpler. > It's trivially easy to cause use-after-free errors with lambdas. I don't fully understand it, but from what's described at the link, this causes the use-after-free bug: [x] () -> future<T> { co_await something(); co_return x; } The fix is to write it like this, I'm pretty sure: [x] () -> future<T> { auto xx = x; co_await something(); co_return xx; } The reason is because coroutines copy their input to their own stack frames, but lambdas are passed by address, and so the coroutine later dereferences a dangling pointer. Again, it just seems more complex than anything in Rust. [1]: https://doc.rust-lang.org/nightly/std/pin/index.html https://doc.rust-lang.org/nightly/std/pin/index.html
- fluoridation 3y ago>If you use a moved-from unique_ptr, for example, you'll dereference a null pointer. You can still check if the pointer is null first, which would be UB if the object was simply in an undefined state. >It's something you have to keep track of. You don't have to keep track of it, you just have to design your classes such that moving a value leaves the source in a usable state. At the point of use, moving is no different from any other operation on the object. There's no intrinsic difference between moving an std::unique_ptr and reset()ing it. You do have to keep in mind the possible values of the object as you operate on it, but this has nothing to do with move semantics. In any language, you wouldn't want to attempt to access the 10th element of a list after clearing the list. Now, if you design your class such that the moved-from state is invalid and distinct from any state the the object could reach by any other means then yes, you will need to treat moves on that type differently. However, that's a problem you created for yourself. >The reason is because coroutines copy their input to their own stack frames, but lambdas are passed by address, and so the coroutine later dereferences a dangling pointer. Yes, like I said, lambdas decouple lifetime from lexical scope. You have to be use them carefully to avoid running into issues. This is not because C++ lambdas are a complex feature than Rust lambdas. The opposite is true: Rust lambdas are more difficult to use incorrectly because Rust's type system is more complex and can keep track more closely of the lifetimes of objects.
- senderista 3y ago> Move semantics in C++ and in Rust are more or less equivalent. That's not my experience at all. The big annoyance for me with C++ move semantics is that it forces me to allow an invalid or default state (representing a moved-from object), which subverts a major premise of RAII: non-default constructors establish class invariants which are maintained for the lifetime of the object, so that all other code can assume those invariants. There should be no such thing as an invalid or partially initialized object. When I'm forced to allow an invalid state to support move semantics, all my code has to either check for this invalid state (if it's logically possible) or assert that it's not present (if it's not logically possible). That's a major source of gratuitous complexity that simply isn't inherent to move semantics, as Rust demonstrates. (The invalid state is necessary because there's no way to prevent destructors from running in C++, so a destructor needs some way to know that an object isn't properly initialized.) C++ move semantics have brought us back to the bad old days of checking isInitialized flags and calling initialize() methods, which is what non-default constructors were supposed to solve.
- fluoridation 3y agoIf you find yourself doing if (!initialized) initialize(); that's a sign that you should have just called initialize() on the moved object while still inside the move constructor, if initialize doesn't need any additional parameters. If there's no way to construct or initialize the class in a default state (e.g. something equivalent to an empty std::string) with no additional parameters, it's probable that the class shouldn't have been movable, and instead the object should have been wrapped either in std::unique_ptr or std::optional. Not every class needs to be movable. Again, this has nothing to do with C++'s move semantics and everything to do with how you define your object's state transformations.
- senderista 3y agoLet me make it a bit more concrete. I have a hazard pointer class, where the constructor registers the provided pointer for GC protection, and the destructor removes GC protection. I would like to be able to dereference this hazard pointer object freely, without doing null checks everywhere. RAII is the perfect fit for these semantics: the constructor establishes the invariant (GC-protected non-null pointer) and all other code can assume the invariant. Until I needed move semantics, that is. In the constructor of a class with a hazard pointer member I needed to be able to initialize a hazard pointer on the stack and then move it into the member variable. (Because the hazard pointer constructor is fallible, I needed to catch exceptions thrown from the hazard pointer's constructor and retry from within the containing class's constructor, so I couldn't just use an initializer list.) In order to support move semantics, I had to give up the invariant that any hazard pointer instance is properly initialized (I needed to use a null pointer to represent the invalid state). That complicated all the clients, which now had to either check for or assert against the invalid state. None of these gymnastics would have been necessary in Rust. Sure, it doesn't have constructors, but it's easy enough to write a factory method that establishes constructor invariants, and then you know that any object returned from that factory method will satisfy those invariants for the entire lifetime of the object. Since it is impossible to accidentally use a moved-from object (unlike C++), there is no need to introduce an invalid state to prevent misuse. I could just freely dereference my hazard pointers, with no checks or asserts necessary.