55 comments

[ 3.0 ms ] story [ 68.5 ms ] thread
I enjoy programming in C a lot, though I wish they would fix C's biggest mistake[1].

[1] <https://digitalmars.com/articles/C-biggest-mistake.html>

C biggest "mistake" (there are historical reasons for this, though, but it doesn't make it acceptable nowadays) is the concept of undefined behavior, period.
Every language has undefined behavior - it's just that some compilers emit an error when it's encountered. You can make a C compiler that refuses to compile code with undefined behavior.
Most undefined behaviour can’t be reliably detected at compile time, though you could throw the error at runtime or make the behaviour defined instead
...which is what we're using ubsan for.
There is a tradeoff though because it allows the code to be optimised more which is often something you want from C code
Should a compiler be allowed to optimize:

    int x = 0;
    int y = 42;
    foo(&x);
    bar(y);
to:

    int x = 0;
    foo(&x);
    bar(42);
without performing analysis on foo? If so, how can that optimization be legal without UB?
How is this UB? foo() won't have any access to y so won't be able to modify it, so we can be more or less certain that y is still 42 when bar() is called? What is it that i'm missing?
That's it -- the optimization is legal because there's no way foo() could modify y, because any (&x)[1] = 20 shenanigans would be UB.

This is how UB is used in optimizations; it's (usually) not that the compiler is finding positions where UB occurs and explicitly choosing to perform mischief, it's performing a bunch of simple and obviously-what-you-want rewrites that are only correct because of UB; otherwise, arbitrary pointer writes are too powerful.

(comment deleted)
UB is what allows `(&x)[1]` to happen at all. If you didn't have UB, there would be no reasonable way to allow code that messes up arbitrary pointers.

A typical non-UB language wouldn't allow `(&x)[1]` and it would be easy to allow the optimization.

It would be difficult or obtuse to design a non-UB language that doesn't allow optimizing y away.

Standard Forth, to my knowledge, doesn't have UB and consequently doesn't allow the optimization. That'd just be

    : foo CELL + 20 SWAP ! ;
there. Machine languages also generally don't have this kind of UB, and don't allow the optimization.

It's not impossible to imagine a C without UB, it's just not a particularly desirable language.

The UB permits the optimization, but is not necessary for it. If a language had a stricter definition of pointer and memory access behavior then the optimization would still be applicable if it conformed to the assumption that people are making around the UB in C.

UB is not necessary for optimization, it just permits some optimizations which may or may not be valid and may or may not be consistent across compilers and platforms because people get to fill in the gaps with whatever they want.

Parameters don't really work the same way in Forth. A similar C program would explicitly pass in an entire stack every time it runs a function, or have a global stack variable. And such a C program would disallow the optimization whether or not you consider UB.

Machine language means you have basically no rules imposed on your code at all. So it's true that you can't optimize anything, but I think that's outside the scope of normal language talk.

In my example, the variables are assumed to be addressable, only the address of x was passed via the stack.

Dunno what you mean about having no rules imposed on your code -- you can define the semantics of the language formally and execute them on a machine, same as you can for C, JavaScript, or Prolog.

> In my example, the variables are assumed to be addressable, only the address of x was passed via the stack.

Yeah but I could add 3 billion to the pointer instead. Now what happens, if we're trying to define behavior?

> you can define the semantics of the language formally

If the semantics aren't a direct translation into CPU opcodes, then it's not machine code. And CPU opcodes can access all memory at all times including self-inspection so you can't change anything for optimization purposes without an additional framework on top.

> Now what happens, if we're trying to define behavior?

The cell whose address is three billion greater, mod the size of the address space, now has a value of 20?

> If the semantics aren't a direct translation into CPU opcodes, then it's not machine code.

Just because the semantics are defined that way doesn't necessarily mean you couldn't make another implementation that obeys those semantics. For instance, QEMU can perform optimizations on the code it runs, and invalidate them if the program changes those instructions. The semantics of reading from and writing to memory are unchanged by these optimizations, and the implementation doesn't need to do anything special on read.

> The cell whose address is three billion greater, mod the size of the address space, now has a value of 20?

Cool. It sounds like you figured out a restricted form of pointers for stack variables, since there's no danger of corrupting the code or breaking important invariants elsewhere.

Now imaging tightening those restrictions a bit. Instead of wrapping pointers inside the entire stack, wrap/constrict pointers inside their original variable.

Now you have a language with no UB that allows the optimizer to replace y with 42!

TL;DR: If you let pointers go hog-wild, your language has UB even before you consider optimizing. You don't need to add UB to allow optimizations. If you don't let pointers go hog-wild, in a language without UB, then you can enhance those restrictions to allow the y->42 optimization while still not having UB.

> For instance, QEMU can perform optimizations on the code it runs, and invalidate them if the program changes those instructions.

The machine can take shortcuts, and when QEMU is acting as a virtual machine it can do that. But the compiler can't take any shortcuts, because it never knows when external code is going to examine random bytes and need them to be unchanged.

> Now imagine tightening those restrictions a bit.

Yep, that's one approach, though that's just as hard to compile as one that is defined to trap and end execution on an out-of-bounds write.

As I said up-thread, it's possible to define a C dialect without UB, it's just not what most of its users actually want out of the language, since on current hardware it adds significant runtime overhead.

> The machine [...] the compiler

Isn't QEMU both the machine and the compiler (EDIT: perhaps better-put, what's the distinction? They're both just the implementation.) The bytes only need to be unchanged on read from inside the program; if you compile them to something else, that's fine. Prolog programs can introspect with clause/2 and it doesn't cause trouble there.

> Yep, that's one approach, though that's just as hard to compile as one that is defined to trap and end execution on an out-of-bounds write.

> As I said up-thread, it's possible to define a C dialect without UB, it's just not what most of its users actually want out of the language, since on current hardware it adds significant runtime overhead.

So my core point is this: It's not UB that enables this optimization. When you ask "how can that optimization be legal without UB?", the hard part is "without UB" all by itself. If you have a language with UB, the optimization is easy to enable. If you have a language without UB, the optimization is easy to enable. That optimization is not an example of why we need UB.

There can be significant runtime overhead to remove UB, but it's not in service of enabling that optimization.

> Isn't QEMU both the machine and the compiler (EDIT: perhaps better-put, what's the distinction? They're both just the implementation.) The bytes only need to be unchanged on read from inside the program; if you compile them to something else, that's fine. Prolog programs can introspect with clause/2 and it doesn't cause trouble there.

Basically, I don't think "The bytes only need to be unchanged on read from inside the program" is true. If you're compiling machine code you're not in charge of the entire computer. The code might depend on other code looking at the bytes, and you won't be able to intercept that unless you do some kind of ridiculous rootkit takeover when the compiled program launches, creating a virtual machine and moving everything that was already running into it. And I don't just mean that in a theoretical gotcha sense, real libraries sometimes need to alter function calls in other libraries.

> that's just as hard to compile as one that is defined to trap and end execution on an out-of-bounds write

I believe simply disallowing "+" and "-" from accepting pointer types is slighlty less work than writing logic for "integral + pointer", "pointer + integral", "pointer - integral", and "pointer - pointer". Although on the other hand it means that instead of literally rewriting "a[i]" to "*(a+i)" in the AST, one has to write a separate logic for array accesses so... okay, so it's exactly as easy to compile ("as hard" technically works as well, but since it's actually rather easy, not hard, I've chosen "as easy", hope you don't mind).

