Zum Inhalt springen

cat posts/rust-lernen-02-typen-und-kontrollfluss.md

Rust lernen (Teil 2): Typen und Kontrollfluss

Ganzzahlen mit fester Breite, Überlauf, Division ohne stille Umwandlung und if und loop als Ausdrücke. Am Ende liest logbuch den Statuscode jeder Zeile und zählt die Fehler.

Vier unbemalte Matroschkas aus Holz stehen der Größe nach in einer Reihe. In der unteren Hälfte der kleinsten, geöffneten Puppe sitzt eine orangefarbene Krabbe aus Knete.

In Python ist eine Zahl eine Zahl. int wächst, so weit der Speicher reicht, und 2 / 5 ergibt 0.4. Rust will dagegen wissen, wie viele Bits eine Zahl hat und ob sie ein Vorzeichen braucht. Umgewandelt wird nur, was Du ausdrücklich hinschreibst.

Diesmal liest logbuch den HTTP-Statuscode aus jeder Zeile und zählt, wie viele Anfragen mit einem Fehler endeten. Unterwegs lernst Du die Zahltypen kennen und siehst, warum if in Rust einen Wert liefert. Ausgangspunkt ist der Stand aus Teil 1.

Wie groß ist eine Zahl?

Ein Statuscode ist eine kleine positive Zahl, also nimm doch den kleinsten Typ:

fn main() {
    let status: u8 = 404;
    println!("{status}");
}
error: literal out of range for `u8`
 --> src/main.rs:2:22
  |
2 |     let status: u8 = 404;
  |                      ^^^
  |
  = note: the literal `404` does not fit into the type `u8` whose range is `0..=255`
  = note: `#[deny(overflowing_literals)]` on by default

u8 ist eine vorzeichenlose Ganzzahl mit 8 Bit, sie reicht von 0 bis 255. Rust hat für jede Breite einen Typ mit und einen ohne Vorzeichen:

Bits mit Vorzeichen ohne Vorzeichen
8 i8 u8
16 i16 u16
32 i32 u32
64 i64 u64
128 i128 u128
Zeigerbreite isize usize

Für Statuscodes passt u16 (bis 65.535). usize ist so breit wie eine Adresse auf der Zielplattform, auf heutigen Rechnern also 64 Bit. Rust nimmt usize für alles, was Speicher zählt: Längen, Indizes, count(). Deshalb gab zaehle_zeilen in Teil 1 ein usize zurück. Ohne weitere Hinweise wird ein Ganzzahl-Literal zu i32, ein Literal mit Punkt zu f64. Daneben gibt es noch f32.

Die Meldung ist übrigens kein gewöhnlicher Typfehler. overflowing_literals ist ein Lint des Compilers, der standardmäßig auf deny steht und damit den Build abbricht. Mehr zu solchen Regeln steht im Begleitartikel zu Cargo, Clippy und rustfmt.

Aus Text wird eine Zahl

In Python schreibst Du int("404"). In Rust heißt das Gegenstück parse:

fn main() {
    let status = "404".parse().expect("keine Zahl");
    println!("{status}");
}
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let status = "404".parse().expect("keine Zahl");
  |         ^^^^^^         ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
  = note: the type must also implement `FromStr`
...
help: consider giving `status` an explicit type
  |
2 |     let status: /* Type */ = "404".parse().expect("keine Zahl");
  |               ++++++++++++

parse kann in viele Typen umwandeln: u8, u16, f64, IP-Adressen und mehr. Welchen Du willst, kann der Compiler hier nirgends ablesen. Du sagst es ihm am Namen oder direkt am Aufruf:

let status: u16 = "404".parse().expect("keine Zahl");
let status = "404".parse::<u16>().expect("keine Zahl");

Die Schreibweise ::<u16> heißt in der Rust-Welt liebevoll Turbofish. Meist brauchst Du keine von beiden, weil der Compiler den Typ aus der Verwendung ableitet. Gibt eine Funktion u16 zurück und steht parse am Ende, ist die Sache klar.

