.NET 10中的[UnsafeAccessorType]:更轻松地实现反射访问

本文深入探讨了.NET 10中引入的[UnsafeAccessorType]特性,它扩展了[UnsafeAccessor]的功能,允许开发者访问无法直接引用的私有成员,如字段、方法和构造函数,并详细介绍了其用法、限制以及与泛型的协作。

.NET 10中使用[UnsafeAccessorType]实现更轻松的反射

这是“探索.NET 10预览版”系列文章的第九篇。

在本文中,我将描述对.NET 8中引入的[UnsafeAccessor]机制的一些改进。[UnsafeAccessor]允许您轻松访问类型的私有字段和调用私有方法,而无需使用反射API。在.NET 9中,此机制可用的方法和类型存在一些限制。在.NET 10中,通过引入[UnsafeAccessorType],其中一些限制已被填补,本文将探讨这些改进。

在.NET 8和9中使用[UnsafeAccessor]调用私有成员

[UnsafeAccessor]机制在.NET 8中引入,并在.NET 9中增加了对泛型的支持。

我将忽略您为何希望以这种方式访问私有成员这个问题,因为这是题外话。您始终可以通过反射API实现此目的,[UnsafeAccessor]只是让它变得更容易、更快一些。

例如,假设您想检索List中的私有_items字段:

1
2
3
4
5
public class List<T>
{
    T[]? _items;
    // .. 其他成员
}

您可以使用如下反射代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 获取用于访问值的FieldInfo对象
var itemsFieldInfo = typeof(List<int>)
    .GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance);

// 创建列表实例
var list = new List<int>(16);

// 使用反射检索列表
var items = (int[])itemsFieldInfo.GetValue(list);

Console.WriteLine($"{items.Length} items"); // 输出 "16 items"

要使用[UnsafeAccessor],您必须创建一个特殊的extern方法,并用该属性修饰,该方法具有访问目标成员的正确签名。对于字段,这意味着一个方法,它接受目标类型的单个参数,并返回字段目标类型的实例作为ref。方法本身的名称并不重要。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 创建列表实例
var list = new List<int>(16);

// 调用方法以检索列表
int[] items = Accessors<int>.GetItems(list);

Console.WriteLine($"{items.Length} items"); // 输出 "16 items"

static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
    public static extern ref T[] GetItems(List<T> list);
}

请注意,由于List是泛型类型,我们必须使用“容器”类型来声明具有正确签名的访问器方法。使方法本身为泛型(GetItems<T>(List<T>))将不起作用,使用封闭类型GetItems(List<int>)也不会起作用。

本文后面还有更多关于调用泛型方法以及泛型类型成员的示例。

还值得注意的是,GetItems()签名返回一个ref(当您访问字段时必须如此),这意味着您也可以更改该字段。下面的示例使用相同的访问器方法,但这次替换了字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 创建列表实例
var list = new List<int>(16);
Console.WriteLine($"Capacity: {list.Capacity}"); // 输出 "Capacity: 16"

// 调用方法以检索字段ref并将字段值设置为空数组
Accessors<int>.GetItems(list) = Array.Empty<int>();
Console.WriteLine($"Capacity: {list.Capacity}"); // 输出 "Capacity: 0"

// 与之前相同的访问器:
static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
    public static extern ref T[] GetItems(List<T> list);
}

当然,您不仅可以调用字段,还可以调用方法(以及属性)和构造函数;任何在UnsafeAccesorType上定义的内容:

1
2
3
4
5
6
7
8
public enum UnsafeAccessorKind
{
  Constructor,
  Method,
  StaticMethod,
  Field,
  StaticField,
}

例如,以下展示了调用定义在List上的静态方法的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 调用私有静态方法
// 我们可以将`null`作为实例参数传递,因为这些都是静态方法
bool isCompat1 = Accessors<int?>.IsCompatibleObject(null, 123); // true
bool isCompat2 = Accessors<int?>.IsCompatibleObject(null, null); // true
bool isCompat3 = Accessors<int?>.IsCompatibleObject(null, 1.23); // false

static class Accessors<T>
{
    // 我们正在调用的方法具有以下签名:
    //     private static bool IsCompatibleObject(object? value)
    // 
    // 我们的extern签名必须将目标类型作为第一个方法参数包含在内
    [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "IsCompatibleObject")]
    public static extern bool CheckObject(List<T> instance, object? value);
}

如上所示,对于实例方法和静态方法,您都将“目标”实例作为方法的第一个参数(在调用静态方法时传递null作为参数)。这样运行时就知道它在处理哪种Type:即第一个参数的类型。但是,如果您无法指定此类型,因为类型本身是私有的,该怎么办?

