Skip to content

Rust Moves vs C++ Move Semantics

Rust moves transfer ownership of non-Copy values; C++ std::move changes an expression's value category so another operation can select a move overload. Neither term promises a particular machine instruction or speed improvement. Compare the source's permitted use, not just the destination's value.

Rust and C++ move requirements

Review ownership, Copy and Clone, closures and RAII. These independent fixtures need stable Rust edition 2024 and C++20, not GPIO or additional libraries.

Transfer a uniquely owned integer to a new owner, then reuse the original variable with a freshly owned zero. Separately, extract a pending integer and represent the now-empty slot explicitly. Compare an owning closure that only reads its captured string with a closure that returns the string to its caller. Finally, preserve both copies of a small integer.

The matched observable output is:

1
2
3
4
5
6
owned=46700
reused=0
slot empty=true, value=-500
repeated=5,5
consumed=5
copied=7,7

The output is intentionally silent about unspecified moved-from string contents, memory addresses, allocator counts and instruction counts.

C++: the cast and the selected operation

Save the complete program as main.cpp. unique_ptr provides a documented null source after move construction. Probe is a separate user-defined type whose counters distinguish the cast, copying and moving; its reset-to-zero policy is chosen by this example, not imposed on all C++ types.

#include <cassert>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Probe {
    inline static int copies = 0;
    inline static int moves = 0;
    int value;
    explicit Probe(int number) : value(number) {}
    Probe(const Probe& other) : value(other.value) { ++copies; }
    Probe(Probe&& other) noexcept : value(std::exchange(other.value, 0)) { ++moves; }
};

int main() {
    std::cout << std::boolalpha;
    auto source = std::make_unique<int>(46700);
    auto destination = std::move(source);
    assert(!source);
    assert(destination && *destination == 46700);
    std::cout << "owned=" << *destination << '\n';
    source = std::make_unique<int>(0);
    std::cout << "reused=" << *source << '\n';

    auto pending = std::make_unique<int>(-500);
    auto extracted = std::exchange(pending, nullptr);
    assert(!pending && extracted && *extracted == -500);
    std::cout << "slot empty=" << !pending << ", value=" << *extracted << '\n';

    auto repeated = [label = std::string("Pi 4B")] { return label.size(); };
    auto first = repeated();
    auto second = repeated();
    assert(first == 5 && second == 5);
    std::cout << "repeated=" << first << ',' << second << '\n';

    auto consume = [label = std::string("Pi 4B")]() mutable {
        return std::move(label);
    };
    auto consumed = consume();
    assert(consumed == "Pi 4B");
    std::cout << "consumed=" << consumed.size() << '\n';

    int original = 7;
    int copied = std::move(original);
    assert(original == 7 && copied == 7);
    std::cout << "copied=" << original << ',' << copied << '\n';

    Probe item(9);
    (void)std::move(item); // Casting alone does not call a move constructor.
    assert(item.value == 9 && Probe::copies == 0 && Probe::moves == 0);
    Probe moved(std::move(item));
    assert(moved.value == 9 && item.value == 0 && Probe::moves == 1);
    const Probe fixed(11);
    Probe from_const(std::move(fixed)); // const Probe&& cannot bind to Probe&&.
    assert(from_const.value == 11 && fixed.value == 11);
    assert(Probe::copies == 1 && Probe::moves == 1);
}

Run both builds without defining NDEBUG, so assertions remain active:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o moves
./moves
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o moves_release
./moves_release

std::move is essentially a cast to an rvalue reference type. It neither transfers a resource by itself nor strips const. A move-enabled destination may invoke a move constructor, a copyable const source may select copying, and an uncopyable const source may be rejected. A named variable declared as T&& is itself an lvalue expression; a forwarding helper needs its own deliberate category handling. Do not add std::move indiscriminately to return statements: copy-elision and return rules are separate topics.

