Об атрибуте CallerArgumentExpression говорят уже много лет. Предполагалось, что он станет частью C# 8.0, но его внедрение в язык отложили. А в этом месяце он, наконец, появился — вместе с C# 10 и .NET 6.



Класс CallerArgumentExpressionAttribute и обработка аргументов во время компиляции кода


В C# 10 конструкция [CallerArgumentExpression(parameterName)] может быть использована для того, чтобы указать компилятору на необходимость захвата текстового представления указанного аргумента. Например:

using System.Runtime.CompilerServices;

void Function(int a, TimeSpan b, [CallerArgumentExpression("a")] string c = "", [CallerArgumentExpression("b")] string d = "")
{
    Console.WriteLine($"Called with value {a} from expression '{c}'");
    Console.WriteLine($"Called with value {b} from expression '{d}'");
}

Нас интересует вызов вышеобъявленной функции. А самое интересное происходит во время компиляции:

Function(1, default);
// Компилируется в: 
Function(1, default, "1", "default");

int x = 1;
TimeSpan y = TimeSpan.Zero;
Function(x, y);
// Компилируется в:
Function(x, y, "x", "y");

Function(int.Parse("2") + 1 + Math.Max(2, 3), TimeSpan.Zero - TimeSpan.MaxValue);
// Компилируется в:
Function(int.Parse("2") + 1 + Math.Max(2, 3), TimeSpan.Zero - TimeSpan.MaxValue, "int.Parse(\"2\") + 1 + Math.Max(2, 3)", "TimeSpan.Zero - TimeSpan.MaxValue");

Параметр функции c декорируется с помощью [CallerArgumentExpression(«a»)]. В результате — при вызове функции C#-компилятор возьмёт выражение, переданное в a, и использует текст этого выражения для c. И, аналогично, текст выражения, использованного для b, будет использован для d.

Проверка аргументов


Наиболее интересный сценарий использования этой возможности заключается в проверке аргументов. Раньше для решения той же задачи создавали множество вспомогательных методов:

public static partial class Argument
{
    public static void NotNull<T>([NotNull] T? value, string name) where T : class
    {
        if (value is null)
        {
            throw new ArgumentNullException(name);
        }
    }

    public static void NotNullOrWhiteSpace([NotNull] string? value, string name)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.StringCannotBeEmpty, name));
        }
    }

    public static void NotNegative(int value, string name)
    {
        if (value < 0)
        {
            throw new ArgumentOutOfRangeException(name, value, string.Format(CultureInfo.CurrentCulture, Resources.ArgumentCannotBeNegative, name));
        }
    }
}

Этими методами можно было пользоваться так:

public partial record Person
{
    public Person(string name, int age, Uri link)
    {
        Argument.NotNullOrWhiteSpace(name, nameof(name));
        Argument.NotNegative(age, nameof(age));
        Argument.NotNull(link, nameof(link));

        this.Name = name;
        this.Age = age;
        this.Link = link.ToString();
    }

    public string Name { get; }
    public int Age { get; }
    public string Link { get; }
}

Проблема этого кода заключается в необходимости постоянной передачи имени аргумента. Такая однообразная работа сильно надоедает программистам. Есть способы, позволяющие избавиться от ручной передачи аргумента, но у этих способов есть собственные проблемы. Например, можно воспользоваться лямбда-выражением с замыканием:

public partial record Person
{
    public Person(Uri link)
    {
        Argument.NotNull(() => link);

        this.Link = link.ToString();
    }
}

А эта версия NotNull может принимать функцию:

public static partial class Argument
{
    public static void NotNull<T>(Func<T> value)
    {
        if (value() is null)
        {
            throw new ArgumentNullException(GetName(value));
        }
    }

    private static string GetName<T>(Func<T> func)
    {
        // func: () => arg компилируется в DisplayClass с полем и методом. Метод - это func.
        object displayClassInstance = func.Target!;
        FieldInfo closure = displayClassInstance.GetType()
            .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)
            .Single();
        return closure.Name;
    }
}

Вот мой материал о замыканиях, и о том, как они компилируются в C#.

