Different observable order of local variable destruction and its return
Asked Answered
T

1

7

I have a program that behaves differently in GCC and Clang. After simplification the minimal reproducible example is

struct A {
    int i;
};

struct Finisher {
    A & a;
    ~Finisher() { a.i = 1; }
};

A f() {
    A a{0};
    Finisher fr{a};
    return a;
}

int main() {
    std::cout << f().i;
}

In GCC it prints 0 and in Clang the output is 1. Online demo: https://gcc.godbolt.org/z/oW4K93bM8

From the observed program behavior, it looks like Clang first performs ~Finisher(), which sets a.i=1, and only then return a. And GCC seemingly chooses the opposite order of operations: first returns a.i=0, and only then executes the destructor of fr.

Is there any undefined behavior in the program? And is it possible to modify Finisher in a way to always change the value of a just before returning it from the function (to return 1)?

Tocsin answered 2/6, 2023 at 18:26 Comment(5)
Looks to me like NRVO in action. With GCC the A that gets returned from f is initialized before fr is destroyed, so it's i is 0. Clang appears to be doing NRVO and a in the function is the same A object used in std::cout << f().i and so you see 1 as fr is destroyed before cout prints.Kingston
Minor clarification to the point that @Kingston makes: both behaviors are allowed.Stated
Interesting. Moral: don't push your luck when writing a destructor.Hieroglyphic
How do you actually do an online demo? I didn't see on option on godbolt that let's me share a link to the code.Wendall
@Eldinur: there's a "Share" menu in the top right corner :)Figure
P
5

It appears GCC does not perform named return value optimization (NRVO) for some aggregates.

If you add the option -fno-elide-constructors to clang, it will give you the same behavior (no NRVO). You can also return std::move(a) to stop NRVO. If you add constructors to A (like below), both compilers give the same result: they perform NRVO (unless you stop it). Same thing if you add a large member to A (like int arr[100];).

struct A {
    A(int i) : i(i) {}
    A(A&& o) : i(o.i) { std::cout << "A(A&&)\n"; }

    int i;
};

NRVO is a form of copy elision, which is one of the only two optimizations allowed to have observable side effects (like make a program print something different). So the program is not ill-formed (no undefined behavior), but it exhibits unspecified behavior.

I am not aware of any way to modify only Finisher to make both compilers do the same thing. An easy fix is to put fr in an inner scope, but I assume you want Finisher to work like a scope guard, which should work in the function's main scope... I believe this issue is one of the reason why scope_exit is not yet in the standard library. As you can read in the "Notes" of this page:

If the EF stored in a scope_exit object refers to a local variable of the function where it is defined, e.g., as a lambda capturing the variable by reference, and that variable is used as a return operand in that function, that variable might have already been returned when the scope_exit's destructor executes, calling the exit function. This can lead to surprising behavior.

Potentiality answered 2/6, 2023 at 19:6 Comment(4)
Out of curiosity, what is the other allowed behaviour-changing optimization?Figure
@Figure allocation elision and extensionPotentiality
That is quite amazingly obscure :DFigure
Indeed. I actually didn't know about it prior to writing this answer, I just read part of the cppreference page on copy elision (the "Notes" at the bottom). :)Potentiality

© 2022 - 2024 — McMap. All rights reserved.