Rust-Grundlagen Teil 2 – Typen in Rust verstehen

Unveränderlichkeit als Standard, keine impliziten Umwandlungen, Option statt null und der Unterschied zwischen &str und String: die Grundlagen des Rust-Typsystems – mit Übungsaufgaben zum Selbermachen.

This article has not been translated yet. You are viewing the original German version.

Read in German →

Rust

Rust-Grundlagen Teil 2 – Typen in Rust verstehen

Marcel Strahl5 min read

Rust-Grundlagen Teil 2 – Typen in Rust verstehen

Im ersten Teil ging es darum, was hinter dem Rust-Hype steckt – und was nicht.

Jetzt geht es richtig los. Und zwar da, wo jede Sprache anfängt: bei den Typen.

Rusts Typsystem ist das strengste in dieser Artikelreihe. Nichts wird implizit umgewandelt, nichts ist heimlich null, nichts ändert sich, ohne dass es im Code steht. Die gute Nachricht: Der Compiler ist dabei nicht Gegner, sondern der beste Erklärer der Branche.


Was ist überhaupt ein Typ?

Ein Typ sagt dem Compiler, was ein Wert ist und was man damit machen darf.

42 ist eine Zahl. Damit kann man rechnen. "42" ist Text, der nur zufällig aussieht wie eine Zahl.

Zum Ausprobieren brauchst du nichts installieren: Der Rust Playground läuft im Browser.

fn main() {
    println!("{}", 42);        // 42
    println!("{}", "42");      // 42 – sieht gleich aus, ist aber ein &str
    println!("{:?}", 4.2);     // 4.2 – {:?} ist die Debug-Ausgabe
    dbg!(42, "42", 4.2, true); // dbg! zeigt Ausdruck UND Wert – dein var_dump()
}

dbg! wird beim Lernen dein wichtigstes Werkzeug. Unsicher? Reinschauen. Fertig.


let und mut – Unveränderlichkeit als Standard

Bevor es um Typen geht, die wichtigste Rust-Eigenheit überhaupt:

let name = "Marcel"; // unveränderlich – Standard!
let mut alter = 34;  // veränderlich – nur mit ausdrücklichem mut
 
alter = 35;          // ok
// name = "Max";     // Compilerfehler

In Kotlin heißt es „nimm val statt var“, in JavaScript „nimm const“. Rust dreht es um: Unveränderlich ist der Standard, Veränderbarkeit muss man beantragen.

Und die Typen? Werden hergeleitet, stehen aber fest. Explizit geht natürlich auch:

let alter: i32 = 34;
let preis: f64 = 19.99;

Die Basis-Typen

Rusts Typnamen sind herrlich ehrlich – sie sagen genau, wie viele Bits ein Wert hat.

i32 – Ganzzahlen

let alter = 34;        // i32 – der Standard für Ganzzahlen
let temperatur = -5;
let weltbevoelkerung: i64 = 8_100_000_000; // größer? i64 sagen

i8, i16, i32, i64: vorzeichenbehaftet mit 8 bis 64 Bit. u8 bis u64: nur positiv. Der Unterstrich in 8_100_000_000 ist nur Lesehilfe.

Man merkt die C-Verwandtschaft – aber ohne das „üblicherweise 4 Bytes“-Raten. i32 ist überall 32 Bit. Punkt.

f64 – Kommazahlen

let preis = 19.99;   // f64 – der Standard
let pi = 3.14159;

Wichtig: Der Dezimaltrenner ist ein Punkt, kein Komma – auch wenn wir im Deutschen „Kommazahl“ sagen.

Und ja, auch f64 hat die Genauigkeits-Falle eingebaut – dazu mehr im Zusatz am Ende.

bool – Wahr oder Falsch

let ist_angemeldet = true;
let hat_bezahlt = false;

Kein „truthy“, kein „falsy“: if 1 { ... } kompiliert nicht. Ein if will einen bool.

char – ein Zeichen, aber richtig

let note = 'A';
let herz = '❤'; // ja, das geht

Ein Rust-char ist kein Byte wie in C, sondern ein voller Unicode-Punkt – Emoji inklusive.


&str und String – die erste Rust-Hürde

Text hat in Rust zwei Typen, und das verwirrt jeden Anfänger genau einmal:

let literal = "Marcel";                // &str – geliehener Blick auf Text
let eigener = String::from("Marcel");  // String – eigener, veränderbarer Text
 
let begruessung = format!("Hallo {literal}"); // format! baut einen neuen String

Die Kurzfassung für heute:

  • &str – ein „Ausleihschein“ auf Text, der woanders liegt. String-Literale sind &str.
  • String – Text, der dir gehört und wachsen darf.

