UnsafeAccessorAttribute 指南 (1)

UnsafeAccessorAttribute 是 .NET 8 加入的新特性,它提供了一種高效能的方式來存取型別的非公開成員。這個 Attribute 允許開發者在編譯時期定義存取器方法,並在執行時期以接近直接存取的效能來存取型別內部的非公開成員。

何時會用到

在真實世界的開發中,偶爾會遇到一些棘手的狀況需要存取非公開成員,原因可能是:

  • 要拓展第三方函式庫的功能,但有一些重要的成員的存取是非公開的,通常是 internal 或 private 成員。
  • 某些 DI 容器或工廠框架在具現化物件時,可能需要注入依賴,但該物件可能沒有公開的建構函式。
  • 需要作物件映射 (Object Mapping),可是偏偏有某一個屬性或欄位是非公開的。
  • 解決AOT(Ahead-of-Time Compilation)場景中可能無法使用反射的問題。
  • 也許還有其他…
可存取的對象

在 .NET 8 所加入的這個 UnsafeAccessorAttribute 可以存取

  1. 執行個體建構式
  2. 執行個體方法
  3. 執行個體欄位
  4. 靜態方法
  5. 靜態欄位
使用方式

微軟把使用方式設計地非常簡便,語法如下:

 [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "DisplayInfo")]
 public extern static  void CallDisplayInfo(Person person);

基本用法就是宣告一個 extern static 方法,傳入存取對象的型別,掛上 UnsafeAccessorAttribute,並指定要存取的對象形式與名稱。

執行個體成員存取

假設有這麼一個甚麼都是 private 的型別:

 public class Person
 {
     /// <summary>
     /// field
     /// </summary>
     private string _name;
     
     /// <summary>
     /// filed
     /// </summary>
     private int _age;

     /// <summary>
     /// constructor
     /// </summary>
     /// <param name="name"></param>
     /// <param name="age"></param>
     private Person(string name, int age) => (_name, _age) = (name, age);

     /// <summary>
     /// method 
     /// </summary>
     private void Display() => Console.WriteLine($"{_name} -- {_age}");

     /// <summary>
     /// method (with return value)
     /// </summary>
     /// <returns></returns>
     private string Describe() => $"My name is {_name} ,and I am {_age} years old";

     /// <summary>
     /// method (with parameters)
     /// </summary>
     /// <param name="year"></param>
     private void AddAge(int year) => _age += year;
 }

針對這個 Person class 寫一個存取器:

 public class PersonAccessor
 {
     /// <summary>
     /// Access private instance constructor
     /// </summary>
     /// <param name="name"></param>
     /// <param name="age"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
     public extern static Person Create(string name, int age);

     /// <summary>
     /// Access private instance method (Display)
     /// </summary>
     /// <param name="person"></param>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Display")]
     public extern static void CallDisplay(Person person);

     /// <summary>
     /// Access private instance method (Describe)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Describe")]
     public extern static string CallDescribe(Person person);

     /// <summary>
     /// Access private instance method (AddAge)
     /// </summary>
     /// <param name="person"></param>
     /// <param name="year"></param>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "AddAge")]
     public extern static void CallAddAge(Person person, int year);

     /// <summary>
     /// Access private instance field (_name)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_name")]
     public extern static ref string GetNameFiled(Person person);

     /// <summary>
     /// Access private instance field (_age)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_age")]
     public extern static ref int GetAgeField(Person person);
 }

對於不同的執行個體成員來說,有一些細節上的差異,接下來解說這一部分

存取執行個體建構式

