[Перевод] Новые возможности C#10: атрибут CallerArgumentExpression
Об атрибуте 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([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#-проектах?