Warum zwei Typen? Weil Rust ohne Garbage Collector auskommt und deshalb im Typ festhält, wem Text gehört. Das ist ein Vorgeschmack auf Ownership – das große Thema eines späteren Teils. Für den Anfang reicht die Faustregel: Literale und Funktionsparameter &str, selbst gebauter Text String.


Keine impliziten Umwandlungen. Wirklich keine.

Kotlin war schon streng. Rust ist strenger:

let a: i32 = 10;
let b: f64 = 3.0;
 
// let c = a / b;           // Compilerfehler: i32 / f64 gibt es nicht
let c = a as f64 / b;       // 3.333... – Umwandlung nur mit ausdrücklichem `as`
 
// let s = "5" + 3;         // Compilerfehler – String-Mathe gibt es nicht
let s = format!("{}{}", 5, 3); // "53" – wenn man es wirklich will, sagt man es

Nicht mal i32 und i64 darf man mischen. Klingt pedantisch – bedeutet aber: Die ganze Kategorie „stille Umwandlung mit Überraschung“ existiert in Rust nicht.

Dafür gibt es Shadowing – dieselbe Variable neu deklarieren, auch mit anderem Typ:

let eingabe = "42";                        // &str
let eingabe: i32 = eingabe.parse().unwrap(); // jetzt eine Zahl
 
println!("{}", eingabe + 1); // 43

Das ist idiomatisches Rust: kein eingabe_str und eingabe_zahl, sondern zweimal eingabe.


Option<T> – Rust hat kein null

Und jetzt das Feature, das Rust von fast allen anderen Sprachen unterscheidet:

Es gibt kein null.

Wo andere Sprachen heimlich null zurückgeben, steht in Rust ein ehrlicher Typ:

fn finde_benutzername(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("marcel"))
    } else {
        None
    }
}

Option<String> heißt: Hier kommt entweder ein String (Some(...)) oder nichts (None). Und der Compiler zwingt jeden Aufrufer, beide Fälle zu behandeln:

match finde_benutzername(1) {
    Some(name) => println!("Gefunden: {name}"),
    None => println!("Kein Benutzer mit dieser ID"),
}
 
// oder kompakt mit Default:
let name = finde_benutzername(99).unwrap_or_else(|| String::from("anonym"));

Eine NullPointerException ist damit nicht selten, sondern unmöglich. Der Fall „kein Wert“ ist kein Laufzeitfehler mehr, sondern ein ganz normaler Typ.


Structs, Tupel und Vektoren

// Struct – benannte Felder
struct Profil {
    name: String,
    alter: u32,
    hobbys: Vec<String>,
}
 
// Vec – die wachsende Liste
let sprachen = vec!["Rust", "PHP", "TypeScript"];
println!("{}", sprachen[0]); // Rust
 
// Tupel – schnelle Gruppierung ohne eigenen Typ
let punkt = (3, 5);
println!("{}", punkt.0); // 3

Arrays mit fester Größe ([i32; 3]) gibt es auch – im Alltag nimmt man aber meistens Vec.


Typen in Funktionen

Parametertypen und Rückgabetyp sind Pflicht – Inferenz gibt es nur innerhalb von Funktionen:

fn berechne_preis_mit_steuer(netto_preis: f64, steuersatz: f64) -> f64 {
    netto_preis * (1.0 + steuersatz)
}

Die letzte Zeile ohne Semikolon ist der Rückgabewert – Rust-Idiom, kein Tippfehler.

Was man dafür bekommt:

  • Jede Funktionssignatur ist ein vollständiger Vertrag
  • Wer „kein Wert“ liefern kann, muss Option schreiben – für jeden sichtbar
  • Falsche Aufrufe scheitern beim Kompilieren, nicht beim Kunden

Aufgaben

Genug Theorie.

Am meisten hängen bleibt, wenn man es selbst tippt. Rust Playground auf und los.

Aufgabe 1: Typen raten

Was gibt Rust hier jeweils aus – oder kompiliert es gar nicht? Erst überlegen, dann ausführen.

fn main() {
    println!("{}", 10 / 3);
    println!("{}", 10.0 / 3.0);
    println!("{}", 10 / 3.0);
    println!("{}", 'A' as u32 + 1);
    println!("{}", "abc" == "abc");
}

Aufgabe 2: Steckbrief

Lege ein struct Profil mit name (String), alter (u32), hobbys (Vec aus Strings) und beruf (Option aus String) an. Erzeuge eine Instanz und gib einen Satz aus wie: Marcel ist 34 und hat 3 Hobbys.

Tipp: hobbys.len() zählt die Einträge, println! mit {}-Platzhaltern baut den Satz.

Aufgabe 3: Option erleben

Nimm die Funktion finde_benutzername aus dem Artikel. Rufe sie mit 1 und mit 99 auf und behandle beide Fälle einmal mit match und einmal mit unwrap_or_else. Was passiert, wenn du auf das Ergebnis von finde_benutzername(99) einfach .unwrap() aufrufst?

