はじめに
この記事は次の記事の C# → Rust 版です。
普通に Rust の関数を extern で公開するだけ、という話ではなく構造体に対して impl キーワードで実装したメソッドを C# から呼び出します。 「ネイティブ (Rust) 側のリソース管理を呼び出し元の C# で行いたい」 を達成する事を目的とします。
基本的には C# 側は元記事とほぼ同じ (一部パラメーターを int から uint に変えましたがそれ以外は同じ) で、 Rust 側を C++ と同じ実装になるようにしています。元記事では "パターン 2" と呼んでいる比較的穏当な方になります。
環境は次の通りです。
- Windows 10
- Visual Studio 2019
- CLion 2019.3
- .NET Core 3.1
- rustc 1.41.0
リポジトリはこちらです。
Rust 側でインスタンスを生成する
Rust は他の言語と同様にメモリはスタックかヒープに確保されます。特に意識しない場合はスタックでヒープに確保したい場合は Box を使います。
Rust はメモリ安全性を強く意識されている言語で普通に使っている限りでは確保したメモリは不要になったら自動で解放されますが、今回の場合は C# 側が主体になっているので Rust の方で破棄されたりしたら困るわけです。
このような場合、 Box::into_raw, Box::from_raw を使用します。 into_raw で管理対象から外して raw pointer を取得します。 from_raw は逆で raw pointer から Box を生成します。
impl RustSample {
fn new() -> RustSample {
RustSample { number: 0, str: String::new() }
}
unsafe fn new_raw() -> *mut RustSample {
Box::into_raw(Box::new(RustSample::new()))
}
fn destroy(&mut self) {
unsafe {
Box::from_raw(self as *mut RustSample);
}
}
}
new_raw では Box 化した RustSample を into_raw で raw pointer にしたものを返します。
destroy では from_raw で再度 Box 化し、そのままスコープを抜けることで解放をします。この流れでは drop trait の実行もされますので、インスタンスの管理以外については通常の Rust とほぼ同じように扱えています。
当たり前の話ですが、 into_raw で raw pointer 化したものは from_pointer で Rust に戻さないとリークします。要・自己責任です。
malloc / free を使う
実は当初 into_raw, from_raw の存在に気づいていなくて malloc / free でやっていました。このやり方は面倒な上にコンストラクタ (new) と drop trait の呼び出しを記述しない事が十分考えられます。のでやらない方が無難でしょう。
関数テーブルを用意する
C++ 版では関数テーブルを自前で生成してそれを配列で返すというやり方をしました。今回もそれを踏襲します。
Rust の関数ポインタは次のようにキャストすると raw pointer (= 外部に出せる) として取得できます。
(2020/9/14 修正: 以前は一回 Rust の関数ポインタにキャストする、と書いていましたが不要でした)
fn foo() {
println!("foo");
}
let p = foo as *const ();
Rust の関数は C++ のインスタンスメソッドと異なりどのような時でも C 言語関数と互換性があります。ので C++ の時のような細工は不要で普通に配列に並べることができます。
let table = &[
RustSample::destroy as *const(),
RustSample::get_current_value as *const(),
];
create_rust_sample_instance では渡されたバッファに対して RustSample のインスタンスと関数テーブルの内容をコピーします。
関数テーブルは必要ない?
C++ の時は上記の通りそれをやる強い理由がありました (C++ のインスタンスメソッドの関数ポインタは通常ポインタと互換性がなかった) が、 Rust にはそれがなく
impl RustSample {
fn add(&mut self, num: i32) {
self.number += num;
}
}
#[no_mangle]
extern fn RustSample_add(s: &mut RustSample, num: i32) {
s.number += num;
}
この二つは ABI 的には等価であるので、 RustSample_add など各メソッドを DllImport で入力するようにしても問題ないですし C# 側を含めた記述量的にも結果的に少なくなるかもしれません。割と趣味的なところかと思いますが、
- 型に対するメソッドは impl を使って実装するのが一般的と思います
- extern で定義した関数を踏み台にして impl のメソッドを呼ぶのは記述量が増えて本末転倒になりそうです (マクロでなんとかなりそうではあります)
- DllImport ではなくコードで DLL の動的ロードをしようとした時、結局手間になりそうです
また、大量に export している関数があるのが個人的にはあんまり好きではないので、そういった観点からでも私は関数テーブルを作るやり方をとるかと思います。
C# から呼び出す
こちらは C++ 版と同じです。
[DllImport("cs_rust_invoke", EntryPoint = "create_rust_sample_instance")]
static extern uint CreateRustSampleInstance(IntPtr[] buffer, uint bufferSize);
Rust は命名規則が snake case なので DllImport 時に調整するのがよいと思います。
public RustSample()
{
uint bufferSize = CreateRustSampleInstance(null, 0);
var buffer = new IntPtr[bufferSize];
if (CreateRustSampleInstance(buffer, bufferSize) != bufferSize)
{
throw new Exception();
}
_self = buffer[0];
_fnDestroy = Marshal.GetDelegateForFunctionPointer<FnAction>(buffer[1]);
テーブルの 0 番に Rust 側のインスタンスポインタ、 1 番以降が関数ポインタなので delegate 化して C# から使います。
public void Add(int value) => _fnAdd(_self, value);
第一引数が渡された struct のインスタンスへのポインタなのでそれを指定します。
public void Dispose()
{
_fnDestroy?.Invoke(_self);
_fnDestroy = null;
_self = IntPtr.Zero;
}
Dispose に RustSample::destroy を呼び出す記述を実装し、ここから Rust 側のインスタンスを破棄するようにします。
文字列の扱い
delegate uint FnAppendChars(IntPtr self, [MarshalAs(UnmanagedType.LPUTF8Str)] string s);
Rust の文字列は UTF-8 なので UnmanagedType.LPUTF8Str を指定するのがよいと思いますが、この列挙値は .NET Framework 4.7 / .NET Core 1.1 以降にあるもののようなので環境によっては使えない場合もあるので注意が必要です (.NET Standard 2.0 でもない) 。
実行
C++ 版と同じになりました。また、 new, print_chars, drop でそれぞれポインタ値を合わせて出力するようにしていますが、それぞれ適切に呼ばれていることが確認できます。
trait object を使う
struct 自体ではなく trait object をインスタンスへの参照として利用しようとした場合、 trait object はこれまで扱ってきた普通のポインタ (raw pointer) と互換性がありません。
によると trait object は
pub struct TraitObject {
pub data: *mut (),
pub vtable: *mut (),
}
という表現になっていて実際に通常のポインタ 2 個分のサイズであることを確認しています。
よって trait object を使用する場合、 C# 側もそれを扱えるような構造にする必要があります。具体的には第一引数に渡すものを trait object と互換性がある形式にする必要があります。が、実際のところ FFI で trait object を必要とするケースはないと思いますのでとりあえず気にする必要はないかと思います。一応そういった注意点があるということだけ記載しておきます。
呼び出し規約について
今回 Windows x64 しか試していないので確認していませんが、実際のところは呼び出し規約に配慮する必要があります。が、呼び出し規約が面倒くさいのは Windows x86 くらいだと思うのでとりあえず見なかったことにしています・・・
おわりに
.NET 用のネイティブコードのリソース管理を C# 主体で行いたいというケースはよくありますので、 Rust でのやり方を明確にしておくことは Rust を使っていくにあたって個人的には重要なことです。
Rust はまだ始めたばかりなので結構試行錯誤をしましたが、最終的にはかなりシンプルなコードになりました。これをベースに作業していけるかなと思います。