3 ms·
I don't think typed errors + isinstance checks are really the alternative. As of Python 3.10, I can just write the same Result-monad-ish destructuring-pattern-
by derefr 4y ago
I don't think typed errors + isinstance checks are really the alternative.
As of Python 3.10, I can just write the same Result-monad-ish destructuring-pattern-matching code in Python that I do in e.g. Elixir:
def foo():
if whatever:
return (True, some_data)
else:
return (False, some_error)
def use_foo():
match foo():
case (True, good_data):
...
case (False, ExpectedErr() as err):
...
case (False, any_unexpected_err):
...
Why bother? Because often, you'll write functions where both "good data" and "bad data" are just data — for example, a classify_spam() function returning (category, confidence_score). Neither case is exceptional; both cases are expected. But you have to handle them separately.
A great example would be an HTTP request-response:
match requests.get(...):
case Response(status_code=code) if code >= 300 and code < 400:
# do a redirect
It would make absolutely no sense for 3xx responses to raise an exception, no? But you do want to handle them separately from the regular flow of code. You want to be dealing with a categorized return object, where you're forced to recognize the category of it — to match on it — in order to do anything further with it.
(I do wish that the default for `match` was to blow up with a ValueError if you run off the end, rather than being a no-op, though. As it stands, you have to explicitly add `case _: raise ValueError(...)` clauses if you want to be strict.)
- bb88 4y agoThe problem is the overloaded "some_data" / "some_error" return value. It's arguably worse semantics than just using exceptions. try: data = foo() except SpecificException as err: ... except BaseException as err: ... except Exception as unhandled_exception: ... It doesn't really look that different than your code above. Further: return (False, some_error) Would be arguably better to write: raise NoBarError(f"Bar wasn't found in file {f}") > match requests.get(...): Speaking of requests I actually like the requests API here. It always gives you a response object, but allows you to do this: resp = requests.get(...) resp.raise_for_status() # raises an exception on 400 and above. It doesn't force you into exceptions, but allows you to choose how you want your errors. The reason is because resp.content may still be meaningful even if resp.status_code is 500. Further it gives you: resp = requests.get(...) if resp.ok: Error and data are not overloaded on the return value. They are a part of the same class.
- derefr 4y ago> The problem is the overloaded "some_data" / "some_error" return value. My point was that it's not "overloaded", because tuples returned for the purposes of matching aren't product-types (i.e. essentially objects but with unnamed positional fields, where the same position has the same meaning regardless of the other positions' values) but rather "mutually-exclusive-pattern-clause-matching conventional sum types" — the pure-data equivalent of the Result monad's `Left(Data) | Right(Error)`. Think Erlang's `{ok, Data} | {error, Error}`. With tagged tuples, you're not supposed to attempt to work with the tuple's fields independent of the whole — i.e. doing tuple[1] is a design smell, because it works regardless of whether you've got the tuple you expected or not. Rather, you're supposed to recognize the pattern that the tuple represents, including the tuple's "shape", and any specific literal tags; and then destructure the result into variables only if the pattern fully matches. Tuples and pattern-matching go together. (And it's kind of weird that Python got one long before the other.) Note that there's no real difference between using tuples here, and using plain data-carrier classes. A type-tag as a positional tuple parameter is not trying to do anything different than an object with an explicit type is; tagged tuples just avoid needing to create little bespoke data-carrier classes for every possible little returning-an-enum-of-patterns situation that crops up a thousand different places in a codebase. > Would be arguably better to write: [raise the error] Why? Just because a piece of data "represents an error", doesn't mean that it's a programming/logic/operational/runtime error in the semantics of your system. It's just data you're carrying around, that represents a certain condition. Maybe a condition you're modelling in some other system. Maybe a condition that's equally common to the "data" path. Picture, for example, implementing an HTTP caching gateway, like Varnish. Receiving a 4xx/5xx response from the origin isn't an error for Varnish — rather, it's an entirely expected condition. An "HTTPErrorResponse" is just a type of data that Varnish works with — caching it, serving it, etc. I recently wrote a Python function called is_domain_trustworthy. It returns True | (False, reason_class, reason_detail). In theory, this function could raise these as errors... but it's not a validation function; it's a predicate. It's answering a question. It wants to answer that question with a yes or no; it's just that in the False case, it also has some extra detail it can pass along about why it decided the domain wasn't trustworthy, which can be used for debugging / audit logging. This "yes or no-and-why" answer to the question is just data. That computed data gets put into a database, even. You can certainly build a validation function on top of this predicate. You'd do so by writing a pattern-match, where one or more arms of the pattern-match raises an exception. But personally, I find it extremely useful to have the "plain" version of the function not raise, but just give me data. More flexible in code; much simpler to work with in the Python REPL; etc. > resp.raise_for_status(); resp.ok A difference with these, is that these are fundamentally examples of a library author trying to predict what the library's users might want to treat as semantic error conditions. Mutually-exclusive pattern matching, on the other hand, doesn't require that the data is modelled to "bake in" any knowledge of conditions users might care about. You can arbitrarily group patterns together. For an example: match resp: case Response(status_code=code) as long_resp if code in [200, 500, 502, 503, 504]: # these requests take a nontrivial amount of time, so capture the request in a histogram case _: pass Note how there's nothing on either arm here that's anything like an error condition. It would never make sense to have a library know about this condition, let alone to treat it as an exception. It's just a pattern, where matching that pattern means your data now takes on some additional meaning.
- dahfizz 4y ago> I don't think typed errors + isinstance checks are really the alternative. It's the alternative proposed by the article, because the code from the article is 5 years old. The author's claim is that calling isinstance is better than exceptions. Pattern matching makes error objects nicer, but still doesn't make them compelling for the general case. There are cases, like HTTP requests, where it makes sense to embed status in the response object. But, critically, every http response has both a status and some content. Every response object is the same type and can be treated in the same way. The article's proposal is to return either a string, or one of a handful of error objects. I will grant that some APIs are nicer when you embed status information in the response type. That is a special case. Preferring error objects over exceptions in the general case is wrong, IMO.
- derefr 4y agoI think things become a lot clearer when you unpack which APIs those "some APIs" are. The case where sum-typed return types (because that's what we're really talking about here) truly shine, isn't in handling "errors" in the sense of invalidated preconditions that you need the caller to handle. Rather, the case where sum-typed return types make sense, is in handling condition states more generally. When I say "state" here, think "finite-state machine." Or actually, maybe think "pushdown automata." Imagine a sum type representing the state of a door, like: Open | Closed(door_tag: DoorTag() | None) | ClosedAndLocked(required_key_bidding) If you call door.try_open(), it makes perfect sense to return this sum type. This type represents the new state of the door, after your attempt. None of the types are exceptional per se, because this is just an attempt to open the door. You're equally happy to have the door in any state afterward. "Open if you can", per se. Certainly, you could write another function door.open() † that does throw an exception if the door cannot be opened. It might also raise a DoorAlreadyOpenException. But this function makes more sense to implement on top of the first function, than the other way around. Door.try_open is more core to how the door models its state and reacts to input, than Door.open is. † In other languages, the naming-complexity expectation would be reversed — the non-raising variant would be `Door.open`, while the raising variant would be something like `Door.open!`.