Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
The difference between undefined behavior and ill-formed C++ programs (devblogs.microsoft.com/oldnewthing)
41 points by st_goliath on Aug 3, 2024 | hide | past | favorite | 21 comments


> Avoiding code paths with undefined behavior is something you do all the time.

> // Check the pointer before using it

> if (p != nullptr) p->DoSomething();

I love this example.


> Undefined behavior (commonly abbreviated UB) is a runtime concept. If a program does something which the language specified as “a program isn’t allowed to do that”, then the behavior at runtime is undefined

Importantly, UB is a runtime condition in the general case, in the sense that it cannot be statically determined (again, in the general case). It may depend on input data, or detecting it may amount to solving the halting problem.

A consequence of that is that UB cannot be caught by static tools in the general case without changing the language so that a large class of previously valid programs (i.e. not containing UB) become invalid.


> Visual Studio has an unofficial command line option to help identify certain classes of IFNDR

I'm sure there's a good reason why this is hard, but I'm a little surprised that this isn't caught by static analysis. Sure enough, I can't get MSVC Code Analysis to complain about the example with different inline functions.


For the One Definition Rule specifically, sure, the tools could just be better, it would be extremely difficult in C++ but this entire problem doesn't arise in many languages.

However a bunch of IFNDR in C++ covers cases where Rice's Theorem applies, so your static analysis cannot help.

Rice says that if you language has non-trivial semantic requirements (not just syntactic ones) that's Undecidable, which means there cannot be any algorithm which always says whether any program does or does not satify the requirement.

C++ is reluctant to lose any programs which might potentially satisfy its requirements, so instead it chooses to say that they must compile unless the compiler can prove they do not, which by Rice we know cannot be 100% effective. In practice, why even bother? Many compilers don't put much work into trying, it might help users but it's not a compliance requirement so it's often not a priority.

Rust's choice here (thus avoiding IFNDR) is to reject programs unless it can show that they meet the requirements. Even if you can convince a PhD team that your program satisfies the requirements, that won't help, rustc needs to be convinced or it won't compile. Unlike C++ the incentives here are aligned, that's what Rust's "Non-lexical lifetimes" change was about - the compiler got smarter, allowing previously unacceptable Rust to compile.


> Rust's choice here (thus avoiding IFNDR) is to reject programs unless it can show that they meet the requirements.

I wonder if a C++ compiler that does that would be feasible. A lot of this check avoidance is due to the expense of compilation when the language was created (and, back then, C++ itself was sometimes considered prohibitively expensive). The Rust compiler is slow because it’s doing a lot of work (the same reasoning applied comparing C++ to C - and C++ started as a transpiler compiling C++ to C first.


> The Rust compiler is slow because it’s doing a lot of work (the same reasoning) [...]

This is commonly assumed but the evidence suggests otherwise. The borrowck for example is not a large fraction of the compiler runtime.

So if you've just assumed this is correct, rather than you've collected evidence, I suggest your foundational argument needs re-evaluating.

As to the idea that you can assess C++ instead, not really, you need invasive language changes, Sean Baxter's "Circle" compiler provides a whole bunch of experimental features including his take on borrow checking, but you can't meaningfully check existing C++, it just gets rejected, you have to write your new checkable C++ instead.


> you have to write your new checkable C++ instead.

That was more or less where I was going. Existing code is mostly hopeless, so, maybe, a dialect that makes more of the developer’s intentions explicit would provide enough for more checking. I don’t have evidence that Rust’s checking is why it’s slow, but I assumed it was a good guess.


I think I'd have guessed similarly, but then I read the data.

I don't think a C dialect is a good idea. You want a blank slate, on which you can begin with your foundational concerns (e.g. constant time evaluation) rather than starting from C. There may be practical reasons to prefer how C spells things, the way Rust looks like a semi-colon language even though it's mostly an ML, but the core language isn't helpful.


The C dialect idea would be useful if you wanted to gradually take a code base in. You’d mark parts of it as unsafe/unchecked and new code could come in using the new constructs with a more convenient syntax.


Linkers could require that all definitions of inline functions be byte-for-byte identical, but in practice people expect to be able to use libraries that were not built with exactly the same compiler binary. Any check looser than that is trying to determine if two bits of machine code are sufficiently equivalent, which is distinctly not easy.

Static analyzers tend to be structurally unable to detect ODR violations - either they only analyze one translation unit at a time, or their cross-module analysis itself relies on the ODR being obeyed.


One reason for this is separate compilation, and linkers not being required to perform that level of static analysis, besides them lacking the necessary information about (e.g.) inlined functions or struct layouts.

Note that in case of shared libraries (DLLs, .so), the OS loader/linker would have to perform that analysis.


> However, if your program avoids the code paths which trigger undefined behavior, then you are safe.

This seems incorrect as demonstrated by the other undefined behavior story I read on HN today[1], the tl;dr of which, as I understood it, is since UB is not allowed, the compiler can elide checks that would protect against UB for the sake of optimization since a correct program wouldn't have caused the UB in the first place and the compiler doesn't have to respect the semantics of incorrect programs.

[1] https://news.ycombinator.com/item?id=41146860


>is since UB is not allowed, the compiler can elide checks that would protect against UB for the sake of optimization

This is incorrect. If a check prevents UB, then the compiler isn't allowed to optimise it away, because the resulting program would then contain UB when it didn't before. Part of the compiler contract is that compilers won't introduce UB - they can only rely on UB already present in the program.

Some people think that you can "catch" UB after the fact, but that's an incorrect understanding of how it works. Once UB has occurred, there is no way to go back to a coherent program state and code which would execute after some UB is therefore irrelevant.


The compiler may remove the nullptr check in:

  ptr->foo = 1;
  if (ptr == nullptr)
     return;
but it may not remove the nullptr check in:

  if (ptr == nullptr)
     return;
  ptr->foo = 1;


To explain why it can be removed in the former:

Since it is UB to dereference a null pointer, the compiler can assume that ptr isn't null after it is dereferenced[1]. Therefore, the if condition will always be false.

In fact if ptr is null, unless the foo field has a very large offset, the behavior you would probably expect would be for the dereference to segfault before reaching the if, so it doesn't really matter if it is optimized away.


>so it doesn't really matter if it is optimized away.

There are cases in which the optimization can result in behavior other than segfaulting, see https://research.swtch.com/ub#null


Sure. That's why I said "the behavior you would probably expect", but that isn't necessarily what happens.


I guess I've misunderstood that other story then, thanks.


The famous case in the linux kernel was caused by a code path which was technically dereferencing a null pointer (but not in practice generating a memory access to the address it pointed to), then doing the check, then dereferencing it "for real". Something like:

int *m = &p->foo;

if(p) *p->bar;

What annoyed people was that while the first line technically dereferences p according to the C standard, all it does in practice is calculate an offset from p. But it was enough for the compiler to rmeove the if and execute the second line unconditionally, making the null pointer dereference "real" (as in the generated assembly would try to read some very low part of memory).


Aha! I've never had c/c++ as a daily driver, so I missed the significance of taking that reference. Thanks for the explanation!


The compiler cannot elide checks preventing UB, e.g. null pointer or integer overflow.

It can assume those don't happen, and use that assumption to compile code.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: