Then you learn about Undefined Behaviour.
Then you learn about Undefined Behaviour.
Do not confuse simple with easy.
Classic example of illusion of simplicity: signed integer overflow. Going outside the range? Undefined. Left shifting a negative integer? undefined. You can't just assume 2's complement, you have to be aware of the Nasal Demons compilers are becoming increasingly apt at summoning.
Another one: out of bounds array index is undefined, even if there was something of the right type there:
#include <stdio.h>
#include <stdint.h>
typedef struct {
uint64_t a[8];
} blk;
int main()
{
blk b[2];
for (size_t i = 0; i < 16; i++) {
b[0].a[i] = i;
}
printf("normal : %lu\n", b[1].a[2]);
printf("overflow: %lu\n", b[0].a[10]); // undefined
return 0;
}
Depending on optimisations and compilers, you won't get the same result every time, and all sanitizers warn you about the UB. Conclusion: you can't assume a flat memory model, even on modern X86 in 32 or 64 bit mode. There are exceptions, depending on how you access memory, through which pointer. Again, your language specs just got longer for not defining what happens outside the bounds.---
Undefined behaviour simplified one thing: the compilers themselves. At least, until they got the idea to use UB for optimisation, and started to summon the Nasal Demons as a result.
I don't see this as an example of illusion of simplicity. On the contrary. Specifying exactly what must happen on integer overflow or left shift is almost always going to require more than saying "the behavior is undefined." Especially so if you try to keep it portable and allow different behaviors that are natural for different systems. And it probably won't help you if you're trying to write portable code, because now you need to worry about all the defined behavior that might not be what you want.
If you want portable code, then you shouldn't be able to assume a specific behavior, and if you want a language standard that enables the language to perform well across dislike platforms, you can't dictate specific behavior.
If you don't care about portability, you're free to enter a contract between you and your platform & compiler. For example, use -fwrapv. The standard doesn't forbid such a thing. By keeping things simple and leaving them undefined, it explicitly enables you to do things like this without violating what's defined in the standard.
> Another one: out of bounds array index is undefined, even if there was something of the right type there:
That's a very specific scenario. In a lot of cases, there are arrays whose indexes are not right there, not constants (would require range analysis), or array size is not known (but maybe could be inferred). So the standard would have to become more complex in order to specify that something specific must happen in the special case that you're indexing into an array of known size in scope, with a constant index. By simply making out of bounds access undefined, they cover all cases of array access in one short sentence.
Luckily, again, since the standard doesn't require any specific behavior, your friendly implementation is free to invent its own specific behavior, for example issue a diagnostic and return an error code because it saw that you were indexing out of bounds.
> Again, your language specs just got longer for not defining what happens outside the bounds.
Why don't you show how to make the spec shorter by defining exactly what happens in this specific case? Remember that the spec still has to cover other cases, so you can't just delete all the text. You have to add a new case! Post a diff.
That's how you specify "exactly what must happen on integer overflow". You thought I would have to take every undefined special case one by one and define them? That's naïve.
A similar argument can be made for pointers: pointers are integers that represent memory addresses. Dereferencing a pointer to an allocated region gives you the value stored in that region (insert casts, uninitialised values, trap representations etc here). Dereferencing a pointer to a region that is not currently allocated is undefined.
And voilà, we no longer care how a pointer was constructed then dereferenced, we only care about the address, and whether it pointed to an allocated region (and initialised with a compatible type etc, though that part could also be simplified).
> That's a very specific scenario.
I'm currently reporting a bug in the Libsodium cryptographic library because its Scrypt implementation (password hash by Colin Percival) has precisely this bug. My guess is that Colin was a little too clever there, and treated C as a lower level language than it actually is.
A very specific scenario that may be, but it's a scenario we do encounter in real life.
So yes, like I said, you can force a particular behavior, and throw out any system for which that doesn't come naturally - you just made the language more complex for them. And no, that is not simpler than "the behavior is undefined", just different (and more imposing).
> And voilà, we no longer care how a pointer was constructed then dereferenced, we only care about the address, and whether it pointed to an allocated region (and initialised with a compatible type etc, though that part could also be simplified).
Eh. You did nothing about undefined behavior. It's still there.
> A very specific scenario that may be, but it's a scenario we do encounter in real life.
So what was your proposal that doesn't make the language more complex? Because above, you just gave us undefined behavior, which is our starting square.
Find me one system in current use (2020) that isn't 2's complement.
> you just made the language more complex for them.
Ah, but I was never talking about implementation complexity. I was talking about specification complexity. Imposing 2's complement makes the specification simpler. No special case, no boundary. Just a binary field. Undefined behaviour draws such a boundary. An extra detail to specify and remember. Some thing that reminds you you're not quite doing modular arithmetic.
> Eh. You did nothing about undefined behavior. It's still there.
I did. There's less of it. I've filled the bug, here's what's this about: https://github.com/jedisct1/libsodium/issues/937
Here's the relevant quote from annex J of the (heavy, I have printed it) C99 specifications:
Addition or subtraction of a pointer into, or just beyond, an array object and an integer type produces a result that does not point into, or just beyond, the same array object. (6.5.6)
That's what caused the bug: we were taking a pointer from an array, then used it to overflow past it. It didn't matter that we could access the memory there. What mattered is that we locally overflew that array. Had we computed the pointer by first working with the outer array, then pointed to the right element, we'd have the same pointer, except this time it wouldn't be undefined.
Bonus note: you don't even have to dereference the pointer, computing its value is enough to trigger undefined behaviour.
Here is some more undefined behaviour I have removed (it's subtle, relatively few programmers know this):
Pointers that do not point into, or just beyond, the same array object are subtracted. (6.5.6)
There go flat memory models. Though to be honest, even segmented memory models could define that. Just make the subtraction already, it doesn't have to mean anything. Make it unspecified or implementation defined at least.
Pointers that do not point to the same aggregate or union (nor just beyond the same array object) are compared using relational operators (6.5.8)
That's the reason we can't implement memmove() portably and efficiently. And depending which tool you ask, even converting the pointers to intptr_t first isn't enough. Real world example here: https://github.com/jedisct1/libsodium/commit/e7e378fad116c18...
---
If you haven't already, go read the annex J from the C standard. Ponder the fact that compilers, unless you specifically ask not to (-fwrapv), will exploit every single one, even on platform that could define some reasonable behaviour (2's complement machines). It may hint at how hostile C is to programmers who care about correctness.
Believe me, I know. I wrote a whole Crypto library[1], and that's one of the easiest things to get right, where C is concerned. I mean, the crypto itself is hard, but the total lack of dependency makes it pathologically easy to write in a portable way. Yet C still makes it hard.
Fortunately, C2x will stop with the meme assumption that two's complement hasn't taken over the world (see draft N2479). It will not, however, specify signed integer overflow, which remains UB. :^)
for (size_t i : 0..10) {} // Will go from 0 to 9 included.
for (size_t i : 10..0, -1) {} // Will go from 10 to 1 included...
// ...or maybe from 9 to 0 included?
// Not sure which is best.