それは突然起きた
ある日のこと。
いつものようにC#でプログラムを書いて、いつものように単体テストを実行していると、なんと TypeLoadException
を吐かれました。
単体テストの対象になった MyStruct構造体
は以下のような感じになっていました。
(Values<T>構造体
は MyStruct構造体
に含まれているため、以下のコードに記しておきます)
public readonly struct Values<T>
{
public required readonly T Zero { get; init; }
public required readonly T One { get; init; }
}
public readonly struct MyStruct
{
public readonly int Value = 0;
public static Values<MyStruct> BasicValues { get; } = new() {
Zero = new MyStruct(0),
One = new MyStruct(1)
};
public MyStruct(int value) {
Value = value;
}
}
TypeLoadExceptionとは
Microsoftの公式ドキュメントによると、以下のように説明されています。
TypeLoadException は、共通言語ランタイムがアセンブリ、アセンブリ内の型、または型を読み込むことができない場合にスローされます。
要するに、「型を管理してる人に『ある型』の情報を求めたけど、その人は知らなかった」ということでしょうか。
どこで例外が起こったか
アセンブリの参照を忘れたかもと思って調べてみましたが、ちゃんと参照されてました。
そうなれば、上のコードの中で TypeLoadException
が起きそうな場所は、あそこしかないですね。
そう、ここ。
public struct MuStruct
{
...
public static Values<MyStruct> BasicValues { get; } = new() {
Zero = new MyStruct(0),
One = new MyStruct(1)
};
...
}
どうして例外が起こったのか
上記の部分を書き換えると、(多分)こんな感じになります。
public struct MyStruct
{
...
public static Values<MyStruct> BasicValues { get; }
static MyStruct() {
BasicValues = new() {
Zero = new MyStruct(0),
One = new MyStruct(1)
};
}
...
}
ここで、static MyStruct()
は 静的コンストラクタ と呼ばれ、Microsoftの公式ドキュメントによると、以下のように説明されています。
静的コンストラクタにより、最初のインスタンスが作成される前、またはそのクラス (基底クラスではない) で宣言された静的メンバーが参照される前に、クラスが初期化されます。
静的コンストラクターは、インスタンス コンストラクターの前に実行されます。イベントまたはデリゲートに割り当てられている静的メソッドが呼び出されるときは型の静的コンストラクターが呼び出されますが、割り当てられるときは呼び出されません。
静的フィールド変数初期化子が静的コンストラクターのクラスに存在する場合、それらは、クラス宣言に出現するテキストの順序で実行されます。 初期化子は、静的コンストラクターの実行直前に実行されます。静的コンストラクター - C# プログラミング ガイド | Microsoft Learn
(※一部文言を変更し、一部を太字にしています)
上記の文章の太字部分に注目してください。
これはつまり、「ある型の静的コンストラクタを実行する前に、その型の静的フィールドの初期化を行う」ということです。
よって、上記のコードでは、MyStruct構造体
の静的コンストラクタが実行される前に、MyStruct.BasicValuesフィールド
の初期化が行われていることになります。
(MyStruct.BasicValues
は実際にはフィールドではないですが、ここでは簡単のため、バッキングフィールドのことを指していると考えてください。)
しかし、この初期化の際に、まだ初期化されていない MyStruct構造体
が使われているために、TypeLoadException
が起きたと考えられます。
C#では自己参照的な型の定義ができるので、てっきりこういうのもいい感じにやってくれるのかと思ってました... (しかもこれでコンパイルが通ったので、ここの実装は大丈夫かな?と疑いもしませんでした。)
小話
ちなみに、以下のようなやつは、なぜか例外もなくうまくいきます。
もろちん、ちゃんと参照もできます。
この場合は、静的コンストラクタが呼ばれた直後に、MyStruct.Defaultフィールド
の初期化が行われているのでしょうか...??
public readonly struct MyStruct // クラスでも可
{
public readonly int Value;
public static MyStruct Default { get; } = new() { Value = 0 }
}
解決策
System.Lazy<T>クラス
を活用して、バッキングフィールドを遅延初期化オブジェクトにすればよいです。
例外の原因となっていた部分を削除し、以下のようにします。
public reaodnly struct MyStruct
{
...
private readonly static Lazy<Values<MyStruct>> _basicValues = new(() => {
return new Values<MyStruct>() {
Zero = new MyStruct(0),
One = new MyStruct(1)
};
});
public static Values<MyStruct> BasicValues { get => _basicValues; }
...
}
System.Lazy<T>クラス
は、簡単に遅延初期化が実装できる、とっても便利なクラスです。
初めて Lazy<T>.Valueプロパティ
にアクセスされた時に、T
型の値の初期化が行われます。
また、スレッドセーフ なのも強いです。
C# (というか.NET) は、自分がほしいものに合うクラスやメソッドが提供されていることがよくあります。手間が省けてありがたい...。
おわりに
実装に関して話すと、「構造体自体が値を提供するのではなく、別のオブジェクトを挟んで値を提供するような実装をしたのも悪いのかも...」と思いました。
しかしながら、これはインターフェースの定義上こうなってしまったので、なんか仕方ない気もします。
(本当はもう少し複雑な仕組みになっているので、インターフェースの定義はどうにも変えられません。)
もしなんか間違ったことを言ってたら、コメントで指摘していただけると嬉しいです。