Почему Visual Studio правильно компилирует эту функцию без оптимизации, но неправильно с оптимизацией?

Я экспериментирую с лямбда-оболочкой, похожей на y-комбинатор (хотя я знаю, что они на самом деле не являются, строго говоря, y-комбинаторами), и я столкнулся с очень странной проблемой. Мой код работает точно так, как я ожидал в конфигурации отладки (с отключенной оптимизацией), но пропускает большие (и важные!) ).

Обратите внимание, что внутренности лямбда-функций в основном не имеют значения, они нужны только для того, чтобы быть уверенными, что он может правильно выполнять рекурсию и т. д.

      // 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;
}

Что должно произойти, так это то, что первая оценка работает нормально, устанавливает , и приводит к удалению самого себя. И тогда вторая оценка должна рухнуть, потому что реально больше не существует. Именно это и происходит в конфигурации отладки. (Примечание: как указывает @largest_prime_is_463035818 ниже, он не должен так сильно падать, как сталкиваться с неопределенным поведением.)

Но в конфигурации Release, насколько я могу судить, весь код в полностью пропускается, и выполнение переходит прямо к лямбде. Очевидно, что точки останова в оптимизированном коде немного подозрительны, но, похоже, именно это и происходит. Я пытался перестроить проект, но без костей; VS кажется довольно непреклонным в этом.

Я сумасшедший? Есть что-то, что я пропустил? Или это реальная ошибка в VS (или даже компиляторе)? Буду очень признателен за любую помощь в определении того, является ли это проблемой кода или проблемой инструмента.

Примечание. Я использую VS2019 16.8.3, используя набор функций.

2 ответа

Неопределенное поведение — это нелокализованное явление. Если ваша программа сталкивается с UB, это означает, что поведение программы в целом неопределенно, а не только в той ее части, которая совершила ошибку.

Таким образом, UB может «путешествовать во времени», влияя на код, который теоретически должен был выполняться правильно до выполнения UB. То есть нет "правильно" в программе, выставляющей УБ; либо программа правильная, либо она неправильная .

Как далеко это зайдет, зависит от реализации, но что касается стандарта, поведение VS соответствует стандарту.

Проблема

The delete thisв вашем коде вызывается в обоих случаях независимо от опций оптимизации.

              if (m_selfDestructTrigger) {
            delete this;
        }

В вашем коде объект "b" удаляется, но затем вы его "оцениваете()", и это вызывает нарушение прав доступа, поскольку вы используете кучу, которая уже была освобождена.

В моем случае это дает ошибку нарушения прав доступа как в конфигурациях выпуска, так и в конфигурациях отладки, но в вашем случае нарушение прав доступа может не произойти с оптимизацией по следующей причине.

Могут быть случаи, как в вашем случае, когда при использовании освобожденной кучи ошибка не возникает и у вас создается впечатление, что программа работает нормально (как с оптимизациями, так и в конфигурации Релиза), потому что освобожденная куча не очищается и сохраняет старый объект.

Это не ошибка компилятора, а то, как вы удаляете объекты.

Как правило, это плохой стиль для объектов, чтобы удалить себя, потому что вы можете затем ссылаться на удаленный объект, как это было в вашем случае. Идет дискуссия о том, должны ли объекты удалять себя .

Если вы прокомментируете строку «удалить», ваш код будет работать без нарушения прав доступа. Вы можете использовать более простой метод для отладки приложения, если вы все еще подозреваете, что это могла быть ошибка компилятора и "выполнение переходит прямо к лямбде". Этот более простой метод заключается в том, чтобы избежать «удаления» и вместо этого вывести некоторый текст из блоков кода, которые, как вы подозреваете, пропущены компилятором.

Решение

Вы можете использовать другой компилятор, в частности, 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

Он также дает идентичные результаты, поэтому код работает одинаково независимо от оптимизации (а не «весь код в оценке полностью пропускается», как вы написали) - вы, вероятно, неправильно отладили его из-за оптимизации.

Другие вопросы по тегам