A production bug that made me care about undefined behavior

(gaultier.github.io)

161 points | by birdculture 4 days ago ago

110 comments

  • nneonneo 4 days ago ago

    Even calling uninitialized data “garbage” is misleading. You might expect that the compiler would just leave out some initialization code and compile the remaining code in the expected way, causing the values to be “whatever was in memory previously”. But no - the compiler can (and absolutely will) optimize by assuming the values are whatever would be most convenient for optimization reasons, even if it would be vanishingly unlikely or even impossible.

    As an example, consider this code (godbolt: https://godbolt.org/z/TrMrYTKG9):

        struct foo {
            unsigned char a, b;
        };
    
        foo make(int x) {
            foo result;
            if (x) {
                result.a = 13;
            } else {
                result.b = 37;
            }
            return result;
        }
    
    At high enough optimization levels, the function compiles to “mov eax, 9485; ret”, which sets both a=13 and b=37 without testing the condition at all - as if both branches of the test were executed. This is perfectly reasonable because the lack of initialization means the values could already have been set that way (even if unlikely), so the compiler just goes ahead and sets them that way. It’s faster!
    • titzer 4 days ago ago

      Indeed, UB is literally whatever the compiler feels like. A famous one [1] has the compiler deleting code that contains UB and falling through to the next function.

      "But it's right there in the name!" Undefined behavior literally places no restrictions on the code generated or the behavior of the program. And the compiler is under no obligation to help you debug your (admittedly buggy) program. It can literally delete your program and replace it with something else that it likes.

      [1] https://kristerw.blogspot.com/2017/09/why-undefined-behavior...

    • jmgao 4 days ago ago

      There are some even funnier cases like this one: https://gcc.godbolt.org/z/cbscGf8ss

      The compiler sees that foo can only be assigned in one place (that isn't called locally, but could called from other object files linked into the program) and its address never escapes. Since dereferencing a null pointer is UB, it can legally assume that `*foo` is always 42 and optimizes out the variable entirely.

      • publicdebates 4 days ago ago

        To those who are just as confused as me:

        Compilers can do whatever they want when they see UB, and accessing an unassigned and unassiganble (file-local) variable is UB, therefore the compiler can just decide that *foo is in fact always 42, or never 42, or sometimes 42, and all would be just as valid options for the compiler.

        (I know I'm just restating the parent comment, but I had to think it through several times before understanding it myself, even after reading that.)

        • jmgao 4 days ago ago

          > Compilers can do whatever they want when they see UB, and accessing an unassigned and unassiganble (file-local) variable is UB, therefore the compiler can just decide that *foo is in fact always 42, or never 42, or sometimes 42, and all would be just as valid options for the compiler.

          That's not exactly correct. It's not that the compiler sees that there's UB and decides to do something arbitrary: it's that it sees that there's exactly one way for UB to not be triggered and so it's assuming that that's happening.

        • masklinn 4 days ago ago

          Although it should be noted that that’s not how compilers “reason”.

          The way they work things out is to assume no UB happens (because otherwise your program is invalid and you would not request compiling an invalid program would you) then work from there.

          • actionfromafar 4 days ago ago

            No who would write an incorrect program! :-d

    • recursivecaveat 4 days ago ago

      Even the notion that uninitialized memory contain values is kind of dangerous. Once you access them you can't reason about what's going to happen at all. Behaviour can happen that's not self-consistent with any value at all: https://godbolt.org/z/adsP4sxMT

      • masklinn 4 days ago ago

        Is that an old 'bot? because I noticed it was an old version of Clang, and I tried switching to the latest Clang which is hilarious: https://godbolt.org/z/fra6fWexM

        • nneonneo 4 days ago ago

          Oh yeah the classic Clang behaviour of “just stop codegen at UB”. If you look at the assembly, the main function just ends after the call to endl (right before where the if test should go); the program will run off the end of main and execute whatever nonsense is after it in memory as instructions. In this case I guess it calls main again (??) and then runs off into the woods and crashes.

          I’ve never understood this behaviour from clang. At least stick a trap at the end so the program aborts instead of just executing random instructions?

          The x and y values are funny too, because clang doesn’t even bother loading anything into esi for operator<<(unsigned int), so you get whatever the previous call left behind in that register. This means there’s no x or y variable at all, even though they’re nominally being “printed out”.

        • recursivecaveat 4 days ago ago

          No I wrote it with the default choice of compiler just now. That newer result is truly crazy though lol.

        • qbane 4 days ago ago

          icc's result is interesting too

        • afiori 4 days ago ago

          This is gold

    • userbinator 4 days ago ago

      If you don't initialise a variable, you're implicitly saying any value is fine, so this actually makes sense.

      • pornel 4 days ago ago

        The difference is that it can behave as if it had multiple different values at the same time. You don't just get any value, you can get completely absurd paradoxical Schrödinger values where `x > 5 && x < 5` may be true, and on the next line `x > 5` may be false, and it may flip on Wednesdays.

        This is because the code is executed symbolically during optimization. It's not running on your real CPU. It's first "run" on a simulation of an abstract machine from the C spec, which doesn't have registers or even real stack to hold an actual garbage value, but it does have magic memory where bits can be set to 0, 1, or this-can-never-ever-happen.

        Optimization passes ask questions like "is x unused? (so I can skip saving its register)" or "is x always equal to y? (so I can stop storing it separately)" or "is this condition using x always true? (so that I can remove the else branch)". When using the value is an undefined behavior, there's no requirement for these answers to be consistent or even correct, so the optimizer rolls with whatever seems cheapest/easiest.

        • actionfromafar 4 days ago ago

          "Your scientists were so preoccupied with whether they could, they didn't stop to think if they should."

          With Optimizing settings on, the compiler should immediately treat unused variables as errors by default.

          • tialaramex 4 days ago ago

            So here are your options:

            1. Syntactically require initialization, ie you can't write "int k;" only "int k = 0;". This is easy to do and 100% effective, but for many algorithms this has a notable performance cost to comply.

            2. Semantically require initialization, the compiler must prove at least one write happens before every read. Rice's Theorem says we cannot have this unless we're willing to accept that some correct programs don't compile because the compiler couldn't see why they're correct. Safe Rust lives here. Fewer but still some programmers will hate this too because you're still losing perf in some cases to shut up the prover.

            3. Redefine "immediately" as "Well, it should report the error at runtime". This has an even larger performance overhead in many cases, and of course in some applications there is no meaningful "report the error at runtime".

            Now, it so happens I think option (2) is almost always the right choice, but then I would say that. If you need performance then sometimes none of those options is enough, which is why unsafe Rust is allowed to call core::mem::MaybeUninit::assume_init an unsafe function which in many cases compiles to no instructions at all, but is the specific moment when you're taking responsibility for claiming this is initialized and if you're wrong about that too fucking bad.

            • rcxdude 4 days ago ago

              With optimizations, 1. and 2. can be kind of equivalent: if initialization is syntactically required (or variables are defined to be zero by default), then the compiler can elide this if it can prove that value is never read.

              • masklinn 4 days ago ago

                That, however, conflicts with unused write detection which can be quite useful (arguably more so than unused variable as it's both more general and more likely to catch issues). Though I guess you could always ignore a trivial initialisation for that purpose.

            • UncleMeat 4 days ago ago

              There isn't just a performance cost to initializing at declaration all the time. If you don't have a meaningful sentinel value (does zero mean "uninitialized" or does it mean logical zero?) then reading from the "initialized with meaningless data just to silence the lint" data is still a bug. And this bug is now somewhat tricky to detect because the sanitizers can't detect it.

              • tialaramex 3 days ago ago

                Yes, that's an important consideration for languages like Rust or C++ which don't endorse mandatory defaults. It may even literally be impossible to "initialize with meaningless data" in these languages if the type doesn't have such "meaningless" values.

                In languages like Go or Odin where "zero is default" for every type and you can't even opt out, this same problem (which I'd say is a bigger but less instantly fatal version of the Billion Dollar Mistake) occurs everywhere, at every API edge, and even in documentation, you just have to suck it up.

                Which reminds of in a sense another option - you can have the syntactic behaviour but write it as though you don't initialize at all even though you do, which is the behaviour C++ silently has for user defined types. If we define a Goose type (in C++ a "class"), which we stubbornly don't provide any way for our users to make themselves (e.g. we make the constructors private, or we explicitly delete the constructors), and then a user writes "Goose foo;" in their C++ program it won't compile because the compiler isn't allowed to leave this foo variable uninitialized - but it also can't just construct it, so, too bad, this isn't a valid C++ program.

          • UncleMeat 4 days ago ago

            If you have a program that will unconditionally access uninitialized memory then the compiler can halt and emit a diagnostic. But that's rarely what is discussed in these UB conversations. Instead the compiler is encountering a program with multiple paths, some of which would encounter UB if taken. But the compiler cannot just refuse to compile this, since it is perfectly possible that the path is dead. Like, imagine this program:

                int foo(bool x, int* y) {
                  if (x) return *y;
                  return 0;
                } 
            
            Dereferencing y would be UB. But maybe this function is called only with x=false when y is nullptr. This cannot be a compile error. So instead the compiler recognizes that certain program paths are illegal and uses that information during compilation.
            • actionfromafar 3 days ago ago

              Maybe we should make that an error.

              • UncleMeat 3 days ago ago

                More modern languages have indeed embedded nullability into the type system and will yell at you if you dereference a nullable pointer without a check. This is good.

                Retrofitting this into C++ at the language level is impossible. At least without a huge change in priorities from the committee.

                • actionfromafar 3 days ago ago

                  Maybe not the Standard, but maybe not impossible to retrofit into:

                      -Werror -Wlet-me-stop-you-right-there
          • pornel 4 days ago ago

            That's what Golang went for. There are order possibilities: D has `= void` initializer to explicitly leave variables uninitialized. Rust requires values to be initialized before use, and if the compiler can't prove they are, it's either an error or requires an explicit MaybeUninit type wrapper.

          • 4 days ago ago
            [deleted]
      • like_any_other 4 days ago ago

        For some values of 'sense'.

    • sethev 4 days ago ago

      That seems like a reasonable optimization, actually. If the programmer doesn’t initialize a variable, why not set it to a value that always works?

      Good example of why uninitialized variables are not intuitive.

    • masklinn 4 days ago ago

      Things can get even wonkier if the compiler keeps the values in registers, as two consecutive loads could use different registers based as you say on what's the most convenient for optimisation (register allocation, code density).

    • quietbritishjim 4 days ago ago

      If I understand it right, in principle the compiler doesn't even need to do that.

      It can just leave the result totally uninitialised. That's because both code paths have undefined behaviour: whichever of result.x or result.y is not set is still copied at "return result" which is undefined behaviour, so the overall function has undefined behaviour either way.

      It could even just replace the function body with abort(), or omit the implementation entirely (even the ret instruction, allowing execution to just fall through to whatever memory happens to follow). Whether any computer does that in practice is another matter.

      • masklinn 4 days ago ago

        > It can just leave the result totally uninitialised. That's because both code paths have undefined behaviour: whichever of result.x or result.y is not set is still copied at "return result" which is undefined behaviour, so the overall function has undefined behaviour either way.

        That is incorrect, per the resolution of DR222 (partially initialized structures) at WG14:

        > This DR asks the question of whether or not struct assignment is well defined when the source of the assignment is a struct, some of whose members have not been given a value. There was consensus that this should be well defined because of common usage, including the standard-specified structure struct tm.

        As long as the caller doesn't read an uninitialised member, it's completely fine.

        • tialaramex 4 days ago ago

          Ooh, thanks for mentioning DR222 that's very interesting.

    • 4 days ago ago
      [deleted]
    • arrowsmith 4 days ago ago

      How is this an "optimization" if the compiled result is incorrect? Why would you design a compiler that can produce errors?

      • Negitivefrags 4 days ago ago

        It’s not incorrect.

        The code says that if x is true then a=13 and if it is false than b=37.

        This is the case. Its just that a=13 even if x is false. A thing that the code had nothing to say about, and so the compiler is free to do.

        • foltik 4 days ago ago

          Ok, so you’re saying it’s “technically correct?”

          Practically speaking, I’d argue that a compiler assuming uninitialized stack or heap memory is always equal to some arbitrary convenient constant is obviously incorrect, actively harmful, and benefits no one.

          • publicdebates 4 days ago ago

            In this example, the human author clearly intended mutual exclusivity in the condition branches, and this optimization would in fact destroy that assumption. That said, (a) human intentions are not evidence of foolproof programming logic, and often miscalculate state, and (b) the author could possibly catch most or all errors here when compiling without optimizations during debugging phase.

            • foltik 4 days ago ago

              Regardless of intention, the code says this memory is uninitialized.

              I take issue with the compiler assuming anything about the contents of that memory; it should be a black box.

              • masklinn 4 days ago ago

                The compiler is the arbiter of what’s what (as long as it does not run afoul the CPU itself).

                The memory being uninitialised means reading it is illegal for the writer of the program. The compiler can write to it if that suits it, the program can’t see the difference without UB.

                In fact the compiler can also read from it, because it knows that it has in fact initialised that memory. And the compiler is not writing a C program and is thus not bound by the strictures of the C abstract machine anyway.

                • foltik 4 days ago ago

                  Yes yes, the spec says compilers are free to do whatever they want. That doesn’t mean they should.

                  > The user didn’t initialize this integer. Let’s assume it’s always 4 since that helps us optimize this division over here into a shift…

                  This is convenient for who exactly? Why not just treat it as a black box memory load and not do further “optimizations”?

                  • masklinn 4 days ago ago

                    > That doesn’t mean they should.

                    Nobody’s stopping you from using non-optimising compilers, regardless of the strawmen you assert.

                    • foltik 4 days ago ago

                      As if treating uninitialized reads as opaque somehow precludes all optimizations?

                      There’s a million more sensible things that the compiler could do here besides the hilariously bad codegen you see in the grandparent and sibling comments.

                      All I’ve heard amounts to “but it’s allowed by the spec.” I’m not arguing against that. I’m saying a spec that incentivizes this nonsense is poorly designed.

                      • Negitivefrags 4 days ago ago

                        Why is the code gen bad? What result are you wanting? You specifically want whatever value happened to be on the stack as opposed to a value the compiler picked?

                      • masklinn 4 days ago ago

                        > As if treating uninitialized reads as opaque somehow precludes all optimizations?

                        That's not what these words mean.

                        > There’s a million more sensible things

                        Again, if you don't like compilers leveraging UBs use a non-optimizing compiler.

                        > All I’ve heard amounts to “but it’s allowed by the spec.” I’m not arguing against that.

                        You literally are though. Your statements so far have all been variations of or nonsensical assertions around "why can't I read from uninitialised memory when the spec says I can't do that".

                        > I’m saying a spec that incentivizes this nonsense is poorly designed.

                        Then... don't use languages that are specified that way? It's really not that hard.

                        • foltik 3 days ago ago

                          From the LLVM docs [0]:

                          > Undef values aren't exactly constants ... they can appear to have different bit patterns at each use.

                          My claim is simple and narrow: compilers should internally model such values as unspecified, not actively choose convenient constants.

                          The comment I replied to cited an example where an undef is constant folded into the value required for a conditional to be true. Can you point to any case where that produces a real optimization benefit, as opposed to being a degenerate interaction between UB and value propagation passes?

                          And to be explicit: “if you don’t like it, don’t use it” is just refusing to engage, not a constructive response to this critique. These semantics aren't set in stone.

                          [0] https://llvm.org/doxygen/classllvm_1_1UndefValue.html#detail...

                          • masklinn 3 days ago ago

                            > My claim is simple and narrow: compilers should internally model such values as unspecified, not actively choose convenient constants.

                            An assertion you have provided no utility or justification for.

                            > The comment I replied to cited an example where an undef is constant folded into the value required for a conditional to be true.

                            The comment you replied to did in fact not do that and it’s incredible that you misread it such.

                            > Can you point to any case where that produces a real optimization benefit, as opposed to being a degenerate interaction between UB and value propagation passes?

                            The original snippet literally folds a branch and two stores into a single store, saving CPU resources and generating tighter code.

                            > this critique

                            Critique is not what you have engaged in at any point.

                            • foltik 3 days ago ago

                              Sorry, my earlier comments were somewhat vague and assuming we were on the same page about a few things. Let me be concrete.

                              The snippet is, after lowering:

                                if (x)
                                  return { a = 13, b = undef }
                                else
                                  return { a = undef, b = 37 }
                              
                              LLVM represents this as a phi node of two aggregates:

                                a = phi [13, then], [undef, else]
                                b = phi [undef, then], [37, else]
                              
                              Since undef isn’t “unknown”, it’s “pick any value you like, per use”, InstCombine is allowed to instantiate each undef to whatever makes the expression simplest. This is the problem.

                                a = 13
                                b = 37
                              
                              The branch is eliminated, but only because LLVM assumes that those undefs will take specific arbitrary values chosen for convenience (fewer instructions).

                              Yes, the spec permits this. But at that point the program has already violated the language contract by executing undefined behavior. The read is accidental by definition: the program makes no claim about the value. Treating that absence of meaning as permission to invent specific values is a semantic choice, and precisely what I am criticizing. This “optimization” is not a win unless you willfully ignore the program and everything but instruction count.

                              As for utility and justification: it’s all about user experience. A good language and compiler should preserve a clear mental model between what the programmer wrote and what runs. Silent non-local behavior changes (such as the one in the article) destroy that. Bugs should fail loudly and early, not be “optimized” away.

                              Imagine if the spec treated type mismatches the same way. Oops, assigned a float to an int, now it’s undef. Let’s just assume it’s always 42 since that lets us eliminate a branch. That’s obviously absurd, and this is the same category of mistake.

              • publicdebates 13 hours ago ago

                It's the same as this:

                    int random() {
                        return 4; // chosen by dice roll
                    }
                
                Technically correct. But not really.
          • 1718627440 4 days ago ago

            Also even without UB, even for a naive translation, a could just happen to be 13 by chance, so the behaviour isn't even an example of nasal demons.

      • throwatdem12311 4 days ago ago

        Because a could be 13 even if x is false because initialisation of the struct doesn’t have defined behavior of what the initial values of a and b need to be.

        Same for b. If x is true, b could be 37 no matter how unlikely that is.

      • xboxnolifes 4 days ago ago

        It is not incorrect. The values are undefined, so the compiler is free to do whatever it want to do with them, even assign values to them.

      • tehjoker 4 days ago ago

        It's not incorrect. Where is the flaw?

  • panstromek 4 days ago ago

    I have bumped into this myself, too. It's really annoying. The biggest footgun isn't even discussed explicitly and it might be how the error got introduced - it's when the struct goes from POD to non-POD or vice-versa, the rules change, so completely innocent change, like adding a string field, can suddenly create undefined behaviour in unrelated code that was correct previously.

    • feelamee 4 days ago ago

      wow, can you elaborate how adding a string field can break some assumptions?

      • nneonneo 4 days ago ago

        Not the OP, but note that adding a std::string to a POD type makes it non-POD. If you were doing something like using malloc() to make the struct (not recommended in C++!), then suddenly your std::string is uninitialized, and touching that object will be instant UB. Uninitialized primitives are benign unless read, but uninitialized objects are extremely dangerous.

        • IshKebab 4 days ago ago

          That's not what was happening in this example though. It would be UB even if it was a POD.

  • fizzynut 4 days ago ago

    Even if you fixed the initialized data problem, this code is still a bug waiting to happen. It should be a single bool in the struct to handle the state for the function as there are only two states that actually make sense.

    succeeded = true; error = true; //This makes no sense

    succeeded = false; error = false; //This makes no sense

    Otherwise if I'm checking a response, I am generally going to check just "succeeded" or "error" and miss one of the two above states that "shouldn't happen", or if I check both it's both a lot of awkward extra code and I'm left with trying to output an error for a state that again makes no sense.

    • deepsun 4 days ago ago

      It happens often when "error" field is not a bool, but a string, aka error_message. Could be empty string, or _null_, or even _undefined_ if we're in JS.

      Then the obvious question why do we need _succeeded_ at all, if we can always check for _error_. Sometimes it can be useful, when the server doesn't know itself if the operation is succeeded (e.g. an IO/database operation timed out), so it might be succeeded, but should also show an error message to user.

      Another possibility if the succeeded is not a bool, but, say, "succeeded_at" timestamp. In general, I noticed that almost always any boolean value in database can be replaced with a timestamp or an error code.

  • MutableLambda 4 days ago ago

    Yeah, looks pretty straightforward to me, but I used to write C++ for a living. I mean, there are complicated cases in C++ starting with C++11, this one is not really one of them. Just init the fields to false. Most of these cases is just C++ trying to bring in new features without breaking legacy code, it has become pretty difficult to keep up with it all.

  • pornel 4 days ago ago

    To me the real horror is that the exact same syntax can be either a perfectly normal thing to do, or a horrible mistake that gives the compiler a license to kill, and this doesn't depend on something locally explicit, but on details of a definition that lives somewhere else and may have multiple layers of indirection.

  • mac3n 4 days ago ago

    Many years had a customer complaint about undefined data changing value in Fortran 77. It turned out that the compiler never allocated storage for uninitialized variables, so it was aliased to something else.

    Compiler was changed to allocate storage for any referenced varibles.

  • vhantz 4 days ago ago

    The two fields in the struct are expected to be false unless changed, then initialize them as such. Nothing is gained by leaving it to the compiler, and a lot is lost.

    • gwd 4 days ago ago

      I think the point is that sometimes variables are defined by the language spec as initialized to zero, and sometimes they aren't.

      Perhaps what you mean is, "Nothing is to be gained by relying on the language spec to initialize things to zero, and a lot is lost"; I'd agree with that.

      • vhantz 4 days ago ago

        Please don't be pedantic. Compilers implement the standard, otherwise it's just a text document.

        • gwd 4 days ago ago

          Not trying to be pedantic. When I hear "leave it to the compiler", I normally think, "let the compiler optimize it, rather than optimizing it yourself". The compiler is doing the initialization either way, but in one case you're relying on a correct understanding of minutiae of the language spec (both for you and all future readers and writers of the code), in another case you're explicitly instructing the compiler to initialize it to zero.

          • vhantz 4 days ago ago

            Yes and I'm saying that in this case the correct and practical choice is to be explicit. No one needs to go read the standard to know that two fields defaulted to false in the strict definition are defaulted to false...

        • hn_go_brrrrr 4 days ago ago

          Compilers implement the parts of the standard they agree with, in the way they think is best. They also implement it in the way they understand the standardese.

          Read a complex enough project that's meant to be used across compiler venrdos and versions, and you'll find plenty of instances where they're working around the compiler not implementing the standard.

          Also, if you attended the standards committee, you would hear plenty of complaints from compiler vendors that certain things are implementable. Sometimes the committee listens and makes changes, other times they put their fingers in their ears and ignore reality.

          There are also plenty of places where the standard lets the compiler make it's own decision (implementation defined behavior). You need to know what your compiler vendor(s) chose to do.

          tl;dr: With a standard as complex as C++'s, the compilers very much do not just "implement the standard". Sometimes you can get away with pretending that, but others very much not.

          • vhantz 4 days ago ago

            Who said compilers "just" implement the standard?

            The standard (to the extent that it is implemented) is implemented by compilers. At this point this whole thread has nothing to do with my original point, just weird one-upping all around

  • letmetweakit 4 days ago ago

    I once reported several UB bugs to a HackerOne-led cryptocurrency bounty program. They were rejected because the software was working as intended and that they would "inspect the assembly every time they compiled". Yeah right.

  • canucker2016 3 days ago ago

    But there's nothing in your code that suggests that there's a problem if the error and success fields are both true.

    Typically you'd have at least an assert (and hopefully some unit tests) to ensure that invariant (.success ^ .error == true).

    But the code has just been getting by on the good graces of the previous stack contents. One random day, the app behaviour changed and left a non-zero byte that the response struct picked up and left the app in the alternate reality where .success == .error

    Others have mentioned sanitizers that may expose the problem.

    Microsoft's Visual C++ compiler has the RTCs/RTC1 compiler switch which fills the stack frame with a non-zero value (0xCC). Using that compiler switch would have exposed the problem.

    You could also create a custom __chkstk stack probe function and have GCC/Clang use this to fill the stack as well as probing the stack. I did this years ago when there was no RTCs/RTC1 compiler option available in VC++.

  • yongjik 4 days ago ago

    I think UB doesn't have much to do with this bug after all.

    The original code defined a struct with two bools that were not initialized. Therefore, when you instantiate one, the initial values of the two bools could be anything. In particular, they could be both true.

    This is a bit like defining a local int and getting surprised that its initial value is not always zero. (Even if the compiler did nothing funny with UB, its initial value could be anything.)

    • masklinn 4 days ago ago

      The entire "its initial value could be anything" is one of the possible consequences of UB. It is not the most dire but in C and C++ it is an outcome from an UB.

      Could a language define un-initialized variables as readable garbage? Sure, but that would be a different language with different semantics, and such languages can also define declaration such that

      > defining a local int and getting surprised that its initial value is not always zero.

      is in fact reasonable. That is what Java and Go opted to do, for instance.

    • baobun 4 days ago ago

      > The original code defined a struct with two bools that were not initialized. Therefore, when you instantiate one, the initial values of the two bools could be anything. In particular, they could be both true.

      Then reading from that struct like in OP constitutes UB.

      • yongjik 4 days ago ago

        Well yes, that would be UB, but even if the C++ compiler had no concept of UB, it would still be wrong code.

  • AdieuToLogic 4 days ago ago

    There are a few problems with this post:

      1 - In C++, a struct is no different than a class
          other than a default scope of public instead of
          private.
      2 - The use of braces for property initialization
          in a constructor is malformed C++.
      3 - C++ is not C, as the author eventually concedes:
    
      At this point, my C developer spider senses are tingling: 
      is Response response; the culprit? It has to be, right? In 
      C, that's clear undefined behavior to read fields from 
      response: The C struct is not initialized.
    
    In short, if the author employed C++ instead of trying to use C techniques, all they would have needed is a zero cost constructor definition such as:

      inline Response () : error (false), succeeded (false)
      {
        ;
      }
    • jhasse 3 days ago ago

      inline and ; are redundant

      • AdieuToLogic 2 days ago ago

        > inline and ; are redundant

        One of my s/w engineering axioms is:

          Better to express intent than assume a future
          reader of a solution, including myself, will
          intrinsically understand the decisions made.
        
        If this costs a few extra keystrokes when authoring an implementation, so be it.
  • inglor_cz 4 days ago ago

    Symbian's way of avoiding this was to use a class called CBase to derive from. CBase would memset the entire allocated memory for the object to binary zeros, thus zeroizing any member variable.

    And by convention, all classes derived from CBase would start their name with C, so something like CHash or CRectangle.

    • jenadine 4 days ago ago

      I'm afraid that's still not defined behaviour in many case. For example, pointer and bool can be initialized with `=0`, but that doesn't mean the binary representation in memory has to be 0, and so initializing with memset would still be wrong. (Even if it works with all compilers I know of.)

      Also, how does CBase knows the size of its allocated memory?

      • ksherlock 4 days ago ago

        The symbian source code is available. Looks like it uses a class-specific operator new() overload.

        https://github.com/SymbianSource/oss.FCL.sf.os.kernelhwsrv/b...

        2. Initialisation of the CBase derived object to binary zeroes through a specific CBase::operator new() - this means that members, whose initial value should be zero, do not have to be initialised in the constructor. This allows safe destruction of a partially-constructed object.

  • kayo_20211030 4 days ago ago

    Great post. It was both funny and humble. Of course, it probably wasn't at all funny at the time.

  • Panzerschrek 4 days ago ago

    That's why I always specify default initializers for fields of fundamental types and other types which don't have default constructor.

  • titzer 4 days ago ago

    tldr; the UB was reading uninitialized data in a struct. The C++ rules for when default initialization occurs are crazy complex.

    I think a sanitizer probably would have caught this, but IMHO this is the language's fault.

    Hopefully future versions of C++ will mandate default initialization for all cases that are UB today and we can be free of this class of bug.

    • torstenvl 4 days ago ago

      Yeah... but I wouldn't characterize the bug itself (in its essential form) as UB.

      Even if the implementation specified that the data would be indeterminate depending on what existed in that memory location previously, the bug would still exist.

      Even if you hand-coded this in assembly, the bug would still exist.

      The essence of the bug is uninitialized data being garbage. That's always gonna be a latent bug, regardless of whether the behavior is defined in an ISO standard.

      • forrestthewoods 4 days ago ago

        Yeah I agree. This is a classic “uninitialized variable has garbage memory value” bug. But it is not a “undefined nasal demons behavior” bug.

        That said, we all learn this one! I spent like two weeks debugging a super rare desync bug in a multiplayer game with a P2P lockstep synchronous architecture.

        Suffice to say I am now a zealot about providing default values all the time. Thankfully it’s a lot easier since C++11 came out and lets you define default values at the declaration site!

        • titzer 4 days ago ago

          I prefer language constructs define that new storage is zero-initialized. It doesn't prevent all bugs (i.e. application logic bugs) but at least gives deterministic results. These days it's zero cost for local variables and near-zero cost for fields. This is the case in Virgil.

          • andrewaylett 4 days ago ago

            That makes things worse if all-zero is not a valid value for the datatype. I'd much prefer a set-up that requires you to initialise explicitly. Rust, for example, has a `Default` trait that you can implement if there is a sensible default, which may well be all-zero. It also has a `MaybeUninit` holder which doesn't do any initialisation, but needs an `unsafe` to extract the value once you've made sure it's OK. But if you don't have a suitable default, and don't want/need to use `unsafe`, you have to supply all the values.

          • kevin_thibedeau 4 days ago ago

            C & C++ run on systems where it may not be zero cost. If you need low latency startup it could be a liability to zero out large chunks of memory.

            • ablob 4 days ago ago

              I think it's acceptable to leave an escape hatch for these situations instead of leaving it to easy to misunderstand nooks and crannies of the standard.

              You don't want to zero out the memory? Slap a "foo = uninitialized" in there to have that exact behavior and get the here be demons sign for free.

              • forrestthewoods 4 days ago ago

                Yeah this issue is super obvious and non-controversial.

                Uninitialized state is totally fine as an opt-in performance optimization. But having a well defined non-garbage default value should obviously be the default.

                Did C fuck that up 50 years ago? Yeah probably. They should have known better even then. But that’s ok. It’s a historical artifact. All languages are full of them. We learn and improve!

                • 1718627440 4 days ago ago

                  I don't know, I expect all variables to be uninitialized until proven otherwise. It makes it easier for me to reason about code, especially convoluted code. But I also like C a lot and actually explicitly invoke UB quite often, so there is that.

                  • forrestthewoods 4 days ago ago

                    I like C and it's great. I wish more people wrote C instead of C++. But there's a reason that literally no modern language makes this choice.

                    If uninitialization was opt-in you would still be free to "assume uninitialized until proven otherwise". But uninitialized memory is such a monumental catastrophic footgun that really is not a justifiable reason to make that default behavior. Which, again, is why no modern languages make that (terrible) design choice.

                    • 1718627440 4 days ago ago

                      I am talking about random convoluted code, I did neither wrote nor control. The UB does not only help the compiler, it also helps me the reverse engineer, since I also can assume that an access without a previous write is either a bug, or I misinterpreted the control flow.

                      • AlotOfReading 4 days ago ago

                        You can assume whatever initialization you want when reading code, even if it's not in the standard. Is your concern that people would start writing code assuming zero-init behavior (as they already do)?

                        That purpose would be better served by reclassifying uninitialized reads as erroneous behavior, which they are for C++26 onwards. What useful purpose is served by having them be UB specifically?

                        • 1718627440 4 days ago ago

                          > Is your concern that people would start writing code assuming zero-init behavior (as they already do)?

                          Yes, I couldn't assume that such code can be deleted safely. Not sure, if people really rely on it, given that it doesn't work.

                          > erroneous behavior

                          So they finally did the thing and made the crazy optimizations illegal?

                          > If the execution of an operation is specified as having erroneous behavior, the implementation is permitted to issue a diagnostic and is permitted to terminate the execution of the program.

                          > Recommended practice: An implementation should issue a diagnostic when such an operation is executed. [Note 3: An implementation can issue a diagnostic if it can determine that erroneous behavior is reachable under an implementation-specific set of assumptions about the program behavior, which can result in false positives. — end note]

                          I don't get it at all. The implementation is already allowed to issue diagnostics as it likes including when the line number of the input file changes. In the case of UB it is also permitted to emit code, that terminates the program. This sounds all like saying nothing. The question is what the implementation is NOT allowed to do for erroneous behaviour, that would be allowed for undefined behaviour.

                          Also if they do this, does that mean that most optimizations are suddenly illegal?

                          Well, yeah the compiler can assume UB never happens, optimizes and that can sometimes surprise the programmer. But I the programmer also program based on that assumption. I don't see how defining all the UB serves me.

                        • torstenvl 4 days ago ago

                          UB doesn't mean there will be nasal demons. It means there can be nasal demons, if the implementation says so. It means the language standard does not define a behavior. POSIX can still define the behavior. The implementation can still define the behavior.

                          Plenty of things are UB just because major implementations do things wildly differently. For example:

                              realloc(p, 0)
                          
                          Having initialization be UB means that implementations where it's zero cost can initialize them to zero, or implementations designed for safety-critical systems can initialize them to zero, or what have you, without the standard forcing all implementations to do so.
                          • masklinn 4 days ago ago

                            > UB doesn't mean there will be nasal demons. It means there can be nasal demons, if the implementation says so.

                            Rather "if the implementation doesn't say otherwise".

                            Generally speaking compiler writers are not mustache-twirling villains stroking a white cat thinking of the most dastardly miscompilation they could implement as punishment. Rather they implement optimisation passes hewing as close as they can to the spec's requirements. Which means if you're out of the spec's guarantees you get whatever emergent behaviour occurs when the optimisation passes run rampant.

                            • torstenvl 3 days ago ago

                              This is both factually incorrect and philosophically unsound.

                              Every asm or IR instruction is emitted by the compiler. It isn't a "doesn't say otherwise" kind of thing. Whatever the motivations are, the compiler and its authors are responsible for everything that results.

                              "if you're out of the spec's guarantees you get whatever emergent behaviour occurs" is simply and patently not factual. There isn't a single compiler in existence for which this is true. Every compiler makes additional guarantees beyond the ISO standard, sometimes due to local dialect, sometimes due to other standards like POSIX, sometimes controlled by configuration or switches (e.g., -fwrapv).

                          • forrestthewoods 4 days ago ago

                            Yeah that’s just really bad language design. Which, again, literally no modern languages do because it’s just terrible horrible awful no good very bad design.

                            • 1718627440 4 days ago ago

                              It's describing rather than prescribing, which yeah isn't really design. Most modern languages don't even (plan to) have multiple implementations, much less a standard.

                          • AlotOfReading 4 days ago ago

                            All of that implementation freedom is also available if the behavior is erroneous instead. Having it defined as UB just gets you nasal demons, which incidentally this rule leads to on modern compilers. For example:

                            https://godbolt.org/z/ncaKGnoTb

                    • kevin_thibedeau 4 days ago ago

                      There are non-standard mechanisms to control variable initialization. GCC has -ftrivial-auto-var-init=zero for zero-init of locals (with some caveats). For globals, you can link them into a different section than bss to disable zero-init.

                • 4 days ago ago
                  [deleted]
    • koyote 4 days ago ago

      > Hopefully future versions of C++ will mandate default initialization for all cases that are UB today and we can be free of this class of bug.

      I have production code where we specifically do not initialise some data in order to be more performant (it gets set before it is read, but not at declaration as we do not have the values at that point).

      I do agree that this (and numerous other footguns) make C++ a pain to work with. I also think it's too late to fix.

      Ideally, all values would be initialised by default and instead you could forcefully construct something that is not initialised (e.g. something like `no_init double x[100];`). Instead, we have the bug-prone default and twenty different ways to initialise something, each with their own caveats.

      C++ would be a much better language if most if not all defaults were reversed.

      • simonask 4 days ago ago

        Every C++ compiler is perfectly able to optimize stale writes, so I'm always skeptical of code that leaves uninitialized fields around. I would always strongly prefer rearranging the code to be easier on the optimizer.

    • trueismywork 4 days ago ago

      For now, best strategy is to initialize everything explicitly.

    • tialaramex 4 days ago ago

      In C++ 26 reading an uninitialized variable is by default Erroneous Behaviour, which means your compiler is encouraged to diagnose this (it's an error) but if it happens anyway (perhaps because the compiler can't tell before runtime) there's a specified behaviour, it isn't Undefined Behaviour. The compiler will have chosen some value for that uninitialized variable and if it can't just diagnose that what you wrote was nonsense, it has some arbitrary value, perhaps configurable or perhaps described in your compiler's documentation.

      So these variables will be more or less what the current "defanged" Rust std::mem::uninitialized() function gets you. A bit slower than "truly" uninitialized variables, but not instant death in most cases if you made a mistake because you're human.

      Those C++ people who feel they actually need uninitialized variables can tell the compiler explicitly [for that particular variable] in C++ 26 that they opt out of this safeguard. They get the same behaviour you've seen described in this thread today, arbitrary Undefined Behaviour if you read the uninitialized variable. This would be similar to modern Rust's MaybeUninit::uninit().assume_init() - you are explicitly telling the compiler it's OK to set fire to everything, you should probably not do this, but we did warn you.

  • nurumaik 3 days ago ago

    Example from this article looks more like "unspecified" behavior rather than "undefined". Title made me expect nasal demons, now I'm a bit disappointed

  • quuxplusone 3 days ago ago

    I mean, "obviously" if you don't initialize your variables, they'll contain garbage. You can't assume that garbage is zero/false, or any other meaningful value.

    But re the distinction at the end of TFA — that a garbage char is slightly more OK than a garbage bool — that's also intuitive. Eight bits of garbage is always going to be at least some valid char (physically speaking), whereas it's highly unlikely that eight bits of garbage will happen to form a valid bool (there being only two valid values for bool out of those 256 possible octets).

    This also relates to the (old in GCC but super new in Clang, IIUC) compiler option -fstrict-bool.