In every HN comment section about superior alternatives to C, or about C's pitfalls, you'll reliably find 2-3 staunch defenders of C's supposed simplicity, wielding arguments that ultimately boil down to "you're using it wrong".
C is simple. C is easy to write. C is very easy to read.
Is it easy to write programs in C? No. But C makes damn sure that what you wrote is what will get executed. Unless you like playing smart, like the guy in the opening post.
C is sadly filled to the brim of footguns, and C++ inherited them all and added some more of their own. A lot of C shenanigans are very likely to directly stem from B, such as the crazy integer promotion rules.
C is simple in the sense that it is very possible to have the whole language "in your head" and at one point in my career I did. Something I would never claim about C++. Yes, you can write atrociously bad C code that is hard to parse and hard to predict, as the webpage demonstrates. All this can be trivially avoided by having the most basic coding standards. And yes I'm sure that some of the modern alternatives to C are better. But all in all, C is simple.
> Undefined behavior means that which is not defined in the standard.
No, you're wrong. "Undefined Behavior" (UB) in the context of C has a very different meaning from the colloquial meaning "not specified". The meaning of UB is explicitly defined in the C standard, and the C standard explicitly lists certain program execution patterns as UB.
> If a ‘‘shall’’ or ‘‘shall not’’ requirement that appears outside of a constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this International Standard by the words ‘‘undefined behavior’’ or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe ‘‘behavior that is undefined’’
This is just a very particular case of calling a function with such arguments that the result will depend on the order of evaluation for the arguments.
This is an undefined operation in almost all programming languages.
Does the first example contain undefined behaviour? (As written it does because x is not initialised, but the text suggests that a value is actually being provided for x)
I think the second example may contain UB on a <=32-bit architecture (right shift by a value greater or equal to the number of bits), or at least this is UB in C++. On a 64-bit architecture it would be fine (but the result would not be 0).
Why? Because section 5.2.4.1 requires implementations to support at least 32 significant characters in external identifiers and section 6.4.2.1 says "If two identifiers differ only in nonsignificant characters, the behavior is undefined."
These ones may be more "obvious", but:
Given e.g. int a[4][5] then accessing a[1][7] is undefined even though one might assume it's like accessing a[2][2]
x << -3 is undefined behaviour though logically one might assume it should be equivalent to x >> 3
And the classic, very common newbie use of "fflush(stdin);" is also undefined.
> x << -3 is undefined behaviour though logically one might assume it should be equivalent to x >> 3
Not saying anything about the other things, but this (in my opinion) is something that should raise eyebrows. At least to me personally that looks rather weird.
"The execution of a program contains a data race if it contains two conflicting actions in different threads, at least one of which is not atomic, and neither happens before the other. Any such data
race results in undefined behavior"
This feels like one of those “you’re a fan of X? Name every Y” memes. If someone has some examples that a reasonable person would write, specifically examples that look like the simplest way to do something, then I’d love to hear them. Negative shifts and infinite loops with counters are just not things you normally encounter.
> This feels like one of those “you’re a fan of X? Name every Y” memes.
How can you identify legal C without understanding undefined behavior? Or rather, what is the list of UB that nay professional C developer should be expected to know?
> If someone has some examples that a reasonable person would write, specifically examples that look like the simplest way to do something, then I’d love to hear them.
Basically all OOP-style C. For instance, the Windows macro
The Linux kernel is also an excellent source of these.
Also, serialization/deserialization libraries are an excellent source for UB. And huge amounts of code depends on UB by using unsigned char arrays for storage of other types.
Furthermore, in practice, C code assumes most of the following:
- null pointers are represented as 0, e.g. if(ptr).
- pointer provenance does not exist
- char and unsigned char are 8-bit bytes
- two's-complement arithmetic
- page-based memory protection
- no types have trap representations
- the values of base types, besides floating point, do not have multiple representations.
> what is the list of UB that nay professional C developer should be expected to know?
Off the top of my head, as a non professional C programmer:
- can’t read from uninitialized memory
- one malloc = one free
- signed overflow is undefined
- pointers to stack variables can’t outlive the stack variable itself
- can’t dereference NULL pointers
(Obviously take this with a massive grain of salt, there are almost certainly important ones that I’m forgetting.)
> null pointers are represented as 0, e.g. if(ptr).
If(ptr) is guaranteed by the standard to work. Memset(ptr, 0, len) is not (and is very broken in other ways as well).
> char and unsigned char are 8-bit bytes
They’re guaranteed to be one byte, where a byte is at least 8 bits. So unless you’re trying to simulate %256 with an implicit conversion, or relying on unsigned overflow to turn 0xFF into 0x00, then you’ll be fine.
If I were to write C, I'd probably come up with my list of "The bad parts" (like the js book), and not use those (and agree with my team not to use those).
Like in JavaScript, you can understand and remember how double equals work, if you try hard enough... Or you can just decide to always use triple equals and voila, your problem is solved once and forever.
But I don't write C, so take this with a grain of salt.
If it were easy to avoid "the bad parts" of C, there would be a lot fewer exploits based on memory corruption. Many of the bad parts are unfortunately integral to the language. Only the strictest coding standards have a chance to avoid all the bad parts, but it's not generally possible to automatically check for compliance with all rules of, e.g. MISRA, so you still have fallible humans in the loop.
I would assume you would claim you have English "in your head"... can you name every word in the top 5,000 words that has a "z" in it? If English is not your first language, than convert to an appropriate challenge in your native language.
We aren't computers, and that's not what we mean by having things in our head.
But that's the problem with C. It looks like a simple language with a small number of straightforward constructs, when in fact it has very poor predictability.
Real-world results are implementation and platform-dependent, which is a polite way of saying they're unhelpfully unpredictable.
Coding standards will help, but they only get you so far because some ambiguities will always be there.
Basically C is a half-assed wrapper around assembler - useful in its day, but machine-dependent and outdated now.
A lot of modern languages sacrificed simplicity for features no one (with enough skill) asked. Even with Python, I can only keep some part of built-in functions and methods together with syntax in my head.
For a large part of standard library, I absolutely need IDE features. Especially since Python is an object oriented language where every data instance has a bunch of methods attached.
I have been coding some time in C (and some of that doing kernel drivers), and I don't think I've ever found "unhelpfully unpredictable" behavior in regular code. The "coding standard" that gets rid of those is "if it looks weird, it goes away". I've seen quite a lot of talk about the problems of undefined behavior but I haven't actually faced those problems in the wild.
You were the one asserting that it only happens in sweetshops, hence my remark wasn't valid, thus I decided to broaden the scope of people failing to write C.
Those mythical C developers that are yet to be found after 50 years searching for them.
Using C is a tradeoff. I personally wouldn't write unsandboxed mission-critical code that has to interpret untrusted remote user input in C. But for anything that's not that, the freedom it gives over everything is awesome, and it makes many things possible to do trivially that would be nigh impossible in most other languages (at least not while keeping around any of their properties of safety).
But, it is, in fact, possible for good developers to write incorrect C code. Noone's claiming that's not the case. It's just a lot less likely for someone who actually knows the language and the codebase.
In the embedded world where there are a lot of C compilers, it's not so much the language you keep in your head but the compiler implementation of the language.
I'm in the process of moving an old codebase from a compiler with a very loose adherence to an old specification to an ARM chip that is using GCC for the compiler. Enabling -Wall to catch all issues that the compiler highlighted, 16-bit ints to 32-bit ints, and quite a few ternary operator issues.
None of these were issues on a system that's been running in production for many years but porting to a new architecture will mean there’s a lot of extra testing to be done just to make sure we catch all the edge cases in the code.
I like the C language and have been using it for close to 30 years now. The main part of the language can be kept in your head but there is so much more to working with it than just the base language.
It's almost completely useless without libraries. It's probably a tough call whether to just write assembly language instead at that point, if you have a nice assembler and know how to use it that might be more productive than C with no libraries.
Much of the stuff programmers are just used to being "part of C" is actually from a library. For example NULL is just a macro that some library provided, C's "null pointers" are just the literal zero for a pointer, even if your hardware does not represent "null pointers" by an all-zeroes value.
Well, an allocator is something your system might just not need. Rust doesn't come with an allocator either, if I only have say 16384 bytes of RAM maybe I don't want an allocator, I can write down what I'm using each byte for.
But C doesn't come with functions like memcmp() and memcpy() or memmove() either and those are pretty fundamental even if you're on a tiny constrained system that can't aspire to stuff like "strings" and "allocation" you've probably heard of data structures and copying.
But the thing is so many things are mighty inconvenient to do in C. Every large program in C has to build up lots of fundamental infrastructure like a library of data structures, and mechanisms for error handling.
Why should one use C and spend a lot time on building a basic platform, rather than spending time, effort and creativity on the core concerns.
Not to mention the chances of so many things to go wrong, in writing C code. Its like climbing a mountain without a safety gear.
If those utilities make up an excessively large portion of your program, C probably isn't the best choice. As long as you don't care about performance lost from the non-zero costs of "zero cost abstractions".
If you're implementing custom data structures, you'll likely be able to make them optimal for your use-case, both from ease-of-use and performance. (except hashmaps. those do suck to write)
Hashmaps are often overrated. I can count on my fingers how many times I used dictionaries in Python. Pretty much anything you can do with dictionaries can be much more cleanly done with classes and other built-in features.
Membership testing? Use sets. Data storage? Use classes with slots or tuples/namedtuples. Custom switch statement? Use the new match/case keywords or just fall back to plain if/elif towers.
It definitely depends on the project. There are plenty of domains where hashmaps are useless, and some where half the things you do need one, and many in between. The in-between is the worst - if you need a hashset/hashmap just once, you'd still have to suffer through implementing one (O(n^2) is just not acceptable). But there is a bright side to this - hashmaps are pretty inefficient if a list could do, so the more you're pushed against using one, the more likely you are to have faster code :) (as long as you don't go the O(n^2) route, that is; also, hashsets are equally hard to implement manually as hashmaps)
The cost of removing an impedance mismatch between other peoples's code and your code base is inversely proportional to the scale of your code-base. If you have a 10 line program, use the standard libraries. If you have 10 billion lines of code, write your own language. Everything inbetween is a trade-off between comfort and effort.
The most K&R have to say is that "C is not a big language, [and it is not well served by a big book]".
A big problem for the analysis this old project attempts is that C makes no effort to clearly define any circumstances it doesn't care about and yet many such circumstances are likely to arise in real world programs. I would consider this a defect in a modern language, but of course its proponents disagree.
Well, the answer to that is of course that C is not a modern language, it's already decades old. Of course there were subsequent versions of the standard that modernized it somewhat, but there are only so many things you can do if you still want to call it "C". And I don't think most projects that still use C (e.g. the Linux kernel) are blind to this, but they had to continue to use it because of the lack of alternatives (that were suitable and had a big enough community). Maybe Rust will be able to establish itself as an alternative, now that it's officially supported for Kernel development?
> The most K&R have to say is that "C is not a big language, [and it is not well served by a big book]".
+1. I think the simplicity of C (and also Unix) was a great idea when it first started, it allowed so much productive explorations and works to be done in a short amount of time. Unfortunately, after decades of development, programs and systems were getting increasingly sophisticated to the point that the original design was not prepared for. Then one starts to suffer from a myriad of design limitations, and people start adding complicated extensions and workarounds to the existing platform, and eventually you get something that is not simple at all.
At this point, the simplicity became a shadow of its former self.
For example, in C, nobody could tell exactly how many undefined or unspecified behaviors exist in a program. Historically, every major optimization technique in a new compiler releases would uncover some unintentional undefined behaviors and would break existing programs. Recently, to solve this problem, UndefinedBehaviorSanitizer has been developed, which is technically a super complex tool. In the same way, the memory safety problem led to the creation of stack canary, NX, ASLR, PIE, CFI, AddressSanitizer, large-scale automated fuzz testing, none of them is simple. Arguably, all of these complexities can be removed if the programming environment itself has a better memory model.
For another example, C stdlib's minimalism caused many attempts to create a powerful standard library. While C++'s STL and Boost are notorious for their all-encompassing features, but arguably the recreation of a homegrown library in all C projects have made them more complex. Not to mention the C stdlib's minimalism is also a shadow of its former self - people claim one could essentially internalize all the functions, well this could be true for ANSI C, but how many people know that C99 has native support for complex numbers? Or how C11 added multithreading?
The Unix situation is similar. When it got popular enough, people started to add more and more features and extensions to satisfy their needs. Nowadays everyone is saying that X is bloat, but if you're a retrocomputing fan and look into the history, you'll see that this has already been the trend ever since the popularization of Unix. It has already began during the early BSD development at Berkeley - students added tons of command line utilities and optional arguments which was heavily criticized by Rob Pike. During the Unix Wars, various vendors also packed lots of features often in a hurry and in a manner that lacked consistency, which was subsequently adopted by everyone because POSIX was the peace agreement. When 4.3BSD started supporting POSIX, ironically, this time it came under attack by some as moving away from the BSD philosophy of simplicity! Later, GNU also added tons of features to the system utilities, sometimes explicitly went against Unix's design to address its perceived limitations (GNU's Not Unix, after all). Eventually there are Linux kernel and systemd, which clearly do more than what a kernel or a service manager is supposed to do. Linux kernel even has Transport Layer Security now!
Most of the feature expansions in C or Unix really have useful purposes and solves real problems in spite of the claims of "bloat". The problem is you have an aging foundation with a false pretense of simplicity, and when extensions are added to meet the demands, they create a lot more complexities in real applications, and the "simple" system with extensions is actually more complex than a "complex" system with a more elaborate design.
Don't get me wrong. I still find the C Programming Language is an excellent work of technical writing, its concise and minimalist style has its own elegance. Also, I still like The Unix Programming Environment as described by Rob Pike, it's revealing how much useful work can be done flexibly in pipes and shell scrip...
> I still find the C Programming Language is an excellent work of technical writing, its concise and minimalist style has its own elegance.
Probably the best non-fiction book I own, Ambrose Bierce and Randall Munroe's books are funnier, my X references are more useful as monitor stands, and my sister's book about cloth (she's an embroiderer) is much prettier, but K&R is the best book.
Perhaps it's notable that my "Student's Introduction to English Grammar" by Huddleston and Pullum is of similar size to K&R. Of course, Huddleston and Pullum merely happened upon English's Grammar already fully formed, which they can only try to explain in retrospect, whereas Ritchie wrote the first C compiler.
The thing I love about nearly all programming languages is that data can just be data and functions can just be functions. This is true even in Java and Javascript if you discipline yourself, and in C too if you avoid macro abuse.
When people say C is simple, they are referring to its conceptual simplicity. This gives people confidence that they have mastered the language. However, there may still be a number of corner cases of the language like undefined behaviors. This is why people frequently fall into C's traps.
In general, I believe conceptual simplicity is beneficial for its wide adoption. Nobody wants to learn a language that makes them feel stupid.
> However, there may still be a number of corner cases of the language like undefined behaviors. This is why people frequently fall into C's traps.
I don't think that's true; the UB that is relevant for day-to-day programming is easily learned.
It's just that a sucker is born every minute and there's always someone who's been taught that C is a high level assembler. Now and then someone slips a piece of code that assumes as much in a project and disproportionately big noise is made about it.
Most UB-related bugs in C code are not there because the author didn't know it's UB, but because they simply coded it wrong. People fall into simple buffer overflows, double frees, etcetra all the time and no-one thought it's ok to overflow a buffer.
Right. All this buzz about UB is a bit much. Sure, it's not great to have to assume that something breaks when you upgrade your compiler when the compiler authors made a change to exploit some UB that I didn't know of.
On the other hand, I'm quite confident that I don't have much UB in my code (as running sanitizers from time to time confirms). And miraculously, I've never ever been surprised by "nasal daemons".
No, the problem of undefined behaviour isn't overstated. It's the reason C and C++ have such a poor security track-record.
> I'm quite confident that I don't have much UB in my code
Plenty of C and C++ programmers are confident they don't have UB in their code, and yet we see a constant stream of security issues arising from UB in codebases developed and maintained by the smartest and most motivated C/C++ programmers, such as kernel code and the Chromium codebase.
From the Chromium project: [0]
> Around 70% of our high severity security bugs are memory unsafety problems (that is, mistakes with C/C++ pointers). Half of those are use-after-free bugs.
These bugs are, of course, undefined behaviour. These are serious bugs that presumably made it through their code-review and code-analysis processes.
(Chromium is written in C++ rather than C, but the point stands.)
> running sanitizers from time to time confirms
It doesn't. Modern static analysis tools are pretty smart, but they're a long way from being able to detect every instance of possible UB, with no false positives. If they were, we'd be in a very different place.
With the current state-of-the-art, the only way we have of developing C/C++ codebases that are free of UB, is to use formal methods (e.g. SeL4). This approach is very rarely taken, on account of the impact on development speed, and the skill needed.
I feel you've missed my point. I'm not saying that there are no exploitable or otherwise dangerous bugs. Of course there are, but the behaviour usually comes from obvious logic bugs and the consequences are pretty much as expected - e.g. memory gets corrupted as a consequence of an out-of-bounds write, and then almost anything could happen. And while this is "undefined behaviour", it is not what people mean when they complain about UB.
> I'm not saying that there are no exploitable or otherwise dangerous bugs. Of course there are, but the behaviour usually comes from obvious logic bugs
Difficult-to-detect undefined behaviour is a significant problem with C and C++, otherwise it wouldn't be a major cause of serious security vulnerabilities in well-resourced, high-profile, security-sensitive C/C++ codebases.
There may also be many instances of easily-detected undefined behaviour that exist only because of sloppy software development. There may also be many instances of undefined behaviour that are relatively benign.
> and the consequences are pretty much as expected - e.g. memory gets corrupted as a consequence of an out-of-bounds write, and then almost anything could happen.
I don't think I'm seeing your point. We agree that undefined behaviour can have serious consequences.
> while this is "undefined behaviour", it is not what people mean when they complain about UB
I don't follow. Undefined behaviour is an unambiguous term of art in C and C++ programming. There are plenty of common misconceptions about UB, sure enough, but the term itself is precise.
> Difficult-to-detect undefined behaviour is a significant problem with C and C++, otherwise it wouldn't be a major cause of serious security vulnerabilities in well-resourced, high-profile, security-sensitive C/C++ codebases.
Do you mean difficult-to-detect but "obviously" bugs that can lead to UB, like buffer overflows that only happen in rare circumstances (like with unsanitized input)? Or do you mean difficult to detect unexpected "miscompilations" by the compiler based on some UB that is non-obvious to most programmers and/or not well-known?
Because I was referring to the latter, and in my perception most talk about UB is. I haven't seen the latter happen myself, and I haven't read that many horror stories where this actually happened.
For better or worse, I'm not angry with the compiler if I code a logic bug and corruption happens. That's just what I expect.
Maybe if you're indeed saying the latter is a "major cause of security vulnerabilities", could you provide a few examples where it's the language's or compiler's "fault"? I can see that the line is not well defined here of course, because technically it's all just UB - but the distinction really was my point, which I made from a practical perspective.
> Do you mean difficult-to-detect but "obviously" bugs that can lead to UB [...]? Or do you mean difficult to detect [...] UB that is non-obvious to most programmers and/or not well-known?
I meant both, as I wasn't drawing this distinction.
> I haven't read that many horror stories where this actually happened
You may be right that most of the problematic instances of UB are doing something that's clearly not right even to inexperienced programmers, but again I don't see much value in making the distinction. I mentioned the Chromium stats earlier. Those security issues are real. Many of those issues presumably wouldn't happen in a language that prevented UB.
(It's not necessarily the case that all the issues would have been avoided, as it's possible a bug might still have manifested, but in a well-defined way. A UB-invoking memory-management bug might correspond to a failure to reset a data-structure, in a safe language. Only the former invokes UB, but both could have security consequences.)
> For better or worse, I'm not angry with the compiler if I code a logic bug and corruption happens. That's just what I expect.
You expect it because you know the C and C++ languages don't offer robust means to avoid undefined behaviour. This doesn't generalise to other languages.
In C and C++, silent data-corruption can occur if you try to write into an array but go out-of-bounds. That doesn't happen in many other modern languages. Unlike Ada, C doesn't even let you enable robust runtime bounds-checks for your debug builds. (To my knowledge no C compiler is able to offer this feature.)
> Maybe if you're indeed saying the latter is a "major cause of security vulnerabilities", could you provide a few examples where it's the language's or compiler's "fault"?
I wasn't drawing the distinction at all. I'll use an example from the plainly erroneous category.
Use-after-free and double-free errors are both undefined behaviour, in C and in C++. In Safe Rust, they cannot ever arise because of the way the language is defined, so the door is closed on those forms of runtime error. We don't need to talk about blame.
Memory-management issues of this sort are precisely the kinds of issues that affect Chromium. Not even Google can keep undefined behaviour out of their most high-profile C++ codebase. (In fairness though, the codebase would likely be quite different if written from scratch today in modern C++.)
On the not plainly erroneous side: a subtlety that can result in security consequences is the elision of a memset call by an optimising compiler which can result in sensitive data not getting zeroed out before deallocation. This isn't actually UB, but it's a good example of 'language lawyer' trouble with C/C++. [0]
As an aside: I once spotted a competent C++ programmer (vastly more experienced than myself) writing code which applied memcpy to an array of non-POD objects. If you'll forgive a painful mix of metaphors: C++ is large enough that even experienced programmers can have blind spots to its dark corners.
> In C and C++, silent data-corruption can occur if you try to write into an array but go out-of-bounds. That doesn't happen in many other modern languages. Unlike Ada, C doesn't even let you enable robust runtime bounds-checks for your debug builds. (To my knowledge no C compiler is able to offer this feature.)
Valgrind offers bounds checks with malloc() allocations and probably other allocations, and while I don't have extensive experience with valgrind it works surprisingly well. I imagine runtime checks could be made possible for any allocator by offering a slice(ptr, type, count) builtin call. I don't really see the language causing any problems, it's more what compiler output and optimizations we've come to expect.
> Memory-management issues of this sort are precisely the kinds of issues that affect Chromium.
They're just what a C programmer expects. I'm not saying you have to like the outcome but this is not "UB" for me - in the sense that it's been utterly obvious from day 1 that this type of issue can cause corruption (because how would it not?), long before there was any talk about UB.
> As an aside: I once spotted a competent C++ programmer (vastly more experienced than myself) writing code which applied memcpy to an array of non-POD objects. If you'll forgive a painful mix of metaphors: C++ is large enough that even experienced programmers can have blind spots to its dark corners.
I'm not defending C++ (in fact I actively avoid having to deal with it) but I think it's obvious that this sort of code is extremely likely to break.
> Valgrind offers bounds checks with malloc() allocations and probably other allocations
Right, and Valgrind is very impressive, but it's a very intrusive and heavyweight tool, rather than a compiler flag. (I often mention Valgrind by name in these sorts of discussions, [0] we're thinking along the same lines here.)
> I imagine runtime checks could be made possible for any allocator by offering a slice(ptr, type, count) builtin call
It would presumably need 'fat pointers', which have ABI issues.
It would need to cope with statically allocated arrays, heap-allocated arrays, and arrays with automatic lifetime, including VLAs and alloca. It would also need to cope with passing a pointer mid-way into an array (something forbidden in, say, Java).
There would be considerable payoff to solving this problem, but it isn't an easy problem to solve, which is why no compiler does so.
> I don't really see the language causing any problems, it's more what compiler output and optimizations we've come to expect.
No, it's the language. The behaviour of modern optimising compilers is consistent with the way the languages is defined. The way the language is defined, and the categories of undefined behaviour that it permits, are very consequential.
I already gave the example of how arrays work differently in Ada, such that Ada compilers can easily add bounds checks for dev builds, whereas C compilers cannot so you need Valgrind, which few people use. (Well, more properly, arrays work differently in C than in just about every other language.) I could give a dozen other examples of ways C permits you to introduce serious bugs into your codebase which other languages robustly defend against.
Again, many categories of bug simply cannot occur in other safer languages, because of the way safe languages are defined. Use-after-free, double-free, signed integer overflow, read-before-write, divide-by-zero, out-of-bound access, mis-aligned access, data-races. Using a language like Safe Rust closes the door on every one of those kinds of undefined behaviour.
Recall that these are precisely the kinds of errors that result in major security vulnerabilities. This isn't purely academic. Safe languages like Safe Rust (or even plain old Java, although Java has some unsafe corners) stops those issues arising, and that means better security.
Also, as we've discussed, the way C is defined makes it difficult for C compilers to use robust compile-time or even run-time checks to detect undefined behaviour. The result is that major C codebases get delivered with undefined behaviour bugs, which are a common source of major security vulnerabilities.
> I'm not saying you have to like the outcome but this is not "UB" for me - in the sense that it's been utterly obvious from day 1 that this type of issue can cause corruption (because how would it not?), long before there was any talk about UB.
Respectfully there is no such thing as 'UB for me'. It's an accepted technical term-of-art with a clear definition. People do PhDs on this topic. [1]
You're not engaging with the points I've made about how other languages are defined in such a way as to prevent these issues arising. You seem to be focussing on how it's the programmer's fault, which isn't the point at all.
Also, operations which cause data-corruption in C aren't always this way in other languages. In Java, an out-of-bounds write (into an array) results in an exception being thrown, for instance. In verified SPARK Ada, out-of-bounds writes cannot arise in the first place.
> long before there was any talk about UB
Prior to the standardisation of the C language that may have been true in the sense that perhaps the term undefined behaviour had not yet been coined, but that doesn't have any bearing on our discussion. C was always an unsafe language.
> I think it's obvious that this sort of code is extremely likely to brea...
> Respectfully there is no such thing as 'UB for me'.
Arguing like that and then saying I'm not "engaging with the points you've made" after you've been talking completely aside the points of my OP, well... you're being a bit of a pain in the butt. I totally get your points, so let's agree that we're just looking for different things.
(Btw, a couple of days ago I did try Rust once again for a few hours, intending to convert a simple toy project to it. After a few hours of fighting the compiler, editing boilerplate files, looking for the right crates for basic Win32 interop, waiting for downloads, etc... I quit without making it work. Nothing changed in my feeling that without an extreme (or potentially infinite) investment of energy, C will continue to be more productive for me personally, for what I do - despite all its flaws).
Parsing and writing a C compiler might not be simple, but writing C is, much more than other languages. It's easy to understand what normal code is doing and how is that translating to instructions (more or less) compared to, say, C++. Even other "simple" languages such as Python have way more magic under the hood.
These examples are fun, but having written C for some time, I would not accept these into a codebase. I don't need to know how the compiler behaves with those to know that it's going to be something weird.
> It's easy to understand what normal code is doing and how is that translating to instructions (more or less) compared to, say, C++.
I don't think it is meaningfully harder to understand translation into assembly between C and C++. Sure, you'll have to learn what a vtable is, and possibly think about exceptions, but if you have debugged some C++ assembly a few times, there shouldn't be anything surprising in there.
Yes, destructors and overloaded operators make the "what is the code doing" part harder to figure out from plain text, but that makes no difference for the emitted instructions (compared to an explicit function call in C). The degree of "magic" in terms of assembly depends more on how good your compiler is at optimizing than whether you use C or C++.
This list contains several invalid items mixed with the good ones. It starts:
Why does the following code return 0 for most values of x? (This should be easy.)
int x;
return x == (1 && x);
The answer is that the code can return what it wants or make demons fly out of your nose, because using the automatic variable x without initializing it is essentially UB (UB in simple words in C89, unarguably UB in C11 because the address of x is not taken, and debatable in C99 but only because of poor choices of words). But I don't think this is the answer that the authors are thinking of.
“It is UB” also applies to “(1 - sizeof(int)) >> 32”, the next question, on ILP32 architectures that were still prevalent when this page was written (shifting an integer type of width 32 by 32), regardless of the discussion the authors want to have about the type of sizeof(t).
“Functions and function pointers are implicitly converted to each other” is one way to describe what the C standard actually says, but that makes it look more complicated than it is. In reality, functions decay to pointers to function in the same way that arrays decay to pointers-to-first-element, and if you are familiar with the latter, it's a good way to understand the former. Only function pointers can be applied. When you write “f(x)”, f first decays to pointer to function, and then is applied. The reason you don't need to dereference a pointer-to-function p when you apply it as “p(x)” is NOT that p will be converted implicitly to a function, but that function application expects a pointer to function.
The first example in 16.3 is also Undefined Behavior, regardless of the target architecture, because the type of “3” is always “int”, so it's a poor illustration of the VC compiler bug they are referring to.
I came here to say this-- for many of the questions, the answer is Undefined Behaviour.
The very 1st one-- I havent done embedded C for a long time, but the 1st thing I was taught was *not* to assume uninitialised variables would be set to 0. The author probably tested on a known safe (read lab like) system.
>> can return what it wants or make demons fly out of your nose,
Indeed, this is the correct answer.
Most the questions on the page seem to be "Let's do weird crap highly dependent on the architecture/compiler, using undefined behaviour, and LOL, we can then blame C"
And not defending C here at all, moved away from it years ago. But there are better criticisms than this
not a good way to give the example then. most c or c++ developers are going to start twitching uncontrollably the second they see a variable declared uninitialized and then immediately read from.
Simple does not mean easy. There is very many simple languages, just look at turing tarpit... No one can argue that many of the languages are not simple. But that doesn't mean they are easy or even practical.
C could be a simple language again if you greatly constrained compilers (-Onone is the closest you can get today). It wouldn't be fast of course, but it would fit the notion of "simple" some people have about it... or at least be closer to "high level assembly".
C still makes it trivial to write memory safety bugs. No one who likes C, especially not the committee, has any appetite whatsoever for improving the situation. I don't see that changing any time soon.
I really don't get why everyone is so afraid of undefined behavior. It's really not that bad. Learn and use it as another tool, instead of blindly hoping you won't encounter it and be surprised when you do. Sure, C has some cases of undefined behavior that are very pointless, but in many cases it's actually useful, and allows anything from faster programs and simpler code, to mapping better to assembly (yes, assembly has undefined behaviors too).
C isn't a language to force you to write guaranteed 100% memory-safe UB-free code. But it's still quite a bit easier to do so in C than assembly for each platform you want to target, and you can get pretty damn close to that assembly you would've otherwise written.
(I in particular do not want any forced memory safety - I've written a custom memory manager with its own semantics of ownership; the language doing anything about safety would just prohibit me doing what I want)
That's pretty much always a problem of memory corruption, which, imo, is very much a separate thing from undefined behavior. Yes, double-free, use-after-free, or reading out-of-bounds is undefined behavior, but -Onone won't make those "better" somehow, and you just can't do anything about it without just completely and utterly changing what C is (and, were that done, some other language would just take its place as an unsafe language that's very nice for trivially making performant code, and we'd be back where we started)
Not if liability finally becomes properly regulated.
It is like selling food on a shitty street joint, people might keep on doing that, until the day sanitary health check comes around and closes it down.
I guess all I can say is one man's thrash is another man's treasure. I personally don't have much a reason to care about mamory safety of my programs, so I would hate to have restrictions on what and how I can do things, it would be literally just wasting my time as a programmer, and wasting performance too.
As a part of the claimed "simplicity" of C is limited ability for hidden control flow in the language. The "nothing magical happens" is both a pro and a con of the language. Things work as described in the code, not as described in the lang spec. This lang limitation promotes simple and not over-engineered solutions. But as I said, it is not always a pro.
Btw I agree that UB can be dangerous and hard to debug. And that "nothing hidden" doesn't really hold because it can be really tricky sometimes to figure out what the compiler did.
When faced with these problems I don't say "you are coding C wrong" as some here claim we do. I would say that you need to be very careful when coding C and use all tools you can to make your life easier and the probability higher for creating better software. Use sanetizers, follow coding standards, use static analysis tools.
When I started studying CS my first language was Java. The syntax felt overly complex and the documentation was so huge it was impossible to find anything useful from it as I had no idea what I'm doing. I almost dropped out because of how overwhelming it all felt. Then I took an optional C course and it saved me. I actually felt like I understand how things work and the standard library is small enough that it's actually useful for a beginner. My simple hello world program even compiled into an .exe file. I felt like I'm actually programming instead of just copying other people's code that I don't understand myself.
C is an extremely simple language. The problem demonstrated here is that sometimes people like to show off and write nonsensical code. Let's consider the examples given.
1. This is straight up unacceptable in any reasonable shop. You must initialise variables before use. This will be flagged at the static analysis step inside the CI/CD pipeline, and your code won't make it to production.
2. sizeof returns a size_t, not an int. This is basic knowledge. Just make it a habit never to perform arithmetic on size_t without casting. This will also probably fail in static analysis.
3. Don't use the same names for globals and locals. Make some kind of rule where globals always have to start with g_. Solved.
4. Just don't do this. I guarantee that you will never need to do strange things like this. As I said, C's problems appear when people are showing off.
5. Please just make two structs, and put one inside the other. Don't do this. It is never required.
I am not going to go on now. It just keeps getting more unnecessary and more ridiculous. There is absolutely no situation that requires you to write C code like this. IMO if you want to show off how smart you are, write code that is obvious at first glance to a kid just out of college. Writing useful code that is easily understandable is a real art, and demonstrates true expertise with the language.
The context here is a tool that has to handle everything, so you won't necessarily run into all of these in your run-of-the-mill project. However, the author does claim:
> The following examples were actually encountered either in real programs or are taken from the ISO C99 standard or from the GCC’s testcases.
I have definitely seen (and probably even written) show-off code in C++ and other languages, but when it comes to C, I feel like the tricky stuff tends to be borne of necessity. For example, I test a code base that requires some type punning. The last time I investigated this, there didn't seem to be consensus on how to do this without UB.
Signed overflow is another bugbear in C. Code that worked perfectly fine for over twenty years was suddenly at the center of mysterious bugs when a new version of GCC decided to exploit some UB.
There have been many related efforts over the years. But inertia of large C codebases means that the industry at large keeps using C, limiting the popularity of such "forks".
> The “alias” attribute on a function declaration tells the linker to treat this declaration as another name for the specified function. CIL will replace the declaration with a trampoline function pointing to the specified target.
That already breaks as soon as someone tries to compare function pointers, no?
135 comments
[ 4.0 ms ] story [ 212 ms ] threadIs it easy to write programs in C? No. But C makes damn sure that what you wrote is what will get executed. Unless you like playing smart, like the guy in the opening post.
There’s no need to have any subset of the language in memory, this is the whole standard.
No, you're wrong. "Undefined Behavior" (UB) in the context of C has a very different meaning from the colloquial meaning "not specified". The meaning of UB is explicitly defined in the C standard, and the C standard explicitly lists certain program execution patterns as UB.
> If a ‘‘shall’’ or ‘‘shall not’’ requirement that appears outside of a constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this International Standard by the words ‘‘undefined behavior’’ or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe ‘‘behavior that is undefined’’
And I would never excrete such a line of code without automatically thinking "hmmm.. does this really do what I think it does?"
This is an undefined operation in almost all programming languages.
There is no intrinsic reason why a[i] = i++ has to result in undefined behavior.
I think the second example may contain UB on a <=32-bit architecture (right shift by a value greater or equal to the number of bits), or at least this is UB in C++. On a 64-bit architecture it would be fine (but the result would not be 0).
if i is signed, this is undefined. You'd think it's just a loop though.
int this_is_a_fairly_long_variable_name_1;
int this_is_a_fairly_long_variable_name_2;
Why? Because section 5.2.4.1 requires implementations to support at least 32 significant characters in external identifiers and section 6.4.2.1 says "If two identifiers differ only in nonsignificant characters, the behavior is undefined."
These ones may be more "obvious", but:
Given e.g. int a[4][5] then accessing a[1][7] is undefined even though one might assume it's like accessing a[2][2]
x << -3 is undefined behaviour though logically one might assume it should be equivalent to x >> 3
And the classic, very common newbie use of "fflush(stdin);" is also undefined.
Not saying anything about the other things, but this (in my opinion) is something that should raise eyebrows. At least to me personally that looks rather weird.
(It's also not unique to C)
It's not obvious which actions are atomic.
How can you identify legal C without understanding undefined behavior? Or rather, what is the list of UB that nay professional C developer should be expected to know?
> If someone has some examples that a reasonable person would write, specifically examples that look like the simplest way to do something, then I’d love to hear them.
Basically all OOP-style C. For instance, the Windows macro
The Linux kernel is also an excellent source of these.Also, serialization/deserialization libraries are an excellent source for UB. And huge amounts of code depends on UB by using unsigned char arrays for storage of other types.
Furthermore, in practice, C code assumes most of the following:
- null pointers are represented as 0, e.g. if(ptr).
- pointer provenance does not exist
- char and unsigned char are 8-bit bytes
- two's-complement arithmetic
- page-based memory protection
- no types have trap representations
- the values of base types, besides floating point, do not have multiple representations.
- etc
Off the top of my head, as a non professional C programmer:
- can’t read from uninitialized memory
- one malloc = one free
- signed overflow is undefined
- pointers to stack variables can’t outlive the stack variable itself
- can’t dereference NULL pointers
(Obviously take this with a massive grain of salt, there are almost certainly important ones that I’m forgetting.)
> null pointers are represented as 0, e.g. if(ptr).
If(ptr) is guaranteed by the standard to work. Memset(ptr, 0, len) is not (and is very broken in other ways as well).
> char and unsigned char are 8-bit bytes
They’re guaranteed to be one byte, where a byte is at least 8 bits. So unless you’re trying to simulate %256 with an implicit conversion, or relying on unsigned overflow to turn 0xFF into 0x00, then you’ll be fine.
Like in JavaScript, you can understand and remember how double equals work, if you try hard enough... Or you can just decide to always use triple equals and voila, your problem is solved once and forever.
But I don't write C, so take this with a grain of salt.
We aren't computers, and that's not what we mean by having things in our head.
"The patient needs more dozing/dosing."
But that's the problem with C. It looks like a simple language with a small number of straightforward constructs, when in fact it has very poor predictability.
Real-world results are implementation and platform-dependent, which is a polite way of saying they're unhelpfully unpredictable.
Coding standards will help, but they only get you so far because some ambiguities will always be there.
Basically C is a half-assed wrapper around assembler - useful in its day, but machine-dependent and outdated now.
You can keep the syntax and all the useful parts of the standard library in your head.
A lot of modern languages sacrificed simplicity for features no one (with enough skill) asked. Even with Python, I can only keep some part of built-in functions and methods together with syntax in my head.
For a large part of standard library, I absolutely need IDE features. Especially since Python is an object oriented language where every data instance has a bunch of methods attached.
you drug in "offshored code delivered by sweep shop consultancies", got a response, and then move the goalposts.
go away.
You were the one asserting that it only happens in sweetshops, hence my remark wasn't valid, thus I decided to broaden the scope of people failing to write C.
Those mythical C developers that are yet to be found after 50 years searching for them.
But, it is, in fact, possible for good developers to write incorrect C code. Noone's claiming that's not the case. It's just a lot less likely for someone who actually knows the language and the codebase.
I'm in the process of moving an old codebase from a compiler with a very loose adherence to an old specification to an ARM chip that is using GCC for the compiler. Enabling -Wall to catch all issues that the compiler highlighted, 16-bit ints to 32-bit ints, and quite a few ternary operator issues.
None of these were issues on a system that's been running in production for many years but porting to a new architecture will mean there’s a lot of extra testing to be done just to make sure we catch all the edge cases in the code.
I like the C language and have been using it for close to 30 years now. The main part of the language can be kept in your head but there is so much more to working with it than just the base language.
Much of the stuff programmers are just used to being "part of C" is actually from a library. For example NULL is just a macro that some library provided, C's "null pointers" are just the literal zero for a pointer, even if your hardware does not represent "null pointers" by an all-zeroes value.
Unless we are speaking about the very niche use case of allocating a big blob of static memory on the global segment.
Most of the stuff done with ISO C + language extensions/external Assembly, can be as easily done in safer languages.
Language extensions and external Assembly aren't a C priviledge.
But C doesn't come with functions like memcmp() and memcpy() or memmove() either and those are pretty fundamental even if you're on a tiny constrained system that can't aspire to stuff like "strings" and "allocation" you've probably heard of data structures and copying.
Or do you mean you can't write your own malloc that complies with the OS's memory management in C?
Because I'm pretty sure you can write a malloc in ISO C when you aren't dealing with an OS (which is usually when you need to write a malloc).
Same deal as it being impossible to implement BSD sockets in ISO C because of the sockaddr funny business.
Not to mention the chances of so many things to go wrong, in writing C code. Its like climbing a mountain without a safety gear.
If you're implementing custom data structures, you'll likely be able to make them optimal for your use-case, both from ease-of-use and performance. (except hashmaps. those do suck to write)
Membership testing? Use sets. Data storage? Use classes with slots or tuples/namedtuples. Custom switch statement? Use the new match/case keywords or just fall back to plain if/elif towers.
It's so much simpler.
A big problem for the analysis this old project attempts is that C makes no effort to clearly define any circumstances it doesn't care about and yet many such circumstances are likely to arise in real world programs. I would consider this a defect in a modern language, but of course its proponents disagree.
+1. I think the simplicity of C (and also Unix) was a great idea when it first started, it allowed so much productive explorations and works to be done in a short amount of time. Unfortunately, after decades of development, programs and systems were getting increasingly sophisticated to the point that the original design was not prepared for. Then one starts to suffer from a myriad of design limitations, and people start adding complicated extensions and workarounds to the existing platform, and eventually you get something that is not simple at all.
At this point, the simplicity became a shadow of its former self.
For example, in C, nobody could tell exactly how many undefined or unspecified behaviors exist in a program. Historically, every major optimization technique in a new compiler releases would uncover some unintentional undefined behaviors and would break existing programs. Recently, to solve this problem, UndefinedBehaviorSanitizer has been developed, which is technically a super complex tool. In the same way, the memory safety problem led to the creation of stack canary, NX, ASLR, PIE, CFI, AddressSanitizer, large-scale automated fuzz testing, none of them is simple. Arguably, all of these complexities can be removed if the programming environment itself has a better memory model.
For another example, C stdlib's minimalism caused many attempts to create a powerful standard library. While C++'s STL and Boost are notorious for their all-encompassing features, but arguably the recreation of a homegrown library in all C projects have made them more complex. Not to mention the C stdlib's minimalism is also a shadow of its former self - people claim one could essentially internalize all the functions, well this could be true for ANSI C, but how many people know that C99 has native support for complex numbers? Or how C11 added multithreading?
The Unix situation is similar. When it got popular enough, people started to add more and more features and extensions to satisfy their needs. Nowadays everyone is saying that X is bloat, but if you're a retrocomputing fan and look into the history, you'll see that this has already been the trend ever since the popularization of Unix. It has already began during the early BSD development at Berkeley - students added tons of command line utilities and optional arguments which was heavily criticized by Rob Pike. During the Unix Wars, various vendors also packed lots of features often in a hurry and in a manner that lacked consistency, which was subsequently adopted by everyone because POSIX was the peace agreement. When 4.3BSD started supporting POSIX, ironically, this time it came under attack by some as moving away from the BSD philosophy of simplicity! Later, GNU also added tons of features to the system utilities, sometimes explicitly went against Unix's design to address its perceived limitations (GNU's Not Unix, after all). Eventually there are Linux kernel and systemd, which clearly do more than what a kernel or a service manager is supposed to do. Linux kernel even has Transport Layer Security now!
Most of the feature expansions in C or Unix really have useful purposes and solves real problems in spite of the claims of "bloat". The problem is you have an aging foundation with a false pretense of simplicity, and when extensions are added to meet the demands, they create a lot more complexities in real applications, and the "simple" system with extensions is actually more complex than a "complex" system with a more elaborate design.
Don't get me wrong. I still find the C Programming Language is an excellent work of technical writing, its concise and minimalist style has its own elegance. Also, I still like The Unix Programming Environment as described by Rob Pike, it's revealing how much useful work can be done flexibly in pipes and shell scrip...
Probably the best non-fiction book I own, Ambrose Bierce and Randall Munroe's books are funnier, my X references are more useful as monitor stands, and my sister's book about cloth (she's an embroiderer) is much prettier, but K&R is the best book.
Perhaps it's notable that my "Student's Introduction to English Grammar" by Huddleston and Pullum is of similar size to K&R. Of course, Huddleston and Pullum merely happened upon English's Grammar already fully formed, which they can only try to explain in retrospect, whereas Ritchie wrote the first C compiler.
In general, I believe conceptual simplicity is beneficial for its wide adoption. Nobody wants to learn a language that makes them feel stupid.
Agreed. Trying to know every single dark corner/UB of the language is not needed.
I don't think that's true; the UB that is relevant for day-to-day programming is easily learned.
It's just that a sucker is born every minute and there's always someone who's been taught that C is a high level assembler. Now and then someone slips a piece of code that assumes as much in a project and disproportionately big noise is made about it.
Most UB-related bugs in C code are not there because the author didn't know it's UB, but because they simply coded it wrong. People fall into simple buffer overflows, double frees, etcetra all the time and no-one thought it's ok to overflow a buffer.
On the other hand, I'm quite confident that I don't have much UB in my code (as running sanitizers from time to time confirms). And miraculously, I've never ever been surprised by "nasal daemons".
No, the problem of undefined behaviour isn't overstated. It's the reason C and C++ have such a poor security track-record.
> I'm quite confident that I don't have much UB in my code
Plenty of C and C++ programmers are confident they don't have UB in their code, and yet we see a constant stream of security issues arising from UB in codebases developed and maintained by the smartest and most motivated C/C++ programmers, such as kernel code and the Chromium codebase.
From the Chromium project: [0]
> Around 70% of our high severity security bugs are memory unsafety problems (that is, mistakes with C/C++ pointers). Half of those are use-after-free bugs.
These bugs are, of course, undefined behaviour. These are serious bugs that presumably made it through their code-review and code-analysis processes.
(Chromium is written in C++ rather than C, but the point stands.)
> running sanitizers from time to time confirms
It doesn't. Modern static analysis tools are pretty smart, but they're a long way from being able to detect every instance of possible UB, with no false positives. If they were, we'd be in a very different place.
With the current state-of-the-art, the only way we have of developing C/C++ codebases that are free of UB, is to use formal methods (e.g. SeL4). This approach is very rarely taken, on account of the impact on development speed, and the skill needed.
[0] https://www.chromium.org/Home/chromium-security/memory-safet...
Difficult-to-detect undefined behaviour is a significant problem with C and C++, otherwise it wouldn't be a major cause of serious security vulnerabilities in well-resourced, high-profile, security-sensitive C/C++ codebases.
There may also be many instances of easily-detected undefined behaviour that exist only because of sloppy software development. There may also be many instances of undefined behaviour that are relatively benign.
> and the consequences are pretty much as expected - e.g. memory gets corrupted as a consequence of an out-of-bounds write, and then almost anything could happen.
I don't think I'm seeing your point. We agree that undefined behaviour can have serious consequences.
> while this is "undefined behaviour", it is not what people mean when they complain about UB
I don't follow. Undefined behaviour is an unambiguous term of art in C and C++ programming. There are plenty of common misconceptions about UB, sure enough, but the term itself is precise.
Do you mean difficult-to-detect but "obviously" bugs that can lead to UB, like buffer overflows that only happen in rare circumstances (like with unsanitized input)? Or do you mean difficult to detect unexpected "miscompilations" by the compiler based on some UB that is non-obvious to most programmers and/or not well-known?
Because I was referring to the latter, and in my perception most talk about UB is. I haven't seen the latter happen myself, and I haven't read that many horror stories where this actually happened.
For better or worse, I'm not angry with the compiler if I code a logic bug and corruption happens. That's just what I expect.
Maybe if you're indeed saying the latter is a "major cause of security vulnerabilities", could you provide a few examples where it's the language's or compiler's "fault"? I can see that the line is not well defined here of course, because technically it's all just UB - but the distinction really was my point, which I made from a practical perspective.
> Do you mean difficult-to-detect but "obviously" bugs that can lead to UB [...]? Or do you mean difficult to detect [...] UB that is non-obvious to most programmers and/or not well-known?
I meant both, as I wasn't drawing this distinction.
> I haven't read that many horror stories where this actually happened
You may be right that most of the problematic instances of UB are doing something that's clearly not right even to inexperienced programmers, but again I don't see much value in making the distinction. I mentioned the Chromium stats earlier. Those security issues are real. Many of those issues presumably wouldn't happen in a language that prevented UB.
(It's not necessarily the case that all the issues would have been avoided, as it's possible a bug might still have manifested, but in a well-defined way. A UB-invoking memory-management bug might correspond to a failure to reset a data-structure, in a safe language. Only the former invokes UB, but both could have security consequences.)
> For better or worse, I'm not angry with the compiler if I code a logic bug and corruption happens. That's just what I expect.
You expect it because you know the C and C++ languages don't offer robust means to avoid undefined behaviour. This doesn't generalise to other languages.
In C and C++, silent data-corruption can occur if you try to write into an array but go out-of-bounds. That doesn't happen in many other modern languages. Unlike Ada, C doesn't even let you enable robust runtime bounds-checks for your debug builds. (To my knowledge no C compiler is able to offer this feature.)
> Maybe if you're indeed saying the latter is a "major cause of security vulnerabilities", could you provide a few examples where it's the language's or compiler's "fault"?
I wasn't drawing the distinction at all. I'll use an example from the plainly erroneous category.
Use-after-free and double-free errors are both undefined behaviour, in C and in C++. In Safe Rust, they cannot ever arise because of the way the language is defined, so the door is closed on those forms of runtime error. We don't need to talk about blame.
Memory-management issues of this sort are precisely the kinds of issues that affect Chromium. Not even Google can keep undefined behaviour out of their most high-profile C++ codebase. (In fairness though, the codebase would likely be quite different if written from scratch today in modern C++.)
On the not plainly erroneous side: a subtlety that can result in security consequences is the elision of a memset call by an optimising compiler which can result in sensitive data not getting zeroed out before deallocation. This isn't actually UB, but it's a good example of 'language lawyer' trouble with C/C++. [0]
As an aside: I once spotted a competent C++ programmer (vastly more experienced than myself) writing code which applied memcpy to an array of non-POD objects. If you'll forgive a painful mix of metaphors: C++ is large enough that even experienced programmers can have blind spots to its dark corners.
[0] https://stackoverflow.com/a/56565637/
Valgrind offers bounds checks with malloc() allocations and probably other allocations, and while I don't have extensive experience with valgrind it works surprisingly well. I imagine runtime checks could be made possible for any allocator by offering a slice(ptr, type, count) builtin call. I don't really see the language causing any problems, it's more what compiler output and optimizations we've come to expect.
> Memory-management issues of this sort are precisely the kinds of issues that affect Chromium.
They're just what a C programmer expects. I'm not saying you have to like the outcome but this is not "UB" for me - in the sense that it's been utterly obvious from day 1 that this type of issue can cause corruption (because how would it not?), long before there was any talk about UB.
> As an aside: I once spotted a competent C++ programmer (vastly more experienced than myself) writing code which applied memcpy to an array of non-POD objects. If you'll forgive a painful mix of metaphors: C++ is large enough that even experienced programmers can have blind spots to its dark corners.
I'm not defending C++ (in fact I actively avoid having to deal with it) but I think it's obvious that this sort of code is extremely likely to break.
Right, and Valgrind is very impressive, but it's a very intrusive and heavyweight tool, rather than a compiler flag. (I often mention Valgrind by name in these sorts of discussions, [0] we're thinking along the same lines here.)
> I imagine runtime checks could be made possible for any allocator by offering a slice(ptr, type, count) builtin call
It would presumably need 'fat pointers', which have ABI issues.
It would need to cope with statically allocated arrays, heap-allocated arrays, and arrays with automatic lifetime, including VLAs and alloca. It would also need to cope with passing a pointer mid-way into an array (something forbidden in, say, Java).
There would be considerable payoff to solving this problem, but it isn't an easy problem to solve, which is why no compiler does so.
> I don't really see the language causing any problems, it's more what compiler output and optimizations we've come to expect.
No, it's the language. The behaviour of modern optimising compilers is consistent with the way the languages is defined. The way the language is defined, and the categories of undefined behaviour that it permits, are very consequential.
I already gave the example of how arrays work differently in Ada, such that Ada compilers can easily add bounds checks for dev builds, whereas C compilers cannot so you need Valgrind, which few people use. (Well, more properly, arrays work differently in C than in just about every other language.) I could give a dozen other examples of ways C permits you to introduce serious bugs into your codebase which other languages robustly defend against.
Again, many categories of bug simply cannot occur in other safer languages, because of the way safe languages are defined. Use-after-free, double-free, signed integer overflow, read-before-write, divide-by-zero, out-of-bound access, mis-aligned access, data-races. Using a language like Safe Rust closes the door on every one of those kinds of undefined behaviour.
Recall that these are precisely the kinds of errors that result in major security vulnerabilities. This isn't purely academic. Safe languages like Safe Rust (or even plain old Java, although Java has some unsafe corners) stops those issues arising, and that means better security.
Also, as we've discussed, the way C is defined makes it difficult for C compilers to use robust compile-time or even run-time checks to detect undefined behaviour. The result is that major C codebases get delivered with undefined behaviour bugs, which are a common source of major security vulnerabilities.
> I'm not saying you have to like the outcome but this is not "UB" for me - in the sense that it's been utterly obvious from day 1 that this type of issue can cause corruption (because how would it not?), long before there was any talk about UB.
Respectfully there is no such thing as 'UB for me'. It's an accepted technical term-of-art with a clear definition. People do PhDs on this topic. [1]
You're not engaging with the points I've made about how other languages are defined in such a way as to prevent these issues arising. You seem to be focussing on how it's the programmer's fault, which isn't the point at all.
Also, operations which cause data-corruption in C aren't always this way in other languages. In Java, an out-of-bounds write (into an array) results in an exception being thrown, for instance. In verified SPARK Ada, out-of-bounds writes cannot arise in the first place.
> long before there was any talk about UB
Prior to the standardisation of the C language that may have been true in the sense that perhaps the term undefined behaviour had not yet been coined, but that doesn't have any bearing on our discussion. C was always an unsafe language.
> I think it's obvious that this sort of code is extremely likely to brea...
Arguing like that and then saying I'm not "engaging with the points you've made" after you've been talking completely aside the points of my OP, well... you're being a bit of a pain in the butt. I totally get your points, so let's agree that we're just looking for different things.
(Btw, a couple of days ago I did try Rust once again for a few hours, intending to convert a simple toy project to it. After a few hours of fighting the compiler, editing boilerplate files, looking for the right crates for basic Win32 interop, waiting for downloads, etc... I quit without making it work. Nothing changed in my feeling that without an extreme (or potentially infinite) investment of energy, C will continue to be more productive for me personally, for what I do - despite all its flaws).
The most bugs in C don't come from undefined behavior, but from plain coding mistakes. That are 98% related to memory (mis)menagement.
Just for fun, I tried the last example (VC ugliness, 16.3, example 1.) and got this
and a program output of -1 using MSVC 2022 (Microsoft C/C++ Optimizing Compiler Version 19.31.31104 for x64).This made me ponder whether the bugs and wrong implementations mentioned have been fixed for quite some time at this point.
Lua has nearly the same length, small reference too.
These examples are fun, but having written C for some time, I would not accept these into a codebase. I don't need to know how the compiler behaves with those to know that it's going to be something weird.
I don't think it is meaningfully harder to understand translation into assembly between C and C++. Sure, you'll have to learn what a vtable is, and possibly think about exceptions, but if you have debugged some C++ assembly a few times, there shouldn't be anything surprising in there.
Yes, destructors and overloaded operators make the "what is the code doing" part harder to figure out from plain text, but that makes no difference for the emitted instructions (compared to an explicit function call in C). The degree of "magic" in terms of assembly depends more on how good your compiler is at optimizing than whether you use C or C++.
“It is UB” also applies to “(1 - sizeof(int)) >> 32”, the next question, on ILP32 architectures that were still prevalent when this page was written (shifting an integer type of width 32 by 32), regardless of the discussion the authors want to have about the type of sizeof(t).
“Functions and function pointers are implicitly converted to each other” is one way to describe what the C standard actually says, but that makes it look more complicated than it is. In reality, functions decay to pointers to function in the same way that arrays decay to pointers-to-first-element, and if you are familiar with the latter, it's a good way to understand the former. Only function pointers can be applied. When you write “f(x)”, f first decays to pointer to function, and then is applied. The reason you don't need to dereference a pointer-to-function p when you apply it as “p(x)” is NOT that p will be converted implicitly to a function, but that function application expects a pointer to function.
The first example in 16.3 is also Undefined Behavior, regardless of the target architecture, because the type of “3” is always “int”, so it's a poor illustration of the VC compiler bug they are referring to.
The very 1st one-- I havent done embedded C for a long time, but the 1st thing I was taught was *not* to assume uninitialised variables would be set to 0. The author probably tested on a known safe (read lab like) system.
>> can return what it wants or make demons fly out of your nose,
Indeed, this is the correct answer.
Most the questions on the page seem to be "Let's do weird crap highly dependent on the architecture/compiler, using undefined behaviour, and LOL, we can then blame C"
And not defending C here at all, moved away from it years ago. But there are better criticisms than this
C, is somewhat practical though.
C still makes it trivial to write memory safety bugs. No one who likes C, especially not the committee, has any appetite whatsoever for improving the situation. I don't see that changing any time soon.
C isn't a language to force you to write guaranteed 100% memory-safe UB-free code. But it's still quite a bit easier to do so in C than assembly for each platform you want to target, and you can get pretty damn close to that assembly you would've otherwise written.
(I in particular do not want any forced memory safety - I've written a custom memory manager with its own semantics of ownership; the language doing anything about safety would just prohibit me doing what I want)
Then again, we guys in security need job safety anyway.
It is like selling food on a shitty street joint, people might keep on doing that, until the day sanitary health check comes around and closes it down.
Btw I agree that UB can be dangerous and hard to debug. And that "nothing hidden" doesn't really hold because it can be really tricky sometimes to figure out what the compiler did. When faced with these problems I don't say "you are coding C wrong" as some here claim we do. I would say that you need to be very careful when coding C and use all tools you can to make your life easier and the probability higher for creating better software. Use sanetizers, follow coding standards, use static analysis tools.
From that point of view, C is simple.
1. This is straight up unacceptable in any reasonable shop. You must initialise variables before use. This will be flagged at the static analysis step inside the CI/CD pipeline, and your code won't make it to production.
2. sizeof returns a size_t, not an int. This is basic knowledge. Just make it a habit never to perform arithmetic on size_t without casting. This will also probably fail in static analysis.
3. Don't use the same names for globals and locals. Make some kind of rule where globals always have to start with g_. Solved.
4. Just don't do this. I guarantee that you will never need to do strange things like this. As I said, C's problems appear when people are showing off.
5. Please just make two structs, and put one inside the other. Don't do this. It is never required.
I am not going to go on now. It just keeps getting more unnecessary and more ridiculous. There is absolutely no situation that requires you to write C code like this. IMO if you want to show off how smart you are, write code that is obvious at first glance to a kid just out of college. Writing useful code that is easily understandable is a real art, and demonstrates true expertise with the language.
> The following examples were actually encountered either in real programs or are taken from the ISO C99 standard or from the GCC’s testcases.
I have definitely seen (and probably even written) show-off code in C++ and other languages, but when it comes to C, I feel like the tricky stuff tends to be borne of necessity. For example, I test a code base that requires some type punning. The last time I investigated this, there didn't seem to be consensus on how to do this without UB.
Signed overflow is another bugbear in C. Code that worked perfectly fine for over twenty years was suddenly at the center of mysterious bugs when a new version of GCC decided to exploit some UB.
Why did C++ make it so big then? There's a good talk about this from Herb Sutter: https://www.youtube.com/watch?v=wIHfaH9Kffs
(Also check out minute ~20 for discussion of your exact question!)
That already breaks as soon as someone tries to compare function pointers, no?
The first few examples show the author is not well-versed in C.
It's true that C's semantics are not that simple, but one should know a language better before deciding to write an interpreter loop for it.
> GCC ugliness
Does the author not know about -std=c99 or -std=c11, which turns GNU extensions off?