The biggest misunderstanding people have around UB is—in my experience—the (often) subconscious assumption that UB optimization is around optimizing the performance of code that exhibits UB. It is not.

You're partially correct. foo() won't have any access to y so won't be able to modify it… unless foo() is written in way that exploits undefined behavior. Compilers can (and should) assume that foo() is not written pathologically in a way that writes into the frames of functions higher in the call stack, and so can make the above optimization.

Y is never visible from foo()s scope. You'd have to employ ABI specific stack manipulation to get to it. Optimization in the outside frame doesn't depend on foo().
And that ABI specific stack manipulation would be UB, so the optimizer can assume it doesn't happen. With no UB, that's not true.
You assume a langue without UB would allow stack manipulation. But then it would have to be defined. How would you possibly define it fully?
There's an area of memory into which variables with automatic storage duration are allocated at implementation-defined addrs, at any point exactly those variables have addresses.
> at any point exactly those variables have addresses

That doesn't sound like it lets me increment any stack address I want and store into the resulting pointer.

And are return values still on the same stack? I probably should have said I meant the traditional kind of C stack.

> That doesn't sound like it lets me increment any stack address I want and store into the resulting pointer.

Why not?

> And are return values still on the same stack?

Other implementation-defined things might have addresses too, those things just aren't variables. I think you might be able to still allow inlining without UB if you make it implementation-defined per call-site what other things might get addresses.