Standard-library moved-from objects generally remain valid with an unspecified state unless otherwise specified. Valid does not mean every operation has its preconditions satisfied, nor that every container is empty. The unique_ptr constructor specifically empties its source; this example relies on that stronger contract. A user-defined type must define and preserve its own moved-from invariants. Its move operation can also fail unless its contract prevents that.

The string-returning lambda remains a callable C++ object after the first invocation. Calling it again is not prevented by a Rust-style FnOnce contract; what it returns depends on the captured string's moved-from state. Our example calls it once and makes no assertion about that state. If one-shot execution is an application requirement, model availability explicitly instead of treating std::move as a one-call restriction.

Rust: ownership transfer without a move constructor

Run cargo new moves --edition 2024 and replace src/main.rs with this complete source:

fn extract(slot: &mut Option<Box<i32>>) -> Option<Box<i32>> {
    slot.take()
}

fn main() {
    let mut source = Box::new(46700);
    let destination = source;
    assert_eq!(*destination, 46700);
    println!("owned={destination}");
    // source cannot be read here, but assigning a new value reinitialises it.
    source = Box::new(0);
    println!("reused={source}");

    let mut pending = Some(Box::new(-500));
    let extracted = extract(&mut pending).expect("fixture is populated");
    assert!(pending.is_none());
    println!("slot empty={}, value={extracted}", pending.is_none());

    let label = String::from("Pi 4B");
    let repeated = move || label.len();
    let first = repeated();
    let second = repeated();
    assert_eq!((first, second), (5, 5));
    println!("repeated={first},{second}");

    let label = String::from("Pi 4B");
    let consume = move || label;
    let consumed = consume();
    assert_eq!(consumed, "Pi 4B");
    println!("consumed={}", consumed.len());

    let original = 7;
    let copied = original;
    assert_eq!((original, copied), (7, 7));
    println!("copied={original},{copied}");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extraction_empties_a_slot_without_losing_zero() {
        let mut slot = Some(Box::new(0));
        assert_eq!(extract(&mut slot).map(|value| *value), Some(0));
        assert!(slot.is_none());
        assert_eq!(extract(&mut slot), None);
    }

    #[test]
    fn a_slot_can_be_refilled() {
        let mut slot = Some(Box::new(-500));
        let retained = extract(&mut slot).unwrap();
        slot = Some(Box::new(46700));
        assert_eq!((*retained, *extract(&mut slot).unwrap()), (-500, 46700));
        assert!(slot.is_none());
    }

    #[test]
    fn move_capture_can_still_implement_fn() {
        fn twice<F: Fn() -> usize>(function: F) -> (usize, usize) {
            (function(), function())
        }
        let label = String::from("Pi 4B");
        assert_eq!(twice(move || label.len()), (5, 5));
    }

    #[test]
    fn explicit_clone_keeps_independent_string_values() {
        let mut original = String::from("Pi 4B");
        let retained = original.clone();
        original.clear();
        assert_eq!(retained, "Pi 4B");
        assert!(original.is_empty());
    }
}

Inside that project:

1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

The Rust move rules leave the source place deinitialised until reassignment. This is not an empty Box that can still be inspected, and Rust does not invoke a user-defined move constructor. A move may be implemented with relocation or eliminated entirely; ownership rules do not specify its runtime cost. Drop concerns eventual destruction, not a callback whenever a value moves.

Copy types such as this integer preserve the source in a by-value use. Clone is an explicit operation with type-specific semantics; cloning a reference-counted owner shares an allocation, whereas this String clone keeps independent string data. Neither copying nor cloning universally means the same cost or ownership structure.

The closure call traits depend on what the body does with captures. A move closure takes captures by value but can still implement Fn if it only reads them. Returning the captured non-Copy String consumes it, so that closure can only be called by value once. Owning a captured reference does not extend its referent's lifetime or automatically satisfy a thread's static lifetime requirement.

Changed requirement: extract through a borrowed owner

