Zum Inhalt springen

Dateien in Rust lesen und schreiben

Beim Lesen und Schreiben von Dateien wird alles, was du über Result und den ?-Operator gelernt hast, auf einmal ganz praktisch. Fast jede Dateioperation kann schiefgehen: Die Datei gibt es vielleicht nicht, oder dir fehlen die Rechte. Deshalb zwingt dich die Standardbibliothek, dich direkt darum zu kümmern.

Der einfachste Weg, den Inhalt einer Datei in einen String zu lesen:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Datei konnte nicht gelesen werden: {e}"),
}
}

fs::read_to_string gibt ein Result<String, std::io::Error> zurück: Ok mit dem Text, wenn es geklappt hat, und Err mit dem Grund, wenn nicht (Datei fehlt, keine Berechtigung und so weiter).

Jede Dateioperation mit match auszuwerten, wird schnell eintönig. Wenn du dich in einer Funktion befindest, die selbst ein Result zurückgibt, räumt der ?-Operator (von der Seite Result) hier ordentlich auf:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // kehrt bei einem Fehler sofort zurück
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Fehler: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Hallo, Datei!");
match result {
Ok(()) => println!("Erfolgreich geschrieben"),
Err(e) => println!("Schreiben fehlgeschlagen: {e}"),
}
}

fs::write legt die Datei an, wenn es sie noch nicht gibt. Gibt es sie schon, wird sie komplett überschrieben. Denk daran, wenn du etwas an eine Datei anhängen willst, statt sie zu ersetzen.

Um etwas an eine bestehende Datei anzuhängen, statt sie zu überschreiben, benutzt du OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "Eine neue Zeile im Log")?;
Ok(())
}

Hier gibt main selbst ein Result zurück. Dieses Muster solltest du kennen: Wenn main ein Result zurückgibt, kannst du ? direkt darin benutzen, statt alles in ein match zu packen.

Bei einer großen Datei ist es nicht immer ideal, alles auf einmal in den Speicher zu laden. So liest du sie stattdessen Zeile für Zeile:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

Bisher wurde jeder Fehler gleich behandelt: ausgeben. Oft willst du aber unterschiedlich reagieren, je nachdem, was schiefgegangen ist. Eine fehlende Datei ist meist kein Problem (vielleicht ist es der erste Start, und du fängst einfach leer an). „Permission denied“ (Zugriff verweigert) dagegen ist ein echtes Problem, das du melden solltest.

Ein io::Error verrät dir mit .kind() seine Kategorie. Das gibt ein Enum ErrorKind (auf Englisch) zurück, auf das du match anwenden kannst:

use std::fs;
use std::io::ErrorKind;
fn main() {
// eine Datei im temporären Ordner des Systems, die es nicht gibt
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("{} Zeilen geladen", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Noch keine Notizen, fange leer an"),
ErrorKind::PermissionDenied => println!("Keine Berechtigung, die Notizdatei zu lesen"),
_ => println!("Etwas anderes ist schiefgegangen: {e}"),
},
}
}

Ausgabe:

Noch keine Notizen, fange leer an
▶ Selbst ausprobieren

std::env::temp_dir() liefert dir den Pfad zum temporären Ordner des Systems (unter Linux etwa /tmp), und .join(...) hängt einen Dateinamen daran. Dateifunktionen nehmen so einen Pfad genauso gern wie einen &str.

Schreib ans Ende immer einen _-Zweig: ErrorKind hat viele Varianten, und mit der Zeit kommen neue dazu. Der _-Zweig fängt alles ab, was du nicht aufgeführt hast.

Beide Übungen schreiben zuerst ihre eigene Datei in den temporären Ordner. Deshalb laufen sie überall, auch im Playground.

Schreib mit fs::write drei To-dos in eine Datei, eins pro Zeile. Lies die Datei dann mit fs::read_to_string wieder ein und gib jede Zeile mit einer Nummer davor aus, danach die Gesamtzahl.

Tipp: Setz \n zwischen die Einträge, damit es getrennte Zeilen werden. text.lines() liefert dir die Zeilen einzeln. Lass main ein std::io::Result<()> zurückgeben, damit du ? benutzen kannst.

Lösung anzeigen
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "Ownership lernen\nein Modul schreiben\neine Datei lesen\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} Einträge");
Ok(())
}

Ausgabe:

1. Ownership lernen
2. ein Modul schreiben
3. eine Datei lesen
3 Einträge
▶ Selbst ausprobieren

Weil fs::write die ganze Datei ersetzt, bekommst du dieselben drei Zeilen, egal wie oft du das Programm startest. Jedes ? gibt den Fehler aus main zurück, falls Schreiben oder Lesen fehlschlägt.

Beginne eine Log-Datei frisch mit einer Kopfzeile und hänge dann mit OpenOptions und writeln! zwei weitere Zeilen an. Lies zum Schluss die ganze Datei wieder ein und gib sie aus.

Tipp: Nimm fs::write für die Kopfzeile (damit jeder Durchlauf sauber beginnt) und dann OpenOptions::new().append(true).open(&path)? für die weiteren Zeilen. print! (nicht println!) vermeidet eine zusätzliche Leerzeile am Ende, weil die Datei schon mit einem Zeilenumbruch endet.

Lösung anzeigen
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== Log ===\n")?; // frisch beginnen
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "gestartet")?;
writeln!(file, "beendet")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Ausgabe:

=== Log ===
gestartet
beendet
▶ Selbst ausprobieren

use std::fs::{self, OpenOptions}; holt sowohl das Modul fs selbst als auch OpenOptions herein. Ohne das erste fs::write würde jeder Durchlauf weiter an die alte Datei anhängen, und das Log würde endlos wachsen. Genau das willst du bei einem echten Log – aber nicht bei einer Übung mit fester Ausgabe.

  • fs::read_to_string(path) liest eine ganze Datei in einen String und gibt ein Result zurück.
  • fs::write(path, contents) schreibt eine Datei und überschreibt sie, falls es sie schon gibt.
  • Mit OpenOptions::new().append(true) hängst du etwas an eine Datei an, statt sie zu ersetzen.
  • Mit main() -> std::io::Result<()> kannst du ? direkt in main benutzen, statt jeden Aufruf mit match auszuwerten.
  • e.kind() sagt dir, welche Art von Fehler passiert ist, etwa ErrorKind::NotFound. So kannst du jeden Fall anders behandeln.
  • Beispiele mit Dateien in deinem Projektordner probierst du am besten lokal aus. std::env::temp_dir() liefert dir einen Ordner, der überall funktioniert.

Kurzer Check

1. Was passiert, wenn du fs::write mit einem Pfad aufrufst, den es schon gibt?

2. Wie hängst du etwas an eine Datei an, statt sie zu überschreiben?

3. Was ermöglicht es dir, wenn main selbst std::io::Result<()> zurückgibt?

4. Was macht der ?-Operator, wenn fs::read_to_string(path)? fehlschlägt?

5. Warum liest man eine große Datei mit BufReader Zeile für Zeile statt mit fs::read_to_string?

Punkte: 0 / 5