Generando tipos de personajes sobre la marcha (o locos con Rust)

En este artículo, nos burlaremos del lenguaje de programación Rust y, en particular, de los objetos de rasgos.


Cuando me familiaricé con Rust, uno de los detalles de la implementación de objetos tipo me pareció interesante. Es decir, la tabla de funciones virtuales no se encuentra en los datos en sí, sino en el puntero "grueso". Cada puntero a un objeto de tipo) contiene un puntero a los datos en sí, así como un enlace a una tabla virtual donde se ubicarán las direcciones de las funciones que implementan este objeto de tipo para una estructura dada (pero como se trata de un detalle de implementación, el comportamiento puede cambiar.


Comencemos con un ejemplo simple que demuestra punteros gruesos. El siguiente código saldrá en la arquitectura de 64 bits 8 y 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)); } 

¿Por qué es esto interesante? Cuando estaba involucrado en Java empresarial, una de las tareas que surgía con bastante regularidad era la adaptación de los objetos existentes a las interfaces dadas. Es decir, el objeto ya existe, emitido como un enlace, pero debe adaptarse a la interfaz especificada. Y no puede cambiar el objeto de entrada, es lo que es.


Tenía que hacer algo como esto:


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

Hubo varios problemas con este enfoque. Por ejemplo, si el mismo objeto se "adapta" dos veces, obtenemos dos Person diferentes (desde el punto de vista de la comparación de enlaces). Y el hecho mismo de que tienes que crear nuevos objetos cada vez es de alguna manera feo.


¡Cuando vi objetos tipográficos en Rust, tuve la idea de que en Rust se podía hacer de manera mucho más elegante! ¡También puede tomar y asignar otra tabla virtual a los datos y obtener un nuevo objeto de rasgo! Y no asigne memoria para cada instancia. Al mismo tiempo, toda la lógica de "pedir prestado" permanece en su lugar: nuestra función de adaptación se verá como algo así como fn adapt<'a>(value: &'a Json) -> &'a Person (es decir, tomamos prestado de fuente de datos).


Incluso más que eso, puede "forzar" el mismo tipo (por ejemplo, String ) para implementar nuestro objeto de tipo varias veces, con un comportamiento diferente. Por qué ¿Pero nunca sabes lo que se puede necesitar en la empresa?


Intentemos implementar esto.


Declaración del problema.


Establecemos la tarea de esta manera: hacemos que la función de annotate , que "asigna" el siguiente objeto de tipo al tipo de String normal:


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

Y la función de annotate sí misma:


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

Escribamos una prueba de inmediato. Primero, asegúrese de que el tipo "asignado" coincida con el tipo esperado. En segundo lugar, nos aseguraremos de que podamos obtener la línea original y será la misma línea (desde el punto de vista de los punteros):


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

Enfoque número 1: ¡y después de nosotros al menos una inundación!


Primero, intentemos hacer una implementación completamente ingenua. Simplemente envuelva nuestros datos en un "contenedor", que además contendrá 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 } } 

Nada especial todavía. Todo es como en Java. Pero no tenemos un recolector de basura, ¿dónde almacenaremos este envoltorio? Necesitamos devolver el enlace, para que siga siendo válido después de llamar a la función de annotate . Pondremos algo aterrador en el Box para que el Wrapper resaltado en el montón. Y luego le devolveremos el enlace. Y para que el contenedor permanezca vivo después de llamar a la función de annotate , "filtraremos" este cuadro:


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

... y la prueba pasa!


Pero esta es una decisión dudosa. No solo seguimos asignando memoria con cada "anotación", sino que la memoria se pierde ( Box::leak devuelve un enlace a los datos almacenados en el montón, sino que al mismo tiempo "olvida" la caja en sí, es decir, no habrá liberación automática )


Enfoque 2: Arena!


