Programmieren in Rust

Eingabe und Ausgabe

Inhaltsverzeichnis

  1. Die Standard-Ströme
    1. Standard-Ausgabe
    2. Standard-Fehlerausgabe
    3. Standard-Eingabe
    4. Pipelines
  2. Dateien
    1. Dateien lesen
    2. Binärdateien lesen
    3. In Blöcken exakter Größe lesen
    4. Dateien schreiben
    5. Datenströme

Die Standard-Ströme

Standard-Ausgabe

Die Standard-Ausgabe stdout lässt sich auf mehrere Arten ansprechen. Zunächst kann man einfach println benutzen.

fn main() {
    println!("{}", "Ausgabe");
}

Man kann die Standard-Ausgabe auf Linux aber auch manuell als Geräte-Datei öffnen:

use std::{io, io::Write, fs::File};

fn main() -> io::Result<()> {
    let mut stdout = File::create("/dev/stdout")?;
    stdout.write_all("Ausgabe".as_bytes())?;
    Ok(())
}

Das ist aber nicht empfehlenswert, da das Verzeichnis /dev/stdout nur in Unix-artigen Betriebssystemen existiert, das Programm also weniger portabel wird. Aus diesem Grund steht die Funktion io::stdout zur Verfügung:

use std::{io, io::Write};

fn main() -> io::Result<()> {
    let mut stdout = io::stdout();
    stdout.write_all("Ausgabe".as_bytes())?;
    Ok(())
}

Der Typ io::Result<()> ist lediglich ein Alias für Result<(),io::Error>.

Standard-Fehlerausgabe

Bei manchen Programmen möchte man neben normalen Ausgaben gleichzeitig auch Fehlermeldungen produzieren. Nun müssen diesen beiden Ausgaben auf irgendeine Art getrennt sein wenn man keine Vermischung haben möchte. Eine einfache Herangehensweise daran ist die Einleitung der Fehlerausgabe in eine andere Datei bzw. in einen anderen Datenstrom.

Bereits zur Verfügung stehen dafür die Makros eprint und eprintln, die sich so verhalten wie print und println, mit dem Unterschied in die Fehlerausgabe stderr zu schreiben. Alternativ ist auch io::stderr() benutzbar, wie zuvor schon für stdout beschrieben.

Ruft man so ein Programm, nennen wir es p0, nun mit dem Shell-Befehl

./p0 > Datei.txt

auf, wird die normale Ausgabe in Datei.txt geleitet, die Fehlermeldungen verbleiben jedoch im Terminal. Umgekehrt geht auch

./p0 2> Fehler.txt

womit die Fehlermeldungen in eine Datei geleitet werden, die normale Ausgabe aber im Terminal verbleibt. Der Befehl

./p0 > Datei.txt 2> Fehler.txt
leitet stdout und stderr in jeweils eine Datei.

Möchte man die Fehlermeldungen nicht irgendwohin schreiben, sondern einfach nur verwerfen, steht dafür eine sonderbare Gerätedatei zur Verfügung, das sogenannte Nullgerät /dev/null. Daten die man in diese Datei leitet, verschwinden einfach. Der Befehl

./p0 2> /dev/null

führt also zur Unterdrückung der Fehlerausgabe.

Standard-Eingabe

Das folgende Programm zeigt, wie zeilenweises Einlesen von Eingaben vonstattengeht.

// Programm p1

use std::{io, io::stdin};

fn chomp(buffer: &mut String) {
    if buffer.ends_with('\n') {
        buffer.pop();
        if buffer.ends_with('\r') {buffer.pop();}
    }
}

fn main() -> io::Result<()> {
    let buffer = &mut String::new();
    loop {
        let n = stdin().read_line(buffer)?;
        if n == 0 {break;}
        chomp(buffer);
        println!("[{}]", buffer);
        buffer.clear();
    }
    Ok(())
}

Die Schleife läuft solange weiter, bis ein EOF kommt. Unter unixoiden Systemen wie Linux kann man EOF durch die Tastenkombination Strg+D erzeugen.

Der Aufruf

./p1 < Datei.txt

bewirkt die Eingabe aus der angegebenen Datei, anstelle auf eine Terminaleingabe zu warten.

Pipelines

Außerdem ist es möglich, die Ausgabe eines anderen Programms als Eingabe zu nutzen. Zur genauen Darlegung brauchen wir ein kurzes Programm, welches eine Ausgabe erzeugt:

// Programm p0

fn main() {
    for i in 0..4 {println!("{}", i);}
}

Mit dem Pipe-Operator wird nun die Ausgabe von p0 der Eingabe von p1 zugeführt:

