↑Programmieren in Rust
Die Kollektionen oder Behälter (engl.
collections, container) sind spezielle generische
Datenstrukturen, die man als Hilfsmittel zur Konstruktion von
Algorithmen und neuen Datenstrukturen benutzen kann. Die
Standardbibliothek von Rust enthält eine Reihe solcher Behälter,
sie sind im Modul std::collections
zu finden.
Die wichtigsten Kollektionen lassen sich wie folgt in Arten einteilen:
Vec
für dynamische Felder,
VecDeque
für Warteschlangen und
LinkedList
für doppelt verkettete Listen.
HashMap
für assoziative
Felder und BTreeMap
für geordnete assoziative Felder.
HashSet
für Mengen
und BTreeSet
für geordnete Mengen.
Ein Wert vom Typ Vec<T>
besteht intern aus drei
Informationen: einem Zeiger auf den aktuellen Puffer, der aktuellen
Länge und der aktuellen Kapazität. Man kann sich das so vorstellen:
struct Vec<T> {ptr: *mut T, len: usize, cap: usize}
Die tatsächliche Formulierung ist noch etwas ausgeklügelter,
besitzt aber die gleiche Struktur. Der Zeiger auf den Puffer
verhält sich zusammen mit der Länge wie ein Slice eines Feldes fester
Länge. Jedoch können sich durch Manipulation des
Feldes alle drei genannten Informationen verändern. Die aktuelle Länge
eines Feldes a
lässt sich via a.len()
auslesen, die aktuelle Kapazität mit a.capacity()
.
Die Kapazität eines Feldes macht eine Aussage darüber, wie viele
Elemente zum leeren Feld hinzugefügt werden können, ohne dass der
Puffer zur Erhöhung der Kapazität realloziert werden muss.
Da dies alles automatisch geschieht, muss man sich darüber aber
keine Gedanken machen. Die Reallokation des Puffers wird
von der Methode push
automatisch aufgerufen, wenn die
Länge gleich der Kapazität ist. Die Kapazität ändert sich normalerweise
in Zweierpotenzen, über den genauen Algorithmus werden jedoch keine
Garantien gemacht, um sich Potential für Optimierungen zu behalten.
Die anfängliche Kapazität darf aber zur Verhinderung der Reallokation
auch manuell festgelegt werden:
let mut a: Vec<i32> = Vec::with_capacity(n);
Ein Feld a: [T; n]
lässt sich interpretieren als
eine Abbildung der Indexmenge auf die Wertemenge:
a: {0, …, n−1} → T.
Ein assoziatives Feld kann man als eine Verallgemeinerung eines Feldes betrachten, die beliebige Indexmengen erlaubt. Die Indizes werden hierbei als Schlüssel bezeichnet und können zunächst einen beliebigen Datentyp haben, solange sie nur unterscheidbar sind.
Für die Unterscheidbarkeit benötigen die Schlüssel eine
Äquivalenzrelation, welche über den Trait Eq
zugänglich
gemacht wird. Für effiziente Umsetzungen sollten die Schlüssel
zudem entweder hashbar sein via Trait Hash
, oder
eine Totalordnung haben via Trait Ord
.
Die Begriffe Äquivalenzrelation (equivalence relation), Halbordnung (partial order) und Totalordnung (total order) sind mathematisch streng definiert. Die Definition der jeweiligen Relation enthält Axiome, die von einer Implementation für einen Datentyp erfüllt werden müssen.
Variadische Funktionen werden in Rust bisweilen nicht unterstützt. Da das Literal für Hashtabellen variadisch ist und die Tupel-Syntax für Schlüsselwert-Paare etwas umständlich ist, behilft man sich am besten mit einem Makro:
macro_rules! hashmap { ($( $key:expr => $value:expr ),*) => {{ let mut _map = HashMap::new(); $(_map.insert($key.into(), $value.into());)* _map }} }
Definition und Anwendung von Hashtabellen gestaltet sich nun wie bei Feldern:
use std::collections::HashMap; fn main() { let m: HashMap<String, i32> = hashmap!{ "x" => 1, "y" => 2 }; println!("{:?}", m); println!("{}", m["x"]); }
So wie a[k]
zum Programmabbruch via panic
führt, falls k
außerhalb von 0..a.len()
liegt, tut es auch m[key]
, falls key
nicht
in m
enthalten ist.
Zusätzlich gibt es auch die Methode m.get(key)
, die
Some(value)
bei Vorhandensein von key
in
m
zurückgibt, sonst None
. Somit ist auch
gestattet:
println!("{:?}", m.get("x"));
Man kann m.get(key)
als Verallgemeinerung von
m[key]
betrachten, da m[key]
im
Wesentlichen das Gleiche bedeutet wie m.get(key).unwrap()
.
Manchmal soll die Äquivalenzrelation zwischen Objekten nicht
über ihren Inhalt definiert sein, sondern über ihre Identifizierung.
Ein erster Ansatz wäre die Speicherung der Identifizierung als
zusätzlicher Eintrag id
. Das kann z. B. eine
Zahl vom Typ u32
sein.
Der Typ zur Identifizierung sei hier Id
genannt.
Man kann einen solchen Typ auch nachträglich als Wrapper-Typ über
jeden beliebigen Objekt-Typ bilden.
Entsprechend ergibt sich nun:
use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::rc::Rc; #[derive(Debug)] struct Id { id: u32, name: Rc<str> } impl Id { fn new(id: u32, name: &str) -> Self { Self {id, name: Rc::from(name)} } } impl Hash for Id { fn hash<H: Hasher>(&self, state: &mut H) { self.id.hash(state); } } impl PartialEq for Id { fn eq(&self, y: &Self) -> bool { self.id == y.id } } impl Eq for Id {} fn main() { let mut m: HashMap<Id, ()> = HashMap::new(); m.insert(Id::new(0, "A"), ()); m.insert(Id::new(1, "A"), ()); println!("{:?}", m); }
Ein wenig problematisch hierbei ist allerdings, dass die Identifizierung manuell festgelegt werden muss. Man bräuchte zusätzlich einen Zähler zur Gewährleistung, dass für jedes Objekt eine einmalige Identifikation erzeugt wird.
Wenn das Objekt aber ohnehin alloziert wird, kann man auch gleich den Zeiger als Speicheradresse betrachten und diese zur Identifizierung heranziehen. Dafür sind lediglich ein paar kleine Modifikationen vorzunehmen:
use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::rc::Rc; #[derive(Debug, Clone)] struct Id { id: Rc<str> } impl Id { fn new(text: &str) -> Self { Self {id: Rc::from(text)} } } impl Hash for Id { fn hash<H: Hasher>(&self, state: &mut H) { (&*self.id as *const str as *const () as usize).hash(state); } } impl PartialEq for Id { fn eq(&self, y: &Self) -> bool { Rc::ptr_eq(&self.id,&y.id) } } impl Eq for Id {} fn main() { let mut m: HashMap<Id, ()> = HashMap::new(); m.insert(Id::new("A"), ()); m.insert(Id::new("A"), ()); println!("{:?}", m); }
Bei der Berechnung des Hashwertes muss man hier aufpassen, dass man
(&*self.id as *const str as *const () as usize).hash(state);anstelle von
(&self.id as *const _ as usize).hash(state);schreibt. Es soll ja nicht die Speicheradresse der Referenz des Zeigers gehasht werden, sondern die Speicheradresse, welche der Zeiger enthält. Dies wird durch das folgende Programm verdeutlicht:
use std::rc::Rc; #[derive(Clone)] struct Id {id: Rc<str>} fn main() { let a = Id {id: Rc::from("A")}; let b = a.clone(); println!("Diese Adressen stimmen nicht überein:"); println!("0x{:x}", &a.id as *const _ as usize); println!("0x{:x}", &b.id as *const _ as usize); println!(); println!("Diese Adressen stimmen überein:"); println!("0x{:x}", &*a.id as *const str as *const () as usize); println!("0x{:x}", &*b.id as *const str as *const () as usize); }
Der Typcast as *const ()
ist übrigens vonnöten, weil
&str
bzw. *const str
ein dicker Zeiger
bestehend aus Datenzeiger und Länge ist. Durch den Typcast wird
die Länge entfernt, es verbleibt ein einfacher Zeiger der sich
in eine Speicheradresse vom Typ usize
umwandeln lässt.
Nun sind Leute beim Programmieren auch noch mit anderen Dingen beschäftigt und würden es dabei gerne vermeiden, ihren mentalen Fokus auf diese Feinheiten zu lenken. Glücklicherweise ist eine Abstraktion dieser Konvertierung erreichbar:
fn rc_as_usize<T: ?Sized>(p: &Rc<T>) -> usize { &**p as *const T as *const () as usize }
Der Trait-Bound ?Sized
drückt hierbei aus, dass
Rc<T>
bzw. &T
auch ein
dicker Zeiger sein kann. Die Variante ohne Unterstützung von
dicken Zeigern ist ein wenig kürzer:
fn rc_as_usize<T>(p: &Rc<T>) -> usize { &**p as *const T as usize }
Eine Warnung noch: Man könnte auf die Idee kommen,
rc_as_usize
zu
fn ptr_as_usize<T: ?Sized>(p: &T) -> usize { p as *const T as *const () as usize }
zu verallgemeinern. Dann ist aber folgendes Programm möglich:
fn main() { let id: Rc<str> = Rc::from("A"); println!("0x{:x}", rc_as_usize(&id)); println!("0x{:x}", ptr_as_usize(&*id)); println!("0x{:x}", ptr_as_usize(&id)); }
Die Ausgabe jagt mir einen Schauer über den Rücken:
0xf3a408 0xf3a408 0xbffe09a8
Ein genauer Blick auf die Typsignatur von ptr_as_usize
bringt die Erklärung. Nämlich bekommt die Funktion beim ersten
Aufruf &T = &str
, beim zweiten jedoch
&T = &Rc<str>
. Diese
gefährliche Subtilität will man eigentlich mittels der
Abstraktion verhindern. Die starke Verallgemeinerung konterkariert
dieses Unterfangen jedoch.
Mengen vom Typ HashSet
sind als Hashtabellen
interpretierbar, deren Schlüssel keine Werte zugeordnet sind.
Eine Menge gibt demnach Antwort auf die Frage, ob ein bestimmter
Schlüssel enthalten ist, enthält aber sonst keine weiteren
Informationen.
Beachtenswert ist, dass Mengen sogar durch Hashtabellen
modellierbar sind. Wir müssen uns dafür an den Typ ()
erinnern, dessen Wertebereich nur aus dem einzigen informationslosen
Wert ()
besteht. Das ist genau das, was wir für die
Werte haben wollen. Demnach ist HashSet<T>
im
Wesentlichen das Gleiche wie HashMap<T,()>
.
Ein Literal definieren wir wieder mittels Makro:
macro_rules! hashset { ($( $key:expr ),*) => {{ let mut _set = HashSet::new(); $(_set.insert($key.into());)* _set }} }
Die Handhabung gestaltet sich wie üblich:
use std::collections::HashSet; fn main() { let m: HashSet<i32> = hashset!{1, 2, 3, 4}; println!("{:?}", m); }
Grundlegende mathematische Relationen und Operationen stehen zur Verfügung.
Bezeichnung | Mathematik | Rust |
---|---|---|
Element-Relation | x∈a | a.contains(&x)
|
Teilmenge-Relation | a⊆b | a.is_subset(&b)
|
Vereinigungsmenge | a∪b | a.union(&b)
|
Schnittmenge | a∩b | a.intersection(&b)
|
Differenzmenge | a\b | a.difference(&b)
|
Symmetrische Differenz | aΔb | a.symmetric_difference(&b)
|
Die Operationen Vereinigung, Schnitt, Differenz und symmetrische Differenz geben hierbei nicht direkt eine Menge zurück, sondern aus Effizienzgründen einen Iterator (nominal verhüllt), aus dem anschließend ein Array oder eine Menge gewonnen werden kann.
let m1: HashSet<i32> = hashset!{1, 2}; let m2: HashSet<i32> = hashset!{2, 3}; let m: HashSet<i32> = m1.union(&m2).cloned().collect();