Programmieren in Rust

Elementare Typen

Inhaltsverzeichnis

  1. Ganzzahlen
  2. Fließkommazahlen
  3. Typumwandlungen
  4. Bitweise Operationen
  5. Bitmasken
  6. Umgang mit Überlauf

Ganzzahlen

Die vorzeichenlosen Ganzzahl-Datentypen in Rust sind u8, u16, u32 und u64. Das u steht hierbei für unsigned (dt. vorzeichenlos) und die Zahl dahinter ist die Anzahl der Bits. Ein Bit ist eine binäre Ziffer. Eine Speicherzelle mit n Bits kann also die 2n Zahlen von 0 bis 2n−1 speichern.

Neben den vorzeichenlosen gibt es auch noch die vorzeichenbehafteten Typen i8, i16, i32 und i64. Das i steht dabei für integer (dt. Ganzzahl). Das Vorzeichen wird hier nicht im ersten oder letzten Bit gespeichert. Stattdessen liegen die Zahlen in der Zweier-Komplement-Darstellung vor. Der Zahlenvorrat reicht hier von −2n−1 bis 2n−1−1.

Außerdem gibt es noch die Typen usize und isize. Der Typ usize ist ein Typ, der intern die Gestalt von u32 oder u64 besitzt, je nachdem ob ein 32-Bit-Programm oder ein 64-Bit-Programm vorliegt. Ein 64-Bit-Programm hat u. a. den Vorteil, dass es mehr als 4 GB Arbeitsspeicher adressieren kann. Es gibt reine n-Bit-Architekturen, auf denen nur n-Bit-Programme ausgeführt werden können. Auf eingeschränkten Mikrocontrollern kann auch n=16 oder gar n=8 sein. Die Typen usize und isize sind dafür da, um mit Längen und Speicheradressen rechnen zu können, unabhängig davon ob eine 32-Bit oder 64-Bit-Architektur vorliegt.

Wir wollen nun die Zahl 12 an den Bezeichner x binden. Das geschieht wie folgt:

let x: i32 = 12i32;

Hier muss man zunächst bemerken, dass der Rust-Compiler in der Lage ist, den Typ von x aus dem Ausdruck auf der rechten Seite vom Zuweisungszeichen »=« zu erkennen. Dieser Vorgang nennt sich Typinferenz. Man darf also kürzer schreiben:

let x = 12i32;

Da aber i32 der Standard-Ganzzahl-Datentyp ist, wird dieser angenommen, falls kein Typ abgeleitet werden kann. Man kann hier also kurz schreiben:

let x = 12;

Angenommen wir benötigen nun einen Ganzzahltyp, der auf einem Computer u16 und auf dem anderen Computer u32 ist. Rust bietet diese Möglichkeit der Abstraktion, indem es uns einen Typ-Alias definieren lässt, welcher ähnlich wie usize verwendet wird:

type uint = u32;
let x: uint = 12;

Hier ist uint ein Alias für u32. Durch Änderung einer einzigen Zeile kann ein komplexes Computerprogramm nun an eine andere Architektur angepasst werden. Auch die Automatisierung dieser Änderung ist möglich. Durch Änderung der Konfiguration in einer Datei ließe sich ein ganzes Softwaresystem anpassen. Ein solcher Extremfall wäre z. B. usize, wobei usize aber ein eigener abstrakter Datentyp ist. Noch strenger lässt sich die Abstraktion formulieren, wenn man wie bei usize einen dafür vorgesehen abstrakten Datentyp formuliert. Eine genaue Erläuterung dazu folgt in einem späteren Kapitel.

Rust erlaubt die binäre, oktale und hexadezimale Darstellung einer Zahl im Quelltext:

let x: u32 = 0b00000000_00000000_00000000_00001100; // binär
let x: u32 = 0b1100; // binär, kurz
let x: u32 = 0x00_00_00_0c; // hexadezimal
let x: u32 = 0x0000000c; // hexadezimal ohne Trennzeichen
let x: u32 = 0xc; // hexadezimal, kurz
let x: u32 = 0o14; // oktal

