Funktionsaufrufe sind in Rust die häufigste Stelle, an der Ownership-Entscheidungen sichtbar werden. Eine Funktion kann ihre Parameter übernehmen (by-value, Move), lesend borgen (&T) oder mutierend borgen (&mut T). Jede Wahl hat unterschiedliche Auswirkungen auf den Aufrufer, auf Lifetime-Beschränkungen und auf API-Klarheit. Dieser Artikel zeigt die drei Modi systematisch, illustriert das Round-Trip-Pattern (Funktion gibt einen gemovedten Wert zurück), und gibt klare Faustregeln für gutes API-Design.
Die drei Parameter-Modi
| Modus | Syntax | Was passiert |
|---|---|---|
| By-value | fn foo(x: T) | Move (oder Copy bei Copy-Typen) — Aufrufer verliert Ownership |
| Shared Borrow | fn foo(x: &T) | Borrow — Aufrufer behält, Funktion liest |
| Mutable Borrow | fn foo(x: &mut T) | Borrow — Aufrufer behält, Funktion schreibt |
fn nehmen(s: String) { // by-value: Move
println!("verbraucht: {s}");
} // s wird hier gedroppt
fn lesen(s: &String) { // &T: lesend borgen
println!("gelesen: {s}");
}
fn modifizieren(s: &mut String) { // &mut T: schreibend borgen
s.push_str(", Welt");
}
fn main() {
let mut text = String::from("Hi");
lesen(&text); // & — text bleibt nutzbar
modifizieren(&mut text); // &mut — text bleibt nutzbar
println!("nach mod: {text}"); // "Hi, Welt"
nehmen(text); // Move — text danach weg
// println!("{text}"); // Fehler — gemoved.
}By-value: wenn die Funktion „übernimmt"
Du nimmst einen Parameter by-value, wenn die Funktion den Wert behalten oder verbrauchen will:
struct Server { konfig: String }
impl Server {
fn aus_config(c: String) -> Self { // c wird behalten
Server { konfig: c }
}
}
fn main() {
let c = String::from("config.toml");
let server = Server::aus_config(c);
// c hier nicht mehr nutzbar — gemoved in den Server.
}Die Funktion aus_config übernimmt c und packt es in den Struct. Der Aufrufer verliert Zugriff auf c — was semantisch korrekt ist, denn der Server „besitzt" jetzt die Config.
Typische by-value-Patterns
- Konstruktoren, die Werte in einen Struct übernehmen.
- Builder-Methoden mit
self-Receiver (Method-Chaining). into-artige Konvertierungsfunktionen.- Funktionen mit konsumierender Semantik: Channel-Send, Thread-Spawn.
&T: lesend borgen — die häufigste Wahl
fn zaehle_zeichen(s: &String) -> usize {
s.chars().count()
}
fn ist_palindrom(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
chars == chars.iter().rev().copied().collect::<Vec<_>>()
}
fn main() {
let text = String::from("anna");
let anzahl = zaehle_zeichen(&text);
let palin = ist_palindrom(&text);
println!("{text} hat {anzahl} Zeichen, Palindrom: {palin}");
}&T ist die richtige Wahl, wenn die Funktion nur liest. Vorteile:
- Aufrufer behält Ownership — kann den Wert nach dem Call weiter nutzen.
- Mehrere
&Tparallel erlaubt — kein Lock-artiger Konflikt. - Cheap: nur ein Pointer wird übergeben.
&T vs. &str-Empfehlung
// Weniger flexibel:
fn weniger_gut(s: &String) -> usize { s.len() }
// Flexibler:
fn besser(s: &str) -> usize { s.len() }
fn main() {
let owned = String::from("Hi");
let literal = "Hi";
besser(&owned); // ok
besser(literal); // ok — String-Literal direkt
// weniger_gut(literal); // Fehler — &String erwartet, &str gegeben
}Bei String-Parametern: &str statt &String. Bei Slice-Parametern: &[T] statt &Vec<T>. Der allgemeinere Typ ist immer flexibler.
&mut T: schreibend borgen
fn anhaengen_zeilennummer(text: &mut String, nummer: u32) {
text.push_str(&format!(" [Zeile {nummer}]"));
}
fn main() {
let mut log = String::from("Etwas passiert");
anhaengen_zeilennummer(&mut log, 42);
println!("{log}"); // "Etwas passiert [Zeile 42]"
}Voraussetzungen für &mut T:
- Die Bindung im Aufrufer muss
mutsein (let mut log = ...). - Beim Call:
&mut log(mit dem&mut).
Der Borrow Checker stellt sicher, dass keine zweite Referenz (egal ob & oder &mut) parallel existiert.
Round-Trip: Move + Rückgabe
Manchmal will eine Funktion Ownership übernehmen, mutieren, und den Wert dann zurückgeben. Das war vor &mut der einzige Weg — heute meist überholt, aber gelegentlich noch sinnvoll:
fn anhaengen(mut s: String, suffix: &str) -> String {
s.push_str(suffix);
s // gibt den modifizierten String zurück
}
fn main() {
let mut t = String::from("Hi");
t = anhaengen(t, ", Welt"); // Move rein, Move raus
println!("{t}"); // "Hi, Welt"
}Funktioniert, ist aber verbose. Idiomatischer:
fn anhaengen(s: &mut String, suffix: &str) {
s.push_str(suffix);
}
fn main() {
let mut t = String::from("Hi");
anhaengen(&mut t, ", Welt");
println!("{t}");
}Round-Trip ist sinnvoll bei:
- Builder-Methoden mit
self-Receiver (Method-Chaining). - Pure Transformationen, wo der „neue" Wert konzeptuell etwas anderes ist als der alte (z. B.
let upper = uppercase(s)). - Method-Chaining-freundliche APIs.
Welcher Modus, wann?
Faustregeln für API-Design:
| Situation | Modus |
|---|---|
| Funktion liest nur | &T (oder &str/&[T] bei Strings/Slices) |
| Funktion mutiert, gibt nichts zurück | &mut T |
| Funktion übernimmt und behält (Struct-Init) | by-value |
| Builder mit Method-Chain | self als by-value, -> Self |
Copy-Typ (i32, bool, ...) | by-value (gleich teuer wie &T) |
| Funktion erstellt neuen Wert ohne Mutation | &T rein, owned Wert raus |
Beispiel-Galerie
// Lesen: &T
fn ist_email(s: &str) -> bool { s.contains('@') }
fn summe(zahlen: &[i32]) -> i32 { zahlen.iter().sum() }
// Mutieren: &mut T
fn sortieren(daten: &mut Vec<i32>) { daten.sort(); }
fn aufrufen<F: FnMut()>(mut f: F) { f(); }
// By-value bei Übernahme
fn channel_send(tx: &std::sync::mpsc::Sender<String>, payload: String) {
tx.send(payload).unwrap(); // payload wird verbraucht
}
// Builder mit Method-Chain
struct B(Vec<i32>);
impl B {
fn neu() -> Self { B(vec![]) }
fn mit(mut self, n: i32) -> Self { self.0.push(n); self }
fn bauen(self) -> Vec<i32> { self.0 }
}
// Pure Transformation
fn upper(s: &str) -> String { s.to_uppercase() }Häufige Stolperfallen
Borrowing während Move-Versuch
fn drucke(s: &String) { println!("{s}"); }
fn nehmen(s: String) { drop(s); }
fn main() {
let s = String::from("Hi");
let r = &s;
// nehmen(s); // Fehler — kann s nicht moven, solange r lebt
drucke(r);
// Nach drucke(r) ist r nicht mehr in Verwendung — jetzt geht es:
nehmen(s); // ok
}Ein aktiver Borrow &s verhindert Move von s. Der Borrow Checker erkennt aber, dass r nach der letzten Verwendung „tot" ist — daher klappt der spätere nehmen(s).
Mutable Borrow vs. shared Borrow
fn main() {
let mut v = vec![1, 2, 3];
let r1 = &v;
// v.push(4); // Fehler — &v lebt noch
let _ = r1;
v.push(4); // ok — r1 nicht mehr in Verwendung
}Die Borrow-Regeln: entweder mehrere &T ODER genau ein &mut T, nie beides parallel. Mehr im References-Kapitel.
Verschachtelte Calls mit &mut
fn main() {
let mut v = vec![1, 2, 3];
// v.push(v.len() as i32); // Fehler — v wird gleichzeitig als &v und &mut v gebraucht
let len = v.len() as i32;
v.push(len); // ok
}v.push(v.len()) braucht gleichzeitig einen &mut v (für push) und einen &v (für len) — Verstoß gegen Borrow-Regel. Lösung: len vorher extrahieren.
Praxis: Ownership-Patterns im echten Code
HTTP-Handler: by-ref nehmen, by-value zurückgeben
struct Request<'a> { path: &'a str, body: Vec<u8> }
struct Response { status: u16, body: Vec<u8> }
fn handle_get(req: &Request) -> Response {
// req nur lesen
let body = format!("Du hast {} angefragt", req.path).into_bytes();
Response { status: 200, body }
}
fn main() {
let req = Request { path: "/users", body: vec![] };
let resp = handle_get(&req);
println!("{}", resp.status);
// req noch nutzbar
}Klassisches Pattern: Request per Borrow lesen, Response by-value zurückgeben. Der Handler verbraucht nichts vom Aufrufer-Kontext.
Sammlung verarbeiten mit &mut
fn normalisiere(zahlen: &mut Vec<f64>) {
let max = zahlen.iter().cloned().fold(0.0f64, f64::max);
if max > 0.0 {
for n in zahlen.iter_mut() {
*n /= max;
}
}
}
fn main() {
let mut v = vec![1.0, 2.0, 4.0];
normalisiere(&mut v);
assert_eq!(v, vec![0.25, 0.5, 1.0]);
}&mut Vec<f64> für In-Place-Mutation. Klassische API-Form für „verändere die Sammlung an Ort und Stelle".
Channel-Send als Verbraucher
use std::sync::mpsc::Sender;
fn versende_jobs(tx: &Sender<String>, jobs: Vec<String>) {
// jobs wird hier konsumiert — jeder Job wird in den Channel gemoved
for job in jobs {
tx.send(job).unwrap();
}
}Vec<String> by-value (die Sammlung wird verbraucht). Sender per &, weil der Sender als Handle weiter nutzbar bleiben soll.
Builder mit self-Konsumption
struct Anfrage {
url: String,
method: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Anfrage {
fn neu(url: impl Into<String>) -> Self {
Anfrage {
url: url.into(),
method: "GET".into(),
headers: Vec::new(),
body: Vec::new(),
}
}
fn method(mut self, m: &str) -> Self { self.method = m.to_string(); self }
fn header(mut self, k: &str, v: &str) -> Self {
self.headers.push((k.to_string(), v.to_string())); self
}
fn body(mut self, b: Vec<u8>) -> Self { self.body = b; self }
}
fn main() {
let a = Anfrage::neu("https://example.com/api")
.method("POST")
.header("Content-Type", "application/json")
.body(b"{}".to_vec());
println!("{}: {}", a.method, a.url);
}Jede Builder-Methode konsumiert self, mutiert intern, gibt self zurück. Ermöglicht Method-Chaining ohne &mut-Bindung im Aufrufer.
Pure Transformation: Borrow rein, owned raus
fn doppelt(zahlen: &[i32]) -> Vec<i32> {
zahlen.iter().map(|&n| n * 2).collect()
}
fn main() {
let original = vec![1, 2, 3];
let neu = doppelt(&original);
assert_eq!(original, vec![1, 2, 3]); // unverändert
assert_eq!(neu, vec![2, 4, 6]);
}Die Funktion liest das Slice, allokiert einen neuen Vec, gibt ihn zurück. Aufrufer behält das Original, bekommt einen neuen, unabhängigen Wert.
Thread mit Move-Closure
use std::thread;
fn main() {
let daten: Vec<u64> = (1..=1_000_000).collect();
let handle = thread::spawn(move || {
let summe: u64 = daten.iter().sum();
summe
});
// daten ist hier weg — Thread besitzt es
let summe = handle.join().unwrap();
println!("{summe}");
}move in der Closure überträgt Ownership in den Thread. Damit ist keine Sync-Kommunikation nötig — der Thread hat seine eigenen Daten.
Read-Only-API mit AsRef
fn dateigroesse_byte<P: AsRef<std::path::Path>>(pfad: P) -> std::io::Result<u64> {
let metadata = std::fs::metadata(pfad)?;
Ok(metadata.len())
}
fn main() -> std::io::Result<()> {
let s = String::from("/tmp/foo.txt");
let _ = dateigroesse_byte(&s); // &String
let _ = dateigroesse_byte("/tmp/bar.txt"); // &str
let _ = dateigroesse_byte(std::path::PathBuf::from("/tmp/baz.txt")); // PathBuf
Ok(())
}AsRef<Path> als Trait-Bound nimmt alles, was sich zu einem Pfad referenzieren lässt. Sehr flexibel — kostenlos, denn der Borrow ist nur ein Pointer.
State-Migration mit mem::replace
use std::mem;
#[derive(Debug)]
enum Verbindung {
Inaktiv,
Verbunden(String),
}
fn schliessen(v: &mut Verbindung) -> Verbindung {
mem::replace(v, Verbindung::Inaktiv) // alten Wert raus, Inaktiv rein
}
fn main() {
let mut v = Verbindung::Verbunden("peer-42".into());
let alt = schliessen(&mut v);
println!("Geschlossen: {alt:?}"); // Verbunden("peer-42")
println!("Aktuell: {v:?}"); // Inaktiv
}mem::replace ermöglicht „nimm den Wert hinter einer &mut-Referenz heraus und ersetze ihn durch einen anderen". Klassisch für State-Machine-Transitionen.
Häufige Stolperfallen
fn foo(s: &String) ist fast immer falsch.
Mit &str ist die Funktion flexibler (akzeptiert auch String-Literale ohne Konvertierung). Clippy warnt mit clippy::ptr_arg. Außer in sehr seltenen Fällen sollte ein String-Parameter &str heißen.
self per Wert verbraucht — wer das Objekt nochmal braucht, klont.
Wenn eine Methode self (ohne &) als Receiver hat, ist das Objekt nach dem Call weg. Wer den Wert weiter braucht, muss vorher .clone() rufen — oder die API anpassen.
&mut self bei nicht-mut-Bindung — Compile-Fehler.
let v = vec![]; v.push(1); schlägt fehl, weil push ein &mut self braucht — die Bindung v ist aber nicht mut. Lösung: let mut v = vec![];. rustc-Suggestion sagt das direkt.
Zwei &mut auf das gleiche Objekt zur gleichen Zeit — verboten.
Der Borrow Checker erlaubt nur EINEN aktiven &mut-Borrow. Wer parallel zwei Funktionen mit &mut-Parameter aufrufen will, muss die Calls sequenziell machen oder die Mutex-/Interior-Mutability-Patterns benutzen.
Methoden-Receiver beeinflussen Ownership.
&self — Read-Only, Aufrufer behält. &mut self — Schreiben, Aufrufer behält, braucht mut-Bindung. self — Verbrauchend, Objekt danach weg. Die Wahl prägt die ganze API.
Cow als Parameter für „accept beides“.
Wenn eine Funktion sowohl &str als auch owned String akzeptieren soll, ist Cow<'_, str> als Parameter elegant — oder generisch mit impl Into<String>. Beides idiomatisch.
Closure-Captures folgen Ownership-Regeln.
Eine Closure ohne move borrowt — ihre Captures müssen die Closure überleben. Mit move werden Captures gemoved — danach hat der Aufrufer keinen Zugriff mehr. Wichtig bei Thread-Spawning.
Zurück-Move-Round-Trip wird durch Closures überflüssig.
Statt t = anhaengen(t, "...") lieber anhaengen(&mut t, "..."). Round-Trip-Moves sind syntaktisch schwerer, semantisch identisch zu &mut-Patterns. Außer für Method-Chaining-Builder: dort ist Round-Trip mit self idiomatisch.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – References and Borrowing
- The Rust Book – Ownership and Functions
- Rust API Guidelines – Naming
- Clippy – ptr_arg
- std::mem::replace