Поделиться через


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.True и Assert.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" в сообщение проверки assert помогло бы разработчику определить, что именно не удалось. Введите 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) в месте вызова. При сбое любого утверждения разработчик будет отображать условие, которое было ложным и будет знать, какой из них произошел сбой.

Для проверки аргументов атрибут нельзя использовать напрямую, но его можно использовать с помощью вспомогательного класса:

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 или пустой строки.

Дополнительные сведения

  • Как и другие атрибуты Caller*, например CallerMemberName, этот атрибут может использоваться только для параметров со значениями по умолчанию.
  • Разрешены несколько параметров, помеченных CallerArgumentExpression, как показано выше.
  • Пространство имен атрибута будет System.Runtime.CompilerServices.
  • Если указана null или строка, которая не является именем параметра (например, "notAParameterName"), компилятор будет передавать пустую строку.
  • Тип, к которому применяется параметр CallerArgumentExpressionAttribute, должен иметь стандартное преобразование из string. Это означает, что пользовательские преобразования из string не разрешены, и на практике тип такого параметра должен быть string, objectили интерфейс, реализованный string.

Недостатки

  • Пользователи, которые знают, как использовать декомпилаторы, смогут видеть некоторые из исходного кода на сайтах вызовов для методов, помеченных этим атрибутом. Это может быть нежелательно или неожиданно для программного обеспечения с закрытым исходным кодом.

  • Хотя это не недостаток самой функции, источником беспокойства может быть то, что сегодня существует Debug.Assert API, который принимает только bool. Даже если перегрузка, принимающая сообщение, имеет второй параметр, помеченный этим атрибутом и сделанный необязательным, компилятор все равно выберет перегрузку без сообщения при разрешении перегрузки. Таким образом, для использования этой функции необходимо удалить перегрузку без сообщений, что приведет к изменению, нарушающему двоичную, но не исходную совместимость.

Альтернативы

  • Если возможность видеть исходный код в местах вызова для методов, использующих этот атрибут, вызывает проблему, мы можем сделать эффекты атрибута по желанию. Разработчики будут активировать его с помощью атрибута [assembly: EnableCallerArgumentExpression] на уровне сборки, который они добавят в AssemblyInfo.cs.
    • В случае, если эффекты атрибута не включены, вызов методов, помеченных атрибутом, не будет ошибкой, чтобы разрешить существующим методам использовать атрибут и поддерживать совместимость источников. Однако атрибут будет игнорироваться, и метод будет вызываться с любым значением по умолчанию.
// 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.

Существует несколько недостатков этого подхода:

  • Несмотря на то, что вы платите за игру, позволяя указать необходимые свойства, он по-прежнему может значительно повредить perf путем выделения массива для выражений или вызовов MethodBase.GetCurrentMethod даже при прохождении утверждения.

  • Кроме того, передача нового флага в атрибут CallerInfo не приведет к нарушению совместимости, но для Debug.Assert не гарантируется получение этого нового параметра из мест вызова, скомпилированных с использованием старой версии метода.

Неразрешенные вопросы

подлежит уточнению

Планирование встреч

N/A