3

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.

  • Compiler 7.4.0 Ubuntu
  • Compiler 7.5.0 Ubuntu

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
Meph
  • 370
  • 1
  • 13
  • 1
    *"...the expected Segfault on reading the dangling reference..."* Why would you expect that? – Eljay Nov 15 '20 at 20:47
  • 1
    This is basically a QOI issue. Note that clang does [warn](https://godbolt.org/z/j1aYje). – cigien Nov 15 '20 at 20:47
  • Msvc also warns in both cases: https://godbolt.org/z/PvqKq7 So, I wouldn't say it's harder to diagnose or anything. Maybe just a compiler bug? – Lukas-T Nov 15 '20 at 20:49
  • what is your question actually? – 463035818_is_not_an_ai Nov 15 '20 at 20:53
  • @idclev463035818 It's obscured by the text, but if I understand correctly, OP is asking why gcc doesn't warn about the second version of `getref`. – cigien Nov 15 '20 at 20:54
  • @idclev463035818 Yup, now there's a question :) – cigien Nov 15 '20 at 20:57
  • Edited in a line with an actual questionmark. I could've checked with the other compilers. Basically the question still stands: Why doesn't gcc do this. But if the problem can be isolated to one brand of compiler, negligance seems plausible. Difficult to answer definitively, I'd give y'all that. – Meph Nov 15 '20 at 21:01
  • GCC [does warn](https://godbolt.org/z/PcefWe), but you need to turn on optimizations to at least `-O2`. – IlCapitano Nov 15 '20 at 21:06
  • 3
    The Standard is littered with instances of "No diagnostic required." If it's not required, all bets are off whether or not you'll get a diagnostic. – user4581301 Nov 15 '20 at 21:40
  • @IlCapitano good find! That, I think, gives an interesting insight in how, or 'when' gcc does it's diagnostics in the compilation process. – Meph Nov 15 '20 at 23:56
  • gcc _does_ warn in both cases; just the second case it only warns if at [`-O2` or higher](https://godbolt.org/z/j3GYav). Also be aware that comparing _unoptimized assembly_ is pointless. – Human-Compiler Nov 16 '20 at 01:32
  • @Human-Compiler you think? I'd expect a diagnostic tool to diagnose on what I actually wrote first. Otherwise I might not be able to relate to errors. It's a compilers decision how to optimize my code and make something equivalent out of it, I just pass the flag. Nontheless, optimized code is not what I wrote. Actually I think that this example is quite fitting in showing problems regarding diagnostic differences between optimized and unoptimized code. Or maybe you'd like to elaborate? – Meph Nov 16 '20 at 18:00
  • 1
    @Meph To be clear: I don't disagree that it's silly that GCC doesn't provide the same warnings on Debug builds as optimized. That doesn't change the fact that this is the way GCC operates. From what I recall reading, this can happen due to some of GCC's optimizations occurring at an earlier pass than semantic/code analysis -- so my guess is that at `-O2` the references are collapsed, and the analysis part catches that this is a local reference. My comment on not comparing unoptimized assembly is not directly related to this point. – Human-Compiler Nov 16 '20 at 19:30
  • 1
    Unoptimized code generation is almost always a 1-1 transcription of the code you author. As a result, comparing unoptimized assembly of two different pieces of source code generally doesn't mean much. Your example of using a function-local reference object produces different assembly because, when not optimized, GCC allocates storage space on the stack for the reference just as it does a pointer. It's generally not a useful comparison, and thus not worth trying to draw conclusions from it. That's all I meant – Human-Compiler Nov 16 '20 at 19:34
  • @Human-Compiler Ok I get you. Example A and B really do the same thing so a strong argument can be made that they should be the same, and are, when optimized. Doing diagnostics on this level does make things easier as you compare minimal examples so to speak. It's like being here on Stackoverflow, where you should post the minimal example for your peers to grasp the problem more easily. We seem to agree however that this shouldn't be the only level a diagnostic aimed at helping the developer, as opposed to the compiler, should operate. – Meph Nov 17 '20 at 17:36

1 Answers1

3

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.

eerorika
  • 232,697
  • 12
  • 197
  • 326
  • "*It is unclear why that is surprising to you.*" Not that the OP had tried it out, but the fact that gcc doesn't warn when clang and msvc do, is a *little* surprising at least. – cigien Nov 15 '20 at 21:06
  • @cigien I suppose expectations are highly subjective. I've seen enough cases where one compiler warns and another doesn't that it doesn't surpris me one bit. Perhaps encountering this example will lead programmers to expect it. – eerorika Nov 15 '20 at 21:12
  • Yeah, that's kind of my point. I agree it's unsurprising with a little experience, but the phrasing suggests that the OP should know that already. How about something like "I know this can appear surprising, but after some time you'll get used to varying QOI for UB diagnostics"? – cigien Nov 15 '20 at 21:15
  • @cigien I think that my answer should tell the reader what they should (not) expect based on how the language is. Programmers cannot always know everything, but that's not relevant to my answer. – eerorika Nov 15 '20 at 22:45
  • Yeah, I see what you mean by that sentence. It's good. – cigien Nov 15 '20 at 23:02
  • "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." Is an interesting point. Segfaults are done by the OS. The memory in actuality is still recognized by OS as actively allocated for the process. So no Segfault. I will leave the question open for some time and see if something might creep in. In fact I am not sure if I should close it. Since the rest of the question is, on a level, now answered by : "Well, gcc bad that way." – Meph Nov 15 '20 at 23:51
  • 1
    @Meph accepting an answer doesn't close the question. If you prefer a later answer, you can freely change which you accept. – eerorika Nov 16 '20 at 00:47
  • 1
    @cigien _"the fact that gcc doesn't warn when clang and msvc do, is a little surprising at least"_ it actually _does_ warn -- but you have to be at [`-O2` or higher](https://godbolt.org/z/j3GYav). IIRC this has to do with the way that GCC performs optimizations before it reaches code-analysis, so probably references get collapsed after `-O2` which allow for this to be detected. – Human-Compiler Nov 16 '20 at 01:35