↑Programmieren in Rust
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>
.
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.txtleitet
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.
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.
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
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.
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.
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()); }) }
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(()) }
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(()) }