9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

C#のクラスの内部構造を黒魔術で分析してみた

Posted at

#全てはこの疑問から始まった。

皆さんはC#プログラマをやっているなら一度は疑問に思ったことがあるでしょうそうでしょう。

「インスタンスってどうやって変数を保持してるんだろう」

と。

え?思ったことないって?それは粛(殴)

そもそもC#という言語はポインタなんぞ面倒なことは気にしなくてもコードがかけてしまう、そんな素敵な言語なのです。
従って我々C#プログラマは、このインスタンスはどこに保存されているいるんだ?だとか、Fieldってどこに保存されてるの?とか、ましてやTypeってどうやって管理されているの?なんてことは気にしなくて良いのです。

実際、そんなことはネット上に記事で載ってないです。

ただ時に「無駄なメモリなんぞ1bitも使いたくない!」とか「boxing?なにそれおいしいの?するわけないじゃん笑」とか「純粋にクラスってどうなってるんだ」とか考える変態さん(褒め言葉)がいらっしゃるわけです。

そこでこの記事では、そんな変態さんのためにそもそも論に立ち返り、

「クラス」の根本的な仕組み

をポインタという概念から紐解いていきたいと思います。

この記事を読んだ暁には皆さんも最適化の沼に嵌っていることでしょうきっと。

※記事の内容に間違いなどありましたらコメントにて指摘いただけると幸いです。

#この記事を読んでわかること

  • インスタンスはどのようにして変数を保持しているのか
  • Type、FieldInfoの根本的な仕組み
  • Boxingなんてもってのほか、ILを使って最適化?
    -->Unsafeを使えば一発で解決

#インスタンスとは何ぞや
C#はオブジェクト指向な言語です。従って、クラスという構造(正確にはType)を定義し、そのインスタンスを作ることで、個別にデータを管理できるわけです。(とっても便利でわかりやすい)

しかし、そんな恩恵を受けられるが故、裏ではちょっと複雑なことをしているのです。

##値型と参照型(理論)
さて、質問です。クラスの中で実体のあるデータはどんなデータでしょう?

例えば下のようなクラスがあったとします。

Data.cs
public class Data
{
    public int val;
    public Nested nested;
    public class Nested
    {
        public int val2;
    }
}

実際にデータ(値)を持っているのはvalval2だけです。nestedは値を持ってはいません。
即ち、実体のあるデータは「値を持っている」データのみです。
このように、直接値を持たなくても良い変数を参照型変数、値を持つ変数を値型変数と言います。

値型はスタック領域(実際に値を持つメモリ領域)に、参照型はヒープ領域(インスタンスを保持するメモリ領域)という仮想メモリ領域に保持されています。

そして、この参照型はヒープ領域にいるインスタンスの参照(ポインタ)を保持しているのです。

##値型と参照型(現実)
しかし、現実の保持のされ方を見てみるとこの理論ではわかりにくい部分があります。
もっと直感的に示した図を載せます。
image.png

因みにこちらの図に示している構造は私がポインタを解析して出した答えなので間違っていたらごめんなさい。

そもそもデータは基本(ローカル変数以外は)インスタンスの中に保持されます。値はむき出しにならず大体の場合はクラスの中に入ってしまいます。ですので、スタックとかヒープに分けて考えるより、こっちの方が直感的かなと思います。

C#のインスタンスではまず、先頭に自分のインスタンスのポインタをInt64のサイズで保存しています(8byte)。そして、その後の8byteでType Handlerのポインタを保持しています。(後の項で説明します)

その後、そのインスタンス内に保持しているインスタンス(ネストしたインスタンス)へのポインタを保持、最後に実データを保持しています。

このように、インスタンスはポインタとデータのbyte配列で構成されているのです。

はい、これでインスタンスのデータを保持することができるようになりました。
(インスタンスはどのようにして変数を保持しているのかの答え)

#Typeとは何ぞや
C#プログラマにとって、Typeは大事な存在です。

こいつがなければType-safeなプログラムがかけず、勿論我らがIntelliSense君(コードを予測して出してくれるアレ)も機能してくれません。

しかし、実際にコードを走らせる際、「このTypeとこのフィールドを...」とかやっていたら重くて仕方ありません。そこで、C#はRuntimeTypeHandleというものをメモリ上に生成することで素早いコード実行を実現しています。

そして、インスタンスを生成する時は必ず、先頭から9 byte目から16 byte目までの8 byte区間にTypeHandleのポインタを書き込みます。

因みにこのTypehandleには

code.cs
//Handleにアクセス
typeof(T).TypeHandle;
//IntPtrにアクセス
typeof(T).TypeHandle.Value;

でPublicにアクセスできますので見てみるといいかもしれないです。

また、Fieldなどについても同様にFieldInfoの中にFieldHandleがありますので是非確認してみると幸せになれるかもしれません(たぶんなれない)

(Type、FieldInfoの根本的な仕組みの答え)

#Unsafeを使った黒魔術

さて皆さんお待たせいたしました。

実践編です。

Reflection?なにそれおいしいの。

Reflectionはよく耳にするFieldなどの動的取得法です。

いいところ:簡単。誰でもできる。
悪いところ:遅い。黒魔術じゃない。

はい、皆さんなら使いませんね。(強制)

というのは投げやりなので一応説明しておきます。

Reflectionを使えばクラスの内部構造を取得し、その取得した結果に基づいてFieldの値を取得できてしまいます。

こんな感じで取得します。

