Génération de types de personnages à la volée (ou fous avec Rust)

Dans cet article, nous allons jouer un peu avec le langage de programmation Rust, et en particulier avec les objets type.


Lorsque j'ai fait la connaissance de Rust, l'un des détails de l'implémentation des objets type m'a semblé intéressant. À savoir, le fait que la table virtuelle des fonctions n'est pas dans les données elles-mêmes, mais dans le pointeur "épais" vers elles. Chaque pointeur vers un objet type) contient un pointeur vers les données elles-mêmes, ainsi qu'un lien vers une table virtuelle où seront localisées les adresses des fonctions qui implémentent cet objet type pour une structure donnée (mais comme il s'agit d'un détail d'implémentation, le comportement peut changer.


Commençons par un exemple simple montrant des pointeurs épais. Le code suivant sortira sur une architecture 64 bits 8 et 16:


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

Pourquoi est-ce intéressant? Lorsque j'étais engagé dans Java d'entreprise, une des tâches qui se posait assez régulièrement était l'adaptation d'objets existants à des interfaces données. Autrement dit, l'objet existe déjà, émis sous forme de lien, mais il doit être adapté à l'interface spécifiée. Et vous ne pouvez pas changer l'objet d'entrée, c'est ce que c'est.


Je devais faire quelque chose comme ça:


 Person adapt(Json value) { // ...- , , ,  "value"  //   Person return new PersonJsonAdapter(value); } 

Il y avait divers problèmes avec cette approche. Par exemple, si le même objet "s'adapte" deux fois, alors nous obtenons deux Person différentes (du point de vue de la comparaison des liens). Et le fait même que vous devez créer de nouveaux objets à chaque fois est en quelque sorte moche.


Quand j'ai vu des objets de type dans Rust, j'ai eu l'idée que dans Rust ça pouvait être fait beaucoup plus élégamment! Vous pouvez également prendre et affecter une autre table virtuelle aux données et obtenir un nouvel objet trait! Et n'allouez pas de mémoire pour chaque instance. Dans le même temps, toute la logique de «l'emprunt» reste en place - notre fonction d'adaptation ressemblera à quelque chose comme fn adapt<'a>(value: &'a Json) -> &'a Person (c'est-à-dire que nous empruntons en quelque sorte à données source).


Plus encore, vous pouvez «forcer» le même type (par exemple, String ) à implémenter notre objet type plusieurs fois, avec un comportement différent. Pourquoi? Mais vous ne savez jamais ce qui peut être nécessaire dans l'entreprise?!


Essayons d'implémenter cela.


Énoncé du problème


Nous définissons la tâche de cette façon: créer la fonction annotate , qui "assigne" l'objet type suivant au type String normal:


 trait Object { fn type_name(&self) -> &str; fn as_string(&self) -> &String; } 

Et la fonction d' annotate elle-même:


 ///    - `Object`,   , ///   "" -- ,    `type_name`. fn annotate<'a>(input: &'a String, type_name: &str) -> &'a dyn Object { // ... } 

Écrivons un test tout de suite. Tout d'abord, assurez-vous que le type "attribué" correspond à celui attendu. Deuxièmement, nous nous assurerons que nous pouvons obtenir la ligne d'origine et ce sera la même ligne (du point de vue des pointeurs):


 #[test] fn test() { let input: String = "hello".into(); let annotated1 = annotate(&input, "Widget"); let annotated2 = annotate(&input, "Gadget"); // -   ,    assert_eq!("Widget", annotated1.type_name()); assert_eq!("Gadget", annotated2.type_name()); let unwrapped1 = annotated1.as_string(); let unwrapped2 = annotated2.as_string(); //       --   assert_eq!(unwrapped1 as *const String, &input as *const String); assert_eq!(unwrapped2 as *const String, &input as *const String); } 

Approche numéro 1: et après nous au moins une inondation!


Tout d'abord, essayons de faire une implémentation complètement naïve. Enveloppez simplement nos données dans un "wrapper", qui contiendra en plus type_name :


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

