Revisiting PEP 505 – None-aware operators

I have not read the entire thread, but coming from typescript, I really - like really - miss the ‘?’ operator. I see the initial proposal is from 2015, so - high time to reconsider this

I would expect it to behave like just like in typescirpt
x = a.b?.c or 12 if b is None or c is None then the vlue is assing 12

2 Likes

My opinion here. The later half of the thread is more correct.

  1. a?.b === __tmp.b if (__tmp := a) is not None else __tmp (later half might be None); same operation priority as a.b
  2. a ?? b === __tmp if (__tmp := a) is not None else b; operation between bitwise or (|) and comparisons, executed left-to-right
  3. a?[b] is not needed, but in case it is – it should behave exactly as a?.__getitem__(b), with the operation priority of a[b]
  4. No other ?syntax is required

Basically, ‘optional chaining’ is a special case of expression-style exception redirection. Currently, I handle my OC needs like:

x = TRY(lambda: obj.a[2].b, nan) 
def TRY(func, default=None, exceptions=(AttributeError, KeyError, IndexError)): ...

This is already brief enough for the occasional real world cases, and even way more flexible. Which makes me question the need for extra (ugly) special operators (?., ??, …) for a very limited use case.
And Python already has chosen ‘nice’ no-new-symbol expression forms for other situations - list/generator comprehensions, ternary ... if ... else ...), global/nonlocal, super() , …

So alternatively, more universal mechanisms could be considered, like:

# just drop the lambda above. A magic pseudo function (like super())
x = try(obj.a[2].b, nan)   # default on Attribute/Key/IndexError 
x = try obj.a[2].b except nan

Or, extending the conditional expression:

x = (obj.a[2].b    except   None)      # default on Attribute/Key/IndexError 
x = (float(y) + z  if COND else DEFAULT   except(ValueError, TypeError) nan) 
x = (obj.a[2].b    if >= 0 else DEFAULT   except nan) # if <comp-op> vs. `??`

This would cover way more than OC use cases and exposes typo prone code more salient.
See also: PEP 463. Ruby rescue (the only nice one from other languages I found)

3 Likes

Not sure if it’s been mentioned here, but a core use case that drives interest in this for me and my team is type-safe access to deeply nested protobuf message. As you may know, if you opt in to explicit field presence in protobuf with the optional keyword, you get Optional fields in the generated bindings.

That means that we have access patterns where a message is received, and the user cares about a specific field of a sub-message, and they have to write something like this:

def handle_message(message: Message):
    foo = message.foo
    if foo is None:
        return None
    bar = foo.bar
    if bar is None:
        return None
    baz = bar.baz
    if baz is None:
        return None
    use_bar(baz)

or, more esoterically

baz = (o := message.foo) and (o := o.bar) and o.baz

Both of which would be lovely to write as:

baz = message.?foo.?bar.?baz
7 Likes

Following up on my answer, I think a notable consequence of any implementation like proxies or lambdas with exception handling necessarily includes the expression a.b.c somewhere in your code, which means one of two things:

  1. Your type-checker is unhappy with you
  2. You have to disable rules in your type-checker

Therefore, if you want type-checking, the language must provide an expression type that is meaningfully different which would signal to type-checkers “hey, this expression isn’t really going to be evaluated blindly”. That’s if you want to use lambdas or some mechanism that guards the final expression with an except clause. If you go the .? route, the type-checking problem is solved.

This PEP has been stuck in circles for AGES and even just that fact has been mentioned many times. For this reason, sorry if the following points have been brought up before.

For this endless cycle to end, what could help is to divide this PEP into 2 parts:

  1. The first PEP is purely for None coalescing. (.? and optionally ?? and ??=)
  2. Afterwards we can make new proposals for dunder methods or other ways to make this behaviour more customizable, not just work for None.

I feel like otherwise we will be forever stuck in this loop of people coming up with ideas that are either in the group 1 or group 2 and no consensus being found.

Inspired by C# and Python’s dict, for the first PEP I would additionally propose adding a safe get method to list, tuple (and possibly other sequences) to support lookup with a default, instead of having to handle IndexErrors.
This would perfectly allow coalescing nested structures, without having to special case any LookupErrors.

Here is how get would look in pseudo Python code:

class list/tuple[T]:
    ...
    
    @overload
    def get(self, index: int, /) -> T | None:
        pass

    @overload
    def get[TDefault](self, index: int, /, default: TDefault) -> T | TDefault:
        pass

    def get(self, index: int, /, default: Any = None) -> Any:
        try:
            return self[index]
        except IndexError:
            return default

Here an example of the usage:

nested: dict[str, list[tuple[tuple[str, ...], ...]]] = {
    "nest": [
        (
            (
                "Hi",
            ),
        )
    ]
}

