为什么在未启用优化时Visual Studio可以正确编译此函数,但在启用优化时却不能?

5

我正在进行一些类似于Y组合器的lambda包装的实验(尽管它们实际上并不严格属于Y组合器,我知道),但是遇到了一个非常奇怪的问题。在Debug配置(关闭优化)下,我的代码运行正常,但是在Release配置(启用 优化(速度为先)(/Ox))下跳过了很多(而且重要的!)部分。

请注意,lambda函数内部的内容基本上不重要,只是为了确保它可以正确递归等。

// main.cpp
#include <iostream>
#include <string>
#define uint unsigned int

// Defines a y-combinator-style thing to do recursive things. Includes a system where the lambda can declare itself to be obsolete.
// Yes, it's hacky and ugly. Don't worry about it, this is all just testing functionality.
template <class F>
class YCombinator {
public:
    F m_f; // the lambda will be stored here
    bool m_selfDestructing = false; //!< Whether the combinator will self-destruct should its lambda mark itself as no longer useful.
    bool m_selfDestructTrigger = false; //!< Whether the combinator's lambda has marked itself as no longer useful.

    // a forwarding operator:
    template <class... Args>
    decltype(auto) evaluate(Args&&... args) {
        // Avoid storing return if we can, 
        if (!m_selfDestructing) {
            // Pass itself to m_f, then the arguments.
            return m_f(*this, std::forward<Args>(args)...);
        }
        else {
            // Pass itself to m_f, then the arguments.
            auto r = m_f(*this, std::forward<Args>(args)...);
            // self-destruct if necessary, allowing lamdas to delete themselves if they know they're no longer useful.
            if (m_selfDestructTrigger) {
                delete this;
            }
            return r;
        }
    }
};
template <class F> YCombinator(F, bool sd)->YCombinator<F>;

// Tests some instances.
int main() {
    // Most basic test
    auto a = YCombinator{
        [](auto & self, uint in)->uint{
            uint out = in;
            for (uint i = 1u; i < in; ++i) {
                out += self.evaluate(i);
            }
            return out;
        },
        false
    };

    // Same as a, but checks it works as a pointer.
    auto b = new YCombinator{
        [](auto & self, uint in)->uint {
            uint out = in;
            for (uint i = 0u; i < in; ++i) {
                out += self.evaluate(i);
            }

            return out;
        },
        false
    };

    // c elided for simplicity

    // Checks the self-deletion mechanism
    auto d = new YCombinator{
        [&a, b](auto & self, uint in)->uint {
            std::cout << "Running d(" << in << ") [SD-" << self.m_selfDestructing << "]..." << std::endl;

            uint outA = a.evaluate(in);
            uint outB = b->evaluate(in);

            if (outA == outB)
                std::cout << "d(" << in << ") [SD-" << self.m_selfDestructing << "] confirmed both a and b produced the same output of " << outA << "." << std::endl;

            self.m_selfDestructTrigger = true;

            return outA;
        },
        true
    };

    uint resultA = a.evaluate(4u);
    std::cout << "Final result: a(4) = " << resultA << "." << std::endl << std::endl;

    uint resultB = (*b).evaluate(5u);
    std::cout << "Final result: b(5) = " << resultB << "." << std::endl << std::endl;

    uint resultD = d->evaluate(2u);
    std::cout << "Final result: d(2) = " << resultD << "." << std::endl << std::endl;

    resultD = d->evaluate(2u);
    std::cout << "Final result: d(2) = " << resultD << "." << std::endl << std::endl;
}

应该发生的是,第一次对进行评估正常运行,设置了并导致其被删除。然后第二次对进行评估应该崩溃,因为不再真正存在。这正是在Debug配置中发生的情况。(注意:正如@largest_prime_is_463035818在下面指出的那样,它不应该崩溃,而应该遇到未定义的行为。)
但是在Release配置中,据我所知,evaluate中的所有代码都被完全跳过,执行直接跳转到lambda表达式。显然,在优化后的代码中设置断点有些可疑,但这似乎就是正在发生的事情。我尝试重新构建项目,但是没有成功;VS似乎非常坚定。
我疯了吗?我错过了什么吗?或者这是VS(甚至编译器)中的实际错误?任何有助于确定这是否是代码问题或工具问题的帮助都将不胜感激。
注:我使用/ std:c++ latest特性集的VS2019 16.8.3版本。

7
“should crash”?你是在试图从未定义的行为中获取定义的行为吗? - 463035818_is_not_a_number
进一步扩展最大质数所说的,似乎这个问题归结为“为什么编译器会省略那些不可能被运行的代码,假设程序已经定义好了?” - Brian61354270
1
事情是 UB 可以进行时间旅行。如果你的代码是 A;B; 而且 B 没有定义,那么编译器也可以随意处理 A - 463035818_is_not_a_number
2
很抱歉,我不理解你的代码。我的评论只是基于你的“应该会崩溃,因为d不再真正存在”的错误前提,但我不确定这是否真正解释了你的观察结果。据我所知,C++标准中没有任何保证程序在任何情况下都会崩溃。 - 463035818_is_not_a_number
您可能会发现这个无关的问题有助于理解程序中看似定义良好的部分如何受到后来未定义的行为的影响。最重要的是:任何地方的未定义行为都会影响整个程序。 - Brian61354270
显示剩余2条评论
2个回答

