If a language allows taking references to fields within a tagged union (as is possible with Rust enums), then "shared-xor-mutable" is required to avoid use-after-free (switching the tagged union to another variant ends the lifetime of the old variant's fields).
> I honestly don't really care how many optimizations that would break; correctness is king.
It's approximately all optimizations.
Good news: there's already a compiler option that does exactly what you want: -O0.
It's even enabled by default (unless overridden by another -O switch)!
A surprising number of those optimizations are only valid by "reasoning from undefined behavior".
Under the as-is rule, optimizations must not change program behavior.
A C program could theoretically use out-of-bounds pointers to scan its own stack, observing whether a value occurs on the stack. Thus, the as-is rule prohibits storing local variables in registers!
But because out-of-bounds pointers are undefined behavior, the compiler can ignore the programs doing stack scanning, and so register allocation becomes possible under the as-is.
So boring old register allocation is one of those "assume that UB doesn't happen" optimizations! Of course, the compiler never actually reasons "this pointer arithmetic is out of bounds therefore I can put that variable over there into a register" -- the reasoning from undefined behavior doesn't happen at compile-time, it already happened when the "register allocation" optimization was designed. But that's the case for most "assume that UB doesn't happen" optimizations!
(this is also why it's so difficult for compilers to warn about undefined behavior -- they optimize based on it without ever detecting it!)
If you want to eliminate "reasoning from undefined behavior", you'd also need to replace the "as-is" rule with something else -- an explicit list of allowed optimizations in the language standard?
Indeed. No UB is something no mainstream C/C++ compiler does (and maybe ever did). Benchmarking the performance impact will require basically rewriting GCC or LLVM, an insurmountable task, because the assumptions are so ingrained.
Though it might be possible to have more "sensible" UB. For example, assuming daemonic non-determinism for allocation address it is possible to turn stack storage into registers, without the need for an aliasing model. Benchmarking it has the same difficulty.
The problem is really surprising UB. I don't think that an experienced C programmer blames the compiler if some pointer offset trick to manipulate variables on the stack stops working. Compilers used to be "less smart", so they didn't get too clever about UB neither. Now they do and it surprises developers. That is a real problem.
A challenge is that it doesn't seem like people agree on what "surprising" UB is. I don't think it would be totally unreasonable to have a compiler mode that specifically assumes that all arithmetic operations are total functions, which covers many of the scenarios that show up in blog posts. But there is somebody in this comment thread who is upset that you can't read from an uninitialized heap address to obtain unpredictable data.
My personal observation is also that it only surprises developers when they read blog posts about it and that this actually comes up quite rarely in practice.
The compiler is only allowed to do that when it can prove `puts` will return (i.e. it must prove `puts` does not call `exit`). In a world with SIGPIPE that shouldn't happen. (unless maybe the compiler can prove that there's enough space available in the stdlib IO buffers so that puts won't actually output anything. but then there's no obversable difference in behavior)
Despite using C++ for a few years now, the EDG source code is still mostly C code (and in fact, the C++ code is still using the old .c file names). In particular, there's no usage of the C++ standard library.
Where other languages might use inheritance, EDG still uses the C-style `union { ... } variant;`.
On a related note, compiling EDG is extremely fast: on my machine, the EDG frontend compiles in <10s; whereas clang takes >10min (caution unfair comparison: clang includes much more than just a frontend).
I think this is fairly common practice when writing compilers. You ideally want to be able to bootstrap from other languages, and C is a much simpler starting point.
Yes, of course. It makes complete sense to have the implementation of a C++ compiler to be written in a highly portable and much simpler language like C.
It's one of the 4 big remaining C++ compiler frontends: gcc, clang, MSVC, EDG.
Most other C++ compilers are based on either EDG or clang. (e.g. the Intel C++ compiler used to be based on the EDG frontend, though modern versions are based on clang instead)
The "frontend" is the part of the compiler that understands the input language: lexer, parser, template instantiation, constexpr evaluation, ...
The EDG frontend produces an intermediate representation (IL) that is then used by the different compiler vendors to generate machine code. Or do code analysis.
No, MSVC is its own front end (the older continuously maintained C++ front end, in fact). However, Visual Studio's C++ IntelliSense uses the EDG front end.
> ILP64 (wherein int is 64 bits) exists. It's not very popular, but it exists; e.g. ICC supports it.
ILP64 is problematic for existing code: there is lots of stuff like hashcode computations using uint32_t with multiplications, relying on the C standard guaranteeing wraparound for unsigned overflows.
But with 64-bit int, uint32_t will promote to a signed int, and overflows will thus be undefined behavior.
This problem already exists with uint16_t multiplications on current architectures, but moving the problem to uint32_t will cause trouble for a lot of existing code that thought using fixed-size types like uint32_t would be safe.
Thank you for being one of the few people who understands that in C/C++, `unsigned OP unsigned` can have each operand be promoted to a signed integer and then have the operation overflow and cause undefined behavior.
I chose to deal with this problem by doing a "pointless" operation to force a promotion to at least unsigned int. For example:
uint16_t x = 0xFFFF;
uint16_t y = 0xFFFF;
uint16_t z = (uint16_t)((x + 0U) * (y + 0U));
This piece of code will work on any machine, such as: (uint16_t = unsigned short = 16 bits, uint32_t = unsigned int = 32 bits); (uint16_t = unsigned short = unsigned int = 16 bits, uint32_t = unsigned long = 32 bits).
But the result is 1, whether you calculate it as 16-by-16 unsigned multiplication (you get 0xFFFE0001 truncated down to 1), or 32-by-32 signed (you multiply -1 by -1 and get 1, with no overflow).
> Integer promotion is the implicit conversion of a value of any integer type with rank less or equal to rank of int or of {a bit-field of type _Bool(until C23)/bool(since C23), int, signed int, unsigned int}, to the value of type int or unsigned int.
> If int can represent the entire range of values of the original type (or the range of values of the original bit-field), the value is converted to type int. Otherwise the value is converted to unsigned int.
The fact that the C/C++ integer conversion rules have these unintuitive footguns is why I made it a talking point.
One reason is that the language didn't originally intend for UB to be so broad. They probably expected that each value would be loaded into an int-sized register, and the calculation would then be performed on two int-sized registers with overflow handled in a platform-dependent but generally sane manner (not time travel).
Yeah, reading the rationale in the standard itself, it's is kinda obvious that they didn't consider potential UB at all: when talking about "questionable signedness" they only mention division/remainder and comparisons as being possibly affected, but not addition/multiplication (due to overflow), and comparisons behaving mostly expectedly (for some value of "expectedly") is the main selling point. But comparing signed and unsigned values without actual thought and explicit cast is almost always a bug anyway! And we had the warnings for that for, like, forever.
Makes me wonder how ergonomic would a language with actually correct signatures for the arithmetic operators be.
+: int × int → int // alternatively int × bool
-: int × int → int // alternatively int × bool
*: int × int → int
/: int × int → int
%: int × int → int
> stuff like hashcode computations using uint32_t with multiplications, relying on the C standard guaranteeing wraparound for unsigned overflows. But with 64-bit int, uint32_t will promote to a signed int, and overflows will thus be undefined behavior.
Yeah, except that multiplying two 32-bit values, recast as 64-bit signed integers, will not overflow. Even adding another 32-bit value to this product will not overflow. Throw in the final cast to uint32_t to throw away the upper sign bits, and you get the identical result.
For 32-bit floats, subnormals are the numbers closer to 0 than 2**(-126) == 0.0000000000000000000000000000000000000117549.
For 64-bit doubles, it's 2**(-1022), a number starting with 308 decimal zeroes.
It should be noted that subnormals have appeared for the first time in standards a decade before Intel 8087 (the ancestor of the IEEE standard), in the standards for digital telephony, and that happened in a certain form of FP8.
When telephony transitioned from analog voice transmission to digital, the PCM (pulse-code modulation) encoded audio signal used 8-bit samples, which were a form of 8-bit floating-point numbers (with American and European encoding variants: mu-law and A-law). The use of a floating-point format enabled the 8-bit samples to have a dynamic range as big as for a 12-bit or 13-bit fixed-point encoding.
Subnormals where used in digital telephony, because otherwise the errors around zero would have been so great that the voice audio would not have been intelligible.
In general, in smaller floating-point formats the use of subnormals is even more important than in bigger formats, in order to avoid the loss of precision around zero.
I don't know why I wrote the comment about parameters earlier -- (N)RVO is about return values.
Those have a different reason for being passed behind a hidden pointer: the class type might have self-referencing pointers, so there must be an explicit move/copy constructor call whenever it changes address, to give the class an opportunity to update those pointers. This cannot work when returning in a register: the callee doesn't know the target address, and the caller doesn't the source address, so neither can call the move constructor.
Thus, all ABIs must pass a pointer (or let caller+callee agree on a memory location in some other way) for types that aren't trivially copyable.
> Memory safety bugs aren't any different from other bugs.
This is highly dependent on what kind of software you are writing.
On the one end, there's stuff like an image format parser in a browser -- a pure function from untrusted bytes to untrusted pixels.
In a memory-safe language, it's pretty hard for such code to have vulnerabilities (other than DoS) in such code -- you'd have to explicitly go out of your way to do weird stuff (open unrelated files, start subprocesses, ...). Simple logic bugs can only lead the wrong pixels or panics. But when memory safety bugs are possible, remote code execution is common in such code. Memory-safe languages make a massive difference here!
On the other end, you have stuff like a javascript JIT compiler, which turns untrusted javascript into trusted machine code -- here pretty much any logic bug leading to "wrong output" can be turned into a remote code execution exploit. Memory safe languages are not very useful here.
The optimization is often possible even if the computer does not see the call, because most (all?) ABIs have always required hidden pointer parameters for class types with non-trivial destructors.
https://godbolt.org/z/9WvnEvEYh
Note how `std::unique_ptr` effectively passed as a `int**`; and that the by-value unique_ptr is not destroyed at the end of the function -- destroying parameters is instead the caller's job (and commonly only happens at the end of the full expression containing the call -- though this choice is implementation-defined).
But that can only work if the caller can see the updated value of the parameter (to avoid double-free for `clear`) -> thus the need to pass the parameter by hidden pointer.
reply