Udostępnij przez


CallerArgumentExpression

Notatka

Ten artykuł jest specyfikacją funkcji. Specyfikacja służy jako dokument projektowy dla funkcji. Zawiera proponowane zmiany specyfikacji wraz z informacjami wymaganymi podczas projektowania i opracowywania funkcji. Te artykuły są publikowane do momentu sfinalizowania proponowanych zmian specyfikacji i włączenia ich do obecnej specyfikacji ECMA.

Mogą wystąpić pewne rozbieżności między specyfikacją funkcji a ukończoną implementacją. Te różnice są przechwytywane w odpowiednich spotkania projektowego języka (LDM).

Więcej informacji na temat procesu wdrażania specyfikacji funkcji można znaleźć w standardzie języka C# w artykule dotyczącym specyfikacji .

Kwestia z mistrzem: https://github.com/dotnet/csharplang/issues/287

Streszczenie

Zezwól deweloperom na uchwytywanie wyrażeń przekazywanych do metody, aby zapewnić lepsze komunikaty o błędach w diagnostycznych/testowych interfejsach API i zmniejszyć liczbę naciśnięć klawiszy.

Motywacja

Gdy weryfikacja asercji lub argumentu zakończy się niepowodzeniem, deweloper chce wiedzieć jak najwięcej o tym, gdzie i dlaczego zakończyła się niepowodzeniem. Jednak dzisiejsze interfejsy API diagnostyczne nie w pełni to ułatwiają. Rozważmy następującą metodę:

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

    return array[0];
}

Jeśli jedno z asercji nie powiedzie się, tylko nazwa pliku, numer wiersza i nazwa metody zostaną podane w stosie wywołań. Deweloper nie będzie mógł stwierdzić, która asercja nie powiodła się z tych informacji — będzie musiał otworzyć plik i przejść do wskazanego numeru wiersza, aby sprawdzić, co poszło nie tak.

Jest to również przyczyna, dla których struktury testowania muszą zapewnić różne metody asercjowania. W przypadku interfejsu xUnit Assert.True i Assert.False nie są często używane, ponieważ nie zapewniają wystarczającego kontekstu co się nie powiodło.

Chociaż sytuacja jest nieco lepsza w przypadku weryfikacji argumentów, ponieważ nazwy nieprawidłowych argumentów są wyświetlane deweloperowi, deweloper musi ręcznie przekazać te nazwy do wyjątków. Jeśli powyższy przykład został przepisany, aby użyć tradycyjnej weryfikacji argumentu zamiast Debug.Assert, wyglądałoby to następująco:

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];
}

Należy zauważyć, że każdemu wyjątkowi trzeba przekazać nameof(array), mimo że z kontekstu jest już jasne, który argument jest nieprawidłowy.

Szczegółowy projekt

W powyższych przykładach, umieszczenie ciągu "array != null" lub "array.Length == 1" w komunikacie asercji pomoże deweloperowi określić, co się nie powiodło. Wprowadź CallerArgumentExpression: jest to atrybut, za pomocą których platforma może uzyskać ciąg skojarzony z określonym argumentem metody. Dodalibyśmy go do Debug.Assert w następujący sposób

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

Kod źródłowy w powyższym przykładzie pozostanie taki sam. Jednak kod, który faktycznie generuje kompilator, odpowiadałby

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

    return array[0];
}

Kompilator rozpoznaje specjalnie atrybut w Debug.Assert. Przekazuje ciąg skojarzony z argumentem, o którym mowa w konstruktorze atrybutu (w tym przypadku condition), w miejscu wywołania. Gdy jedna z asercji zakończy się niepowodzeniem, deweloper zobaczy warunek, który okazał się fałszywy, i będzie wiedział, która z nich zawiodła.

W przypadku weryfikacji argumentów atrybut nie może być używany bezpośrednio, ale można go użyć za pomocą klasy pomocniczej:

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];
}

Propozycja dodania takiej klasy pomocniczej do frameworku jest w toku w https://github.com/dotnet/corefx/issues/17068. Jeśli ta funkcja języka została zaimplementowana, propozycję można zaktualizować, aby skorzystać z tej funkcji.

Metody rozszerzeń

Parametr this w metodzie rozszerzenia może być przywołyny przez CallerArgumentExpression. Na przykład:

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

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

