やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

C#ツアー 構造体

 自分で使うことはなさそう。

成果物

情報源

クラスより構造体のほうがメモリ消費が少ない?

小規模なデータ構造には、クラスよりむしろ構造体を使用するほうが、アプリケーションが実行するメモリ割り当ての数を大幅に減らすことができます。

 そうなの?

たとえば、次のプログラムでは、100 個のポイントの配列を作成し初期化します。 Point をクラスとして実装すると、101 個の別々のオブジェクトがインスタンス化されます。配列に1 個、残りは 100 個の要素に 1 個ずつです。

 日本語がわかりにくいが、1個の先頭アドレスポインタ変数と、100個のPointインスタンス変数、という意味だろう。

public static void Main() 
{
    Point[] points = new Point[100];
    for (int i = 0; i < 100; i++)
        points[i] = new Point(i, i);
}

 ドキュメントには書いてないが、たぶん以下のようなクラスだろう。比較するなら書いてくれよ……。

class Point {
    public int x, y;
}

これに代わる方法が、ポイントを構造体にすることです。

 それで本当にメモリが少なくなるの?

struct Point
{
    public int x, y;
    public Point(int x, int y) 
    {
        this.x = x;
        this.y = y;
    }
}

ここでは 1 つのオブジェクトのみがインスタンス化されます。すなわち、配列に 1 個です。そして、Point インスタンスがその配列内にインラインで格納されます。

 ええと、つまり、「newした構造体のライフタイムはforブロック内のみである。よって、1ループあたり1インスタンス分のメモリしか消費せずに済む」という意味か?

メモリ確保場所 確保場所における解放方法
クラス ヒープ GCガーベジコレクション)がいつか自動で
構造体 スタック ブロック終端直後に必ず

 そしてforブロック文の1つ外側のMain関数ブロックでは、それぞれ以下のようにメモリ確保されていると。

Main関数側のメモリ確保内容
クラス 1個の先頭アドレスポインタ変数と、100個のPointインスタンス変数
構造体 1個の先頭アドレスポインタ変数と、1個(for1回分)のPointインスタンス変数

 要するに、スタック(値型)変数である構造体は、newしたブロック文の終端に達すると即解放される。対して、ヒープ(参照型)変数であるクラスは、newしたブロック文の終端になっても即解放されず、参照されなくなった後にGCが自動的に解放する。だからforブロック内でnewしているなら構造体のほうがMain関数内におけるメモリ消費を少なく抑えることができる。

 クラスと構造体は、そのメモリを解放する仕組みとタイミングが違う。ブロック終端後に即解放される構造体のほうが、メモリ消費を少なくしやすい。ということか。

 この話は構造体がどうというより、スタックとヒープのメモリ解放における話だと思うのだが。

 ドキュメントの最初にあった「メモリ割り当ての数を大幅に減らすことができます」は、「構造体はブロック文内で囲んで寿命を短くすることができる」という意味なのね。別にメモリ消費が少なくなるとは書いていないし。わかりにくい表現だけども。

構造体のほうがメモリ消費が大きい場合もある

  • 構造体全体をコピーすることは通常、オブジェクト参照をコピーするよりも非効率である
    • 割り当てと値パラメーターの引き渡しは参照型よりも構造体のほうが手がかかる
  • 構造体への参照を作成できない(in,out,refを除く)

構造体へのポインタ

 C言語を学習したことがあるなら、構造体を関数の引数に渡す時、ポインタにしたほうがメモリ消費が少ないことを理解できるはず。

 構造体のフィールドすべてを含んだサイズのメモリ確保をするより、その構造体のアドレスを示すポインタ変数のメモリを確保するほうが少なくて済む。

 たとえばint型100個のフィールドをもつ構造体をメモリ確保するより、その構造体のポインタ変数1個をメモリ確保したほうが1/100で済む。int型とポインタ変数の消費メモリは同一のはず。32bitマシンならどちらも32bit(4Byte)だろう。

struct S {
    int i0;
    int i1;
    ...
    int i99;
}
メモリ確保 サイズ(Byte)
構造体のメモリを確保する 400
構造体のポインタ変数メモリを確保する 4

 構造体のメモリをコピーして渡すものを「値渡し」、構造体の参照(アドレス)をコピーして渡すものを「参照渡し」という。以下はメソッドMの引数に構造体Sを渡すときの記述である。