.NET 9中[UnsafeAccessor]的限制

在.NET 9中使用[UnsafeAccessor]的一大限制是,您必须能够直接引用目标签名中涉及的所有类型。举个具体例子,假设您正在使用的库中有一个类似这样的类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class PublicClass
{
    private readonly PrivateClass _private = new("Hello world!");

    internal PrivateClass GetPrivate() => _private;
}

internal class PrivateClass(string someValue)
{
    internal string SomeValue { get; } = someValue;
}

这非常刻意,但足以满足我们的目的。假设您有一个PublicClass的实例,但您真正需要的是_private字段上持有的SomeValue。然而,PrivateClass被标记为internal,因此您无法直接引用它。这意味着这些访问器都无法工作,因为无法在签名中使用PrivateClass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_private")]
static extern ref readonly PrivateClass GetByField(PublicClass instance);
//                         👆 ❌ 无法引用PrivateClass

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPrivate")]
static extern PrivateClass GetByMethod(PublicClass instance);
//            👆 ❌ 无法引用PrivateClass

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_SomeValue")]
static extern string GetSomeValue(PrivateClass instance);
//                               👆 ❌ 无法引用PrivateClass

上面的例子是由于类型的可见性导致无法在签名中引用该类型。.NET 9中还有另一种情况不起作用:在编译时无法访问您正在处理的类型。第二种情况可能比较少见,但我想到了几个例子:

  • .NET运行时由于不同库之间的循环依赖而遇到这种情况,例如HTTP和加密库。
  • Datadog检测库需要访问我们正在检测的库的内部属性,但由于版本兼容性限制,我们无法直接引用这些库。

在.NET 9中,解决这些问题的唯一方法是回退到“普通”反射、使用System.Reflection.Emit或使用System.Linq.Expressions,所有这些都比[UnsafeAccessor]慢得多,并且更加繁琐。幸运的是,在.NET 10中,我们有一种两全其美的方法:即使对于无法引用的类型,我们也可以使用[UnsafeAccessor]!

在.NET 10中使用[UnsafeAccessorType]访问未引用的类型

在.NET 9中,您必须能够直接引用[UnsafeAccessor]方法签名中使用的所有类型。.NET 10引入了[UnsafeAccessorType]属性,它使我们即使对于无法引用的类型也能使用[UnsafeAccessor]。

将[UnsafeAccessorType]与[UnsafeAccessor]结合使用

.NET 10引入了一个新属性[UnsafeAccessorType],它允许您将[UnsafeAccessor]参数的预期类型指定为字符串,这解决了上一节中描述的两个场景。最好通过实际操作来理解这一点,因此让我们看一个与之前相同的例子。假设我们有这个层次结构。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class PublicClass
{
    private readonly PrivateClass _private = new("Hello world!");

    internal PrivateClass GetPrivate() => _private;
}

internal class PrivateClass(string someValue)
{
    internal string SomeValue { get; } = someValue;
}

我们将创建一些不安全访问器方法来检索那个棘手的SomeValue:

1
2
3
4
5
6
7
8
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPrivate")]
[return: UnsafeAccessorType("PrivateClass")] // 👈 将目标返回类型指定为字符串
static extern object GetByMethod(PublicClass instance);
//            👆 使用object作为返回类型

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_SomeValue")]
static extern string GetSomeValue([UnsafeAccessorType("PrivateClass")] object instance);
// 在属性中指定目标类型 👆 并使用object作为实例类型 👆

如上面的示例所示,您使用object代替直接引用类型,并添加一个带有“真实”类型的[UnsafeAccessorType](稍后会详细说明)。一旦我们有了上述定义,我们就可以链式调用这两个方法来检索PrivateClass实例中存储的值,即使我们无法直接引用它:

1
2
3
4
5
6
7
8
9
// 创建目标实例
var publicClass = new PublicClass();

// 调用GetPrivate(),并将结果作为object返回
object privateClass = GetByMethod(publicClass);

// 传递object并调用SomeValue的getter方法
string value = GetSomeValue(privateClass);
Console.WriteLine(value); // Hello world!

就是这样,您可以使用[UnsafeAccessor]处理无法引用的类型。

使用[UnsafeAccessorType]指定类型名称

