C# 14 新功能 partial event (feat. weak event, source generator)

C# 14 引入了 partial event,為事件模型補上長久以來缺少的那塊拼圖。事件終於像方法與類別一樣,可以被「部分定義」,讓開發者與工具(尤其是 Source Generator)得以共同塑造事件的行為與生命週期。
在這篇文章中,我將以弱事件(weak event)整合 為例,示範 partial event 如何與 Source Generator 協同運作,並展示這項語言新特性如何讓事件擴充從此變得自然而優雅。

partial event

partial event 這個新功能使得我們可以在一個 partial class 定義事件的宣告,而在另一個 partial class 完成實作,例如:

 public partial class Person
 {
     // declare partial event
     public partial event EventHandler<StringChangedEventArgs> NameChanged;
     public string Name
     {
         get => field;
         set
         {
             if (field != value)
             {
                 var old = field;
                 field = value;
                 OnNameChanged(new StringChangedEventArgs(old, value));
             }
         }
     }
 }

 /// <summary>
 /// using nuget package ThomasLevesque.WeakEvent
 /// </summary>
 public partial class Person
 {

     private EventHandler<StringChangedEventArgs> _nameChangedHandler;
     public partial event EventHandler<StringChangedEventArgs> NameChanged
     {
         add => _nameChangedHandler += value;
         remove => _nameChangedHandler -= value;
     }

     // implement partial method for event invocation
     protected void OnNameChanged(StringChangedEventArgs args)
     {
         _nameChangedHandler?.Invoke(this, args);
     }
 }

乍看之下,partial constructor 或 partial event 似乎只是語言團隊補上的小功能,感覺沒什麼實際用途。但如果把它放回 partial class 的歷史脈絡來看,就會發現它其實延續了 C# 一直以來的設計哲學:讓同一個類別的不同責任能自然地拆分到多個檔案中,這種拆分方式在 .NET 生態系已經存在很久,而且有幾個非常典型、實際的應用場景:

  • 多人協作同一個類別的時候,如果全卡在同一個檔案,很容易在合併分支的時候搞出事來。分開兩個檔案各做各的可以有效降低問題發生機率。
  • 其中一邊並非是人為寫作的程式碼,而是藉由產生器產生的,像 Windows Forms 的設計畫面 、Entity Framework 的 model scaffold 或是 roslyn source generator
弱事件模式

使用事件模式的時候,最大的問題就是事件訂閱很容易造成『不知不覺』的記憶體洩漏 (memory leak)。弱事件模式的核心價值在於:避免事件訂閱造成的記憶體洩漏,同時保持事件模型的自然使用方式。一般事件訂閱會讓訂閱者被發行者持有強參考,只要事件沒有解除訂閱,垃圾回收機制(GC)就無法回收訂閱者,這在 UI、MVVM、長生命週期服務中特別常見。(關於這檔事,Huanlin 老師有寫過幾篇很棒的文章,可以參考使用 Weak Events 來避免記憶體洩漏問題)

為避免對於 WPF API 的依賴,這邊我就不採用 WPF 中既有的 WeakEventManagerWeakEventManager<TEventSource,TEventArgs> 展示,我找到一個好心人寫的 nuget 包 –  ThomasLevesque.WeakEvent  (source code)。

加上這個套件後實作端的 partial class 更改如下:

 public partial class Person
 {
     // implement partial event (weak event mode) 
     private readonly WeakEventSource<StringChangedEventArgs> _nameChangedHandler = new();
     public partial event EventHandler<StringChangedEventArgs> NameChanged
     {
         add => _nameChangedHandler.Subscribe(value);
         remove => _nameChangedHandler.Unsubscribe(value);
     }

     // implement partial method for event invocation
     protected void OnNameChanged(StringChangedEventArgs args)
     {
         _nameChangedHandler?.Raise(this, args);
     }
 }

add/remove 會分別使用 WeakEventSource<T> 的 Subscribe/Unsubscribe,執行事件委派函式的部分也改為 WeakEventSource<T>.Raise 。

source generator 的應用

弱事件模式很棒,但衍生出來的是得多寫一些程式碼,如果專案中的自訂類別有很多事件需要實作弱事件模式就會讓人覺得很煩,這個讓人覺得很煩,又偏偏不得不寫的程式碼有個名稱叫『樣板程式碼 (boilerplate)』,要解決寫作樣板程式碼的困擾其中一個方式就是 source generator。以上面的例子來看,我們只要處理宣告的 partial class,實作端則由 source generator 來代勞即可。

