Programmieren in Rust

Trait-Objekte

Inhaltsverzeichnis

  1. Laufzeit-Polymorphie
    1. Heterogene Felder
    2. Downcasts
  2. Technische Umsetzung
    1. Dispatch-Tabellen

Laufzeit-Polymorphie

Heterogene Felder

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.

Realisierung mit Enumerationen

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());
    }
}

Realisierung mit Trait-Objekten

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());
    }
}

Gemischte Realisierung

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());
    }
}

Downcasts

Der Trait Any

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>());
}

Benutzung von Any für Downcasts

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 {}

Technische Umsetzung

Dispatch-Tabellen

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());
    }
}

Referenzen

  1. »How to get a struct reference from a boxed trait?«. In: StackOverflow (rust, traits) (13. November 2015).