C#に潜むstructの罠

こんにちは。技術部平山です。 この記事ではC#のstructを使った際にはまった罠について書きます。 Unityでの体験を軸にお話しますが、Unityに限ったことではないかと思います。

お急ぎの方のために結論を申しあげますと、structを使うなとなります。 どうしてもstructを使いたい気分になった時に、罠にはまって時間を無駄にする覚悟をした上で使いましょう。 未来に活きる良い失敗ができると思いますし、最終的には製品の性能も上がるとは思いますが、 structを使わないといけない理由は、たぶんありません。なくても製品は作れます。

しかし、一回もstructと書かなかったとしても、C#で書く限りstructからは逃れられないのです。

structとは

C#の型にはclassとstructがあります。 ...といった概論については、C#のクラスと構造体の違い・使い分け方 という良い記事がありますので、そちらをご覧になるのが良いと思います。 一次情報としてはMSDNのリファレンス、 それに公式のプログラミングガイド を読むのが良いでしょう。

とはいえ、その二つで何が根本的に違うのか?ということは、案外説明されていません。 おそらく基本的すぎて説明する気にならないからだろうと思いますが、 Cのような原始的な言語の経験をお持ちでない方も多いはずで、 実はあまり知られていないのではないかと思います。少し説明いたしましょう。

ヒープとスタック

変数にはメモリが必要でして、メモリには大きく分けて2種類あります。 ヒープとスタックです。 C#もそういう言語の一つです。

classのインスタンスをnewすると、ヒープと呼ばれるメモリが使われます。 Heapという英単語の意味 を知っているとイメージしやすいかと思いますが、「ゴチャッと山になってる何か」です。 トランプの山札はheapですし、食べ物を山盛りにしてもheapと言います。 メモリがゴチャッと山になっている所があって、そこから必要なだけ取ってくるイメージですね。

一方、structのインスタンスをnewすると、スタックと呼ばれるメモリが使われます。 これもStackという英単語の意味 を知っていた方がイメージしやすいでしょう。「積み重なった何か」です。 箱や皿や本が積み重なっているとstackです。

「山になってる何か」と「積み重った何か」は何が違うの?という話ですが、 heapはごっちゃりしていて、stackは結構ちゃんと積んである、という違いがあります。 プログラミング的な違いもそのイメージとだいたい合っていまして、 ヒープはいろんな所にアクセスできますが、スタックは綺麗に積んであるので一番上にしかアクセスできません。 下の方にあるものは、上にあるものをどけないとアクセスできないのです。

structの場合

以下のコードをご覧ください。

struct T{ int _x; }

void Foo()
{
    T a = new T();
    Bar();
}
void Bar()
{
    T b = new T();
}

Foo()を呼ぶと、Tのインスタンスがスタックの中に作られて、aという名前がつきます。 Tはstructなので、newするとスタックに置かれます。

さらにBar()を呼ぶと、Tのインスタンスがスタックに作られて、bという名前がつきます。 Bar()の実行中も、aと名前がついたインスタンスはメモリに存在しています。 そうでないと、Barが終わった後にaが使えませんよね? 一方、Bar()が終わると、bと名前がついたインスタンスはメモリから消えます。

