动态生成字符类型(或对Rust疯狂)

在本文中,我们将使用Rust编程语言,尤其是trait对象。


当我熟悉Rust时,实现类型对象的细节之一对我来说似乎很有趣。 即,虚拟功能表不在数据本身中,而是在指向它的“粗略”指针中。 每个指向类型对象的指针都包含一个指向数据本身的指针,以及指向虚拟表的链接,在该表中将找到为给定结构实现该类型对象的函数的地址(但是由于这是实现细节,因此行为可能会更改。


让我们从一个演示粗指针的简单示例开始。 以下代码将在64位体系结构8和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)); } 

为什么这很有趣? 当我从事企业Java时,经常出现的任务之一是使现有对象适应给定的接口。 也就是说,该对象已经存在,以链接的形式发布,但是必须适应于指定的接口。 而且您不能更改输入对象,它就是它。


我不得不做这样的事情:


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

这种方法存在各种问题。 例如,如果同一对象两次“适应”,那么我们将得到两个不同的Person (从链接比较的角度来看)。 而且,每次都必须创建新对象的事实在某种程度上很难看。


当我在Rust中看到类型对象时,我想到在Rust中可以更优雅地完成它! 您还可以获取另一个虚拟表并将其分配给数据,并获得一个新的特征对象! 并且不要为每个实例分配内存。 同时,“借用”的整个逻辑仍然存在-我们的适应函数将看起来像fn adapt<'a>(value: &'a Json) -> &'a Person (也就是说,我们有点向源数据)。


甚至更多,您可以“强制”相同类型(例如String )以不同的行为多次实现我们的类型对象。 怎么了 但是您永远不知道企业中可能需要什么?


让我们尝试实现这一点。


问题陈述


我们以这种方式设置任务:创建带annotate函数,该函数将以下类型对象“分配”为常规String类型:


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

annotate功能本身:


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

让我们马上编写一个测试。 首先,确保“ assigned”类型与期望的类型匹配。 其次,我们将确保可以得到原始行,并且该行将是同一行(从指针的角度来看):


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

方法一:在我们之后至少发生洪灾!


首先,让我们尝试一个完全幼稚的实现。 只需将我们的数据包装在一个“包装器”中,该包装器还将另外包含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 } } 

没什么特别的。 一切都像Java。 但是我们没有垃圾收集器,我们将在哪里存储这个包装器? 我们需要返回链接,以便在调用annotate函数之后它仍然有效。 我们将一些令人恐惧的内容放入“ Box以便在堆上突出显示“ Wrapper ”。 然后,我们将返回链接。 为了使包装器在调用annotate函数之后仍然有效,我们将“泄漏”此框:


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

...测试通过了!


但这是一个可疑的决定。 我们不仅仍然为每个“注释”分配内存,所以内存泄漏( Box::leak返回指向存储在堆上的数据的链接,但与此同时“忘记”了Box本身,也就是说,不会自动释放)


方法2:竞技场!


首先,让我们尝试将这些包装保存在某个位置,以便在某些时候将其释放。 但是同时保留annotate签名。 也就是说,返回带有引用计数的链接(例如Rc<Wrapper> )不起作用。


最简单的选择是创建一个辅助结构,即“类型系统”,它将负责存储这些包装。 当我们完成时,我们将释放该结构及其所有包装。


这样的东西。 typed-arena库用于存储包装器,但是您可以使用Vec<Box<Wrapper>> ,主要是确保Wrapper器不会在任何地方移动(在Rust晚上,您可以使用pin API进行此操作):


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

但是,负责Wrapper类型的链接的生存期的参数在哪里呢? 我们必须摆脱它,因为我们不能在typed_arena::Arena<Wrapper<'?>>类型中typed_arena::Arena<Wrapper<'?>>某些固定的寿命。 每个包装器都有一个唯一的参数,具体取决于input


取而代之的是,我们撒一些不安全的Rust来摆脱lifetime-time参数:


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

测试再次通过,从而使我们对决策的正确性充满信心。 除了因unsafe而感到不舒服(应该如此,最好不要与不安全的Rust开玩笑!)。


但是,关于承诺的选项又如何呢?该选项不需要为包装程序分配额外的内存?


方法3:打开地狱之门


想法。 对于每个唯一的“类型”(“小部件”,“小工具”),我们将创建一个虚拟表。 在程序执行期间动手。 然后,我们将其分配给数据本身给我们的链接(我们记得,它只是String )。


首先,简短描述一下我们需要得到什么。 那么,对类型对象的引用如何安排? 实际上,这些只是两个指针,一个指向数据本身,另一个指向虚拟表。 所以我们写:


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

#[repr(C)]我们需要保证在内存中的正确位置)。