nested.get("nest")?.get(0)?.get(0)
>>> "Hi"

TL;DR

  • Split PEP into 2 parts, the first being just None support, the second one being for additional customizability.
    In part 1, additionally add safe get to list, tuple (…) to allow easy None coalescing.
6 Likes

I would actually love to have list/tuple.get(index, default=None). That’s the best proposal in this thread so far.

28 Likes

One small addition:
I think we could just add it to the Sequence abstract base class in general with the default pseudo implementation I described above. This should not break any backwards compatibility.

Also, if people don’t like get as a name, another idea would be the name at.

No. The interfaces defined by Sequence and co inside of collections.abc are effectively frozen. We can add new classes, but we can’t modify existing classes because implementing the interface does not require implementers to actually inherit from the class. See the big comment in _collections_abc talking about this issue.

2 Likes

I like get as the name because it makes lists and tuples interchangeable with dicts in code that doesn’t care about the type. Currently you can do:

def f(dict_or_list, i):
    try:
        return dict_or_list[i]
    except LookupError:
        return None

With .get then it could just be dict_or_list.get(i).

Thinking types/ABCs first in Python is going to lead you to poor implementation decisions (as Cornelius already said, but allow me to go a bit deeper). All typing is duck-typing/structural-typing, and the types and ABCs are there as scaffolding, but are never required. So additions or changes to typing types or ABCs can only break legitimate uses, because they don’t automatically get the addition/change. It’s just not how Python works or is structured.[1]


  1. And if you really want it to be, choose literally any other language. I like that Python is actually different here - there are more than enough languages that force you to code under remote control, and it’s so powerful that Python doesn’t restrict you like that. ↩︎

16 Likes

A getitem counterpart to getattr would be my preferred spelling, since it would work for any sequence, not just the built-in ones.

11 Likes

In my opinion the ?. operator would be very helpful! In document data models with a lot of optional values it can get very verbose in Python to navigate the properties nowadays, especially if you want code that is typed correctly.

1 Like

If it helps, I’m now in favor of the stripped-down version, with only the ??, ??=, and ?. operators, and without magic for missing attributes/keys – just return None if the value is None rather than attempting a further getattr operation on None.

It would have simplified code I’ve written over the past year where there are quite a few classes (mine as well as 3rd party) with attributes whose type is Something | None. And ??= would have helped with arguments whose default is None.

32 Likes

If we strip this pep down to only instance attributes (?.), I feel we might miss a big opportunity for improvement: handling deeply nested data structures (like JSON responses).

I would like to see the scope include None-aware sub-scripting, allowing us to express this perhaps:

value = dct1?["y"]?[2]?["a"]?[0]

Instead of this typical helper-function overhead:

value = deep_get(dct1, ["y", 2, "a", 0])

Excluding ?[ ] leaves a significant gap in usability for data-heavy apps imho.

I would also be even happier if the pep went even further and supported None-aware assignment (deep set) functionality, i.e.

dct1?["y"]?[2]?["a"]?[0] = 0

But if this is is too complex for now, I’d definitely urge the introduction of None-aware sub-scripting to modernize Python’s data manipulation capabilities.

6 Likes

This could be expressed using only ?. although admittedly a bit more verbose.

value = dct1?.get("y")?.__getitem__(2)?.get("a")?.__getitem__(0)

Adding list/tuple.get(index, default=None) like suggested above might help here.


AFAIR the readability concerns especially with the none-aware subscript operator were one of the main reasons PEP 505 stalled in the first place. So I’d recommend against including it in the first step. [1]


  1. It can always be proposed and evaluated separately at a later point once the community has some more experience with none-aware operators. ↩︎

Let’s not go there. This was a very contentious issue in earlier discussions and caused the PEP to fail. Please don’t repeat that dead end in the discussion.

(The technical reason is that in order to implement it you’d have to catch exceptions, and there were wide-ranging concerns about that.)

Again, please withdraw the suggestion (or propose it in a follow-up PEP).

1 Like

I understand. Yes I withdraw the suggestion.

(Perhaps in a future follow-up pep)

3 Likes

I believe that the ?. operator together with list.get()/tuple.get()(and, obviously, the existing dict.get()) would, to much extent, alleviate the pain of the lack of []?.

But who is going to index a list or tuple with an index they’re not sure of? I don’t think that’s nearly as common as accessing an attribute that might be None. And for JSON the most likely case is a dict with an optional key.

Let’s move list/tuple .get() to a follow-up PEP. I think it’s key here not to over-ask.

13 Likes

The Sequence.get(idx, default)/getitem(key, default) idea was born from one of the exception catching tangents (since it assumes a non-None container base) so it would definitely be out of scope of any narrowed proposal that focused on simplified handling of attributes that are set to None.