UnsafeAccessorAttribute 是 .NET 8 加入的新特性,它提供了一種高效能的方式來存取型別的非公開成員。這個 Attribute 允許開發者在編譯時期定義存取器方法,並在執行時期以接近直接存取的效能來存取型別內部的非公開成員。
何時會用到
在真實世界的開發中,偶爾會遇到一些棘手的狀況需要存取非公開成員,原因可能是:
- 要拓展第三方函式庫的功能,但有一些重要的成員的存取是非公開的,通常是 internal 或 private 成員。
- 某些 DI 容器或工廠框架在具現化物件時,可能需要注入依賴,但該物件可能沒有公開的建構函式。
- 需要作物件映射 (Object Mapping),可是偏偏有某一個屬性或欄位是非公開的。
- 解決AOT(Ahead-of-Time Compilation)場景中可能無法使用反射的問題。
- 也許還有其他…
可存取的對象
在 .NET 8 所加入的這個 UnsafeAccessorAttribute 可以存取
- 執行個體建構式
- 執行個體方法
- 執行個體欄位
- 靜態方法
- 靜態欄位
使用方式
微軟把使用方式設計地非常簡便,語法如下:
[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 則是慢得多。範例在此。
附加警語
這玩意兒看起來很威猛,但對於封裝性的破壞也是核彈級的,使用前請務必仔細思量。