[Перевод] Новые возможности C#10: атрибут CallerArgumentExpression

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

iqfrnlh6rfhp2ch6sctznzehmus.jpeg

Класс 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([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(Func value)
    {
        if (value() is null)
        {
            throw new ArgumentNullException(GetName(value));
        }
    }

    private static string GetName(Func 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(Expression> value)
    {
        if (value.Compile().Invoke() is null)
        {
            throw new ArgumentNullException(GetName(value));
        }
    }

    private static string GetName(Expression> 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([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 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;

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

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


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

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

image-loader.svg

© Habrahabr.ru