C# UnsafeAccessor: Access Private Members in .NET
The UnsafeAccessor attribute, introduced in .NET 8,
provides a high-performance mechanism to access private and internal
members of a class or struct without the runtime overhead of traditional
reflection. This article covers what the [UnsafeAccessor]
attribute is, how it compares to standard reflection and
source-generated approaches, how to implement it for fields, methods,
and constructors, and why it is essential for modern Native AOT
(Ahead-of-Time) compilation.
What is UnsafeAccessor?
In C# and .NET, accessing non-public members historically required
using reflection via System.Reflection APIs (such as
GetField, GetMethod, or
BindingFlags.NonPublic). While functional, reflection
incurs significant performance penalties due to string lookups, member
resolution, dynamic dispatch, and boxing/unboxing.
The UnsafeAccessorAttribute, located in the
System.Runtime.CompilerServices namespace, resolves this
issue. It instructs the runtime (CoreCLR or Native AOT) to create an
optimized, direct bridge to the target private member during compilation
or JIT time.
How UnsafeAccessor Works
To use UnsafeAccessor, you define an
extern static method matching the signature of the target
member and annotate it with the [UnsafeAccessor] attribute.
The compiler and the runtime generate code that accesses the member
directly, achieving near-native performance identical to calling a
public member directly.
The attribute requires an UnsafeAccessorKind enum
parameter, which specifies what type of member is being targeted:
Constructor: Accesses a private constructor.Method: Accesses a private instance method.StaticMethod: Accesses a private static method.Field: Reads or writes a private instance field.StaticField: Reads or writes a private static field.
Code Examples
Consider the following target class with private members:
namespace App;
public class TargetClass
{
private string _secretField = "Original Secret";
private TargetClass(string initialValue)
{
_secretField = initialValue;
}
private string GetSecretMessage(string prefix)
{
return $"{prefix}: {_secretField}";
}
}1. Accessing a Private Constructor
To instantiate TargetClass through its private
constructor, define an accessor with
UnsafeAccessorKind.Constructor:
using System.Runtime.CompilerServices;
using App;
public static class Accessors
{
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
public static extern TargetClass CreateInstance(string initialValue);
}
// Usage:
TargetClass instance = Accessors.CreateInstance("New Secret");2. Reading and Modifying a Private Field
To access an instance field, the first parameter must be the target
instance. To allow both reading and modifying the field, return the
field type by reference (ref):
public static class Accessors
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_secretField")]
public static extern ref string GetSecretField(TargetClass target);
}
// Usage:
TargetClass instance = Accessors.CreateInstance("Initial");
// Read private field
ref string secret = ref Accessors.GetSecretField(instance);
Console.WriteLine(secret); // Output: Initial
// Modify private field directly
secret = "Updated via ref";
Console.WriteLine(Accessors.GetSecretField(instance)); // Output: Updated via ref3. Invoking a Private Method
For private instance methods, the first parameter represents the target instance, followed by any parameters the target method expects:
public static class Accessors
{
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetSecretMessage")]
public static extern string CallGetSecretMessage(TargetClass target, string prefix);
}
// Usage:
string result = Accessors.CallGetSecretMessage(instance, "CONFIDENTIAL");
Console.WriteLine(result); // Output: CONFIDENTIAL: Updated via refKey Advantages Over Reflection
- Near-Zero Overhead: Calls to
UnsafeAccessormethods are inlined by the JIT/AOT compiler, bypassing metadata lookup and dynamic dispatch entirely. - Native AOT Compatible: Reflection often fails in
Native AOT because the trimmer strips private members deemed unused.
UnsafeAccessorsignals to the compiler that the target member is strictly referenced, preventing trimming. - No Boxing for Value Types: Primitive values and
structs do not need to be boxed into
object, eliminating garbage collection pressure. - Clean Syntax: Interacting with members via strongly typed C# signatures prevents runtime casting and stringly-typed member-name errors.
Limitations
- Safety Guarantee: Because access modifiers exist to protect state invariants, bypassing them can result in unexpected behavior if internal invariants are broken.
- Generic Constraints: Accessing private members on open generic types requires defining accessors that mirror the target type's generic parameters.
- Runtime Resolution: If the target member signature
changes or is renamed in a dependency update, the runtime throws a
MissingFieldExceptionorMissingMethodExceptionat execution time.