./p0 | ./p1

Angegeben ist der Aufruf von im aktuellen Arbeitsverzeichnis befindlichen Programmen. Beide Programmdateien müssen sich dafür in diesem Verzeichnis befinden.

Die Programme in einer Pipeline dürfen auch aus unterschiedlichen Programmiersprachen entstammen. Anstelle des obigen Programms p0 könnte man genauso gut das Python-Programm

# Programm p0

for i in range(0, 4):
    print(i)

benutzen. Der Aufruf:

python ./p0 | ./p1

Dateien

Dateien lesen

Zum Lesen von Text-Dateien gibt es die Komfort-Funktion read_to_string, das macht die Sache besonders einfach.

use std::{fs, io};

fn main() -> io::Result<()> {
    let s = fs::read_to_string("Datei.txt")?;
    println!("{}", s);
    Ok(())
}

Die Funktion vereinfacht die Benutzung von vielseitigeren Werkzeugen. Unter Verwendung dieser gestaltet sich die Funktion so:

use std::{fs, io, io::Read};

fn read_to_string(path: &str) -> io::Result<String> {
    let mut file = fs::File::open(path)?;
    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?;
    Ok(buffer)
}

Diese Formulierung der Funktion ist jedoch etwas ineffizient, weil der Puffer beim Lesen öfters realloziert werden muss. Besser, man initialisiert den Puffer gleich mit der benötigten Kapazität:

use std::{fs, io, io::Read};

fn read_to_string(path: &str) -> io::Result<String> {
    let mut file = fs::File::open(path)?;
    let info = file.metadata()?;
    let len = match usize::try_from(info.len()) {
        Ok(value) => value, _ => 0
    };
    let mut buffer = String::with_capacity(len);
    file.read_to_string(&mut buffer)?;
    Ok(buffer)
}

Spitzfindige fragen sich jetzt natürlich, wie lang denn so eine Geräte-Datei wie /dev/stdin ist. Bei stdin handelt es sich ja um einen Datenstrom, der kann potenziell unendlich lang sein. Probieren wir das aus:

fn main() -> std::io::Result<()> {
    println!("{}", std::fs::metadata("/dev/stdin")?.len());
    Ok(())
}

Bei mir kommt da null raus. Das muss natürlich nicht heißen, dass da immer null herauskommen müsse. Es wäre z. B. denkbar, dass der Rückgabewert die Länge der aktuellen Pufferbelegung ist. Um Gewissheit über das Verhalten zu haben, müsste man die Spezifikation der Betriebssystem-Schnittstelle konsultieren, wenn es diese denn gibt. Wir wollen uns mit diesen technischen Details jetzt nicht aufhalten, da diese eigentlich immer unerheblich sind.

Binärdateien lesen

Da eine Zeichenkette in Rust korrektes UTF-8 enthalten muss, wird die Funktion read_to_string zwangsläufig eine Validierung vornehmen. Möchte man auch eine Datei mit invalidem UTF-8 verarbeiten können, sollte diese zunächst als Binärdatei eingelesen werden, hierfür steht die Funktion fs::read zur Verfügung. Mit den Binärdaten kann man dann machen was man will.

use std::{fs::read, error::Error};

fn main() -> Result<(),Box<dyn Error>> {
    let data: Vec<u8> = read("Datei.txt")?;
    let s = String::from_utf8(data)?;
    println!("{}", s);
    Ok(())
}

Hier ist nun alternativ möglich:

let s = String::from_utf8_lossy(&data);

Eine Binärdatei kann auch sehr groß sein, größer als dass sie vollständig in den Hauptspeicher geladen werden kann. Wir wollen den CRC32-Prüfwert von so einer Datei berechnen, siehe ›Algorithmen: Fehlererkennung‹. Der naive Ansatz ginge so:

use std::{env::args, fs::read, io};

fn main() -> io::Result<()> {
    let crc = crc::CRC32::new();
    let argv: Vec<String> = args().collect();
    let data: Vec<u8> = read(&argv[1])?;
    println!("{:08x}", crc.hash(&data, 0));
    Ok(())
}

Die Berechnung des Prüfwertes lässt sich nun aufteilen auf Datenblöcke. Die Blöcke kommen jeweils zunächst von der Datei in einen Puffer der Blockgröße, günstig ist 64 Kibibyte. Dann wird vom Block der Prüfwert bestimmt, wobei der Prüfwert des vorangegangenen Blocks mit einfließt.

use std::{env::args, fs::File, io, io::Read};
use crc::CRC32;