5

未定义行为是一种非局部现象。如果您的程序遇到UB,那意味着程序的整体行为是不确定的,而不仅仅是做坏事的那个小部分。

因此,UB可能会“时光旅行”,影响在执行UB之前理论上应该正确执行的代码。也就是说,在展示UB的程序中没有“正确”或“错误”之分;要么程序是正确的,要么程序是错误的

这种影响程度取决于实现,但就标准而言,VS的行为与标准一致。


2
问题:
无论优化选项如何,您代码中的delete this都会在两种情况下被调用。
        if (m_selfDestructTrigger) {
            delete this;
        }

在你的代码中,“b”对象被删除,但是你又对其进行了“evaluate()”,导致访问冲突,因为你正在使用已经释放的堆。
在我的情况下,无论是Release还是Debug配置,都会出现访问冲突错误,但在你的情况下,由于优化的原因,访问冲突可能不会发生。
在某些情况下(例如你的情况),使用已释放的堆不会导致错误,你可能会认为程序运行正常(例如在优化或发布配置下),因为已释放的堆未被清除,仍然保留着旧对象。
这不是编译器错误,而是你删除对象的方式。
通常,对象自我删除是一种不好的风格,因为你可能会引用已删除的对象,就像在你的情况下一样。关于对象是否应该自我删除,有一个讨论。
如果您注释掉“delete”行,您的代码将在不发生访问冲突的情况下运行。如果您仍然怀疑这可能是编译器错误并且“执行直接跳转到lambda”,则可以使用更简单的方法来调试应用程序。这种更简单的方法是避免“删除”,而是从您怀疑编译器跳过的代码块中输出一些文本。
解决方案:
您可以使用另一个编译器,特别是带有sanitizer的clang,以确保您使用的Microsoft Visual Studio编译器没有错误。
例如,使用:
clang++.exe -std=c++20 -fsanitize=address calc.cpp 

运行生成的可执行文件。

在这个例子中,您的代码使用"Address Sanitizer"编译,它是一个由此编译器支持的内存错误检测器。将来使用各种卫士可能有助于您调试C/C++程序。

您将会得到以下类似的错误提示,显示您在释放堆空间后仍在使用它:

=================================================================
==48820==ERROR: AddressSanitizer: heap-use-after-free on address 0x119409fa0380 at pc 0x7ff799c91d6c bp 0x004251cff720 sp 0x004251cff768
READ of size 1 at 0x119409fa0380 thread T0
    #0 0x7ff799c91d6b in main+0xd6b (c:\calc\clang\calc.exe+0x140001d6b)
    #1 0x7ff799c917de in main+0x7de (c:\calc\clang\calc.exe+0x1400017de)
    #2 0x7ff799cf799f in __scrt_common_main_seh d:\agent\_work\63\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:288
    #3 0x7ffe3cff53fd in BaseThreadInitThunk+0x1d (C:\WINDOWS\System32\KERNEL32.DLL+0x1800153fd)
    #4 0x7ffe3ddc590a in RtlUserThreadStart+0x2a (C:\WINDOWS\SYSTEM32\ntdll.dll+0x18006590a)

0x119409fa0380 is located 16 bytes inside of 24-byte region [0x119409fa0370,0x119409fa0388)
freed by thread T0 here:
    #0 0x7ff799cf6684 in operator delete C:\src\llvm_package_6923b0a7\llvm-project\compiler-rt\lib\asan\asan_new_delete.cpp:160
    #1 0x7ff799c91ede in main+0xede (c:\calc\clang\calc.exe+0x140001ede)
    #2 0x7ff799c916e4 in main+0x6e4 (c:\calc\clang\calc.exe+0x1400016e4)
    #3 0x7ff799cf799f in __scrt_common_main_seh d:\agent\_work\63\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:288
    #4 0x7ffe3cff53fd in BaseThreadInitThunk+0x1d (C:\WINDOWS\System32\KERNEL32.DLL+0x1800153fd)
    #5 0x7ffe3ddc590a in RtlUserThreadStart+0x2a (C:\WINDOWS\SYSTEM32\ntdll.dll+0x18006590a)

证明

您还可以使用以下批处理文件来比较使用clang编译的具有和不具有优化版本的输出,以找出它们产生相同的结果:

clang++ -std=c++20 -O3 -o calc-O3.exe calc.cpp
clang++ -std=c++20 -O0 -o calc-O0.exe calc.cpp
calc-O3.exe > calc-O3.txt
calc-O0.exe > calc-O0.txt
fc calc-O3.txt calc-O0.txt

它将会给出以下内容:
Comparing files calc-O3.txt and calc-O0.txt
FC: no differences encountered

使用Microsoft Visual Studio编译器,请使用以下批处理文件:
cl.exe /std:c++latest /O2 /Fe:calc-O3.exe calc.cpp
cl.exe /std:c++latest /Od /Fe:calc-O0.exe calc.cpp
calc-O3.exe > calc-O3.txt
calc-O0.exe > calc-O0.txt
fc calc-O3.txt calc-O0.txt

它也会产生相同的结果,因此代码运行方式不受优化影响(而不是像你写的那样“完全跳过evaluate中的所有代码”)- 由于优化问题,你可能调试不正确。

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