我在上一个示例中使用的类型名称非常简单,只是PrivateClass,但这隐藏了一个事实:这实际上是一个完全限定的类型名称,就像您可能在Type.GetType(name)中使用的那样。这需要完全限定,尽管不一定需要程序集限定,但这样做通常更健壮。另请注意,对于泛型类型和方法,这些字符串必须采用开放或封闭的泛型格式,具体取决于它们的用法,例如List``1[[!0]]。同样,您需要使用+来处理嵌套类。

为了使这一切更具体,我包含了一些来自运行时[UnsafeAccessor]单元测试的示例,这些示例演示了在访问器方法中使用[UnsafeAccessorType]引用类型。请注意,所有类型都定义在一个名为PrivateLib的程序集中,并且所有类和成员都是内部的,因此无法直接引用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace PrivateLib;

internal class Class1
{
    static int StaticField = 123;
    int InstanceField = 456;

    Class1() { }

    static Class1 GetClass() => new Class1();

    private Class1[] GetArray(ref Class1 a) => new[] { a };
}

internal class GenericClass<T>
{
    List<Class1> ClosedGeneric() => new List<Class1>();

    List<U> GenericMethod<U>() => new List<U>();

    bool GenericWithConstraints<V, W>(List<T> a, List<V> b, List<W> c, List<Class1> d)
        where W : T
         => true;
}

以下是一组具有代表性的访问器示例,按复杂度递增排列。每个示例都展示了[UnsafeAccessorType]的不同用法。这也演示了不同类型的访问器,例如构造函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 1. 调用Class1的构造函数,返回的类型名称是程序集限定的
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
[return: UnsafeAccessorType("PrivateLib.Class1, PrivateLib")]
extern static object CreateClass();

// 2. 调用静态方法。返回类型和“目标”参数都是程序集限定的
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "GetClass")]
[return: UnsafeAccessorType("PrivateLib.Class1, PrivateLib")]
extern static object CallGetClass([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);

// 3. 返回静态字段的引用
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "StaticField")]
extern static ref int GetStaticField([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);

// 4. 返回实例字段的引用
// 注意,我们不能在返回类型上使用[UnsafeAccessorType]。稍后详述。
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "InstanceField")]
extern static ref int GetInstanceField([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);

// 5. 通过引用传递对象并返回数组
// 注意通过引用传递时签名中的`&`。
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetArray")]
[return: UnsafeAccessorType("PrivateLib.Class1[], PrivateLib")]
extern static object CallM_RC1(TargetClass tgt, [UnsafeAccessorType("PrivateLib.Class1&, PrivateLib")] ref object a);

// 6. 调用泛型类型上的方法,并返回封闭泛型类型
// 返回类型混合使用了完全限定(用于BCL类型)和程序集限定的类型
// 注意开放泛型定义使用!0表示未指定的类型参数
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ClosedGeneric")]
[return: UnsafeAccessorType("System.Collections.Generic.List`1[[PrivateLib.Class1, PrivateLib]]")]
extern static object CallGenericClassClosedGeneric([UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object a);

// 7. 调用泛型类型上的泛型方法。
// 这与上面类似,但我们使用!!0来表示未指定的泛型方法类型参数。
// 注意访问器方法本身必须是泛型的。
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GenericMethod")]
[return: UnsafeAccessorType("System.Collections.Generic.List`1[[!!0]]")]
extern static object CallGenericClassGenericMethod<U>([UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object a);

// 8. 调用具有类型约束的泛型类型上的泛型方法
// 这是上述情况的更复杂版本,但额外指定了与目标方法约束匹配的类型约束。
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GenericWithConstraints")]
public extern static bool CallGenericClassGenericWithConstraints<V,  W>(
    [UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object tgt,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[!0]]")]
    object a,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[!!0]]")]
    object b,
    List<W> c,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[PrivateLib.Class1, PrivateLib]]")]
    object d) where W : T;

这些示例展示了使用[UnsafeAccessorType]可以进行的各种调用,这使得这种方法非常强大。更重要的是,它很快。通常使用[UnsafeAccessor]比传统反射快得多。不幸的是,即使在.NET 10中,我们仍然有一些无法做到的事情。

[UnsafeAccessorType]的限制

如上一节所示,您可以将[UnsafeAccessorType]与[UnsafeAccessor]结合使用,以访问运行时无法引用的类型的私有成员,但仍然存在一些无法替代传统反射使用的空白。

总结如下:

  1. 如果您无法表示泛型类型参数,则无法在泛型类型上调用访问器。
  2. 您无法调用需要标记[UnsafeAccessorType]的字段。
  3. 您无法调用需要标记[UnsafeAccessorType]的返回ref的方法。

为了澄清这些情况,我将提供一些不起作用的类型和访问器的示例。

1. 无法表示类型参数

第一种情况很简单。假设我们有Generic类型,以及一个辅助类Class1:

1
2
internal class Generic<T> { }
internal class Class1 { }

我们需要创建Generic类型的实例,即使它被标记为internal,因此我们为其创建一个访问器:

1
2
3
4
5
6
static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
    [return: UnsafeAccessorType("Generic`1[[!0]]")]
    public static extern object Create();
}

