C# の配列は共変性 (covariance) があり、以下のコードはコンパイルできます。
object[] array = new int[10]; array[2] = 5;
そして困ったことに、次のコードもコンパイルできます。
object[] array = new int[10]; array[2] = "hello"; // ← 代入できる!?!? (実行時エラー)
しかし、コンパイルできますが、実際に実行すると実行時エラーになります。array
インスタンスはint[]
なのでstring
を代入できるはずがありません。にもかかわらず、静的にはエラーになりません。
CLR は C#1.0 の時点から配列をサポートしており、C# の型の中でも超特殊な扱いを受けているため、ランタイムの中の見えないところで色々とハックな実装がされています。
上記の共変性の問題はある意味C#の言語設計のミスで、C# の登場時にはジェネリックがなく、汎用型に使える設計にはobject
で受ける以外の選択肢がありませんでした。そして、C#が設計のもとにした Java が同様に配列の共変性を持っているのをそのまま持ってきたのが原因です。もちろん、当時の Java にもジェネリックはありません。
ジェネリック登場以降も、後方互換性のため、今更仕様を変更できずこのようなコードが書けてしまいます。
ジェネリック登場以降は汎用型の用途でobject[]
を使うような行儀の悪いコードはまず書きませんが、配列の共変性自体はobject
に限らないため、例えば継承関係にあるクラスAnimal
とDog
でAnimal[] animals = new Dog[10];
などのコードは今でも十分ありえます。
abstract class Animal { } class Dog : Animal { } class Cat : Animal { } Animal[] animals = new Dog[10]; animals[2] = new Cat(); // runtime error !!!
この時、CLR から実行時エラーが出るということは、暗黙的に型チェックが行われているということです。 つまり、全ての参照型の配列はこの共変性の仕様のせいでパフォーマンスに影響が出ます。(配列変数の型とインスタンスの型が一致している or していないに関わらず発生します)
Animal[] _animals = new Dog[100000]; Dog _dog = new Dog(); int[] _intArray = new int[100000]; int _num = 0; // 参照型の配列の場合 void M1() { for(int i = 0; i < _animals.Length; i++) { _animals[i] = _dog; // 毎回型チェックが入る } } // 値型の配列の場合 void M2() { for(int i = 0; i < _intArray.Length; i++) { _intArray[i] = _num; // 型チェックは発生しない } }
上記のM1
メソッドはM2
メソッドより遅いです。M1
は参照型配列の共変性の仕様により、代入できない型のインスタンスが代入されないように暗黙的に型チェックが毎ループ走ります。
一方で、C# の構造体は継承はないため、共変性・反変性とは無縁であり、値型の配列に型チェックは発生しません。
この型チェックを回避するための方法として構造体にラップするというのがあります。
struct Wrapper { public readonly Animal animal; public Wrapper(Animal animal) => Animal = animal; }
Wrapper[] _wrapperArray = new Wrapper[100000]; Wrapper _wrapper = new Wrapper(new Dog()); void M3() { for(int i = 0; i < _wrapperArray.Length; i++) { _wrapperArray[i] = _wrapper; // 型チェックは発生しない } }
構造体にラップすると、値型の配列になり、共変性はないため型チェックが消えます。
これは CLR が変わらない限り (後方互換性のためまず変わらないと思うけど)、参照型の配列全てで発生する問題です。もちろん、よっぽどホットな高速化が必要な部分以外は気にする必要がない問題ですが、知っておいて損はないはずです。 とはいえ、気にし過ぎは可読性や実装コストの面で有害ですらあるので、必要な場面以外は忘れておきましょう。。。