3

This question refers to Howard Hinnant's answer to the question Guaranteed elision and chained function calls.

At the bottom of his answer, he says:

Note that in this latest design, if your client ever does this:

X&& x = a + b + c;

then x is a dangling reference (which is why std::string does not do this).

The paragraph "Lifetime of a temporary" of the article "Reference initialization" on cppreference.com lists exceptions to the lifetime rules of temporary objects bound to a reference. One being:

"a temporary bound to a reference parameter in a function call exists until the end of the full expression containing that function call: if the function returns a reference, which outlives the full expression, it becomes a dangling reference."

I think it is rather meant "if the function returns a reference to the temporary object the reference parameter is bound to", not just some other reference. As a consequence, I reckon this is the rule that explains Howard Hinnant's above-mentioned statement.

The following example is based on the example given in the question that I am referring to:

struct X
{
    int _x;
    X() : _x(0) {}
    X(int x) : _x(x) {}
    X(X const& other) : _x(other._x) {}
    X(X&& other) noexcept : _x(other._x) { other._x = 0; std::cout << "Move from " << &other << " to " << this << std::endl; }
    X& operator+=(const X& other) { _x += other._x; return *this; }

    friend X operator+(X const& lhs, X const& rhs)
    {
        std::cout << "X const& lhs: " << &lhs << std::endl;
        X temp = lhs;
        temp += rhs;
        return temp;
    }

    friend X&& operator+(X&& lhs, X const& rhs)
    {
        std::cout << "X&& lhs: " << &lhs << std::endl;
        lhs += rhs;
        return std::move(lhs);
    }
};

int anotherFunc(int a)
{
    int bigArray[3000]{};
    std::cout << "ignore:" << &bigArray << std::endl;
    int b = a * a;
    std::cout << "int b: " << &b << std::endl;
    return 2 * b;
}

int main()
{
    X a(1), b(2), c(3), d(4);
    X&& sum = a + b + c + d;
    std::cout << "X&& sum: " << &sum << std::endl;
    anotherFunc(15);
    std::cout << "sum._x: " << sum._x << std::endl;

    return 0;
}

This prints

X const& lhs: 000000907DAFF8B4
Move from 000000907DAFF794 to 000000907DAFFA14
X&& lhs: 000000907DAFFA14
X&& lhs: 000000907DAFFA14
X&& sum: 000000907DAFFA14
ignore:000000907DAFC360
int b: 000000907DAFF254
sum._x: 10

when compiled with MSVC; and similar outputs when compiled with gcc or clang.

sum should be a dangling reference here. Still, the correct value "10" is being printed. It even works when pushing a large array onto the stack between the reference initialization of sum and the access via said reference. The memory used for the temporary object that sum refers does not get reused and is always allocated elsewhere (in relation to the stack frame of the following function call), no matter how big or small the next stack frame is.

Why does every compiler that I've tested preserve the temporary object local to X&& operator+(X&& lhs, X const& rhs) even though sum should be a dangling reference according to the rule on cppreference.com. Or, to be more precise: Despite accessing a dangling reference being undefined behaviour, why does every compiler implement it that way?

Ruperrrt
  • 489
  • 2
  • 13
  • 2
    What did you expect to happen? You can't observe the *absence* of undefined behaviour, and printing "10" is one of its countless possible manifestations. – molbdnilo Aug 09 '21 at 11:35
  • I would have expected that any of these compilers would have reused the stack memory that was used for the temporary object that sum binds to. Why would they ALL preserve that temporary object if there is no rule that requires them to? – Ruperrrt Aug 09 '21 at 11:42
  • 1
    Undefined Behaviour means that the program is not subject to (logical) analysis and any attempt is a waste of time and effort. All you can do is remove the UB and re-test. See also [Old New Thing - Undefined behavior can result in time travel (among other things, but time travel is the funkiest)](https://devblogs.microsoft.com/oldnewthing/20140627-00/?p=633) – Richard Critten Aug 09 '21 at 11:57
  • 2
    Because function stack address space if usually calculated only once at entrance and usually does not overlap with inner function calls (but there can be tail recursion optimizations, etc.) . If you'd also print `&bigArray[3000]` you'd notice it does not overlap with `sum` address. You can maybe see the dangling reference value overwritten (if lucky enough and no optimizations enabled) with something like this: `X *psum; { X&& sum = a + b + c + d; psum = ∑ } { int v = 42; std::cout << "&v = " << &v << " v = " << v << std::endl; } std::cout << "sum._x: " << psum->_x << std::endl;` – dewaffled Aug 09 '21 at 12:01
  • 2
    @Ruperrrt It's completely up to the implementation whether it will or will not reuse the stack space reserved for a temporary. Also, ABI typically prescribes some stack alignment, which may cause the stack frame of `anotherFunc` to be placed below of what you expect. Here is a live demo with defined behavior: https://godbolt.org/z/v7q1vsK1h. Note that the stack storage for temporary at `rsp+15` is not reused in `g1` call after its lifetime ends. – Daniel Langr Aug 09 '21 at 12:04
  • @RichardCritten Even with UB, it may be sometimes instructive to study the observed behavior, which is usually fully defined by the implementation-generated machine code. – Daniel Langr Aug 09 '21 at 12:06
  • @DanielLangr I have considered this (look at the generated code) but with changes to optimisation level (or other flags), compiler version, compiler vendor all affecting the generated code I don't see what practical use such knowledge would be. So I usually just advise - fix it (the UB) and move on. – Richard Critten Aug 09 '21 at 12:10
  • Thanks for all the answers! So this IS in fact UB, the rule I've quoted from cppreference is applicable here and those 3 compilers just happen to implement it that way? I was in doubt if I was missing some other rule. – Ruperrrt Aug 09 '21 at 12:22

1 Answers1

1

I like to keep an example class A around for situations like this. The full definition of A is a little too lengthy to list here, but it is included in its entirety at this link.

In a nutshell, A keeps a state and a status, and the status can be one of these enums:

    destructed             = -4,
    self_move_assigned     = -3,
    move_assigned_from     = -2,
    move_constructed_from  = -1,
    constructed_specified  =  0

That is, the special members set the status accordingly. For example ~A() looks like this:

~A()
{
    assert(is_valid());
    --count;
    state_ = randomize();
    status_ = destructed;
}

And there's a streaming operator that prints this class out.

Language lawyer disclaimer: Printing out a destructed A is undefined behavior, and anything could happen. That being said, when experiments are compiled with optimizations turned off, you typically get the expected result.

For me, using clang at -O0, this:

#include "A.h"
#include <iostream>

int
main()
{
    A a{1};
    A b{2};
    A c{3};
    A&& x = a + b + c;
    std::cout << x << '\n';
}

Outputs:

destructed: -1002199219

Changing the line to:

    A x = a + b + c;

Results in:

6
Howard Hinnant
  • 206,506
  • 52
  • 449
  • 577