Der Unterstrich darf an beliebigen Stellen eingefügt werden und dient ausschließlich als Mittel, um bessere Lesbarkeit zu ermöglichen. Auch die Ausgabe in den unterschiedlichen Darstellungen ist möglich:

let x: u32 = 12;
println!("{}, 0b{:b}, 0x{:x}, 0o{:o}", x, x, x, x);

Ausgabe:

12, 0b1100, 0xc, 0o14

Fließkommazahlen

Die Datentypen f32 und f64 repräsentieren Fließkommazahlen, wobei 32 bzw. 64 wieder für die Zahl der Bits steht. Der Typ f64 ist der Standarddatentyp für Fließkommazahlen. Der Typ f32 sollte nur in extrem Laufzeit-kritischen Routinen verwendet werden, etwa in den Grafik-Kernen von Computerspielen, da dort jede Verzögerung schmerzlich ist.

Wird keine weitere Typ-Annotation angegeben, die Zahl aber mit einen Punkt dargestellt, dann wird f64 angenommen:

let x = 1.0; //  let x: f64 = 1.0;

Die elementaren Funktionen sind ohne weitere Einbindungen verfügbar, müssen jedoch als Methodenaufruf geschrieben werden:

use std::f64::consts::PI;
const GRAD: f64 = PI/180.0;

let x = f64::sqrt(2.0);
let y = f64::sin(90.0*GRAD);

Neue Funktionen lassen sich ganz einfach definieren:

fn f(x: f64) -> f64 {
    1.0/(x.abs() + 1.0)
}

fn main(){
    println!("{}", f(1.0))
}

Typumwandlungen

Sichere Typumwandlungen

Für sichere Typumwandlungen steht die Funktion from zur Verfügung:

let x: u8 = 0;
let y: i32 = i32::from(x);

Normalerweise ist eine solche Umwandlung eine umkehrbare Einbettung. Z. B. ist die Umwandlung von u8 nach i32 eine Einbettung, da u8 als Teilmenge von i32 betrachtet werden kann, und umkehrbar, da niemals zwei u8-Zahlen zur selben i32-Zahl werden.

In der Übersicht ergeben sich die folgenden Umwandlungen:

u16::from(x: u8),
u32::from(x: u8), u32::from(x: u16),
u64::from(x: u8), u64::from(x: u16), u64::from(x: u32)

i16::from(x: u8),
i32::from(x: u8), i32::from(x: u16),
i64::from(x: u8), i64::from(x: u16), i64::from(x: u32)

i16::from(x: i8),
i32::from(x: i8), i32::from(x: i16),
i64::from(x: i8), i64::from(x: i16), i64::from(x: i32)

Nun stellt sich noch die Frage, wie Umwandlungen mit möglichem Verlust gehandhabt werden sollen. Falls ein Verlust auftritt, würde man das Verhalten dann gerne selbst festlegen. Dies erlaubt die Funktion try_from. Bei Verlust auf den gleichen Wert abzubilden, lässt sich wie folgt bewerkstelligen:

const DEFAULT: u8 = 0;

fn main() {
    let x: u32 = 12;
    let y: u8 = match u8::try_from(x) {
        Ok(value) => value,
        _ => DEFAULT
    };
    println!("{}", y);
}

Wenn man aber von vornherein weiß, dass das Argument im Wertebereich von u8 liegt, sollte man stattdessen dies schreiben:

let y: u8 = match u8::try_from(x) {
    Ok(value) => value,
    _ => unreachable!()
};

Für die Fallunterscheidung mit match gibt es auch Kurzschreibweisen:

let y = u8::try_from(x).unwrap_or(DEFAULT);

bzw.

let y = u8::try_from(x).unwrap_or_else(|_| unreachable!());

Besteht Unklarheit darüber, wie sich ein Fehler am besten lokal handhaben lässt, ist der Ausweg das Weiterreichen des Fehlers nach oben an den Aufrufer.