Aufgabe 4: Der Geld-Klassiker

Ein Kunde kauft drei Artikel zu je 0.10 €. Prüfe mit ==, ob die Summe 0.30 ergibt. Falls nicht: Rechne dasselbe noch einmal in Cent als u32 und prüfe erneut.

Aufgabe 5: Shadowing-Parser

Lies den String "42" per parse() in eine Zahl ein – mit Shadowing, also ohne neuen Variablennamen. Verdopple die Zahl und gib sie aus. Was gibt "abc".parse::<i32>() zurück – und warum ist das besser als eine Exception?


Lösungsvorschläge

Erst selbst probieren. Ehrlich. Dann vergleichen.

Lösung zu Aufgabe 1
println!("{}", 10 / 3);          // 3 – Ganzzahl-Division schneidet ab
println!("{}", 10.0 / 3.0);      // 3.3333333333333335
// println!("{}", 10 / 3.0);     // kompiliert nicht! i32 / f64 gibt es nicht
println!("{}", 'A' as u32 + 1);  // 66 – erst der explizite Cast macht das Zeichen zur Zahl
println!("{}", "abc" == "abc");  // true – == vergleicht den Inhalt

Die dritte Zeile ist der Unterschied zu C und Java: Was dort still konvertiert wird, ist in Rust ein Compilerfehler.

Lösung zu Aufgabe 2
struct Profil {
    name: String,
    alter: u32,
    hobbys: Vec<String>,
    beruf: Option<String>,
}
 
fn main() {
    let profil = Profil {
        name: String::from("Marcel"),
        alter: 34,
        hobbys: vec![
            String::from("Programmieren"),
            String::from("Lesen"),
            String::from("Gaming"),
        ],
        beruf: Some(String::from("Software-Entwickler")),
    };
 
    println!("{} ist {} und hat {} Hobbys.", profil.name, profil.alter, profil.hobbys.len());
}
Lösung zu Aufgabe 3
fn finde_benutzername(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("marcel")) } else { None }
}
 
fn main() {
    match finde_benutzername(1) {
        Some(name) => println!("Gefunden: {name}"),
        None => println!("Kein Benutzer"),
    }
 
    let name = finde_benutzername(99).unwrap_or_else(|| String::from("anonym"));
    println!("{name}"); // anonym
 
    // finde_benutzername(99).unwrap(); // panic! „called `Option::unwrap()` on a `None` value“
}

.unwrap() heißt: „Ich bin sicher, da ist ein Wert – sonst stürz ab.“ In Übungen ok, in echtem Code fast immer durch match, unwrap_or_else oder das ?-Operator-Muster ersetzt.

Lösung zu Aufgabe 4
let summe = 0.10 + 0.10 + 0.10;
println!("{}", summe == 0.30); // false – Float-Rundungsfehler
println!("{summe}");           // 0.30000000000000004
 
let summe_in_cent: u32 = 10 + 10 + 10;
println!("{}", summe_in_cent == 30); // true – deshalb Geld immer in Cent als Ganzzahl
Lösung zu Aufgabe 5
fn main() {
    let zahl = "42";
    let zahl: i32 = zahl.parse().unwrap(); // Shadowing: gleicher Name, neuer Typ
 
    println!("{}", zahl * 2); // 84
 
    let ergebnis = "abc".parse::<i32>();
    println!("{ergebnis:?}"); // Err(ParseIntError { kind: InvalidDigit })
}

parse() gibt ein Result zurück – der Fehlerfall ist Teil des Typs, genau wie bei Option. Man sieht an der Signatur, dass Parsen schiefgehen kann, und der Compiler zwingt einen, den Fall zu behandeln. Keine Exception, die drei Schichten später explodiert.


Zusatz: Die Float-Falle

Und jetzt kommt eine Falle, in die wirklich jeder einmal reinläuft – auch in Rust:

println!("{}", 0.1 + 0.2 == 0.3); // false

Ja, wirklich. false.

Floats sind binär gespeichert und dadurch nicht beliebig genau. Das ist IEEE 754 und betrifft praktisch jede Sprache – so streng Rusts Typsystem auch ist, an der Mathematik der Floats ändert es nichts. Rusts Linter Clippy warnt immerhin, wenn man Floats mit == vergleicht.

Geldbeträge rechnet man deshalb auch in Rust niemals mit Floats, sondern in Cent als Ganzzahl.


Wie geht es weiter?

Das waren die Bausteine, aus denen jedes Rust-Programm besteht.

Im nächsten Teil geht es um Kontrollstrukturen – if als Expression, match, Schleifen. Also darum, wie ein Programm Entscheidungen trifft. Und danach ans Eingemachte: Ownership.