3 ms·
>you want a language that actually gives defined semantics to all combinations of language constructs No, this is wrong. It's a common misconception though. Yo
by fooker 2y ago
>you want a language that actually gives defined semantics to all combinations of language constructs
No, this is wrong. It's a common misconception though. You would only want that in a hypothetical world where all computers are exactly the same.
Undefined and implementation defined behavior is what allows us to have performance at all. Here are some simple examples.
Suppose we want to make division by zero and null pointer dereference defined. Now every time you write a/b or *x, the compiler will be forced to emit an extra branching check before this operation.
Something much more common---addition. What about signed overflow? Do you want the compiler to emit an overflow check in advance? Similar reasoning for shift instructions.
UB in the language specification allows compilers to optimize based on the assumption that the programs you write won't have undefined behavior. If compilers are not able to do this, it becomes impossible to implement most optimizations we rely on. It's a very core feature of modern language specifications, not an oversight you can fix by thinking about it for 10 minutes.
- grumpyprole 2y agoI'm sorry I still don't buy it. Can you please show me a use case where ignoring null pointer or overflow checks makes your product non-viable or uncompetitive? Some of these checks could be removed by languages with better compilers and likely more restrictions. That is the better approach. As a user, I don't want to run code that is potentially unsafe and/or insecure.
- fooker 2y agoIt's not ignoring about ignoring null pointer or overflow checks, it's having to necessarily insert these checks everywhere.
- grumpyprole 2y agoWe should build compilers that insert these checks for us (if they cannot statically determine them unnecessary). The ability to omit these checks doesn't IMHO justify undefined behaviour.
- fooker 2y agoWell, good news is that you have optional modes in most compilers that do this. You would not want to force these by default, nobody wants it. You can not statically determine them unnecessary in for the vast majority of code, even stuff as simple as `print(read(a) + read(b))`.
- daemin 2y agoSo the simplest case for not providing a language specification for dereferencing a null pointer is that it requires putting in checks everywhere to detect the condition and then do something in the case where the pointer is null. So what should the null pointer case do then? Something like emit an exception, or send a signal, or call std::terminate to exit the process? I know that languages like Java have a NullPointerException which they can throw and handle for situations like this, but they're also built on a highly specified virtual machine architecture that is consistent across hardware platforms. This also does not guarantee that your program is safe from crashing when this exception gets thrown, as you have to handle it somewhere. For something as general as this it will probably be in the Main function, so you might as well let it go unhandled as there's not that much you can do at that point. For a language like C++ it is simpler, easier, and I would argue more correct, to just let the hardware handle the situation, which in this case would trigger a memory error of trying to access invalid memory. As the real issue is probably somewhere else in the code which isn't being handled correctly and the bad data is flowing through to the place where it accesses the null pointer and the program crashes. To add to that in a lot of cases the program isn't crashing while trying to access address 0, it's crashing trying to access address 200, or 1000, or something like that, and putting in simplistic checks isn't going to catch those. You could argue that the check should guard against accessing the lowest 1k of memory, but then when do you stop, at 64k? Then you have an issue with programs that must fit within 1k of memory. Leaving it unspecified is the better choice.
- bigstrat2003 2y ago> UB in the language specification allows compilers to optimize based on the assumption that the programs you write won't have undefined behavior. Given that has proven to be a completely false assumption, I don't think there's a justification for compilers continuing to make it. Whatever performance gains they are making are simply not worth the unreliability they are courting.
- fooker 2y ago> Given that has proven to be a completely false assumption This part is correct. The problem is in how to deal with this. If you want the compiler to correctly deal with code having undefined behavior, often the only possibility is to assume that all code has undefined behavior. That means, almost every operation gets a runtime branch. That is completely incompatible with how modern hardware works. The rest is wrong, but again, this is a common misconception. Language designers and compiler writers are not idiots, contrary to popular belief. UB as a concept exists for a reason. It's not for marginal performance boosts, it is to enable any compiler based transformation, and a notion of portability.
- jay-barronville 2y ago> Suppose we want to make division by zero and null pointer dereference defined. A good example is WebAssembly*—address 0x00000000 is a perfectly fine and well-defined address in linear memory. In practice though, most code you’ll come across targeting WebAssembly treats it as if dereferencing it is undefined behavior. * Of course WebAssembly is a compiler target rather than a language, but it serves as a good example of the point you’re making.
- samatman 2y agoZig, a language which is explicitly aimed at the same domain as C, has an improved semantics for all of these things. If a pointer can be null, it must be an optional pointer, and you must in fact check before you dereference it. This is what you want. Is it ok to write a program which segfaults at random because you didn't check for a pointer which can be null? Of course not. If you don't null-check the return value of e.g. malloc, your program is invalid. But the benefit is in the other direction. Careful C checks for null before using a pointer, and keeping track of whether null has been checked is a manual process. This results in redundant null checks if you can't statically prove (by staring at the code and thinking very hard) that it isn't null. So in practice you're likely to have a combination of not checking and getting burned, and checking a pointer which was already checked. To do otherwise you have to understand the complete call graph, this is infeasible. Zig doesn't do any of this. If it's a pointer, you can safely dereference it. If it's an optional pointer, you must check, and then: it's a pointer. Safe to pass down the call stack and freely use. If you want C behavior you can always YOLO and just say `yoloptr.?.*`. Overflow addition and divide by zero are safety checked undefined behavior, a critical concept in the specification. They will panic with a stack trace in debug and ReleaseSafe mode, and blow demons out of your nose in ReleaseFast and ReleaseSmall modes. There's also +% for guaranteed wraparound twos-complement overflow, and +| for saturating addition. Also `@addWithOverflow` if your jam is checking the overflow bit. Unwrapping an optional without checking it is also safety-checked UB: if you were wrong about the assumption that the payload carries a value, you'll get a panic and stack trace on the line where you did `yolo.?`. Shift operations require that the right hand side of the shift be a type log2(Type.bitwidth) of the left hand side. Zig allows integers of any width, so for a: u64, calling a << b requires that b be a u6 or smaller. Which is fine: if you know values will be within 0..63, you declare them u6, and if you want to shift on a byte, you truncate it: you were going to mask it anyway, right? Zig simply refuses to let you forget this. Addition of two u6 is just as fast as addition of the underlying bytes because of, you got it, safety-checked undefined behavior. In release mode it will just do what the chip does. There's a common theme here: some things require undefined behavior for performance. Zig does what it can to crash your program if that behavior is exhibited while you're developing it. Other things require that you take some well-defined actions or you'll get UB: Zig tracks those in the type system. You'll note that undefined behavior is very much a part of the Zig specification, for the same reasons as in C. But that's not a great excuse to make staying within the boundaries of defined behavior as pointlessly difficult as it is in C.
- dralley 2y agoAnd yet somehow languages such as Rust, which have no UB (in the safe subset) manages to be within 5% of C and often faster in both real-world codebases and microbenchmarks.
- fooker 2y agoIt’s just a change in jargon for ‘marketing’ reasons. For example: Rust will silently wrap signed integers in release mode even when it’s considered a bug and crashes in debug mode.
- Slyfox33 2y agoSigned overflow is not UB in rust. That's not the same thing at all.
- fooker 2y agoIt’s not. You have missed my point.
- sixfiveotwo 2y ago[dead]
- rcxdude 2y agoThat is pretty much the only example where there's a compromise between performance and correctness as a difference between release and debug mode, and note that it's a) not undefined behaviour and b) does not violate any of rust's safety guarantees. Every other example you mention is done by rust in release mode and the performance impact is minimal, so I would say it's a good counterexample to your claims that defining these things would hamstring performance (signed integer overflow especially is an obvious no-brainer for defining. Note that doesn't necessarily mean overflow checks! Even just defining the result precisely would remove a lot of footguns).
- dooglius 2y ago> Now every time you write a/b or *x, the compiler will be forced to emit an extra branching check before this operation. This is wrong, because you would define them to have the behavior that the architecture in question does, so no changes would be needed. For integer division this would mean entering an implementation-defined exceptional state that does not by default continue execution (on Linux, SIGFPE with the optional ability to handle that signal). For dereferencing a pointer, it should have the same semantics as a load/store to any other address--if something is there it works normally, if the memory is unmapped e.g. for typical Linux x86 programs you get SIGSEGV (just as you would for accessing any other unmapped address).
- fooker 2y agoOkay, you get half of the story. Suppose now, there are two architectures with slightly differing behavior. Can the compiler still optimize signed x + 1 > x to true?