fn main() -> Result<(), std::num::TryFromIntError> {
    let x: u32 = 12;
    let y = match u8::try_from(x) {
        Ok(value) => value,
        Err(e) => return Err(e)
    };
    println!("{}", y);
    Ok(())
}

Auch hier gibt es wieder eine Kurzschreibweise:

let y = u8::try_from(x)?;

Verlustbehaftete Typumwandlungen

Neben den sicheren Typumwandlungen gibt es in Rust noch die nackten Typumwandlungen mit dem Schlüsselwort »as«. Zwar stellen diese Umwandlungen keinen unsicheren Code dar, jedoch erlauben sie stillschweigend Verlust und Transmutation, sind daher also mit Vorsicht zu genießen.

Man sollte solche nackten Typumwandlungen nur dann verwenden, wenn sie nicht mit from möglich sind, beispielsweise bei einer potentiell verlustbehafteten Umwandlung:

let x: u32 = 12;
let y: u8 = x as u8;

Was passiert hierbei? Bei einer Umwandlung von u32 nach u8 werden ganz einfach die drei höherwertigen Nullbytes abgeschnitten:

Vorher:  u32: 00000000 00000000 00000000 00001100
Nachher:  u8: 00001100

Die Operation »x as u8« ist demnach äquivalent zu:

match u8::try_from(x & 0xff) {
    Ok(value) => value,
    _ => unreachable!()
}

Möchte man ganz pedantisch sein, sollte die Absicht klargestellt werden. Unmissverständlich wäre

fn u8_wrapping_from_u32(x: u32) -> u8 {
    x as u8
}

oder kurz:

trait WFrom<T> {
    fn wfrom(x: T) -> Self;
}

impl WFrom<u32> for u8 {
    fn wfrom(x: u32) -> u8 {x as u8}
}

Man kann dann u8::wfrom(x) schreiben. Was ein Trait ist, ist jetzt nicht so wichtig, das wird später ausführlich erklärt.

Einige Leser mögen sich fragen, warum es usize::from(x) nicht für x: u32 gibt. Dies liegt daran, dass usize auf 16-Bit-Architekturen nur 16 Bit groß ist. Per Definition ist from frei von Verlust und panic, was die Umwandlung von 32 Bit zu 16 Bit aber nicht gestattet.

Zieht man Unterstützung für 16-Bit-Architekturen in Erwägung und will man auf weitergehende algorithmische Anpassung verzichten, ist die folgende Funktion etwas sicherer als die direkte Benutzung von as usize.

#[cfg(not(target_pointer_width = "16"))]
fn usize_from_u32(x: u32) -> usize {
    x as usize
}

#[cfg(target_pointer_width = "16")]
fn usize_from_u32(x: u32) -> usize {
    usize::try_from(x).unwrap()
}

Transmutierende Typumwandlungen

Bei transmutierenden Umwandlungen werden Daten einfach als von anderem Typ interpretiert, ohne eine semantische Umwandlung vorzunehmen. Aus einer puristischen Sichtweise heraus ist das grundsätzlich problematisch, da durch die Offenlegung der internen Repräsentation der Daten jegliche Abstraktion zerstört wird. Aus diesem Grund sind Transmutationen charakteristisch für sehr maschinennahe Programmierung.

Betrachten wir z. B. die Umwandlung von i32 in u32:

fn main() {
    let x: i32 = -1;
    println!("{}", x as u32);
}

Hierbei wird Zweierkomplement-Darstellung offengelegt, was dazu führt, dass -1 als std::u32::MAX interpretiert wird. Wer noch nie von der Zweierkomplement-Darstellung gehört hat, wird über das riesig große Ergebnis verblüfft sein.

Wer pedantisch sein will, sollte auch hier die Absicht klarstellen, z. B. als u32::transmute_from(x).

trait TransmuteFrom<T> {
    fn transmute_from(x: T) -> Self;
}

impl TransmuteFrom<i32> for u32 {
    fn transmute_from(x: i32) -> u32 {x as u32}
}

Bitweise Operationen