fn hash(crc: &CRC32, file: &mut File) -> io::Result<u32> {
    let mut buffer = [0; 0x10000];
    let mut hash = 0;
    loop {
        let n = file.read(&mut buffer)?;
        if n == 0 {break;}
        hash = crc.hash(&buffer[..n], hash);
    }
    Ok(hash)
}

fn main() -> io::Result<()> {
    let crc = CRC32::new();
    let argv: Vec<String> = args().collect();
    let mut file = File::open(&argv[1])?;
    println!("{:08x}", hash(&crc, &mut file)?);
    Ok(())
}

Hiermit lässt sich nun der Prüfwert von 10 GB großen Dateien ermitteln, auch wenn man nur wenige MB Arbeitsspeicher zur Verfügung hat. Faktisch ist der Arbeitsspeicherbedarf des Programms nun verschwindend gering.

Welches die günstigste Blockgröße ist, vermag ich nicht genau zu sagen. Ein Anhaltspunkt besteht in der Hypothese, dass die Benutzung eines ganzzahligen Vielfachen der Blockgröße oder Clustergröße des Dateisystems effizient ist. Die Blockgröße beträgt meistens 512 Byte oder ein ganzzahliges Vielfaches. Weil ein Cluster eine Zusammenfassung von Blöcken ist, muss die Clustergröße ein ganzzahliges Vielfaches der Blockgröße sein, ein typischer Wert ist 4 Kibibyte. Auf der anderen Seite müsste es am günstigen sein, einen möglichst kleinen Puffer zu benutzen, damit die Belegung des Cache-Speichers gering bleibt.

In Blöcken exakter Größe lesen

Ein wenig schwieriger ist das Lesen einer Datei in Blöcken, wenn diese mit Ausnahme des letzten eine exakte Blockgröße haben sollen. Eine naive Bewerkstelligung wäre die folgende, bei der ich zur Verdeutlichung zusätzlich eine Variable last und eine Zusicherung hinzugefügt habe. Das Problem ist nämlich, dass die Erfüllung dieser Zusicherung nicht garantiert ist. Beim Linux, auf dem ich gerade diesen Text schreibe, klappt das zwar, aber das muss ja nicht heißen, dass es überall gut geht.

use std::{io, io::Read, fs::File};
const BLOCK_SIZE: usize = 0x10000;