Rien de spécial pour le moment. Tout est comme en Java. Mais nous n'avons pas de ramasse-miettes, où allons-nous entreposer cet emballage? Nous devons renvoyer le lien, afin qu'il reste valide après avoir appelé la fonction annotate . Nous allons mettre quelque chose d'effrayant dans la Box pour que le Wrapper mis en évidence sur le tas. Et puis nous lui retournerons le lien. Et pour que l'encapsuleur reste vivant après avoir appelé la fonction annotate , nous allons "fuir" cette case:


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

... et le test passe!


Mais c'est une décision douteuse. Non seulement nous allouons toujours de la mémoire à chaque "annotation", donc la mémoire fuit ( Box::leak renvoie un lien vers les données stockées sur le tas, mais en même temps "oublie" la boîte elle-même, c'est-à-dire qu'il n'y aura pas de libération automatique )


Approche 2: Arena!


Pour commencer, essayons de sauvegarder ces wrappers quelque part afin qu'ils soient néanmoins publiés à un moment donné. Mais en même temps, conserver la signature annotate telle quelle. Autrement dit, le renvoi d'un lien avec le comptage de références (par exemple, Rc<Wrapper> ) ne fonctionne pas.


L'option la plus simple est de créer une structure auxiliaire, un "système de type", qui sera chargée de stocker ces wrappers. Et lorsque nous aurons terminé, nous publierons cette structure et tous les emballages avec.