Zahlen sind abstrakte Objekte, die physisch auf irgendeine Art dargestellt werden müssen. Eine praktische Methode dafür sind Stellenwertsysteme, und im Computer sind Zahlen bekanntlich im Binärsystem dargestellt. Zugriff auf die einzelnen Bits einer Speicherzelle ist auch möglich – mittels Bitmasken und Verschiebungen.

Bitweise Operationen lassen sich in zwei Gruppen unterscheiden. Das sind zum einen bitweise Operationen der booleschen Algebra, das sind NOT, AND, OR, XOR. Zum anderen gibt es Verschiebe-Operationen einschließlich zyklischen Verschiebungen, auch Rotation genannt. In Rust haben diese Operationen folgende Syntax:

Operation Op. Rust
NegationNOT!a
KonjunktionANDa&b
DisjunktionORa|b
KontravalenzXORa^b

Operation Op. Rust
LinksshiftSHLa<<n
RechtsshiftSHRa>>n
LinksrotationROTLa.rotate_left(n)
RechtsrotationROTRa.rotate_right(n)

Man kann auch ein ganzes Byte aus Bits zusammensetzen. Man geht von der Darstellung im Binärsystem aus:

a[7]*27 + a[6]*26 + a[5]*25 +a[4]*24 + a[3]*23 + a[2]*22 +a[1]*21 + a[0]*20

Wir wollen die Ziffern zum Komfort auch in Big-Endian schreiben und drehen die Reihenfolge deshalb um:

a[7]*20 + a[6]*21 + a[5]*22 +a[4]*23 + a[3]*24 + a[2]*25 +a[1]*26 + a[0]*27

Die Zweierpotenzen kodiert man als Bitmasken. Anstelle von Addition benutzt man bitweises Oder:

fn u8_from_bits(a: &[bool; 8]) -> u8 {
    u8::from(a[7])*0b00000001 | u8::from(a[6])*0b00000010 |
    u8::from(a[5])*0b00000100 | u8::from(a[4])*0b00001000 |
    u8::from(a[3])*0b00010000 | u8::from(a[2])*0b00100000 |
    u8::from(a[1])*0b01000000 | u8::from(a[0])*0b10000000
}

fn main() {
    let bits = &[false, false, false, false, true, false, true, false];
    println!("0b{:08b}", u8_from_bits(bits));
}

Man könnte das auch wie gewohnt schreiben:

fn u8_from_bits(a: &[bool; 8]) -> u8 {
    let mut acc = 0;
    for k in 0..7 {
        acc += u8::from(a[k as usize])*(2u8).pow(7-k);
    }
    acc
}

Eigentlich nimmt man dafür aber Linksshift und bitweises Oder:

fn u8_from_bits(a: &[bool; 8]) -> u8 {
    let mut acc = 0;
    for k in 0..7 {
        acc |= u8::from(a[k])*(2<<(6-k));
    }
    acc
}

Schließlich lässt sich die Multiplikation noch beseitigen:

fn u8_from_bits(a: &[bool; 8]) -> u8 {
    let mut acc = 0;
    for k in 0..7 {
        acc |= u8::from(a[k])<<(7-k);
    }
    acc
}

Entsprechend gestaltet sich die Formulierung der umgekehrten Funktion:

fn bits_from_u8(x: u8) -> [bool; 8] {
    let mut bits: [bool; 8] = [false; 8];
    for k in 0..7 {
        if (x>>(7-k)) & 0b00000001 == 1 {bits[k] = true;}
    }
    bits
}

Effizienter ist es jedoch, den Zugriff auf sämtliche Bits einer Speicherzelle zu vermeiden. Man liest bzw. schreibt nur die Bits, welche für den jeweiligen Zweck von Interesse sind.

Bitmasken

Manchmal möchte man nicht eine ganze Speicherzelle auf einen bestimmten Wert setzen, sondern lediglich eine in ihr enthaltene Bitgruppe. Zur Bewerkstelligung sind Bitmasken hilfreich.

