Error sum types do the exact same thing.
> The alternative that the parent said was making all of your state be a union with some kind of error, and making sure all accesses handle the fact the variable might be in a erroneous state.
Which is a non-issue as it is lifted to the type system. The type system will not let you forget about that.
> That is a huge explosion of possible states in your program, and essentially making every invariant weak everywhere.
You get an error state added to a given value, which you also get via exceptions, except implicitly and without notification of the additional state.
Type-safe error values also provide simpler error handling and recovery in many case, because they don't require split-path handling.
> Then FFI, I suppose you mean interfacing with C. Problems that arise when interfacing with other programming languages are orthogonal to a language's ability to be used for system programming.
It very much isn't, part of the system programming workload is to provide reusable components.
> Obviously you wouldn't let an exception propagate through some C code, that's forbidden.
And rarely if every checkable statically, hence unsafe.
If you have 3 variables, each of which can be in 10 states, that's 10^3 states your program can be in.
If instead you have 11 states because all your variables are actually unions with an error, that's 11^3 states (assuming all error states are equivalent to a single state).
Now in practice it's even worse since what you care about isn't the possible states of your values, but rather how many different paths you have in your control flow to handle them.
Then you're comparing 1 (none of my values are in an erroneous state) with 2^3=8 (any of my values can be in an erroneous state or not).
What exceptions do is enforcing that your working set does not have to encode any erroneous states, preventing the combinatorial explosion of states, which of course is a net win, there isn't really any valid argument that can be made against it.
Where people are debating is that sometimes you do want errors to be part of your working set, in which case you shouldn't use exceptions. But choice is difficult for some, especially those seeking absolute doctrines.
> It very much isn't, part of the system programming workload is to provide reusable components.
That's already somewhat dubious, since a lot of system programming tasks are really purpose-built for a usecase or for specific hardware, and regardless, there is nothing about that which has anything to do with interfacing with C.
I do a lot of system programming and I write it all in C++, which has a lot of advantages over C beyond exceptions.
The advantage of ADT approach is that you can store error unions more permanently when it makes sense. It is not additional syntax, unlike exceptions. In that sense it is the simpler approach of the two. If anything it is exceptions that has a little bit more of an explosion of complexity because you have to specify stuff using multiple mechanisms (types vs exceptions), and possibly have to switch between the two when refactoring.
I say that as someone who doesn't think highly of either approach. In my view, plain error values are fine, there isn't any clever language solution needed. If you find yourself checking return values a lot (as opposed to storing error values in handles and checking them at strategic locastions), that can hint an architectural problem.
Exceptions don't do that, they stop the flow entirely, then match it to a point arbitrarily higher on the stack, and resume after that whole sub-tree has been destructed.
They're also much more efficient than branching and maybe returning on the result of every single function call.
Exceptions actually do that, except hidden and unsignaled.
> They're also much more efficient than branching and maybe returning on the result of every single function call.
Not when actually taken.
Which IMO is good in exactly one situation: when raising the exception means that the program contains a bug.
Using panics (ah sorry... exceptions) in this case is justified as it should be really exceptional (if there is a bug anyway we have more pressing problems than performance) and in the absence of bug if we were to use a Result type it would mean we would have a "BugError" variant that is actually dead code everywhere where the program is not buggy.
So in my opinion a correct approach is to unwrap whenever you have an invariant that guarantees that there should be a value, with a panic handler set at the boundary of the logical task to fail the entire logical task in case there is a bug. A logical task can be an asynchronous light task, a thread, or the whole process depending on the situation.
I much prefer it not being the whole process when the process is e.g. a web server or a word processor (and the failure occurred somewhere in an ancillary function)
(I don't see why it should, either. The blanket "efficiency" argument is unconvincing to me).
Ok, I see one reason: The programmer might want control which implementation is used. That would require an additional mini-feature in the language syntax/function types. But this still wouldn't be an argument for a whole different syntax and forced separate code paths as required for traditional exceptions. And it's theoretic anyway -- I don't think it's important to give the user this "control".
This isn't what actually happens though, what actually happens is that people declare local and member variables that are the_type_i_actually_want instead of Result<err, the_type_i_actually_want> and bubble up their errors like they would exceptions. So they get the benefits that you've claimed, but they don't need to pay the runtime cost of not-thrown exceptions that C++ users have to pay, they don't have to use external tools to tell them that functions they're using can throw exceptions, and they don't have to enjoy the wonders of Java where checked exceptions in function signatures regularly prevent the use of streams.
If we were to compare Rust panics vs C++ exceptions, then handling of Rust panics is much less flexible. From what I understand, it's essentially a std::abort and it can be hardly used otherwise, which is only a subset of how C++ exceptions can be commonly used too.
If we were to compare Rust Result vs C++ std::expected, they boil down to pretty much the same with the difference of Rust requiring the call-site to unconditionally check for the return value. That may or may not be preferable in every situation.
> they don't need to pay the runtime cost of not-thrown exceptions that C++ users have to pay
Had this been true, which in 99% of cases it isn't unless you can support your claim, do you mind sharing how Rust implemented their zero-cost panics?
mgaunard says:
> The alternative that the parent said was making all of your state be a union > with some kind of error, and making sure all accesses handle the fact the > variable might be in a erroneous state.
This is exactly what `Result` is in Rust. While I haven't used Rust, it seems that panics are generally discouraged and only used as a last resort whereas exceptions are more commonly used in C++ and Java.
What parent comment tried to (wrongly) imply, and your comment as well, is that exceptions in C++ are (commonly) used as a control flow mechanism. And they are not.
On the contrary, the approach you describe introduce a lot of overhead, since it affects all code paths, the function call ABI, jumps after every function call etc.
Also in C++ you have operators that are integrated in the type system and are resolved at compile-time to know whether an given expression can throw an exception or not. Do not confuse C++ with Java.
This is not true for a variety of reasons, but the main ones are maybe missed optimizations and otherwise-unnecessary spills of objects into memory so that their destructors may be called.
> Also in C++ you have operators that are integrated in the type system and are resolved at compile-time to know whether an given expression can throw an exception or not.
Maybe if you only have a single TU or LTO? In general any function from another TU can throw an exception so you don't have this.
The missed optimization opportunity you describe only affects the Windows ABI, designed in 1989.
> Maybe if you only have a single TU or LTO?
Whether a function can throw or not is part of its signature.
But with decades gone, things changed. That constant costs is relatively not so small anymore, and those branches are much cheaper now.
Oh yes, there is. C++ compiler has to emit unwind tables, register destructors for RAII resources and generate the RTTI information (where applicable).
In this trivial example, consider and compare two versions, the first does not have an exception handler, the second one wrap a single constructor call with a dummy try/catch block:
– No exception handling: https://godbolt.org/z/MK1bof45d
vs
– With a dummy try/catch block: https://godbolt.org/z/hT4Efez1h
For the latter one, the object file size is up by 1kB instantly by virtue of adding a no-op exception handler. Exception handling implementation is not standardised and varies across different compilers AND also across different runtimes. Due to space constraints, the C++ exceptions are oftentimes a big no-no in the embedded world due to the space and time cost the language imposes. As well as long gone are the days when a «try» was a «setjmp» plus a few bells and whistles and «throw ecx;» was a «longjmp».
You do that by having types that encode a guaranteed non-erroneous state. It's not like exceptions are doing anything all that different, they're just trying to establish that guarantee in a language where variant record types and pattern matching are not first-class facilities.
This is something where C and C++ actually regressed from PASCAL, which did have support for variant records.
Early return is nothing like exceptions. Early returns needs to return something which passes the problem to someone else. It's also a choice to do it at all.
> If instead you have 11 states because all your variables are actually unions with an error, that's 11^3 states (assuming all error states are equivalent to a single state).
> Now in practice it's even worse since what you care about isn't the possible states of your values, but rather how many different paths you have in your control flow to handle them.
You're really demonstrating that you have no clue about the subject and refuse to think about it.
If the current function does not deal to specifically deal with erroneous results (aka it would be a passthrough for exceptions) then it unifies the error states into one, by either pruning their branches through early-returning, or unifying the triplet of results into a result of triplet.
Hence you don't have 11^3 states but 10^3 + 1.
> What exceptions do is enforcing that your working set does not have to encode any erroneous states, preventing the combinatorial explosion of states, which of course is a net win, there isn't really any valid argument that can be made against it.
The problem is that none of that is actually true, you're literally inventing combinatorial explosions which effectively don't exist.
Unless they would have to in all cases at which point exception would lead to a significantly worse combinatorial explosion, because exceptions would not allow representing the product of 11 states as just that, and instead would need 20^3 states as every possible value would have to be paired with two error states, success and failure.
> That's already somewhat dubious
It really is not.
> there is nothing about that which has anything to do with interfacing with C.
The C (or system) ABI is the linga franca of inter-language communication, unless you decide to pay for a network cost.
> I do a lot of system programming and I write it all in C++, which has a lot of advantages over C beyond exceptions.
And plenty of drawbacks as well.
But if all you know is C and C++ and you see the entire world through that lens, I can see why you're missing most of the field, you're essentially blind.
I find your tone too inadequate to engage further with you though.
In non-trivial cases they are equivalent too. For example, collections need to maintain at a minimum a valid state in the presence of types with exception-throwing (fallible) constructors. This is a mess with or without exceptions in basically the same way. It's such a mess that the C++ standard allows for unspecified behavior of `std::vector::push_back` if the contained type has a throwing move constructor. Throwing move constructors are of course ridiculous but nonetheless allowed.
And that I would say is the biggest flaw with exceptions: they presume the fallibility of everything by default. This is not only brain damaging, it actively creates situations where there are no good options.
This is incorrect. With error return values you're adding a branch to every function call which is quite expensive on the whole. You're adding i-cache pressure & you're adding branch prediction pressure.
Exceptions in nearly every language that supports them (including C++) don't go through return values at all. Rather when thrown the stack is walked to find an exception handler. So exceptions are more expensive to throw than return values, but completely free when not thrown unlike return values.
> It very much isn't, part of the system programming workload is to provide reusable components.
There's absolutely no issue with exceptions & library boundaries in general. Statically checked exceptions also exist (see Java - although there's a big debate on if that's a good idea or not, but also see https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p07... ), I'm not sure why you're arguing as if they don't.
Is that true though? Placing exception handlers in the stack, so examining every stack frame for one, is equivalent to testing the sum type to see if it is in error state, surely?
My disclaimer: I know very little about compilers, so this is an actual question.
Anyway, the typical implementation involves two phases, one which uses a table to identify the matxhing catch clause, then another one going through landing pads for each frame of the stack. Just consult the Itanium ABI spec for technical details.
Here's a simple example showing exceptions vs. sum types: https://godbolt.org/z/9Er6eKxs9
In the non-throwing exception path, there's literally no error or exception handling code executed at all. Whereas in the sum-type error-returning version, you have a branch at every call site that's always executed regardless of if there's an error or not.
Now the exception handler generates ".cold" clones of the function, so the total assembly for the exception handling one is larger. However, that assembly isn't every executed if an exception isn't thrown, which is the broader point. So it's not taking up CPU cache space & it's not taking up branch predictor slots.
In C++, they are not. Since C++ allows objects to be created on the stack within the each current scope, every constructor call for such a new object has to register its destructor with the exception handler's object cleanup jump tables.
Consider an edge case with a loop where 100k local objects are created. Each constructor invocation will incur an overhead of an indexed memory store of the destructor's address in the exception handler's table[0] for each object.
An indexed memory store is typically 1x instruction for a CISC architecture (it can be more if the data section is located too «far» in the address space, and the ISA limits the offset width in the instruction encoding). It is typically several load low and shift, load low and store instructions on a RISC architecture. L1 D-cache and L2 cache, sometimes L3 cache as well (if the exception table grows large), and TLB reloads[1] get involved at all times. All of the aforementioned is just to register a destructor for an exception that might not occur. So, no, it is not free and (occasionally) the time dimension is not even clearly defined.
This is rather unique and specific to C++ because it is an outlier and allows new objects to be created on the heap AND also on the local stack. Languages that allow new objects to be only created on the heap do not incur such an overhead.
[0] Bonus point: 100k local objects being created inside a try/catch block will also blow the jump table out of proportions and add more cache pressure and cache line reloads.
[1] And even page faults might occur – if the exception table crosses memory pages.
(In theory a compiler-writer could try to reuse destructor-calling code between the normal exit case and the exception case, but that might force one extra branch in the non-exception case.)
It's not equivalent at all.
If that is the whole point of constructors, it's the best argument why Rust does not have them. Programming is chock-full of "this must be called only by that or in this or that context..." and practically speaking, only a part of them can be handled by language objects and construct/deconstruct semantics.
Plus, C++ gives enough escape hatches to get not-constructed objects or to un-construct objects without them going out of scope. The "guarantees" provided by C++ that require a ton of baggage like ridiculous initializer lists or 17 different kinds of constructors (to the point where it's sometimes almost impossible to tell which will be called), or requiring an out-of-band mechanism (exceptions) to signal construction failure.... are simply not worth it in my book.
https://doc.rust-lang.org/rust-by-example/mod/struct_visibil...
The alternative that the parent said was making all of your state be a union with some kind of error, and making sure all accesses handle the fact the variable might be in a erroneous state. That is a huge explosion of possible states in your program, and essentially making every invariant weak everywhere.
Then FFI, I suppose you mean interfacing with C. Problems that arise when interfacing with other programming languages are orthogonal to a language's ability to be used for system programming. Obviously you wouldn't let an exception propagate through some C code, that's forbidden.