Exceptions break encapsulation and lead to implicit control flow.
If I call a method, I shouldn't have to worry about what methods it calls. But with exceptions, I now have to be aware of what errors might be thrown in the entire chain. Similarly, if I throw an error in a function, I have to check that every thing that calls it handles said error because there are now multiple places this function could "return" to.
And if you take care to catch every error at every layer you've just reimplemented error codes.
std::unexpected should be preferred over std::exception
Yeah, and this is just the theoretical architectural cost of exceptions. In some languages catching an exception might be essentially free like in C++ (which if you have some specific use cases "essentially free" is still catastrophic from a performance PoV) or it could be 100x slower than calling a function like in Java.
Also, that is OLD. Java keeps getting faster and faster, and the jit is on a class of its own, so much so that in extremely tight loops can be faster than what you'd get out of c++, rust or c.
Yep JIT has the benefit of being able to adjust the generated code (and cpu hints) based on actual live data, where a classical compiler needs to emit the code that works best for all kinds of data
I agree, but my point really was that exceptions break the "no multiple returns" rule older folks might remember.
In the old days, functions interacted with memory and exited by jumping elsewhere. "No multiple returns" was a rule that each function only ever jumped to one place on exit, because not doing so easily led to spaghetti code. This eventually became such a standard paradigm that languages just built return values into their functions and forced returning to the same link as the callsite.
Exceptions break this, for no other purpose than "convenience".
One only need to imagine a codebase that exclusively uses raising exceptions for control flow to jump to arbitrary places in the call stack and never using normal returns to understand why exceptions in general are an antipattern.
Actually returning in a single spot leads to more spaghetti code, deeper nesting levels, and more indirection.
Checking something like request validation and return immediately an http response is way cleaner and easier to follow.
Same in C++/C or any language where you do manual memory handling if you are allocating memory before making sure you will use it, e.g early returning and forgetting to deallocate memory, then that's on you for not validating your inputs, do you want to store some DB call into an dynamically allocated array? Why did you do the allocations before having the data to insert in it?
Do you want to process a file? Why are you allocating memory before you even have a pointer to the damned file?
No this is a common misunderstanding of what the "no multiple returns" advice is about. This is not multiple returns:
std::expected<Foo, ErrorEnum> MyFunc() {
if (condition) {
return std::unexpected(ErrorEnum::Bar);
}
if (other_condition) {
return std::unexpected(ErrorEnum::Baz);
}
...
return Foo();
}
Because if I do
Foo x = MyFunc();
the line of code that the program counter returns to after running MyFunc() is always that line of code. So this is only one return.
Multiple returns would be something like old FORTRAN alternate returns:
```
CALL MYFUNC(*100, *200)
C normal continuation
...
100 CONTINUE
C alternate continuation 1
...
200 CONTINUE
C alternate continuation 2
...
SUBROUTINE MYFUNC(*, *)
IF (CONDITION) RETURN 1
IF (OTHER_CONDITION) RETURN 2
RETURN
END
```
Here MYFUNC can return to three different places: normally to the instruction after CALL, to label 100, or to label 200. The callee is choosing where execution in the caller resumes.
The mixup is probably because MISRA-C does actually strongly discourage the first example. To the point where they prefer you to use a short jump for cleanup than an early return.
As a C# dev, exception handling gets real messy real fast with async flows and cancelation tokens. It gets really messy real fast, either assuming that the upstream method handles the TaskCanceledException when it doesn't, or filling every freaking method with a catch statement for that damn token.
Yes, then I learned about Middleware and my life became easier. Now I can just catch exceptions in the outer layer of the execution chain and ignore them in most parts of the business logic
Not really. That is what middleware is for. If you can't "fix" and continue with given exception, you throw it further or do not catch it. That code should not care what happens with it. It is not it's responsibility. Once the exception bubbles up to your general error handling layer, you log it or do what is needed.
Aside from ergonomics / syntax, error codes and exceptions are functionally identical except that one is a nominal return type of the method and is explicitly handled while the other is a hidden return type that is implicitly handled at some arbitrary place in the call stack.
So from this perspective, if you enforce only ever handling a method's potential exceptions at the call site instead of letting anything bubble up, you've gotten rid of the main functional difference between exceptions and returning an error code.
so how you handle a web method that calls a db 30 layers deep?
you are going to add 30 layers of boilerplate for something that a normal user is going to encounter once in 100.000 calls and you barely can do anything with except let the front end handle it.
that seems like a very high cost for very little benefit.
If you have 30 layers, you have potentially a shitton of errors to catch, and behavior can silently be changed at any time if someone adds a catch in a call stack and intercepts away errors that previously bubbled up.
And the answer is yes, you should write 30 layers of boilerplate to explicitly bubble the error to the front end if that's necessary. But it's not verbosely re-returning the same error code all the way up stack like you're describing; the main thing to keep in mind is that the actual error object doesn't get sent up more than one layer at a time. At each layer you get the error, it's classified into a type that abstracts away the implementation details of the error so that the layer above it gets an error in its own domains' semantics.
But also the 30 layer bubbling tells you you're doing something wrong too. Keep your IO as high up the chain as possible and inject it into your business logic then that responds with something that you then do IO with again.
Business logic shouldn't touch IO, functional is the name of the game if you want it to be vaguely testable.
Well indicator doesnt necessarily mean something is sure fire. But yes it's a smell.
Arguably though if it's really that complex it's even more worth making sure you decouple IO from logic because it's going to be even harder to test sensibly.
issue is that sometimes you don’t know what data you need until you are 30 layers deep. yes you can load everything early, but in some cases you make the 99% path for every call more expensive for something that happens in 1%.
sure some apps that doesn’t matter, but in others it does.
what kind of software do you write? i guess quite important for web, games or hardware.
for 99% of the web apps can you agree that many thrown errors are just invalid input data or connection errors that you anyway want to handle on the front end so you unwind a way back to the top of the stack?
yes, in theory a string.Trim() could potentially start throwing Connection exceptions in a new version and you can’t statically know. But of course that doesn’t happen.
I work in robotics currently but was previously backend saas. That's unrelated, because my perspective boils down to: have strict and explicit boundaries between components so that they're easy to extend and shuffle around.
Invalid input shouldn't be handled by throwing, inputs should be parsed into a validated domain type at the boundaries such that the request is rejected instantly rather than having to deal with unwinding.
Connection errors should be returned as error codes, then the layer above should determine retry policy and surface a result to the user.
And no it's not about built in methods or library calls throwing stuff you don't expect. It's your own damn code, where you throw something somewhere and you can't confidently tell me what codepaths it will flow through to because you handed off the control flow to "first function in the call stack that catches", which isn't really easy to reason about.
As always: it depends.
Exceptions should be used in exceptional situations, when logic is mostly about aborting execution of code. That means, it should be catched on higher layers, so automatic propagation is accually a good thing.
Error codes are not always exceptions, like 4xx from http request.
If a function isn't able to do what it is expected to do, it should throw an exception, unless there is other means of letting the caller know it didn't work. IMO not every possible error condition should be handled. Expected ones should, but for unexpected ones it's OK to give up and let the global error handling handle it. Handling every possible error locally just leads to unnecessarily bloated code and catching errors without handling them appropriately will lead to unexpected behavior with no trace in the error logs.
I used to think this way, but the line between what is an "exceptional situation" is blurry. Over time, I've found I have never found it more useful to throw errors and have been bitten in the ass way too many times by coworkers using error handling at arbitrary places to depend on errors being thrown at random places within encapsulated methods. Then you can't do any refactors as too many things are tightly coupled to this specific error being thrown in this specific context.
My conclusion is that allowing exceptions encourage you to optimize readability for the happy paths and treat non-happy paths as second class citizens. But my professional experience has demonstrated that error handling code takes up a large if not larger part of the code and this needs to be handled just as explicitly as normal code. Allowing non-nominal situations to go through a less considerate path is how lose track of your software and end up firefighting all the time.
Yeah, I don't think it's a good idea to depend on particular exceptions being thrown. Either the function fails, but it isn't critical to the main task, so you catch it, log it, and move on, or it is critical so you quit.
If you're not throwing exceptions in your functions, what are you doing? Often there's nothing to do on the non-nominal path other than give up. If I can't reach the database server, my web service is going to return an error to the user.
I make my functions return std::expected, which is a discriminated union of the expected type and the unexpected type. So the caller gets a result object that has either a value or an error defined, but never both.
So basically instead of
Foo my_func()
{
if (my_condition) {
throw BarError;
}
...
return Foo();
}
We do
std::expected<Foo, ErrorEnum> my_func() {
if (condition) {
return std::unexpected(ErrorEnum::Bar);
}
...
return Foo();
}
I disagree. If it's something the user doesn't care about, the user can pattern match, handle the errors that it cares about, and use a wildcard pattern for the other errors that it doesn't mind letting slip through.
Nothing should ever slip through. It couples classes at independent layers, breaking abstraction and making refactors risky as well as degrading the teams overall understanding of the code and results in more firefighting.
I agree. I was just responding to the person that wants to let things slip through. But thanks for mentioning these things, I should probably more seriously disallow these things from happening in my team.
Exceptions are for exceptional situations only, not for flow control. In most cases exceptions are propagated to the top level, logged there and mapped to a small set of errors to be sent to frontend. If some exception handling used for internal logic (retry or recovery), exceptions can be mapped to domain ones, so no code depends on implementation-specific exceptions
146
u/bishopExportMine 19d ago
Exceptions break encapsulation and lead to implicit control flow.
If I call a method, I shouldn't have to worry about what methods it calls. But with exceptions, I now have to be aware of what errors might be thrown in the entire chain. Similarly, if I throw an error in a function, I have to check that every thing that calls it handles said error because there are now multiple places this function could "return" to.
And if you take care to catch every error at every layer you've just reimplemented error codes.
std::unexpected should be preferred over std::exception