今回は珍しく C++/CLI のお話。なお、C# と混ざると型名がややこしくなるので、今回はマネージドの型については CLR の汎用型名で統一します。
今ふと思い立ってとある C 言語のライブラリを C++/CLI でラップしようと試みているところなのですが、そこでふと気になったのが多分 C++/CLI 使う人たちが一度は立ち止まる問題である、String^ から wchar_t* への変換のこと。
一般的には、ToCharArray の先頭要素のアドレスを pin_ptr<wchar_t> で受けたり、Marshal::StringToHGlobalUni でポインタにしたりする、と言う風な解説がなされますが、これらはメモリのコピーが発生するためどうしてもパフォーマンスに影響が出てきます。後者なんか FreeHGlobal の呼び出し責任も出てきますし。
実のところ、この点に関してだけは C# の方が優れた解決を提供しています。fixed を使えば考える必要なく String を Char* にしてくれますから。同じ動作を C++/CLI で書くとしたら、pin_ptr<Char> pointer = &original[0];
ってことになるんでしょうが、残念ながらコンパイラはこの記述を認めてくれません。String の [] はプロパティ呼び出しであって配列の添え字じゃないですからねぇ。
C# でポインタに変換したい場合、私はよく GCHandle を使用します(まあ「よく」といってもそもそも使う機会はほとんどないですが)。これなら .NET に用意されている機構ですから言語に関係なく使用できますね。ただ、ネックは必ず GCHandle.Free を呼び出す必要があることです。
try-finally 構文があるとは言え、やはりどうせなら pin_ptr みたいに自動変数でどうにかしていただきたい。そこで再び C# の fixed 構文に目を向けて見ます。そもそもこいつは一体どうやって実装されているんでしょう? 結構この String に対する fixed は変態的で、String が変更不可オブジェクトとされているにも関わらず Char* の任意の位置の値をがりがり書き換えることができます。もちろん、元の String にもそれが反映されます。気持ち悪いですね。
気持ち悪いどころか、同じインターンプールを参照している文字列があれば当然のようにその文字列も変更されてしまいます。発見の著しく困難なバグの種になるでしょうから、Char* への変更はやめておいたほうがいいでしょう。
コードを追いかけるだけではどうにもならないので、ildasm を使って MSIL でどう実現されているのかを調べてみます。
コードunsafe void Sample1(String text) { fixed (Char* ptr = text) { ptr[0] = 'a'; } }
MSIL(抜粋整形).locals init (char* V_0, string pinned V_1) IL_0000: ldarg.0 IL_0001: stloc.1 IL_0002: ldloc.1 IL_0003: conv.i IL_0004: dup IL_0005: brfalse.s IL_000d IL_0007: call int32 [mscorlib] System.Runtime.CompilerServices.RuntimeHelpers ::get_OffsetToStringData() IL_000c: add IL_000d: stloc.0 IL_000e: ldloc.0 IL_000f: ldc.i4.s 97 IL_0011: stind.i2 IL_0012: ldnull IL_0013: stloc.1 IL_0014: ret
まず目を引くのがローカル変数宣言部分の Char* 型変数についている pinned キーワードです。どうやらこのキーワードを持つ変数に代入されたオブジェクトは自動的に固定されるようです。ちなみに、System.Reflection.Emit.ILGenerator クラスの DeclareLocal メソッドには第二引数に Boolean を取るオーバーロードが存在し、これで pinned 指定をするかどうかを決定できます。
しかしこのオーバーロードは .NET 2.0 以降から追加されたもののようで、一体 .NET 1.1 ではどうすれば実現できたんでしょうか? やっぱ無理だったのかな。
IL コードではあまり悩む様子もなく、引数の String をロードしてまず pinned なローカル String 変数に代入して、それ(String への参照ですね)をポインタ型に変換しています。次に、唐突の感がありますが System.Runtime.CompilerServices.RuntimeHelpers.OffsetToStringData の値を加算し、最終的に Char* に変換しています。このプロパティは、名前どおり String オブジェクト内の実データを格納している部分へのオフセットと言うことなのでしょう。
ちなみに、conv.i は native int に変換するそうです(参考:MSDN2ライブラリ/OpCodes.Conv_I フィールド )。int なんだから +1 が 4/8 バイトになるんじゃないかと思ってしまいますが、+1 で 1 バイト進むだけみたいです。
目指すべき形は見えてきました。その前に、C++/CLI で似たようなコードで感覚をつかんでみましょう。pin_ptr の型引数(って言うのかしらん?)にはハンドル型も渡すことができます。
pin_ptr<String^> pointer = &original;
ですので上記はコンパイル可能なコードになります。まあこれって要するに original の中身じゃなくて original そのもののピン止めですので、意味はないんですが(String^* とか貰ってもねぇ?)。これの IL を見てみましょう。
pin_ptr<String> が使えれば夢が広がるんですが、コンパイラに却下されます。
.locals (string& pinned /* modopt は省略 */ V_0)
IL_0000: ldarga.s original
IL_0002: stloc.0
IL_0003: ret
先ほどの C# の IL と異なるのは、ldarga.s です。original そのものではなくそのアドレスをロードしてるわけですね。
さて、それでは C# が出力するコードに近くなるよう C++/CLI で書いてみましょう。
static void Sample2(String^ original) {
// まず、String^ のポインタを取得
pin_ptr<String^> str_ptr = &original;
// 参照のポインタなんだからそれってポインタのポインタと考えていいよね
Byte** byte_ptr_ptr = reinterpret_cast<Byte**>(str_ptr);
// 今度はそのポインタ自体じゃなくてその指す先をピン止め
pin_ptr<Byte> byte_ptr = *byte_ptr_ptr;
// ポインタに秘密の数字を加算
byte_ptr += System::Runtime::CompilerServices::RuntimeHelpers::OffsetToStringData;
// 目的の型にキャストして終了
wchar_t* pointer = reinterpret_cast<wchar_t*>(byte_ptr);
// あとはこの pointer を使ってお好きに
}
さて、割と複雑になりました。ですので文字列を与えてポインタを受け取るような関数にしたいところですが、残念ながらそういう関数にはできません。考えてみれば当然の話ですが、pin_ptr でピン止めできるのはローカル変数のみで、関数を超えてピン止めすることはできないので。そうするには GCHandle が必要です。
Managed C++ の時代から、Microsoft が提供する SDK のヘッダファイルの中には vcclr.h というのがあり、ここで PtrToStringChars というインライン関数が定義されています。やってることを簡単に説明すると、String*(String^) を直接 Byte*(interior_ptr<Byte>)にキャストして OffsetToStringData を加えてるだけで、ピン止めのことが考慮に入っていません。インターンプールの文字列なら移動されることなんてないような気もしますが、そうでない文字列もあるわけで、いったいこれは妥当なのかどうなのか。とりあえず私は怖いから使いません。
となると C/C++ として基本の発想はマクロでしょう。と言うことで、以下のようなマクロを定義してみました。
// 変数名が重複しないようにするための仕込み
#define CONCAT(x, y) CONCAT2(x, y)
#define CONCAT2(x, y) x##y
// wchar_t* に変換するための引数省略版マクロ
#define TO_STRPTR_WCHAR(STRING_PTR, STRING) \
TO_STRPTR_TYPED(wchar_t, STRING_PTR, STRING)
// Byte* に変換するための引数省略版マクロ
#define TO_STRPTR_BYTE(STRING_PTR, STRING) \
TO_STRPTR_TYPED(Byte, STRING_PTR, STRING)
// 任意の型のポインタに変換するためのマクロ
#define TO_STRPTR_TYPED(TYPE, TYPED_PTR, STRING) \
TO_STRPTR_TYPED_LOCAL(TYPE, TYPED_PTR, STRING, \
CONCAT(STRING_REF_PTR_, __LINE__), \
CONCAT(BYTE_PTR_PTR_, __LINE__), \
CONCAT(BYTE_ARRAY_PTR_, __LINE__))
// ローカル変数名をどうにかしたマクロ。内部使用向け
#define TO_STRPTR_TYPED_LOCAL(TYPE, TYPED_PTR, STRING, \
STRING_REF_PTR, BYTE_PTR_PTR, \
BYTE_ARRAY_PTR) \
pin_ptr<String^> STRING_REF_PTR = &((STRING)); \
Byte** BYTE_PTR_PTR = reinterpret_cast<Byte**>(STRING_REF_PTR); \
pin_ptr<Byte> BYTE_ARRAY_PTR = *BYTE_PTR_PTR; \
BYTE_ARRAY_PTR += System::Runtime::CompilerServices:: \
RuntimeHelpers::OffsetToStringData; \
TYPE* TYPED_PTR = reinterpret_cast<TYPE*>(BYTE_ARRAY_PTR);
// 使用例
String^ original = "sample";
// _WCHARではマクロの第一引数の名前の変数が暗黙に宣言される
// wc は wchar_t* 型
// ただし、wc は NULL 終端されてない可能性がある点に注意
TO_STRPTR_WCHAR(wc, original);
// _TYPED ではマクロの第一引数の型を持つ第二引数の名前の変数
// 型は const 指定もできる
TO_STRPTR_TYPED(const void, pointer, original);
memcpy(target, pointer, original->Length * 2);
pin_ptr の有効範囲がスコープ内限定のためスコープを分けて変数宣言するわけにも行かないので、__LINE__ マクロを使って変数名を行番号縛りにしています。これで一つのスコープ内に複数の TO_STRPTR_TYPED マクロを並べることができます。そのために CONCAT マクロと CONCAT2 マクロを用意し、変数名を結合させています。このマクロ名はほかで使われてそうなので名前を変えたほうがいいかも。
とりあえず書いてみましたが、ここはこの方が良い、とかそもそもこんなの不要でこれを使えば良い、とかあればぜひお知らせください。/CLI を含め、C++ にはあまり詳しくないものですので。
ここでは、PtrToStringCharsの戻り値をpinしています。MSが載せていることですし、これで十分ではないでしょうか。
ご指摘ありがとうございました。
さて、本当にpin止めされているのか?どうやって確かめればいいかな...