この記事では、Rustプログラミング言語、特に型オブジェクトを少し楽しみます。
Rustに精通したとき、型オブジェクトの実装の詳細の1つが興味深く思えました。 つまり、仮想関数テーブルはデータ自体の中にあるのではなく、それへの「太い」ポインタの中にあります。 型オブジェクトへの各ポインター)には、データ自体へのポインターと、特定の構造に対してこの型オブジェクトを実装する関数のアドレスが配置される仮想テーブルへのリンクが含まれます(ただし、これは実装の詳細であるため、動作が変更される場合があります)。
太いポインターを示す簡単な例から始めましょう。 次のコードは、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に従事していたとき、かなり定期的に発生したタスクの1つは、既存のオブジェクトを特定のインターフェースに適合させることでした。 つまり、オブジェクトは既に存在し、リンクとして発行されていますが、指定されたインターフェイスに適合させる必要があります。 そして、入力オブジェクトを変更することはできません。それはそれです。
私はこのようなことをしなければなりませんでした:
Person adapt(Json value) { // ...- , , , "value" // Person return new PersonJsonAdapter(value); }
このアプローチにはさまざまな問題がありました。 たとえば、同じオブジェクトが2回「適応」する場合、2つの異なるPerson
取得されます(リンク比較の観点から)。 そして、毎回新しいオブジェクトを作成しなければならないという事実は、なんとなくugいものです。
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 { // ... }
すぐにテストを書きましょう。 まず、「割り当てられた」タイプが予想されるものと一致することを確認します。 次に、元の文字列を取得できることを確認し、同じ文字列になるようにします(ポインターの観点から)。
#[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); }
アプローチ番号1:そして少なくとも私たちの後に洪水!
最初に、完全に単純な実装を作成してみましょう。 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
ヒープ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::leak
は、ヒープに格納されているデータへのリンクを返しますが、同時にボックス自体を「忘れる」、つまり、自動解放は行われません)
アプローチ2:アリーナ!
まず、これらのラッパーをどこかに保存して、ある時点でリリースされるようにします。 ただし、同時にannotate
署名をそのまま保持します。 つまり、参照カウント(たとえば、 Rc<Wrapper>
)でリンクを返すことはできません。
最も簡単なオプションは、これらのラッパーを格納する「型システム」である補助構造を作成することです。 そして、終了したら、この構造とそれを含むすべてのラッパーをリリースします。
そのようなもの。 typed-arena
ライブラリはラッパーを格納するために使用されますが、 Vec<Box<Wrapper>>
使用して取得できます。主なことは、 Wrapper
がどこにも移動しないことを保証することです(夜には、これに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
に応じて一意のパラメーターがありinput
!
代わりに、lifetime-timeパラメーターを取り除くために、少し安全でないRustを振りかけます:
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
でunsafe
と不快に感じることに加えて(そうであるべきであるが、安全でないRustと冗談を言わない方が良い!)。
しかし、それでも、ラッパーに追加のメモリ割り当てを必要としない約束されたオプションについてはどうでしょうか?
アプローチ#3:地獄の門を開く
アイデア。 一意の「タイプ」(「ウィジェット」、「ガジェット」)ごとに、仮想テーブルを作成します。 プログラムの実行中の手。 そして、それ自体をデータ自体によって与えられたリンクに割り当てます(思い出すように、これは単にString
)。
まず、取得する必要があるものの簡単な説明。 それでは、型オブジェクトへの参照は、どのように配置されますか? 実際、これらはデータへのポインターと仮想テーブルへのポインターの2つにすぎません。 だから私たちは書く:
#[repr(C)] struct TraitObject { pub data: *const (), pub vtable: *const (), }
( #[repr(C)]
メモリ内の正しい場所を保証する必要があります)。
すべてが単純なようです。指定されたパラメーターの新しいテーブルを生成し、型オブジェクトへのリンクを「収集」します! しかし、この表は何で構成されていますか?
この質問に対する正しい答えは、「これは実装の詳細です」です。 しかし、そうします。 プロジェクトのルートにrust-toolchain
ファイルを作成し、そこに書き込みます: nightly-2018-12-01
。 結局、固定アセンブリは安定していると見なすことができますよね?
Rustバージョンを修正したので(実際、以下のライブラリの1つに対してナイトリービルドが必要になります)。
インターネットでいくつかの検索を行った後、テーブル形式が単純であることがわかります:最初にデストラクタへのリンクがあり、次にメモリの割り当てに関連付けられた2つのフィールド(タイプサイズとアライメント)があり、関数が次々に行きます(順序はコンパイラの裁量ですが、 2つの関数のみであるため、推測の確率はかなり高く、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)]
必要です。 私は2つの構造に分割しましたが、少し後でそれが役に立ちます。
次に、 annotate
機能を提供する型システムを作成してみましょう。 生成されたテーブルをキャッシュする必要があるので、キャッシュを取得しましょう。
struct TypeInfo { vtable: ObjectVirtualTable, } #[derive(Default)] struct TypeSystem { infos: RefCell<HashMap<String, TypeInfo>>, }
TypeSystem::annotate
関数が共有リンクとして受信&self
できるように、 RefCell
の内部状態を使用します。 TypeSystem
から「借用」して、生成した仮想テーブルがannotate
から返す型オブジェクトへの参照よりも長くTypeSystem
するようにするため、これは重要です。
多くのインスタンスに注釈を付けることができるようにしたいため、可変リンクとして&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) } } }
このテーブルはどこから入手できますか? その最初の3つのエントリは、指定されたタイプの他の仮想テーブルのエントリと一致します。 したがって、それらを取得してコピーするだけです。 まず、このタイプを取得しましょう:
trait Whatever {} impl<T> Whatever for T {}
この「他の仮想テーブル」を取得することは有用です。 次に、これら3つのエントリを彼からコピーします。
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_fn
とas_string_fn
アドレスはどこで取得しますか? as_string_fn
は一般に必要ではなく、データポインターは常にタイプオブジェクトの表現の最初のレコードとして使用されます。 つまり、この関数は常に同じです。
impl Object for String { // ... fn as_string(&self) -> String { self } }
しかし、2番目の機能では、それほど簡単ではありません! また、名前 "type"、 type_name
にも依存します。
関係ありません。実行時にこの関数を生成するだけです。 dynasm
ライブラリーを使用してみましょう(現時点では、Rustナイトリービルドが必要です)。 について読む
関数呼び出し規約 。
簡単にするために、Mac OSとLinuxのみに関心があると仮定します(これらの楽しい変換のすべての後、互換性はもう気になりませんよね?)。 そして、もちろん、排他的にx86-64です。
2番目の関数as_string
、実装が簡単です。 最初のパラメータはRDI
レジスタにあることが約束されています。 そして、値をRAX
返します。 つまり、機能コードは次のようになります。
dynasm!(ops ; mov rax, rdi ; ret );
しかし、最初の関数は少し複雑です。 まず、 &str
を返す必要があり&str
が、これは太いポインタです。 最初の部分は文字列へのポインタで、2番目の部分は文字列スライスの長さです。 幸いなことに、上記の規則により、2番目の部分にEDX
レジスタを使用して128ビットの結果を返すことができます。
type_name
文字列を含む文字列スライスへのリンクをどこかに取得するために残っています。 type_name
に依存することは望ましくありません(ただし、存続期間の注釈を通じて、 type_name
が戻り値よりも長く存続することを保証できます)。
しかし、この行のコピーがあり、それをハッシュテーブルに入れます。 指を交差させて、 String::as_str
が返す文字列スライスの位置は、文字String
移動によって変化しないとString::as_str
ます(そして、この文字列がキーによって保存される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コードで型オブジェクトの独自の実装を生成できます!
後者のソリューションは、実装の詳細に積極的に依存しているため、使用を推奨していません。 しかし実際には、あなたがしなければならないことをしなければなりません。 絶望的な時代には必死の手段が必要です!
ただし、ここでは、1つ(複数)の機能に依存しています。 つまり、テーブルを使用している型オブジェクトへの参照がなくなった後、テーブルが実質的に占有しているメモリを解放しても安全です。 一方では、タイプオブジェクトの参照を介してのみ仮想テーブルを使用できることは論理的です。 一方、Rustが提供するテーブルの寿命は'static
です。 いくつかの目的のためにテーブルをリンクから分離するコードを想定することは完全に可能です(たとえば、 いくつかの汚いトリックのためにあなたは決して知りません )。
ソースコードはこちらにあります 。