Лямбда-выражения могут быть, кроме того, скомпилированы в деревья выражений. Поэтому NotNull можно реализовать и в расчёте на то, чтобы эта функция принимала бы выражение (вот мой материал о деревьях выражений и об их компиляции в C#):

public static partial class Argument
{
    public static void NotNull<T>(Expression<Func<T>> value)
    {
        if (value.Compile().Invoke() is null)
        {
            throw new ArgumentNullException(GetName(value));
        }
    }

    private static string GetName<T>(Expression<Func<T>> expression)
    {
        // expression: () => arg компилируется в DisplayClass с полем. Здесь тело выражения нужно для организации доступа к полю экземпляра DisplayClass.
        MemberExpression displayClassInstance = (MemberExpression)expression.Body;
        MemberInfo closure = displayClassInstance.Member;
        return closure.Name;
    }
}

Эти подходы несут с собой необходимость использования синтаксиса лямбда-выражений и повышенную нагрузку на систему во время выполнения кода. Подобные конструкции, кроме того, чрезвычайно легко «поломать». Теперь же, в C# 10, благодаря CallerArgumentExpression, наконец появилось более приличное решение задачи проверки аргументов:

public static partial class Argument
{
    public static T NotNull<T>([NotNull] this T? value, [CallerArgumentExpression("value")] string name = "")
        where T : class =>
        value is null ? throw new ArgumentNullException(name) : value;

    public static string NotNullOrWhiteSpace([NotNull] this string? value, [CallerArgumentExpression("value")] string name = "") =>
        string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.StringCannotBeEmpty, name), name)
            : value;

    public static int NotNegative(this int value, [CallerArgumentExpression("value")] string name = "") =>
        value < 0
            ? throw new ArgumentOutOfRangeException(name, value, string.Format(CultureInfo.CurrentCulture, Resources.ArgumentCannotBeNegative, name))
            : value;
}

В результате проверка аргументов выполняется с использованием более компактного и быстрого кода:

public record Person
{
    public Person(string name, int age, Uri link) => 
        (this.Name, this.Age, this.Link) = (name.NotNullOrWhiteSpace(), age.NotNegative(), link.NotNull().ToString());
        // Компилируется в:
        // this.Name = Argument.NotNullOrWhiteSpace(name, "name");
        // this.Age = Argument.NotNegative(age, "age");
        // this.Link = Argument.NotNull(link, "link").ToString();

    public string Name { get; }
    public int Age { get; }
    public string Link { get; }
}

Имя аргумента генерируется во время компиляции, а во время выполнения кода нет вообще никакой дополнительной нагрузки на систему.

Проверка утверждений и логирование


Ещё один интересный сценарий применения новых возможностей открывается в сферах проверки утверждений и логирования:

[Conditional("DEBUG")]
static void Assert(bool condition, [CallerArgumentExpression("condition")] string expression = "")
{
    if (!condition)
    {
        Environment.FailFast($"'{expression}' is false and should be true.");
    }
}

Assert(y > TimeSpan.Zero);
// Компилируется в:
Assert(y > TimeSpan.Zero, "y > TimeSpan.Zero");

[Conditional("DEBUG")]
static void Log<T>(T value, [CallerArgumentExpression("value")] string expression = "")
{
    Trace.WriteLine($"'{expression}' has value '{value}'");
}

Log(Math.Min(Environment.ProcessorCount, x));
// Компилируется в:
Log(Math.Min(Environment.ProcessorCount, x), "Math.Min(Environment.ProcessorCount, x)");

Использование новых возможностей в старых проектах


Если на компьютере установлен .NET 6.0 SDK и доступен C# 10, CallerArgumentExpression можно пользоваться в проектах, нацеленных на .NET 5 и .NET 6. В более старых .NET- или .NET Standard-проектах CallerArgumentExpressionAttribute недоступен. Но даже в таких проектах, при условии установленного .NET 6.0 SDK, можно воспользоваться этой возможностью. Достаточно вручную добавить в проект класс CallerArgumentExpressionAttribute и применить его как встроенный атрибут:

#if !NET5_0 && !NET6_0
namespace System.Runtime.CompilerServices;

/// <summary>
/// Позволяет захватывать выражения, переданные методу.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
internal sealed class CallerArgumentExpressionAttribute : Attribute
{
    /// <summary>
    /// Инициализирует новый экземпляр <see cref="T:System.Runtime.CompilerServices.CallerArgumentExpressionAttribute" /> class.
    /// </summary>
    /// <param name="parameterName">Имя целевого параметра.</param>
    public CallerArgumentExpressionAttribute(string parameterName) => this.ParameterName = parameterName;

    /// <summary>
    /// Получает имя целевого параметра <c>CallerArgumentExpression</c>.
    /// </summary>
    /// <returns>
    /// Имя целевого параметра <c>CallerArgumentExpression</c>.
    /// </returns>
    public string ParameterName { get; }
}
#endif

