ADVENT CALENDAR

C#におけるメモリレイアウトとUnsafeクラスでできる悪事

By Funny_Silkie

はじめに

ゲームを作ったり軽いスクリプトを書いたりする際はオブジェクトのメモリレイアウトなんぞ気にする機会はないと思います。 この記事ではそんなオブジェクトのメモリレイアウトに目を向けます。 後半ではメモリレイアウトを利用したリフレクションめいた操作を扱います。

C#におけるメモリレイアウト

参照型も値型も実体となるメモリ領域は連続しています。 以下のコードの通り intstringbool をメンバーに持つクラスを定義したとしましょう。

class Hoge
{
    public int Index;
    public string Name;
    public bool IsFuga;
}

このクラスのインスタンスを生成した時,9バイト以上1の領域が確保され,その領域は他のオブジェクトに分断されることなく連続しています。 イメージとしては以下の通り。

byte offsetMemberSize
0Index4 byte
1
2
3
4Name4 byte
5
6
7
8IsFuga1 byte

ここで9バイト「以上」としたのにはちょっとしたカラクリがあり,メモリアクセスの際のパフォーマンス向上のためにメンバー間に余分な領域が挿入されることがあります。 また,上の表ではC#内で記述した順番通りにメンバーを並べていますが,必ずしもこの通りになるとは限らず,メンバーの順番が入れ替えられることもあります。

普通にC#を使っているだけならメモリレイアウトを気にすることはありませんが,この記事の後半にあるようなことをやる好事家やC/C++とやり取りを行う方々はメモリレイアウトの知識が必要になります。 そして,先程述べたような勝手な並び替えや余分な領域の生成が却って不都合になる場合があります。

そこで StructLayoutAttribute の登場です。 この属性を使用するとクラスや構造体のメモリレイアウトを自由にカスタマイズすることができます。 使い方は以下の通りです。

// 無指定の場合と同じく,自動で並び替え等が行われる
[StructLayout(LayoutKind.Auto)]
class AutoLayout
{
    public int Index;
    public string Name;
    public bool IsFuga;
}

// 記述したメンバー順でメモリに配置される
[StructLayout(LayoutKind.Sequential)]
class SequentialLayout
{
    public int Index;
    public string Name;
    public bool IsFuga;
}

// 自分でメモリ上の位置を決められる
[StructLayout(LayoutKind.Explicit)]
class ExplicitLayout
{
    // 0-3 に配置
    [FieldOffset(0)]
    public int Index;
    // 5-8 に配置
    [FieldOffset(5)]
    public string Name;
    // 4に配置
    [FieldOffset(4)]
    public bool IsFuga;
}

StructLayout のコンストラクタに設定する LayoutKind 列挙型の値に応じてメモリレイアウトが変化します。
LayoutKind.Auto の場合では無指定の場合と同じく,パフォーマンス向上のためにメンバーの並び替えや余剰スペースの挿入などが起こり得ます。
LayoutKind.Sequential の場合では,ソースコードの上に記述したメンバーから順でメモリに配置されていきます。
LayoutKind.Explicit の場合,各メンバーに FieldOffsetAttribute を付けて数値でメモリ上の位置を指定することになります。 FieldOffset の値次第では同じメモリ領域上に複数のメンバーが割り当てられている状態を作ることができます。 また,フィールドに FieldOffsetAttribute の付け忘れがあった場合はコンパイルエラーになります。

Unsafeクラス

System.Runtime.CompilerServices 名前空間にあるUnsafeクラスはマネージド/アンマネージドポインター操作のメソッドを実装しています。 Unsafeの名の通り,C#のコンパイラがサポートしている安全性をガン無視した操作が多分にあるため使う際には自己責任な面があります。 そんなUnsafeクラスのメソッドの中で,今回は As メソッドを扱っていきます。

この As メソッドは2つのオーバーロードが存在しますが,どちらもやる事は同じ,「入力したオブジェクトの示すメモリ領域をそのまま別の型として扱う」です。 この As メソッドで行われる処理はキャストと全く異なるもので,本来なら InvalidCastException が飛んでくるような変換でさえ何食わぬ顔でやってくれます。 メソッド内部では特にメモリを改変するような処理を一切せずに型情報だけ書き換えているため,動作も爆速です。 そして引数に指定したオブジェクトと返ってくるオブジェクトが全く同じメモリ領域を参照しているため,うまく変換してしまえば本来なら private 等で隠されているフィールドすらお構いなく改変できてしまうのです。

