this post was submitted on 15 Nov 2023
24 points (90.0% liked)

Python

6331 readers
119 users here now

Welcome to the Python community on the programming.dev Lemmy instance!

πŸ“… Events

PastNovember 2023

October 2023

July 2023

August 2023

September 2023

🐍 Python project:
πŸ’“ Python Community:
✨ Python Ecosystem:
🌌 Fediverse
Communities
Projects
Feeds

founded 1 year ago
MODERATORS
 

This is a discussion on Python's forums about adding something akin to a throws keyword in python.

all 28 comments
sorted by: hot top controversial new old
[–] palebluethought 11 points 11 months ago (2 children)

Aren't checked exceptions in Java generally regarded as a bad mistake?

[–] [email protected] 9 points 11 months ago

Yes, but not because the goal of having exceptions in types is bad, rather Java's type system isn't advanced enough to support the ideal solution here.

Scala 3 is working on experimental capture checking capabilities, which allows functions to express certain capabilities (file access, networking, db, etc.), and CanThrow capabilities (e.g exceptions at the type level) are one reification of this.

The CanThrow docs I linked have a good introduction into why Java checked exceptions are bad, and how Scala's alternative is far better. Essentially it comes down to a lack of polymorphism in checked exceptions. In practice this means they're incredibly verbose outside of simple usecases, and with a very easy escape hatch (RuntimeException), you don't even get the guarantee of knowing a function without checked exceptions doesn't throw.

Python will also have this latter issue. Python's "typing" in general has this issue actually. Types aren't validated unless you use an external tool, and even then Any is a leaky abstraction that can hide any level of typing errors, unlike in properly typed languages where it's not leaky. You need it to be leaky in gradually typed environments, or you wouldn't be able to use a ton of the Python ecosystem, but this vastly reduces the effectiveness of the typing solution.

I don't know if Python's solution here will address the lack of polymorphism that Java's solution has, I'll have to look into it more.

[–] [email protected] 3 points 11 months ago (1 children)

I heard the same, but not sure why. Do you have a link?

[–] [email protected] 6 points 11 months ago (2 children)

When I used to write Java and switched to Python, this was one of the things I missed. It was always quite clear which exceptions I had to catch (or not). Just today, I ran into the issue of trying to cover the exceptions a library could throw without using except: or except Exception as e, but finally gave up and gave in to it. The linter wasn't happy, but fuck it.

[–] [email protected] 9 points 11 months ago
[–] [email protected] 3 points 11 months ago* (last edited 11 months ago) (2 children)

I disagree, I'd instead like to move toward handling errors as logic, and keeping exceptions for actually exceptional cases. If you're expecting an exception, that's data.

So here's my proposal:

  • introduce monads like Maybe/Result that forces the dev to handle expected errors in logic
  • make an easy way to return errors early without interrupting logic flow
  • simplify checking for None values in chaining

For the first (not exactly a monad, may need a new type to wrap things):

def maybe_err(val: int) -> Result[int, ValueError]:
    if val < 0:
        return ValueError("cannot be negative")
    return val

match (val := maybe_err(-1)):
    case int():
    case ValueError():

For the second:

val = maybe_error(-1)?  # special handling to return instances of Error early

And the third:

val = x?.y?.z ?? DEFAULT

I like this much better than having try/except blocks throughout the code, and reserve those only for logging and whatnot at the top level. If you document exceptions, people will use them even more as data instead of exceptions.

So only raise if you want it to bubble all the way up, return errors if it's just data for the caller. Libraries should almost never raise.

[–] [email protected] 1 points 11 months ago (1 children)

Anything but over9000 variations of nullables like in C#

[–] [email protected] 1 points 11 months ago* (last edited 11 months ago)

I'm not too familiar with C# (last used it like a decade ago), but I think the rules here would be pretty simple:

  • x? - if x is None or an Error, return from the function early, otherwise use the value and continue
  • x?.y - same as above, but with an attribute of x
  • x ?? y - instead of returning as in the first, use y as the default value

And maybe add an option to convert exceptions from a function to an Error value (maybe some_func?() to convert to error values? IDK, I haven't thought through that part as much).

Hopefully that's simple enough to be useful.

If I were proposing this, I'd limit it to optional chaining since that's far more annoying to me currently.

[–] [email protected] 1 points 11 months ago (1 children)

@sugar_in_your_tea If you're expecting exceptions, make custom ones. That's the best way to distinguish between those you expect and those you don't. Using custom exceptions improves readability too.

[–] [email protected] 3 points 11 months ago (1 children)

My point is that I don't like using exceptions for communicating regular errors, only unrecoverable faults. So adding features to document exceptions better just doesn't feel like the right direction.

Maybe that's un-Pythonic of me, idk. From the zen of Python:

Errors should never pass silently.
Unless explicitly silenced.

Using monads could let programmers silently pass errors.

I just really don't like the exception model after years of using other languages (mostly Rust and Go), I much prefer to be forced to contend with errors as they happen instead of just bubbling them up by default.

[–] [email protected] 0 points 11 months ago (1 children)

@sugar_in_your_tea The idea of exceptions is that you can choose when to deal with them. So if you want to deal with them immediately,
nothing is stopping you.

If you think handling errors with every function call explicitly is easier, I guess you're using very few functions. For the project I'm working on, your proposal would probably double the number of lines. Thanks, but no thanks.

[–] [email protected] 1 points 11 months ago* (last edited 11 months ago) (1 children)

Handling can mean a lot of things. You can use a sigil to quickly return early from the function without cluttering up your code. For example, in Rust (code somewhat invalid because I couldn't post the generic arg to Result because lemmy formatting rules):

fn my_func() -> Result {
    let val = some_func_that_can_error()?;
    return Some(val.operation_that_can_error());
}

let val = match my_func() {
    Err(err) => {
        println!("Your error: {err}");
        return;
    }
    Some(val) => val,
};
// use val here

That question mark inside my_func shows the programmer that there's a potential error, but that the caller will handle it.

I'm suggesting something similar for Python, where you can easily show that there's a potential error in the code, without having to do much to deal with it when it happens if the only thing you want to do is bubble it up.

If we use exceptions, it isn't obvious where the errors could occur, and it's easy to defer handling it much too late unless you want to clutter your code.

[–] [email protected] 0 points 11 months ago (1 children)

@sugar_in_your_tea I'm by far not qualified to discuss this in depth. But it seems to me that almost every function call ever can fail. Therefore, do you need to do this with every single function call?

That seems terribly inefficient and bloated. How is that readable for anyone?

[–] [email protected] 1 points 11 months ago (1 children)

That's where the difference between exceptional cases comes in. Rust and Go both have the concept of a panic, which is an error that can only be caught with a special mechanism (not a try/except).

So that'll cover unexpected errors like divide by zero, out of memory, etc, and you'd handle other errors as data (e.g. record not found, validation error, etc).

I don't think Python should necessarily go as far as Go or Rust, just that handling errors like data should be an option instead of being forced to use try/except, which I find to be gross. In general, I want to use try/except if I want a stack trace, and error values when I don't.

[–] [email protected] 0 points 11 months ago* (last edited 11 months ago) (1 children)

@sugar_in_your_tea But isn't all that possible in Python? Don't monads cover exactly what you want? Why does it need to be implemented some different way?

Also, divide by zero should be data just as well. Failing to program around having nothing to divide by is not a reason to have a program panic.

Also, having two systems for largely the same behavior doesn't seem to improve usability and clarity, in my opinion.

[–] [email protected] 1 points 11 months ago (1 children)

divide by zero should be data as well

I disagree. You should be checking your input data so the divide by zero is impossible. An invalid input error is data and it can probably be recovered from, whereas a divide by zero is something your program should never do.

If having the error is expected behavior (e.g. records/files can not exist, user data can be invalid, external service is down, etc), it's data. If it's a surprise, it's an exception and should crash.

doesn't seem to improve usability

I'm proposing that the programmer chooses. The whole design ethos around Python is that it should look like pseudocode. Pseudocode generally ignores errors, but if it doesn't, it's reasonable to express it as either an exception or data.

Documenting functions with "throws" isn't something I'd do in pseudocode because enumerating the ways something can fail generally isn't interesting. However, knowing that a function call can fail is interesting, so I think error passing in the Rust way is an interesting, subtle way of doing that.

I'm not saying we should absolutely go with monadic error returns, I'm saying that if we change error handling, I'd prefer to go that route than Java's throws, because I think documenting exceptions encourages bad use of exceptions. The code I work on already has way too many try/except blocks, I'm concerned this would cement that practice.

[–] [email protected] 0 points 11 months ago (2 children)

@sugar_in_your_tea Since when is Python supposed to equal pseudo code? It should be easily readable, but that doesn't mean it should *equal* pseudo code.

You can either test for values being 0 before dividing, or catching an exception when it is. Especially when dividing multiple times in one function, I would go for the latter option.

[–] [email protected] 1 points 11 months ago (1 children)

It's not an explicit design goal, but it explains a lot of the Zen of Python and other pushback on PIPs, so to me it's always been an unwritten design goal (be as close to pseudocode as practical, but no closer). It's also how I generally write code (start with Python "pseudocode," then decide what to use in production).

For example, from the Zen of Python:

There should be one-- and preferably only one --obvious way to do it.

Being clever in Python is a bad thing, just as it is in pseudocode. Python will never win awards for performance, so if you need that, you drop in something non-Python to do the expensive operations to keep the rest of the code clean and obvious.

If you think of Python as pseudocode, everything else makes a ton more sense.

You can test for values being 0 before dividing, or catching an exception when it is.

Ideally, you just test for input variables outside of the function and do neither. Something like:

def calc(x, y):
    assert x > 0
    assert y != 0
    ...

This throw exceptions if the preconditions fail, but those can (and should) be removed for production since their primary purpose is to inform the developer of the preconditions and catch mistakes in development. In production, you'd rely on some kind of schema validation to ensure the asserts never trigger (I'm partial to Pydantic).

So ideally you'd never expect a divide by zero or clutter your code with zero checks outside of those asserts (which shouldn't be relied on) because you've already prevented those cases from happening.

[–] [email protected] 0 points 11 months ago (1 children)

@sugar_in_your_tea Using asserts in any code except testing is frowned upon, afaik. You should use specific exceptions instead of vague unlabeled assertion errors.

You also seem to think that you're not allowed to use exception to communicate the fact a check failed. If that's the case, you're seriously underusing the power of exceptions.

It sounds a lot to me like you don't even want to use Python or think it shouldn't be used for anything serious. Why then even argue about it?

[–] [email protected] 1 points 11 months ago

Assertion errors should never fire, they're merely there for documentation and catching mistakes in development. Any assertion is merely a sanity check (the value should've been checked before calling the function), which is why they're disabled in production.

In fact, I conceptually like the way D makes checking preconditions and postconditions explicit. However, it's clunky in practice imo, so asserts are usually elegant enough. I honestly only use asserts when it's the clearest way to document the usage constraints.

you're seriously underusing the power of exceptions

No, I use them for communicating data errors and whatnot and have a bunch of custom exceptions in my code. It's the current Pythonic way, so that's what I do.

However, I don't like that pattern and find it to either hide errors or clutter my code. I much prefer the Rust style of error handling where errors are always acknowledged when they can happen, but usually handled at a higher level (like you'd do in Python, but with explicit syntax to acknowledge a call could error). I find this gives me, the programmer, a chance to consider the error case to correct logical mistakes before actually running any code, and it also improves code reviews because it's obvious to the reviewer that the code could error. I've had far too many bugs caused by not knowing or forgetting a call could raise an error.

you don't even want to use Python

When did I say that? I use it at my day job and actually argued against using Rust for our project because Python maps really well to our problem domain. Our project is hundreds of thousands of lines of Python across a dozen or more microservices, and it has served us well.

Criticizing a language doesn't necessarily mean I don't like it, it just means I think it could be better. Python is generally my first choice unless I know I need top performance and correctness out of the gate. For example, I'm writing a distributed lemmy competitor in Rust in my free time for various reasons (mostly I don't want to deal with Python installers, and there's no server component), and also building a game in Godot in GDScript (very similar to Python, even worse in perf). There are very few languages I actively dislike.

That said, in general, I prefer functional-style programming, and exceptions are one glaring wart that makes FP in Python feel bad. I want that to be better.

[–] [email protected] 0 points 11 months ago (1 children)

@sugar_in_your_tea I don't think we should change any functionality when it comes to exception handling. Code based documentation would be great for type checking and auto-generated docs, but they can be done using annotations, not changed interfaces.

Monads are already possible, but should not be the normal way to code either. It's clunky and difficult to understand. It might work great for some scenarios, but doesn't for many others.

[–] [email protected] 1 points 11 months ago

Monads are only clunky because Python doesn't really support them.

And I agree, I don't think we should change existing exception handling, just allow the programmer to interact with it differently. I'd love to be able to turn exceptions into monads with a little bit of syntax. Under the hood, Python would still do the try/except, but my code would use exceptions as values instead. You'd still be able to use the older try/except explicitly just like you can express a list comprehension as a generator manually, you'd just have the option to do something else if it's cleaner in your project.

That said, exceptions as values isn't a hill I'm willing to die on, but I will push against "throws" being added, and optional chaining is a hill I'm willing to die on.

[–] [email protected] -2 points 11 months ago

Yeah, let's not. This is not a good idea