aの上にbが積まれていて、bをどけない限りaが見えない、 と考えれば良いかと思います(Bar実行時にFooの変数が見える言語もありますがC#は見えません)。 そして、どけたものはもう使えません。完全に消えます。

この「今実行中の関数で作った変数しか見えない」「関数が終わったら消える」という 制限のおかげで、メモリ確保や管理が著しく簡単になり、 aやbをメモリに用意したり、解放したりする際の手間はゼロ同然になっています。

そして、structな型の代表例がintです。

void Foo()
{
    int a = 0;
    Bar();
}
void Bar()
{
    int b = 0;
}

こう書くのは、

void Foo()
{
    int a = new System.Int32(0);
    Bar();
}
void Bar()
{
    int b = new System.Int32(0);
}

の省略形にすぎません。 System.Int32 はstructなので、a,bと名前をつけた インスタンスはスタック上に作られ、 Bar()が終わればbは消え、Foo()が終わればaは消えます。

classの場合

ところが、classは違います。

class T{ int _x; };

void Foo()
{
    var a = new T();
    Bar();
}
void Bar()
{
    var b = new T();
}

classはnewするとインスタンスがヒープに作られます。ヒープは積み重なっていないので いつでもどこでもアクセスでき、関数を出ても勝手には消えません。 Bar()が終わってもbはまだメモリにあり、 Foo()が終わってもaはまだメモリにあります。

今の場合、いずれにせよ関数を出ればアクセスできなくなるので同じに見えますが、 中身は全然違っているのです。

returnと代入

さて、関数を出ると変数がなくなってしまう、となると、 変数をreturnした時はどうなるのでしょうか。

struct T{ int _x; };

void Foo()
{
    var a = Bar();
}
T Bar()
{
    var b = new T();
    return b;
}

ここでFoo()を呼んだ時に起こることは以下のようになります。

  • Foo()にて、Bar()の返り値はTで、Tはstructなので、まずTのインスタンスをスタックに作ってaと名前をつける。
  • Bar()を実行し、Tのインスタンスをスタックに作り、bと名前をつける。
  • Bar()が終わるとbが消えてしまうので、その前にbのコピーをスタック上に作る。これには名前がない。
  • Foo()に戻ってきて、Bar()が作ったbのコピーの中身をaにコピーする。
  • Foo()が終わる時にはaが消える。

Bar()が終わる時にはbは消えるので、それをFoo()が受け取ることはできません。 受け取るのはbのコピーです。そして、aはBar()を呼ぶ前にスタックに作られています。 Tをnewしたのは一回だけですが、a、b、bのコピー、という3つのT型インスタンスが出てくるのです。

  • structをreturnすると名前のないコピーが作られる
  • structを=で代入すると、中身がコピーされる。左辺が初出なら、前もってスタックにインスタンスが作られる

ということです。しかしclassは全く違います。

class T{ int _x; };

void Foo()
{
    var a = Bar();
}
T Bar()
{
    var b = new T();
    return b;
}

structをclassにしただけですが、挙動は以下のように変わります。

  • Foo()にて、Bar()の返り値はTで、Tはclassなので、aという名前だけ用意しておく。
  • Bar()にて、Tのインスタンスをヒープの中に作り、bという名前でアクセスできるようにする
  • Bar()がbをreturnし、これをaに代入すると、aはbが指しているのと同じインスタンスを指すようになる。

まず、classなので作ったTのインスタンスはBar()を出ても消えません。 そして、classの=による代入は、「左辺が右辺が指しているものを指すようになる」という動作で、 コピーではありません。 ここで、T型のインスタンスは1つしか作られず、コピーは一度も発生しません。

return=の挙動が全然違うのです。

classという単語を使うかstructという単語を使うかでこれほどの違いがあり、 それがさまざまな所で挙動の違いを見せる原因となります。 では具体的にどんな恐ろしいことが起こるかを見ていきましょう。

UnityでよくあるVector3の恐怖

UnityにはUnityEngine.Vector3 という型があります。こいつはstructです。ですから、

void Foo()
{
    var a = Bar();
}
Vector3 Bar()
{
    var b = new Vector3();
    return b;
}

と書けば、Vector3のインスタンスは計3回スタックに作られ、 2回のコピーが発生します。

返すコピーは変更不可

さて、少し実用に近づけてみます。

static Vector3 _v;
Vector3 GetV()
{
    return _v;
}
void Foo()
{
    GetV().x = 4f;
}

_vをstaticに用意しました。staticなstructはヒープでもスタックでもない、 固定的な場所に置かれ、永遠に消えません。 では、Foo()実行後の_v.xはどうなっているでしょうか?

これはそもそも実行できません。コンパイルが通らないのです。 GetV()が返すのは、_vそのものでなく、「名前がない_vのコピー」です。 そして、C#では、「関数が返した名前のないコピー」は変更不能なのです。 つまり、GetV().xは変更不能なので、=による代入ができません。 このために、上のようなコードを書いて_v.xが4になったと勘違いするバグは 起きないようになっています。親切設計ですね。

Vector3.Set()問題

ですが、UnityEngine.Vector3には恐ろしい関数が用意されています。

var a = new Vector3();
a.Set(1f, 2f, 3f);

Set()によって、a.xが1、a.yが2、a.zが3になるわけで一見便利なのですが、 これによって以下のような事故が起こります。

GetV().Set(1f, 2f, 3f);

これはコンパイルを通り、エラーなしに実行されます。 そして、_vは何も変化しません。 GetV()が返した「名前のない_vのコピー」 が(1,2,3)になりはするものの、それは何にも代入されていないのでそのまま消えるのです。

悲しいことに、C#は直接.xのように指定して代入文を書くと コンパイルエラーにしてくれるのに、変数をいじる関数呼び出しはコンパイルできてしまいます。 これによって「よし、_vが(1,2,3)になったな」と思ってバグる人は私だけではないと思います。

プロパティとの素敵なコラボレーション

とはいえ、これだけならまだ害は少ないと言えます。例えば、

struct Vertex{ public Vector3 position; }

void Foo()
{
    v = new Vertex();
    v.position.Set(1f, 2f. 3f);
}

は問題なく動き、vが(1,2,3)になります。これが本当に牙を剥くのは、 C#が持つ「プロパティ」という素敵な機能と併用した時です。

struct Vertex{ public Vector3 position{ get; set; } }

void Foo()
{
    v = new Vertex();
    v.position.Set(1f, 2f. 3f);
}

アウトです。vは(1,2,3)にはなりません。

プロパティというのは 「関数を変数に見せかける仕組み」 です。 オブジェクト指向入門 という、私がいたく感動した本があるのですが(すごくおすすめ)、 そこには「関数か変数かは実装であって、インターフェイスで区別できる必要はない」 というようなことが書かれています。 変数が本当に変数なのか値を返す関数なのかを意識しないで使えるべきだ、 ということで、私もその思想には共感します。

しかし、C#でstructを使う場合は話が別です。

思い出してください。関数がstructを返せば、それはコピーにすぎません。 ですから、このコードはv.positionを(1,2,3)にはできません。 何も起こらないコードになるのです。

実際の状況で何が起こるかと言えば、

transform.position.Set(1f, 2f, 3f);

と書いた時に、物体の位置を(1,2,3)にしたつもりでいたが、なってない、というような状況です。 Unity初心者の頃に私も罠にはまりました。

ここから導かれる教訓は、structに中身を変更する関数を用意してはならない、となります。 これを守るには、「コンストラクタが終わったら書き換え不能にする」 (immutableとも。「変化できる」のmutableにimをつけて対義語にしているので「変化できない」の意)か、 「書き換え得る変数は素直にpublicにしちゃう」ということになります。 可能なら前者にすべきですが、それで性能が劣化する場合には 後者にするケースもあるかと思います。

配列などにつっこんだ時に起こる地獄

私が今でもたまにやらかしてバグる事として、 配列などのコレクションにつっこんだ時の操作をミスることがあります。

var a = new int[10];
var b = a[4];
b = 7;

実行後のa[4]の値はいくつでしょうか?

7じゃありません。intはstructであり、structの代入(=)はコピーです。 bはスタックに置かれたaとは何の関係もない変数で、それが7になっただけです。 var b = ...と書いた時に、右辺の型がstructであればインスタンスがスタックに作られます。 bは単なる名前でなく実体があり、それが7になるのです。

当たり前だろ、と思われるかもしれません。私だってそう思いますが、 以下のように書いてもそう言えるでしょうか。

struct T{ public int x; }

がどこかに書かれているとして、

var a = new T[10];
var b = a[4];
b.x = 7;

を実行した後のa[4]の値は?

もちろん7じゃありませんね。 Tはstructなので代入はコピーですし、 var b = ...と書いた時にはインスタンスがスタックに用意されます。 単にb.xが7になるだけで、a[4]とは何の関係もありません。 にも関わらず、私は結構な頻度でこのミスをします。 たぶん、classでなくstructにしたことを忘れているからです。

とはいえ、こんなケースなら、

a[4].x = 7;

と書けばいいし、実際そう書くわけですが、以下のようなケースになると話が違ってきます。

struct T
{
    public int x, y, z, w;
}

がどこかにあるとして、

a[index].x = 1;
a[index].y = 2;
a[index].z = 3;
a[index].w = 4;

と書くのは結構面倒くさいですよね。配列をループで順にアクセスしながら中身を設定する、 というような時に、4回もa[index]を書くのは面倒くさいわけです。

var b = a[index];
b.x = 1;
b.y = 2;
b.z = 3;
b.w = 4;

と書きたい衝動に駆られます。そしてバグるのです。

対策

では、こういう場合の正解はどのようなものでしょうか。一つ考えられるのは、

struct T
{
    public void Set(int x, int y, int z, int w){ ... }
}

を用意することです。そうすれば、

a[index].Set(1, 2, 3, 4);

と書けるので、別の変数に入れてコードを短くしよう、という衝動を抑えられます。 しかし、これは先程の 「structに中身を変更する関数を用意するな」 に違反します。 じゃあどうするか?

struct T
{
    public T(int x, int y, int z, int w){ ... }
}

というコンストラクタを用意して、

a[index] = new T(1, 2, 3, 4);

とすればだいぶマシになります。 コンストラクタなら後から呼べないので、事故の原因にはなりません。 x,y,z,wをprivateにできて、さらにreadonlyまでつけられれば最高です。

ただ、後から書き換えが必要な場合にはそうも行きません。publicにしておいて、

var b = a[index];
b.z = 3;
a[index] = b;

のように、書く場合もあるでしょう。この「変数を用意してコピーして、一部書き換えてから、コピーで戻す」は、 Unity使用時には頻繁にやる操作で、 例えばTransform型が持つpositionのyだけ+5したい、という場合、

var p = transform.position;
position.y += 5f;
transform.position = p;

と書く羽目になるわけです。配列とは関係ありませんが、 「コードが長くなって腹立たしい」という問題点は同じです。 transform.positionと2回書きたくないわけですから。

防ぐには

つまり、structを使う場合はコードが多少長くなっても我慢するしかない時がある、 ということを承知しておく必要があります。 下手に短くしようとすると、罠にはまります。 Vector3のように罠の存在が有名ならばいいですが、 自作した型だと、structにしたことを忘れていることは多いでしょう。

このような事情から言って、他人が使う型をstructにするのは極力避けるべきである、 という教訓が導かれます。自分ならともかく、他人が作った型がclassかstructかなんて いちいち気にしたくはありません。他人にそのような面倒を強いるのは、 良い設計ではないのです。

そして、「未来の自分は他人」であり、 「別の部分を実装している自分も他人」ですから、 structは何かのclass内でprivateに定義せよという、 より実践的なルールが導かれます。

UnityのVector3型のように性能的な事情でやむを得ない場合はあるでしょうが、 せめてSet()なんて関数を作らないようにしましょう。 もしどうしてもそれが必要なら、structを返すプロパティは極力避けて、GetHoge()やHoge()にしましょう。 それなら関数とすぐわかります。foo.v.Set(4)よりはfoo.GetV().Set(4)の方が 問題に気づきやすくなります。

性能が激落ちする罠

structはメモリ確保が高速なわけですが、その代わりに頻繁にコピーが発生しますから、 「確保解放の回数に比べてコピー回数が多い、あるいは型の容量デカい」用途だと逆に遅くなる可能性があります。 変数を10個以上持った型をstructにするのは、たぶん間違っています。

また、structを使ってまで高速化したいと言うならば、 refout の使い方はよく研究した方が良いでしょう。コピーを減らせます。 structをreturnしたくなったらoutを使う方が良いかもしれません(コンパイラが勝手にその変形をやってくれることを期待したい所ですが)。

ですが、そんな細かいことよりも遥かに問題になることがあるのです。

GC Alloc地獄

以下のコードをご覧ください。

struct T
{
    public T(int x){ _x = x; }
    int _x;
}
void Foo()
{
    var set = new Dictionary<T, int>();
    // ... いろいろする。例えば、
    if (set.ContainsKey(new T(3)))
    {
        // 何か
    }
}

structなTを作って、それをKeyとしたDictionaryに入れ、いろいろします。 Add()したり、Remove()したり、ContainsKey()したり、TryGetValue()したり、 まあいろいろするでしょう。

ですが、これが結構な遅さになります。実際にどんな状態か見てみましょう。 以下のようなコードを用意します。私はUnity上で作りましたが、 Unityである必要はありません。

struct T
{
    public T(int x){ _x = x; }
    int _x;
}
public void Foo()
{
    var set = new Dictionary<T, int>();
    const int N = 1000 * 1000;
    for (int i = 0; i < N; i++)
    {
        set.Add(new T(i), i);
    }
    int sum = 0;
    for (int i = 0; i < N; i++)
    {
        int value;
        if (set.TryGetValue(new T(i), out value))
        {
            sum += value;
        }
    }
    for (int i = 0; i < N; i++)
    {
        set.Remove(new T(i));
    }
}

Unity2018.3.9、MacBook Pro mid-2014のエディタ実行にて、 Deep Profile有効状態で測定したところ、 6.63秒かかりました。これが速いか遅いかはわかりませんが、 何に時間を食っているのかをプロファイラで見てみましょう。

f:id:hirasho0:20190326121528p:plain

見事にDictionaryの操作です。Add()、TryGetValue()、Remove()の3操作ですが、 GC Allocの所にご注目ください。 TryGetValue()、Remove()で57.2MB、Add()で70.5MBもGC Allocしています。 GC Allocしているということは、classをnewしてヒープの中にインスタンスを作った、 ということです。

まあAdd()でnewが必要なのはわかります。 Dictionaryの内部データ構造に必要なものを何かしらnewするのでしょう。 しかし、TryGetValue()やRemove()でnewするのは謎です。

そこで、もっと詳しく見てみましょう。

f:id:hirasho0:20190326121532p:plain

TryGetValue()内でObjectEqualityCompare.Equals()なるものを呼んでおり、 それが猛烈にGC.Allocしています。また、 ObjectEqualityComparer.GetHashCode()なるものも呼んでいて、 これも猛烈にGC.Allocしています。

Dictionaryの内部実装はハッシュ ですから、ハッシュ値を計算する必要があります。 GetHashCode()はそのためのものでしょう。

さらに、ハッシュ値が等しい場合には実際に等しいかどうかを調べる必要があり、Equals() はそのためのものと推測できます。 そして、どちらも上のTでは定義していないので、デフォルトの実装が呼ばれ、 それは全ての型の基底であるSystem.Object(あるいはobject)GetHashCode()Equals() ということになります(実際にそうかは実装依存で、事実このUnity内の実装ではそうなっていませんが、概念上はそれでいいと思います)。

では、これらはなんでGC Allocしてるんでしょうか? もうおわかりでしょう。boxing(ボクシング)です

boxingについて

System.Objectはclassです。しかしTはstructです。 structに対してclassの関数は呼べません。 そういう時には、boxingと呼ばれる処理が自動で行われて、 structがclassに変換されます。

boxingというのは言うならばこんな処理です。

struct T{ ... }
class BoxedT{ T _value; }

var boxed = new BoxedT(t);
boxed.GetHashCode();
boxed.Equals(...);

structなTがあるとした時、それに対応して、 Tを持ったBoxedTなるclassが自動で定義される、とでも考えてください。 System.Objectの関数を呼ばねばならなくなった時には、 BoxedTをnewして、中にTを入れておきます。 BoxedTはclassでSystem.Objectを継承しますから、 System.Objectの関数が呼べますし、 またTにキャストしたくなったら、中身を取り出します(取り出すのはunboxingと言います)。

さて、newしてますよね。

classのnewですから、ヒープの中にインスタンスが作られます。 これはスタックにインスタンスを用意するよりずっと重い処理です。 高速化するためにstructにしたはずなのに、これでは台無しになってしまいます。

IEquitable<T>

ではどうすればいいか?こうします。

struct T : System.IEquatable<T>
{
    public T(int x){ _x = x; }
    public bool Equals(T other){ return _x == other._x; }
    int _x;
}

TにSystem.IEquatable<T> を実装させます。必要な関数は Equals() で、同じならtrue、違えばfalseを返させます。 これだけで、実行時間が4.37秒まで減りました。ほぼ1.5倍です。 プロファイラを見てみましょう。

f:id:hirasho0:20190326121536p:plain

TryGetValue()のGC Alloc量が57.2MBから19.1MBになりました。1/3です。 そして、先程あったObjectEqualityCompare.Equals()が消滅して、 代わりに自分で定義したT.Equals()が呼ばれています。

structは普通の継承はできませんが、interfaceの実装はできます。 そして、System.IEquatable<T>を実装していれば、 それが「二つのものが等しいかどうか」の判定に使われ、 System.Objectへのキャストが不要になるのです。 単にEquals(T)を実装しただけでは使われず、interfaceの指定が必要なことに注意しましょう。

GetHashCode()のoverride

ではもう一つのGetHashCode()もどうにかしましょう。 Equalsと同じように、GetHashCode()を持つinterfaceが何かあるのでしょうか? これが不思議なことにないのです。こうします。

struct T : System.IEquatable<T>
{
    public T(int x){ _x = x; }
    public bool Equals(T other){ return _x == other.x; }
    public override int GetHashCode(){ return _x; }
    int _x;
}

GetHashCode()をoverrideするだけです。これで、実行時間が3.05秒まで減りました。 元の倍速ですね。プロファイラを見るとこうなります。

f:id:hirasho0:20190326121540p:plain

TryGetValue()とRemove()のGC Allocが0になりました。Add()は仕方ないですね。

なんでGetHashCode()をoverrideしないといけないのか、 逆に、なんでGetHashCode()をoverrideすれば良いのかは、正直よくわかっていません。 structは全てValueType というclassを継承している「ことになって」おり、 何もしなければValueType.GetHashCode() がデフォルト実装として使われます。しかし、structはvirtualな関数を持つことはできず、 そもそも継承自体できませんから、ValueTypeにキャストしたり、ValueTypeから元に戻したりすることはできません。 よくわかりませんが、「まあそういうものなんだろう」と思っておきます。 そのうちコンパイラが進歩して何もしなくても良くなるのかもしれませんが、 でも現状(Unity2018.3.9)はGetHashCode()をoverrideしないと 無駄なGC Allocが消えないようです。

余談: GetHashCode()の実装について

GetHashCode()の実装は、実は簡単ではありません。 上の例では中身がint一個なのでそのまま返して終わりでいいのですが、 複数の変数を含んでいたり、複雑なデータ型だったりすると、 値を作るのは簡単ではなくなります。下手な値を返すと、 DictionaryやHashSetの性能が落ちてしまいますし、 それどころか正しく動かない可能性もあります。

GetHashCode()は、

  • 等しいとみなされるものは同じ値を返さねばならない

という条件を絶対に守らねばなりません(異なるものが同じ値を返すのはかまわない)。 今回はstructの話なので関係ありませんが、 classに対してGetHashCode()を実装する場合は問題になります。 classに対するGetHashCode()のデフォルト実装は、中身が同じでもインスタンスが異なれば 異なる値を返します。ですから、stringのように「中身が同じなら同じとしたい」 のであれば、GetHashCode()を自作する必要があるのです。 Equals()を「中身が同じならtrue」として実装した場合、 GetHashCode()をデフォルトのままにしておくとバグるのです(つい最近やらかしました)。

classだったらどうなの?

さて、がんばってstructで高速化してきましたが、そもそもclassだったら速度はどうなのか? ということは当然確認する必要がありますね。見てみましょう。

class T
{
    public T(int x){ _x = x; }
    int _x;
}

と、何もinterfaceを実装せず、ただclassにしてみました。

3.28秒でした。structで高速化したのとほとんど変わりません。 だから言ったでしょう? 結論は 「structを使うな」 だって。

プロファイラを見てみましょう。

f:id:hirasho0:20190326121548p:plain

GC Allocが129MBと、structでがんばった時の2.5倍くらい食っていますが、 速度は大差ありません。実はnewって案外速いんですよ

ただ、structの方が速いのは事実ですし、何よりメモリ消費量が全然違います。 それをもって「がんばる価値がある」と考えるならstructを使うのも良いでしょう。 しかし、製品で100万個もインスタンスを作ることはまずないでしょうし、 実際には差は微々たるものになると思われます。 これだけ罠があるstructをそれでも使いますか?

SortedDictionaryやAray.Sort()を使う場合

System.IEquatable<T>を実装してGetHashCode()をoverride、 というのは、DictionaryやHashSetを使う場合の話です。

SortedDictionaryを使う場合や、Array.Sort()、List.Sort()等を使う場合は 話が別であり、今度はSystem.IComparable<T> の実装が必要になります。 SortedDictionaryはDictionaryと違って中身が探索木 なので、大小関係の定義が必要だからです。

struct T : System.IComparable<T>
{
    public T(int x){ _x = x; }
    public int CompareTo(T other){ return _x - other._x; }
    int _x;
}

intを返すCompareTo(T)を実装します。自分が小さければマイナス、大きければプラス、 同じなら0を返せば良く、intの倍は引き算するだけで簡単です。

ただ、これに関しては、やらないとコンパイルが通らないので、ひどい問題になることはないでしょうし、 structに限った話でもありません。classでも同じ準備が必要です。 ただ、structに限って、あるミスをした時のダメージがひどく大きくなります。

私がこの前やったミス

ここで間違ってSystem.IComparableを実装すると、どうなるでしょうか。

struct T : System.IComparable
{
    public T(int x){ _x = x; }
    public int CompareTo(object other){ return _x - ((T)other)._x; }
    int _x;
}

CompareTo(T)でなく、CompareTo(object)を実装してしまいました。 もうおわかりですね。System.Objectへのキャストが引き起こすboxing地獄であり、 これはstruct特有の問題です。

f:id:hirasho0:20190326121544p:plain

ソートの場合、比較を行う回数が要素数をNとしてN*log(N)倍になりますから、 それはもうひどいことになっています。たかが100万個をソートするのに、 630MBもの無駄なメモリが使われてしまいました。 実行時間は3倍くらいになっています。

なお、「IEquatable<T>と一緒にIEquatableも実装しておこう」と書かれている記事を見かけますが、 意図がよくわかりません。 「遅くてメモリ汚しまくりだけど動いちゃう」より「そもそもコンパイル通らない」の方が安全です。 型指定がないObjectバージョンのEquals()やCompareTo()が必要なケースが どれだけあるのか私にはわかりません。

GC Allocを避けるため、のまとめ

まとめますと、structを定義して、それが Listにつっこまれてソートされたり、Dictionaryにつっこまれたりすることがありうるのであれば、 System.IEquatable<T>、GetHashCode()、System.IComparable<T>を 実装すべきだ、ということになります。 実装しない場合、どこでGC Alloc祭になるかわかったものではありません。

ただし、すでに述べたように、 「structを他人に使わせるな」 というルールを守るのであれば、使うのは自分だけ、 つまりclass内にprivate定義されるだけですから、 そのclassで使う機能だけ実装すれば良いことになります。

なお、このGC Alloc地獄に陥る問題については、「気にしない」という考え方も可能です。 Dictionaryはたかだか2倍にしかなりませんでしたし、ソートでも3倍です。 「2倍や3倍は誤差」という考え方もあろうかと思います。 しかし、だったらclassでいいじゃんとも言えますし、 structにしたいくらい性能を気にするなら最後までやれよ、 と個人的には思います。まあ、人それぞれでしょう。

そういえば一つ覚えておいた方がいいこととして、GC Allocしまくれば、後でGC.Collectの時間が増す という事実があります。GC.Collect()、つまりガベージコレクションにかかる時間は、 GC Allocされて作られたインスタンスの数が多いほど長くなります。 実際どれくらい長くなるかは実装依存ですので、案外大丈夫かもしれませんが、 大丈夫でないかもしれません。 安全を期すならば、それについても考えておいて損はないでしょう。

おわりに

これまで出てきた「struct使用上の注意」をまとめてみましょう。

  • 中身を変更する関数を用意するな。理想的には「書き換え不能」、それが無理なら素直に変数をpublicにしてしまえ。
    • 中身を変更する関数が必要なら、プロパティでstructを返すな
  • structはclass内にprivateで定義し、公開するな
  • Dictionary,HashSetに入れるならSystem.IEquatable<T>を実装し、GetHashCode()をoverrideせよ
  • SortedDictionaryに入れたりソートしたりするなら、System.IComparable<T>を実装せよ

なお、これは私の個人的見解であり、弊社内で合意が取れているわけでもありません。 少なくとも、Unityの中の人とは意見が異なるでしょうね。私ならVector3.Set()は用意しないでしょうから。

さて、一番重要な結論は、おわかりですね?

「structを使うな」 です。私のようなレベルの人間が使うと、 structを使う度に最低1回はバグらせて時間を余計に食います。 上で紹介したミスは、全て私が自分でやらかしたものです。

実にいい勉強になりました。

おまけ

structの利用、とは少しズレますが、こんなのもよく見掛けます。

yield return 0;

yield returnの次に書くものはIEnumerator.Current に格納され、 IEnumerator.CurrentはSystem.Objectでclassなので、 intでstructである0はboxingされます。GC Allocされて遅いわけです。 5fを返せばfloatでこれもstructですし、Vector3を返せばこれもstructです。 処理系がよろしく最適化してくれるかもしれませんが、してくれないかもしれません。

ここで、

yield return null;

とすれば、この問題はありません。nullはSystem.Object型でclassだからです。

この、0をyield returnするのって、誰が広めたんでしょうね? 誰かが最初にやったんだと思うのですが...