Para comenzar, intentemos guardar estos envoltorios en algún lugar para que, sin embargo, se liberen en algún momento. Pero al mismo tiempo conserva la firma de annotate tal como está. Es decir, devolver un enlace con recuento de referencias (por ejemplo, Rc<Wrapper> ) no funciona.


La opción más simple es crear una estructura auxiliar, un "sistema de tipos", que se encargará de almacenar estos contenedores. Y cuando terminemos, lanzaremos esta estructura y todos los envoltorios con ella.


Algo asi. La biblioteca typed-arena se usa para almacenar envoltorios, pero podría funcionar con el tipo Vec<Box<Wrapper>> , lo principal es garantizar que Wrapper no se mueva a ninguna parte (en la noche Rust puede usar el pin API para esto):


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

Pero, ¿a dónde se fue el parámetro responsable de la vida útil del enlace para el tipo Wrapper ? Tuvimos que deshacernos de él, ya que no podemos atribuir una vida útil fija en el tipo typed_arena::Arena<Wrapper<'?>> . ¡Cada contenedor tiene un parámetro único, dependiendo de la input !


En cambio, rociamos un poco de óxido inseguro para deshacernos del parámetro de por vida:


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

Y las pruebas pasan nuevamente, lo que nos da confianza en la exactitud de la decisión. Además de sentirse incómodo con unsafe (como debería ser, ¡es mejor no bromear con óxido inseguro!).


Pero aún así, ¿qué pasa con la opción prometida, que no requiere asignaciones de memoria adicionales para envoltorios?


Enfoque n. ° 3: dejar que se abran las puertas del infierno


Idea. Para cada "tipo" único ("Widget", "Gadget"), crearemos una tabla virtual. Manos durante la ejecución del programa. Y lo asignamos al enlace que nos proporcionan los datos en sí (que, como recordamos, es simplemente String ).


Primero, una breve descripción de lo que necesitamos obtener. Entonces, una referencia a un objeto tipo, ¿cómo está organizado? De hecho, estos son solo dos punteros, uno para los datos en sí y otro para la tabla virtual. Entonces escribimos:


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

( #[repr(C)] necesitamos garantizar la ubicación correcta en la memoria).


¡Parece que todo es simple, generaremos una nueva tabla para los parámetros dados y "recopilaremos" un enlace al objeto tipo! Pero, ¿en qué consiste esta tabla?


La respuesta correcta a esta pregunta sería "este es un detalle de implementación". Pero lo haremos; cree un archivo de rust-toolchain en la raíz de nuestro proyecto y escríbalo allí: nightly-2018-12-01 . Después de todo, un conjunto fijo puede considerarse estable, ¿verdad?


Ahora que hemos arreglado la versión Rust (de hecho, necesitaremos el ensamblaje nocturno para una de las bibliotecas a continuación).


Después de una búsqueda en Internet , descubrimos que el formato de la tabla es simple: primero hay un enlace al destructor, luego dos campos asociados con la asignación de memoria (tamaño de letra y alineación), y luego las funciones van una tras otra (el orden queda a discreción del compilador, pero tenemos solo dos funciones, por lo que la probabilidad de adivinar es bastante alta, 50%).


Entonces escribimos:


 #[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 forma similar, #[repr(C)] necesita #[repr(C)] para garantizar la ubicación correcta en la memoria. Me dividí en dos estructuras, un poco más tarde nos será útil.


Ahora intentemos escribir nuestro sistema de tipos, que proporcionará la función de annotate . Tendremos que almacenar en caché las tablas generadas, así que obtengamos el caché:


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

Utilizamos el estado interno de RefCell para que nuestra función TypeSystem::annotate pueda recibir &self como un enlace compartido. Esto es importante, ya que "tomamos prestado" de TypeSystem para asegurarnos de que las tablas virtuales que generamos vivan más tiempo que la referencia al objeto de tipo que devolvemos de la annotate .


Dado que queremos poder anotar muchas instancias, no podemos tomar prestado &mut self como un enlace mutable.


Y bosquejaremos este código:


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

¿De dónde sacamos esta mesa? Las primeras tres entradas coincidirán con las entradas de cualquier otra tabla virtual para el tipo especificado. Por lo tanto, solo tómalos y cópialos. Primero, obtengamos este tipo:


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

Es útil para nosotros obtener esta "cualquier otra tabla virtual". Y luego, copiamos estas tres entradas de él:


 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 } 

