C#: геттеры - сеттеры без создания дополнительной переменной?
Вопрос довольно прост, но ответа на него я найти не могу...
Смотрите. Очевидно, что я могу написать что то вроде
class MyClass
{
public Item MyProperty { get; private set; }
}
Таким образом, у меня в классе есть "торчащее наружу" свойство MyProperty типа Item, которое я могу изменять только изнутри класса.
Услождним задачу. Пусть я хочу каждый раз при изменении свойства еще и кидать событие. Я могу написать что то вроде
class MyClass
{
public event Action<Item> OnMyPropertyChanged;
private Item _myProperty;
public Item MyProperty
{
get => _myProperty;
private set
{
_myProperty = value;
OnMyPropertyChanged?.Invoke(_myProperty);
}
}
}
Но... при этом мне пришлось "заплатить" за то, что меня перестали устраивать автоматические геттеры и сеттеры: я вручную ввел переменную _myProperty, которая мне, в общем то, совершенно не нужна.
Просто я не могу понять, как БЕЗ введения переменной переписать сеттер так, чтобы он не только "сетил", но и посылал событие.
( ну, то есть, воспользоваться "публичным" именем свойства MyProperty я, очевидно, не могу: при вызовах вида get => MyProperty; возникает рекурсия...)
Не подкажете ли Вы мне какое то изящное решение, без введения моей собственной private переменной?
Вообще, вопрос, скорее, теоретический...
Ответы (1 шт):
Конечно, можно!
Раньше было нельзя, но с C# 9 у нас появилась первоклассная во всех смыслах кодогенерация, и добавлять реализацию свойств стало легко.
Кодогенерация — свежий проект, и покамест в нём есть ограничения. Тем не менее, уже сейчас многое можно делать. (Будет развиваться дальше как сама фича, так и поддержка её в Visual Studio.) На текущий момент вы не можете изменять существующий код, только добавлять новый. Поэтому сделаем так, чтобы программисту нужно было лишь объявить поле и добавить к нему атрибут, а свойство и реализацию отправки PropertyChanged взял на себя генератор.
За основу я взял вот этот уже готовый пример и лишь немного его изменил. (Полный код можно форкнуть тут.)
Итак, добавьте к своему solution'у ещё один проект (назовём его, например, GeneratorSample). В проектный файл положите следующее:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0-3.final"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.0"
PrivateAssets="all" />
</ItemGroup>
</Project>
Генератором является класс, который реализует интерфейс Microsoft.CodeAnalysis.ISourceGenerator, и отмечен атрибутом [Generator] из того же пространства имён Microsoft.CodeAnalysis. Положим такой класс в наш проект, назовём его AutoNotifyGenerator. У нас получится что-то такое:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
namespace GeneratorSample
{
[Generator]
public class AutoNotifyGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
}
}
}
Нам нужно заполнить оба метода. (using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory очень пригождается при генерации кода на Roslyn, т. к. класс SyntaxFactory приходится упоминать очень часто.)
Для начала метод Initialize.
public void Initialize(GeneratorInitializationContext context)
{
// регистрируем syntax receiver, который будет создаваться на каждый пробег генерации
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
Сам SyntaxReceiver объявим ниже вот таким образом прямо внутри нашего класса:
/// <summary>
/// Создаётся на каждый пробег анализатора
/// </summary>
class SyntaxReceiver : ISyntaxReceiver
{
public List<FieldDeclarationSyntax> CandidateFields { get; } = new();
/// <summary>
/// Вызывается для каждого синтаксического узла в compilation, мы можем
/// проверить узлы и сохранить информацию, полезную для генерации
/// </summary>
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// любое поле с атрибутом -- кандидат на рассмотрение
if (syntaxNode is FieldDeclarationSyntax fieldDeclarationSyntax &&
fieldDeclarationSyntax.AttributeLists.Count > 0)
{
CandidateFields.Add(fieldDeclarationSyntax);
}
}
}
Теперь, метод Execute. Он более сложный. Что нам нужно сделать? Во-первых, мы добавим в проект атрибут [AutoNotify], чтобы не заставлять программиста самого определять его, или использовать какие-то библиотеки, где он определён. Затем, найдём поля, помеченные этим атрибутом. (Для этого нам пригодится список всех полей с атрибутами, который нам даёт SyntaxReceiver.) Затем, для каждого из классов, где есть такое вот поле, добавим partial-часть класса (для этого и сам класс должен быть помечен как partial), где определим недостающее свойство.
Вот что у нас получается:
public void Execute(GeneratorExecutionContext context)
{
// Добавить текст атрибута и вспомогательный класс
context.AddSource("AutoNotifyAttribute",
SourceText.From(attributeAndHelperText, Encoding.UTF8));
// получаем наш заполненный SyntaxReceiver
if (!(context.SyntaxReceiver is SyntaxReceiver receiver))
return;
// мы создадим новую Compilation с атрибутом
// TODO: в будущем будет разрешено добавлять исходники во время инициализации,
// так что этот шаг не будет нужен
CSharpParseOptions options =
(context.Compilation as CSharpCompilation).SyntaxTrees[0].Options
as CSharpParseOptions;
Compilation compilation = context.Compilation.AddSyntaxTrees(
CSharpSyntaxTree.ParseText(
SourceText.From(attributeAndHelperText, Encoding.UTF8), options));
// получаем добавленный атрибут and INotifyPropertyChanged
INamedTypeSymbol attributeSymbol =
compilation.GetTypeByMetadataName("AutoNotify.AutoNotifyAttribute");
INamedTypeSymbol notifySymbol =
compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");
// обходим поля-кандидаты, выбираем только те, у которых
// действительно есть нужный атрибут
List<IFieldSymbol> fieldSymbols = new List<IFieldSymbol>();
foreach (FieldDeclarationSyntax field in receiver.CandidateFields)
{
SemanticModel model = compilation.GetSemanticModel(field.SyntaxTree);
foreach (VariableDeclaratorSyntax variable in field.Declaration.Variables)
{
// получаем символ, декларируемый этим полем, и запоминаем его,
// если он и правда имеет атрибут
IFieldSymbol fieldSymbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
if (fieldSymbol.GetAttributes()
.Any(ad => ad.AttributeClass.Equals(
attributeSymbol,
SymbolEqualityComparer.Default)))
fieldSymbols.Add(fieldSymbol);
}
}
// сгруппируем поля по классам, и сгенерируем новые исходные файлы
foreach (var group in fieldSymbols.GroupBy(f => f.ContainingType))
{
CompilationUnitSyntax classDef =
ProcessClass(group.Key, group.ToList(), attributeSymbol, notifySymbol,
context, options);
if (classDef is null)
continue;
var classSource = classDef.ToFullString();
var filenameHint = $"{group.Key.Name}_autoNotify.cs";
context.AddSource(filenameHint, SourceText.From(classSource, Encoding.UTF8));
}
}
Сам исходный текст атрибута и вспомогательной функци кладём рядом:
const string attributeAndHelperText = @"
using System;
namespace AutoNotify
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
sealed class AutoNotifyAttribute : Attribute
{
public AutoNotifyAttribute()
{
}
public string PropertyName { get; set; }
}
static class NotifyHelper
{
static public bool Equals<T>(T field, T value) =>
System.Collections.Generic.EqualityComparer<T>.Default.Equals(field, value);
}
}
";
Теперь, нам нужен вспомогательный метод ProcessClass, который и создаст для нас необходимый исходник.
CompilationUnitSyntax ProcessClass(
INamedTypeSymbol classSymbol, List<IFieldSymbol> fields,
ISymbol attributeSymbol, ISymbol notifySymbol,
GeneratorExecutionContext context, CSharpParseOptions options)
{
// проверим, а не вложенный ли это класс?
if (!classSymbol.ContainingSymbol.Equals(
classSymbol.ContainingNamespace, SymbolEqualityComparer.Default))
{
// TODO: выдать дагностическое сообщение, что класс должен быть на верхнем уровне
return null;
}
string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
// будем класть наш partial-класс в то же пространство имён
var ns = NamespaceDeclaration(ParseName(namespaceName))
.AddUsings(UsingDirective(ParseName("System")),
UsingDirective(ParseName("System.ComponentModel")))
.NormalizeWhitespace();
// добавили использование System и System.ComponentModel
// теперь определим класс и не забудем добавить partial
var cl = ClassDeclaration(classSymbol.Name)
.AddModifiers(Token(SyntaxKind.PartialKeyword));
// если класс не реализует INotifyPropertyChanged сам, добавим реализацию
if (!classSymbol.Interfaces.Contains(notifySymbol))
{
cl = cl.AddBaseListTypes(
SimpleBaseType(ParseTypeName(notifySymbol.ToDisplayString())))
.AddMembers(
EventDeclaration(
ParseTypeName(
"System.ComponentModel.PropertyChangedEventHandler"),
"PropertyChanged")
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
.NormalizeWhitespace()
);
}
// для каждого из полей создадим свойство
foreach (IFieldSymbol fieldSymbol in fields)
cl = ProcessField(cl, fieldSymbol, attributeSymbol, context, options);
// добавим декларацию класса в пространство имён
ns = ns.AddMembers(cl);
var unit = CompilationUnit().AddMembers(ns).NormalizeWhitespace();
return unit;
}
Нам теперь нужно определить метод ProcessField, который будет добавлять поле к классу. Тут существует много возможностей для ошибок (например, повторяющиеся имена полей), мы их не будем отлавливать все, поскольку проект всё же служит для иллюстрации (а он и так у нас получается немаленький).
Мы не будем конструировать синтаксическое дерево вручную, à la Roslyn Quoter, а немного сжульничаем: просто сгенерируем текстовый шаблон, и распарсим его. Вот что у нас получается:
private ClassDeclarationSyntax ProcessField(
ClassDeclarationSyntax cl, IFieldSymbol fieldSymbol, ISymbol attributeSymbol,
GeneratorExecutionContext context, CSharpParseOptions options)
{
// получим имя и тип поля
string fieldName = fieldSymbol.Name;
ITypeSymbol fieldType = fieldSymbol.Type;
// получим атрибут AutoNotify у поля, и посмотрим, не указал ли программист
// конкретное имя для свойства
AttributeData attributeData =
fieldSymbol.GetAttributes()
.Single(ad => ad.AttributeClass.Equals(
attributeSymbol,
SymbolEqualityComparer.Default));
TypedConstant overridenNameOpt =
attributeData.NamedArguments
.SingleOrDefault(kvp => kvp.Key == "PropertyName")
.Value;
string propertyName = chooseName(fieldName, overridenNameOpt);
if (propertyName.Length == 0 || propertyName == fieldName)
{
// не смогли создать имя для свойства? сообщим ошибку
context.ReportDiagnostic(
Diagnostic.Create(FieldErrorDiagnostic,
fieldSymbol.Locations.FirstOrDefault(),
fieldName));
return cl;
}
// шаблон для декларации
var declarationText = $@"
public {fieldType.ToDisplayString()} {propertyName}
{{
get => this.{fieldName};
set
{{
if (AutoNotify.NotifyHelper.Equals(this.{fieldName}, value))
return;
this.{fieldName} = value;
PropertyChanged?.Invoke(
this,
new System.ComponentModel.PropertyChangedEventArgs(nameof({propertyName})));
}}
}}
";
var subtree = ParseMemberDeclaration(declarationText, 0, options);
cl = cl.AddMembers(subtree);
return cl;
string chooseName(string fieldName, TypedConstant overridenNameOpt)
{
if (!overridenNameOpt.IsNull)
return overridenNameOpt.Value.ToString();
fieldName = fieldName.TrimStart('_');
if (fieldName.Length == 0)
return string.Empty;
if (fieldName.Length == 1)
return fieldName.ToUpper();
return fieldName.Substring(0, 1).ToUpper() + fieldName.Substring(1);
}
}
Нам ещё не хватает определения диагностического сообщения:
static DiagnosticDescriptor FieldErrorDiagnostic =
new DiagnosticDescriptor(
"AutoNotifyGenerator",
"Cannot generate property for this field",
"Property cannot be generated for field {0}",
"Code generation",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
Генератор готов.
Что же, давайте теперь его использовать. В проекте, к которому мы хотим подключить генератор, добавляем его как ссылку на проект, и изменяем проектный файл таким образом:
<ItemGroup>
<ProjectReference Include="..\GeneratorSample\GeneratorSample.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"/>
</ItemGroup>
Пишем тестовый код:
using AutoNotify;
namespace TestApp
{
class Program
{
static void Main(string[] args)
{
var t = new Test();
t.PropertyChanged += (o, args) =>
Console.WriteLine($"Changed value of {args.PropertyName}");
t.X = 10;
}
}
partial class Test
{
[AutoNotify]
int x;
}
}
Компилируем, запускаем. Должно работать! У меня выводится на консоль:
Changed value of X
Что ещё можно сделать с этим генератором? Можно улучшить диагностику. Можно сделать из него nuget-пакет, чтобы не таскать его в форме исходников. Можно генерировать более изощрённый и более параметризируемый исходный код. Вы же программист, действуйте!