次の方法で共有


CallerArgumentExpression

手記

この記事は機能仕様です。 仕様は、機能の設計ドキュメントとして機能します。 これには、提案された仕様の変更と、機能の設計と開発時に必要な情報が含まれます。 これらの記事は、提案された仕様の変更が最終決定され、現在の ECMA 仕様に組み込まれるまで公開されます。

機能の仕様と完成した実装の間には、いくつかの違いがある可能性があります。 これらの違いは、関連する 言語設計会議 (LDM) ノートでキャプチャされます。

機能仕様を C# 言語標準に導入するプロセスの詳細については、仕様に関する記事を参照してください。

チャンピオンの課題: https://github.com/dotnet/csharplang/issues/287

概要

開発者は、メソッドに渡された式をキャプチャして、診断/テスト API でより優れたエラー メッセージを有効にし、キーストロークを減らすことができます。

モチベーション

アサーションまたは引数の検証が失敗した場合、開発者は、失敗した場所と理由について可能な限り知りたいと考えています。 ただし、現在の診断 API では、これを完全に容易にすることはできません。 次の方法について考えてみましょう。

T Single<T>(this T[] array)
{
    Debug.Assert(array != null);
    Debug.Assert(array.Length == 1);

    return array[0];
}

アサートのいずれかが失敗した場合、スタック トレースにはファイル名、行番号、メソッド名のみが指定されます。 開発者は、この情報からどのアサートが失敗したかを知ることができません。ファイルを開き、指定された行番号に移動して何が問題が発生したかを確認する必要があります。

これは、テスト フレームワークがさまざまなアサート メソッドを提供する必要がある理由でもあります。 xUnit では、失敗した内容に関する十分なコンテキストが提供されないため、Assert.TrueAssert.False は頻繁に使用されません。

引数の検証では無効な引数の名前が開発者に表示されるため、状況は少し優れていますが、開発者はこれらの名前を例外に手動で渡す必要があります。 上記の例が、Debug.Assertの代わりに従来の引数検証を使用するように書き換えられた場合は、次のようになります。

T Single<T>(this T[] array)
{
    if (array == null)
    {
        throw new ArgumentNullException(nameof(array));
    }

    if (array.Length != 1)
    {
        throw new ArgumentException("Array must contain a single element.", nameof(array));
    }

    return array[0];
}

nameof(array) は各例外に渡す必要がありますが、どの引数が無効であるかはコンテキストから既に明確になっています。

詳細な設計

上記の例では、アサート メッセージに文字列 "array != null" または "array.Length == 1" を含めると、開発者は失敗した内容を判断するのに役立ちます。 CallerArgumentExpressionを入力します。これは、フレームワークが特定のメソッド引数に関連付けられている文字列を取得するために使用できる属性です。 次のように Debug.Assert に追加します

public static class Debug
{
    public static void Assert(bool condition, [CallerArgumentExpression("condition")] string message = null);
}

上記の例のソース コードは同じままです。 ただし、コンパイラが実際に出力するコードは、

T Single<T>(this T[] array)
{
    Debug.Assert(array != null, "array != null");
    Debug.Assert(array.Length == 1, "array.Length == 1");

    return array[0];
}

コンパイラは、Debug.Assertの属性を特別に認識します。 これは、呼び出しサイトの属性のコンストラクター (この場合は condition) で参照される引数に関連付けられた文字列を渡します。 いずれかのアサートが失敗すると、開発者は false だった状態が表示され、失敗した状態が認識されます。

引数の検証では、属性を直接使用することはできませんが、ヘルパー クラスを使用して使用できます。

public static class Verify
{
    public static void Argument(bool condition, string message, [CallerArgumentExpression("condition")] string conditionExpression = null)
    {
        if (!condition) throw new ArgumentException(message: message, paramName: conditionExpression);
    }

    public static void InRange(int argument, int low, int high,
        [CallerArgumentExpression("argument")] string argumentExpression = null,
        [CallerArgumentExpression("low")] string lowExpression = null,
        [CallerArgumentExpression("high")] string highExpression = null)
    {
        if (argument < low)
        {
            throw new ArgumentOutOfRangeException(paramName: argumentExpression,
                message: $"{argumentExpression} ({argument}) cannot be less than {lowExpression} ({low}).");
        }

        if (argument > high)
        {
            throw new ArgumentOutOfRangeException(paramName: argumentExpression,
                message: $"{argumentExpression} ({argument}) cannot be greater than {highExpression} ({high}).");
        }
    }

    public static void NotNull<T>(T argument, [CallerArgumentExpression("argument")] string argumentExpression = null)
        where T : class
    {
        if (argument == null) throw new ArgumentNullException(paramName: argumentExpression);
    }
}

static T Single<T>(this T[] array)
{
    Verify.NotNull(array); // paramName: "array"
    Verify.Argument(array.Length == 1, "Array must contain a single element."); // paramName: "array.Length == 1"

    return array[0];
}

static T ElementAt<T>(this T[] array, int index)
{
    Verify.NotNull(array); // paramName: "array"
    // paramName: "index"
    // message: "index (-1) cannot be less than 0 (0).", or
    //          "index (6) cannot be greater than array.Length - 1 (5)."
    Verify.InRange(index, 0, array.Length - 1);

    return array[index];
}

このようなヘルパー クラスをフレームワークに追加する提案は、https://github.com/dotnet/corefx/issues/17068で進行中です。 この言語機能が実装されている場合は、提案を更新してこの機能を利用できます。

拡張メソッド

拡張メソッドの this パラメーターは、CallerArgumentExpressionによって参照できます。 例えば:

public static void ShouldBe<T>(this T @this, T expected, [CallerArgumentExpression("this")] string thisExpression = null) {}