所以來建造一個產生器:

    public class WeakEventGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            context.RegisterPostInitializationOutput(static (context) => PostInitializationOutput(context));
            
            var referenceExsit = context.MetadataReferencesProvider
                              .Collect()
                              .Select((metadataReferences, cancellationToken) =>
                              {                                  
                                  const string targetAssemblyName = "WeakEvent";
                                  bool found = metadataReferences.Any(
                                      reference => reference is PortableExecutableReference peReference 
                                                             && peReference.Display.Contains(targetAssemblyName)                                          
                                  );
                                  return found;
                              });

            IncrementalValuesProvider<WeakEventModel> model = context.SyntaxProvider.ForAttributeWithMetadataName(
                                          "WeakEventGenerators.WeakEventAttribute",
                                          static (node, _) => IsTarget(node),
                                          static (context, _) => GetOutputContext(context));
            

            var result = model.Combine(referenceExsit);
            context.RegisterSourceOutput(result, static (context, source) => ExecuteOutput(context, source));
            
        }

        private static void ExecuteOutput(SourceProductionContext context, (WeakEventModel model, bool referenceExist) source)
        {
            if (!source.referenceExist) { return; }
            string @namespace = source.model.Namespace;
            string typeName = source.model.TypeName;
            List<string> eventImplementations = new List<string>();
            foreach (var @event in source.model.Events)
            {
                var fieldName = $"_{@event.EventName}";
                string eventImplementation = $$"""
                     private readonly WeakEventSource<{{@event.EventArgsType}}> {{fieldName}}  = new();
                     public partial event EventHandler<{{@event.EventArgsType}}> {{@event.EventName}}
                     {
                         add => {{fieldName}}.Subscribe(value);
                         remove => {{fieldName}}.Unsubscribe(value);
                     }

                     protected void On{{@event.EventName}}({{@event.EventArgsType}} args)
                     {
                         {{fieldName}}?.Raise(this, args);
                     }
                     """;
                eventImplementations.Add(eventImplementation);
            }
            var builder = new StringBuilder();
            builder.Append($$"""
                using WeakEvent;
                namespace {{@namespace}};
                public partial class {{typeName}}
                {
                   {{string.Join("\n", eventImplementations)}}
                }
                """);
            context.AddSource($"{typeName}_WeakEvents.g.cs", builder.ToString());
        }

        private static WeakEventModel GetOutputContext(GeneratorAttributeSyntaxContext context)
        {
            var targetSymbol = context.TargetSymbol as INamedTypeSymbol;
            string @namespace = targetSymbol.ContainingNamespace.ToDisplayString();
            string typeName = targetSymbol.Name;
            var events = targetSymbol.GetMembers()
                                     .OfType<IEventSymbol>()
                                     .Where(e => e.IsPartialDefinition && e.Type is INamedTypeSymbol symbol
                                                                       && symbol.IsGenericType
                                                                       && symbol.ConstructedFrom.Name == "EventHandler" 
                                                                       && symbol.ConstructedFrom.ContainingNamespace.ToDisplayString() == "System" 
                                                                       && symbol.TypeArguments.Length == 1)
                                     .Select(e => new WeakEventEventModel(e.Name, (e.Type as INamedTypeSymbol).TypeArguments[0].ToDisplayString()))
                                     .ToArray();

            return new WeakEventModel(@namespace, typeName, events);
        }

        private static bool IsTarget(SyntaxNode node)
        {
            return (node is ClassDeclarationSyntax classDeclaration && classDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword));
        }

        private static void PostInitializationOutput(IncrementalGeneratorPostInitializationContext context)
        {
            const string attributeText = """
                namespace WeakEventGenerators
                {
                   [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
                   public sealed class WeakEventAttribute : System.Attribute
                   {
                       public WeakEventAttribute(){}
                   }
                }
                """;
            context.AddSource("WeakEventAttribute.g.cs", attributeText);
        }
    }

這個產生器在初始化過程會建立一個用來標示這個類別要對應產生 partial event 實作的標籤 – WeakEventAttribute。編譯時期根據這個標籤所在的型別內部搜尋 partial event,建立必要的 model 、根據 model 產生對應的 partial event 實作程式碼,於是宣告端只要加上 WeakEventAttribute 就好,剩下的就是 source generator 的工作了:

 [WeakEvent]
 public partial class Person
 {
     // declare partial event
     public partial event EventHandler<StringChangedEventArgs> NameChanged;

     public string Name
     {
         get => field;
         set
         {
             if (field != value)
             {
                 var old = field;
                 field = value;
                 OnNameChanged(new StringChangedEventArgs(old, value));
             }
         }
     }
 }

這篇文章的範例在此

各位如果對 Source Generator 的開發有興趣, 12/20 skilltree 有場活動 『Roslyn 魔法工坊:打造你的 Source Generator (點擊此連結了解詳情)』,可以參考看看。