That came out wrong. What I mean is that it's not a threat from the standard that its buddy the compiler will ruin your computer, your day, and your life. Instead, it's a promise from the programmer to the compiler that the programmer won't perform some operations.
Since the programmer promised not to do some things, the compiler assumes those things aren't done while reasoning about the code, but if it turns out one of those things was done, the compiler's whole chain of reasoning is potentially invalid. So the compiler's options are to trust the programmer, and make the assumptions, or to instead treat the programmer as a dirty liar, and not make any of the assumptions while expending much effort considering all the subtle ways the programmer could try to trick it. Actually, there's a third option, trust but verify, where the compiler trusts the programmer to make a best effort, but verifies the assumptions when convenient. Unfortunately, it's not often convenient to verify in C or C++, but broadly compilers are getting better at it over time.
I'll just add that the third option is usually not considered viable because the main point of using C/C++ is for performance, thus people writing code want their compiler to optimize it as much as possible.
It may be possible to limit the bad effects of faulty assumptions by being very careful about how deductions propogate from unreliable assumptions. However it's certainly not easy, especially without throwing out all optimizations that interact with a potentially undefined operation.
It is possible to design a languge such that a compiler doesn't need to make assumptions to perform optimizations, if programs are required to give enough information to compilers that they can statically prove properties they need to perform optimizations. But C and C++ are not designed that way, and it can't easily be retrofitted on top of them.
I ask because when I wrote that post, I couldn't remember if I had heard the term before. In other words, I am not sure if I copied someone else or not, and if I did, I want to add attribution.
I don't think any programmer puts UB on purpose into the code, even if they can enumerate from memory all 200 or so cases of UB just in the C standard (no idea how big the list is in the C++ standard - thousands maybe?).
The original sin was compiler writers exploiting UB for optimisations in bizarre ways instead of working with the C and C++ committees to fix the language to enable those types of optimizations without requiring UB, or at least to classify UB into different 'hazard categories' (most types of UB in the standard are completely irrelevant for code generation and optimization)
I agree with you. But the problem is not people putting UB in their code, either on purpose or by mistake: we all do that, every day!
The problem is people trying to defend their code once it has been made clear to them that it contains UB, and trying to fight the compiler rather than fix their error.
How about when code is written correctly and then later the standards body makes previously implementation defined behaviour into undefined? I've got some code that calls realloc that WG14 declared to be undefined years after I wrote it and I doubt that experience is unique.
The evangelical attitude that the standard committee knows best and your code is wrong and you should immediately down tools to work around the compiler noticing the opportunity to miscompile it is quite popular. I think it's a really compelling argument to build nothing whatsoever on the ISO C/C++ stack and replace what you do have with something less hostile, aka anything whatsoever - rust, python, raw machine code written in hex - none of them have this active hostility towards the dev baked into the language design.
For realloc, different implementations did different things and clearly said they will not change. There wasn't really any other choice. If your program was written for one implementation where it works, it can continue to do so, but it was never portable to other implementations. The standard now simply reflects this reality.
Put the two together and you get a fast and fragile language implementation. I know why the benchmark people push the compiler in that direction. I'm doubtful that WG21 or WG14 especially want this emergent property.
My suspicion is that this is an accident of history that has too much unwarranted inertia behind it. The moral stance that it's all lesser programmers erroneously writing wrong code is aggravating in that context as it actively opposes anyone making things better.
Compare to the concurrent memory model: while DRF-SC still has a plenty of UB, it is at least possible for a competent programmer to figure out the correctness of their code.
I certainly do not believe that is realistic to stamp out all UB from C and C++ (at least while pretending that the resulting languages have anything to do with the original ones), but there is a lot that the standard could do to try to limit the most egregious cases, possibly providing different levels of conformance (like it is done for floats and IEE754).
[1] of course implementors are part of the committee so they are not blameless.
I don't know how constraining is this more restrictive implementation of the standard is, but certainly it will help with maintaining a bit of sanity.
Thank you for pointing this out.
Since when is it reasonable to assume that?
> Then you have weird edge cases like assigning the return value of a two argument std::max involving temporaries to a reference
You have a reference to a temporary. Reference lifetime extension is a thing. No UB there. Completely defined and supported.
However, some benchmarks use iteration on a signed integer, and assuming that loop terminates makes it slightly faster, so in order to retain that marginal advantage over other languages, signed iteration shall be assumed to never overflow.
This is very typical of the C++ experience.
No, they cannot. You don't have the right to make any assumption about integer overflow in C.
Most of the whining about UB is from people who still refuse to accept that you don't have the right to think about your processor family once you write in any language that is not assembly.
I'd actually go the other way: I think most practicing programmers have not read the entire standard specifying most languages they use day-to-day and have no real idea what the abstraction-break looks like that turns their code into a format consumable by the next layer down.
How many Java programmers do we assume know anything about the bytecode of the JVM, for example?
The corresponding Java, C#, Python standards, equally with the standard library, are even bigger.
To come back to the point, many don't know how deceptively complex Python happens to be, even though on the surface looks like a BASIC replacement.
If we compare to equivalent Python code, for example, the behavior is simple and straightforward: division by zero causes a runtime error that is reported in a well-defined manner. Similarly with other arithmetic issues in C++ that can trigger UB - e.g. integer overflow is just not a thing in Python (short of OOM). The detailed rules may well be complicated, but it doesn't matter as much when the behavior is intuitive and conforms to common sense expectations.
Further, those 'checks' might be in place for a long time, outlasting compiler versions and perhaps even language standards.
Indeed, it isn't. The check becomes useless if it happens after the division, though.
As a slightly contrived example, assert(sizeof(char) == 1) is true by definition and could be elided, but it might be a useful reminder to see it in the source, next to code that implicitly relies on this truth.
[1]: https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.h... [2]: https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
The point is that there is still value in that - your running program can at least say "oops" after the fact. You'll at least get errors when running your tests!
Removing the `printf ("oops")` altogether under the guise of "well, something 3 lines above was UB, so we can delete the rest of the program" is what users are complaining about.
The way the compilers go about it now, is that they don't even let you fail tests because you made an oopsie a few lines ago and now everything after that is considered safe for deletion.
Look at this example:
char *myvar = null;
...
myvar[0] = 'A';
...
if (myvar == null) {
printf ("oops\n");
}
I literally want every single one of those lines emitted in the final output.1. I don't want the 'myvar[0]' assignment removed because the compiler determined that mvar is NULL. If it's there, at least I'll get a crash before the 'oops'. If the crash doesn't come, at least I have the 'oops' to warn in testing.
2. I don't want the `myvar == null` check removed. If the crash in the assignment didn't happen, I **want** the message 'oops' printed.
The compiler is clearly going against the intention of the programmer if it fails to emit code for any of those lines, because it turns a failing test into a passing one.
If you want to be able to observe values through invalid pointers then the compiler can't do things like store locals in registers since maybe you care about checking a value through one of these pointers. Huge mess! Like, whose to say that the line "myvar[0] = 'A';" doesn't write to the location in memory where the constant string "oops\n" is stored? How will the compiler ensure that your program does what you expect after a bogus write somewhere in memory?
I'm not asking for that. I'm asking that the compiler not remove code because of a prior error!"
If the program prints garbage, that's good enough to fail on a test.
If the program cannot print anything at all, then so be it.
What's actually* happening is that an over-eager compiler is removing something that exists. I'm asking that it not do that.
I'm not asking that it ensures that the line after the error executes, I'm asking that it not be eliminated.
I'm not sure what 'eliminated' even mean here. If the pointed to object has been replaced by a register, there might not even be a pointer to check. I.e. there might not be any meaningful code the compiler could generate for the line.
Compiler transformations attempt to preserve semantics of the code, do not attempt a faithful line-by-line translation (beyond toy compilers). If there is no semantic for a line, how can the compiler translate it?
Consider this code:
static inline int * get_x(void* ctx) { return (int*)ctx; }
static inline int indirect( int*(*getter)(void*), void*ctx) {
int * ptr = getter(ctx);
if(!ptr) { return 0; } // 1
return *ptr;
}
int main() {
int x = 10;
return indirect(get_x, &x);
}
After optimization, the code can be (and indeed is: https://godbolt.org/z/5vb9PdnYs) transformed to a simple "return 10". There is no meaningful translation for the line at [1] as there isn't actually any pointer or memory location the pointer could be pointing to in the translated program.It doesn't even make sense to warn, as main, get_x and indirect could be in completely independent libraries and each make sense as written on its own (getter+ctx is just an hand built closure, so the code is far from non-realistic).
I dunno if that is relevant. In the example you posted, simply removing the `static inline` leaves a pointer to check. The value isn't moved to a register.
We are arguing the cases when the source lines are there, but then are not emitted. In the code samples under discussion, the pointer values are still there and can be checked. Their values are not sitting in some register.
> (getter+ctx is just an hand built closure, so the code is far from non-realistic).
Qualifying with `static inline` is unusual, and none of the objections are using static+inlined code as samples of poor code generation. The examples being presented are a lot more real (as they are taken from existing project, not contrived to make an argument).
The code under discussion does not compile, so it is hard to discuss it. Please consider this variant: https://godbolt.org/z/EMzr4naMP
As you can see, while the check is still present in the non-inlined foo, main doesn't actually call it and omits the check. There is nothing left in main to check as there is no myvar object left.
This example and my previous one are similar: in both examples, not only the compiler could statically compute the value of the pointer, but it can track the pointee directly ('int x' in my example, the null pointer in your), hence it doesn't need to allocate any memory or registers for the pointer.
Separately, in both cases the compiler was able to statically prove that the pointer must be pointing to something (in my example because it knows the target, in yours because of the assignment through it), so the null check can be removed as the value of the condition is statically known to be always true.
Finally, I believe that in your example GCC found the contradiction with the pointer being both null and not null and realized that the whole main function is not possibly reachable, hence it isolates it with ud2 (clang is just silly).
What code would you expect GCC to generate in main to test the condition? Should it allocate a register, initialize it just for the purpose of performing the branch? Should it do it just for your example or also for mine?
(NB entirely. That is, it's reasonable to coalesce arithmetic expressions together or inline functions such that a single operation in the output has subsumed multiple lines of code. However, if for a source S you can remove one or more entire lines of code to produce source S' with the same output, then something is wrong with the intent of the program or the compiler has misinterpreted it)
(For instance, in QEMU we have code like: "if (kvm_enabled()) { do_something_kvm_specific(); }". In a no-KVM build, kvm_enabled() evaluates to compile-time false, and in a KVM build it does a runtime check of whether the user turned on KVM for this run.)
I suppose some of this depends on what you mean by "eliminating a line of code", as an optimizing compiler pretty quickly transforms the program into a state where any kind of 1:1 mapping between lines of code and instructions is impossible.
I don't think hoisting would be covered by this suggestion, pjc50 suggested deletion rather than moving.
I read the proposal very literally, which also means I'm unclear what options you have in your final paragraph:
Compile function as is; For each line, make a copy with that line missing and recompile; when this identical to the original, flag that line as "warning: optimised out of existence"
Optimizing compilers can also produce different output but with identical semantics depending on a lot of heuristics. What happens when deleting a line of code produces different inlining or specialization decisions? What is a rigorous definition of "identical"?
"Deleting a line" is also not the only way that unexpected and perhaps unwanted behavior from UB reasoning appears. Unexpected code motion is absolutely a thing that people have complained about. I don't think you can so quickly insist that things like hoisting are out of scope here.
As a compiler--indeed, optimization--developer, one of the things that continually frustrates me about discussions on UB is that so many suggestions are based on just complete incomprehension of how compilers work. One of the first things compilers do is throw away the original source code; outside of initial front-end codegen, there is no reference to the original AST, and instead everything is based on a pseudo-assembly IR. A concept like an "entire line of code" just doesn't exist in IR; even mapping instructions back to line of codes is a difficult process because frequently they aren't even attributable to a line.
Enough is retained to allow line-step debugging. Godbolt will even do a nice mapping for you between source and object lines. Yes, a lot of it gets thrown away. My point is "this line of code has no effect on the output" is a problem, and the developer should be warned rather than lulled into a false sense of security that their check is doing something.
(Code coverage has the same issue of doing line attribution, plus the difficulty of hitting error conditions in practice, but it would also show unexecuted lines of code)
Only without optimization. Every compiler I have ever used (and I have used a lot of them!) starts having difficulty with step-debugging at the lowest-level of optimization, typically -O1. Some functions work, most don't.
That doesn't have to be an immutable fact for all implementations of compilers for all languages. The Rust compiler has multiple IRs (AST, HIR, THIR, MIR & LLVM IR), and for all but the last one (codegen), we keep around, if not a reference to the HIR node that allows us to navigate its context, at least a Span that points at the user's code (sometimes a whole struct or linked list of "facts" we've collected so far, each containing Spans). rustc also throws away information to reduce memory consumption, much to my dismay, but we still perform later checks to re evaluate (sometimes as an approximation) what the reason for a certain "decision" was, so that we can talk to the user in terms they understand, and not in terms of compiler internals.
The ideal design would be to have a single implementation of all logic with two modes of operation: the low memory consumption one (what you state as the way compilers work), and a "track everything without discarding metadata until the end" mode, that can provide better human readable output.
https://www.youtube.com/watch?v=w3_e9vZj7D8
Compilers generaly do not abuse UB (outside of compiler bugs), its just that UB is a very missunderstood subject.
I watched the video and didn't learn anything new. I know UB pretty well.
> Compilers generaly do not abuse UB
It's disheartening to see this take from the guy who said that one word broke C.
It is also false, in my experience because some people, including compiler developers, think that compiler freedom is the definition of UB, not a side effect of that one-word change:
https://gavinhoward.com/2023/08/the-scourge-of-00ub/
So your claim in the video that such things are scary to compiler developers is sad. They have broken existing code; they are not afraid to.
I used to be somewhat confident (https://gavinhoward.com/2024/05/a-grateful-open-letter-to-je...) in the direction of C with you and JeanHeyd Meneide on WG14. But if you sincerely believe what you just said, then it seems you have changed your mind since you wrote that one word changed C. So I am not as confident anymore.
00UB does need fixing, and C needs less UB. If you don't think so anymore, then C will become untenable for me to use.
> I understand C way better than i used to.
Maybe, but I did not see it in that video.
What I do know is that you do not know much about the humans using C or the environments where C is used. C is a tool for humans, not machines. Prioritizing machines over humans will make C worse over time, and implementors will use that to further excuse their behavior.
Perhaps it is. It wasn't always so.
In K&R the term "behavior is undefined" occurs often. Everyone understood it's meaning. It meant "you get what the hardware gives you", meaning the compiler will output the same instructions it always does, but K&R didn't say what those instructions would do (typically because it couldn't). Of course what the hardware did on any given arch was perfectly well defined.
The definition had it's upsides and downsides. On the upside, programmers took advantage of their knowledge of the hardware they were targeting to write efficient code. Embedded programmers tend to do that sort of thing fairly aggressively (for example, there is often something useful stored in location 0). The downside is if they did that then their code wasn't portable.
The definition gets ugly if the programmer is trying to write portable code, because it means they don't get warned if they code they wrote wouldn't port easily. As a consequence writing non-portable code was and remains easy mistake to make. The sane solution was a --error-if-not-portable compiler option.
But that's not what we go is it? Instead the meaning of "the behavior is undefined" morphed from "hardware defined" to "implementations are allowed to assume that the respective runtime condition does not ever occur". From what I can tell compiler writes turned that definition into "the behavior is compiler writer defined" so they could gain some edge in the "who has the best optimiser" games they love to play. Consequently the definition the compiler writer uses is almost always "delete the code".
But doing that made it harder to write correct code. Whereas before the code always had the same meaning on some hardware, it now changes it's meaning depending on the same hardware on whether you supply -O0 or -O2. And it does so without warning, because we never got the --error-if-not-portable option. The result has been numerous bugs. For example take:
int parse_packet()
{
uint8_t buffer[1500];
int len = read_from_internet(buffer, sizeof(buffer));
uint32_t field_size = ntohs(*(uint32_t*)buffer);
if (buffer + field_size >= &buffer[len] || buffer + field_size < buffer)
return -1; /* error return */
/* continue parsing the packet. */
return 0; /* success */
}
In the K&R world it's clear what the programmer intends, and on all arch's I know a straightforward compilation would produce the behaviour he intended, and indeed "gcc -O0" produces what they expected. But "buffer + len < buffer" could only be true if len is so large it wraps to before buffer. That's UB so it triggers the "implementations are allowed to assume that the respective runtime condition does not ever occur" clause. Consequently gcc -O2 deletes that test. This really happened, and the result was a CVE.There were two reasonable outcomes for this code. One is the K&R approach. The other was the --error-if-not-portable approach which means refuse to compile the code. I think most compiler users (as opposed to people playing word games in order to win some optimisation game) would call what actually happened "compilers abusing UB". That because no one wins from that particular "optimisation", except the compiler writer doing some micro benchmark. At best the programmer had a flaw the compiler knew about and exploited, but didn't warn him about. The users of the compilers output got hit with a CVE.
That's the best interpretation. The worst is the C standard committee has lost the plot. Their goal should be to produced a simple, clear standard even a novice programmer could safely pick up and read to learn the language. That is what K&R was. Instead we've arrived at the point governments are saying the language is too dangerous to use.