contestant.Points.ShouldBe(1337); // thisExpression: "contestant.Points"

thisExpression は、ドットの前にあるオブジェクトに対応する式を受け取ります。 Ext.ShouldBe(contestant.Points, 1337)などの静的メソッド構文を使用して呼び出された場合、最初のパラメーターが thisマークされていないかのように動作します。

this パラメーターに対応する式が常に存在する必要があります。 クラスのインスタンスがそれ自体で拡張メソッド (コレクション型内から this.Single() など) を呼び出した場合でも、this はコンパイラによって要求されるため、"this" が渡されます。 この規則が将来変更される場合は、null または空の文字列を渡すことを検討できます。

追加の詳細

  • CallerMemberNameなどの他の Caller* 属性と同様に、この属性は既定値を持つパラメーターでのみ使用できます。
  • 上記のように、CallerArgumentExpression でマークされた複数のパラメーターが許可されます。
  • 属性の名前空間は System.Runtime.CompilerServicesです。
  • null またはパラメーター名ではない文字列 (例: "notAParameterName") が指定されている場合、コンパイラは空の文字列を渡します。
  • 適用されるパラメーター CallerArgumentExpressionAttribute の型には、stringからの標準変換が必要です。 つまり、string からのユーザー定義の変換は許可されません。実際には、このようなパラメーターの型は、stringobject、または stringによって実装されるインターフェイスである必要があります。

欠点

  • 逆コンパイルの使用方法を知っているユーザーは、この属性でマークされたメソッドの呼び出しサイトでソース コードの一部を確認できます。 これは、クローズド ソース ソフトウェアでは望ましくない、または予期しない場合があります。

  • これは機能自体の欠陥ではありませんが、現在、boolしか取らない Debug.Assert API が存在することが懸念される可能性があります。 メッセージを受け取るオーバーロードで 2 番目のパラメーターがこの属性でマークされ、省略可能になった場合でも、コンパイラはオーバーロード解決で no-message 1 を選択します。 したがって、この機能を利用するには、メッセージなしのオーバーロードを削除する必要があります。これはバイナリ (ソースではない) 破壊的変更になります。

選択肢

  • メソッドの呼び出しサイトでソースコードの確認が問題となった場合、この属性の効果をオプトイン方式に変更できます。 開発者は、AssemblyInfo.csに配置するアセンブリ全体の [assembly: EnableCallerArgumentExpression] 属性を使用して有効にします。
    • 属性の効果が有効になっていない場合、属性でマークされたメソッドを呼び出してもエラーになりません。これにより、既存のメソッドで属性を使用し、ソースの互換性を維持できます。 ただし、この属性は無視され、指定された既定値でメソッドが呼び出されます。
// Assembly1

void Foo(string bar); // V1
void Foo(string bar, string barExpression = "not provided"); // V2
void Foo(string bar, [CallerArgumentExpression("bar")] string barExpression = "not provided"); // V3

// Assembly2

Foo(a); // V1: Compiles to Foo(a), V2, V3: Compiles to Foo(a, "not provided")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")

// Assembly3

[assembly: EnableCallerArgumentExpression]

Foo(a); // V1: Compiles to Foo(a), V2: Compiles to Foo(a, "not provided"), V3: Compiles to Foo(a, "a")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")
  • Debug.Assertに新しい呼び出し元情報を追加するたびに バイナリ互換性の問題が発生しないようにするには、呼び出し元に関するすべての必要な情報を含む CallerInfo 構造体をフレームワークに追加することもできます。
struct CallerInfo
{
    public string MemberName { get; set; }
    public string TypeName { get; set; }
    public string Namespace { get; set; }
    public string FullTypeName { get; set; }
    public string FilePath { get; set; }
    public int LineNumber { get; set; }
    public int ColumnNumber { get; set; }
    public Type Type { get; set; }
    public MethodBase Method { get; set; }
    public string[] ArgumentExpressions { get; set; }
}

[Flags]
enum CallerInfoOptions
{
    MemberName = 1, TypeName = 2, ...
}

public static class Debug
{
    public static void Assert(bool condition,
        // If a flag is not set here, the corresponding CallerInfo member is not populated by the caller, so it's
        // pay-for-play friendly.
        [CallerInfo(CallerInfoOptions.FilePath | CallerInfoOptions.Method | CallerInfoOptions.ArgumentExpressions)] CallerInfo callerInfo = default(CallerInfo))
    {
        string filePath = callerInfo.FilePath;
        MethodBase method = callerInfo.Method;
        string conditionExpression = callerInfo.ArgumentExpressions[0];
        //...
    }
}

class Bar
{
    void Foo()
    {
        Debug.Assert(false);

        // Translates to:

        var callerInfo = new CallerInfo();
        callerInfo.FilePath = @"C:\Bar.cs";
        callerInfo.Method = MethodBase.GetCurrentMethod();
        callerInfo.ArgumentExpressions = new string[] { "false" };
        Debug.Assert(false, callerInfo);
    }
}

これはもともと https://github.com/dotnet/csharplang/issues/87で提案されました.

この方法にはいくつかの欠点があります。

  • 必要なプロパティを指定できるようにすることで、従量課金制のフレンドリであるにもかかわらず、アサートが通過した場合でも、式/呼び出し MethodBase.GetCurrentMethod に配列を割り当てることでパフォーマンスが大幅に低下する可能性があります。

  • さらに、CallerInfo 属性に新しいフラグを渡しても重大な変更にはなりませんが、Debug.Assert は、古いバージョンのメソッドに対してコンパイルされた呼び出しサイトから新しいパラメーターを実際に受け取る保証はありません。

未解決の質問

未定

デザイン会議

N/A