The C and C++ languages are notorious for the very large section of the map labeled here be dragons, or more formally, undefined behavior.
When undefined behavior is invoked, anything is possible. For example, a variable can be both true and false. John Regehr has a list of interesting examples, as well as some winners of the ensuing contest.
Consider the following function:
int table[4];
bool exists_in_table(int v)
{
for (int i = 0; i <= 4; i++) {
if (table[i] == v) return true;
}
return false;
}
What does this have to do with time travel, you ask? Hang on, impatient one.
First of all, you might notice the off-by-one error
in the loop control.
The result is that the function reads one past the end
of the table array before giving up.
A classical compiler wouldn't particularly care.
It would just generate the code to read the out-of-bounds
array element (despite the fact that doing so is a violation
of the language rules),
and it would return true if the
memory one past the end of the array happened to match.
A post-classical compiler, on the other hand, might perform the following analysis:
- The first four times through the loop,
the function might return
true. - When
iis 4, the code performs undefined behavior. Since undefined behavior lets me do anything I want, I can totally ignore that case and proceed on the assumption thatiis never 4. (If the assumption is violated, then something unpredictable happens, but that's okay, because undefined behavior grants me permission to be unpredictable.) - The case where
iis 5 never occurs, because in order to get there, I first have to get through the case whereiis 4, which I have already assumed cannot happen. - Therefore, all legal code paths return
true.
As a result, a post-classical compiler can optimize the function to
bool exists_in_table(int v)
{
return true;
}
Okay, so that's already kind of weird. A function got optimized to basically nothing due to undefined behavior. Note that even if the value isn't in the table (not even in the illegal-to-access fifth element), the function will still return true.
Now we can take this post-classical behavior one step further: Since the compiler can assume that undefined behavior never occurs (because if it did, then the compiler is allowed to do anything it wants), the compiler can use undefined behavior to guide optimizations.
int value_or_fallback(int *p)
{
return p ? *p : 42;
}
The above function accepts a pointer to an integer and either returns the pointed-to value or (if the pointer is null) returns the fallback value 42. So far so good.
Let's add a line of debugging to the function.
int value_or_fallback(int *p)
{
printf("The value of *p is %d\n", *p);
return p ? *p : 42;
}
This new line introduces a bug: It dereferences the pointer p
without checking if it is null.
This tiny bug actually has wide-ranging consequences.
A post-classical compiler will optimize the function to
int value_or_fallback(int *p)
{
printf("The value of *p is %d\n", *p);
return *p;
}
because it observes that the null pointer check is no longer
needed:
If the pointer were null, then the printf already
engaged in undefined behavior,
so the compiler is allowed to do anything in the case the pointer
is null (including acting as if it weren't).
Okay, so that's not too surprising. That may even be an optimization you expect from a compiler. (For example, if the ternary operator was hidden inside a macro, you would have expected the compiler to remove the test that is provably false.)
But a post-classical compiler can now use this buggy function to start doing time travel.
void unwitting(bool door_is_open)
{
if (door_is_open) {
walk_on_in();
} else {
ring_bell();
// wait for the door to open using the fallback value
fallback = value_or_fallback(nullptr);
wait_for_door_to_open(fallback);
}
}
A post-classical compiler can optimize this entire function to
void unwitting(bool door_is_open)
{
walk_on_in();
}
Huh?
The compiler observed that the call
value_
invokes undefined behavior on all code paths.
Propagating this analysis backward,
the compiler then observes that if
door_ is false,
then the else branch invokes undefined
behavior on all code paths.
Therefore, the entire else branch can be treated
as unreachable.²
Okay, now here comes the time travel:
void keep_checking_door()
{
for (;;) {
printf("Is the door open? ");
fflush(stdout);
char response;
if (scanf("%c", &response) != 1) return;
bool door_is_open = response == 'Y';
unwitting(door_is_open);
}
}
A post-modern compiler may propagate the analysis that
"if door_ is false,
then the behavior is undefined"
and rewrite this function to
void keep_checking_door()
{
for (;;) {
printf("Is the door open? ");
fflush(stdout);
char response;
if (scanf("%c", &response) != 1) return;
bool door_is_open = response == 'Y';
if (!door_is_open) abort();
walk_on_in();
}
}
Observe that even though the original code rang the bell before crashing, the rewritten function skips over ringing the bell and just crashes immediately. You might say that the compiler went back in time and unrung the bell.
This "going back in time" is possible even for
objects with external visibility like files,
because the standard allows for anything at all
to happen when undefined behavior is encountered.
And that includes hopping in a time machine and
pretending you never called fwrite.
Even if you claim that the compiler is not allowed
to perform time travel,¹
it's still possible to see earlier operations become undone.
For example,
it's possible that the undefined operation resulted
in the file buffers being corrupted,
so the data never actually got written.
Even if the buffers were flushed, the undefined operation
may have resulted in a call to
ftruncate to logically remove the data
you just wrote.
Or it may have resulted in a DeleteFile
to delete the file you thought you had created.
All of these behaviors have the same observable effect, namely that the earlier action appears not to have occurred. Whether they actually occurred and were reversed or never occurred at all is moot from a compiler-theoretic point of view.
The compiler may as well have propagated the effect of the undefined operation backward in time.
¹ For the record, the standard explicitly permits time travel in the face of undefined behavior:
However, if any such execution contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).
(Emphasis mine.)
²
Another way of looking at this transformation is that the
compiler saw that the else branch invokes
undefined behavior on all code paths, so it rewrote
the code as
void unwitting(bool door_is_open)
{
if (door_is_open) {
walk_on_in();
} else {
walk_on_in();
}
}
taking advantage of the rule that undefined behavior
allows anything to happen,
so in this case, it decided that "anything" was
"calling walk_ by mistake."
Bonus chatter: Note that there are some categories of undefined behavior which may not be obvious. For example, dereferencing a null pointer is undefined behavior even if you try to counteract the dereference before it does anything dangerous.
int *p = nullptr; int& i = *p; foo(&i); // undefined
You might think that the &
and the *
cancel out and the result is as if you had written
foo(p),
but the fact that you created a reference to a nonexistent object,
even if you never carried through on it,
invokes undefined behavior (§8.5.3(1)).
Related reading: What Every C Programmer Should Know About Undefined Behavior, Part 1, Part 2, Part 3.
Update:
Broke the &* into two lines
because it is the lone * that is the problem.
"Here be dragons"? What happened to the nasal demons?
catb.org/…/nasal-demons.html
[…] Therefore, the entire else branch can be treated as unreachable.² […] Even if you claim that the compiler is not allowed to perform time travel,¹ […]
I see what you did there.
What do we want? Undefined behavior! When do we want it? It's irrelevant.
Raymond: the C99 standard explicitly states: "83) Thus, &*E is equivalent to E (even if E is a null pointer)" (6.5.3.3, page 89, not sure about C89 though)
I don't know where you got your knowledge?
The omit null check after deref annoys me if there's no option to turn it off as kerenl mode code might have to handle a null passed from user space to system call.
It's too late in general to unring that bell. Windows got lucky so few programs depended on it.
I think I read somewhere though that in C++, &*E is indeed undefined behavior and not equivalent to E. So the bonus chatter may be true for C++. Someone confirm or disprove it please, because I know nothing about C++
Something something c++ operator overloading something something, I'd imagine. After all, the dereference operator might have side-effects! (Shudders)
Raymond, I meant that operator overloading could be one of the reasons that &*E is not necessarily equal to E in C++, where it would necessarily be in C.
Sorry Raymond, I wasn't talking about the article, I was trying to provide an explanation to the comment above mine about the behavior in c++ vs c. I see now that they're all off topic, including mine.
The undefined behavior explained in the `value_or_fallback()` example resulted in an actual Linux kernel exploit a while back (the TUN/TAP driver exploit).
See lwn.net/…/342330
A good example of the undefined behavior is if you do something like
std::vector<int> vect;
int *x = &vect[0];
I got bit by that before vector had a .data() method. My initial expectation was that x would contain NULL. Fortunately no time travel was invoked and the program simply crashed.
@Pete, No, they are not off topic. Raymond claims that &*nullptr is undefined behavior in both C and C++, but I pointed out it is not undefined behavior in C, but I'm not sure about C++. You pointed out that it is undefined behavior in C++ for the reason you gave.
Nit: In the unwitting function, the optimisation can only be done if the compiler can prove that ring_bell terminates or invokes undefined behaviour.
I would simplify the explanation for the first example to: When executing the loop, at some point i will equal 4. This invokes undefined behaviour due to accessing beyond the end of the array, allowing the function to return true. The final iteration of the loop will thus always return true, and so the loop can be optimised to a "return true" statement.
SimonRev: Maybe the undefined behavior invoked a time travel and modified everyone's memory, as if the program simply crashed? Wait, if a time travel is invoked and no one remembers it, does it make a real time travel?
I always question this kind of compiler behaviour especially when you see these kinds of results. Since the likelihood that these invocations of undefined behaviour are unintentional, then wouldn't it be better finding better ways of telling the user that the code is wrong rather than optimising it away and causing lots of confusion when things break.
@David: Are you complaining that compilers turn buggy code into buggier code?
Can you please give an example of "A post-modern compiler" that does such crazy optimizations exploiting undefined behaviors? Is this theoretical reasoning? Or, does it can really happen?
@VinDuv, compilers certainly turn buggy code hard to de-bug.
The `exists_in_table()` example (the first one) is a variation of a paradox that a professor in one of my university classes presented way back when – before anyone talked about undefined behavior on computers.
The paradox was presented as:
A teacher of a class on logic announces on a Friday that sometime during the next week a surprise quiz will be given, and that no one will know before the quiz is given when it will be. The students deduce that if the quiz hasn't been given by Friday, then it would have to be given on Friday. But that couldn't happen because they would know before Friday's class that the quiz would be given that day. Since Friday is eliminated as a candidate for quiz day, reduction eliminates all the other days of the week, and the conclusion is that the quiz can't be given. The students spend the weekend partying instead of studying.
The students show up for class on Monday, and find that they have to take a surprise quiz.
@Kay:
Follow the links Raymond gave to John Regehr's articles on real world undefined behavior. These aren't just theoretical.
@John Doe
s/hard/nigh-unto-impossible/
This StackOverflow question where gcc turns a loop with four iterations into a infinite loop due to signed integer overflow is one of the best I have see:
stackoverflow.com/…/why-does-this-code-output-more-than-4-lines
"You might think that the &* cancel out and the result is as if you had written foo(p), but the fact that you performed the dereference at all (even if you never carried through on it) invokes undefined behavior."
Is this really true? The C99 standard says:
"The unary & operator yields the address of its operand. If the operand has type ‘‘type’’,
the result has type ‘‘pointer to type’’. If the operand is the result of a unary * operator,
neither that operator nor the & operator is evaluated and the result is as if both were
omitted, except that the constraints on the operators still apply and the result is not an
lvalue."
(the constraints are basically that the * still has to be used on a pointer, so you can't write &*1)
I think you statement: `When i is 4, the code performs undefined behavior. Since undefined behavior lets me do anything I want, I can totally ignore that case and proceed on the assumption that i is never 4.` is paradoxical. If you are going to ignore the case and proceed on the assumption that i is never 4 then you would never have reached the state of undefined behavior in the first place where you would want to not ignore the case.
About the whole &*p thing, it is indeed legal in C++: stackoverflow.com/…/dereferencing-an-invalid-pointer-then-taking-the-address-of-the-result
It becomes undefined behaviour when there is an lvalue-to-rvalue conversion required for *p.
This is great. Post-classical compilers are very good at producing code that very efficiently does the wrong* thing. Progress!
* allowed by the standard but obviously wrong anyway.
@Kay: "Can you please give an example of "A post-modern compiler" that does such crazy optimizations exploiting undefined behaviors?"
Yes, there have been several security holes "caused" by compiler optimizations. This can be as simple as a "char* password = malloc(size); …; memset(password, '