Python erlaubt status = int(status), und Rust erlaubt das auch:

let status = "404";
let status: u16 = status.parse().expect("keine Zahl");

Das zweite let legt eine neue Variable an, die die erste verdeckt. Das heißt Verschatten (Shadowing). Anders als bei mut darf die neue Variable einen anderen Typ haben, und beide bleiben unveränderlich. Shadowing (Verschatten) lohnt sich, wenn ein Wert umgewandelt wird und die alte Form danach nicht mehr gebraucht wird.

Der Statuscode

Im Format combined steht der Statuscode an neunter Stelle, wenn Du die Zeile an Leerzeichen trennst:

203.0.113.7 - - [02/Nov/2026:08:14:09 +0100] "GET /favicon.ico HTTP/1.1" 404 153 ...
0           1 2 3                     4      5    6            7         8   9

In Python wäre das int(zeile.split()[8]). In Rust:

fn statuscode(zeile: &str) -> u16 {
    let feld = zeile
        .split_whitespace()
        .nth(8)
        .expect("Zeile ohne Statuscode");
    feld.parse().expect("Statuscode ist keine Zahl")
}

split_whitespace() zerlegt die Zeile wie Pythons split() ohne Argument. nth(8) kennst Du aus Teil 1: Es liefert das Feld, falls es eins gibt, und expect bricht sonst ab. parse braucht hier keine Typangabe, weil der Rückgabetyp u16 sie liefert. Die Zeilenumbrüche vor den Punkten setzt rustfmt, sobald eine Kette zu lang wird.

Wasserdicht ist das nicht. Bei Verbindungen ohne gültige Anfrage, wie sie Portscanner erzeugen, steht statt "GET / HTTP/1.1" oft nur "-" im Log. Dann steht an Position 8 etwas anderes, und logbuch bricht mit einem Panic ab. Für heute reicht das. Teil 7 macht aus solchen Zeilen ordentliche Fehler mit Zeilennummer.

Rechnen ohne stille Umwandlung

Nun soll logbuch den Anteil der Fehler ausgeben. Python-Reflex:

let anteil = fehler / zeilen * 100;

Das kompiliert, wenn beide usize sind, und ergibt für 2 Fehler in 5 Zeilen 0. Der Operator / teilt Ganzzahlen ganzzahlig, wie // in Python. Einen float erzeugt er nie. Der Compiler warnt nicht. Aus seiner Sicht ist alles in Ordnung, das Programm rechnet nur etwas anderes, als Du meinst.

Also nach f64 umwandeln. Wandelst Du nur einen Operanden um, kommt das:

error[E0277]: cannot divide `f64` by `usize`
 --> src/main.rs:5:34
  |
5 |     println!("{}", fehler as f64 / zeilen);
  |                                  ^ no implementation for `f64 / usize`
  |
  = help: the trait `Div<usize>` is not implemented for `f64`
...

Python wandelt int bei Bedarf in float um. Rust tut das nicht, nicht einmal zwischen zwei Ganzzahltypen: u32 / usize scheitert genauso. Beide Seiten brauchen denselben Typ:

let anteil = fehler as f64 / zeilen as f64 * 100.0;

as bindet stärker als /, Klammern sind hier nicht nötig. Auch 100 muss als 100.0 dastehen.

Zwei Unterschiede zu Python solltest Du kennen. Erstens rundet // in Python ab, Rust schneidet Richtung null ab: -7 / 2 ergibt in Rust -3, -7 // 2 in Python -4. Das hat Teil 2 von „Python lernen“ für die Python-Seite gezeigt. Zweitens prüft as nichts. 300_i32 as u8 ergibt stillschweigend 44, -1_i32 as u8 ergibt 255. Wo der Wert nicht passen könnte, nimm u8::try_from(x), das einen Fehler meldet. Wie man den behandelt, kommt in Teil 7.

Überlauf

Was passiert, wenn eine Rechnung den Wertebereich verlässt?