En principio, podríamos obtener el tamaño y la alineación a través de std::mem::size_of::<String>() y std::mem::align_of::<String>() . Pero de dónde más puede ser "robado" el destructor, no lo sé.


Ok, pero ¿de dónde obtenemos las direcciones de estas funciones, type_name_fn y as_string_fn ? Puede notar que as_string_fn generalmente no es necesario, el puntero de datos siempre va como el primer registro en la representación del objeto tipo. Es decir, esta función es siempre la misma:


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

¡Pero con la segunda función no es tan fácil! También depende de nuestro nombre "tipo", type_name .


No importa, solo podemos generar esta función en tiempo de ejecución. Tomemos la biblioteca de dynasm para esto (en este momento, requiere la construcción nocturna Rust). Leer sobre
llamadas a funciones convenciones .


Para simplificar, supongamos que solo estamos interesados ​​en Mac OS y Linux (después de todas estas transformaciones divertidas, la compatibilidad ya no nos molesta, ¿verdad?). Y, sí, exclusivamente x86-64, por supuesto.


La segunda función, as_string , es fácil de implementar. Se nos promete que el primer parámetro estará en el registro RDI . Y devuelva el valor a RAX . Es decir, el código de función será algo como:


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

Pero la primera función es un poco más complicada. Primero, necesitamos regresar &str , que es un puntero grueso. Su primera parte es un puntero a una cadena, y la segunda parte es la longitud del corte de cadena. Afortunadamente, la convención anterior le permite devolver resultados de 128 bits utilizando el registro EDX para la segunda parte.


Queda por llegar a algún lugar un enlace a un segmento de cadena que contiene nuestra cadena type_name . No queremos confiar en type_name (aunque a través de anotaciones de la vida útil podemos garantizar que type_name vivirá más tiempo que el valor devuelto).


Pero tenemos una copia de esta línea, que ponemos en la tabla hash. Cruzando los dedos, String::as_str que la ubicación del segmento de cadena que String::as_str no String::as_str no cambia al mover la String (y la String moverá en el proceso de cambiar el tamaño del HashMap donde la cadena almacena esta cadena). No sé si la biblioteca estándar garantiza este comportamiento, pero ¿lo estamos jugando fácilmente?


Obtenemos los componentes necesarios:


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

Y escribe esta función:


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

Y finalmente, el código de 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) } } 

Para fines de dynasm también necesitamos agregar el campo de buffer a nuestra estructura TypeInfo . Este campo controla la memoria que almacena el código de nuestras funciones generadas:


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

¡Y todas las pruebas pasan!


Hecho, maestro!


¡Tan fácil y naturalmente puede generar su propia implementación de objetos tipo en código Rust!


La última solución se basa activamente en los detalles de implementación y, por lo tanto, no se recomienda su uso. Pero en realidad, tienes que hacer lo que tienes que hacer. ¡Los tiempos desesperados requieren medidas desesperadas!


Sin embargo, hay una (más) característica en la que confío aquí. A saber, que es seguro liberar la memoria ocupada virtualmente por la tabla después de que no haya referencias al objeto de tipo que la usa. Por un lado, es lógico que pueda usar una tabla virtual solo a través de referencias de objetos de tipo. Por otro lado, las tablas proporcionadas por Rust tienen una vida útil 'static . Es completamente posible asumir algún código que separe la tabla del enlace para algunos de sus propósitos ( nunca se sabe, por ejemplo, para algunos de sus trucos sucios ).


El código fuente se puede encontrar aquí .

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


All Articles