„Move" ist eines der zentralen Verben in Rust — und gleichzeitig eines der missverstandensten. Ein Move ist keine teure Operation: er kopiert auf Bit-Ebene wenige Bytes (typischerweise drei Maschinenwörter für einen String oder Vec) und markiert die Original-Bindung als „nicht mehr verwendbar". Heap-Daten werden nicht angefasst, kein memcpy über tausende Bytes. Dieser Artikel zeigt im Detail, was bei let y = x; mit einem non-Copy-Typ passiert, wann Moves überall auftreten und wie partielle Moves von Struct-Feldern funktionieren.
Was ein Move wirklich tut
Bei let s2 = s1; mit s1: String passieren genau zwei Dinge:
- Die drei Bytes (Pointer auf Heap, Länge, Kapazität) werden vom Stack-Slot
s1zum Stack-Slots2kopiert. - Der Compiler markiert
s1als „leer" — jede weitere Verwendung führt zu einem Compile-Fehler.
Auf dem Heap passiert nichts. Die Bytes des Strings bleiben unverändert an ihrer Adresse. Nur die Frage „wem gehören sie?" ist neu beantwortet: jetzt s2, nicht mehr s1.
fn main() {
let s1 = String::from("Hallo");
// Stack: s1 = { ptr, len=5, cap=5 } -> Heap: "Hallo"
let s2 = s1;
// Stack: s2 = { ptr, len=5, cap=5 } -> Heap: "Hallo" (gleicher Heap-Block)
// s1 = (leer, nicht mehr nutzbar)
// println!("{s1}"); // Fehler E0382
println!("{s2}"); // ok
}Schematisch sieht das so aus:
Vor dem Move: Nach dem Move:
Stack: Stack:
s1 ──┐ s1 (leer)
│ s2 ──┐
Heap: │ │
┌───┐│ Heap: │
│"Hallo"│ <───── ptr ┌───┐ │
└───┘ │"Hallo"│ <───── ptr
└───┘Beim Drop am Scope-Ende wird nur einmal auf den Heap-Block freigegeben — durch s2. Hätte Rust statt Move eine Bit-Kopie inkl. Aliasing gemacht (wie in C/C++ standardmäßig), würden s1 und s2 beide auf denselben Heap-Block zeigen, und das Drop am Scope-Ende würde ihn zweimal freigeben (Double-Free). Genau das verhindert Move-Semantik.
Wann wird gemoved?
Move passiert an mehreren Stellen — überall dort, wo ein non-Copy-Wert in eine andere Bindung „wandert":
1. Bei let-Zuweisung
let a = String::from("Hi");
let b = a;
// a ist hier gemoved.2. Bei Funktions-Argument
fn nehmen(s: String) {
println!("{s}");
} // s wird hier gedroppt
fn main() {
let text = String::from("Hi");
nehmen(text);
// println!("{text}"); // Fehler — gemoved in nehmen()
}3. Bei Funktions-Rückgabe
fn produzieren() -> String {
let s = String::from("Hi");
s // s wird hier zum Aufrufer gemoved
}
fn main() {
let bekommen = produzieren();
// bekommen ist jetzt Besitzer
}4. Bei Pattern-Destrukturierung
fn main() {
let paar = (String::from("a"), String::from("b"));
let (x, y) = paar; // beide Strings gemoved
// println!("{:?}", paar); // Fehler — gemoved.
}5. Bei match-Arms
fn main() {
let optional: Option<String> = Some(String::from("Hi"));
match optional {
Some(text) => println!("{text}"),
None => println!("nichts"),
}
// optional ist hier gemoved (in einen der match-Arms).
}6. Beim Einsortieren in Container
fn main() {
let s = String::from("Hi");
let mut v: Vec<String> = Vec::new();
v.push(s); // s wird in den Vec gemoved
// println!("{s}"); // Fehler.
}7. Bei Closure-Capture mit move
fn main() {
let s = String::from("Hi");
let closure = move || println!("{s}"); // s wird in die Closure gemoved
closure();
// println!("{s}"); // Fehler.
}Das move-Keyword vor der Closure ist nicht zufällig — es macht explizit, dass die Closure ihre Captures by-value übernimmt.
Wann wird NICHT gemoved?
Drei Fälle, in denen kein Move stattfindet:
Wenn der Typ Copy ist
fn main() {
let a: i32 = 5;
let b = a; // Kopie — kein Move
println!("{a} {b}"); // 5 5 — beide nutzbar
}i32 ist Copy. Statt Move passiert eine Bit-Kopie, beide Bindungen bleiben unabhängig nutzbar. Mehr im Copy-und-Clone-Artikel.
Bei Borrow (Referenz)
fn lesen(s: &String) {
println!("{s}");
}
fn main() {
let text = String::from("Hi");
lesen(&text); // & — kein Move, nur Borrow
println!("{text}"); // ok, text gehört noch mir
}Mit & wird eine Referenz übergeben — kein Wechsel des Besitzers. Mehr im References-Kapitel.
Wenn .clone() aufgerufen wird
fn main() {
let s1 = String::from("Hi");
let s2 = s1.clone(); // Tiefe Kopie auf dem Heap
println!("{s1} {s2}"); // Beide unabhängig nutzbar
}.clone() erzeugt eine tiefe Kopie — der Heap-Block wird dupliziert, beide Bindungen besitzen jetzt unabhängige Werte. Teuer (Heap-Allocation), aber explizit gewünscht.
Partielle Moves
Aus einem Struct können einzelne Felder gemoved werden, während andere zurückbleiben — solange der Rest-Struct nicht mehr als Ganzes verwendet wird.
struct Person {
vorname: String,
nachname: String,
alter: u32,
}
fn main() {
let p = Person {
vorname: String::from("Anna"),
nachname: String::from("Müller"),
alter: 28,
};
let v = p.vorname; // v übernimmt vorname; p.vorname ist "weg"
// println!("{}", p.vorname); // Fehler — partiell gemoved
println!("{}", p.nachname); // ok — nachname noch da
println!("{}", p.alter); // ok — alter ist Copy
// println!("{:?}", p); // Fehler — p ist nicht mehr komplett
}Der Compiler verfolgt pro Feld, ob es gemoved wurde. p.nachname und p.alter sind weiterhin verwendbar — p als Ganzes aber nicht mehr.
Partielles Move mit Pattern
struct Punkt { x: f64, y: f64, label: String }
fn main() {
let p = Punkt { x: 3.0, y: 4.0, label: String::from("A") };
let Punkt { label, .. } = p; // nur label moven, x und y bleiben
println!("{label}");
println!("{}", p.x); // ok — x ist Copy und nicht gemoved
// println!("{}", p.label); // Fehler.
}Punkt { label, .. } destrukturiert nur das label-Feld. x und y (beide f64, also Copy) bleiben unangefastet. Wenn alle gemoveden Felder Copy wären, gäbe es nicht einmal einen partiellen Move.
Moves verhindern
Drei klassische Wege, um einen Move zu umgehen, wenn du den Wert weiter brauchst:
1. Borrow statt Move
fn lesen(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("Hi");
let n = lesen(&s); // &s — kein Move
println!("{s} hat {n} Bytes");
}Beste Wahl, wenn die Funktion nur liest.
2. Klonen vor Move
fn verbrauchen(s: String) -> usize {
s.len()
}
fn main() {
let s = String::from("Hi");
let n = verbrauchen(s.clone()); // Klone übergeben
println!("{s} hat {n} Bytes");
}Pragmatisch, wenn die Funktion Ownership erwartet und du den Wert weiter brauchst. Heap-Allocation.
3. Funktion gibt den Wert zurück
fn anhaengen_und_zurueck(mut s: String, suffix: &str) -> String {
s.push_str(suffix);
s
}
fn main() {
let mut s = String::from("Hi");
s = anhaengen_und_zurueck(s, "!");
println!("{s}"); // "Hi!"
}Funktioniert, ist aber verbose. Meist ist &mut-Parameter besser.
Move bei Compound-Typen
Bei zusammengesetzten Typen werden alle non-Copy-Felder gleichzeitig gemoved:
fn main() {
let v: Vec<String> = vec![
String::from("a"),
String::from("b"),
];
let w = v; // ganzer Vec gemoved — inkl. aller Strings
// for s in &v { ... } // Fehler.
}Ein Vec<String> wird als Einheit gemoved. Die einzelnen String-Werte „wandern" mit — physisch passiert aber nichts: nur die drei Vec-Header-Bytes (Pointer/Length/Capacity) werden im Stack umkopiert.
Praxis: Move im echten Code
Builder mit konsumierender Übergabe
struct Konfig { host: String, port: u16 }
struct Server { konfig: Konfig }
impl Server {
fn aus_konfig(k: Konfig) -> Self {
Server { konfig: k } // k wird in Server gemoved
}
}
fn main() {
let k = Konfig { host: "localhost".into(), port: 8080 };
let s = Server::aus_konfig(k);
// println!("{}", k.host); // Fehler — k gemoved.
println!("{}", s.konfig.host);
}Konfig wird in Server gemoved — semantisch sinnvoll, weil der Server jetzt die Konfiguration besitzt.
Worker-Thread bekommt seine eigenen Daten
use std::thread;
fn main() {
let daten = vec![1u64; 1_000_000];
let handle = thread::spawn(move || {
let summe: u64 = daten.iter().sum();
println!("Summe: {summe}");
});
// println!("{:?}", daten.len()); // Fehler — gemoved.
handle.join().unwrap();
}move || in der Closure macht klar: alle eingefangenen Variablen werden in die Closure gemoved. Damit kann der spawned Thread daten sicher besitzen — keine Race Condition möglich, weil der Main-Thread keinen Zugriff mehr hat.
Iterator-into_iter verbrauchend
fn main() {
let v = vec![String::from("a"), String::from("b"), String::from("c")];
for s in v.into_iter() { // verbrauchender Iterator
println!("{s}"); // s ist hier owned String
}
// v ist hier nicht mehr da.
}into_iter verbraucht den Vec — jedes Element wird in s gemoved. Idiomatisch, wenn man die Elemente weiterverarbeiten und der Container danach nicht mehr gebraucht wird.
Channel-Producer
use std::sync::mpsc::channel;
fn main() {
let (tx, rx) = channel::<Vec<u8>>();
let producer = std::thread::spawn(move || {
for _ in 0..3 {
let payload = vec![0u8; 1024]; // 1 KB
tx.send(payload).unwrap(); // Move in den Channel
}
});
while let Ok(daten) = rx.recv() {
println!("Erhalten: {} Bytes", daten.len());
}
producer.join().unwrap();
}Jeder send moved den Vec<u8> in den Channel. Im Receiver-Thread wird er aus dem Channel herausgemoved und in daten gebunden. Keine Race-Condition möglich.
Owner-Wechsel in einer State-Machine
enum Verbindung {
Inaktiv,
Verbunden { socket: String, peer_id: u64 },
Geschlossen { logs: Vec<String> },
}
fn schliessen(v: Verbindung) -> Verbindung {
match v {
Verbindung::Verbunden { socket, peer_id } => {
let logs = vec![format!("Schloss Verbindung zu {peer_id} ({socket})")];
Verbindung::Geschlossen { logs }
}
andere => andere,
}
}Der match-Arm destrukturiert und moved socket und peer_id — sie werden in einen neuen Geschlossen-State umgepackt. Klassische funktionale State-Transition.
Funktion gibt Ownership zurück (Round-Trip-Pattern)
fn maybe_modify(mut s: String, condition: bool) -> String {
if condition {
s.push_str(" (modifiziert)");
}
s
}
fn main() {
let mut s = String::from("Original");
s = maybe_modify(s, true);
println!("{s}"); // "Original (modifiziert)"
}Bei diesem Pattern wird der String in die Funktion gemoved und kommt am Ende zurück. Funktioniert, aber &mut String als Parameter wäre meist eleganter.
Vec-Element extrahieren mit take
use std::mem;
struct Buffer { daten: Vec<u8> }
impl Buffer {
fn alles_nehmen(&mut self) -> Vec<u8> {
mem::take(&mut self.daten) // Wert herausnehmen, durch Default ersetzen
}
}
fn main() {
let mut b = Buffer { daten: vec![1, 2, 3] };
let weg = b.alles_nehmen();
assert!(b.daten.is_empty()); // Buffer ist nicht „kaputt", nur leer
println!("{:?}", weg);
}std::mem::take ist eine Standard-Methode für „nimm den Wert heraus und ersetze durch Default". Sehr nützlich, wenn man aus einem &mut self-Receiver einen owned Wert extrahieren will, ohne den Receiver „kaputt" zu hinterlassen.
Häufige Stolperfallen
Move ist günstig — denke nicht in „kopieren“.
Ein Move kopiert wenige Maschinenwörter (3 für String, Vec, Box) im Stack. Keine Heap-Operation. In Maschinencode oft 2–3 Instruktionen. Wer denkt „Move ist teuer, ich nehme lieber &", hat das mentale Modell falsch — & ist kostenfrei UND verändert Borrow-Semantik. Move ist günstig UND verändert Ownership.
{x:?} beim Print verbraucht x nicht.
println!("{x:?}", x) nutzt Debug via Referenz — x ist hinterher noch nutzbar. Print-Macros nehmen ihre Args immer per Referenz. Move passiert erst, wenn du x ohne & an eine Funktion weitergibst.
Partielle Moves verbieten Verwendung des Gesamt-Structs.
Nach let v = p.vorname; ist p als Ganzes nicht mehr nutzbar — auch nicht via println!("{p:?}"). Einzelne intakte Felder gehen aber: println!("{}", p.nachname). Wenn du Debug auf dem Struct nutzen willst, vermeide partielle Moves oder klone.
Move bei Vec[i] ist verboten.
let s = v[1]; mit v: Vec<String> ist Compile-Fehler — der Vec würde mit „Loch" zurückbleiben. Lösungen: v[1].clone(), v.swap_remove(1) (entfernt und tauscht mit letztem), mem::take(&mut v[1]) (ersetzt mit String::default()), oder v.remove(1) (kostet O(n), Elemente werden nachgerückt).
move-Closure überträgt ALLE Captures.
move || ... moved jedes eingefangene Element — auch solche, die nur gelesen werden. Wer in einer Closure mit Captures auf Mutation verzichten will, sollte erst prüfen, ob auch Borrow reicht. Bei Threads ist move aber meist nötig.
String::from(&str) alloziert; .clone() auch.
Sowohl String::from("x") als auch "x".to_string() als auch .clone() auf einem String machen Heap-Allocation. Wer das im Hot-Path vermeiden will: Slices borrowen, Cow<str> nutzen, oder einmalig Allokationen vorab machen.
let _ = x; dropt sofort.
Der Unterstrich als komplettes Pattern bindet nicht — er dropt den Wert sofort. let _ = guard.lock() gibt den Lock direkt wieder frei. Wer den Wert bis zum Scope-Ende halten will: let _guard = ... (mit Namen, Unterstrich-präfixiert für „unused").
Move kann durch derive(Copy) umgehbar gemacht werden.
Für eigene Structs aus Copy-Feldern lässt sich #[derive(Copy, Clone)] setzen — danach werden sie kopiert statt gemoved. Aber nur, wenn ALLE Felder Copy sind. String als Feld macht das unmöglich. Mehr im Copy-und-Clone-Artikel.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – Ways Variables and Data Interact: Move
- The Rustonomicon – Ownership
- std::mem::take
- std::mem::replace
- rustc Error E0382