Quelque chose comme ça. La bibliothèque typed-arena est utilisée pour stocker les wrappers, mais vous pouvez vous en tirer avec le type Vec<Box<Wrapper>> , l'essentiel est de garantir que Wrapper ne se déplace nulle part (dans Rust la nuit, vous pouvez utiliser l' API pin pour cela):


 struct TypeSystem { wrappers: typed_arena::Arena<Wrapper>, } impl TypeSystem { pub fn new() -> Self { Self { wrappers: typed_arena::Arena::new(), } } ///     `input`,      , ///    (  ,    , ///        )! pub fn annotate<'a: 'b, 'b>( &'a self, input: &'b String, type_name: &str ) -> &'b dyn Object { self.wrappers.alloc(Wrapper { value: input, type_name: type_name.into(), }) } } 

Mais où est passé le paramètre responsable de la durée de vie du lien pour le type Wrapper ? Nous avons dû nous en débarrasser, car nous ne pouvons pas attribuer une durée de vie fixe dans le type typed_arena::Arena<Wrapper<'?>> . Chaque wrapper a un paramètre unique, selon l' input !


Au lieu de cela, nous saupoudrons un peu de rouille dangereuse pour nous débarrasser du paramètre de durée de vie:


 struct Wrapper { value: *const String, type_name: String, } impl Object for Wrapper { fn type_name(&self) -> &str { &self.type_name } ///   -- ,     (  /// `annotate`),     (    - /// `&Object`)  ,      (`String`). fn as_string(&self) -> &String { unsafe { &*self.value } } } 

Et les tests passent à nouveau, nous donnant ainsi confiance dans la justesse de la décision. En plus de se sentir mal à l'aise avec la unsafe (comme il se doit, il vaut mieux ne pas plaisanter avec la rouille dangereuse!).


Mais encore, qu'en est-il de l'option promise, qui ne nécessite pas d'allocations de mémoire supplémentaires pour les wrappers?


Approche n ° 3: laissez les portes de l'enfer s'ouvrir


Idée. Pour chaque "type" unique ("Widget", "Gadget"), nous allons créer une table virtuelle. Mains pendant l'exécution du programme. Et nous l'attribuons au lien qui nous est donné par les données elles-mêmes (qui, rappelons-le, est simplement String ).


Tout d'abord, une courte description de ce que nous devons obtenir. Donc, une référence à un objet type, comment est-il organisé? En fait, ce ne sont que deux pointeurs, l'un vers les données elles-mêmes et l'autre vers la table virtuelle. Nous écrivons donc:


 #[repr(C)] struct TraitObject { pub data: *const (), pub vtable: *const (), } 

( #[repr(C)] nous devons garantir l'emplacement correct dans la mémoire).


Il semble que tout soit simple, nous allons générer une nouvelle table pour les paramètres donnés et "collecter" un lien vers l'objet type! Mais en quoi consiste ce tableau?


La bonne réponse à cette question serait "ceci est un détail d'implémentation". Mais nous le ferons; créez un fichier rust-toolchain à la racine de notre projet et écrivez-le: nightly-2018-12-01 . Après tout, un ensemble fixe peut être considéré comme stable, non?


Maintenant que nous avons corrigé la version Rust (en fait, nous aurons besoin de l'assemblage nocturne pour l'une des bibliothèques ci-dessous).


Après quelques recherches sur Internet , on découvre que le format du tableau est simple: il y a d'abord un lien vers le destructeur, puis deux champs associés à l'allocation de mémoire (taille de type et alignement), puis les fonctions se succèdent (l'ordre est à la discrétion du compilateur, mais nous avons seulement deux fonctions, donc la probabilité de deviner est assez élevée, 50%).


Nous écrivons donc:


 #[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, } 

De même, #[repr(C)] nécessaire pour garantir l'emplacement correct en mémoire. Je me suis divisé en deux structures, un peu plus tard il nous sera utile.


Essayons maintenant d'écrire notre système de types, qui fournira la fonction annotate . Nous aurons besoin de mettre en cache les tables générées, alors récupérons le cache:


 struct TypeInfo { vtable: ObjectVirtualTable, } #[derive(Default)] struct TypeSystem { infos: RefCell<HashMap<String, TypeInfo>>, } 

Nous utilisons l'état interne de RefCell pour que notre fonction TypeSystem::annotate puisse recevoir &self tant que lien partagé. Ceci est important, car nous «empruntons» à TypeSystem pour nous assurer que les tables virtuelles que nous avons générées vivent plus longtemps que la référence à l'objet type que nous renvoyons d' annotate .


Puisque nous voulons être en mesure d'annoter de nombreuses instances, nous ne pouvons pas emprunter &mut self comme un lien mutable.


Et nous allons esquisser ce 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 { //    ,  ? let vtable = unimplemented!(); TypeInfo { vtable } }); let object_obj = TraitObject { data: input as *const String as *const (), vtable: &imp.vtable as *const ObjectVirtualTable as *const (), }; //       - unsafe { std::mem::transmute::<TraitObject, &dyn Object>(object_obj) } } } 

D'où obtient-on ce tableau? Les trois premières entrées de celle-ci correspondront aux entrées de toute autre table virtuelle pour le type spécifié. Par conséquent, prenez-les et copiez-les. D'abord, obtenons ce type:


 trait Whatever {} impl<T> Whatever for T {} 

Il nous est utile d’obtenir cette «toute autre table virtuelle». Et puis, nous copions ces trois entrées de lui:


 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 { //  ! header: *whatever_vtable_header, type_name_fn: unimplemented!(), as_string_fn: unimplemented!(), }; TypeInfo { vtable } 

Fondamentalement, nous pourrions obtenir la taille et l'alignement via std::mem::size_of::<String>() et std::mem::align_of::<String>() . Mais d'où peut-on "voler" le destructeur, je ne sais pas.


D'accord, mais où obtient-on les adresses de ces fonctions, as_string_fn et as_string_fn ? Vous pouvez remarquer que as_string_fn n'est généralement pas nécessaire, le pointeur de données est toujours le premier enregistrement dans la représentation de l'objet type. Autrement dit, cette fonction est toujours la même:


 impl Object for String { // ... fn as_string(&self) -> String { self } } 

Mais avec la deuxième fonction, ce n'est pas si facile! Cela dépend aussi de notre nom "type", type_name .


Peu importe, nous pouvons simplement générer cette fonction lors de l'exécution. Prenons la bibliothèque dynasm pour cela (pour le moment, elle nécessite une construction nocturne de Rust). Lisez
conventions d'appel de fonction .


Par souci de simplicité, supposons que nous ne nous intéressions qu'à Mac OS et Linux (après toutes ces transformations amusantes, la compatibilité ne nous dérange plus vraiment, non?). Et, oui, exclusivement x86-64, bien sûr.


La deuxième fonction, as_string , est facile à implémenter. On nous promet que le premier paramètre sera dans le registre RDI . Et renvoyez la valeur à RAX . Autrement dit, le code de fonction sera quelque chose comme:


 dynasm!(ops ; mov rax, rdi ; ret ); 

Mais la première fonction est un peu plus délicate. Tout d'abord, nous devons retourner &str , qui est un pointeur épais. Sa première partie est un pointeur sur une chaîne et la deuxième partie est la longueur de la tranche de chaîne. Heureusement, la convention ci-dessus vous permet de renvoyer des résultats 128 bits en utilisant le registre EDX pour la deuxième partie.


Il reste à trouver quelque part un lien vers une tranche de chaîne qui contient notre chaîne type_name . Nous ne voulons pas compter sur type_name (bien que grâce aux annotations de la durée de vie, nous pouvons garantir que type_name vivra plus longtemps que la valeur retournée).


Mais nous avons une copie de cette ligne, que nous mettons dans la table de hachage. Croiser les doigts, nous supposerons que l'emplacement de la tranche de chaîne que String::as_str ne String::as_str pas ne change pas en déplaçant la String (et String sera déplacé dans le processus de modification de la taille du HashMap où cette chaîne est stockée par la clé). Je ne sais pas si la bibliothèque standard garantit ce comportement, mais jouons-nous simplement avec facilité?


Nous obtenons les composants nécessaires:


 let type_name_ptr = type_name.as_str().as_ptr(); let type_name_len = type_name.as_str().len(); 

Et écrivez cette fonction:


 dynasm!(ops ; mov rax, QWORD type_name_ptr as i64 ; mov rdx, QWORD type_name_len as i64 ; ret ); 

Et enfin, le code d' annotate final:


 pub fn annotate<'a: 'b, 'b>(&'a self, input: &'b String, type_name: &str) -> &'b Object { let type_name = type_name.to_string(); //       let type_name_ptr = type_name.as_str().as_ptr(); let type_name_len = type_name.as_str().len(); let mut infos = self.infos.borrow_mut(); let imp = infos.entry(type_name).or_insert_with(|| unsafe { let mut ops = dynasmrt::x64::Assembler::new().unwrap(); //     `type_name` let type_name_offset = ops.offset(); dynasm!(ops ; mov rax, QWORD type_name_ptr as i64 ; mov rdx, QWORD type_name_len as i64 ; ret ); //     `as_string` let as_string_offset = ops.offset(); dynasm!(ops ; mov rax, rdi ; ret ); let buffer = ops.finalize().unwrap(); //      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 { header: *whatever_vtable_header, type_name_fn: std::mem::transmute(buffer.ptr(type_name_offset)), as_string_fn: std::mem::transmute(buffer.ptr(as_string_offset)), }; TypeInfo { vtable, buffer } }); assert_eq!(imp.vtable.header.size, std::mem::size_of::<String>()); assert_eq!(imp.vtable.header.align, std::mem::align_of::<String>()); let object_obj = TraitObject { data: input as *const String as *const (), vtable: &imp.vtable as *const ObjectVirtualTable as *const (), }; unsafe { std::mem::transmute::<TraitObject, &dyn Object>(object_obj) } } 

À des fins de dynasm nous devons également ajouter le champ buffer à notre structure TypeInfo . Ce champ contrôle la mémoire qui stocke le code de nos fonctions générées:


 #[allow(unused)] buffer: dynasmrt::ExecutableBuffer, 

Et tous les tests réussissent!


C'est fait, maître!


Si facilement et naturellement, vous pouvez générer votre propre implémentation d'objets de type en code Rust!


Cette dernière solution repose activement sur les détails de mise en œuvre et n'est donc pas recommandée pour une utilisation. Mais en réalité, vous devez faire ce que vous avez à faire. Les temps désespérés nécessitent des mesures désespérées!


Il y a cependant une (plus) une caractéristique sur laquelle je me fie. A savoir, qu'il est sûr de libérer la mémoire occupée virtuellement par la table après qu'il n'y ait aucune référence à l'objet type l'utilisant. D'une part, il est logique que vous ne puissiez utiliser une table virtuelle qu'à travers des références d'objets type. En revanche, les tables fournies par Rust ont une durée de vie 'static . Il est tout à fait possible de supposer du code qui séparera la table du lien pour certains de ses objectifs (on ne sait jamais , par exemple, pour certains de ses trucs sales ).


Le code source peut être trouvé ici .

Source: https://habr.com/ru/post/fr432202/


All Articles