为什么在这种情况下空合并运算符(??)无法工作?

15

当我运行这段代码并省略fileSystemHelper参数(因此默认为null)时,出现了意外的NullReferenceException异常:

public class GitLog
    {
    FileSystemHelper fileSystem;

    /// <summary>
    ///   Initializes a new instance of the <see cref="GitLog" /> class.
    /// </summary>
    /// <param name="pathToWorkingCopy">The path to a Git working copy.</param>
    /// <param name="fileSystemHelper">A helper class that provides file system services (optional).</param>
    /// <exception cref="ArgumentException">Thrown if the path is invalid.</exception>
    /// <exception cref="InvalidOperationException">Thrown if there is no Git repository at the specified path.</exception>
    public GitLog(string pathToWorkingCopy, FileSystemHelper fileSystemHelper = null)
        {
        this.fileSystem = fileSystemHelper ?? new FileSystemHelper();
        string fullPath = fileSystem.GetFullPath(pathToWorkingCopy); // ArgumentException if path invalid.
        if (!fileSystem.DirectoryExists(fullPath))
            throw new ArgumentException("The specified working copy directory does not exist.");
        GitWorkingCopyPath = pathToWorkingCopy;
        string git = fileSystem.PathCombine(fullPath, ".git");
        if (!fileSystem.DirectoryExists(git))
            {
            throw new InvalidOperationException(
                "There does not appear to be a Git repository at the specified location.");
            }
        }
当我在调试器中单步执行代码时,在跨过第一行(带 ?? 运算符的行)后, fileSystem 仍然具有空值,如此屏幕截图所示(跨过下一行会抛出 NullReferenceException ): When is null not null? 这不是我预期的结果!我期望空合并运算符能够发现参数为空,并创建一个new FileSystemHelper()。我已经盯着这段代码看了很长时间,但还是看不出问题出在哪里。
ReSharper指出该字段仅在此方法中使用,因此可能可以转换为局部变量……因此我尝试了一下,猜猜怎么着?它奏效了。所以,我已经解决了问题,但我却无法看出为什么上面的代码不起作用。我感觉我快要学习到一些有趣的关于C#的东西,或者我做了什么愚蠢的事情。有人能看出这里发生了什么吗?

你已经在方法参数中声明fileSystemHelpernull,我不确定,但可能与此有关。但再说一遍,这只是我的猜测。 - trinaldi
2
你确定NRE不是在GetFullPath内部发生的(忽略观察器显示的内容)?我看不出以上代码中有任何会导致这种行为的问题。 - user2864740
好的,我已经退出VisualStudio去做其他事情,然后重新加载它,现在一切都正常了,我无法再现问题。我认为这可能是ReSharper单元测试运行器中的奇怪缓存问题。我使用一个简单的MSpec测试来执行代码。当运行单元测试时,ReSharper会对程序集进行影子复制,有时候,只有有时候,影子复制似乎会“卡住”,我以前见过几次。因此,最有可能的是,我实际上正在运行旧代码,尽管我已经手动重建了所有内容。这是我能想到的最好的解释... - Tim Long
2个回答

12

我在VS2012中使用以下代码重现了它:

public void Test()
{
    TestFoo();
}

private Foo _foo;

private void TestFoo(Foo foo = null)
{
    _foo = foo ?? new Foo();
}

public class Foo
{
}

如果您在TestFoo方法的结尾处设置断点,您预期会看到_foo变量被设置,但在调试器中仍将显示为null。

但是,如果您随后对_foo进行任何操作,它就会正确显示。即使是像简单的赋值一样。

_foo = foo ?? new Foo();
var f = _foo;
如果您逐步执行它,您会发现直到被分配给f之前,_foo显示为null。

这让我想起延迟执行行为,例如LINQ,但我找不到任何可以证实这一点的信息。

这很可能只是调试器的怪癖。也许有MSIL技能的人可以阐明在幕后发生了什么。

另外有趣的是,如果用其等效的空合并运算符替换:

_foo = foo != null ? foo : new Foo();

那么它就不会表现出这种行为。

我不是一个汇编/MSIL专家,但仅仅观察这两个版本之间的反汇编输出就很有趣:

        _foo = foo ?? new Foo();
0000002d  mov         rax,qword ptr [rsp+68h] 
00000032  mov         qword ptr [rsp+28h],rax 
00000037  mov         rax,qword ptr [rsp+60h] 
0000003c  mov         qword ptr [rsp+30h],rax 
00000041  cmp         qword ptr [rsp+68h],0 
00000047  jne         0000000000000078 
00000049  lea         rcx,[FFFE23B8h] 
00000050  call        000000005F2E8220 
        var f = _foo;
00000055  mov         qword ptr [rsp+38h],rax 
0000005a  mov         rax,qword ptr [rsp+38h] 
0000005f  mov         qword ptr [rsp+40h],rax 
00000064  mov         rcx,qword ptr [rsp+40h] 
00000069  call        FFFFFFFFFFFCA000 
0000006e  mov         r11,qword ptr [rsp+40h] 
00000073  mov         qword ptr [rsp+28h],r11 
00000078  mov         rcx,qword ptr [rsp+30h] 
0000007d  add         rcx,8 
00000081  mov         rdx,qword ptr [rsp+28h] 
00000086  call        000000005F2E72A0 
0000008b  mov         rax,qword ptr [rsp+60h] 
00000090  mov         rax,qword ptr [rax+8] 
00000094  mov         qword ptr [rsp+20h],rax 

将其与内联-if版本进行比较:

        _foo = foo != null ? foo : new Foo();
0000002d  mov         rax,qword ptr [rsp+50h] 
00000032  mov         qword ptr [rsp+28h],rax 
00000037  cmp         qword ptr [rsp+58h],0 
0000003d  jne         0000000000000066 
0000003f  lea         rcx,[FFFE23B8h] 
00000046  call        000000005F2E8220 
0000004b  mov         qword ptr [rsp+30h],rax 
00000050  mov         rax,qword ptr [rsp+30h] 
00000055  mov         qword ptr [rsp+38h],rax 
0000005a  mov         rcx,qword ptr [rsp+38h] 
0000005f  call        FFFFFFFFFFFCA000 
00000064  jmp         0000000000000070 
00000066  mov         rax,qword ptr [rsp+58h] 
0000006b  mov         qword ptr [rsp+38h],rax 
00000070  nop 
00000071  mov         rcx,qword ptr [rsp+28h] 
00000076  add         rcx,8 
0000007a  mov         rdx,qword ptr [rsp+38h] 
0000007f  call        000000005F2E72A0 
        var f = _foo;
00000084  mov         rax,qword ptr [rsp+50h] 
00000089  mov         rax,qword ptr [rax+8] 
0000008d  mov         qword ptr [rsp+20h],rax 

基于这个,我确实认为发生了某种延迟执行。与第一个例子相比,第二个例子中的赋值语句非常简短。


3
这似乎是64位调试器的问题。如果使用“任何CPU”来构建和编译,则会出现此问题。将其改为x86目标,问题便不再存在。 - Jeff Mercado
3
我想我知道问题出在哪里……VS生成的64位构建代码中的行号是不正确的。在空值合并指令之后的行所生成的行号实际上“位于”空值合并指令的中间。因此调试器会在该行打断点,但是前一个指令还没有完全执行完毕。跨过那一行将完成该指令。如果您逐步通过反汇编,可以看到成员变量设置的位置。 - Jeff Mercado
这与此处的情况非常相似:http://geekswithblogs.net/twickers/archive/2011/03/31/misreporting-of-variable-values-when-debugging-x64-code-with-the.aspx 和 http://connect.microsoft.com/VisualStudio/feedback/details/655793/。但是那些问题应该已经被修复了。也许是一个回归问题? - Matt Johnson-Pint
1
我已经在Connect上打开了一个错误报告,使用以上复现源代码并链接到这个问题。如果您也能够复原这个问题,可以访问错误报告并点击“我也能复现”按钮,或者投票支持该问题。虽然这不是一个致命错误,但它会导致很多混淆和浪费时间。https://connect.microsoft.com/VisualStudio/feedback/details/805334/x64-compiler-generates-incorrect-source-lines-for-null-coalescing-operator-resulting-in-incorrect-value-display-during-debugging - Tim Long
1
“它在RyuJIT中工作”是解决此问题的可接受解决方案吗? - Kevin Frei
显示剩余2条评论

1

有人在这个问题中遇到了相同的问题。有趣的是,它也使用了this._field = expression ?? new ClassName();格式。这可能是调试器的某种问题,因为将值写出来似乎对他们产生了正确的结果。

尝试添加调试/日志代码以显示分配后字段的值,以消除附加调试器中的奇怪行为。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接