fn main() {
    let mut zaehler: u8 = 0;
    for _ in 0..300 {
        zaehler += 1;
    }
    println!("{zaehler}");
}
$ cargo run
...
thread 'main' (72884) panicked at src/main.rs:4:9:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo run --release
...
44

Im Debug-Build prüft Rust jede Rechnung und bricht beim Überlauf ab. Im Release-Build fallen die Prüfungen aus Geschwindigkeitsgründen weg, und der Wert läuft von 255 über auf 0 und zählt weiter bis 44. Anders als der Überlauf vorzeichenbehafteter Zahlen in C ist das kein undefiniertes Verhalten, sondern festgelegt. Ein Fehler im Programm bleibt es trotzdem.

Willst Du ein bestimmtes Verhalten, sag es ausdrücklich:

let x: u8 = 250;
assert_eq!(x.checked_add(10), None); // kein Ergebnis
assert_eq!(x.wrapping_add(10), 4); // läuft über
assert_eq!(x.saturating_add(10), 255); // bleibt am Maximum

checked_add liefert wieder einen Behälter, wie nth. Was None bedeutet, erklärt Teil 6.

if liefert einen Wert

Bleibt die leere Datei. Bei 0 Zeilen teilt logbuch 0.0 durch 0.0 und gibt NaN aus. Also eine Fallunterscheidung:

let anteil = if zeilen == 0 {
    0.0
} else {
    fehler as f64 / zeilen as f64 * 100.0
};

if ist in Rust ein Ausdruck. Jeder Zweig ist ein Block, und der Wert eines Blocks ist sein letzter Ausdruck ohne Semikolon, wie am Ende einer Funktion. Python hat dafür eine eigene Schreibweise, 0.0 if zeilen == 0 else .... Rust braucht keine, weil es nur ein if gibt.

Daraus folgen zwei Regeln, die der Compiler durchsetzt. Beide Zweige müssen denselben Typ liefern:

error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:3:52
  |
3 |     let art = if status >= 400 { "Fehler" } else { 0 };
  |                                  --------          ^ expected `&str`, found integer
  |                                  |
  |                                  expected because of this

Und ohne else gibt es keinen Wert:

error[E0317]: `if` may be missing an `else` clause
 --> src/main.rs:3:15
  |
3 |     let art = if status >= 400 { "Fehler" };
  |               ^^^^^^^^^^^^^^^^^^^--------^^
  |               |                  |
  |               |                  found here
  |               expected `&str`, found `()`
  |
  = note: `if` expressions without `else` evaluate to `()`
  = help: consider adding an `else` block that evaluates to the expected type

() ist das leere Tupel, der Einheitstyp (Unit). Er hat genau einen Wert, ebenfalls (). Ihn liefert alles, was keinen eigentlichen Wert hat, etwa ein if ohne else oder eine Funktion ohne Rückgabetyp. Das nächste Gegenstück in Python ist das None, das eine Funktion ohne return liefert.

Schleifen

for läuft wie in Python über alles, was sich durchlaufen lässt:

fn zaehle_fehler(text: &str) -> usize {
    let mut fehler = 0;
    for zeile in text.lines() {
        if statuscode(zeile) >= 400 {
            fehler += 1;
        }
    }
    fehler
}

Für Zahlenfolgen gibt es Bereiche: 0..300 entspricht range(300) und schließt die 300 aus, 0..=300 schließt sie ein. Das _ im Überlauf-Beispiel heißt wie in Python: Der Wert interessiert nicht. while gibt es auch und funktioniert wie erwartet.

Neu ist loop, eine Endlosschleife, die erst mit break endet. Und break kann einen Wert mitnehmen. Ein Beispiel, das eine Byte-Zahl lesbar macht:

fn lesbare_groesse(bytes: u64) -> String {
    let einheiten = ["B", "KiB", "MiB", "GiB"];
    let mut wert = bytes as f64;
    let mut stufe = 0;
    let einheit = loop {
        if wert < 1024.0 || stufe == einheiten.len() - 1 {
            break einheiten[stufe];
        }
        wert /= 1024.0;
        stufe += 1;
    };
    format!("{wert:.1} {einheit}")
}

