We see 2 Examples which both have dangling references: Example A:
int& getref()
{
int a;
return a;
}
Example B:
int& getref()
{
int a;
int&b = a;
return b;
}
We call them both with this same main function:
int main()
{
cout << getref() << '\n';
cout << "- reached end" << std::endl;
return 0;
}
In Example A I get a compiler warning and the expected Segfault on reading the dangling reference. In Example B I get neither the warning nor the Segfault and returns the correct value of a unexpectedly.
Why is there no warning with B?
Tested on 2 machines so far.
This is not a question about what dangling references are, its about warnings and in extent compiler behavior! This is undefined behaviour. Yes, the program could theoretically do anything, even explode the world or actually work. "This is undefined behavior" is not a satisfactory answer, as it answers only what the program is capable of doing and not why the compiler does not even detect this in Example B.
This is therefore no duplicate of this question.
The fact that the program seems to have no runtime errors reproducably in the Example B where there is also no warning could just be a coincidence or not.
I've taken the liberty of using Compiler Explorer to look at generated code
under g++ 7.5, specifically what getref()
does in assembly.
Example A:
getref():
push rbp
mov rbp, rsp
mov eax, 0
pop rbp
ret
Example B:
getref():
push rbp
mov rbp, rsp
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
Now my assembly is a bit rusty, but in Example B even more stack memory seems to be involved, which would, theoretically, create even more potential of memory being referenced dangly and therefore more detectable, as it is less likely to be subjected to optimization. I am surprised by the compiler detecting the dangling reference whilst only handling registers, but not when actual memory is involved, like in the assembly of Example B.
Maybe anyone here as any insight as to why B is harder to detect than A.
Here is the complete assembly of Example B in case it is of interest:
getref():
push rbp
mov rbp, rsp
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
.LC0:
.string "- reached end"
main:
push rbp
mov rbp, rsp
call getref()
mov eax, DWORD PTR [rax]
mov esi, eax
mov edi, OFFSET FLAT:_ZSt4cout
call std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
mov esi, 10
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char)
mov esi, OFFSET FLAT:.LC0
mov edi, OFFSET FLAT:_ZSt4cout
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
mov esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))
mov eax, 0
pop rbp
ret
__static_initialization_and_destruction_0(int, int):
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
cmp DWORD PTR [rbp-4], 1
jne .L7
cmp DWORD PTR [rbp-8], 65535
jne .L7
mov edi, OFFSET FLAT:_ZStL8__ioinit
call std::ios_base::Init::Init() [complete object constructor]
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:_ZStL8__ioinit
mov edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
call __cxa_atexit
.L7:
nop
leave
ret
_GLOBAL__sub_I_getref():
push rbp
mov rbp, rsp
mov esi, 65535
mov edi, 1
call __static_initialization_and_destruction_0(int, int)
pop rbp
ret
B ... returns the correct value of a unexpectedly.
Since the behaviour of the program is undefined, no behaviour should be unexpected.
Furthermore, there is nothing "correct" about whatever value was returned. It is simply garbage.
I am surprised by the compiler detecting the dangling reference whilst only ...
It is practically impossible for a compiler to detect all indirections through invalid references. Thus there must be some point of complexity at which the compiler will not detect it. You have found two examples on separate sides of that figurative "point". It is unclear why that is surprising to you.
Maybe anyone here as any insight as to why B is harder to detect than A.
It is more complex. The returned reference is not initialised directly from a local object but rather another reference which could in theory refer to a non-local object. It is not until the initialiser of that intermediate reference is analysed until we may find that it does indeed refer to a local object.
So, the C++ perspective is thoroughly answered by "It's UB". Perhaps you may be wondering why the produced assembly programs behave differently.
mov eax, 0
It's simply because the produced program from case A returns a memory value 0 i.e. null. Memory at address 0 of course isn't mapped as something that your process can access, so the operating system raises a SEGFAULT signal when the program attempts to read that memory.
mov rax, QWORD PTR [rbp-8]
B program on the other hand returns a pointer to the stack. Since that address was mapped to the process, there is no reason for the operating system to raise a signal.
For what it's worth, GCC does detect the bug and produces identical assembly for the different functions when optimisation is enabled.