FieldGetterReflection.cs
public class FieldGetter
{
    void Getter()
    {
        FieldInfo fieldInfo = typeof(T).GetField("FieldName");
        T obj = "Data";
        var data = fieldInfo.GetValue(obj);
    }
}

後はfieldInfoとobjに適切なデータを入れてください。
ね?とっても簡単でしょう?

しかし、ここに重要な落とし穴があります。
fieldInfo.GetValue(obj);この部分のコード定義を見てみましょう。

image.png

ふぁっ!?なんと?
返り値がobjectじゃないですかやだー

そう。これがReflectionが遅くなる原因です。

Fieldの中身が先ほど話した参照型であれば大した問題はおきません。
しかし、これが値型だった場合は大きな問題が生じます。

値型はスタック領域にデータがいます。
しかし、objectは参照型であるため、値型のデータを引っ張ってきて__わざわざ__ヒープ領域に持っていき、さらに__わざわざ__値型に直すハメになるのです。コードでこれを再現するとこんな感じ。

hogehoge.cs
// 100をdataの中に入れる
int data = (int)(object)100;

あ ほ く さ。

はい。これが理由です。

なんでGetValue<T>を実装しなかったんだろう()。

ということでReflectionを使うのがおすすめできない理由はこれです。

IL生成?やっぱやりたくない。

よく最適化をするにあたって、ILを使うという解決策があります。

ILとは共通中間言語(intermediate language)の略で、.Netで最も低水準な命令を書くのに使われる言語です。

そこで直接ILを書き、動的にコンパイルさせちゃえばいいじゃんということになるわけです。

因みに、フィールドの変数を取ってくるILコードを作っておいたので参考までに。
(中身は単純で、インスタンスをスタックに積み上げ、Ldfldに渡してRetというものを書いてるだけです)

FieldGetterIL.cs
public class FieldGetter
{
    void Getter()
    {
        FieldInfo fieldInfo = typeof(T).GetField("FieldName");
        T obj = "Data";
        var met = new System.Reflection.Emit.DynamicMethod("Get",
            fieldInfo.FieldType, new Type[1] { typeof(FieldInfo) });
        var il = met.GetILGenerator();
        il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0);
        il.Emit(System.Reflection.Emit.OpCodes.Ldfld, fieldInfo);
        il.Emit(System.Reflection.Emit.OpCodes.Ret);
        var del = (Func<Delegate, int>)met.CreateDelegate(typeof(Func<Delegate, int>));
        var func = new Func<T>(obj);
        del.Invoke(func);
    }
}

TにはオブジェクトのType、objには実際のオブジェクトを入れればOKです。
後は出来たdelをちゃんとキャッシュしておけば問題ありません。

実際大変便利な、便利すぎる手法なのですが、残念ながらC#が多く活躍するUnityで使われるIL2CPP(c++に直してくれるというもの、iOSなどでは良く使われる)では使用できません。
また、せっかくポインタでやればいいのに、動的コンパイルの時間が無駄です。

おすすめしたいのはやまやまですが、IL2CPPで使えないのが痛すぎる。ので、却下です。

Unsafeという黒魔術(真)

最後に残された手段、Unsafeクラスです。
これはそもそもデフォルトで使えないようになっているのでNuGetとかでSystem.Runtime.CompilerServices.Unsafeと調べ、インスコしちゃいましょう。

image.png

こんな感じです。

すると、System.Runtime.CompilerServices.Unsafeが使えるようになります。
それでは先ほどの例を基にコードを作っていきましょう。

image.png
まず、Dataのインスタンス、dataインスタンスのポインタを取得してみましょう。

Unsafe.cs
Unsafe.As<Data, IntPtr>(ref data)

こんな感じです。これでポインタ(を安全に使える)IntPtrが返ってきます。
このIntPtrが「自分のポインタ」の部分であるハズです。

従って、nestedを示すポインタは

Unsafe.cs
unsafe
{
    var data_ptr = (byte*)Unsafe.As<Data, IntPtr>(ref data).ToPointer();
    var nested_ptr_arr = new byte[8];
    for (int i = 0; i < 8; i++)
    {
         //先頭の16byte分ずらしたところから8byte分だけ切り取る
         nested_ptr_arr [i] = *((byte*)data_ptr + 16 + i);
    }
    var nested_ptr = (void*)BitConverter.ToInt64(nested_ptr_arr, 0);
}

こんな感じで取得できます。(OffsetとかBitConverterを使わないとかそういう最適化をすればもっと早いですが。)
こういう感じで、ポインタを駆使しながら解析をしてしまえば、理論上最速でデータをそのまま持ってくることができます。

また、同様にvalのポインタ(val_ptr)を計算し、Marshalで適当に確保したbyte領域に対しコピーして突っ込んであげれば

Copy.cs
var dest = Marshal.AllocHGlobal(4);
Unsafe.CopyBlock(dest.ToPointer(), val_ptr, 4);

こんな感じで理論上最速でbyte変換(実際はそもそも変換をしない。stackにいるbyteデータをそのままコピっているだけなので)しちゃえばOKです。

私が考え付く限り、これが最速のチューニング方法です。

#結局?
長々とお付き合いいただきありがとうございました。

「インスタンスってどうやって変数を保持してるんだろう」

の疑問を解決することは出来ましたでしょうか?

Unsafeを好きになってくれましたでしょうか?

この記事を読んで、少しでも好きになってくれたら私としてはとても嬉しい限りです。

最速というところに焦点を置き、仕組みを一から研究してみるというのも面白いC#の遊び方なのではないでしょうか?

是非これを機に、皆さんも黒魔術の沼にはまってみてはいかがでしょうか?

9
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?