Ownership and moves: C++ and Rust

C++ has a move constructor, std::move and a moved-from object that is valid but unspecified. Rust has a move with no constructor and no cast, and a moved-from binding that may not be used at all. The two languages agree on the cheap part, the transfer of a buffer, and disagree on what is left behind.

This page takes one string, moves it, prints the moved-from value and then writes to it again. Both programs are built and run on the machine named at the end, and the second program is the one the Rust compiler refuses.

The task in C++

std::move is a cast to an rvalue reference; it does not move anything by itself. The move constructor of std::string then takes the buffer, and the source is left in a valid state with an unspecified value, which on this implementation is an empty string.

The program prints the stolen value, the state of the source and a line after writing to the source again. That last line is legal: a moved-from string may be assigned to and used, as long as the program does not depend on its old contents.

#include <iostream> #include <string> #include <utility> int main() { std::string greeting = "hello"; std::string stolen = std::move(greeting); std::cout << "stolen: " << stolen << '\n'; std::cout << "greeting: '" << greeting << "' size " << greeting.size() << '\n'; greeting += " again"; std::cout << "after: '" << greeting << "'\n"; }

g++ -std=c++23 -Wall -Wextra ownership.cpp -o ownership
./ownership

stolen: hello
greeting: '' size 0
after: ' again'

No warning: the standard says the source is valid, and the program uses it in a valid way. A reader who expected the original text to still be there has a bug that neither the compiler nor a sanitizer will find.

The same task in Rust

Rust has no move constructor and no std::move: assignment of a String is the move. The source binding is not left empty and is not left valid; it is uninitialised from the compiler point of view, and the borrow checker will not let the program read it.

The same three lines therefore stop at the second print, and the compiler explains where the value went and how to get a copy if one is really needed.

fn main() { let greeting = String::from("hello"); let stolen = greeting; println!("stolen: {stolen}"); println!("greeting: {greeting}"); }

rustc --edition 2024 ownership.rs

error[E0382]: borrow of moved value: `greeting`
--> ownership.rs:5:26
|
2 | let greeting = String::from("hello");
| -------- move occurs because `greeting` has type `String`, which does not implement the `Copy` trait
3 | let stolen = greeting;
| -------- value moved here
4 | println!("stolen: {stolen}");
5 | println!("greeting: {greeting}");
| ^^^^^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let stolen = greeting.clone();
| ++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

The difference is not that Rust moves less efficiently. The buffer is not copied in either language. The difference is that Rust makes the state of the source part of the type system instead of a note in the standard.

What differs and why

Who owns the value: in C++ both objects exist after the move, and the standard describes the source as valid but unspecified. In Rust only one binding owns the value at a time, and the other one is considered moved out; the compiler tracks that per binding, not per object.

What the compiler rejects: C++ rejects a use of an object whose type has no copy and no move, and accepts everything else. Rust rejects any use of a moved binding, including passing it to a function, and suggests a clone with the allocation cost in the message.

What happens at run time: nothing different. A move is a pointer, length and capacity copied into the new binding, and the old buffer is not touched. The cost of a move is the same in both languages, and neither allocates.

The habit that breaks is the one in the C++ program: after a move the code writes to the source and prints it. In Rust that program does not exist, and the fix is either to clone before the move or to borrow instead of moving, which the lesson on borrowing teaches.

The habit that breaks

The habit is to reason about a moved-from value as if it were still a value. The C++ program shows why that is dangerous: the output happens to be an empty string here, and another standard library implementation may leave the old contents in place. A test that depends on either is testing the implementation.

Rust removes the question by removing the state. The source of a move cannot be read, written or passed, so there is nothing to reason about; the error message is the whole documentation of the rule.

The C++ lesson for this ground is Move constructors and move assignment, and the Rust lesson is Ownership and moves.

The C++ program was built with g++ 15.2.0 and the Rust program with rustc 1.96.1, and both were run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev