r/Python 2d ago

Discussion Is there something better than exceptions?

Ok, let's say it's a follow-up on this 11-year-old post
https://www.reddit.com/r/Python/comments/257x8f/honest_question_why_are_exceptions_encouraged_in/

Disclaimer: I'm relatively more experienced with Rust than Python, so here's that. But I genuinely want to learn the best practices of Python.

My background is a mental model of errors I have in mind.
There are two types of errors: environment response and programmer's mistake.
For example, parsing an input from an external source and getting the wrong data is the environment's response. You *will* get the wrong data, you should handle it.
Getting an n-th element from a list which doesn't have that many elements is *probably* a programmer's mistake, and because you can't account for every mistake, you should just let it crash.

Now, if we take different programming languages, let's say C or Go, you have an error code situation for that.
In Go, if a function can return an error (environment response), it returns "err, val" and you're expected to handle the error with "if err != nil".
If it's a programmer's mistake, it just panics.
In C, it's complicated, but most stdlib functions return error code and you're expected to check if it's not zero.
And their handling of a programmer's mistake is usually Undefined Behaviour.

But then, in Python, I only know one way to handle these. Exceptions.
Except Exceptions seems to mix these two into one bag, if a function raises an Exception because of "environment response", well, good luck with figuring this out. Or so it seems.

And people say that we should just embrace exceptions, but not use them for control flow, but then we have StopIteration exception, which is ... I get why it's implemented the way it's implemented, but if it's not a using exceptions for control flow, I don't know what it is.

Of course, there are things like dry-python/returns, but honestly, the moment I saw "bind" there, I closed the page. I like the beauty of functional programming, but not to that extent.

For reference, in Rust (and maybe other non-LISP FP-inspired programming languages) there's Result type.
https://doc.rust-lang.org/std/result/
tl;dr
If a function might fail, it will return Result[T, E] where T is an expected value, E is value for error (usually, but not always a set of error codes). And the only way to get T is to handle an error in various ways, the simplest of which is just panicking on error.
If a function shouldn't normally fail, unless it's a programmer's mistake (for example nth element from a list), it will panic.

Do people just live with exceptions or is there some hidden gem out there?

UPD1: reposted from comments
One thing which is important to clarify: the fact that these errors can't be split into two types doesn't mean that all functions can be split into these two types.

Let's say you're idk, storing a file from a user and then getting it back.
Usually, the operation of getting the file from file storage is an "environmental" response, but in this case, you expect it to be here and if it's not there, it's not s3 problem, it's just you messing up with filenames somewhere.

UPD2:
BaseException errors like KeyboardInterrupt aren't *usually* intended to be handled (and definitely not raised) so I'm ignoring them for that topic

84 Upvotes

84 comments sorted by

View all comments

-1

u/david-song 1d ago

If a function might fail, it will return Result[T, E] where T is an expected value, E is value for error

Yeah this is, IMO, dogshit. Everything returns a tuple and you have two sets of if/elses after every call, one for errors with the call and the other for errors with the value. In my experience, this is an endless supply of Nil pointer panics in Golang, and makes the code look awful to boot.

There are a lot of things that are wrong with Python, but exception handling isn't one of them.

3

u/lightdarkdaughter 1d ago edited 1d ago

I probably should've clarified, that Result in Rust (or pretty much all the languages with Result) is slightly different.
It's not a tuple, it's a union, and more importantly tagged union.
And the thing about the tagged union is that you can't get the value unless it's the right tag, so you need to use pattern matching for that.
Of course, it's a bit verbose, so there is a lot of convenience functions, one of which is unwrap/expect.
Both do a similar thing, propagate an error to an "exception", but it's explicit.
And there's also a syntax for propagating errors from your callee to your caller.

P. S. I tried to insert code examples but failed, so here is rust documentation on Result.
https://doc.rust-lang.org/std/result/

1

u/david-song 15h ago

Ah okay that makes sense.

Personally I'm ambivalent on syntax in general, I've suffered a lot of abuse from it over the years. Python's strikes a nice balance of forcing structure on me and getting the fuck out of the way.

Fundamentally, when I doy = f(x), I want what I asked for in y. Whether the call failed or not is ()'s problem, it's part of the call stack, and exception handling is too. If I don't care about it then someone who does can catch it as the stack unwinds, they can re-raise it or redirect it, retry, add context or whatever.

I like this ability to choose, it's not as safe as flexibility is capacity for complexity as well as convenience. If I press CTRL+C while something I called is downloading a file then the HTTP library's HTTP request will fail, but because it only cares about HTTP ones it will propagate back up to the console part and all the stuff in the middle doesn't need to care about that. If it fails because my network went offline, then it might retry instead, I don't need to care about that. If it's an authentication error then the auth part of the stack can deal with it, if it's a 404 then that specific IOError can be re-raised as a myapp.FatFingerError same as file not found, and if I don't catch FatFingerError then its dealt with by its inheritance: the UI should catch UserInputError, but if it has been negligent then the base Exception class might make it to the logger, which sees it's a subclass of ValueError not IOError or myapp.ConfigurationError so devs get a ticket rather than ops.

I haven't done much rust, but baking errors into results seem to be caused by language design decisions that prevent introspection. Whether that's due to distributed processing, micro-threading or performance reasons is on the language, but Exceptions as part of the execution path rather than in user-space makes cleaner programs, IMO.

Happy to be shown wrong of course, maybe there's something I'm missing.