I'm okay with undefined behavior, but the language should revert back to the original (an exhaustive list of permissible behavior by the host when the C abstract machine's behavior is undefined) rather than the current mere list of examples of possible behavior by the host.
"I'm okay with undefined behavior, as long as its behavior is well-defined."

You keep using that word. I don't think it means what you think it means. :)

A behavior like "corrupts whatever is in memory at that location" when your code screws up a pointer is not exactly "well-defined", but it does disallow "pretends it could have corrupted RAM and does whatever it feels like instead".

To put that another way, you can treat certain behaviors as if they're opaque functions until after you're done optimizing. Similar to how you might handle volatile.

Some of these changes would be very easy. For example, instead of treating "dereference zero" as unreachable code, treat it the same as "dereference unknown location".

Your platform almost certainly does do something sane and reasonable for UB-exhibiting code.

Where your problem lies is what happens when you optimize that code. And guaranteeing optimizations do something superficially sane on unsound code while still performing reasonable and obvious optimizations for well-formed code is insanely hard.

The fundamental problem is that the design of C guarantees that lots and lots and lots of syntactically valid and intuitively written code is semantically total nonsense. Part of this is that C is low-level and part is just that it was designed 50 years ago before anyone had the faintest idea what kinds of terrible mistakes were being made.

If there were simple fixes here it wouldn’t still be a problem.

> Where your problem lies is what happens when you optimize that code. And guaranteeing optimizations do something superficially sane on unsound code while still performing reasonable and obvious optimizations for well-formed code is insanely hard.

It really depends on the type of optimization.

A lot of the scariest results come when the compiler treats code as dead, and in very many cases you could go ahead and emit the code anyway and performance would be fine.

> If there were simple fixes here it wouldn’t still be a problem.

Nobody is trying to reduce the scope of UB, though. Even things that are very easy to define or implementation-define like syntax errors or left shifting with certain parameters are still sitting on the list.

So, which behaviours should be allowed for this code?

    void (*f)() = (void (*)())rand();
    f();
The three enumerated options in the ANSI C standard and no others, especially not invisibly modifying non-dead code during translation.

The compiler could behave in a documented manner with or without a warning; it could error out and stop translation; or it could do what you fracking told it to, consequences be damned.

So it should never do function inlining, or replace multiplication by two with a bit shift? Because both of them quite fit "invisibly modifying non-dead code during translation".

> or it could do what you fracking told it to, consequences be damned

Yes, that's precisely what happens. The point is agreeing on what you "told it". The agreement stipulated in the standard is that in some cases (when you hit UB) you're effectively not agreeing on anything, i.e., the compiler can do what it want.

If that's not what you like, please let me know what should be mandated in the case of the fragment I wrote above, and how a compiler could offer that guarantee in an efficient and effective manner.

As to your first point: fair. It shouldn't modify it in a semantically material way.

> that's precisely what happens