看起来一切都很简单,我们将为给定的参数生成一个新表,并“收集”指向类型对象的链接! 但是此表由什么组成?


该问题的正确答案将是“这是实现细节”。 但是我们会这样做; 在我们项目的根目录中创建一个rust-toolchain文件,并将其写入以下位置: nightly-2018-12-01 。 毕竟,固定组件可以被认为是稳定的,对吗?


既然我们已经修复了Rust版本(实际上,我们将需要下面的库之一的夜间汇编)。


Internet上进行一些搜索之后,我们发现表格式很简单:首先是指向析构函数的链接,然后是两个与内存分配相关联的字段(类型大小和对齐方式),然后函数一个接一个地走(顺序由编译器决定),但是我们拥有只有两个函数,因此猜测的可能性非常高(50%)。


所以我们写:


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

同样,需要#[repr(C)]来保证在内存中的正确位置。 我分为两个结构,稍后它将对我们有用。


现在,让我们尝试编写我们的类型系统,该系统将提供annotate功能。 我们将需要缓存生成的表,因此让我们获取缓存:


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

我们使用RefCell的内部状态,以便我们的TypeSystem::annotate函数可以接收&self作为共享链接。 这很重要,因为我们从TypeSystem “借用”以确保所生成的虚拟表的生存期比对我们从annotate返回的类型对象的引用的生存期更长。


由于我们希望能够注释许多实例,因此我们不能将&mut self用作可变链接。


我们将草绘以下代码:


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

我们从哪里得到这张桌子? 其中的前三个条目将与指定类型的任何其他虚拟表的条目匹配。 因此,只需复制它们即可。 首先,让我们获得这种类型:


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

对于我们来说,获得这个“任何其他虚拟表”非常有用。 然后,我们从他那里复制以下三个条目:


 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 } 

原则上,我们可以通过std::mem::size_of::<String>()std::mem::align_of::<String>()获得大小和对齐方式。 但是我不知道还能从哪里“销毁”析构函数。


好的,但是我们从哪里获得这些函数的地址type_name_fnas_string_fn呢? 您可能会注意到,通常不需要as_string_fn ,数据指针始终作为类型对象表示中的第一条记录。 也就是说,此功能始终相同:


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

但是使用第二个功能并不是那么容易! 它还取决于我们的名称“ type”, type_name


没关系,我们可以在运行时生成此函数。 让我们为此使用dynasm库(目前,它需要Rust每晚构建)。 了解有关
函数调用约定


为简单起见,假设我们只对Mac OS和Linux感兴趣(经过所有这些有趣的转换之后,兼容性真的不再困扰我们了,对吧?)。 而且,是的,当然只有x86-64。


第二个函数as_string易于实现。 我们保证第一个参数将在RDI寄存器中。 并将值返回给RAX 。 也就是说,功能代码将类似于:


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

但是第一个功能有些棘手。 首先,我们需要返回&str ,它是一个粗指针。 它的第一部分是指向字符串的指针,第二部分是字符串切片的长度。 幸运的是,上述约定允许您使用第二部分的EDX寄存器返回128位结果。


剩下的地方是指向包含我们的字符串type_name的字符串切片的链接。 我们不想依赖type_name (尽管通过生命周期的注释,我们可以保证type_name寿命比返回值的寿命长)。


但是我们有此行的副本,并将其放入哈希表中。 双手String::as_str ,我们将假设String::as_str将不String::as_str的字符串片段的位置不会因为移动String而发生改变(并且在更改通过键存储此字符串的HashMap的大小的过程中将移动String )。 我不知道标准库是否可以保证这种行为,但是我们玩起来容易吗?


我们获得了必要的组件:


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

并编写此函数:


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

最后,最后的annotate代码:


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

出于dynasm目的dynasm我们还需要将buffer字段添加到TypeInfo结构中。 此字段控制用于存储我们生成的函数的代码的内存:


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

并且所有测试都通过了!


做完了,主人!


因此,您可以轻松自然地在Rust代码中生成自己的类型对象实现!


后一种解决方案积极地依赖于实现细节,因此不建议使用。 但实际上,您必须做必须做的事情。 绝望的时代需要绝望的措施!


但是,我在此依赖一个(多个)功能。 即,在没有引用使用类型对象的情况下,释放表实际上占用的内存是安全的。 一方面,逻辑上只能通过类型对象的引用使用虚拟表是合乎逻辑的。 另一方面,Rust提供的表具有'static寿命”。 完全有可能假设一些出于某些目的将表与链接分开的代码(例如,您从不知道其某些肮脏的技巧 )。


源代码可以在这里找到

Source: https://habr.com/ru/post/zh-CN432202/


All Articles