言いたいこと
C#の値型は、default(T)や配列確保時、フィールド未初期化時など、
「全フィールドがゼロやnullで初期化された状態」
になる。これはstructを使用する上で、避けることができない制約である。
このときの値が
「表現したい値集合の中に必ず含まれ、不変条件を破らない」
ときのみ、値型を採用することをおすすめする。
例
例えば、「非ゼロの32bit符号なし整数値」をあらわす値型を作成するとき、
struct NotZeroUInt32
{
public uint AsUInt32 { get; }
public NotZeroUInt32(uint val)
{
if (val == 0) throw new ArgumentOutOfRangeException(nameof(val));
AsUInt32 = val;
}
}
これではいけない。
default(NotZeroUInt32).AsUInt32がゼロになってしまうからだ。
もし、こうした形でしか表せないのであれば、この型をstructで実装することをあきらめたほうがいい。
今回の場合は、回避方法があって、
struct NotZeroUInt32
{
uint ValueMinusOne { get; }
public uint AsUInt32 => ValueMinusOne + 1U;
public NotZeroUInt32(uint val)
{
if (val == 0) throw new ArgumentOutOfRangeException(nameof(val));
ValueMinusOne = val - 1U;
}
}
とすればよい。この場合、default(NotZeroUInt32).AsUInt32は1になり、これは不変条件を破らない。
(値型はIEquatable<T>を実装すべき云々もあるけど、今回の主題ではないので省略)
struct Int32Range
{
public int Max { get; }
public int Min { get; }
public Int32Range(int max, int min) { ... }
}
これもdefault(Int32Range)とすると、MaxとMinがゼロになる。
採用したい不変条件がMin <= Maxであればかまわないかもしれないが、Min < Maxであればstructでは素直には表現できないことになる。
結論
structはヒープ割り当てを避けられ大量生成時に有利なため、採用したくなる場面がある。
その代償として、このdefault制約を受け入れなくてはならない。
パフォーマンスの制約上、目をつぶって使わざるを得ない場合もあるが、通常は「全フィールドがゼロ初期化された状態」が正しい値である場合にのみ値型にするというルールに従うことをおすすめする。
これに違反した場合、「IsDefault」などというプロパティを作り、この型の値を受け取るメソッド内でこのプロパティがtrueならArgumentExceptionを投げるようなことをする必要がでてくる。参照型のnullチェックと変わりないように思うかもしれないが、APIの使い方を型から読み取れなくなってしまうのは大きな問題だ。
値型から参照型に後から切り替えるのはかなり面倒な作業になるので、最初の設計時に注意することが肝心になる。
余計な付け足し
最後に、この記事は
に触発されて書いたものだ。
ゲームはパフォーマンスに厳しい場面が数多くあると思う。Unityに関して書いてあるこの記事に意見する気はまったくないことを付けくわえておく。