Всё это должно быть представлено внутренними механизмами. В результате — когда на эту сборку сошлётся другая сборка, не будет конфликта со встроенной версией [CallerArgumentExpression]. А компилятор C# 10 сам всё поймёт, после чего поведёт себя так, как мы уже видели в самом первом примере.

Пользуетесь ли вы CallerArgumentExpression в своих C#-проектах?

Комментарии (10)


  1. Dmitry3A
    24.11.2021 17:37
    +1

    Интересно почему string а не ExpressionTree? Ну и с именами бы могли придумать что-то типа

    void Function(int a, TimeSpan b, [CallerArgumentExpression()] string aCallerArg = "", [CallerArgumentExpression("b")] string d = "")

    т.е. добавив определённый префикс избавляемся от необходимости указывать имя как параметер у CallerArgumentExpression и даём возможность упасть при компиляции если такого аргумента не обнаружено.


    1. DistortNeo
      24.11.2021 21:47
      +2

      Интересно почему string а не ExpressionTree?

      Потому что будет слишком сложно. Во-первых, не любой код можно представить в виде Expression. А во-вторых, это должна быть константа времени компиляции, чтобы не допускать просадок в производительности. Ну и в-третьих, для логирования все равно бы пришлось переводить в строку. Ну и даже если бы и был способ получить Expression, то доступа к значениям переменных бы все равно не было.


      1. Dmitry3A
        24.11.2021 23:16
        +1

        не любой код можно представить в виде Expression
        Интересно что нельзя представить в виде Expression что можно использовать при вычислении параметра?
        это должна быть константа времени компиляции, чтобы не допускать просадок в производительности
        производительности компилятора? там затраты будут совсем смешные.

        Ну если в 99% для логгирования, то понятно, но интересно же использовать для каких нибудь хитрых штук, там Expression как раз был бы полезен.


        1. DistortNeo
          25.11.2021 09:35
          +1

          Интересно что нельзя представить в виде Expression что можно использовать при вычислении параметра?

          А вы поэкспериментируйте. Как минимум, нельзя использовать нативные указатели (редкий сценарий, но всё же).


          производительности компилятора? там затраты будут совсем смешные.

          Производительности программы. Потому что строки могут быть константами времени компиляции, а ExpressionTree — нет. Было бы не очень приятно, если бы этот объект конструировался при вызове.


          1. Dmitry3A
            25.11.2021 19:28

            Как минимум, нельзя использовать нативные указатели (редкий сценарий, но всё же).
            Какой-нибудь LINQ тоже на expression tree заточен, как-то обходят ограничения.
            Expression<Func<int, bool>> lambda = num => num < 5; 
            Что-то подобное разве не на этапе компиляции создаётся? А передача ссылки на строку или ссылки на лямбду по идее одинакова по затратам.

            Я не спорю что есть причины почему строку передают и скорее всего они уже жёваны-пережёваны при дизайне фичи, просто для себя хотелось понять.


            1. DistortNeo
              25.11.2021 22:43

              Что-то подобное разве не на этапе компиляции создаётся?

              Внезапно, нет. Посмотрите IL-код и сразу получите ответ на вопрос: Expression каждый раз конструируется заново.


              А передача ссылки на строку или ссылки на лямбду по идее одинакова по затратам.

              Если лямбда обращается к внешним переменным, то до кучи ещё конструируется объект с захваченными переменными. А это уже накладные расходы.


              Причём в случае представления лямбды в виде Expression выделить неизменяемую часть не представляется возможным. Причина заключается в том, что Expression может представлять только статический метод. Поэтому все обращения к захваченным переменным выглядят как FieldExpression(ConstantExpression(obj)), где ConstantExpression каждый раз разный.


        1. vabka
          25.11.2021 12:58

          Интересно что нельзя представить в виде Expression что можно использовать при вычислении параметра?

          Да хотябы новомодный паттерн-матчинг через switch-expression или через is.

          Нельзя null coalescing operator (который ?.)


          1. Dmitry3A
            25.11.2021 19:29

            Да хотябы новомодный паттерн-матчинг через switch-expression или через is.

            Нельзя null coalescing operator (который ?.)
            Это потому что пока не сделали или есть принципиальные ограничения дизайна?


    1. OlegAxenow
      25.11.2021 09:19

      По поводу "упасть при компиляции" - решается nameof(a).

      P.S. Конечно, это не гарантирует, что не окажется поля с таким названием... Вероятность этого можно снизить с помощью StyleCop или чем-то вроде. Сам уже лет 10 пользуюсь для приватных полей подчёркиванием ("_name"), коллег тоже подсадил - все довольны.


      1. Dmitry3A
        25.11.2021 19:33

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