↑Programmieren in Rust
In dynamischen Programmiersprachen wie Python sind Felder so wie alle Datenstrukturen heterogen. Damit ist gemeint, dass jedes Feld-Element einen unterschiedlichen Typ besitzen darf. Bei der schnellen Zusammenstellung von kleineren Programmen kann diese Flexibilität ausgesprochen praktisch sein. Für jemanden, der nur dynamische Sprachen gewohnt ist, mag das ganz natürlich erscheinen, in Rust müssen wir eine solche Funktionalität jedoch erst konstruieren.
Man könnte nun darauf kommen, den Einwand vorzubringen, dass es mit den Tupeln schon heterogene Felder gäbe. Das ist zwar richtig, führt aber am Ziel vorbei, weil zwei Tupel von unterschiedlichem Typ sind, sobald die Element-Typen oder die Längen nicht mehr übereinstimmen. Stattdessen wollen wir haben, dass zu einem Index des gleichen Feldes Werte unterschiedlichen Typs gespeichert werden können.
Grundsätzlich wird Heterogenität durch Konstruktion von Element-Typen erreicht, die mehrere Typen zusammenfassen. Dieser Ansatz erlaubt es, die gewöhnlichen vorhandenen generischen Datenstrukturen weiterhin zu benutzen.
Ein Ansatz zur Zusammenfassung sind, wie schon bekannt, die Enumerationen. Nachteilig ist hierbei jedoch, dass die in Frage kommenden Element-Typen schon im Voraus bekannt sein müssen.
enum Object { Int(i32), String(String) } impl Object { fn to_string(&self) -> String { match self { Object::Int(x) => format!("{}", x), Object::String(x) => x.clone() } } } fn main() { let v = vec![ Object::String(String::from("Boot")), Object::Int(12) ]; for object in &v { println!("{}", object.to_string()); } }
Was wir bräuchten, wäre eine Art Enumeration von allen beliebigen Typen. Weil die Speichergröße von Typen unbeschränkt hoch sein kann, muss dies ein Typ dynamischer Größe sein. Werte eines solchen Typs sind nur über Zeiger ansprechbar.
Zudem muss eine Schnittstelle der Operationen vorhanden sein,
die alle Typen gemeinsam haben. In diesem Beispiel ist das die
Methode to_string
. Was to_string
tut, ist
für jeden Typ unterschiedlich, nur die Schnittstelle bleibt gleich.
Um dies zu erreichen, bekommt jeder Typ zur Laufzeit eine eigene
Dispatch-Tabelle, etwas schwammig auch Tabelle virtueller
Methoden oder kurz virtual table genannt. Diese Tabelle
enthält Funktionenzeiger auf die gewünschten Operationen. Zum Zugriff
auf die Tabelle bekommt der Wert-Zeiger den Zeiger auf die
Dispatch-Tabelle nachgestellt.
Der Compiler macht dies alles automatisch für uns. Die Schnittstelle
wird als Trait definiert, nennen wir ihn Object
.
Der durch das Schlüsselwort dyn
eingeleitete Operator
ordnet dem Trait den zugehörigen opaken Typ mit dieser Schnittstelle
zu. Weil dyn Object
wie gesagt ein Typ dynamischer
Größe ist, sind die Werte nur über Zeigertypen wie
&dyn Object
oder Box<dyn Object>
ansprechbar. Die Zeiger dieser Typen sind wie gesagt Paare von
Zeigern auf Wert und Dispatch-Tabelle.
trait Object { fn to_string(&self) -> String; } impl Object for i32 { fn to_string(&self) -> String {format!("{}", self)} } impl Object for String { fn to_string(&self) -> String {self.clone()} } fn main() { let v: Vec<Box<dyn Object>> = vec![ Box::new(String::from("Boot")), Box::new(12) ]; for object in &v { println!("{}", object.to_string()); } }
Es ist nicht besonders effizient, Werte von kleinen Typen wie
i32
über Zeiger anzusprechen, geschweige denn, deren
Speicherplätze über Box
auf dem Haldenspeicher zu
allozieren. Ein Ansatz zur Vermeidung von solchen Zeiger-Indirektionen
ist eine Zusammenfügung aus Enumeration und Trait-Objekt.
Das Trait-Objekt wird hierbei als residuale Variante der Enumeration
betrachtet.
trait Interface { fn to_string(&self) -> String; } enum Object { Int(i32), Ref(Box<dyn Interface>) } impl Object { fn to_string(&self) -> String { match self { Object::Int(x) => format!("{}", x), Object::Ref(ref x) => x.to_string() } } } impl Interface for String { fn to_string(&self) -> String {self.clone()} } fn main() { let v: Vec<Object> = vec![ Object::Ref(Box::new(String::from("Boot"))), Object::Int(12) ]; for object in &v { println!("{}", object.to_string()); } }
Die Standardbibliothek stellt den Trait Any
zur
Verfügung und implementiert diesen bereits für alle Typen. Genau
genommen liegt die Implementation nur für Typen ohne Lebenszeiten
außer 'static
vor, aber das ist jetzt erst einmal
nicht so wichtig. Der Zweck von Any
ist die
Typ-Identifikation zur Laufzeit.
Die opaken Zeigertypen wie &dyn Any
und Box<dyn Any>
dürfen daher auf Werte
beliebigen Typs zeigen. Man kann dies als sicheres Äquivalent
zu den aus der Programmiersprache C bekannten void-Zeigern
sehen. Anders als bei void-Zeigern gewährleistet die Typ-Identifikation
einen sicheren Downcast. Ein Downcast vermittelt, von einem allgemeinen
Typ wieder zu einem speziellen zu kommen. Man darf sich das als
Analogon zum Matching einer Enum-Variante vorstellen.
use std::any::Any; struct Duck {name: String} fn main() { let object: &dyn Any = &Duck {name: "Donald".into()}; assert!(object.is::<Duck>()); if let Some(duck) = object.downcast_ref::<Duck>() { println!("{} ist eine Ente.", duck.name); } }
Naive Benutzung von Any
befähigt uns zur Kaputtbrechung
des Typsystems. Zwar wird dadurch die Typsicherheit nicht beschädigt,
da die Typ-Identifikation diese erhält, allerdings ist das Resultat
ein zur Kompilierzeit teilweise untypisiertes Programm. Bspw. könnten
wir das heterogene Feld letztendlich auch wie folgt konstruieren.
use std::any::Any; fn object_to_string(x: &dyn Any) -> String { if let Some(s) = x.downcast_ref::<String>() { s.clone() } else if let Some(n) = x.downcast_ref::<i32>() { format!("{}", n) } else { unimplemented!() } } fn main() { let v: Vec<Box<dyn Any>> = vec![ Box::new(String::from("Boot")), Box::new(12) ]; for object in &v { println!("{}", object_to_string(object.as_ref())); } }
Die Konvertierung object.as_ref()
ist hier aus einem
subtilen Grund notwendig. Nämlich besitzt
Box<dyn Any>
selbst den Any
-Trait.
Die Variable object
bezieht sich auf diesen Typ,
und somit auch x
. Folgendes Programm zeigt ausführlich
die Unterschiede:
use std::any::Any; struct Duck {} fn main() { let object: Box<dyn Any> = Box::new(Duck {}); let object = &object; let x: &dyn Any = object; assert!(x.is::<Box<dyn Any>>()); let x: &dyn Any = object.as_ref(); assert!(x.is::<Duck>()); let x: &dyn Any = &**object; assert!(x.is::<Duck>()); }
Einen Downcast möchte man nun für beliebige Trait-Objekte
zur Verfügung haben, nicht bloß für dyn Any
.
Um dies zu erreichen, macht man sich genau den durch Any
zur Verfügung gestellten Mechanismus zunutze. Hierfür bedarf es
lediglich eines weiteren Upcasts nach dyn Any
.
Der direkte Upcast von Trait-Objekt-Typ zu Trait-Objekt-Typ
ist allerdings nicht möglich. Um dennoch zum Ziel zu gelangen,
müssen wir einen kleinen Umweg gehen. Bei Trait-Methoden besitzt
das self-Argument jeweils den ursprünglichen Typ. Daher dürfen wir
eine Methode zum Upcast schreiben, nennen wir sie as_any
.
Dies wollen wir erreichen:
Diesen Umweg müssen wir gehen:
Die Implementation gestaltet sich so:
use std::any::Any; trait Object { fn as_any(&self) -> &dyn Any; } struct Duck {name: String} impl Object for Duck { fn as_any(&self) -> &dyn Any {self} } fn main() { let object: &dyn Object = &Duck {name: "Donald".into()}; assert!(object.as_any().is::<Duck>()); if let Some(duck) = object.as_any().downcast_ref::<Duck>() { println!("{} ist eine Ente.", duck.name); } }
Man kann as_any
auch gleich für alle Typen mit Trait
Any
implementieren:
use std::any::Any; trait AsAny { fn as_any(&self) -> &dyn Any; } impl<T: Any> AsAny for T { fn as_any(&self) -> &dyn Any {self} } trait Object: AsAny {} struct Duck {name: String} impl Object for Duck {} fn main() { let object: Box<dyn Object> = Box::new(Duck { name: "Donald".into() }); assert!((&*object).as_any().is::<Duck>()); if let Some(duck) = (&*object).as_any().downcast_ref::<Duck>() { println!("{} ist eine Ente.", duck.name); } }
Hier muss man allerdings vorsichtig sein, denn nun ist zwingend
(&*object).as_any()
anstelle von object.as_any()
zu schreiben. Das muss man machen, weil as_any
als Nebeneffekt nun auch für Box<dyn Object>
vorliegt. Um diese Problematik aufzuzeigen, wurde Box
in das Beispiel eingebracht.
Dieses Problem besteht aber nur, solange AsAny
eingebunden ist. Das wissen wir zu verhindern, indem
AsAny
durch ein Modul verhüllt wird:
mod as_any { use std::any::Any; pub trait AsAny { fn as_any(&self) -> &dyn Any; } impl<T: Any> AsAny for T { fn as_any(&self) -> &dyn Any {self} } } trait Object: as_any::AsAny {}
Zum besseren Verständnis von Trait-Objekten wird im Folgenden
ein Nachbau des Dispatch-Mechanismus gezeigt. Der Typ
Box<dyn Object>
ist als Struktur
BoxDynObject
modelliert. Diese besteht aus zwei Zeigern,
dem untypisierten Zeiger data
auf die Daten und
dem Zeiger dispatch
auf die jeweilige Dispatch-Tabelle.
Für den untypisierten Zeiger dient zunächst
Box<dyn Any>
als Hilfsmittel. Dies gestattet
die Konstruktion des Dispatch-Mechanismus ohne Rückgriff auf
unsichere Mittel.
use std::any::Any; struct Dispatch { to_string: fn(pself: &dyn Any) -> String } struct BoxDynObject { data: Box<dyn Any>, dispatch: &'static Dispatch } impl BoxDynObject { fn to_string(&self) -> String { (self.dispatch.to_string)(&*self.data) } } fn to_string_i32(pself: &dyn Any) -> String { let pself = pself.downcast_ref::<i32>().unwrap(); format!("{}", pself) } fn to_string_string(pself: &dyn Any) -> String { let pself = pself.downcast_ref::<String>().unwrap(); pself.clone() } static DISPATCH_I32: Dispatch = Dispatch { to_string: to_string_i32 }; static DISPATCH_STRING: Dispatch = Dispatch { to_string: to_string_string }; impl From<i32> for BoxDynObject { fn from(x: i32) -> Self { BoxDynObject { data: Box::new(x), dispatch: &DISPATCH_I32 } } } impl From<&str> for BoxDynObject { fn from(x: &str) -> Self { BoxDynObject { data: Box::new(String::from(x)), dispatch: &DISPATCH_STRING } } } fn main() { let v: Vec<BoxDynObject> = vec!["Boot".into(), 12.into()]; for object in &v { println!("{}", object.to_string()); } }
Wir verlassen nun die sicheren Gefilde und tauschen
Box<dyn Any>
gegen *mut u8
aus.
Als Nebeneffekt kommt hinzu, dass wir uns nun selbst um
die Destruktoraufrufe kümmern müssen. Da der Destruktor
drop
selbst von Typ zu Typ unterschiedlich ausfällt,
muss auch dieser als Methode in der Dispatch-Tabelle vorkommen.
struct Dispatch { to_string: fn(pself: *const u8) -> String, drop: fn(pself: *mut u8) } struct BoxDynObject { data: *mut u8, dispatch: &'static Dispatch } impl BoxDynObject { fn to_string(&self) -> String { (self.dispatch.to_string)(self.data) } } fn to_string_i32(pself: *const u8) -> String { let pself = pself as *const i32; format!("{}", unsafe {&*pself}) } fn to_string_string(pself: *const u8) -> String { let pself = pself as *const String; unsafe {&*pself}.clone() } fn drop_i32(pself: *mut u8) { drop(unsafe {Box::from_raw(pself as *mut i32)}); } fn drop_string(pself: *mut u8) { drop(unsafe {Box::from_raw(pself as *mut String)}); } static DISPATCH_I32: Dispatch = Dispatch { to_string: to_string_i32, drop: drop_i32 }; static DISPATCH_STRING: Dispatch = Dispatch { to_string: to_string_string, drop: drop_string }; impl Drop for BoxDynObject { fn drop(&mut self) { (self.dispatch.drop)(self.data) } } impl From<i32> for BoxDynObject { fn from(x: i32) -> Self { BoxDynObject { data: Box::into_raw(Box::new(x)) as *mut u8, dispatch: &DISPATCH_I32 } } } impl From<&str> for BoxDynObject { fn from(x: &str) -> Self { let data = Box::new(String::from(x)); BoxDynObject { data: Box::into_raw(data) as *mut u8, dispatch: &DISPATCH_STRING } } } fn main() { let v: Vec<BoxDynObject> = vec!["Boot".into(), 12.into()]; for object in &v { println!("{}", object.to_string()); } }