抛出 NullReferenceException 但对象通过了 null 检查,这怎么可能?

KBa*_*Bar 2 c# delegates system.reflection eventhandler .net-6.0

我正在使用该答案中的AddEventHandler方法,但是当在具有值类型参数的EventHandler上执行此操作时,会发生:

using System.Reflection;

public class Program
{
    public static event EventHandler<bool> MyEvent;

    public static void Main()
    {
        EventInfo eventInfo = typeof(Program).GetEvent(nameof(MyEvent));
        AddEventHandler(eventInfo, null, (s, e) => {
            if (e == null) return; // either if condition or null conditional operator
            Console.WriteLine(e?.ToString());
        });
        MyEvent(null, true);
    }

    public static void AddEventHandler(EventInfo eventInfo, object client, EventHandler handler)
    {
        object eventInfoHandler = eventInfo.EventHandlerType
            .GetConstructor(new[] { typeof(object), typeof(IntPtr) })
            .Invoke(new[] { handler.Target, handler.Method.MethodHandle.GetFunctionPointer() });

        eventInfo.AddEventHandler(client, (Delegate)eventInfoHandler);
    }
}
Run Code Online (Sandbox Code Playgroud)

在此输入图像描述

有什么解释吗?

Evk*_*Evk 7

您正在使用未记录的内部 api,更糟糕的是该 api 接受原始指针。因此,如果您滥用此类 api(并且您永远无法确定您是否正确使用它,因为它没有记录),那么如果出现(可怕的)错误,这并不奇怪。

请注意,AddEventHandler第三个参数是EventHandler,它是这种类型的委托:

delegate void EventHandler(object sender, EventArgs e);
Run Code Online (Sandbox Code Playgroud)

您的MyEvent委托类型是:

delegate void EventHandler(object sender, int e);
Run Code Online (Sandbox Code Playgroud)

AddEventHandler使用内部未记录的编译器生成的委托构造函数,该构造函数接受两个参数:委托目标和指向方法的原始指针。然后,它只是将原始指针传递给该构造函数的委托方法,handler而不进行任何检查。您传递的委托和正在创建的委托可能完全不兼容,但您不会注意到,直到运行时才会尝试调用它。

在这种情况下,运行时认为它有指向 method 的委托void (object, bool),但实际上它指向 method void (object, EventArgs)。您通过调用它MyEvent(null, true),运行时将true布尔值作为第二个参数传递(它是值类型,因此直接传递值),但您的委托实际上指向期望的方法EventArgs,这是一个引用类型。所以它需要一个类型为某个对象的地址EventArgs。它获取布尔值,就好像它是指向 的指针一样EventArgs

现在,== null一般情况下基本上只是检查引用是否为零(所有字节均为 0)。True布尔值不由 0 表示,因此 null 检查通过。

然后,它尝试访问位于该“地址”的对象。它无法工作,您访问受保护的内存并收到访问冲突错误。然而,正如这个答案中所解释的:

但如果 (A) 访问冲突发生在低于 0x00010000 的地址处,并且 (B) 发现这种冲突是由 jitted 代码发生的,则它会变成 NullReferenceException,否则它会变成 AccessViolationException

所以它变成了NullReferenceException你观察。

有趣的是,如果您像这样更改代码:

MyEvent(null, false);
Run Code Online (Sandbox Code Playgroud)

然后它将运行而不会出现错误,因为false它是由零字节表示的,因此e == null检查将返回true。

您可以更多地使用此代码,例如将事件类型更改为 int:

public static event EventHandler<int> MyEvent; 
Run Code Online (Sandbox Code Playgroud)

然后执行以下操作:

MyEvent(null, 0x00010001);
Run Code Online (Sandbox Code Playgroud)

现在它将抛出AccessViolationException而不是NullReferenceException链接答案声明的那样(现在我们正在尝试访问高于 0x00010000 的位置的内存,因此运行时不会将此访问冲突转换为空引用异常)。

这是另一个有趣的事情,我们使用此答案中的代码在运行时获取 .NET 对象的内存地址,然后将该地址传递给处理程序:

public static event EventHandler<IntPtr> MyEvent;

public static unsafe void Main() {
    // it's not even EventArgs, it's string
    var fakeArgument = "Hello world!";
    // some black magic to get address
    var typedRef = __makeref(fakeArgument);
    IntPtr ptr = **(IntPtr**)(&typedRef);
    EventInfo eventInfo = typeof(Program).GetEvent(nameof(MyEvent));
    AddEventHandler(eventInfo, null, (object s, EventArgs e) => {
        if (e == null) return;
        // e is actually a string here, not EventArgs...
        Console.WriteLine(e?.ToString());
    });
    MyEvent(null,  ptr);
}
Run Code Online (Sandbox Code Playgroud)

出于上述原因,此代码输出“Hello world!”。

长话短说 - 不要在实际代码中使用这种危险的未记录的内部 api。