einheit bekommt den Wert, den break mitnimmt. Bei for und while geht das nicht, beide liefern immer (). Der Grund: Sie können auch enden, ohne ein break zu erreichen, und dann gäbe es keinen Wert. loop liefert seinen Wert nur über break.

Nebenbei: ["B", "KiB", "MiB", "GiB"] ist ein Array mit fester Länge, kein Python-list. stufe wird ohne Angabe zu usize, weil es als Index dient. Und format! arbeitet wie println!, gibt den Text aber als String zurück, statt ihn auszugeben.

Der Stand

use std::env;
use std::fs;

fn main() {
    let pfad = env::args().nth(1).expect("Aufruf: logbuch <DATEI>");
    let text = fs::read_to_string(&pfad).expect("Datei nicht lesbar");

    let zeilen = zaehle_zeilen(&text);
    let fehler = zaehle_fehler(&text);
    let anteil = if zeilen == 0 {
        0.0
    } else {
        fehler as f64 / zeilen as f64 * 100.0
    };
    println!("{pfad}: {zeilen} Zeilen, davon {fehler} Fehler ({anteil:.1} %)");
}

fn zaehle_zeilen(text: &str) -> usize {
    text.lines().count()
}

fn zaehle_fehler(text: &str) -> usize {
    let mut fehler = 0;
    for zeile in text.lines() {
        if statuscode(zeile) >= 400 {
            fehler += 1;
        }
    }
    fehler
}

fn statuscode(zeile: &str) -> u16 {
    let feld = zeile
        .split_whitespace()
        .nth(8)
        .expect("Zeile ohne Statuscode");
    feld.parse().expect("Statuscode ist keine Zahl")
}

#[cfg(test)]
mod tests {
    use super::*;

    const ZEILE_200: &str = r#"203.0.113.7 - - [02/Nov/2026:08:14:03 +0100] "GET / HTTP/1.1" 200 5123 "-" "Mozilla/5.0 (X11; Linux x86_64)""#;
    const ZEILE_404: &str = r#"192.0.2.44 - - [02/Nov/2026:08:15:31 +0100] "POST /wp-login.php HTTP/1.1" 404 153 "-" "python-requests/2.32.3""#;

    #[test]
    fn zaehlt_zeilen() {
        assert_eq!(zaehle_zeilen("eins\nzwei\ndrei\n"), 3);
    }

    #[test]
    fn letzte_zeile_ohne_umbruch_zaehlt_mit() {
        assert_eq!(zaehle_zeilen("eins\nzwei"), 2);
    }

    #[test]
    fn leerer_text_hat_keine_zeilen() {
        assert_eq!(zaehle_zeilen(""), 0);
    }

    #[test]
    fn liest_statuscode() {
        assert_eq!(statuscode(ZEILE_200), 200);
        assert_eq!(statuscode(ZEILE_404), 404);
    }

    #[test]
    fn zaehlt_fehler() {
        let text = format!("{ZEILE_200}\n{ZEILE_404}\n{ZEILE_200}\n");
        assert_eq!(zaehle_fehler(&text), 1);
    }

    #[test]
    #[should_panic(expected = "Zeile ohne Statuscode")]
    fn kaputte_zeile_bricht_ab() {
        statuscode("das ist keine Logzeile");
    }
}

Zu den Tests: const legt eine Konstante an, ihr Typ muss immer dastehen. Die Logzeilen enthalten Anführungszeichen, deshalb stehen sie in einem Rohstring (Raw String): r#"…"# endet erst bei "#, und \ bleibt ein gewöhnliches Zeichen, ähnlich wie bei Pythons r"…". #[should_panic] erwartet, dass der Test mit einem Panic endet, und mit expected auch, dass die Meldung den Text enthält. In pytest heißt das pytest.raises(..., match=...).