Aufgabe sei es, die niedrigstwertigen vier Bits eines Bytes byte auf einen bestimmten Wert value zu setzen. Da die höchstwertigen vier Bits erhalten bleiben sollen, extrahieren wir diese zunächst durch UND-Verknüpfung mit der Bitmaske 0xf0 bzw. 0b11110000. Diese Operation resultiert in einem Wert, bei dem die niedrigstwertigen vier Bits null sind. Vom Wert value sollen auf der anderen Seite nur die niedrigstwertigen vier Bits von Bedeutung sein. Die Gewissheit, dass die höchstwertigen vier null sind, schafft die UND-Verknüpfung mit der Bitmaske 0x0f bzw. 0b00001111. Schließlich kombinieren wir die beiden Resultate durch eine ODER-Verknüpfung. Die Gesamtoperation ist:

// Set least significant 4 bits
fn set_ls4(byte: &mut u8, value: u8) {
    *byte = (0xf0 & *byte) | (0x0f & value);
}

Arbeitet man stattdessen mit Speicherzellen vom Typ u32, nimmt die Operation die Form

fn set_ls4(cell: &mut u32, value: u32) {
    *cell = (0xfff0 & *cell) | (0xf & value);
}

an. Befinden sich die Bitgruppen von Speicherzelle und Wert an verschiedenwertigen Stellen, muss man entsprechend Bitverschiebungen hinzufügen.

Umgang mit Überlauf

Der Wertebereich der elementaren Typen ist ja beschränkt, was die Frage aufwirft, wie mit arithmetischem Überlauf umzugehen ist. Überlauf bedeutet, dass das Ergebnis einer Operation den erlaubten Wertebereich verlässt. Wie ein überlaufendes Becken darf man arithmetischen Überlauf nicht einfach ignorieren, denn die Programmlogik kann in empfindlicher Weise davon abhängig sein, was im schlimmsten Fall zu einer Sicherheitslücke führen kann.

Problematisch sind allgemein solche Fehler, die längere Zeit unentdeckt bleiben, oder deren Ursache verschleiert bleibt. Diesen widerspenstigen Fehlern wirkt man zunächst am besten mit einer Fail-Fast-Strategie entgegen. Manifestieren tut sich dies meistens in Form von zusätzlichen Laufzeit-Prüfungen. Als Kompromisslösung lässt man diese zusätzlichen Prüfungen nur im Debug-Modus und während automatisierten Tests vorkommen, während sie im Release-Modus entfernt werden. Man kann solche Prüfungen auf Wunsch auch manuell mit den Makros assert und debug_assert einfügen.

Entsprechend ist dies bei den arithmetischen Operationen gehandhabt. Diese führen bei Überlauf im Debug-Modus zum Programmabbruch via panic. Im Release-Modus liegt stattdessen Wrapping-Verhalten vor, das direkt effizientem Maschinencode entspricht. Man kann die Prüfungen natürlich auch im Release-Modus durchführen lassen, wofür es ein Compiler-Flag gibt.

Das Wrapping-Verhalten entspricht bei vorzeichenlosen Ganzzahlen der modularen Arithmetik. Die folgenden drei Beispiele sind im Release-Modus äquivalent:

fn main() {
    let x: u8 = 100;
    let y: u8 = 200;
    println!("{}", x + y);
}

fn main() {
    let x: u8 = 200;
    let y: u8 = 100;
    println!("{}", x.wrapping_add(y));
}

fn main() {
    let x: u32 = 200;
    let y: u32 = 100;
    println!("{}", (x + y)%256);
}

Es gibt zusätzlich spezielle Operationen zur genauen Kontrolle des Verhaltens beim Überlauf:

Operation Verhalten
x.checked_add(y) None bei Überlauf, sonst Some(x+y)
x.overflowing_add(y) (x.wrapping_add(y), true) bei Überlauf, sonst (x+y, false)
x.saturating_add(y) Maximum des Zahlenraums bei Überlauf, sonst x+y
x.wrapping_add(y) x+y modulo Zahlenraumgröße bei Überlauf, sonst x+y

Entsprechende Varianten gibt es für die anderen arithmetischen Operationen.