void M(    S s) {} // 値渡し
void M(ref S s) {} // 参照渡し(渡す前に`new`必要。代入可)
void M(out S s) {} // 参照渡し(渡す前に`new`不要。代入可)
void M(in  S s) {} // 参照渡し(渡す前に`new`必要。代入不可)

メモリ計測してみた

 構造体SとクラスCnewしたときのメモリをそれぞれ計測してみた。開始から終了までの間のメモリ差分値である。

|S|C -|-|- 1|-7224|-9184 2|-15720|-5864 3|-27880|10536 4|-3160|-7296 5|2968|-22160 6|-17856|-9968 7|40816|13064 8|7880|10448 9|30776|25040 0|10312|7480

 10回ほどやってみた差分である。見事にバラバラ。まったく何もわからない。負数になるのが謎。GCのせいだろう。計測したい箇所以外のメモリ解放がGCによって自動的に行われているため計測できないのだろう。つまり、メモリ計測は不可能という結論……。

 メモリ管理をGCにまかせているのだから仕方ないのか?

計測ログ

計測ログ

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-7224|4281112|4288336
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-9184|4275664|4284848

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-15720|4285728|4301448
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-5864|4276832|4282696

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-27880|4266264|4294144
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |10536|4307224|4296688

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-3160|4276632|4279792
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-7296|4277744|4285040

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |2968|4290288|4287320
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-22160|4272528|4294688

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-17856|4292656|4310512
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |-9968|4275664|4285632

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |40816|4321496|4280680
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |13064|4300312|4287248

$ ./run.sh 
^[[A^[[B===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |7880|4285672|4277792
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |10448|4296096|4285648

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |30776|4315288|4284512
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |25040|4313920|4288880

$ ./run.sh 
===== S =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |10312|4292728|4282416
===== C =====
方法|差分|終了|開始
----|----|----|----
Env|0|0|0
GC |7480|4291448|4283968

コード

S.cs

class S
{
    MemoryMeasurer mm = new MemoryMeasurer();
    public void Run()
    {
        Console.WriteLine("===== S =====");
        Point[] points = new Point[100];
        mm.Start(); // この時点とメモリと
        for (int i = 0; i < 100; i++) {
            points[i] = new Point(i, i);
        }
        mm.Stop(); // この時点のメモリを取得する
        mm.Show(); // その差分を表示する
    }
    struct Point {
        int x, y;
        public Point(int x, int y) => (this.x, this.y) = (x, y);
    }
}

C.cs

class C
{
    MemoryMeasurer mm = new MemoryMeasurer();
    public void Run()
    {
        Console.WriteLine("===== C =====");
        Point[] points = new Point[100];
        mm.Start(); 
        for (int i = 0; i < 100; i++) {
            points[i] = new Point(i, i);
        }
        mm.Stop(); 
        mm.Show(); 
    }
    class Point {
        int x, y;
        public Point(int x, int y) => (this.x, this.y) = (x, y);
    }
}
class MemoryMeasurer
{
    long[] envs = new long[3];
    long[] gcs = new long[3];

    public void Start()
    {
        SetMemory(ref envs[0], ref gcs[0]);
    }
    public void Stop()
    {
        SetMemory(ref envs[1], ref gcs[1]);
    }
    public void Show()
    {
        CalcDiff();
        Console.WriteLine("方法|差分|終了|開始");
        Console.WriteLine("----|----|----|----");
        Console.WriteLine($"Env|{envs[2]}|{envs[1]}|{envs[0]}");
        Console.WriteLine($"GC |{gcs[2]}|{gcs[1]}|{gcs[0]}");
    }
    private void SetMemory(ref long env, ref long gc)
    {
        env = Environment.WorkingSet;
        gc = GC.GetTotalMemory(true);
    }
    private void CalcDiff()
    {
        envs[2] = envs[1] - envs[0];
        gcs[2] = gcs[1] - gcs[0];
    }
}

Program.cs

class Program
{
    static void Main()
    {
        new S().Run();
        new C().Run();
    }
}

run.sh

csc -nologo -recurse:*.cs -nullable:enable -langversion:preview
chmod 755 ./Program.exe
./Program.exe

 以下参考

対象環境

$ uname -a
Linux raspberrypi 4.19.42-v7+ #1218 SMP Tue May 14 00:48:17 BST 2019 armv7l GNU/Linux