No, it really isn't. Numerous compilers do static analysis to identify code that could be UB and then mangle it to mean something entirely different or eliminate it entirely. And if the compiler really did already do this, you wouldn't need to resort to...

> the compiler can do what it want[s]

Under current standards, yes, but that's a circular and nonsensical argument. You can't base your argument on what the standard permits when the very content of what the standard should be is the topic of discussion.

> please let me know what should be mandated

I already responded. The ANSI C standard is clear, and the modification to that language in subsequent ISO standards is, in my opinion, ill-advised.

On the contrary, I don't think C would have been so ubiquitous without it. People use C for maximum speed and efficiency. When maximum speed isn't a requirement, there are plenty of more productive and less footgunny languages to choose from. Without the things that C allows to be ignored during optimization, you either wouldn't have compilers that optimize the "simple" language equally well, or you'd have to make the language bigger to give all the right hints and guarantees where they're needed.

I see UB commonly characterized as compiler being mean for silly reasons, with implication that compilers could just stop doing the bad UB and do the good UB, but that isn't accurate or realistic view. The creeping UB is just a symptom of how optimization passes are implemented, and they would be exponentially harder to implement equally well without the concept of UB. "Just do what I mean" is not a well-defined compilation target, and there are tons of surprisingly bad performance issues. e.g. without signed overflow indexing of arrays by int could not use 64-bit CPU's addressing modes, because they overflow differently. You can use size_t, but good luck being diligent about it in a language that loves its ints.

No. The mistake is conflating "undefined behavior" with "unspecified behavior".

Signed overflow was not intended to be undefined--it was unspecified because it has a perfectly acceptable specification that differs between processors, and it was expected that the compiler would define it. The fact that gcc and clang pick non-sensical specifications in order to gain 3% in performance was not something anybody expected back when this stuff was written.

"Division by zero" is, on the other hand, genuinely undefined behavior. "Dereferencing a NULL pointer" is a genuinely undefined behavior. There generally isn't a good way for a program to continue after encountering them.

> Signed overflow was not intended to be undefined--it was unspecified

This seems wrong to me. The Standard very much calls signed overflow undefined behavior (Section 3.3):

> If an exception occurs during the evaluation of an expression (that is, if the result is not mathematically defined or not representable), the behavior is undefined.

In addition, overflow is explicitly listed as undefined behavior in at least two other places. For example, in section 1.6, Definitions of Terms:

> An example of undefined behavior is the behavior on integer overflow.

And in the list of undefined behavior (A.6.2):

> An arithmetic operation is invalid (such as division or modulus by 0) or produces a result that cannot be represented in the space provided (such as overflow or underflow) (3.3).

If signed overflow was not intended to be undefined, then it seems like a rather large oversight to say it's undefined behavior and to give it as an example of such.

> because it has a perfectly acceptable specification that differs between processors, and it was expected that the compiler would define it.

What you're describing is implementation-defined behavior in the C standard. If it was expected that implementations would define the behavior, why didn't the committee just... use that definition, instead of saying signed overflow is UB but actually meaning it's implementation-defined behavior?

In addition, unspecified behavior is a third category of behavior distinct from undefined and implementation-defined behavior. The Standard imposes no requirements on unspecified behavior in a correct program, and does not require that an implementation document what it does in such a situation.

[0]: https://port70.net/~nsz/c/c89/c89-draft.html

UB is not a bug, it's a feature.
The declaration syntax and the lack of a proper module/namespacing system is also a mistake, but perhaps not as big as the way arrays decays into pointers. Implicit conversions I suppose could also be classified as a mistake.
What you're calling "mistakes" look to me like intentional design decisions made in order to optimize for specific kinds of problems.

C is not a high level language, nor should it be. If you're doing the sort of work that is better suited for a high level language, then using a different language is the right call.

Well, IIRC even in the K&R book, a book which many C programmers admire for it's excellent documentation and prose, and rightly so, there is a specific section mentioning that C's declaration syntax has been castigated.

