ねののお庭。

かりかりもふもふ。

【C#】Unsafe.BitCast のすゝめ。

今まで Unsafe.As() を使っていた箇所の多くは Unsafe.BitCast() を使う事をオススメします...!

Unsafe.As()

Unsafe.As() はパラメータとして渡されたオブジェクトを問答無用で任意の型にキャストする非常な危険な代物です。 Unsafe とついているから危険なのはそれはそうという感じでしょう。

例えば大きさの異なる値型(構造体)に Unsafe.As() を用いた場合、意図しないメモリ領域を読んでしまいます。 非常に危険。

uint value0 = 0x1111;
uint value1 = 0x63;
ref ulong value2 = ref Unsafe.As<uint, ulong>(ref value1);
ulong value3 = Unsafe.As<uint, ulong>(ref value1); // ref を外せば ulong としてコピー

value1 = 0x7;

Console.WriteLine($"0x{value1:X}"); // 0x7
Console.WriteLine($"0x{value2:X}"); // 0x111100000007
Console.WriteLine($"0x{value3:X}"); // 0x111100000063

参照型の場合はどうでしょう。 Unsafe.As() はオブジェクトの実際の型をいじくっているだけでなく、単に任意のオブジェクトを任意の型として見えるようにしているだけですから、実際の型情報(type handle) は元のオブジェクトのままです。 そのため当然ながら GetType() を呼び出せば元のオブジェクトの型情報が取得できます。

キャストされたオブジェクトのメソッドを呼び出した場合はどうでしょうか。 virtual ではない method を呼び出した場合はキャスト先の型のメソッドが呼び出されます。 そして virtual method の場合は runtime は virtual table を正しく引けないため System.ExecutionEngineException を投げて死にます。

var a = new A();
var b = Unsafe.As<A, B>(ref a);

Console.WriteLine(b.GetType()); // A
b.M1(); // B.M1()
b.Mb(); // B.Mb()
b.M2(); // An unhandled exception of type 'System.ExecutionEngineException' occurred in Unknown Module.

class A
{
    public void M1() => Console.WriteLine("A.M1()");
    public void Ma() => Console.WriteLine("A.Ma()");
    public virtual void M2() => Console.WriteLine("A.M2()");
}

class B
{
    public void M1() => Console.WriteLine("B.M1()");
    public void Mb() => Console.WriteLine("B.Mb()");
    public virtual void M2() => Console.WriteLine("B.M2()");
}

Unsafe.As() の安全な使い方

そんな危険な Unsafe.As() ですが、使い方を絞れば安全に使えます。 Unsafe.As() を安全に使える条件は以下の2つです。

  • メモリレイアウトが同一な構造体
  • サイズが同一で参照型を含まない構造体

要するに特定の条件下の値型のキャストをする分には安全なのです。 なぜならスタックに存在する値型は、ヒープに存在するオブジェクトのように header や type handle を持たないので、上記の条件さえ満たしていれば Unsafe.As() を用いてキャストしても問題ないのです。

Unsafe.As() には様々な用途がありますが、上記以外の用途は C# 的にも runtime 的にも何も保証はないので (unsafe ですから!) runtime の実装の詳細が変更されてぶっ壊れてもしゃーなし、という感じです。 特に参照型に対する Unsafe.As() はかなりおっかないです。

Unsafe.BitCast()

値型のキャストは頻出パターンであり、特にハイパフォーマンスな実装を行う際には避けて通れません。 値型のキャストを行うため、.NET 7 までは多くの場合 Unsafe.As() が用いられてきました。 つまり Unsafe.As() を上記の「安全な使い方」の範疇で使う事は非常に多かったのです。 しかしそのような Unsafe.As() の使い方をしている場合、.NET 8 以降は Unsafe.BitCast() を用いるのがおススメです。 Unsafe.As()Unsafe.BitCast() のシグネチャは以下の通り。

public static unsafe class Unsafe
{
    public static ref TTo As<TFrom,TTo> (ref TFrom source);

    public static TTo BitCast<TFrom,TTo> (TFrom source);
}

Unsafe.BitCast() が出来る事は Unsafe.As() でも出来ますが、以下のような特徴があります。

  • Unsafe.BitCast() の型引数として渡せるのは構造体だけ
    • 構造体でなかったら実行時例外
  • キャスト元とキャスト先の構造体のサイズは同一でなければならない
    • サイズが同一でなければ実行時例外
  • パラメータは参照渡しではなく値渡し

このため Unsafe.BitCast()Unsafe.As() よりかなり安全です。

もちろん安全なだけでなく、パフォーマンス的にもメリットがあります。 そもそも Unsafe.BitCast()導入された理由はパフォーマンス面にありますUnsafe.As() は ref を用いている事から分かる通り、パラメータを値渡しではなく参照渡しするわけですが、それが JIT 最適化の邪魔となっているケースが存在しました。 Unsafe.BitCast() はそれらの課題を解決し、パフォーマンスを改善するべく導入された代物です。 例えば参照渡しではなく値渡しにする事によってインライン化による最適化がより強力に効くようになるようです

ちなみに .NET 8 時点では Unsafe.BitCast() の型引数には struct 制約がかけられていたのですが、.NET 9 で使い勝手の観点から制約が取り外されました。 Unsafe class なんだからええじゃろという事で。 BCL 及び runtime 内部の実装的には struct 制約がかなり邪魔だった模様

まとめ

Unsafe.BitCast() はいいぞ! これからは Unsafe.As() より Unsafe.BitCast() を積極的に使っていきましょう...!

勿論「積極的に unsafe を使っていこうな!」という事ではないですからね...! unsafe を持ち出さずに高速にキャストできるならそれに越したことはありません。