In diesem Artikel werden wir uns über die Programmiersprache Rust und insbesondere über Merkmalsobjekte lustig machen.
Als ich Rust kennenlernte, erschien mir eines der Details der Implementierung von Typobjekten interessant. Die Tatsache, dass sich die virtuelle Funktionstabelle nicht in den Daten selbst befindet, sondern im "dicken" Zeiger darauf. Jeder Zeiger auf ein Typobjekt enthält einen Zeiger auf die Daten selbst sowie eine Verknüpfung zu einer virtuellen Tabelle, in der sich die Adressen von Funktionen befinden, die dieses Typobjekt für eine bestimmte Struktur implementieren (da dies jedoch ein Implementierungsdetail ist, kann sich das Verhalten ändern.
Beginnen wir mit einem einfachen Beispiel, das dicke Zeiger demonstriert. Der folgende Code wird auf der 64-Bit-Architektur 8 und 16 ausgegeben:
fn main () { let v: &String = &"hello".into(); let disp: &std::fmt::Display = v; println!(" : {}", std::mem::size_of_val(&v)); println!(" -: {}", std::mem::size_of_val(&disp)); }
Warum ist das interessant? Als ich mich mit Enterprise Java beschäftigte, war eine der Aufgaben, die ziemlich regelmäßig auftraten, die Anpassung vorhandener Objekte an bestimmte Schnittstellen. Das heißt, das Objekt ist bereits vorhanden und wird als Link ausgegeben. Es muss jedoch an die angegebene Schnittstelle angepasst werden. Und Sie können das Eingabeobjekt nicht ändern, es ist das, was es ist.
Ich musste so etwas tun:
Person adapt(Json value) {
Bei diesem Ansatz gab es verschiedene Probleme. Wenn sich beispielsweise dasselbe Objekt zweimal "anpasst", erhalten wir zwei verschiedene Person
(aus Sicht des Linkvergleichs). Und die Tatsache, dass Sie jedes Mal neue Objekte erstellen müssen, ist irgendwie hässlich.
Als ich in Rust Typobjekte sah, kam mir die Idee, dass es in Rust viel eleganter gemacht werden könnte! Sie können den Daten auch eine andere virtuelle Tabelle zuweisen und ein neues Merkmalsobjekt erhalten! Und weisen Sie nicht jeder Instanz Speicher zu. Gleichzeitig bleibt die gesamte Logik des "Ausleihens" bestehen - unsere Anpassungsfunktion sieht aus wie fn adapt<'a>(value: &'a Json) -> &'a Person
(das heißt, wir leihen uns aus) Quelldaten).
Darüber hinaus können Sie denselben Typ (z. B. String
) zwingen, unser Typobjekt mehrmals mit unterschiedlichem Verhalten zu implementieren. Warum? Aber Sie wissen nie, was im Unternehmen benötigt werden kann ?!
Versuchen wir dies umzusetzen.
Erklärung des Problems
Wir setzen die Aufgabe folgendermaßen: Erstellen Sie die annotate
Funktion, die dem regulären String
Typ das folgende annotate
"zuweist":
trait Object { fn type_name(&self) -> &str; fn as_string(&self) -> &String; }
Und die annotate
Funktion selbst:
Schreiben wir gleich einen Test. Stellen Sie zunächst sicher, dass der "zugewiesene" Typ mit dem erwarteten übereinstimmt. Zweitens stellen wir sicher, dass wir die ursprüngliche Linie erhalten können und es sich um dieselbe Linie handelt (aus Sicht der Zeiger):
#[test] fn test() { let input: String = "hello".into(); let annotated1 = annotate(&input, "Widget"); let annotated2 = annotate(&input, "Gadget");
Ansatz Nummer 1: und nach uns zumindest eine Flut!
Versuchen wir zunächst, eine völlig naive Implementierung durchzuführen. Wickeln Sie unsere Daten einfach in einen "Wrapper", der zusätzlich type_name
enthält:
struct Wrapper<'a> { value: &'a String, type_name: String, } impl<'a> Object for Wrapper<'a> { fn type_name(&self) -> &str { &self.type_name } fn as_string(&self) -> &String { self.value } }
Noch nichts Besonderes. Alles ist wie in Java. Aber wir haben keinen Müllsammler. Wo werden wir diesen Wrapper aufbewahren? Wir müssen den Link zurückgeben, damit er nach dem Aufrufen der annotate
Funktion gültig bleibt. Wir werden etwas Unheimliches in die Box
legen, damit der Wrapper
auf dem Haufen hervorgehoben wird. Und dann werden wir den Link dazu zurückgeben. Und damit der Wrapper nach dem Aufrufen der annotate
Funktion am Leben bleibt, "lecken" wir diese Box:
fn annotate<'a>(input: &'a String, type_name: &str) -> &'a dyn Object { let b = Box::new(Wrapper { value: input, type_name: type_name.into(), }); Box::leak(b) }
... und der Test besteht!
Dies ist jedoch eine zweifelhafte Entscheidung. Wir weisen nicht nur immer noch Speicher für jede "Annotation" zu, so dass der Speicher Box::leak
( Box::leak
gibt einen Link zu den auf dem Heap gespeicherten Daten zurück, sondern "vergisst" gleichzeitig die Box selbst, dh es erfolgt keine automatische Freigabe )
Ansatz 2: Arena!
Lassen Sie uns zunächst versuchen, diese Wrapper irgendwo zu speichern, damit sie dennoch irgendwann freigegeben werden. Gleichzeitig bleibt jedoch die annotate
unverändert. Das heißt, die Rückgabe eines Links mit Referenzzählung (z. B. Rc<Wrapper>
) funktioniert nicht.
Die einfachste Möglichkeit besteht darin, eine Hilfsstruktur zu erstellen, ein "Typsystem", das für die Speicherung dieser Wrapper verantwortlich ist. Und wenn wir fertig sind, werden wir diese Struktur und alle Wrapper damit freigeben.
Irgendwie so. Die typed-arena
Bibliothek wird zum Speichern von Wrappern verwendet, aber Sie könnten mit dem Vec<Box<Wrapper>>
auskommen. Die Hauptsache ist, sicherzustellen, dass sich Wrapper
nirgendwo bewegt (in Night Rust können Sie die Pin-API dafür verwenden):
struct TypeSystem { wrappers: typed_arena::Arena<Wrapper>, } impl TypeSystem { pub fn new() -> Self { Self { wrappers: typed_arena::Arena::new(), } }
Aber wohin ging der Parameter, der für die Lebensdauer des Links für den Wrapper
Typ verantwortlich ist? Wir mussten es loswerden, da wir dem Typ typed_arena::Arena<Wrapper<'?>>
feste Lebensdauer nicht typed_arena::Arena<Wrapper<'?>>
. Jeder Wrapper hat je nach input
einen eindeutigen Parameter!
Stattdessen streuen wir etwas unsicheren Rost darüber, um den Parameter für die Lebensdauer zu entfernen:
struct Wrapper { value: *const String, type_name: String, } impl Object for Wrapper { fn type_name(&self) -> &str { &self.type_name }
Und die Tests bestehen erneut und geben uns Vertrauen in die Richtigkeit der Entscheidung. Zusätzlich dazu, dass Sie sich mit unsafe
Material unwohl fühlen (wie es sein sollte, ist es besser, nicht mit unsicherem Rost zu scherzen!).
Was ist jedoch mit der versprochenen Option, für die keine zusätzlichen Speicherzuweisungen für Wrapper erforderlich sind?
Ansatz 3: Lass die Tore der Hölle öffnen
Idee. Für jeden eindeutigen "Typ" ("Widget", "Gadget") erstellen wir eine virtuelle Tabelle. Hände während der Ausführung des Programms. Und wir weisen es dem Link zu, der uns von den Daten selbst gegeben wird (was, wie wir uns erinnern, einfach String
).
Zunächst eine kurze Beschreibung dessen, was wir bekommen müssen. Wie ist ein Verweis auf ein Typobjekt angeordnet? Tatsächlich sind dies nur zwei Zeiger, einer auf die Daten selbst und der andere auf die virtuelle Tabelle. Also schreiben wir:
#[repr(C)] struct TraitObject { pub data: *const (), pub vtable: *const (), }
( #[repr(C)]
wir müssen den korrekten Speicherort im Speicher garantieren).
Es scheint, dass alles einfach ist, wir werden eine neue Tabelle für die angegebenen Parameter generieren und einen Link zum Typobjekt "sammeln"! Aber woraus besteht diese Tabelle?
Die richtige Antwort auf diese Frage wäre "Dies ist ein Implementierungsdetail." Aber wir werden es tun; Erstellen Sie eine rust-toolchain
Datei im Stammverzeichnis unseres Projekts und schreiben Sie sie dort: nightly-2018-12-01
. Eine feste Baugruppe kann doch als stabil angesehen werden, oder?
Nachdem wir die Rust-Version repariert haben (tatsächlich benötigen wir die nächtliche Assembly für eine der folgenden Bibliotheken).
Nach einiger Suche im Internet stellen wir fest, dass das Tabellenformat einfach ist: Zuerst gibt es eine Verknüpfung zum Destruktor, dann zwei Felder, die der Speicherzuweisung zugeordnet sind (Typgröße und Ausrichtung), und dann werden Funktionen nacheinander ausgeführt (die Reihenfolge liegt im Ermessen des Compilers, aber wir haben nur zwei Funktionen, daher ist die Wahrscheinlichkeit des Raten ziemlich hoch (50%).
Also schreiben wir:
#[repr(C)] #[derive(Clone, Copy)] struct VirtualTableHeader { destructor_fn: fn(*mut ()), size: usize, align: usize, } #[repr(C)] struct ObjectVirtualTable { header: VirtualTableHeader, type_name_fn: fn(*const String) -> *const str, as_string_fn: fn(*const String) -> *const String, }
In ähnlicher Weise wird #[repr(C)]
benötigt, um die korrekte Position im Speicher zu gewährleisten. Ich habe mich in zwei Strukturen aufgeteilt, wenig später wird es uns nützlich sein.
Versuchen wir nun, unser Typsystem zu schreiben, das die annotate
Funktion bereitstellt. Wir müssen die generierten Tabellen zwischenspeichern, also holen wir uns den Cache:
struct TypeInfo { vtable: ObjectVirtualTable, } #[derive(Default)] struct TypeSystem { infos: RefCell<HashMap<String, TypeInfo>>, }
Wir verwenden den internen Status von RefCell
damit unsere TypeSystem::annotate
Funktion &self
als gemeinsamen Link empfangen kann. Dies ist wichtig, da wir uns von TypeSystem
„ausleihen“, um sicherzustellen, dass die von uns generierten virtuellen Tabellen länger leben als der Verweis auf das TypeSystem
, das wir von annotate
.
Da wir in der Lage sein möchten, viele Instanzen mit Anmerkungen zu versehen, können wir uns nicht als veränderbare Verbindung ausleihen &mut self
.
Und wir skizzieren diesen Code:
impl TypeSystem { pub fn annotate<'a: 'b, 'b>( &'a self, input: &'b String, type_name: &str ) -> &'b dyn Object { let type_name = type_name.to_string(); let mut infos = self.infos.borrow_mut(); let imp = infos.entry(type_name).or_insert_with(|| unsafe {
Woher bekommen wir diesen Tisch? Die ersten drei Einträge stimmen mit den Einträgen für jede andere virtuelle Tabelle für den angegebenen Typ überein. Nehmen Sie sie daher einfach und kopieren Sie sie. Lassen Sie uns zuerst diesen Typ erhalten:
trait Whatever {} impl<T> Whatever for T {}
Es ist nützlich für uns, diese "jede andere virtuelle Tabelle" zu erhalten. Und dann kopieren wir diese drei Einträge von ihm:
let whatever = input as &dyn Whatever; let whatever_obj = std::mem::transmute::<&dyn Whatever, TraitObject>(whatever); let whatever_vtable_header = whatever_obj.vtable as *const VirtualTableHeader; let vtable = ObjectVirtualTable {
Im Prinzip könnten wir die Größe und Ausrichtung durch std::mem::size_of::<String>()
und std::mem::align_of::<String>()
. Aber wo sonst kann der Destruktor "gestohlen" werden, weiß ich nicht.
Ok, aber woher bekommen wir die Adressen dieser Funktionen, type_name_fn
und as_string_fn
? Möglicherweise stellen Sie fest, dass as_string_fn
im Allgemeinen nicht benötigt wird. Der Datenzeiger wird immer als erster Datensatz in der Darstellung des as_string_fn
. Das heißt, diese Funktion ist immer dieselbe:
impl Object for String {
Aber mit der zweiten Funktion ist es nicht so einfach! Es hängt auch von unserem Namen "Typ", type_name
.
Es spielt keine Rolle, wir können diese Funktion nur zur Laufzeit generieren. Nehmen wir die dynasm
Bibliothek dafür (im Moment erfordert es Rust Nightly Build). Lesen Sie über
Funktionsaufrufkonventionen .
Nehmen wir zur Vereinfachung an, wir interessieren uns nur für Mac OS und Linux (nach all diesen lustigen Transformationen stört uns die Kompatibilität nicht mehr wirklich, oder?). Und natürlich ausschließlich x86-64.
Die zweite Funktion, as_string
, ist einfach zu implementieren. Wir werden versprochen, dass der erste Parameter im RDI
Register sein wird. Und geben Sie den Wert an RAX
. Das heißt, der Funktionscode lautet ungefähr so:
dynasm!(ops ; mov rax, rdi ; ret );
Die erste Funktion ist jedoch etwas kniffliger. Zuerst müssen wir &str
, was ein dicker Zeiger ist. Der erste Teil ist ein Zeiger auf eine Zeichenfolge, und der zweite Teil ist die Länge des Zeichenfolgenabschnitts. Glücklicherweise können Sie mit der obigen Konvention 128-Bit-Ergebnisse mithilfe des EDX
Registers für den zweiten Teil zurückgeben.
Es bleibt noch irgendwo ein Link zu einem String-Slice, der unseren String- type_name
enthält. Wir möchten uns nicht auf type_name
(obwohl wir durch lebenslange Anmerkungen garantieren können, dass type_name
länger als der zurückgegebene Wert lebt).
Aber wir haben eine Kopie dieser Zeile, die wir in die Hash-Tabelle einfügen. Wenn wir die Daumen String::as_str
, gehen wir davon aus, dass sich die Position des String-Slice, den String::as_str
nicht String::as_str
, nicht durch Verschieben des String::as_str
String
ändert (und String
wird sich beim Ändern der Größe der HashMap
in der dieser String vom Schlüssel gespeichert wird). Ich weiß nicht, ob die Standardbibliothek dieses Verhalten garantiert, aber spielen wir es einfach?
Wir bekommen die notwendigen Komponenten:
let type_name_ptr = type_name.as_str().as_ptr(); let type_name_len = type_name.as_str().len();
Und schreibe diese Funktion:
dynasm!(ops ; mov rax, QWORD type_name_ptr as i64 ; mov rdx, QWORD type_name_len as i64 ; ret );
Und schließlich der letzte annotate
Code:
pub fn annotate<'a: 'b, 'b>(&'a self, input: &'b String, type_name: &str) -> &'b Object { let type_name = type_name.to_string();
Für dynasm
wir auch das buffer
zu unserer TypeInfo
Struktur TypeInfo
. Dieses Feld steuert den Speicher, in dem der Code unserer generierten Funktionen gespeichert ist:
#[allow(unused)] buffer: dynasmrt::ExecutableBuffer,
Und alle Tests bestehen!
Fertig, Meister!
So einfach und natürlich können Sie Ihre eigene Implementierung von Typobjekten in Rust-Code generieren!
Die letztere Lösung stützt sich aktiv auf Implementierungsdetails und wird daher nicht zur Verwendung empfohlen. Aber in Wirklichkeit muss man tun, was man muss. Verzweifelte Zeiten erfordern verzweifelte Maßnahmen!
Es gibt jedoch eine (mehrere) Funktion, auf die ich mich hier verlasse. Das heißt, dass es sicher ist, den von der Tabelle virtuell belegten Speicher freizugeben, nachdem keine Verweise auf das Typobjekt vorhanden sind, das sie verwendet. Einerseits ist es logisch, dass Sie eine virtuelle Tabelle nur durch Verweise auf Typobjekte verwenden können. Auf der anderen Seite haben von Rust bereitgestellte Tabellen eine 'static
Lebensdauer. Es ist durchaus möglich, einen Code anzunehmen, der die Tabelle für einige ihrer Zwecke vom Link trennt (Sie wissen zum Beispiel für einige seiner schmutzigen Tricks nie ).
Quellcode finden Sie hier .