← Back to context

Comment by dalvrosa

5 days ago

Not sure what you mean, but std::move is one of the greatest tools in C++

This is one case where Rust benefited from C++’s experience — move by default with opt-in clone/copy is IMO the better setup.

  • This is an easy mistake to make but it's not what happened

    Programmers already knew (~20 years ago) when the C++ move feature was designed that what people want is the destructive move assignment semantic, the thing Rust has today. Other languages did have that. But C++ 98 already existed and WG21 already did not want to make it difficult to take your crusty 10+ year old C++ codebase, slap a sticker on it and say this is "Modern C++"

    The C++ "move" proposal is slightly sneaky, it admits that what they're proposing is not the destructive move (again, people know they want this) but it gives the impression that if they really want destructive move they can add it later, without revealing what's really going on underneath.

    In fact C++ move is roughly what Rust would call core::mem::take, we move something in the usual way (a destructive move) but then we replace it with some value of the same type, in the case of core::mem::take it's Default::default()

    To enable this, C++ is full of types which look superficially familiar to a Rust programmer but have a weird "empty" state to provide that default value where none would make sense. For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.

    [Edited to clarify timeline]

    • > For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.

      This would be the case even with destructive moves unless you also add some way for std::optional<std::unique_ptr<T>> to be no larger than a pointer by letting std::optional take advantage of the fact that a non-null std::unique_ptr has a bit representation that leaves room for sentinel values.

      Also, at the language level, C++ moves are sort-destructive as the moved from object only has to guarantee to be able to run the destructor. E.g. you could still have a std::nonull_ptr where move sets the internal pointer to zero but calling anything except the destructor on such an instance throws / calls std::terminate() / is UB. It's only the stdlib types that make additional guarantees - because in most cases it can be done without additional cost.

      1 reply →

  • And the most important idea: destructive moves. Since C++ doesn't track lifetimes it has to leave the object in a "valid state" after a move and the destructor still runs which has to have a check if it should do something or not.

    • BTW, Rust's lifetime annotations for borrowed references are a mostly orthogonal feature.

      Liveness of objects for move/drop semantics is tracked differently, without any syntax and with implicit runtime drop flags where necessary.

      C++ could probably add the same deinitialized/moved-from state tracking (with an opt-in for back compat sake) purely to avoid dtor bloat, without having to add safety of borrow checking.

      3 replies →

    • While not the common case, C++ move semantics match the situation in systems code where correctness requires decoupling logical object lifetimes and destructors. The object is logically dead but the destructor may be deferred indefinitely for safety reasons.

      Most code doesn't have the shared memory setups where deferred destruction is necessary.

  • It did for sure, but the problem with C++ is its heritage, specifically that structures can be self-referential. For instance, the Rust's url::Url type has to use usize offsets for tracking the location of each of its components. Conversely, in C++, someone could have already created a similar Url type that would use std::string for the buffer and char pointers for the component locations. As such, you cannot simply memcpy from one struct into another and forget the former as std::string could have its own in-place storage and that would invalidate all pointers - you'll need to define a move constructor instead.

std::move is a great tool when used correctly. However used incorrectly it makes code worse: more verbose and less performant. Since I have no idea how you are using it I can't comment on your experience. My experience is people (including me!) get it wrong fairly often. Fortunately tools can detect a lot of cases where you get it wrong.

  • My little trick is to think of them as "oh, I accidentally made an lvalue from an actual rvalue here because a name was introduced, so I need to cast (i.e move() or forward()) back to an rvalue again", that's why i have them as macros: MOVE_CAST and FORWARD_CAST defined as static_cast (also avoids blowing up compile times). I never think in terms of "moving this object".