Next, even if C is not a "high level language", which depend on your definition of "high level language", the perks of a proper module system for low level coding cannot go unmentioned.

- Rust, a language that lives in a somewhat similar abstraction and power space as C, has a module system.

- C++, a language that lives closer to C in features, also now, in C++23 I believe, has a module system, and before that, had namespaces.

On implicit conversions, I think having implicit conversions are hard to get right, and I think that C has them is not a feature, but rather a bug. Languages that have taken some inspirations from C, has chosen usually to do away with implicit conversions, or at the very least, limit their use.

And the array -> pointer in functions, I percieve to be a big mistake, not at the time, perhaps it was not known better ways, but as the years roll by, I think it's a misfeature given the countless exploits that have taken place due to a read past the bounds of an array.

I think C is a good language, but these misfeatures burdens the language in an undue way, and I think a better way to design a language is to be more explicit. Something like Rust's

    unsafe { /* Here be dragons */ }
is better design, in my opinion. I think if you want implicit conversions, there should be an explicit way to declare that you want things to be implicit.

That's just my 2¢

I don't entirely agree with this article ... it's quite possible to "pass" an array as a pointer to an array type, as opposed to a decayed pointer, e.g.

  void f( int (*a) [5] );
  int a[5] = {1, 2, 3, 4, 5};
  f( &a );
The larger problem here is more that this mechanism doesn't support arbitrary sizes. Which probably makes sense from how c handles memory, but it's very restrictive.

Incidentally, I recently had an argument with a friend who had been "coding for years" in c, that the memory address of "a" and "&a" in the example above are identical. He thought it was so obvious I was wrong (because pointers) that I had to code it and print the addresses for him and prove they're the same. He was completely mind-blown xD

You can’t pass an array by value and you can’t pass a pointer with a corresponding length of non-fixed size (you can actually with VLAs, but frankly the syntax sucks and not all compilers support it). This missing language feature has led to horrible things like nil-terminated strings , a million bespoke ways to encode the length of the array, or just functions that pray you have enough space like `strcpy` and `gets`.
> You can't pass an array by value

Sure, but, this is the case in all languages, no? Otherwise, what should be the convention? A deep copy? A first-degree shallow copy? You simply can't avoid the referential nature of arrays for use as arguments to functions. So the issue here shouldn't be the referential nature of this, but the fact that they chose to implement it as a "decaying" pointer as opposed to a "pointer to array type of a certain size".

And hence why I'm pointing out there is, in fact, a (relatively simple) way to do this, but only for fixed sizes. But the reason it's not THAT useful in practice is because, in general, you probably wouldn't want the function's parameter to expect a fixed size array, but you'd probably want the flexibility of a variable-sized array, whose length is defined at runtime. And this is what is not supported by c.

So in some sense, in the absence of support for variable-sized array parameters, then not expecting fixed array sizes at the point of defining function parameters, and using a decaying pointer instead (which simply doesn't care about the size), is probably the lesser evil of the two. So for me the 'bigger' evil isn't decaying pointers, but the lack of support for VLAs as function parameters. This would have allowed you to use the above syntax to pass a pointer to an array type, retaining full information about the pointed array, including its size.

(in other words I think we're saying a very similar thing but with different words)

I have 2 copies of this book, one that we keep at my work office and one I bought for myself. It's a good book!
It looks like an introductory C programming book.

I was hoping for it to be a "hello experienced C programmer, here's new stuff that's happened in the past 20 years" kind of thing which would be useful.

I think most people have a hard time keeping up with the progress of all the technologies they use unless they've got a pretty narrow focus.

For instance, I learned make in the 90s. I should probably go read up on what's happened in the past 25 years or so. Probably lots of cool stuff.

Edit: it looks like just reading the release notes is probably sufficient. There's indeed lots of interesting new things

There are some good books to give you that kind of delta for C++11/14/17/20.

  Modern C++ for Absolute Beginners
  Modern C++ Programming Cookbook
  et al.
I'm actually starting to like C++ again. Weird.