↑Programmieren in Rust
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
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)) }
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)?;
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() }
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} }
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 |
---|---|---|
Negation | NOT | !a
|
Konjunktion | AND | a&b
|
Disjunktion | OR | a|b
|
Kontravalenz | XOR | a^b
|
Operation | Op. | Rust |
---|---|---|
Linksshift | SHL | a<<n
|
Rechtsshift | SHR | a>>n
|
Linksrotation | ROTL | a.rotate_left(n)
|
Rechtsrotation | ROTR | a.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.
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.
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.