以下のコードを見てみましょう。 このコードでは本来アクセスできないはずの ReadOnlyCollection<T> の内部リストにアクセスして中身を改変しています。 肝はすり替え対象の UnsafeList<T> のメモリレイアウトを ReadOnlyCollection<T> のそれと同じにしていることです。 これによって ReadOnlyCollection<T>list フィールドに当たるメモリ領域に public メンバーである List を割り当て,内部リストの自由な改変が可能になっています。 このソースコードでは Add メソッドを使っていますが, Clear メソッドを呼べば readOnly の中身を空にすることができますし, illegal.List = null としてしまえば ReadOnlyCollection<T> の内部リストを null にすることができ,メソッドやインデクサなどのあらゆる操作で NullReferenceException が発生するようになってしまいます。

class Program
{
    static void Main(string[] args)
    {
        // 1-3を格納する読み取り専用リストを生成
        var readOnly = new ReadOnlyCollection<int>(new List<int>() { 1, 2, 3 });
        // リストの値を出力
        Console.WriteLine(string.Join(", ", readOnly));

        // ReadOnlyCollection<T> とメモリレイアウトが同じ UnsafeList<T> にすり替え
        UnsafeList<int> illegal = Unsafe.As<ReadOnlyCollection<int>, UnsafeList<int>>(ref readOnly);
        // リストに値を追加
        illegal.List.Add(4);
        // リストの値を出力
        Console.WriteLine(string.Join(", ", readOnly));

        // 出力
        // 1, 2, 3
        // 1, 2, 3, 4
    }
}

// ReadOnlyCollection<T> とメモリレイアウトが同じクラス
[StructLayout(LayoutKind.Sequential)]
class UnsafeList<T>
{
    public IList<T> List;
}

とこんな感じで使い方次第で破滅を齎すことができる Unsafe.As ですが,ジェネリックまわりでは使い道があったりします。

// 1を返す
T GetOne<T>()
{
    Type type = typeof(T);
    if (type == typeof(int))
    {
        int value = 1;
        return (T)(object)value;
    }
    if (type == typeof(uint))
    {
        uint value = 1u;
        return (T)(object)value;
    }
    if (type == typeof(float))
    {
        float value = 1.0f;
        return (T)(object)value;
    }
    if (type == typeof(double))
    {
        double value = 1.0;
        return (T)(object)value;
    }
    if (type == typeof(decimal))
    {
        decimal value = 1.0m;
        return (T)(object)value;
    }

    throw new NotSupportedException($"型引数Tが無効です");
}

このように型引数 T に応じて1の値を返すメソッドを用意する時,普通に実装すると上のコードのように,一旦 object にキャストしてから T にキャストすることになります。 しかしこの方法ではボックス化2が起こってしまい,パフォーマンスが悪くなってしまいます。

ここで Unsafe.As を使うとボックス化を回避することができます(以下のコードを参照)。

// 1を返す
T GetOne<T>()
{
    Type type = typeof(T);
    if (type == typeof(int))
    {
        int value = 1;
        return Unsafe.As<int, T>(ref value);
    }
    if (type == typeof(uint))
    {
        uint value = 1u;
        return Unsafe.As<uint, T>(ref value);
    }
    if (type == typeof(float))
    {
        float value = 1.0f;
        return Unsafe.As<float, T>(ref value);
    }
    if (type == typeof(double))
    {
        double value = 1.0;
        return Unsafe.As<double, T>(ref value);
    }
    if (type == typeof(decimal))
    {
        decimal value = 1.0m;
        return Unsafe.As<decimal, T>(ref value);
    }

    throw new NotSupportedException($"型引数Tが無効です");
}

こんな感じで Unsafe.As メソッドはジェネリックまわりの型変換を行う際の使用が主になると思います。

最初に挙げたような全く同じメモリレイアウトの型間の変換は,標準ライブラリにある System.Memory<T> から System.ReadOnlyMemory<T> への暗黙的な変換にて見られたりします。 この二つの構造体はメモリレイアウトが全く同じで,型情報だけで読み取り専用かそうでないかの振る舞いを変えているわけです。

最後に

C#におけるメモリレイアウトと Unsafe.As メソッドについて扱ってみました。 この知識を使うことは滅多にありませんが,知っておくと面白いネタにはなるのではないかと思います。

以下自分語り。 数年ぶりにAmCr然とした活動に参加しました。 もう卒研をやる身分になってしまいましたがそれっぽいことができて良かったと思います。 また気力と時間があれば何かしらのアクションを起こしてみたいですね。

参考資料


  1. 4バイト(int) + 4バイト(参照型) + 1バイト(bool) = 9バイト ↩︎

  2. 構造体をクラスに変換する時に起こる現象。超重い。 ↩︎

SHARE THIS POST