這大概是最簡單的, attribute 設定要傳入 UnsafeAccessorKind.Constructor  表示存取對象為建構式,回傳值型別要設定正確,上面的例子是呼叫 Person class 的建構式,所以回傳值型別就是 Person;若建構式帶有參數,就要設定相同的參數清單:

 [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
 public extern static Person Create(string name, int age);
存取執行個體方法

先來看比較簡易的,沒有回傳值也沒有參數的方法:

 [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Display")]
 public extern static void CallDisplay(Person person);

UnsafeAccessorKind 要選 Method 表示存取對象為方法,Name 則指名要存取的方法名稱。參數清單則需要在第一個加入 Person 型別的參數;這點有些類似擴充方法的第一個參數或是像使用反射的時候呼叫 MethodBase.Invoke 方法需要在第一個參數傳入對應執行個體一樣。

如果有回傳值的話,那就必須要宣告與對應方法相同型別的回傳值,目前這邊是沒有甚麼共逆變這一類轉換的。

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Describe")]
public extern static string CallDescribe(Person person);

有參數的情況下,當然要在 Person 參數後方加入相同的參數清單。

 [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "AddAge")]
 public extern static void CallAddAge(Person person, int year);
存取執行個體欄位

這就比較特殊一些,會用到一個不太常用到的功能 ref return,這會直接參考到該欄位的變數位址,而不是單純回傳變數的內容值;之所以這麼設計的原因就是對於欄位不僅僅可以取得它的內容值,也需要能夠修改。

 [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_name")]
 public extern static ref string GetNameFiled(Person person);

GetNameField 方法的回傳型別就是該欄位的型別,記住,一定要 ref return,否則會在執行時期拋出錯誤。

整個 Accessor 的呼叫範例如下。

 static void Main(string[] args)
 {
     var person = PersonAccessor.Create("Joe", 25);
     PersonAccessor.CallDisplay(person);
     ref int age = ref PersonAccessor.GetAgeField(person);
     age = 31;
     PersonAccessor.CallDisplay(person);
     ref string name = ref PersonAccessor.GetNameFiled(person);
     name = "David";
     PersonAccessor.CallDisplay(person);
     string description = PersonAccessor.CallDescribe(person);
     Console.WriteLine(description);

     PersonAccessor.CallAddAge(person, 10);
     PersonAccessor.CallDisplay(person);
 }
屬性怎麼辦?

UnsafeAccessorKind 列舉還真沒有屬性這個項目,不過仔細想想屬性在編譯時其實會為 setter 和 getter 個別產出方法,所以轉個圈呼叫這些方法就可以了,例如以下的存取對象具有兩個私有屬性。

 public class Person
 {
     /// <summary>
     /// property
     /// </summary>
     private string Name { get; set; }

     /// <summary>
     /// property
     /// </summary>
     private int Age { get; set; }

     public Person(string name, int age)
     {
         Name = name;
         Age = age;
     }

     public override string ToString()
     {
         return  $"{Name} -- {Age}";
     }
 }

Accessor 類別中存取私有屬性的宣告方式如下:

 public static class PersonAccessor
 {
     /// <summary>
     /// Accessing private property's getter (Name)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Name")]
     public extern static string GetNameProperty(Person person);

     /// <summary>
     /// Accessing private property's setter (Name)
     /// </summary>
     /// <param name="person"></param>
     /// <param name="name"></param>

     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Name")]
     public extern static void SetNameProperty(Person person, string name);

     /// <summary>
     /// Accessing private property's getter (Age)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Age")]
     public extern static int GetAgeProperty(Person person);

     /// <summary>
     /// Accessing private property's setter (Age)
     /// </summary>
     /// <param name="person"></param>
     /// <param name="age"></param>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Age")]
     public extern static void SetAgeProperty(Person person, int age);
 }

反正就是對於 getter 對應的方法名稱就是 get_屬性名稱;而 setter 就是 set_屬性名稱。另外要注意的是對於 get 方法的回傳型別是對應屬性型別的,而 set 方法的回傳則是 void。

存取屬性的呼叫範例如下。

 static void Main(string[] args)
 {
     var person = new Person("Alice", 28);
     // Accessing private properties via UnsafeAccessor
     string name = PersonAccessor.GetNameProperty(person);
     int age = PersonAccessor.GetAgeProperty(person);
     Console.WriteLine($"Person: {person.ToString()}");
     // Modifying private properties via UnsafeAccessor
     PersonAccessor.SetNameProperty(person, "Bob");
     PersonAccessor.SetAgeProperty(person, 35);
     Console.WriteLine($"Updated Person: {person.ToString()}");
 }

 

存取靜態欄位與靜態方法

存取靜態成員這部分就比較奇巧 (台語),我個人是認為微軟在未來的幾次更版應該會有所變化。

先來看一個簡單的範例,有個型別有這麼兩個靜態成員:

 public class Configuration
 {
     /// <summary>
     /// static field
     /// </summary>
     private static string _key = "Default-key";

     /// <summary>
     /// static method
     /// </summary>
     /// <param name="key"></param>
     private static void SetKey(string key)
     {
         _key = key;
     }
 }

Accessor 就要這樣設計

 public static class ConfigurationAccessor
 {
     /// <summary>
     /// Accessing private static field's getter (_key)
     /// </summary>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_key")]
     public extern static ref string GetKeyField(Configuration _);
     /// <summary>
     /// Accessing private static method (SetKey)
     /// </summary>
     /// <param name="key"></param>
     [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "SetKey")]
     public extern static void CallSetKey(Configuration _ , string key);
 }

上文所說奇巧的地方就在於即使是存取靜態成員,第一個參數還是要設定為目標型別,所以呼叫的時候就變得有點妙 (想想也還好,反射不就也這樣嗎)。

 static void Main(string[] args)
 {
     ref string key = ref ConfigurationAccessor.GetKeyField(null);
     Console.WriteLine($"Current Key: {key}");
     ConfigurationAccessor.CallSetKey(null, "New-Secret");
     ref string newKey = ref ConfigurationAccessor.GetKeyField(null);
     Console.WriteLine($"Updated Key: {newKey}");
 }

依據.NET8 的用法如果存取對象是個靜態類別就根本沒辦法,不過在 .NET 10 這問題有解。

Benchmark

為了測試比對方便,存取對象的類別所有成員修飾詞都宣告為 public,這樣就可以看出和直接存取的差異。

  public class Person
  {
      /// <summary>
      /// field
      /// </summary>
      public string _name;

      /// <summary>
      /// filed
      /// </summary>
      public int _age;

      /// <summary>
      /// constructor
      /// </summary>
      /// <param name="name"></param>
      /// <param name="age"></param>
      public Person(string name, int age) => (_name, _age) = (name, age);
     
      /// <summary>
      /// method (with parameters)
      /// </summary>
      /// <param name="year"></param>
      public void AddAge(int year) => _age += year;
  }
 public static class PersonAccessor
 {
     /// <summary>
     /// Access private instance constructor
     /// </summary>
     /// <param name="name"></param>
     /// <param name="age"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
     public extern static Person Create(string name, int age); 

     /// <summary>
     /// Access private instance method (AddAge)
     /// </summary>
     /// <param name="person"></param>
     /// <param name="year"></param>
     [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "AddAge")]
     public extern static void CallAddAge(Person person, int year);


     /// <summary>
     /// Access private instance field (_name)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_name")]
     public extern static ref string GetNameFiled(Person person);

     /// <summary>
     /// Access private instance field (_age)
     /// </summary>
     /// <param name="person"></param>
     /// <returns></returns>
     [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_age")]
     public extern static ref int GetAgeField(Person person);
 }

Benchmark 測試程式碼如下

 [MemoryDiagnoser]
 public class AccessorBenchmark
 {
     private Person _person;
     [GlobalSetup]
     public void Setup()
     {
         _person = new Person("BenchmarkUser", 40);
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Field — direct access")]
     public void AccessPrivateField_Direct()
     {            
         _person._age += 1;
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Field — unsafeaccessor")]
     public void AccessPrivateField_Unsafe()
     {
         ref int age = ref PersonAccessor.GetAgeField(_person)
         age += 1;
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Field — reflection")]
     public void AccessPrivateField_Reflection()
     {
         var fieldInfo = typeof(Person).GetField("_age");
         int age = (int)fieldInfo.GetValue(_person);
         age += 1;
         fieldInfo.SetValue(_person, age);
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Method — direct access")]
     public void AccessPrivateMethod_Direct()
     {
         _person.AddAge(2);
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Method — unsafeaccessor")]
     public void AccessPrivateMethod_Unsafe()
     {
         PersonAccessor.CallAddAge(_person, 2);
     }

     [MethodImpl(MethodImplOptions.NoInlining)]
     [Benchmark(Description = "Method — reflection")]
     public void AccessPrivateMethod_Reflection()
     {
         var methodInfo = typeof(Person).GetMethod("AddAge");
         methodInfo.Invoke(_person, new object[] { 2 });
     }
 }

以下是測試結果

// * Summary *

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7171/25H2/2025Update/HudsonValley2)
12th Gen Intel Core i7-1265U 2.70GHz, 1 CPU, 12 logical and 10 physical cores
.NET SDK 10.0.100
  [Host]     : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3


| Method                    | Mean       | Error     | StdDev    | Median     | Gen0   | Allocated |
|-------------------------- |-----------:|----------:|----------:|-----------:|-------:|----------:|
| 'Field — direct access'   |  0.0008 ns | 0.0027 ns | 0.0025 ns |  0.0000 ns |      - |         - |
| 'Field — unsafeaccessor'  |  0.0019 ns | 0.0041 ns | 0.0039 ns |  0.0000 ns |      - |         - |
| 'Field — reflection'      | 18.5785 ns | 0.1466 ns | 0.1300 ns | 18.5971 ns | 0.0076 |      48 B |
| 'Method — direct access'  |  0.0028 ns | 0.0051 ns | 0.0042 ns |  0.0005 ns |      - |         - |
| 'Method — unsafeaccessor' |  0.0025 ns | 0.0039 ns | 0.0036 ns |  0.0000 ns |      - |         - |
| 'Method — reflection'     | 19.4067 ns | 0.1388 ns | 0.1231 ns | 19.4273 ns | 0.0089 |      56 B |

測試的方式可能因為每個執行速度太快沒有很精準,但起碼可以看得出來 UnsafeAccessor 的效能非常逼近直接存取,而 Reflection 則是慢得多。範例在此

附加警語

這玩意兒看起來很威猛,但對於封裝性的破壞也是核彈級的,使用前請務必仔細思量。