在本文中,我们将使用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) {
这种方法存在各种问题。 例如,如果同一对象两次“适应”,那么我们将得到两个不同的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
功能本身:
让我们马上编写一个测试。 首先,确保“ assigned”类型与期望的类型匹配。 其次,我们将确保可以得到原始行,并且该行将是同一行(从指针的角度来看):
#[test] fn test() { let input: String = "hello".into(); let annotated1 = annotate(&input, "Widget"); let annotated2 = annotate(&input, "Gadget");
方法一:在我们之后至少发生洪灾!
首先,让我们尝试一个完全幼稚的实现。 只需将我们的数据包装在一个“包装器”中,该包装器还将另外包含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(), } }
但是,负责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 }
测试再次通过,从而使我们对决策的正确性充满信心。 除了因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 {
我们从哪里得到这张桌子? 其中的前三个条目将与指定类型的任何其他虚拟表的条目匹配。 因此,只需复制它们即可。 首先,让我们获得这种类型:
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 {
原则上,我们可以通过std::mem::size_of::<String>()
和std::mem::align_of::<String>()
获得大小和对齐方式。 但是我不知道还能从哪里“销毁”析构函数。
好的,但是我们从哪里获得这些函数的地址type_name_fn
和as_string_fn
呢? 您可能会注意到,通常不需要as_string_fn
,数据指针始终作为类型对象表示中的第一条记录。 也就是说,此功能始终相同:
impl Object for String {
但是使用第二个功能并不是那么容易! 它还取决于我们的名称“ 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();
出于dynasm
目的dynasm
我们还需要将buffer
字段添加到TypeInfo
结构中。 此字段控制用于存储我们生成的函数的代码的内存:
#[allow(unused)] buffer: dynasmrt::ExecutableBuffer,
并且所有测试都通过了!
做完了,主人!
因此,您可以轻松自然地在Rust代码中生成自己的类型对象实现!
后一种解决方案积极地依赖于实现细节,因此不建议使用。 但实际上,您必须做必须做的事情。 绝望的时代需要绝望的措施!
但是,我在此依赖一个(多个)功能。 即,在没有引用使用类型对象的情况下,释放表实际上占用的内存是安全的。 一方面,逻辑上只能通过类型对象的引用使用虚拟表是合乎逻辑的。 另一方面,Rust提供的表具有'static
寿命”。 完全有可能假设一些出于某些目的将表与链接分开的代码(例如,您从不知道其某些肮脏的技巧 )。
源代码可以在这里找到 。