$ cargo test
...
running 6 tests
test tests::letzte_zeile_ohne_umbruch_zaehlt_mit ... ok
test tests::leerer_text_hat_keine_zeilen ... ok
test tests::kaputte_zeile_bricht_ab - should panic ... ok
test tests::zaehlt_zeilen ... ok
test tests::liest_statuscode ... ok
test tests::zaehlt_fehler ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ cargo run -- access.log
...
access.log: 5 Zeilen, davon 2 Fehler (40.0 %)

Die Reihenfolge der Tests wechselt von Lauf zu Lauf, weil cargo test sie parallel ausführt.

Übungen

1. Warum bricht das ab?

fn main() {
    let eingabe = " 404\n";
    let status: u16 = eingabe.parse().expect("keine Zahl");
    println!("{status}");
}

Der Code kompiliert, bricht aber beim Start ab. Lies die Meldung und bring das Programm zum Laufen, mit Shadowing.

Lösung
thread 'main' (23072) panicked at src/main.rs:3:39:
keine Zahl: ParseIntError { kind: InvalidDigit }
Anders als Pythons `int(" 404\n")` verträgt `parse` keine Leerzeichen und keinen Zeilenumbruch. `trim()` schneidet sie ab:
let eingabe = " 404\n";
let eingabe = eingabe.trim();
let status: u16 = eingabe.parse().expect("keine Zahl");

2. Client oder Server? Zähle 4xx- und 5xx-Codes getrennt. Schreib dafür zaehle_klassen(text: &str) -> (usize, usize) mit Test. Tipp: 404 / 100 ist in Rust 4.

Lösung
fn zaehle_klassen(text: &str) -> (usize, usize) {
    let mut client = 0;
    let mut server = 0;
    for zeile in text.lines() {
        let klasse = statuscode(zeile) / 100;
        if klasse == 4 {
            client += 1;
        } else if klasse == 5 {
            server += 1;
        }
    }
    (client, server)
}
Die Funktion gibt ein Tupel zurück, das Du wie in Python zerlegst: `let (client, server) = zaehle_klassen(&text);`. Im Test vergleicht `assert_eq!` ganze Tupel:
#[test]
fn zaehlt_klassen() {
    let text = format!("{ZEILE_200}\n{ZEILE_404}\n{ZEILE_404}\n");
    assert_eq!(zaehle_klassen(&text), (2, 0));
}
Mit der Beispieldatei ergibt das zwei 4xx und keinen 5xx. Die `if`-Kette ist noch etwas holprig. Teil 6 ersetzt sie durch `match`.

3. Lesbare Größe. Übernimm lesbare_groesse in logbuch und gib die Dateigröße mit aus. Schreib vorher Tests für 0, 1023, 1024 und 1536 Bytes. Was liefert die Funktion für 3 TiB, also 3 * 1024 * 1024 * 1024 * 1024 Bytes?

Lösung Die Ergebnisse sind `"0.0 B"`, `"1023.0 B"`, `"1.0 KiB"` und `"1.5 KiB"`. Bei 3 TiB bleibt die Funktion bei der größten Einheit stehen und liefert `"3072.0 GiB"`, denn die Bedingung `stufe == einheiten.len() - 1` beendet die Schleife. Ohne diese Bedingung würde `einheiten[4]` gelesen, und das Programm bräche mit einem Panic ab: Rust prüft jeden Array-Zugriff. Die Ausgabe ergänzt Du so:
println!("{}", lesbare_groesse(text.len() as u64));
`text.len()` ist ein `usize`, die Funktion erwartet `u64`, also brauchst Du wieder `as`.

Im nächsten Teil

Bisher bekommen alle Funktionen ihren Text mit & geliehen. In Teil 3 lässt Du das & weg und lernst Ownership (Besitz) kennen. Dabei geht es um die Frage, wem ein Wert gehört und warum sich eine Variable nach einem Funktionsaufruf plötzlich nicht mehr benutzen lässt.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!