If a caller retains a mutable reference to an owner, leave a valid replacement before returning the old value. Our optional slot uses take to leave None and can later be refilled. A non-optional slot can use mem::replace to install a replacement, or mem::take if a suitable Default exists. Choosing None versus a default value is part of the API contract: zero is not an empty reading.

C++ exchange makes replacement explicit too. Merely moving from a field leaves whatever state that type's move operation defines. For an optional, moving its contained value does not by itself disengage the optional. Define extraction and empty-state behaviour deliberately; our C++ slot uses a nullable unique_ptr rather than pretending optional and unique_ptr are identical.

Partial Rust moves are possible for eligible fields, but leave the whole aggregate unavailable while a field is uninitialised. Moving a non-Copy field out of a type implementing Drop is restricted because its destructor expects its value to remain intact. Use an explicit extraction method that maintains the invariant, rather than adding unsafe code to bypass the restriction.

Intentional compiler failures and repairs

Compile this Rust program independently. A mutable borrow grants access, not permission to return the owner's value while leaving the caller's slot uninitialised:

1
2
3
4
5
6
7
8
fn extract(owner: &mut Box<i32>) -> Box<i32> {
    *owner
}

fn main() {
    let mut owner = Box::new(46700);
    let _ = extract(&mut owner);
}

Repair the body with std::mem::replace(owner, Box::new(0)) if zero is the documented replacement, then assert that the returned value is 46700 and the caller owns zero. If emptiness is meaningful, change the interface to the optional slot in the working example. Borrowing only to inspect the integer is another legitimate design; it is not ownership transfer.

Compile this C++ program separately. std::move does not remove const and unique_ptr cannot be copied:

1
2
3
4
5
6
7
#include <memory>
#include <utility>
int main() {
    const auto owner = std::make_unique<int>(46700);
    auto destination = std::move(owner);
    (void)destination;
}

If transfer is required, make the source non-const before moving and check the documented null state. If only read access is required, retain the owner and borrow instead. Do not cast away const to manufacture an ownership-transfer API.

Selection criteria and exercises

Requirement Rust candidate C++ candidate
Inspect without acquiring ownership Shared borrow const reference / documented non-owning view
Transfer a unique owner By-value non-Copy parameter or assignment Move-enabled owning type plus appropriate expression
Retain two independent string values Explicit String clone String copy
Extract and leave explicit absence Option::take Defined extraction protocol, such as exchange to null
Call an owning read-only closure repeatedly move capture whose body supports Fn Owning capture with read-only body
Enforce one-shot consumption FnOnce boundary Explicit state or separately designed API
  1. Add an empty-slot test and refill it with zero. Distinguish absence from zero on the second extraction.
  2. Remove the Rust reinitialisation and read source after moving it. Expect rejection, not a null pointer. Then repair by borrowing or assigning a new owner, according to the requirement.
  3. Call the consuming Rust closure twice; it must fail. Keep the read-only move closure callable twice and explain why the keyword alone does not select FnOnce-only behaviour.
  4. Add a Drop implementation to a struct with a String field, then attempt to move that field out. Repair with replacement while preserving the destructor's contract.
  5. Explain the Probe const-copy counter and why moved-from unique_ptr nullness says nothing about moved-from string emptiness. No benchmark follows from these tests.

Verification and next step

On October 10, 2026, the extracted sources passed on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust/Cargo 1.99.0 and GCC g++ 14.2.0. Rust check, exact-source formatting, four debug/release tests and exact outputs passed. Replacement and Drop-preserving repairs passed their assertions. Separate borrowed extraction, moved-source reading, repeated consuming closure and Drop-field extraction were rejected with E0507, E0382 and E0509. C++20 output, Probe counters, non-const transfer repair, empty-slot extraction and zero refill assertions passed at -O0/-O2; moving the const unique_ptr was rejected. Sources/logs were retained and generated Cargo targets cleaned. These checks establish selected contracts, not move costs or a formal equivalence between languages.

Next: address stability and Pin, including the difference between moving an owner and moving the value it points to.

Previous: error contracts · Next: address stability and Pin · Course overview

Donate