はじめに
例えばrustやC/C++では、単体テスト等でAssert内の a != b
のような表現をエラー内容の一部として出すことができる。
マクロがある言語ではこのような機能が比較的容易に実現可能だが、C#ではマクロを排除していたため、この機能の実現が難しかった(式木やSourceGeneratorを使えば実現は可能?)。
しかし、C#10より、CallerArgumentExpressionという仕様が策定され、dotnet-6.0から使用できるようになったため、容易に実現ができるようになった(検討自体はかなり前からされていたようだが)。
この記事では、それを解説しようと思う。
環境
コンパイラがC#10に対応している必要があるため、dotnet-sdkは6.0以降が必要となる。
言語バージョン(msbuildプロパティのLangVersion
)に関しては10以上から使えることになっているが、9指定でも使えたので制限があるのかは不明。
ただし、TargetFrameworkに関しては、netcoreapp3.1以上は特に何もしなくても使える。
それ以下のバージョン(net4x含む)で使いたい場合は、System.Runtime.CompilerServices.CallerArgumentExpressionAttribute
をクラスライブラリから拝借する必要がある。
条件としては
- 名前空間とクラス名が一致(
System.Runtime.CompilerServices.CallerArgumentExpressionAttribute
) -
ParameterName
というgetプロパティを持っている - コンストラクタに引数として
string
ただ一つだけを取り、ParameterNameにセットする
とすればOKのようだ。共存は不可なので、TargetFrameworkによる条件付きコンパイルで分ける必要がある。
使い方
呼び出される側
例として、引数に真偽値をとるメソッドvoid Assert(bool isok)
を想定する。
これをCallerArgumentExpressionに対応するためには、メソッド側は以下のようにする。
void Assert(bool isok, [CallerArgumentExpression("isok")] string isokExpression = null)
{
// 実際の処理...
}
ポイントは、
- 後ろに
CallerArgumentExpression
を属性に付け、型をstringにしたオプション引数を追加する- オプション引数にしないとビルドエラーになる
-
CallerArgumentExpression
のコンストラクタには、対応する引数の名前を設定する- 名前が間違っていたりすると警告が出る(エラーにはならない)
- 引数の名前は特に制約はない
- 対応する引数の型(例ではisokの型)に制約はない
となる。
対応しない環境(dotnet-sdk-5.0以下でビルドした場合等)では、ここは普通にnullが入ることに注意。
呼び出し側
呼び出す方は、普通のメソッドと同様に Assert(a == b)
のように呼び出せばいい。
どのような値が入るか
expressionに何が入るかという所だが、これは引数部分に指定した式がそのままconst文字列として入る。
具体的に言うと、Assert(0 == 1)
のように呼び出すと、expressionには"0 == 1"
が入り、1.ToString() == "1"
のようにした場合はそのまま"1.ToString() == \"1\""
が入る。途中にある改行やスペース、コメント等もそのまま保持されるようだ。
ただし、前後の改行や空白、コメントがある場合は削除される。
sharplabの結果を見る限り、これはコンパイル時に解決される。
また、LINQのクエリ式やラムダ式でも受け付け可能。
void LinqCallerExpression(IEnumerable<int> list, [CallerArgumentExpression("list")]string? expr = null)
{
// expr = 'from i in Enumerable.Range(0, 100) select i'
Console.WriteLine($"expr = '{expr}'");
}
LinqCallerExpression(from i in Enumerable.Range(0, 100) select i);
void FuncExpression(Func<int, int> f, [CallerArgumentExpression("f")]string? expr = null)
{
// expr = 'i => i + 1'
Console.WriteLine($"expr = '{expr}'");
}
FuncExpression(i => i + 1);
注意点
コンパイル時に決定されるので、中身の展開はされず、あくまで書いたままの表現で変換される。
また、表現の伝搬は行わないので、
void A(int a, [CallerArgumentExpression("a")] string aExpresssion = null)
{
B(a);
}
void B(int b, [CallerArgumentExpression("b")] string bExpression = null)
{
}
とすると、A(int a)
の方にどのような形式で渡そうと、bExpression
に入るのは"a"という事になる。
終わりに
CallerArgumentExpressionについて解説したが、実際の使いどころというと、
- Assertでfalseだった時の例外メッセージに含める
- ThrowIfNullの時に例外メッセージに含める
- LINQクエリを使う時にデバッグメッセージに入れる
辺りで、主にトラブルシューティング想定の仕込みかデバッグ用かなと。
いずれにせよ、こういう仕組みがあるということを知っておいて損はないだろう。