fn process_file(file: &mut File, mut callback: impl FnMut(&[u8]))
-> io::Result<()>
{
    let mut buffer: [u8; BLOCK_SIZE] = [0; BLOCK_SIZE];
    let mut last = false;
    loop {
        let n = file.read(&mut buffer)?;
        if n == 0 {break;}
        if n < BLOCK_SIZE {
            assert!(last == false);
            last = true;
        }
        callback(&buffer[..n]);
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let argv: Vec<String> = std::env::args().collect();
    let mut file = File::open(&argv[1])?;
    process_file(&mut file, |data| {
        println!("{}", data.len());
    })?;
    Ok(())
}

Nun ist in der Standardbibliothek die Funktion read_exact vorhanden. Diese gibt allerdings keine Information darüber, wie viele Bytes der letzte Block hat, wenn der Puffer nicht vollständig gefüllt werden konnte. Wir müssten das im Voraus wissen. Beschaffen wir uns vorher die Länge der Datei, können wir die Anzahl count der vollen Blöcke und den Rest rem ausrechnen.

fn process_file(file: &mut File, mut callback: impl FnMut(&[u8]))
-> io::Result<()>
{
    let len = file.metadata()?.len();
    let count = len/(BLOCK_SIZE as u64);
    let rem = (len%(BLOCK_SIZE as u64)) as usize;
    let mut buffer: [u8; BLOCK_SIZE] = [0; BLOCK_SIZE];
    for _ in 0..count {
        file.read_exact(&mut buffer)?;
        callback(&buffer);
    }
    if rem != 0 {
        file.read_exact(&mut buffer[..rem])?;
        callback(&buffer[..rem]);
    }
    Ok(())
}

Allerdings ist diese Variante nicht mehr streamingfähig, denn bei einem Datenstrom muss die Länge nicht unbedingt im Vorhinein bekannt sein. Man muss nun wissen, dass read_exact auch nur mittels read implementiert wird, nämlich wird read solange aufgerufen, bis der Puffer gefüllt ist. Da read_exact nicht besonders kompliziert ist, bietet sich die Formulierung einer eigenen angepassten Variante an.

fn read_exact(file: &mut impl Read, mut buffer: &mut [u8])
-> io::Result<usize>
{
    let mut sum = 0;
    while !buffer.is_empty() {
        let n = file.read(&mut buffer)?;
        if n == 0 {break;}
        buffer = &mut buffer[n..];
        sum += n;
    }
    Ok(sum)
}

fn process_file(file: &mut impl Read,
    mut callback: impl FnMut(&[u8])
) -> io::Result<()>
{
    let mut buffer = [0; BLOCK_SIZE];
    loop {
        let n = read_exact(file, &mut buffer)?;
        if n != 0 {callback(&buffer[..n]);}
        if n < BLOCK_SIZE {break;}
    }
    Ok(())
}

Den Typ File habe ich zusätzlich gegen Polymorphie über alle Typen mit Trait Read ersetzt. Das erlaubt auch, einfach mal so BufReader dazwischen zu klemmen. Die Aufrufe von read können unter Umständen einen gewissen Aufwand bedeuten. Falls BLOCK_SIZE recht klein ist, wäre es besser, man hätte noch einen größeren Puffer dazwischen, damit die Anzahl der Aufrufe von read gering bleibt. Das geht nun super einfach:

fn main() -> io::Result<()> {
    let argv: Vec<String> = std::env::args().collect();
    let mut file = io::BufReader::new(File::open(&argv[1])?);
    process_file(&mut file, |data| {
        println!("{}", data.len());
    })
}

Dateien schreiben

Daten in Dateien schreiben ist denkbar einfach. Man erzeugt einfach eine neue Datei und schreibt die Daten da rein.

use std::{io, io::Write, fs::File};

fn main() -> io::Result<()> {
    let mut file = File::create("Datei.txt")?;
    file.write_all("Daten".as_bytes())?;
    Ok(())
}

Falls die Datei schon vorhanden war, wird sie überschrieben. Manchmal möchte man nicht, dass Dateien einfach so überschreibbar sind, das ist vor allem wichtig bei Programmen die mit Benutzern interagieren. Man kann vor dem Schreiben der Datei prüfen, ob der angegebene Pfad schon existiert. Dies lässt sich wie folgt bewerkstelligen:

use std::{io, io::Write, fs::File, path::Path};

fn write(path: &str, data: &[u8]) -> io::Result<()> {
    if Path::new(path).exists() {
        Err(io::Error::new(io::ErrorKind::AlreadyExists,
            format!("Pfad '{}' existiert bereits", path)))
    } else {
        let mut file = File::create(path)?;
        file.write_all(data)
    }
}

fn main() -> io::Result<()> {
    write("Datei.txt", "Daten".as_bytes())?;
    Ok(())
}

Datenströme

Einen Datenstrom kann man als eine Datei verstehen, deren Ende nicht im Voraus bekannt ist. Damit verbieten sich per se alle Algorithmen, welche die Datei zur Verarbeitung zunächst gänzlich einlesen wollen. Das Einlesen der Daten muss stattdessen auf irgendeine Art häppchenweise vonstattengehen.

Betrachten wir beispielhaft /dev/urandom, den Zufallszahlengenerator des Betriebssystems. Diese Gerätedatei liefert einen endlosen Strom gleichverteilt zufälliger Bytes. Das folgende Programm zeigt, wie sich damit Zufallszahlen des Typs u32 erzeugen lassen.

use std::{fs, io, io::Read};
use std::{thread::sleep, time::Duration};

fn main() -> io::Result<()> {
    let mut buffer: [u8; 4] = [0; 4];
    let mut file = fs::File::open("/dev/urandom")?;
    loop {
        file.read_exact(&mut buffer)?;
        let value = u32::from_ne_bytes(buffer);
        println!("0x{:08x}", value);
        sleep(Duration::from_millis(1000));
    }
}

Eine Alternative zum gezeigten Ansatz besteht in der Umwandlung der Datei in einen Iterator über ihre Bytes. Weil Iteratoren endlos sein dürfen, eignen sie sich auch zur Beschreibung von Datenströmen.

Das folgende Programm zeigt, wie der Zufallszahlengenerator des Betriebssystems zur Simulation eines Spielwürfels genutzt werden kann. Zur Berechnung der Ergebnisse kommt hierbei die Verwerfungsmethode zur Anwendung.

use std::{fs, io, io::Read};
use std::{thread::sleep, time::Duration};

fn main() -> io::Result<()> {
    let file = fs::File::open("/dev/urandom")?;
    for byte in file.bytes() {
        let value = byte? & 0b111;
        if value > 5 {continue;}
        println!("{}", value + 1);
        sleep(Duration::from_millis(1000));
    }
    Ok(())
}