当我们需要引用的T可以被引用时,这可以正常工作:

1
2
object instance = Accessors<int>.Create();
Console.WriteLine(generic.GetType()); // Generic`1[System.Int32]

但如果我们需要创建Generic的实例会发生什么?简单的答案是我们不能。我们需要调用Accessors<Class1>.Create(),但这不会编译,因为我们无法引用Class1。因此,如果我们有这种模式,则必须回退到传统反射。更重要的是,即使我们使用“传统”反射创建了Generic的实例,我们也将无法使用[UnsafeAccessor]方法与对象交互,因为我们总是需要通过定义在Accessors<Class1>上的方法来这样做,这又是不可能的,因为我们无法引用Class1。

2. 无法表示字段返回类型

我们将扩展前面的示例,添加一个新类型Class2,它有几个引用Class1类型的字段:

1
2
3
4
5
6
7
internal class Class1 { }

internal class Class2
{
    private Class1 _field1 = new();
    private readonly Class1 _field2 = new();
}

如果_field1和_field2引用已知类型,那么我们可以为它们创建[UnsafeAccessor]而不会出现问题。您可能认为像下面这样的访问器会起作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 用于创建C2实例的辅助函数
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
[return: UnsafeAccessorType("Class2")]
static extern object Create();

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field1")]
[return: UnsafeAccessorType("Class1")]
static extern ref object CallField1([UnsafeAccessorType("Class2")] object instance);

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field2")]
[return: UnsafeAccessorType("Class1")]
static extern ref readonly object CallField2([UnsafeAccessorType("Class2")] object instance);

但是,如果您尝试使用CallField1()或CallField2(),将在运行时收到错误:

1
2
3
object class2 = Create();
object field1 = CallField1(class2); // 抛出 System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute
object field2 = CallField2(class2); // 抛出 System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute

在这两种情况下,您都会收到一个System.NotSupportedException,说明Invalid usage of UnsafeAccessorTypeAttribute:您根本无法访问字段,除非您可以表示这些类型。

顺便说一下,当我第一次尝试这个功能时,这真的让我很困惑。访问字段是我尝试的第一件事,我以为是哪里做错了。😅

再重复一遍,如果您访问的字段没有使用[UnsafeAccessorType],例如如果_field1是int,那么这是可以正常工作的。只有在尝试将[UnsafeAccessorType]与字段一起使用时才会失败。

3. 无法表示ref方法返回

与之前的问题类似,如果您有一个返回ref的方法,并且您无法表示该类型,那么您就不能使用[UnsafeAccessor]。例如,让我们添加一个GetField1()方法,它返回_field1的引用:

1
2
3
4
5
6
7
internal class Class1 { }

internal class Class2
{
    private Class1 _field1 = new();
    private ref Class1 GetField1(Class2 a) => ref _field1;
}

这个访问器可能如下所示:

1
2
3
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetField1")]
[return: UnsafeAccessorType("Class1&")] // ref 返回
static extern ref object CallGetField1([UnsafeAccessorType("Class2")] object instance);

但尝试调用此访问器会在运行时失败,与尝试直接访问字段的方式相同:

1
2
object class2 = Create();
object field1 = CallGetField1(class2); // 抛出 System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute

这些是我发现的所有限制,因此只要您尝试做的事情不属于这些类别之一,那么您应该没问题!

总结

在本文中,我描述了对.NET 8中引入的[UnsafeAccessor]机制的一些改进。我展示了[UnsafeAccessor]如何让您在.NET 8和.NET 9中轻松访问私有字段和调用类型的私有成员,而无需使用反射API。然后,我描述了一些限制,即您需要能够引用您正在访问的成员所使用的类型。

接下来,我介绍了[UnsafeAccessorType]属性,并展示了如何使用它在编译时无法引用的类型上调用方法。我展示了如何使用它来调用方法、构造函数和字段,以及如何处理泛型类型和泛型方法。

最后,我描述了[UnsafeAccessorType]的限制,即您不能将其用于处理无法引用类型参数的泛型类型的实例,并且不能在字段或返回ref的方法上使用[UnsafeAccessorType]。

本系列

探索.NET 10预览版

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计