This idea isn’t new (PEP 463 – Exception-catching expressions | peps.python.org, which is where I’m pulling lots of my examples from, and PEP 505 – None-aware operators | peps.python.org), but I’d like to discuss it again in the context of dealing with both sides of “dealing with functions that decided to handle failure differently than I need them to”.
The reason why I’m proposing this is because adapting the behavior when calling such functions can usually be expressed as a single thought - “If this value can’t be found, I want to raise an exception” or “if this operation fails, just use this default value instead”, which should optimally translate to a single clear line of code. The current reality is that writing the code to achieve that will often take two to four lines instead, with cumbersome syntax, levels of indentation, and overall more surface area that you have to understand while reading the code, that could produce bugs.
When a function that’s supposed to return a value fails to achieve its goal, there are two common approaches to dealing with it:
- raising an exception
- returning
None
Plus the cop-out:
- provide either a parameter which lets the caller decide what happens (including setting a better default than
None
), or an alternative function with the other behavior
I personally like the cop-out best, but it puts strain both on library maintainers for writing, documenting, and caring for these additional parameters/functions, as well as users who’ll then have to learn one more keyword/function and how it’s used.
Some examples, in case you don’t know what I’m talking about.
- standard library, likes to cop-out with a default-parameter:
next(iter, default)
min(sequence, default)
dict[key]
vsdict.get(key, default)
, two “functions” plus a default-parameter in onelist.pop()
has noNone if raise
option or alternativebytes.decode(encoding, errors='strict' | 'ignore' | 'replace')
, cop-out, but “default” didn’t applyasyncio.current_task()
has noraise if None
option or alternative
- sqlalchemy:
Query.one
vsQuery.one_or_none
- pandas:
pandas.to_datetime(arg, errors='ignore' | 'raise' | 'coerce', ...)
DataFrame.loc
has noNone if raise
option or (straight-forward) alternative
I’d like to propose the following:
# raise if None
task: asyncio.Task = asyncio.current_task() ?? raise ValueError("Can't proceed if no task is present.")
# appropriate default instead of None
match: re.Match = re.search(my_pattern, my_string) ?? re.search("", "")
# None if raise
value: int | None = my_int_container.pop() ?! None
# appropriate default if raise
value: int = my_int_container.pop() ?! 0
# raise a different exception
value: str = my_str_dict["key"] ?! raise IndexError
That’s it. The syntax is inspired by null-coalescing operators that I know from C# and PHP.
Discussion points that I anticipate:
- why not words instead of
??
and?!
, obscure operators aren’t pythonic → happy to hear suggestions, I simply couldn’t come up with anything good that didn’t look like a half-baked conditional expression. - the exception handling should be able to narrow down caught exceptions → I personally agree, but since I’m proposing a pattern with the purpose of simplification, I didn’t want to start off with any potential bells or whistles. Also, I don’t know what it should look like,
foo = func() ?! ValueError: None
? It would make it syntactically different from its??
sibling as well, which might be confusing. - why not provide check-options to both operators then, and have their omission mean
?? None: ...
and?! Exception: ...
respectively → I like the simplicity of just not having that, but maybe it’s not useful enough if it’s missing.