thisExpression otrzyma wyrażenie odpowiadające obiektowi przed kropką. Jeśli jest wywoływana przy użyciu składni metody statycznej, np. Ext.ShouldBe(contestant.Points, 1337), będzie ona zachowywać się tak, jakby pierwszy parametr nie został oznaczony this.

Zawsze powinno istnieć wyrażenie odpowiadające parametrowi this. Nawet jeśli wystąpienie klasy wywołuje metodę rozszerzenia na sobie, np. this.Single() z wnętrza typu kolekcji, this jest wymagany przez kompilator, więc "this" zostanie przekazany. Jeśli w przyszłości ta reguła zostanie zmieniona, możemy rozważyć przekazanie null lub pustego ciągu znaków.

Dodatkowe szczegóły

  • Podobnie jak inne atrybuty Caller*, takie jak CallerMemberName, ten atrybut może być używany tylko dla parametrów z wartościami domyślnymi.
  • Dozwolone są wiele parametrów oznaczonych CallerArgumentExpression, jak pokazano powyżej.
  • Przestrzeń nazw atrybutu będzie System.Runtime.CompilerServices.
  • Jeśli zostanie podana null lub ciąg, który nie jest nazwą parametru (np. "notAParameterName"), kompilator przekaże pusty ciąg.
  • Typ parametru, do którego odnosi się CallerArgumentExpressionAttribute, musi mieć standardową konwersję z string. Oznacza to, że nie są dozwolone żadne użytkownikowskie konwersje z string, a w praktyce oznacza to, że typ takiego parametru musi być string, objectlub interfejsem implementowanym przez string.

Wady i niedogodności

  • Osoby, które wiedzą, jak używać dekompilatorów, będą mogły zobaczyć części kodu źródłowego w miejscach wywołań metod oznaczonych tym atrybutem. Może to być niepożądane/nieoczekiwane w przypadku oprogramowania zamkniętego źródła.

  • Chociaż nie jest to wada samej funkcji, źródłem obaw może być to, że istnieje interfejs API Debug.Assert obecnie, który przyjmuje tylko bool. Nawet jeśli przeciążenie przyjmujące komunikat miało drugi parametr oznaczony tym atrybutem i stał się opcjonalny, kompilator nadal wybierze tę bez komunikatu podczas rozwiązywania przeciążenia. W związku z tym przeciążenie komunikatów nie powinno zostać usunięte, aby móc korzystać z tej funkcji, co byłoby zmianą powodującą niezgodność binarną (chociaż nie źródło).

Alternatywy

  • Jeśli możliwość wyświetlenia kodu źródłowego w miejscach wywołań metod używających tego atrybutu okaże się problemem, możemy sprawić, że efekty atrybutu będą opcjonalne. Deweloperzy umożliwią tę funkcję za pomocą atrybutu [assembly: EnableCallerArgumentExpression] dla całego zestawu, który umieszczają w AssemblyInfo.cs.
    • W przypadku, gdy efekty atrybutu nie są włączone, wywoływanie metod oznaczonych atrybutem nie byłoby błędem, aby umożliwić istniejącym metodom używanie atrybutu i zachowanie zgodności źródła. Jednak atrybut zostanie zignorowany, a metoda zostanie wywołana z dowolną podaną wartością domyślną.
// 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")
  • Aby zapobiec występowaniu problemu zgodności binarnej za każdym razem, gdy chcemy dodać nowe informacje o wywołującym do Debug.Assert, alternatywnym rozwiązaniem byłoby dodanie struktury CallerInfo do frameworku, która zawiera wszystkie niezbędne informacje o obiekcie wywołującym.
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);
    }
}

Pierwotnie zaproponowano to w https://github.com/dotnet/csharplang/issues/87.

Istnieje kilka wad tego podejścia:

  • Pomimo tego, że funkcja pay-for-play jest przyjazna, umożliwiając określenie potrzebnych właściwości, nadal może to znacznie zaszkodzić, przydzielając tablicę dla wyrażeń/wywoływania MethodBase.GetCurrentMethod nawet wtedy, gdy asercyjna przechodzi.

  • Ponadto, przekazywanie nowej flagi do atrybutu CallerInfo nie spowoduje niezgodności, ale Debug.Assert nie ma gwarancji, że faktycznie otrzyma ten nowy parametr z miejsc wywołań skompilowanych ze starą wersją metody.

Nierozwiązane pytania

TBD

Spotkania projektowe

N/A